feat(widget): per-residence widget configuration (Android, gitea#6)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Android UI Tests / ui-tests (pull_request) Has been cancelled
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>
This commit is contained in:
@@ -2,6 +2,7 @@ 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
|
||||
@@ -43,7 +44,13 @@ class HoneyDueSmallWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val repo = WidgetDataRepository.get(context)
|
||||
val tasks = repo.loadTasks()
|
||||
// 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)
|
||||
|
||||
@@ -125,4 +132,35 @@ class OpenAppAction : ActionCallback {
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user