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