summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-07-12-widget-refresh-button.md
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-12 10:21:14 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-12 10:21:14 +0000
commitc0454371a044ef4f20bfe44ee9f183fb336656dd (patch)
tree5c334116ba9d82049076c784d5d852b6a8238efa /docs/superpowers/plans/2026-07-12-widget-refresh-button.md
parent72d9ee7bd547515afe6d09b35f6ef37bbc0baa78 (diff)
docs: add widget refresh button implementation plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD
Diffstat (limited to 'docs/superpowers/plans/2026-07-12-widget-refresh-button.md')
-rw-r--r--docs/superpowers/plans/2026-07-12-widget-refresh-button.md362
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).