9c9e6009c7
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>
271 lines
10 KiB
Kotlin
271 lines
10 KiB
Kotlin
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
|
|
)
|
|
}
|
|
}
|