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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user