feat(widget): per-residence widget configuration (Android, gitea#6)
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:
Trey T
2026-05-11 13:31:46 -05:00
parent 498e6b8064
commit 9c9e6009c7
13 changed files with 669 additions and 6 deletions
@@ -0,0 +1,270 @@
package com.tt.honeyDue.widget
import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.updateAll
import com.tt.honeyDue.ui.theme.AppSpacing
import com.tt.honeyDue.ui.theme.HoneyDueTheme
import com.tt.honeyDue.ui.theme.ThemeManager
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
/**
* Per-widget residence selector. Launched by the system when the user
* pins a new honeyDue widget (because each widget provider XML now
* declares `android:configure`) and again when they hit "Edit Widget".
*
* Saves the chosen residence id under
* `widget_residence_id_<appWidgetId>` in [WidgetDataStore] so each
* widget instance can independently scope its task list (gitea#6).
* Selecting "All residences" clears the key and the widget reverts to
* the legacy unscoped behaviour.
*/
class WidgetConfigActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// The system passes the just-created widget id in the extras.
// Without it we don't know which widget to configure — bail
// with CANCELED so the system removes the placeholder tile.
val appWidgetId = intent?.extras?.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
setResult(Activity.RESULT_CANCELED)
finish()
return
}
// Default a CANCEL result so the widget is removed if the user
// dismisses without saving (matches the Android convention).
setResult(
Activity.RESULT_CANCELED,
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
)
setContent {
val theme = ThemeManager.currentTheme
HoneyDueTheme(themeColors = theme) {
WidgetConfigScreen(
appWidgetId = appWidgetId,
onCommit = { residenceId ->
lifecycleScope.launch {
val repo = WidgetDataRepository.get(this@WidgetConfigActivity)
repo.saveResidenceIdFor(appWidgetId, residenceId)
// Repaint every widget tile so this one
// picks up the new scope on the next frame
// (Glance handles which `appWidgetId` we
// belong to via the per-instance state).
HoneyDueSmallWidget().updateAll(this@WidgetConfigActivity)
HoneyDueMediumWidget().updateAll(this@WidgetConfigActivity)
HoneyDueLargeWidget().updateAll(this@WidgetConfigActivity)
setResult(
Activity.RESULT_OK,
Intent().putExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
appWidgetId
)
)
finish()
}
}
)
}
}
}
}
/**
* The actual picker UI. Loads residences from [WidgetDataRepository]
* and offers an "All residences" option above them. Empty state shows
* a helper message instead of an empty list (user hasn't created any
* residences yet, or the main app hasn't synced).
*/
@Composable
private fun WidgetConfigScreen(
appWidgetId: Int,
onCommit: (Long?) -> Unit
) {
var residences by remember { mutableStateOf<List<WidgetResidenceDto>?>(null) }
var selectedId by remember { mutableStateOf<Long?>(null) }
val context = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(appWidgetId) {
val repo = WidgetDataRepository.get(context)
// Pre-select whatever the user picked last time they configured
// this same widget; falls back to "All residences" on first run.
selectedId = repo.loadResidenceIdFor(appWidgetId)
residences = repo.loadResidences()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(AppSpacing.lg)
) {
Text(
text = "Choose a residence",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(AppSpacing.sm))
Text(
text = "This widget will only show tasks for the residence you pick. " +
"Choose \"All residences\" to keep showing every home.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(AppSpacing.lg))
val items = residences
if (items == null) {
// Loading state — DataStore reads off the IO dispatcher.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Column
}
LazyColumn(
modifier = Modifier.weight(1f, fill = true),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
// "All residences" — selecting clears the per-widget key.
item {
ResidenceRow(
title = "All residences",
isSelected = selectedId == null,
onClick = { selectedId = null }
)
}
if (items.isEmpty()) {
item {
EmptyResidencesNote()
}
} else {
items(items, key = { it.id }) { residence ->
ResidenceRow(
title = residence.name,
isSelected = selectedId == residence.id,
onClick = { selectedId = residence.id }
)
}
}
}
Spacer(Modifier.height(AppSpacing.lg))
Button(
onClick = { onCommit(selectedId) },
modifier = Modifier.fillMaxWidth()
) {
Text("Save")
}
}
}
@Composable
private fun ResidenceRow(
title: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(AppSpacing.md))
.background(
if (isSelected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.clickable(onClick = onClick)
.padding(AppSpacing.lg)
) {
androidx.compose.foundation.layout.Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null,
tint = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.fillMaxWidth(0.04f))
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f)
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
@Composable
private fun EmptyResidencesNote() {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(AppSpacing.md))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(AppSpacing.lg)
) {
Text(
text = "No residences yet — open honeyDue and add a property first, " +
"then come back to configure this widget.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}