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