From 9c9e6009c727742092f7e567ff1c4388fef49269 Mon Sep 17 00:00:00 2001 From: Trey T Date: Mon, 11 May 2026 13:31:46 -0500 Subject: [PATCH] feat(widget): per-residence widget configuration (Android, gitea#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_` 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) --- .../src/androidMain/AndroidManifest.xml | 13 + .../tt/honeyDue/widget/HoneyDueLargeWidget.kt | 15 +- .../honeyDue/widget/HoneyDueMediumWidget.kt | 10 +- .../tt/honeyDue/widget/HoneyDueSmallWidget.kt | 40 ++- .../honeyDue/widget/WidgetConfigActivity.kt | 270 ++++++++++++++++++ .../honeyDue/widget/WidgetDataRepository.kt | 84 +++++- .../com/tt/honeyDue/widget/WidgetDataStore.kt | 59 ++++ .../tt/honeyDue/widget/WidgetRefreshWorker.kt | 27 ++ .../com/tt/honeyDue/widget/WidgetTaskDto.kt | 14 + .../res/xml/honeydue_large_widget_info.xml | 1 + .../res/xml/honeydue_medium_widget_info.xml | 1 + .../res/xml/honeydue_small_widget_info.xml | 1 + .../widget/WidgetResidenceFilterTest.kt | 140 +++++++++ 13 files changed, 669 insertions(+), 6 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetConfigActivity.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetResidenceFilterTest.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 8d67f72..f3312ae 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -121,6 +121,19 @@ + + + + + + + ` 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) + } + } + } } diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetConfigActivity.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetConfigActivity.kt new file mode 100644 index 0000000..712f50b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetConfigActivity.kt @@ -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_` 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?>(null) } + var selectedId by remember { mutableStateOf(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 + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataRepository.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataRepository.kt index 133fe57..ace0b40 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataRepository.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataRepository.kt @@ -115,6 +115,66 @@ class WidgetDataRepository internal constructor(private val context: Context) { return all.filterNot { it.id in pending } } + /** + * Load the cached task list filtered by [residenceId]. Pass `null` to + * return every residence's tasks (the "All residences" widget option, + * matching pre-gitea#6 behaviour). + * + * Pending completions are excluded — same contract as [loadTasks]. + */ + suspend fun loadTasksForResidence(residenceId: Long?): List { + val all = loadTasks() + return filterTasksForResidence(all, residenceId) + } + + /** + * Resolve the residence scope for [appWidgetId] and return only its + * tasks. The widget [GlanceAppWidget.provideGlance] looks up its + * `appWidgetId` and calls this; configuration changes take effect on + * the next `updateAll` invocation. + */ + suspend fun loadTasksForWidget(appWidgetId: Int): List { + val residenceId = store.readResidenceIdFor(appWidgetId) + return loadTasksForResidence(residenceId) + } + + // ========================================================================= + // Residence sidecar (gitea#6) + // ========================================================================= + + /** + * Persist the user's residences so [WidgetConfigActivity] can offer + * them in its picker. Called from the main app whenever + * `DataManager.myResidences` updates. + */ + suspend fun saveResidences(residences: List) { + store.writeResidencesJson(json.encodeToString(residences)) + } + + /** Read the persisted residence list (empty when never written or after logout). */ + suspend fun loadResidences(): List { + val raw = store.readResidencesJson() + return try { + json.decodeFromString>(raw) + } catch (e: Exception) { + emptyList() + } + } + + /** Read which residence this widget instance is currently scoped to (null = All). */ + suspend fun loadResidenceIdFor(appWidgetId: Int): Long? = + store.readResidenceIdFor(appWidgetId) + + /** Persist the chosen residence for this widget instance. */ + suspend fun saveResidenceIdFor(appWidgetId: Int, residenceId: Long?) { + store.writeResidenceIdFor(appWidgetId, residenceId) + } + + /** Drop the per-widget residence selection when the widget is removed. */ + suspend fun clearResidenceIdFor(appWidgetId: Int) { + store.clearResidenceIdFor(appWidgetId) + } + /** Queue a task id for optimistic completion. See [loadTasks]. */ suspend fun markPendingCompletion(taskId: Long) { val current = store.readPendingCompletionIds().toMutableSet() @@ -141,8 +201,15 @@ class WidgetDataRepository internal constructor(private val context: Context) { * * Pending-completion tasks are excluded (via [loadTasks]). */ - suspend fun computeStats(): WidgetStats { - val tasks = loadTasks() + suspend fun computeStats(): WidgetStats = computeStatsFromTasks(loadTasks()) + + /** + * Compute the same stats off a pre-filtered task list. Used by + * [HoneyDueLargeWidget] after applying the per-widget residence + * scope (gitea#6) so the stat tiles reflect only the residence the + * user picked. + */ + fun computeStatsFromTasks(tasks: List): WidgetStats { var overdue = 0 var within7 = 0 var within8To30 = 0 @@ -257,5 +324,18 @@ class WidgetDataRepository internal constructor(private val context: Context) { /** Legacy accessor — delegates to [get]. */ fun getInstance(context: Context): WidgetDataRepository = get(context) + + /** + * Pure filter — exposed for unit-test coverage. Mirrors iOS' + * `WidgetDataManager.filterTasks(_:forResidenceId:)` semantics + * (gitea#6). + */ + fun filterTasksForResidence( + tasks: List, + residenceId: Long? + ): List { + if (residenceId == null) return tasks + return tasks.filter { it.residenceId == residenceId } + } } } diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataStore.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataStore.kt index 8ebea7f..78c5c55 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataStore.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataStore.kt @@ -32,6 +32,15 @@ internal object WidgetDataStoreKeys { val PENDING_COMPLETION_IDS = stringPreferencesKey("pending_completion_ids") val LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time") val USER_TIER = stringPreferencesKey("user_tier") + /** JSON-serialized List for the configuration picker (gitea#6). */ + val WIDGET_RESIDENCES_JSON = stringPreferencesKey("widget_residences_json") + + /** + * Returns a key for the `Long` residence id this `appWidgetId` is + * scoped to. Missing key = "All residences" (legacy behaviour). + */ + fun residenceIdKeyFor(appWidgetId: Int) = + longPreferencesKey("widget_residence_id_$appWidgetId") } /** @@ -90,6 +99,56 @@ class WidgetDataStore(private val context: Context) { prefs.remove(WidgetDataStoreKeys.PENDING_COMPLETION_IDS) prefs.remove(WidgetDataStoreKeys.LAST_REFRESH_TIME) prefs.remove(WidgetDataStoreKeys.USER_TIER) + prefs.remove(WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON) + // Per-widget residence ids are added dynamically as + // `widget_residence_id_` keys; sweep them by + // prefix so logout doesn't leave dangling per-instance + // scoping behind. + prefs.asMap().keys + .filter { it.name.startsWith("widget_residence_id_") } + .forEach { prefs.remove(it) } + } + } + + // ========================================================================= + // Per-residence widget configuration (gitea#6) + // ========================================================================= + + /** + * Read the user's residences (id + name) as persisted by the main + * app. Used by [WidgetConfigActivity] to populate its picker. + */ + suspend fun readResidencesJson(): String = + store.data.first()[WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON] ?: "[]" + + suspend fun writeResidencesJson(json: String) { + store.edit { prefs -> + prefs[WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON] = json + } + } + + /** + * Read the residence id this widget instance is scoped to, or `null` + * for "All residences" (no scoping — the legacy default). + */ + suspend fun readResidenceIdFor(appWidgetId: Int): Long? = + store.data.first()[WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId)] + + suspend fun writeResidenceIdFor(appWidgetId: Int, residenceId: Long?) { + store.edit { prefs -> + val key = WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId) + if (residenceId == null) { + prefs.remove(key) + } else { + prefs[key] = residenceId + } + } + } + + /** Clear scoping for a removed widget instance (called from `onDeleted`). */ + suspend fun clearResidenceIdFor(appWidgetId: Int) { + store.edit { prefs -> + prefs.remove(WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId)) } } } diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorker.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorker.kt index 844ea17..a297f64 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorker.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorker.kt @@ -18,6 +18,13 @@ interface WidgetRefreshDataSource { suspend fun fetchTasks(): ApiResult> /** Fetch the current user's subscription tier ("free" | "premium"). */ suspend fun fetchTier(): String + /** + * Fetch the user's residences for the widget configuration picker + * (gitea#6). Returning an empty list is non-fatal — the worker will + * just skip the residence sidecar update and pre-existing scopes + * keep working until next refresh. + */ + suspend fun fetchResidences(): List = emptyList() } /** @@ -48,6 +55,16 @@ internal object DefaultWidgetRefreshDataSource : WidgetRefreshDataSource { } } + override suspend fun fetchResidences(): List { + val result = APILayer.getMyResidences(forceRefresh = false) + return when (result) { + is ApiResult.Success -> result.data.residences.map { r -> + WidgetResidenceDto(id = r.id.toLong(), name = r.name) + } + else -> emptyList() + } + } + private fun mapToWidgetTasks(response: TaskColumnsResponse): List { val out = mutableListOf() for (column in response.columns) { @@ -112,6 +129,16 @@ class WidgetRefreshWorker( val repo = WidgetDataRepository.get(ctx) repo.saveTasks(tasksResult.data) repo.saveTierState(tier) + // Best-effort residence sidecar update — failure is + // non-fatal because pre-existing scopes (and the + // "All residences" fallback) keep working with stale + // data until the next refresh succeeds (gitea#6). + runCatching { + val residences = dataSource.fetchResidences() + if (residences.isNotEmpty()) { + repo.saveResidences(residences) + } + } refreshGlanceWidgets(ctx) // Chain the next scheduled refresh so cadence keeps ticking // even if the OS evicts our periodic request. Wrapped in diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskDto.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskDto.kt index 832174d..33e2588 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskDto.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskDto.kt @@ -34,6 +34,20 @@ data class WidgetTaskDto( val completed: Boolean ) +/** + * Lightweight residence identifier persisted to the widget DataStore. + * + * Written by the main app whenever [com.tt.honeyDue.data.DataManager.myResidences] + * updates so the widget configuration activity can offer the current + * residence list (gitea#6 — per-residence widget selection). Mirrors + * iOS' `WidgetDataManager.WidgetResidence` shape. + */ +@Serializable +data class WidgetResidenceDto( + val id: Long, + val name: String +) + /** * Summary metrics computed from the cached task list. * diff --git a/composeApp/src/androidMain/res/xml/honeydue_large_widget_info.xml b/composeApp/src/androidMain/res/xml/honeydue_large_widget_info.xml index 715ac7a..482d5b9 100644 --- a/composeApp/src/androidMain/res/xml/honeydue_large_widget_info.xml +++ b/composeApp/src/androidMain/res/xml/honeydue_large_widget_info.xml @@ -14,4 +14,5 @@ android:previewLayout="@layout/widget_large_preview" android:description="@string/widget_large_description" android:updatePeriodMillis="1800000" + android:configure="com.tt.honeyDue.widget.WidgetConfigActivity" android:widgetFeatures="reconfigurable" /> diff --git a/composeApp/src/androidMain/res/xml/honeydue_medium_widget_info.xml b/composeApp/src/androidMain/res/xml/honeydue_medium_widget_info.xml index ffd7e25..2aee545 100644 --- a/composeApp/src/androidMain/res/xml/honeydue_medium_widget_info.xml +++ b/composeApp/src/androidMain/res/xml/honeydue_medium_widget_info.xml @@ -14,4 +14,5 @@ android:previewLayout="@layout/widget_medium_preview" android:description="@string/widget_medium_description" android:updatePeriodMillis="1800000" + android:configure="com.tt.honeyDue.widget.WidgetConfigActivity" android:widgetFeatures="reconfigurable" /> diff --git a/composeApp/src/androidMain/res/xml/honeydue_small_widget_info.xml b/composeApp/src/androidMain/res/xml/honeydue_small_widget_info.xml index e247d17..70f239a 100644 --- a/composeApp/src/androidMain/res/xml/honeydue_small_widget_info.xml +++ b/composeApp/src/androidMain/res/xml/honeydue_small_widget_info.xml @@ -14,4 +14,5 @@ android:previewLayout="@layout/widget_small_preview" android:description="@string/widget_small_description" android:updatePeriodMillis="1800000" + android:configure="com.tt.honeyDue.widget.WidgetConfigActivity" android:widgetFeatures="reconfigurable" /> diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetResidenceFilterTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetResidenceFilterTest.kt new file mode 100644 index 0000000..571b568 --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetResidenceFilterTest.kt @@ -0,0 +1,140 @@ +package com.tt.honeyDue.widget + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Coverage for the per-residence widget filter added in gitea#6. + * + * Two surfaces under test: + * + * 1. `WidgetDataRepository.filterTasksForResidence` — the pure filter + * used by `loadTasksForResidence` and (transitively) by the + * timeline provider. Mirrors iOS' `WidgetDataManager.filterTasks`. + * 2. The per-`appWidgetId` DataStore key — verifies round-tripping + * a saved residence id and clearing it for a removed widget. + */ +@RunWith(RobolectricTestRunner::class) +class WidgetResidenceFilterTest { + + private lateinit var context: Context + private lateinit var repo: WidgetDataRepository + + @Before + fun setUp() = runTest { + context = ApplicationProvider.getApplicationContext() + repo = WidgetDataRepository.get(context) + repo.clearAll() + } + + @After + fun tearDown() = runTest { + repo.clearAll() + } + + private fun task(id: Long, residenceId: Long): WidgetTaskDto = + WidgetTaskDto( + id = id, + title = "Task $id", + priority = 0, + dueDate = null, + isOverdue = false, + daysUntilDue = 0, + residenceId = residenceId, + residenceName = "", + categoryIcon = "", + completed = false + ) + + // --- pure filter ------------------------------------------------------- + + @Test + fun filter_nullResidenceReturnsAllTasks() { + val tasks = listOf(task(1, 10), task(2, 20), task(3, 30)) + val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = null) + assertEquals(listOf(1L, 2L, 3L), result.map { it.id }) + } + + @Test + fun filter_matchingResidenceKeepsOnlyMatchingTasks() { + val tasks = listOf(task(1, 10), task(2, 20), task(3, 10), task(4, 30)) + val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 10) + assertEquals(listOf(1L, 3L), result.map { it.id }) + } + + @Test + fun filter_unknownResidenceReturnsEmpty() { + val tasks = listOf(task(1, 10), task(2, 20)) + val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 999) + assertTrue(result.isEmpty()) + } + + @Test + fun filter_preservesInputOrder() { + // Subset only — the timeline provider relies on this so its + // own sort step ("overdue first, then by due date") operates + // on already-filtered tasks. + val tasks = listOf(task(5, 1), task(3, 1), task(7, 1), task(1, 2)) + val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 1) + assertEquals(listOf(5L, 3L, 7L), result.map { it.id }) + } + + // --- DataStore round-trip --------------------------------------------- + + @Test + fun perWidgetResidenceId_roundTripsThroughDataStore() = runTest { + // Initial state: no scope persisted → returns null ("All residences"). + assertNull(repo.loadResidenceIdFor(appWidgetId = 42)) + + repo.saveResidenceIdFor(appWidgetId = 42, residenceId = 7L) + assertEquals(7L, repo.loadResidenceIdFor(appWidgetId = 42)) + + // Different widget id stays unscoped — keys are per-instance. + assertNull(repo.loadResidenceIdFor(appWidgetId = 99)) + + // Save null clears the scope ("All residences" selected after a + // previously residence-scoped tile). + repo.saveResidenceIdFor(appWidgetId = 42, residenceId = null) + assertNull(repo.loadResidenceIdFor(appWidgetId = 42)) + } + + @Test + fun loadTasksForWidget_appliesPerInstanceScope() = runTest { + repo.saveTasks(listOf(task(1, 10), task(2, 20), task(3, 10))) + repo.saveResidenceIdFor(appWidgetId = 1, residenceId = 10L) + repo.saveResidenceIdFor(appWidgetId = 2, residenceId = 20L) + + assertEquals(listOf(1L, 3L), repo.loadTasksForWidget(1).map { it.id }) + assertEquals(listOf(2L), repo.loadTasksForWidget(2).map { it.id }) + // Unconfigured tile defaults to "All residences" — every task. + assertEquals(listOf(1L, 2L, 3L), repo.loadTasksForWidget(3).map { it.id }) + } + + @Test + fun clearResidenceIdFor_dropsScope() = runTest { + repo.saveResidenceIdFor(appWidgetId = 5, residenceId = 11L) + assertEquals(11L, repo.loadResidenceIdFor(5)) + + repo.clearResidenceIdFor(5) + assertNull(repo.loadResidenceIdFor(5)) + } + + @Test + fun saveResidences_roundTripsResidenceList() = runTest { + val payload = listOf( + WidgetResidenceDto(id = 1, name = "Home"), + WidgetResidenceDto(id = 2, name = "Cabin") + ) + repo.saveResidences(payload) + assertEquals(payload, repo.loadResidences()) + } +}