From 58b9371d0dfec0dd158374557a5dd971fdf25e57 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 12:52:01 -0500 Subject: [PATCH] P3 Stream M: CompleteTaskAction (widget interactive completion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glance ActionCallback wires to WidgetActionProcessor: premium marks pending + calls API + refreshes; free tier opens paywall deep link instead. Idempotent, rollback-safe on API failure. Also fixes a one-line compile error in WidgetTaskActionReceiver.kt where updateAllWidgets() had been removed by Stream L — swapped for forceRefresh() so the build stays green. The legacy receiver is now redundant (replaced by CompleteTaskAction) but deletion is deferred to a Stream K follow-up so the AndroidManifest entry can be removed in the same commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tt/honeyDue/widget/CompleteTaskAction.kt | 39 +++ .../honeyDue/widget/WidgetActionProcessor.kt | 157 +++++++++++ .../widget/WidgetTaskActionReceiver.kt | 2 +- .../honeyDue/widget/CompleteTaskActionTest.kt | 79 ++++++ .../widget/WidgetActionProcessorTest.kt | 262 ++++++++++++++++++ 5 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/CompleteTaskAction.kt create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetActionProcessor.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/CompleteTaskActionTest.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetActionProcessorTest.kt diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/CompleteTaskAction.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/CompleteTaskAction.kt new file mode 100644 index 0000000..ef9d54a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/CompleteTaskAction.kt @@ -0,0 +1,39 @@ +package com.tt.honeyDue.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback + +/** + * Glance [ActionCallback] wired to the "complete task" button on + * [HoneyDueLargeWidget] (and wherever else Stream K surfaces the control). + * + * The callback itself is deliberately thin — all policy lives in + * [WidgetActionProcessor]. This keeps the Glance action registration simple + * (required for Glance's reflective `actionRunCallback` + * pattern) and lets the processor be unit-tested without needing a Glance + * runtime. + * + * iOS parity: mirrors `iosApp/HoneyDue/AppIntent.swift` `CompleteTaskIntent`. + */ +class CompleteTaskAction : ActionCallback { + + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val taskId = parameters[taskIdKey] ?: return + WidgetActionProcessor.processComplete(context, taskId) + } + + companion object { + /** + * Parameter key used by widget task rows to pass the clicked task's + * id. Parameter name (`task_id`) matches the iOS `AppIntent` + * parameter for discoverability. + */ + val taskIdKey: ActionParameters.Key = ActionParameters.Key("task_id") + } +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetActionProcessor.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetActionProcessor.kt new file mode 100644 index 0000000..52a0ff8 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetActionProcessor.kt @@ -0,0 +1,157 @@ +package com.tt.honeyDue.widget + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.tt.honeyDue.models.TaskCompletionCreateRequest +import com.tt.honeyDue.network.APILayer +import com.tt.honeyDue.network.ApiResult + +/** + * Coordinates the server-side effect of a "complete task" tap on a widget. + * + * Mirrors `iosApp/iosApp/Helpers/WidgetActionProcessor.swift` semantics: + * + * - **Free tier:** do not hit the API. Fire an `ACTION_VIEW` intent against + * the `honeydue://paywall?from=widget` deep link so the hosting app can + * land on the upgrade flow. Return [Result.FreeTier]. + * - **Premium:** record optimistic pending-completion state in + * [WidgetDataRepository] (which hides the task from subsequent renders), + * call [APILayer.createTaskCompletion], then either + * (a) **success** — clear pending and ask [WidgetUpdateManager] to + * refresh so the now-completed task is confirmed gone, or + * (b) **failure** — roll back pending so the task reappears in the + * widget and the user can retry. Return [Result.Failed] carrying + * the error so the caller can surface a Toast / log / retry. + * - **Idempotent:** if the task is already in the pending set, return + * [Result.AlreadyPending] without hitting the API. A double-tap while + * the first completion is in flight must not double-complete. + * + * Test hooks ([refreshTrigger], [processOverrideForTest]) are intentionally + * public-internal so Robolectric unit tests can observe side effects and + * swap the entry point without reflection. Call [resetTestHooks] in test + * teardown. + */ +object WidgetActionProcessor { + + sealed class Result { + /** API completion succeeded. Task is gone from the widget's view. */ + object Success : Result() + + /** User is on the free tier. Paywall deep-link was fired instead. */ + object FreeTier : Result() + + /** Task is already in the pending set — duplicate tap, no-op. */ + object AlreadyPending : Result() + + /** API call failed. Pending state has been rolled back. */ + data class Failed(val error: Throwable) : Result() + } + + /** + * Entry point. Usually invoked from [CompleteTaskAction.onAction], which + * runs on Glance's callback dispatcher. Safe to invoke off the main + * thread. + */ + suspend fun processComplete(context: Context, taskId: Long): Result { + processOverrideForTest?.let { return it(context, taskId) } + + val repo = WidgetDataRepository.get(context) + val store = WidgetDataStore(context) + + // Idempotent short-circuit: another tap is already in flight. + if (store.readPendingCompletionIds().contains(taskId)) { + return Result.AlreadyPending + } + + // Tier gate — free users get the paywall, not the API. + val tier = repo.loadTierState() + if (tier != TIER_PREMIUM) { + launchPaywall(context) + return Result.FreeTier + } + + // Optimistic UI: hide the task from the widget before hitting the API. + repo.markPendingCompletion(taskId) + + val request = TaskCompletionCreateRequest( + taskId = taskId.toInt(), + notes = WIDGET_COMPLETION_NOTE + ) + + val apiResult: ApiResult<*> = try { + APILayer.createTaskCompletion(request) + } catch (t: Throwable) { + repo.clearPendingCompletion(taskId) + return Result.Failed(t) + } + + return when (apiResult) { + is ApiResult.Success<*> -> { + // Completion synced. Clear the pending marker and force a + // refresh so the worker re-fetches the (now shorter) task + // list from the API. + repo.clearPendingCompletion(taskId) + refreshTrigger(context) + Result.Success + } + is ApiResult.Error -> { + // Server rejected the completion. Roll back the optimistic + // state so the task re-appears in the widget. + repo.clearPendingCompletion(taskId) + val message = apiResult.message.ifBlank { "widget-complete failed" } + Result.Failed(RuntimeException(message)) + } + else -> { + // ApiResult.Idle / ApiResult.Loading are not valid terminal + // states here — treat as failure, roll back. + repo.clearPendingCompletion(taskId) + Result.Failed( + IllegalStateException("Unexpected terminal ApiResult: $apiResult") + ) + } + } + } + + /** Fire the `honeydue://paywall?from=widget` deep link. */ + private fun launchPaywall(context: Context) { + val uri = Uri.parse(PAYWALL_URI) + val intent = Intent(Intent.ACTION_VIEW, uri).apply { + // Widget callbacks don't run inside an Activity — must ask the + // OS for a fresh task. + flags = Intent.FLAG_ACTIVITY_NEW_TASK + setPackage(context.packageName) + } + context.startActivity(intent) + } + + // ==================================================================== + // Test hooks — see class kdoc. Do NOT reference from production code. + // ==================================================================== + + /** + * Function called after a successful API completion to nudge the widget + * host into re-rendering. Defaults to [WidgetUpdateManager.forceRefresh]; + * tests swap this to observe the call without mocking WorkManager. + */ + @JvmField + internal var refreshTrigger: (Context) -> Unit = { WidgetUpdateManager.forceRefresh(it) } + + /** + * If set, [processComplete] short-circuits to this lambda instead of + * running the real pipeline. Used by [CompleteTaskActionTest] to isolate + * parameter-parsing behaviour from the processor's side effects. + */ + @JvmField + internal var processOverrideForTest: (suspend (Context, Long) -> Result)? = null + + /** Restore test hooks to production defaults. Call from `@After`. */ + internal fun resetTestHooks() { + refreshTrigger = { WidgetUpdateManager.forceRefresh(it) } + processOverrideForTest = null + } + + private const val TIER_PREMIUM = "premium" + private const val PAYWALL_URI = "honeydue://paywall?from=widget" + private const val WIDGET_COMPLETION_NOTE = "Completed from widget" +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskActionReceiver.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskActionReceiver.kt index 6207fb6..98f9829 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskActionReceiver.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskActionReceiver.kt @@ -46,7 +46,7 @@ class WidgetTaskActionReceiver : BroadcastReceiver() { // Update widgets after completion if (result is com.tt.honeyDue.network.ApiResult.Success) { - WidgetUpdateManager.updateAllWidgets(context) + WidgetUpdateManager.forceRefresh(context) } } catch (e: Exception) { e.printStackTrace() diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/CompleteTaskActionTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/CompleteTaskActionTest.kt new file mode 100644 index 0000000..6e50e1b --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/CompleteTaskActionTest.kt @@ -0,0 +1,79 @@ +package com.tt.honeyDue.widget + +import android.content.Context +import android.os.Build +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Verifies the Glance [CompleteTaskAction] correctly pulls the task id from + * [ActionParameters] and forwards to [WidgetActionProcessor.processComplete]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class CompleteTaskActionTest { + + private lateinit var context: Context + + private data class Invocation(val context: Context, val taskId: Long) + private val invocations = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + invocations.clear() + // Swap the processor's entry point for a capturing spy. + WidgetActionProcessor.processOverrideForTest = { ctx, id -> + invocations += Invocation(ctx, id) + WidgetActionProcessor.Result.Success + } + } + + @After + fun tearDown() { + WidgetActionProcessor.resetTestHooks() + } + + private val dummyGlanceId: GlanceId = object : GlanceId {} + + @Test + fun completeTaskAction_reads_taskId_from_parameters() = runTest { + val action = CompleteTaskAction() + val params = actionParametersOf(CompleteTaskAction.taskIdKey to 123L) + + action.onAction(context, dummyGlanceId, params) + + assertEquals(1, invocations.size) + assertEquals(123L, invocations.single().taskId) + } + + @Test + fun completeTaskAction_missing_taskId_noOp() = runTest { + val action = CompleteTaskAction() + // No task_id parameter provided. + val params: ActionParameters = actionParametersOf() + + action.onAction(context, dummyGlanceId, params) + + assertEquals( + "processComplete must not be invoked when task_id is absent", + 0, + invocations.size + ) + } + + @Test + fun completeTaskAction_taskIdKey_nameMatchesIos() { + assertEquals("task_id", CompleteTaskAction.taskIdKey.name) + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetActionProcessorTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetActionProcessorTest.kt new file mode 100644 index 0000000..85d616d --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetActionProcessorTest.kt @@ -0,0 +1,262 @@ +package com.tt.honeyDue.widget + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import com.tt.honeyDue.models.TaskCompletionCreateRequest +import com.tt.honeyDue.models.TaskCompletionResponse +import com.tt.honeyDue.network.APILayer +import com.tt.honeyDue.network.ApiResult +import io.mockk.Ordering +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Tests for [WidgetActionProcessor]. + * + * Mirrors iOS WidgetActionProcessor.swift semantics: + * - Free tier taps open paywall deep link instead of completing. + * - Premium taps perform optimistic mark-pending, API call, refresh-or-rollback. + * - Double-taps while a completion is pending are a no-op. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class WidgetActionProcessorTest { + + private lateinit var context: Context + private lateinit var repo: WidgetDataRepository + private var refreshCalls: Int = 0 + private var lastRefreshContext: Context? = null + + @Before + fun setUp() = runTest { + context = ApplicationProvider.getApplicationContext() + repo = WidgetDataRepository.get(context) + repo.clearAll() + + refreshCalls = 0 + lastRefreshContext = null + WidgetActionProcessor.refreshTrigger = { ctx -> + refreshCalls += 1 + lastRefreshContext = ctx + } + + mockkObject(APILayer) + } + + @After + fun tearDown() = runTest { + unmockkAll() + WidgetActionProcessor.resetTestHooks() + repo.clearAll() + } + + private fun successResponse(taskId: Int): TaskCompletionResponse = + TaskCompletionResponse( + id = 1, + taskId = taskId, + completedBy = null, + completedAt = "2026-01-01T00:00:00Z", + notes = "Completed from widget", + actualCost = null, + rating = null, + images = emptyList(), + createdAt = "2026-01-01T00:00:00Z", + updatedTask = null + ) + + // ---------- 1. Free tier: paywall only, no API call ---------- + + @Test + fun processComplete_freeTier_opensPaywall_doesNotCallApi() = runTest { + repo.saveTierState("free") + + val result = WidgetActionProcessor.processComplete(context, taskId = 42L) + + assertEquals(WidgetActionProcessor.Result.FreeTier, result) + coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) } + assertEquals("Widget refresh should not fire on free tier", 0, refreshCalls) + + // ACTION_VIEW intent with honeydue://paywall?from=widget was fired. + val shadowApp = shadowOf(context.applicationContext as android.app.Application) + val next = shadowApp.nextStartedActivity + assertNotNull("Expected paywall intent to be started", next) + assertEquals(Intent.ACTION_VIEW, next.action) + assertNotNull(next.data) + assertEquals("honeydue", next.data!!.scheme) + assertEquals("paywall", next.data!!.host) + assertEquals("widget", next.data!!.getQueryParameter("from")) + } + + // ---------- 2. Premium success: mark pending → API → clear pending ---------- + + @Test + fun processComplete_premium_marksPendingThenCompletes() = runTest { + repo.saveTierState("premium") + repo.saveTasks(listOf(fakeTask(id = 7L))) + + coEvery { APILayer.createTaskCompletion(any()) } coAnswers { + // At the instant the API is hit, the task MUST be in the pending set. + assertTrue( + "Task should be marked pending before API call", + repo.isPendingCompletion(7L) + ) + ApiResult.Success(successResponse(7)) + } + + val result = WidgetActionProcessor.processComplete(context, taskId = 7L) + + assertEquals(WidgetActionProcessor.Result.Success, result) + coVerify(exactly = 1) { + APILayer.createTaskCompletion(match { + it.taskId == 7 && it.notes == "Completed from widget" + }) + } + assertFalse( + "Pending should be cleared after successful API call", + repo.isPendingCompletion(7L) + ) + } + + // ---------- 3. Premium API failure: rollback pending ---------- + + @Test + fun processComplete_premium_apiFailure_clearsPending() = runTest { + repo.saveTierState("premium") + repo.saveTasks(listOf(fakeTask(id = 11L))) + coEvery { APILayer.createTaskCompletion(any()) } returns + ApiResult.Error("Server exploded", 500) + + val result = WidgetActionProcessor.processComplete(context, taskId = 11L) + + assertTrue( + "Expected Failed result but got $result", + result is WidgetActionProcessor.Result.Failed + ) + assertFalse( + "Pending must be cleared on failure so the task reappears in widget", + repo.isPendingCompletion(11L) + ) + assertEquals("No widget refresh on failure", 0, refreshCalls) + } + + // ---------- 4. Idempotent: duplicate taps are no-ops ---------- + + @Test + fun processComplete_idempotent() = runTest { + repo.saveTierState("premium") + repo.saveTasks(listOf(fakeTask(id = 99L))) + // Seed the pending set — simulates a tap still in flight. + repo.markPendingCompletion(99L) + + val result = WidgetActionProcessor.processComplete(context, taskId = 99L) + + assertEquals(WidgetActionProcessor.Result.AlreadyPending, result) + coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) } + assertEquals(0, refreshCalls) + } + + // ---------- 5. Premium success triggers widget refresh ---------- + + @Test + fun processComplete_premium_success_triggersWidgetRefresh() = runTest { + repo.saveTierState("premium") + repo.saveTasks(listOf(fakeTask(id = 5L))) + coEvery { APILayer.createTaskCompletion(any()) } returns + ApiResult.Success(successResponse(5)) + + val result = WidgetActionProcessor.processComplete(context, taskId = 5L) + + assertEquals(WidgetActionProcessor.Result.Success, result) + assertEquals("forceRefresh should fire exactly once on success", 1, refreshCalls) + assertNotNull(lastRefreshContext) + } + + // ---------- 6. Order of operations: API before refresh ---------- + + @Test + fun processComplete_premium_ordersOperations_apiBeforeRefresh() = runTest { + repo.saveTierState("premium") + repo.saveTasks(listOf(fakeTask(id = 3L))) + var apiCalledAt: Int = -1 + var refreshCalledAt: Int = -1 + var tick = 0 + coEvery { APILayer.createTaskCompletion(any()) } coAnswers { + apiCalledAt = tick++ + ApiResult.Success(successResponse(3)) + } + WidgetActionProcessor.refreshTrigger = { + refreshCalledAt = tick++ + } + + WidgetActionProcessor.processComplete(context, taskId = 3L) + + assertTrue("API must fire before refresh", apiCalledAt >= 0) + assertTrue("refresh must fire before or after API but both must run", refreshCalledAt >= 0) + assertTrue( + "API should be ordered before refresh ($apiCalledAt < $refreshCalledAt)", + apiCalledAt < refreshCalledAt + ) + } + + // ---------- 7. Missing tier defaults to free ---------- + + @Test + fun processComplete_missingTier_treatedAsFree() = runTest { + // No saveTierState call — repo defaults to "free". + + val result = WidgetActionProcessor.processComplete(context, taskId = 1L) + + assertEquals(WidgetActionProcessor.Result.FreeTier, result) + coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) } + } + + // ---------- 8. Paywall intent carries NEW_TASK flag so it can start from app context ---------- + + @Test + fun processComplete_freeTier_paywallIntentIsStartable() = runTest { + repo.saveTierState("free") + + WidgetActionProcessor.processComplete(context, taskId = 77L) + + val shadowApp = shadowOf(context.applicationContext as android.app.Application) + val next = shadowApp.nextStartedActivity + assertNotNull(next) + // Must have NEW_TASK so it can be launched outside an Activity context + // (the callback fires from a broadcast-adjacent context). + assertTrue( + "Paywall intent should include FLAG_ACTIVITY_NEW_TASK", + (next.flags and Intent.FLAG_ACTIVITY_NEW_TASK) != 0 + ) + } + + // ---------- Helpers ---------- + + private fun fakeTask(id: Long): WidgetTaskDto = WidgetTaskDto( + id = id, + title = "Task $id", + priority = 2L, + dueDate = null, + isOverdue = false, + daysUntilDue = 1, + residenceId = 1L, + residenceName = "Home", + categoryIcon = "house.fill", + completed = false + ) +}