feat(widget): per-residence widget configuration — closes #6 #10

Merged
admin merged 2 commits from feat/6-widget-residence-picker into master 2026-05-11 13:39:06 -05:00
13 changed files with 669 additions and 6 deletions
Showing only changes of commit 9c9e6009c7 - Show all commits
@@ -121,6 +121,19 @@
</intent-filter>
</receiver>
<!-- Per-widget residence picker (gitea#6). Each widget provider
XML declares `android:configure` pointing at this activity,
so the system launches it whenever the user pins a new
tile or hits "Edit Widget" on an existing one. -->
<activity
android:name=".widget.WidgetConfigActivity"
android:exported="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<!-- Small Widget Receiver (2x1) -->
<receiver
android:name=".widget.HoneyDueSmallWidgetReceiver"
@@ -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.
*
@@ -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" />
@@ -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" />
@@ -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" />
@@ -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())
}
}