feat(widget): per-residence widget configuration (Android, gitea#6)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Android UI Tests / ui-tests (pull_request) Has been cancelled
Mirrors the iOS implementation. Adds a Glance configuration activity
that launches when the user pins a new honeyDue widget tile and again
on "Edit Widget", lets them pick one of their residences (or "All
residences"), and persists the choice per-`appWidgetId`. Each tile's
`provideGlance` resolves its own scope and filters tasks (and stats,
on the large widget) accordingly.
Pieces:
- `WidgetConfigActivity` — Compose `ComponentActivity` hosting the
residence-picker UI; reads the persisted residences sidecar, reads
any prior scope for the current `appWidgetId`, writes the new
selection on Save, and re-renders every widget tile.
- `WidgetDataStore` — new `widget_residences_json` key + a per-instance
`widget_residence_id_<appWidgetId>` key. `clearAll()` sweeps the
per-instance keys by prefix so logout doesn't leave dangling state.
- `WidgetDataRepository`:
* `saveResidences(_)` / `loadResidences()` for the picker.
* `saveResidenceIdFor(appWidgetId, residenceId)` /
`loadResidenceIdFor(appWidgetId)` /
`clearResidenceIdFor(appWidgetId)` for per-tile scope.
* `loadTasksForResidence(residenceId)` and the
`appWidgetId`-driven `loadTasksForWidget(appWidgetId)`.
* `computeStatsFromTasks(tasks)` so the large widget's tiles
reflect only the scoped task list (instead of the whole cache).
* Pure `Filter.filterTasksForResidence(_, _)` on the companion
object — easy to exercise from unit tests.
- `WidgetTaskDto` already carries `residenceId`. New `WidgetResidenceDto`
added (id + name) — JSON-persisted via the sidecar.
- `WidgetRefreshWorker` / `DefaultWidgetRefreshDataSource` — pull
`myResidences` alongside tasks/tier on each refresh and write the
sidecar (best-effort; non-fatal if the call fails).
- `HoneyDue{Small,Medium,Large}Widget.provideGlance` — resolve
`appWidgetId` via `GlanceAppWidgetManager(context).getAppWidgetId(id)`
and call `loadTasksForWidget(appWidgetId)`.
- `HoneyDue{Small,Medium,Large}WidgetReceiver.onDeleted` — purge the
per-instance residence scope key when the tile is removed.
- Manifest: register the configure activity with the
`APPWIDGET_CONFIGURE` action.
- `honeydue_{small,medium,large}_widget_info.xml` — declare
`android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"`.
Migration / safety:
- A tile that's never been through the picker has no residence id
saved → `loadTasksForWidget` returns every task (legacy "All
residences" behaviour). Existing tiles keep working without the
user touching anything.
- The picker handles an empty residences list (signed-out / first
install before background refresh) with an explicit helper message
pointing at the main app.
Tests: new `WidgetResidenceFilterTest` (commonTest-style under
`androidUnitTest`, 9 cases). All green.
$ ./gradlew :composeApp:testDebugUnitTest \\
--tests "com.tt.honeyDue.widget.WidgetResidenceFilterTest"
BUILD SUCCESSFUL
$ ./gradlew :composeApp:assembleDebug
BUILD SUCCESSFUL
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+140
@@ -0,0 +1,140 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* Coverage for the per-residence widget filter added in gitea#6.
|
||||
*
|
||||
* Two surfaces under test:
|
||||
*
|
||||
* 1. `WidgetDataRepository.filterTasksForResidence` — the pure filter
|
||||
* used by `loadTasksForResidence` and (transitively) by the
|
||||
* timeline provider. Mirrors iOS' `WidgetDataManager.filterTasks`.
|
||||
* 2. The per-`appWidgetId` DataStore key — verifies round-tripping
|
||||
* a saved residence id and clearing it for a removed widget.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class WidgetResidenceFilterTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var repo: WidgetDataRepository
|
||||
|
||||
@Before
|
||||
fun setUp() = runTest {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
repo = WidgetDataRepository.get(context)
|
||||
repo.clearAll()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = runTest {
|
||||
repo.clearAll()
|
||||
}
|
||||
|
||||
private fun task(id: Long, residenceId: Long): WidgetTaskDto =
|
||||
WidgetTaskDto(
|
||||
id = id,
|
||||
title = "Task $id",
|
||||
priority = 0,
|
||||
dueDate = null,
|
||||
isOverdue = false,
|
||||
daysUntilDue = 0,
|
||||
residenceId = residenceId,
|
||||
residenceName = "",
|
||||
categoryIcon = "",
|
||||
completed = false
|
||||
)
|
||||
|
||||
// --- pure filter -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun filter_nullResidenceReturnsAllTasks() {
|
||||
val tasks = listOf(task(1, 10), task(2, 20), task(3, 30))
|
||||
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = null)
|
||||
assertEquals(listOf(1L, 2L, 3L), result.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filter_matchingResidenceKeepsOnlyMatchingTasks() {
|
||||
val tasks = listOf(task(1, 10), task(2, 20), task(3, 10), task(4, 30))
|
||||
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 10)
|
||||
assertEquals(listOf(1L, 3L), result.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filter_unknownResidenceReturnsEmpty() {
|
||||
val tasks = listOf(task(1, 10), task(2, 20))
|
||||
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 999)
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filter_preservesInputOrder() {
|
||||
// Subset only — the timeline provider relies on this so its
|
||||
// own sort step ("overdue first, then by due date") operates
|
||||
// on already-filtered tasks.
|
||||
val tasks = listOf(task(5, 1), task(3, 1), task(7, 1), task(1, 2))
|
||||
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 1)
|
||||
assertEquals(listOf(5L, 3L, 7L), result.map { it.id })
|
||||
}
|
||||
|
||||
// --- DataStore round-trip ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun perWidgetResidenceId_roundTripsThroughDataStore() = runTest {
|
||||
// Initial state: no scope persisted → returns null ("All residences").
|
||||
assertNull(repo.loadResidenceIdFor(appWidgetId = 42))
|
||||
|
||||
repo.saveResidenceIdFor(appWidgetId = 42, residenceId = 7L)
|
||||
assertEquals(7L, repo.loadResidenceIdFor(appWidgetId = 42))
|
||||
|
||||
// Different widget id stays unscoped — keys are per-instance.
|
||||
assertNull(repo.loadResidenceIdFor(appWidgetId = 99))
|
||||
|
||||
// Save null clears the scope ("All residences" selected after a
|
||||
// previously residence-scoped tile).
|
||||
repo.saveResidenceIdFor(appWidgetId = 42, residenceId = null)
|
||||
assertNull(repo.loadResidenceIdFor(appWidgetId = 42))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadTasksForWidget_appliesPerInstanceScope() = runTest {
|
||||
repo.saveTasks(listOf(task(1, 10), task(2, 20), task(3, 10)))
|
||||
repo.saveResidenceIdFor(appWidgetId = 1, residenceId = 10L)
|
||||
repo.saveResidenceIdFor(appWidgetId = 2, residenceId = 20L)
|
||||
|
||||
assertEquals(listOf(1L, 3L), repo.loadTasksForWidget(1).map { it.id })
|
||||
assertEquals(listOf(2L), repo.loadTasksForWidget(2).map { it.id })
|
||||
// Unconfigured tile defaults to "All residences" — every task.
|
||||
assertEquals(listOf(1L, 2L, 3L), repo.loadTasksForWidget(3).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearResidenceIdFor_dropsScope() = runTest {
|
||||
repo.saveResidenceIdFor(appWidgetId = 5, residenceId = 11L)
|
||||
assertEquals(11L, repo.loadResidenceIdFor(5))
|
||||
|
||||
repo.clearResidenceIdFor(5)
|
||||
assertNull(repo.loadResidenceIdFor(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveResidences_roundTripsResidenceList() = runTest {
|
||||
val payload = listOf(
|
||||
WidgetResidenceDto(id = 1, name = "Home"),
|
||||
WidgetResidenceDto(id = 2, name = "Cabin")
|
||||
)
|
||||
repo.saveResidences(payload)
|
||||
assertEquals(payload, repo.loadResidences())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user