feat(widget): per-residence widget configuration (Android, gitea#6)
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:
Trey T
2026-05-11 13:31:46 -05:00
parent 498e6b8064
commit 9c9e6009c7
13 changed files with 669 additions and 6 deletions
@@ -45,8 +45,14 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val repo = WidgetDataRepository.get(context)
val tasks = repo.loadTasks()
val stats = repo.computeStats()
// Per-instance residence scoping (gitea#6). Stats are computed
// 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 isPremium = tier.equals("premium", ignoreCase = true)
@@ -135,4 +141,9 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
/** AppWidget receiver for the large widget. */
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
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) {
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 isPremium = tier.equals("premium", ignoreCase = true)
@@ -122,4 +125,9 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
/** AppWidget receiver for the medium widget. */
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
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.Intent
import kotlinx.coroutines.launch
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
@@ -43,7 +44,13 @@ class HoneyDueSmallWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
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 isPremium = tier.equals("premium", ignoreCase = true)
@@ -125,4 +132,35 @@ class OpenAppAction : ActionCallback {
/** AppWidget receiver for the small widget. */
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
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 }
}
/**
* 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]. */
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<WidgetTaskDto>): 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<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 LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time")
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.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_<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>>
/** 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<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> {
val out = mutableListOf<WidgetTaskDto>()
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
@@ -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.
*