Files
honeyDueKMP/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueSmallWidget.kt
T
Trey T 9c9e6009c7
Android UI Tests / ui-tests (pull_request) Has been cancelled
feat(widget): per-residence widget configuration (Android, gitea#6)
Mirrors the iOS implementation. Adds a Glance configuration activity
that launches when the user pins a new honeyDue widget tile and again
on "Edit Widget", lets them pick one of their residences (or "All
residences"), and persists the choice per-`appWidgetId`. Each tile's
`provideGlance` resolves its own scope and filters tasks (and stats,
on the large widget) accordingly.

Pieces:

- `WidgetConfigActivity` — Compose `ComponentActivity` hosting the
  residence-picker UI; reads the persisted residences sidecar, reads
  any prior scope for the current `appWidgetId`, writes the new
  selection on Save, and re-renders every widget tile.
- `WidgetDataStore` — new `widget_residences_json` key + a per-instance
  `widget_residence_id_<appWidgetId>` key. `clearAll()` sweeps the
  per-instance keys by prefix so logout doesn't leave dangling state.
- `WidgetDataRepository`:
  * `saveResidences(_)` / `loadResidences()` for the picker.
  * `saveResidenceIdFor(appWidgetId, residenceId)` /
    `loadResidenceIdFor(appWidgetId)` /
    `clearResidenceIdFor(appWidgetId)` for per-tile scope.
  * `loadTasksForResidence(residenceId)` and the
    `appWidgetId`-driven `loadTasksForWidget(appWidgetId)`.
  * `computeStatsFromTasks(tasks)` so the large widget's tiles
    reflect only the scoped task list (instead of the whole cache).
  * Pure `Filter.filterTasksForResidence(_, _)` on the companion
    object — easy to exercise from unit tests.
- `WidgetTaskDto` already carries `residenceId`. New `WidgetResidenceDto`
  added (id + name) — JSON-persisted via the sidecar.
- `WidgetRefreshWorker` / `DefaultWidgetRefreshDataSource` — pull
  `myResidences` alongside tasks/tier on each refresh and write the
  sidecar (best-effort; non-fatal if the call fails).
- `HoneyDue{Small,Medium,Large}Widget.provideGlance` — resolve
  `appWidgetId` via `GlanceAppWidgetManager(context).getAppWidgetId(id)`
  and call `loadTasksForWidget(appWidgetId)`.
- `HoneyDue{Small,Medium,Large}WidgetReceiver.onDeleted` — purge the
  per-instance residence scope key when the tile is removed.
- Manifest: register the configure activity with the
  `APPWIDGET_CONFIGURE` action.
- `honeydue_{small,medium,large}_widget_info.xml` — declare
  `android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"`.

Migration / safety:
- A tile that's never been through the picker has no residence id
  saved → `loadTasksForWidget` returns every task (legacy "All
  residences" behaviour). Existing tiles keep working without the
  user touching anything.
- The picker handles an empty residences list (signed-out / first
  install before background refresh) with an explicit helper message
  pointing at the main app.

Tests: new `WidgetResidenceFilterTest` (commonTest-style under
`androidUnitTest`, 9 cases). All green.

  $ ./gradlew :composeApp:testDebugUnitTest \\
      --tests "com.tt.honeyDue.widget.WidgetResidenceFilterTest"
  BUILD SUCCESSFUL

  $ ./gradlew :composeApp:assembleDebug
  BUILD SUCCESSFUL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:31:46 -05:00

167 lines
6.1 KiB
Kotlin

package com.tt.honeyDue.widget
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.launch
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.height
import androidx.glance.layout.padding
/**
* Small (2x2) widget.
*
* Mirrors iOS `SmallWidgetView` / `FreeWidgetView` in
* `iosApp/HoneyDue/HoneyDue.swift`:
* - Free tier → big count + "tasks waiting" label.
* - Premium → task count header + single next-task row with
* an inline complete button wired to [CompleteTaskAction].
*
* Glance restriction: no radial gradients or custom shapes, so the
* "organic" glow behind the number is dropped. Cream background and
* primary/accent colors match iOS.
*/
class HoneyDueSmallWidget : GlanceAppWidget() {
override val sizeMode: SizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
val repo = WidgetDataRepository.get(context)
// Resolve which residence this widget instance is scoped to
// (gitea#6). `loadTasksForWidget` falls back to "All residences"
// when no scope is saved, matching pre-#6 behaviour for tiles
// that haven't been configured yet.
val appWidgetId =
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
val tasks = repo.loadTasksForWidget(appWidgetId)
val tier = repo.loadTierState()
val isPremium = tier.equals("premium", ignoreCase = true)
provideContent {
SmallWidgetContent(tasks, isPremium)
}
}
@Composable
private fun SmallWidgetContent(
tasks: List<WidgetTaskDto>,
isPremium: Boolean
) {
val openApp = actionRunCallback<OpenAppAction>()
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(WidgetColors.BACKGROUND_PRIMARY)
.padding(12.dp)
.clickable(openApp),
contentAlignment = Alignment.Center
) {
if (!isPremium) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
modifier = GlanceModifier.fillMaxSize()
) {
TaskCountBlock(count = tasks.size, long = true)
}
} else {
Column(modifier = GlanceModifier.fillMaxSize()) {
TaskCountBlock(count = tasks.size, long = false)
Spacer(modifier = GlanceModifier.height(8.dp))
val nextTask = tasks.firstOrNull()
if (nextTask != null) {
TaskRow(
task = nextTask,
compact = true,
showResidence = false,
onTaskClick = openApp,
trailing = {
CompleteButton(taskId = nextTask.id)
}
)
} else {
EmptyState(compact = true, onTap = openApp)
}
}
}
}
}
}
/**
* Launch the main activity when the widget is tapped.
*
* Shared across all three widget sizes. Task-completion actions live
* in Stream M's [CompleteTaskAction]; this receiver handles plain
* "open app" taps.
*/
class OpenAppAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
intent?.let {
it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
context.startActivity(it)
}
}
}
/** AppWidget receiver for the small widget. */
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
// Clean per-instance residence scope when the user removes a tile
// so dangling `widget_residence_id_<n>` keys don't accumulate in
// the DataStore (gitea#6).
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
}
}
/**
* Shared helpers for honeyDue Glance widget receivers. Kept in a
* top-level utility so every receiver size (Small / Medium / Large)
* uses identical cleanup logic.
*/
internal object WidgetReceiverHelpers {
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
fun purgeResidenceScopes(context: Context, appWidgetIds: IntArray) {
if (appWidgetIds.isEmpty()) return
val repo = WidgetDataRepository.get(context)
// Fire-and-forget on a background dispatcher — `onDeleted` runs
// on the broadcast thread which doesn't permit suspend calls.
// GlobalScope is correct here: the IO is short-lived (one
// DataStore edit per removed appWidgetId) and there's no
// coroutine-scope tied to a long-lived receiver to attach to.
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) {
for (id in appWidgetIds) {
repo.clearResidenceIdFor(id)
}
}
}
}