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:
@@ -121,6 +121,19 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Per-widget residence picker (gitea#6). Each widget provider
|
||||||
|
XML declares `android:configure` pointing at this activity,
|
||||||
|
so the system launches it whenever the user pins a new
|
||||||
|
tile or hits "Edit Widget" on an existing one. -->
|
||||||
|
<activity
|
||||||
|
android:name=".widget.WidgetConfigActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<!-- Small Widget Receiver (2x1) -->
|
<!-- Small Widget Receiver (2x1) -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.HoneyDueSmallWidgetReceiver"
|
android:name=".widget.HoneyDueSmallWidgetReceiver"
|
||||||
|
|||||||
@@ -45,8 +45,14 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val repo = WidgetDataRepository.get(context)
|
val repo = WidgetDataRepository.get(context)
|
||||||
val tasks = repo.loadTasks()
|
// Per-instance residence scoping (gitea#6). Stats are computed
|
||||||
val stats = repo.computeStats()
|
// 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 tier = repo.loadTierState()
|
||||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||||
|
|
||||||
@@ -135,4 +141,9 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
|
|||||||
/** AppWidget receiver for the large widget. */
|
/** AppWidget receiver for the large widget. */
|
||||||
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
|
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val repo = WidgetDataRepository.get(context)
|
val repo = WidgetDataRepository.get(context)
|
||||||
val tasks = repo.loadTasks()
|
// Per-instance residence scoping (gitea#6). See small widget for rationale.
|
||||||
|
val appWidgetId =
|
||||||
|
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||||
|
val tasks = repo.loadTasksForWidget(appWidgetId)
|
||||||
val tier = repo.loadTierState()
|
val tier = repo.loadTierState()
|
||||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||||
|
|
||||||
@@ -122,4 +125,9 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
|
|||||||
/** AppWidget receiver for the medium widget. */
|
/** AppWidget receiver for the medium widget. */
|
||||||
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
|
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.tt.honeyDue.widget
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
@@ -43,7 +44,13 @@ class HoneyDueSmallWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val repo = WidgetDataRepository.get(context)
|
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 tier = repo.loadTierState()
|
||||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||||
|
|
||||||
@@ -125,4 +132,35 @@ class OpenAppAction : ActionCallback {
|
|||||||
/** AppWidget receiver for the small widget. */
|
/** AppWidget receiver for the small widget. */
|
||||||
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,66 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
|||||||
return all.filterNot { it.id in pending }
|
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<WidgetTaskDto> {
|
||||||
|
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<WidgetTaskDto> {
|
||||||
|
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<WidgetResidenceDto>) {
|
||||||
|
store.writeResidencesJson(json.encodeToString(residences))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the persisted residence list (empty when never written or after logout). */
|
||||||
|
suspend fun loadResidences(): List<WidgetResidenceDto> {
|
||||||
|
val raw = store.readResidencesJson()
|
||||||
|
return try {
|
||||||
|
json.decodeFromString<List<WidgetResidenceDto>>(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]. */
|
/** Queue a task id for optimistic completion. See [loadTasks]. */
|
||||||
suspend fun markPendingCompletion(taskId: Long) {
|
suspend fun markPendingCompletion(taskId: Long) {
|
||||||
val current = store.readPendingCompletionIds().toMutableSet()
|
val current = store.readPendingCompletionIds().toMutableSet()
|
||||||
@@ -141,8 +201,15 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* Pending-completion tasks are excluded (via [loadTasks]).
|
* Pending-completion tasks are excluded (via [loadTasks]).
|
||||||
*/
|
*/
|
||||||
suspend fun computeStats(): WidgetStats {
|
suspend fun computeStats(): WidgetStats = computeStatsFromTasks(loadTasks())
|
||||||
val tasks = 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<WidgetTaskDto>): WidgetStats {
|
||||||
var overdue = 0
|
var overdue = 0
|
||||||
var within7 = 0
|
var within7 = 0
|
||||||
var within8To30 = 0
|
var within8To30 = 0
|
||||||
@@ -257,5 +324,18 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
|||||||
|
|
||||||
/** Legacy accessor — delegates to [get]. */
|
/** Legacy accessor — delegates to [get]. */
|
||||||
fun getInstance(context: Context): WidgetDataRepository = get(context)
|
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<WidgetTaskDto>,
|
||||||
|
residenceId: Long?
|
||||||
|
): List<WidgetTaskDto> {
|
||||||
|
if (residenceId == null) return tasks
|
||||||
|
return tasks.filter { it.residenceId == residenceId }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ internal object WidgetDataStoreKeys {
|
|||||||
val PENDING_COMPLETION_IDS = stringPreferencesKey("pending_completion_ids")
|
val PENDING_COMPLETION_IDS = stringPreferencesKey("pending_completion_ids")
|
||||||
val LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time")
|
val LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time")
|
||||||
val USER_TIER = stringPreferencesKey("user_tier")
|
val USER_TIER = stringPreferencesKey("user_tier")
|
||||||
|
/** JSON-serialized List<WidgetResidenceDto> 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.PENDING_COMPLETION_IDS)
|
||||||
prefs.remove(WidgetDataStoreKeys.LAST_REFRESH_TIME)
|
prefs.remove(WidgetDataStoreKeys.LAST_REFRESH_TIME)
|
||||||
prefs.remove(WidgetDataStoreKeys.USER_TIER)
|
prefs.remove(WidgetDataStoreKeys.USER_TIER)
|
||||||
|
prefs.remove(WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON)
|
||||||
|
// Per-widget residence ids are added dynamically as
|
||||||
|
// `widget_residence_id_<appWidgetId>` 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ interface WidgetRefreshDataSource {
|
|||||||
suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>>
|
suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>>
|
||||||
/** Fetch the current user's subscription tier ("free" | "premium"). */
|
/** Fetch the current user's subscription tier ("free" | "premium"). */
|
||||||
suspend fun fetchTier(): String
|
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<WidgetResidenceDto> = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +55,16 @@ internal object DefaultWidgetRefreshDataSource : WidgetRefreshDataSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchResidences(): List<WidgetResidenceDto> {
|
||||||
|
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<WidgetTaskDto> {
|
private fun mapToWidgetTasks(response: TaskColumnsResponse): List<WidgetTaskDto> {
|
||||||
val out = mutableListOf<WidgetTaskDto>()
|
val out = mutableListOf<WidgetTaskDto>()
|
||||||
for (column in response.columns) {
|
for (column in response.columns) {
|
||||||
@@ -112,6 +129,16 @@ class WidgetRefreshWorker(
|
|||||||
val repo = WidgetDataRepository.get(ctx)
|
val repo = WidgetDataRepository.get(ctx)
|
||||||
repo.saveTasks(tasksResult.data)
|
repo.saveTasks(tasksResult.data)
|
||||||
repo.saveTierState(tier)
|
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)
|
refreshGlanceWidgets(ctx)
|
||||||
// Chain the next scheduled refresh so cadence keeps ticking
|
// Chain the next scheduled refresh so cadence keeps ticking
|
||||||
// even if the OS evicts our periodic request. Wrapped in
|
// even if the OS evicts our periodic request. Wrapped in
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ data class WidgetTaskDto(
|
|||||||
val completed: Boolean
|
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.
|
* Summary metrics computed from the cached task list.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
android:previewLayout="@layout/widget_large_preview"
|
android:previewLayout="@layout/widget_large_preview"
|
||||||
android:description="@string/widget_large_description"
|
android:description="@string/widget_large_description"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||||
android:widgetFeatures="reconfigurable" />
|
android:widgetFeatures="reconfigurable" />
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
android:previewLayout="@layout/widget_medium_preview"
|
android:previewLayout="@layout/widget_medium_preview"
|
||||||
android:description="@string/widget_medium_description"
|
android:description="@string/widget_medium_description"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||||
android:widgetFeatures="reconfigurable" />
|
android:widgetFeatures="reconfigurable" />
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
android:previewLayout="@layout/widget_small_preview"
|
android:previewLayout="@layout/widget_small_preview"
|
||||||
android:description="@string/widget_small_description"
|
android:description="@string/widget_small_description"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||||
android:widgetFeatures="reconfigurable" />
|
android:widgetFeatures="reconfigurable" />
|
||||||
|
|||||||
+140
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user