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())
+ }
+}