Files
honeyDueKMP/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/HoneyDueLargeWidget.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

150 lines
5.8 KiB
Kotlin

package com.tt.honeyDue.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
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.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.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
/**
* Large (4x4) widget.
*
* Mirrors iOS `LargeWidgetView`:
* - When there are tasks: list of up to 5 tasks with residence/due
* labels, optional "+N more" text, and a 3-pill stats row at the
* bottom (Overdue / 7 Days / 30 Days).
* - When empty: centered "All caught up!" state above the stats.
* - Free tier collapses to the count-only layout.
*
* Glance restriction: no LazyColumn here because the list is bounded
* (max 5), so a plain Column is fine and lets us compose the stats row
* at the bottom without nesting a second scroll container.
*/
class HoneyDueLargeWidget : GlanceAppWidget() {
override val sizeMode: SizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
val repo = WidgetDataRepository.get(context)
// Per-instance residence scoping (gitea#6). Stats are computed
// off the same filtered list so the bottom-tile counters
// ("Overdue / 7 days / 30 days") match the visible tasks
// instead of aggregating across every residence.
val appWidgetId =
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
val tasks = repo.loadTasksForWidget(appWidgetId)
val stats = repo.computeStatsFromTasks(tasks)
val tier = repo.loadTierState()
val isPremium = tier.equals("premium", ignoreCase = true)
provideContent {
LargeWidgetContent(tasks, stats, isPremium)
}
}
@Composable
private fun LargeWidgetContent(
tasks: List<WidgetTaskDto>,
stats: WidgetStats,
isPremium: Boolean
) {
val openApp = actionRunCallback<OpenAppAction>()
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(WidgetColors.BACKGROUND_PRIMARY)
.padding(14.dp)
.clickable(openApp)
) {
if (!isPremium) {
Column(
modifier = GlanceModifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically
) {
TaskCountBlock(count = tasks.size, long = true)
}
} else {
Column(modifier = GlanceModifier.fillMaxSize()) {
WidgetHeader(taskCount = tasks.size, onTap = openApp)
Spacer(modifier = GlanceModifier.height(10.dp))
if (tasks.isEmpty()) {
Box(
modifier = GlanceModifier.defaultWeight().fillMaxWidth(),
contentAlignment = Alignment.Center
) {
EmptyState(compact = false, onTap = openApp)
}
} else {
val shown = tasks.take(MAX_TASKS)
shown.forEachIndexed { index, task ->
TaskRow(
task = task,
compact = false,
showResidence = true,
onTaskClick = openApp,
trailing = { CompleteButton(taskId = task.id) }
)
if (index < shown.lastIndex) {
Spacer(modifier = GlanceModifier.height(4.dp))
}
}
if (tasks.size > MAX_TASKS) {
Spacer(modifier = GlanceModifier.height(4.dp))
Text(
text = "+ ${tasks.size - MAX_TASKS} more",
style = TextStyle(
color = WidgetColors.textSecondary,
fontSize = 10.sp,
fontWeight = FontWeight.Medium
),
modifier = GlanceModifier.fillMaxWidth()
)
}
Spacer(modifier = GlanceModifier.defaultWeight())
}
Spacer(modifier = GlanceModifier.height(10.dp))
StatsRow(stats = stats)
}
}
}
}
companion object {
private const val MAX_TASKS = 5
}
}
/** AppWidget receiver for the large widget. */
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
}
}