diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-07-12-widget-refresh-button.md | 362 |
1 files changed, 362 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-12-widget-refresh-button.md b/docs/superpowers/plans/2026-07-12-widget-refresh-button.md new file mode 100644 index 0000000..9cd5e59 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-refresh-button.md @@ -0,0 +1,362 @@ +# Widget Refresh Button Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a tappable refresh icon to the doot Android widget that immediately triggers a fetch, with a static "loading" icon shown while it's in flight. + +**Architecture:** A new `RefreshTaskAction` (Glance `ActionCallback`) sets a `IS_REFRESHING` DataStore flag and redraws the widget synchronously (so the icon flips instantly), then enqueues the existing `RefreshWorker.runOnce()`. `RefreshWorker` is restructured so that, regardless of outcome (success, failure, or the early "no url/token configured" return), it always clears the flag and redraws the widget once at the end — so the icon never gets stuck in the loading state. + +**Tech Stack:** Kotlin, Jetpack Glance (`GlanceAppWidget`, `RemoteViews`-backed), AndroidX `WorkManager`, AndroidX `DataStore<Preferences>`. + +## Global Constraints + +- RemoteViews cannot animate a rotating icon — the "spinner" is a static icon swap (`ic_refresh` ↔ `ic_refresh_loading`), confirmed acceptable in the design spec. +- Follow the existing fire-and-forget pattern used by `CompleteTaskAction`/`CompleteWorker`: actions enqueue `WorkManager` work and return immediately; workers call `DootWidget().updateAll()` when done. +- No new automated tests: this is Glance composition + `WorkManager` wiring, the same category of code as the rest of `DootWidget.kt` and `*Worker.kt`, none of which have unit tests today. Verification is a manual build-install-tap cycle, described exactly in Task 1 Step 8. + +--- + +### Task 1: Refresh button end-to-end (data flag, drawables, action, worker, UI) + +**Why one task:** None of these pieces are independently testable in isolation — the action needs the flag and drawables to mean anything, the worker's flag-clearing has no observable effect without the UI reading the flag, and the UI has nothing to tap without the action. This is a single vertical slice with one real deliverable: tap the icon, see it load, see it revert. + +**Files:** +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt` +- Create: `android/app/src/main/res/drawable/ic_refresh.xml` +- Create: `android/app/src/main/res/drawable/ic_refresh_loading.xml` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` + +**Interfaces:** +- Produces: `Keys.IS_REFRESHING: Preferences.Key<Boolean>` +- Produces: `RefreshTaskAction : ActionCallback` (no parameters, `actionRunCallback<RefreshTaskAction>()`) +- Produces: `RefreshButton(isRefreshing: Boolean)` composable in `DootWidget.kt` +- Modifies: `WidgetRoot(items: List<WidgetItem>, now: Instant)` → `WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean)` — the sole caller, `DootWidget.provideGlance()`, is updated in the same step. + +- [ ] **Step 1: Add the `IS_REFRESHING` DataStore key** + +Edit `android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt`. Add the `booleanPreferencesKey` import and the new key: + +```kotlin +package org.terst.doot.widget.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore + +val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "doot_widget") + +object Keys { + val SERVER_URL = stringPreferencesKey("server_url") + val TOKEN = stringPreferencesKey("token") + val ITEMS_JSON = stringPreferencesKey("items_json") + val NOW = stringPreferencesKey("now") + val LAST_UPDATED = longPreferencesKey("last_updated") + val IS_REFRESHING = booleanPreferencesKey("is_refreshing") +} +``` + +- [ ] **Step 2: Add the two drawables** + +Create `android/app/src/main/res/drawable/ic_refresh.xml` (idle state — circular arrow, same 24dp/stroke conventions as the existing `ic_checkbox_empty.xml`): + +```xml +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M4,12 A8,8 0 1,1 7,18" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> + <path + android:pathData="M4,12 L4,7 L9,7" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> +``` + +Create `android/app/src/main/res/drawable/ic_refresh_loading.xml` (loading state — three dots, visually distinct from the idle icon): + +```xml +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M5,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> + <path + android:pathData="M12,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> + <path + android:pathData="M19,12 m-2,0 a2,2 0 1,1 4,0 a2,2 0 1,1 -4,0" + android:fillColor="#FFFFFF" /> +</vector> +``` + +- [ ] **Step 3: Add `RefreshTaskAction` to `Actions.kt`** + +Replace the full contents of `android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt`: + +```kotlin +package org.terst.doot.widget.ui + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.updateAll +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.dataStore +import org.terst.doot.widget.work.CompleteWorker +import org.terst.doot.widget.work.RefreshWorker + +class CompleteTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val id = parameters[idKey] ?: return + val source = parameters[sourceKey] ?: return + CompleteWorker.enqueue(context, id, source) + } + + companion object { + val idKey = ActionParameters.Key<String>("item_id") + val sourceKey = ActionParameters.Key<String>("item_source") + } +} + +// Sets IS_REFRESHING synchronously (before the network round trip) so the +// widget's icon flips to the loading state on the spot -- RefreshWorker +// clears the flag when it finishes, regardless of outcome, so the icon +// never gets stuck. +class RefreshTaskAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + context.dataStore.edit { it[Keys.IS_REFRESHING] = true } + DootWidget().updateAll(context) + RefreshWorker.runOnce(context) + } +} +``` + +- [ ] **Step 4: Restructure `RefreshWorker.doWork()` to always clear the flag** + +Replace the full contents of `android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt`: + +```kotlin +package org.terst.doot.widget.work + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.glance.appwidget.updateAll +import androidx.work.* +import kotlinx.coroutines.flow.first +import org.terst.doot.widget.ui.DootWidget +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore +import java.util.concurrent.TimeUnit + +class RefreshWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val result = performRefresh() + applicationContext.dataStore.edit { it[Keys.IS_REFRESHING] = false } + DootWidget().updateAll(applicationContext) + return result + } + + private suspend fun performRefresh(): Result { + val prefs = applicationContext.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.takeIf { it.isNotBlank() } ?: return Result.failure() + val token = prefs[Keys.TOKEN]?.takeIf { it.isNotBlank() } ?: return Result.failure() + + val repo = WidgetRepository(okhttp3.OkHttpClient(), url, token) + return repo.fetchAndPersist(applicationContext).fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() } + ) + } + + companion object { + const val WORK_NAME = "doot_widget_refresh" + + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder<RefreshWorker>(15, TimeUnit.MINUTES) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + + fun runOnce(context: Context) { + val request = OneTimeWorkRequestBuilder<RefreshWorker>().build() + WorkManager.getInstance(context).enqueue(request) + } + } +} +``` + +Note: this drops the old success-branch's own `DootWidget().updateAll()` call, since `doWork()` now always calls it exactly once after `performRefresh()` returns, on every path (success, retry, or the early-return failures from missing url/token). + +- [ ] **Step 5: Wire `isRefreshing` through `DootWidget.provideGlance()` and `WidgetRoot`** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, change `provideGlance`: + +```kotlin + override suspend fun provideGlance(context: Context, id: GlanceId) { + val prefs = context.dataStore.data.first() + val items = parseItems(prefs) + val now = prefs[Keys.NOW]?.let { runCatching { Instant.parse(it) }.getOrNull() } + ?: Instant.now() + val isRefreshing = prefs[Keys.IS_REFRESHING] ?: false + + provideContent { + WidgetRoot(items, now, isRefreshing) + } + } +``` + +Change the `WidgetRoot` signature and its "TODAY" header `Row` (this is the top of the function body — the `allDayEvents`/`rest`/etc. computation below is unchanged): + +```kotlin +@Composable +fun WidgetRoot(items: List<WidgetItem>, now: Instant, isRefreshing: Boolean) { +``` + +Then find this block inside `WidgetRoot`: + +```kotlin + Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp)) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + ) + } +``` + +Replace it with: + +```kotlin + Row( + modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "TODAY", + style = TextStyle( + color = ColorProvider(Color(0x66FFFFFF)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ), + modifier = GlanceModifier.defaultWeight() + ) + RefreshButton(isRefreshing) + } +``` + +- [ ] **Step 6: Add the `RefreshButton` composable** + +In the same file, add this new composable directly after the existing `AllDayRow` composable (so it sits near the other small icon/row composables): + +```kotlin +@Composable +fun RefreshButton(isRefreshing: Boolean) { + Box( + modifier = GlanceModifier + .size(24.dp) + .clickable(actionRunCallback<RefreshTaskAction>()), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider( + if (isRefreshing) org.terst.doot.widget.R.drawable.ic_refresh_loading + else org.terst.doot.widget.R.drawable.ic_refresh + ), + contentDescription = if (isRefreshing) "Refreshing" else "Refresh", + colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))), + modifier = GlanceModifier.size(14.dp) + ) + } +} +``` + +No new imports needed — `actionRunCallback`, `Box`, `Image`, `ImageProvider`, `ColorFilter`, `ColorProvider`, `GlanceModifier`, `Color`, `Alignment` are all already imported in this file (used by `TaskRow`'s checkbox, which this mirrors). + +- [ ] **Step 7: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. If it fails, the error output will name the file/line — common mistakes here are a missing import or a typo in the drawable resource names (`R.drawable.ic_refresh` / `R.drawable.ic_refresh_loading` must exactly match the two file names from Step 2, minus `.xml`). + +- [ ] **Step 8: Manual verification on device** + +Build and install the release APK the same way prior widget fixes in this session were verified: + +```bash +cd android && ./gradlew assembleRelease +cp app/build/outputs/apk/release/app-release.apk /site/static.terst.org/public/files/doot-widget.apk +chown www-data:www-data /site/static.terst.org/public/files/doot-widget.apk +``` + +Then on the device: reinstall the widget's APK, open the widget, and confirm: +1. The refresh icon is visible in the top-right of the "TODAY" row. +2. Tapping it immediately swaps the icon to the loading (three-dot) glyph — no delay, no network wait. +3. Within a few seconds (once the fetch completes), the icon swaps back to the idle refresh icon. +4. The widget's item list reflects freshly-fetched data (e.g. toggle airplane mode off/on between taps if you want to force a visible content change, or just confirm no crash and the icon cycle completes). +5. Turn off wifi/data entirely, tap refresh: confirm the icon still reverts to idle after `WorkManager`'s constraint check fails to satisfy `NetworkType.CONNECTED` and the retry backs off — it must not get stuck on the loading icon indefinitely. (`RefreshWorker` has no explicit network constraint on `runOnce()`'s one-time request, only `schedule()`'s periodic request does — so this checks the underlying `fetchAndPersist()` call's own failure path via `Result.retry()`, not a WorkManager constraint block.) + +- [ ] **Step 9: Commit** + +```bash +git add android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt \ + android/app/src/main/res/drawable/ic_refresh.xml \ + android/app/src/main/res/drawable/ic_refresh_loading.xml \ + android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt \ + android/app/src/main/java/org/terst/doot/widget/work/RefreshWorker.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +git commit -m "feat(widget): add manual refresh button with loading-state icon" +``` + +--- + +## Out of scope + +The other four widget features (quick add, clickable-date reschedule, recurrence display, overdue badge) — each gets its own spec and plan, per the design doc's sequencing (refresh → overdue badge → clickable reschedule → quick add → recurrence). |
