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())
+ }
+}
diff --git a/iosApp/HoneyDue/AppIntent.swift b/iosApp/HoneyDue/AppIntent.swift
index 10796d4..c83cac5 100644
--- a/iosApp/HoneyDue/AppIntent.swift
+++ b/iosApp/HoneyDue/AppIntent.swift
@@ -10,9 +10,79 @@ import AppIntents
import Foundation
// MARK: - Widget Configuration Intent
+
+/// Per-instance widget configuration. The `residence` parameter (added
+/// for gitea#6) lets users with multiple residences pick which one a
+/// given widget tile shows tasks for. When unset the widget continues
+/// to display tasks across every residence — that's the single-home
+/// default and matches pre-#6 behaviour for users who only have one
+/// property.
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "honeyDue Configuration" }
- static var description: IntentDescription { "Configure your honeyDue widget" }
+ static var description: IntentDescription {
+ IntentDescription("Pick which residence this widget shows tasks for.")
+ }
+
+ @Parameter(title: "Residence")
+ var residence: WidgetResidenceEntity?
+}
+
+// MARK: - Residence Entity (configuration picker)
+
+/// `AppEntity` exposing the user's residences to the widget's
+/// configuration sheet. Reads from the `widget_residences.json`
+/// sidecar that the main app writes via
+/// `WidgetDataManager.saveResidences(...)`.
+struct WidgetResidenceEntity: AppEntity, Identifiable, Hashable {
+ /// Backing residence id (matches `Residence.id` on the server). Stored
+ /// as `String` because `AppEntity.id` requires `Hashable`-conformance
+ /// for stable widget reconfiguration — Apple's docs recommend a stable
+ /// string identifier over `Int` so the widget timeline survives
+ /// device-id changes.
+ var id: String
+
+ var name: String
+
+ /// Convenience integer form for `CacheManager.getUpcomingTasks`.
+ var intId: Int? { Int(id) }
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation {
+ TypeDisplayRepresentation(name: "Residence")
+ }
+
+ var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(title: "\(name)")
+ }
+
+ static var defaultQuery = WidgetResidenceEntityQuery()
+}
+
+/// Provides the residence choices the configuration sheet displays. The
+/// list is sourced from the App-Group-shared `widget_residences.json`
+/// the main app maintains; on a brand-new install (or signed-out state)
+/// the sheet falls back to showing only the "All residences" implicit
+/// option exposed by the optional parameter.
+struct WidgetResidenceEntityQuery: EntityQuery {
+ /// Look up specific residences by id (used when the system needs to
+ /// re-resolve a saved configuration after the user reopens the
+ /// widget edit sheet).
+ func entities(for identifiers: [WidgetResidenceEntity.ID]) async throws -> [WidgetResidenceEntity] {
+ let known = loadAll()
+ return identifiers.compactMap { id in known.first(where: { $0.id == id }) }
+ }
+
+ /// Populate the picker. Sorted alphabetically so the list is stable
+ /// across refreshes — `WidgetDataManager.saveResidences` writes in
+ /// the order the API returned, which can shuffle on server-side
+ /// re-sorts.
+ func suggestedEntities() async throws -> [WidgetResidenceEntity] {
+ loadAll().sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ }
+
+ private func loadAll() -> [WidgetResidenceEntity] {
+ let raw = CacheManager.getResidences()
+ return raw.map { WidgetResidenceEntity(id: String($0.id), name: $0.name) }
+ }
}
// MARK: - Complete Task Intent
diff --git a/iosApp/HoneyDue/HoneyDue.swift b/iosApp/HoneyDue/HoneyDue.swift
index 2e49928..93a2007 100644
--- a/iosApp/HoneyDue/HoneyDue.swift
+++ b/iosApp/HoneyDue/HoneyDue.swift
@@ -84,6 +84,12 @@ class CacheManager {
let inProgress: Bool
let dueDate: String?
let category: String?
+ /// Owning residence id. Decoded from `residence_id` in the widget
+ /// cache. Optional so older app builds (pre-gitea#6) that omitted
+ /// the key still decode successfully — in that case the widget
+ /// behaves like the legacy "all residences" mode regardless of
+ /// what the configuration intent picks.
+ let residenceId: Int?
let residenceName: String?
let isOverdue: Bool
let isDueWithin7Days: Bool
@@ -93,12 +99,45 @@ class CacheManager {
case id, title, description, priority, category
case inProgress = "in_progress"
case dueDate = "due_date"
+ case residenceId = "residence_id"
case residenceName = "residence_name"
case isOverdue = "is_overdue"
case isDueWithin7Days = "is_due_within_7_days"
case isDue8To30Days = "is_due_8_to_30_days"
}
+ /// Custom init with a default `residenceId` so the existing
+ /// #Preview literal-task sites compile without each having to
+ /// add the new parameter. Production decode uses the synthesized
+ /// `Decodable` path.
+ init(
+ id: Int,
+ title: String,
+ description: String?,
+ priority: String?,
+ inProgress: Bool,
+ dueDate: String?,
+ category: String?,
+ residenceId: Int? = nil,
+ residenceName: String?,
+ isOverdue: Bool,
+ isDueWithin7Days: Bool,
+ isDue8To30Days: Bool
+ ) {
+ self.id = id
+ self.title = title
+ self.description = description
+ self.priority = priority
+ self.inProgress = inProgress
+ self.dueDate = dueDate
+ self.category = category
+ self.residenceId = residenceId
+ self.residenceName = residenceName
+ self.isOverdue = isOverdue
+ self.isDueWithin7Days = isDueWithin7Days
+ self.isDue8To30Days = isDue8To30Days
+ }
+
/// Whether this task is pending completion (tapped on widget, waiting for sync)
var isPendingCompletion: Bool {
WidgetActionManager.shared.isTaskPendingCompletion(taskId: id)
@@ -147,14 +186,19 @@ class CacheManager {
}
}
- static func getUpcomingTasks() -> [CustomTask] {
+ static func getUpcomingTasks(forResidenceId residenceId: Int? = nil) -> [CustomTask] {
let allTasks = getData()
// Filter for actionable tasks (not completed, including in-progress and overdue)
- // Also exclude tasks that are pending completion via widget
+ // Also exclude tasks that are pending completion via widget.
+ // When a residence is configured for this widget instance
+ // (gitea#6), drop tasks owned by other residences.
let upcoming = allTasks.filter { task in
- // Include if: not pending completion
- return task.shouldShow
+ guard task.shouldShow else { return false }
+ if let residenceId, let taskResidenceId = task.residenceId {
+ return taskResidenceId == residenceId
+ }
+ return true
}
// Sort by due date (earliest first), with overdue at top
@@ -171,6 +215,36 @@ class CacheManager {
return date1 < date2
}
}
+
+ // MARK: - Residence sidecar (gitea#6)
+
+ private static let residencesFileName = "widget_residences.json"
+
+ private static var residencesFileURL: URL? {
+ sharedContainerURL?.appendingPathComponent(residencesFileName)
+ }
+
+ struct WidgetResidence: Codable, Identifiable, Hashable {
+ let id: Int
+ let name: String
+ }
+
+ /// Synchronously load every residence the main app has persisted for
+ /// widget configuration. Empty when the user is signed out or the
+ /// sidecar has not yet been written.
+ static func getResidences() -> [WidgetResidence] {
+ guard let fileURL = residencesFileURL,
+ FileManager.default.fileExists(atPath: fileURL.path) else {
+ return []
+ }
+ do {
+ let data = try Data(contentsOf: fileURL)
+ return try JSONDecoder().decode([WidgetResidence].self, from: data)
+ } catch {
+ print("CacheManager: Error decoding residences - \(error)")
+ return []
+ }
+ }
}
struct Provider: AppIntentTimelineProvider {
@@ -184,7 +258,7 @@ struct Provider: AppIntentTimelineProvider {
}
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
- let tasks = CacheManager.getUpcomingTasks()
+ let tasks = CacheManager.getUpcomingTasks(forResidenceId: configuration.residence?.intId)
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
return SimpleEntry(
date: Date(),
@@ -195,7 +269,7 @@ struct Provider: AppIntentTimelineProvider {
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline {
- let tasks = CacheManager.getUpcomingTasks()
+ let tasks = CacheManager.getUpcomingTasks(forResidenceId: configuration.residence?.intId)
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
// Use a longer refresh interval during overnight hours (11pm-6am)
diff --git a/iosApp/HoneyDueTests/WidgetResidenceFilterTests.swift b/iosApp/HoneyDueTests/WidgetResidenceFilterTests.swift
new file mode 100644
index 0000000..ac24df6
--- /dev/null
+++ b/iosApp/HoneyDueTests/WidgetResidenceFilterTests.swift
@@ -0,0 +1,90 @@
+import XCTest
+@testable import honeyDue
+
+/// Tests for the per-residence widget filter added in gitea#6.
+///
+/// `WidgetDataManager.filterTasks(_:forResidenceId:)` is the pure
+/// function the widget timeline provider calls when a configuration
+/// intent has a residence selected. These tests guarantee the contract
+/// stays stable: nil → pass-through, matching id → only matching tasks,
+/// no match → empty, missing residenceId on a task → never leaks into
+/// a residence-scoped widget.
+final class WidgetResidenceFilterTests: XCTestCase {
+
+ private func makeTask(
+ id: Int,
+ residenceId: Int? = nil
+ ) -> WidgetDataManager.WidgetTask {
+ WidgetDataManager.WidgetTask(
+ id: id,
+ title: "Task \(id)",
+ description: nil,
+ priority: nil,
+ inProgress: false,
+ dueDate: nil,
+ category: nil,
+ residenceId: residenceId,
+ residenceName: nil,
+ isOverdue: false,
+ isDueWithin7Days: false,
+ isDue8To30Days: false
+ )
+ }
+
+ func testNilResidenceReturnsAllTasks() {
+ // "All residences" config — widget passes nil, gets every task.
+ let tasks = [
+ makeTask(id: 1, residenceId: 10),
+ makeTask(id: 2, residenceId: 20),
+ makeTask(id: 3, residenceId: nil),
+ ]
+ let result = WidgetDataManager.filterTasks(tasks, forResidenceId: nil)
+ XCTAssertEqual(result.map(\.id), [1, 2, 3])
+ }
+
+ func testMatchingResidenceKeepsOnlyMatchingTasks() {
+ let tasks = [
+ makeTask(id: 1, residenceId: 10),
+ makeTask(id: 2, residenceId: 20),
+ makeTask(id: 3, residenceId: 10),
+ makeTask(id: 4, residenceId: 30),
+ ]
+ let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 10)
+ XCTAssertEqual(result.map(\.id), [1, 3])
+ }
+
+ func testUnknownResidenceReturnsEmpty() {
+ let tasks = [
+ makeTask(id: 1, residenceId: 10),
+ makeTask(id: 2, residenceId: 20),
+ ]
+ let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 999)
+ XCTAssertTrue(result.isEmpty)
+ }
+
+ func testNilResidenceIdOnTaskDoesNotMatchScopedConfiguration() {
+ // A task written by an older app build (no `residence_id` in JSON)
+ // must NOT leak into a residence-scoped widget — we'd rather hide
+ // it than misattribute it to the wrong home.
+ let tasks = [
+ makeTask(id: 1, residenceId: 10),
+ makeTask(id: 2, residenceId: nil),
+ ]
+ let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 10)
+ XCTAssertEqual(result.map(\.id), [1])
+ }
+
+ func testFilterPreservesInputOrder() {
+ // The filter is a pure subset op — no sorting side effects.
+ // Timeline provider relies on this so its sort step (overdue
+ // first, then by due date) operates on already-filtered tasks.
+ let tasks = [
+ makeTask(id: 5, residenceId: 1),
+ makeTask(id: 3, residenceId: 1),
+ makeTask(id: 7, residenceId: 1),
+ makeTask(id: 1, residenceId: 2),
+ ]
+ let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 1)
+ XCTAssertEqual(result.map(\.id), [5, 3, 7])
+ }
+}
diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift
index d2995d8..9c6b9a1 100644
--- a/iosApp/iosApp/Data/DataManagerObservable.swift
+++ b/iosApp/iosApp/Data/DataManagerObservable.swift
@@ -274,11 +274,14 @@ class DataManagerObservable: ObservableObject {
}
observationTasks.append(residencesTask)
- // MyResidences
+ // MyResidences. Mirror every update into the widget App Group
+ // sidecar so the widget's configuration intent (gitea#6) can
+ // offer the current residence list without making a network call.
let myResidencesTask = Task { [weak self] in
for await response in DataManager.shared.myResidences {
guard let self else { return }
self.myResidences = response
+ WidgetDataManager.shared.saveResidences(from: response)
}
}
observationTasks.append(myResidencesTask)
@@ -732,6 +735,7 @@ class DataManagerObservable: ObservableObject {
inProgress: task.inProgress,
dueDate: task.effectiveDueDate,
category: task.categoryName,
+ residenceId: Int(task.residenceId),
residenceName: nil,
isOverdue: overdueIds.contains(task.id),
isDueWithin7Days: isDueWithin7Days,
diff --git a/iosApp/iosApp/Helpers/WidgetDataManager.swift b/iosApp/iosApp/Helpers/WidgetDataManager.swift
index c89847a..a87eb0b 100644
--- a/iosApp/iosApp/Helpers/WidgetDataManager.swift
+++ b/iosApp/iosApp/Helpers/WidgetDataManager.swift
@@ -24,6 +24,7 @@ final class WidgetDataManager {
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
}()
private let tasksFileName = "widget_tasks.json"
+ private let residencesFileName = "widget_residences.json"
private let actionsFileName = "widget_pending_actions.json"
private let pendingTasksFileName = "widget_pending_tasks.json"
private let tokenKey = "widget_auth_token"
@@ -295,7 +296,15 @@ final class WidgetDataManager {
!loadPendingActionsSync().isEmpty
}
- /// Task model for widget display - simplified version of TaskDetail
+ /// Task model for widget display - simplified version of TaskDetail.
+ ///
+ /// `residenceId` (added for gitea#6 per-residence widget selection)
+ /// is encoded as `residence_id` to match the widget extension's
+ /// `CacheManager.CustomTask` JSON shape. The extension uses it to
+ /// filter the timeline when the user picks a specific residence in
+ /// the widget configuration intent. Older JSON written by previous
+ /// app versions omitted the key — the field is optional so decode
+ /// of pre-existing widget caches still succeeds.
struct WidgetTask: Codable {
let id: Int
let title: String
@@ -304,6 +313,7 @@ final class WidgetDataManager {
let inProgress: Bool
let dueDate: String?
let category: String?
+ let residenceId: Int?
let residenceName: String?
let isOverdue: Bool
let isDueWithin7Days: Bool
@@ -313,11 +323,53 @@ final class WidgetDataManager {
case id, title, description, priority, category
case inProgress = "in_progress"
case dueDate = "due_date"
+ case residenceId = "residence_id"
case residenceName = "residence_name"
case isOverdue = "is_overdue"
case isDueWithin7Days = "is_due_within_7_days"
case isDue8To30Days = "is_due_8_to_30_days"
}
+
+ /// Custom init with a default `residenceId` so existing test
+ /// literals (TaskMetricsTests) compile without each adding the
+ /// new field. Production code that has the residence id passes
+ /// it explicitly.
+ init(
+ id: Int,
+ title: String,
+ description: String?,
+ priority: String?,
+ inProgress: Bool,
+ dueDate: String?,
+ category: String?,
+ residenceId: Int? = nil,
+ residenceName: String?,
+ isOverdue: Bool,
+ isDueWithin7Days: Bool,
+ isDue8To30Days: Bool
+ ) {
+ self.id = id
+ self.title = title
+ self.description = description
+ self.priority = priority
+ self.inProgress = inProgress
+ self.dueDate = dueDate
+ self.category = category
+ self.residenceId = residenceId
+ self.residenceName = residenceName
+ self.isOverdue = isOverdue
+ self.isDueWithin7Days = isDueWithin7Days
+ self.isDue8To30Days = isDue8To30Days
+ }
+ }
+
+ /// Lightweight residence identifier for widget configuration. Persists
+ /// `(id, name)` of every residence the user belongs to so the widget
+ /// extension can populate its `ResidenceEntityQuery` without making
+ /// a network call (gitea#6).
+ struct WidgetResidence: Codable, Equatable {
+ let id: Int
+ let name: String
}
/// Metrics calculated from an array of tasks - shared between app and widget
@@ -418,6 +470,12 @@ final class WidgetDataManager {
}
}
+ // `task.residenceId` is non-optional Int32 on Kotlin so always
+ // promotes safely. `residenceName` is left blank because the
+ // widget already resolves it via the saved residences file
+ // (gitea#6) — keeping the field around for forward-compat
+ // with the existing JSON shape consumed by older widget
+ // builds.
let widgetTask = WidgetTask(
id: Int(task.id),
title: task.title,
@@ -426,6 +484,7 @@ final class WidgetDataManager {
inProgress: task.inProgress,
dueDate: task.effectiveDueDate,
category: task.categoryName ?? "",
+ residenceId: Int(task.residenceId),
residenceName: "",
isOverdue: isOverdue,
isDueWithin7Days: isDueWithin7Days,
@@ -540,10 +599,94 @@ final class WidgetDataManager {
print("WidgetDataManager: Error clearing cache - \(error)")
}
+ // Also clear residences so the configuration intent stops
+ // offering stale options after sign-out.
+ if let resURL = self.residencesFileURL {
+ try? FileManager.default.removeItem(at: resURL)
+ }
+
DispatchQueue.main.async {
WidgetCenter.shared.reloadAllTimelines()
}
}
}
+ // MARK: - Residences (per-residence widget selection, gitea#6)
+
+ /// Path to the residence sidecar file inside the App Group container.
+ private var residencesFileURL: URL? {
+ sharedContainerURL?.appendingPathComponent(residencesFileName)
+ }
+
+ /// Persist the user's residences (id + name) to the App Group so the
+ /// widget extension's configuration intent can offer them as choices.
+ /// Call whenever `DataManagerObservable.myResidences` updates.
+ func saveResidences(_ residences: [WidgetResidence]) {
+ guard let fileURL = residencesFileURL else {
+ print("WidgetDataManager: Unable to access shared container for residences")
+ return
+ }
+
+ fileQueue.async {
+ do {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = .prettyPrinted
+ let data = try encoder.encode(residences)
+ try data.write(to: fileURL, options: .atomic)
+ print("WidgetDataManager: Saved \(residences.count) residences for widget config")
+ } catch {
+ print("WidgetDataManager: Error saving residences - \(error)")
+ }
+
+ DispatchQueue.main.async {
+ // Configuration intent reads on-demand, but reload the
+ // currently-pinned widgets so the visible task list
+ // refreshes against any rename.
+ self.reloadWidgetTimelinesIfNeeded()
+ }
+ }
+ }
+
+ /// Convenience: save from a Kotlin `MyResidencesResponse` directly.
+ func saveResidences(from myResidences: MyResidencesResponse?) {
+ let residences = (myResidences?.residences ?? []).map { r in
+ WidgetResidence(id: Int(r.id), name: r.name)
+ }
+ saveResidences(residences)
+ }
+
+ /// Load the persisted residences synchronously. Used by the widget
+ /// extension's `ResidenceEntityQuery` (`AppIntents` requires sync
+ /// reads).
+ func loadResidencesSync() -> [WidgetResidence] {
+ guard let fileURL = residencesFileURL else { return [] }
+
+ return fileQueue.sync {
+ guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
+ do {
+ let data = try Data(contentsOf: fileURL)
+ return try JSONDecoder().decode([WidgetResidence].self, from: data)
+ } catch {
+ print("WidgetDataManager: Error loading residences - \(error)")
+ return []
+ }
+ }
+ }
+
+ // MARK: - Pure filter (covered by tests)
+
+ /// Return only the tasks for `residenceId`. When `residenceId` is
+ /// `nil`, returns the input unchanged — that's the "All residences"
+ /// configuration option in the widget.
+ ///
+ /// Factored out as a pure function so it can be exercised from unit
+ /// tests without booting the widget timeline provider.
+ static func filterTasks(
+ _ tasks: [WidgetTask],
+ forResidenceId residenceId: Int?
+ ) -> [WidgetTask] {
+ guard let residenceId else { return tasks }
+ return tasks.filter { $0.residenceId == residenceId }
+ }
+
}