9c9e6009c7
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>
150 lines
5.8 KiB
Kotlin
150 lines
5.8 KiB
Kotlin
package com.tt.honeyDue.widget
|
|
|
|
import android.content.Context
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.glance.GlanceId
|
|
import androidx.glance.GlanceModifier
|
|
import androidx.glance.action.clickable
|
|
import androidx.glance.appwidget.GlanceAppWidget
|
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
|
import androidx.glance.appwidget.SizeMode
|
|
import androidx.glance.appwidget.action.actionRunCallback
|
|
import androidx.glance.appwidget.provideContent
|
|
import androidx.glance.background
|
|
import androidx.glance.layout.Alignment
|
|
import androidx.glance.layout.Box
|
|
import androidx.glance.layout.Column
|
|
import androidx.glance.layout.Spacer
|
|
import androidx.glance.layout.fillMaxSize
|
|
import androidx.glance.layout.fillMaxWidth
|
|
import androidx.glance.layout.height
|
|
import androidx.glance.layout.padding
|
|
import androidx.glance.text.FontWeight
|
|
import androidx.glance.text.Text
|
|
import androidx.glance.text.TextStyle
|
|
|
|
/**
|
|
* Large (4x4) widget.
|
|
*
|
|
* Mirrors iOS `LargeWidgetView`:
|
|
* - When there are tasks: list of up to 5 tasks with residence/due
|
|
* labels, optional "+N more" text, and a 3-pill stats row at the
|
|
* bottom (Overdue / 7 Days / 30 Days).
|
|
* - When empty: centered "All caught up!" state above the stats.
|
|
* - Free tier collapses to the count-only layout.
|
|
*
|
|
* Glance restriction: no LazyColumn here because the list is bounded
|
|
* (max 5), so a plain Column is fine and lets us compose the stats row
|
|
* at the bottom without nesting a second scroll container.
|
|
*/
|
|
class HoneyDueLargeWidget : GlanceAppWidget() {
|
|
|
|
override val sizeMode: SizeMode = SizeMode.Single
|
|
|
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
|
val repo = WidgetDataRepository.get(context)
|
|
// 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)
|
|
|
|
provideContent {
|
|
LargeWidgetContent(tasks, stats, isPremium)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun LargeWidgetContent(
|
|
tasks: List<WidgetTaskDto>,
|
|
stats: WidgetStats,
|
|
isPremium: Boolean
|
|
) {
|
|
val openApp = actionRunCallback<OpenAppAction>()
|
|
|
|
Box(
|
|
modifier = GlanceModifier
|
|
.fillMaxSize()
|
|
.background(WidgetColors.BACKGROUND_PRIMARY)
|
|
.padding(14.dp)
|
|
.clickable(openApp)
|
|
) {
|
|
if (!isPremium) {
|
|
Column(
|
|
modifier = GlanceModifier.fillMaxSize(),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
TaskCountBlock(count = tasks.size, long = true)
|
|
}
|
|
} else {
|
|
Column(modifier = GlanceModifier.fillMaxSize()) {
|
|
WidgetHeader(taskCount = tasks.size, onTap = openApp)
|
|
|
|
Spacer(modifier = GlanceModifier.height(10.dp))
|
|
|
|
if (tasks.isEmpty()) {
|
|
Box(
|
|
modifier = GlanceModifier.defaultWeight().fillMaxWidth(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
EmptyState(compact = false, onTap = openApp)
|
|
}
|
|
} else {
|
|
val shown = tasks.take(MAX_TASKS)
|
|
shown.forEachIndexed { index, task ->
|
|
TaskRow(
|
|
task = task,
|
|
compact = false,
|
|
showResidence = true,
|
|
onTaskClick = openApp,
|
|
trailing = { CompleteButton(taskId = task.id) }
|
|
)
|
|
if (index < shown.lastIndex) {
|
|
Spacer(modifier = GlanceModifier.height(4.dp))
|
|
}
|
|
}
|
|
if (tasks.size > MAX_TASKS) {
|
|
Spacer(modifier = GlanceModifier.height(4.dp))
|
|
Text(
|
|
text = "+ ${tasks.size - MAX_TASKS} more",
|
|
style = TextStyle(
|
|
color = WidgetColors.textSecondary,
|
|
fontSize = 10.sp,
|
|
fontWeight = FontWeight.Medium
|
|
),
|
|
modifier = GlanceModifier.fillMaxWidth()
|
|
)
|
|
}
|
|
Spacer(modifier = GlanceModifier.defaultWeight())
|
|
}
|
|
|
|
Spacer(modifier = GlanceModifier.height(10.dp))
|
|
StatsRow(stats = stats)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private const val MAX_TASKS = 5
|
|
}
|
|
}
|
|
|
|
/** 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)
|
|
}
|
|
}
|