# 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`. ## 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` - Produces: `RefreshTaskAction : ActionCallback` (no parameters, `actionRunCallback()`) - Produces: `RefreshButton(isRefreshing: Boolean)` composable in `DootWidget.kt` - Modifies: `WidgetRoot(items: List, now: Instant)` → `WidgetRoot(items: List, 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 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 ``` Create `android/app/src/main/res/drawable/ic_refresh_loading.xml` (loading state — three dots, visually distinct from the idle icon): ```xml ``` - [ ] **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("item_id") val sourceKey = ActionParameters.Key("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(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().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, 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()), 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).