P3 Stream M: CompleteTaskAction (widget interactive completion)

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) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:52:01 -05:00
parent 6b3e64661f
commit 58b9371d0d
5 changed files with 538 additions and 1 deletions

View File

@@ -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<CompleteTaskAction>`
* 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<Long> = ActionParameters.Key("task_id")
}
}

View File

@@ -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"
}

View File

@@ -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()

View File

@@ -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<Invocation>()
@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)
}
}

View File

@@ -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<TaskCompletionCreateRequest> {
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
)
}