diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-12 09:52:49 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-12 09:52:49 +0000 |
| commit | 53e49d526d57961cfe826dc96044b59e2a8e8980 (patch) | |
| tree | 83ae178adaba8c2d853a50196e2c6489c9b4a036 /docs/superpowers | |
| parent | 5ed9f04225e69a0d1bdc23c9da5761b663c18271 (diff) | |
docs: add widget quick-add implementation plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-07-12-widget-quick-add.md | 562 |
1 files changed, 562 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-12-widget-quick-add.md b/docs/superpowers/plans/2026-07-12-widget-quick-add.md new file mode 100644 index 0000000..07a1552 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-widget-quick-add.md @@ -0,0 +1,562 @@ +# Widget Quick Add 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:** Let the user create a new doot task directly from the widget, via a "+" button that opens a small text-entry sheet. + +**Architecture:** A new bearer-token-protected JSON endpoint (`POST /api/widget/add`) mirrors the existing `/api/widget/complete`/`/api/widget/reschedule` handlers and creates an undated native task via the existing `store.CreateNativeTask`. The Android side follows the same "tap widget element → launch a full Activity with a Compose bottom sheet → do the work → close" pattern already used for reschedule, via a new `QuickAddActivity`. + +**Tech Stack:** Go (`internal/handlers`, `cmd/dashboard`), Kotlin/Jetpack Glance + Compose (Android widget). + +## Global Constraints + +- No in-widget text input — Glance/RemoteViews can't reliably support it. The button launches a full Activity, same pattern as the existing detail popup. +- The new task is created undated (no due date) — quick add is for capture, not scheduling; the user can reschedule it afterward via the existing clickable-date flow. +- The Android `addTask` request body must use proper JSON encoding (`kotlinx.serialization`), NOT manual string interpolation like `reschedule`/`complete` use — those two are safe because `id`/`source` are internal identifiers that can't contain `"` or `\`, but a task title is arbitrary user text and must be safely escaped. +- Server validates title non-empty independent of the client-side disabled-button check (defense at the trust boundary, not just the UI). + +--- + +### Task 1: Server — POST /api/widget/add endpoint + +**Files:** +- Modify: `internal/handlers/widget.go` +- Modify: `cmd/dashboard/main.go` +- Test: `internal/handlers/widget_test.go` + +**Interfaces:** +- Produces: `Handler.HandleWidgetAdd(w http.ResponseWriter, r *http.Request)` +- Consumes: `h.store.CreateNativeTask(task models.Task) error` (existing), `newID() string` (existing, same package, `internal/handlers/handlers.go:24`) + +- [ ] **Step 1: Write the failing tests** + +Add to `internal/handlers/widget_test.go`, after `TestHandleWidgetComplete_UnknownID_Returns404`: + +```go +// TestHandleWidgetAdd_CreatesTask proves the quick-add feature: POSTing a +// title to /api/widget/add creates an undated native task the same way the +// web UI's HandleUnifiedAdd does, but via the widget's bearer-token JSON +// API instead of a session-authenticated HTML form. +func TestHandleWidgetAdd_CreatesTask(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":"Buy milk"}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + tasks, err := s.GetUndatedNativeTasks() + if err != nil { + t.Fatalf("failed to read back tasks: %v", err) + } + found := false + for _, task := range tasks { + if task.Content == "Buy milk" { + found = true + } + } + if !found { + t.Error("expected a task with content 'Buy milk' to have been created") + } +} + +func TestHandleWidgetAdd_EmptyTitle_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":""}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandleWidgetAdd_WhitespaceOnlyTitle_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":" "}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/handlers/ -run TestHandleWidgetAdd -v` +Expected: compile error — `h.HandleWidgetAdd undefined (type *Handler has no field or method HandleWidgetAdd)`. + +- [ ] **Step 3: Implement `HandleWidgetAdd`** + +In `internal/handlers/widget.go`, add this type near the other request types (`widgetCompleteRequest`, `widgetRescheduleRequest`): + +```go +type widgetAddRequest struct { + Title string `json:"title"` +} +``` + +Add the handler after `HandleWidgetComplete` (at the end of the file, or directly after `HandleWidgetComplete`'s closing brace): + +```go +// HandleWidgetAdd creates a new undated native task from the widget's quick-add sheet. +func (h *Handler) HandleWidgetAdd(w http.ResponseWriter, r *http.Request) { + var req widgetAddRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + title := strings.TrimSpace(req.Title) + if title == "" { + http.Error(w, "title is required", http.StatusBadRequest) + return + } + + task := models.Task{ + ID: newID(), + Content: title, + Priority: 1, + } + if err := h.store.CreateNativeTask(task); err != nil { + http.Error(w, "failed to create task", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} +``` + +`strings` is already imported in this file (used by `WidgetAuthMiddleware`'s `strings.HasPrefix`/`strings.TrimPrefix`) — no new import needed. `models` is already imported too. + +- [ ] **Step 4: Register the route** + +In `cmd/dashboard/main.go`, find: + +```go + r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet) + r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete) + r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) +``` + +Replace with: + +```go + r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet) + r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete) + r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) + r.With(widgetAuth).Post("/api/widget/add", h.HandleWidgetAdd) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/handlers/ -run TestHandleWidgetAdd -v` +Expected: PASS — all three new tests. + +- [ ] **Step 6: Run the full Go test suite, build, and gofmt check** + +Run: `go build ./... && go test ./... 2>&1 | tail -20` +Expected: only the two pre-existing unrelated failures (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations`) plus the pre-existing `internal/models` vet failure — confirm no new failures. The `go build ./...` must succeed cleanly since `cmd/dashboard/main.go` was touched. + +Run: `gofmt -l internal/handlers/widget.go internal/handlers/widget_test.go cmd/dashboard/main.go` +Expected: no output. + +- [ ] **Step 7: Commit** + +```bash +git add internal/handlers/widget.go internal/handlers/widget_test.go cmd/dashboard/main.go +git commit -m "feat(widget): add POST /api/widget/add for quick-add" +``` + +--- + +### Task 2: Android — QuickAddActivity and widget button + +**Depends on:** Task 1 (for a meaningful on-device test; not a compile dependency). + +**Files:** +- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt` +- Create: `android/app/src/main/res/drawable/ic_add.xml` +- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` +- Create: `android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt` +- Modify: `android/app/src/main/AndroidManifest.xml` + +**Interfaces:** +- Produces: `WidgetRepository.addTask(title: String): Result<Unit>` +- Produces: `QuickAddButton()` composable in `DootWidget.kt` +- Produces: `QuickAddActivity` (new Activity, must be declared in the manifest to be launchable) + +- [ ] **Step 1: Add `addTask` to `WidgetRepository`** + +In `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt`, add the `@Serializable` import and a small request data class at file scope (after the `private val json = ...` line), then the new method at the end of the `WidgetRepository` class body (after `complete`): + +```kotlin +package org.terst.doot.widget.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +private data class WidgetAddRequest(val title: String) + +class WidgetRepository( + private val client: OkHttpClient, + private val serverUrl: String, + private val token: String +) { + /** Fetches /api/widget and returns the parsed response. Does NOT persist. */ + suspend fun fetchRaw(): Result<WidgetResponse> = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$serverUrl/api/widget") + .header("Authorization", "Bearer $token") + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + val body = checkNotNull(response.body?.string()) { "Empty body" } + json.decodeFromString<WidgetResponse>(body) + } + } + + /** Fetches and persists to DataStore. Call this from workers. */ + suspend fun fetchAndPersist(context: Context): Result<WidgetResponse> { + return fetchRaw().onSuccess { resp -> + context.dataStore.edit { prefs -> + prefs[Keys.ITEMS_JSON] = json.encodeToString(resp.items) + prefs[Keys.NOW] = resp.now + prefs[Keys.LAST_UPDATED] = System.currentTimeMillis() + } + } + } + + /** POSTs a due-date update to /api/widget/reschedule. */ + suspend fun reschedule(id: String, source: String, dateISO: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = """{"id":"$id","source":"$source","date":"$dateISO"}""" + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/reschedule") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + /** POSTs a task completion to /api/widget/complete. */ + suspend fun complete(context: Context, id: String, source: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = """{"id":"$id","source":"$source"}""" + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/complete") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + /** + * POSTs a new task title to /api/widget/add. Uses proper JSON encoding + * (not manual string interpolation like reschedule/complete above) because + * the title is arbitrary user text that could contain characters that + * break hand-built JSON -- id/source above are safe because they're + * internal identifiers, never user-typed free text. + */ + suspend fun addTask(title: String): Result<Unit> = + withContext(Dispatchers.IO) { + val body = json.encodeToString(WidgetAddRequest(title)) + .toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/api/widget/add") + .header("Authorization", "Bearer $token") + .post(body) + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } +} +``` + +- [ ] **Step 2: Add the `+` drawable** + +Create `android/app/src/main/res/drawable/ic_add.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="M12,5 L12,19 M5,12 L19,12" + android:strokeWidth="2" + android:strokeColor="#FFFFFF" + android:fillColor="@android:color/transparent" + android:strokeLineCap="round" /> +</vector> +``` + +- [ ] **Step 3: Add `QuickAddButton` and wire it into the TODAY row** + +In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, find the "TODAY" header `Row` (added by the refresh-button feature): + +```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) + } +``` + +Replace with (adds `QuickAddButton()` before the refresh button): + +```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() + ) + QuickAddButton() + Spacer(modifier = GlanceModifier.width(4.dp)) + RefreshButton(isRefreshing) + } +``` + +Add the new composable directly after `RefreshButton` (find it — it was added by the refresh-button feature, right after `AllDayRow`): + +```kotlin +@Composable +fun QuickAddButton() { + val context = LocalContext.current + val intent = Intent(context, QuickAddActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + Box( + modifier = GlanceModifier + .size(24.dp) + .clickable(actionStartActivity(intent)), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider(org.terst.doot.widget.R.drawable.ic_add), + contentDescription = "Add task", + colorFilter = ColorFilter.tint(ColorProvider(Color(0x99FFFFFF))), + modifier = GlanceModifier.size(14.dp) + ) + } +} +``` + +No new imports needed — `Intent`, `LocalContext`, `actionStartActivity` are all already imported/used in this file (see `TaskRow`'s `detailIntent`). + +- [ ] **Step 4: Create `QuickAddActivity.kt`** + +Create `android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt`: + +```kotlin +package org.terst.doot.widget.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.appwidget.updateAll +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +class QuickAddActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + QuickAddSheet( + onAdd = { title -> + lifecycleScope.launch { + val prefs = this@QuickAddActivity.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@launch + val token = prefs[Keys.TOKEN] ?: return@launch + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.addTask(title).onSuccess { + repo.fetchAndPersist(this@QuickAddActivity) + DootWidget().updateAll(this@QuickAddActivity) + finish() + } + } + }, + onDismiss = ::finish + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickAddSheet( + onAdd: (String) -> Unit, + onDismiss: () -> Unit +) { + var title by remember { mutableStateOf("") } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Text( + text = "Quick Add", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + placeholder = { Text("Task title") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color.White.copy(alpha = 0.3f) + ) + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { onAdd(title.trim()) }, + enabled = title.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) + ) { + Text("Add", fontSize = 15.sp) + } + Spacer(Modifier.height(20.dp)) + } + } +} +``` + +- [ ] **Step 5: Declare `QuickAddActivity` in the manifest** + +In `android/app/src/main/AndroidManifest.xml`, find the existing `<activity android:name=".widget.ui.TaskDetailActivity" ...>` declaration and add a sibling entry for `QuickAddActivity` immediately after it, copying its exact attributes (theme, exported state, etc.) — read the existing `TaskDetailActivity` entry first to match its attributes exactly, then add: + +```xml + <activity + android:name=".widget.ui.QuickAddActivity" + android:exported="false" + android:theme="@style/Theme.Doot.Transparent" /> +``` + +(Use whatever exact `android:theme` and other attributes `TaskDetailActivity`'s entry uses — copy them verbatim rather than guessing, since this plan was written without reading the manifest directly. If `TaskDetailActivity`'s entry has additional attributes not shown above, e.g. `android:launchMode` or `android:windowSoftInputMode`, include those too — a text-entry sheet in particular likely wants `android:windowSoftInputMode="adjustResize"` if `TaskDetailActivity` doesn't already set it, so the keyboard doesn't cover the input field.) + +- [ ] **Step 6: Build and confirm no compile errors** + +```bash +cd android && ./gradlew assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 7: Commit** + +```bash +git add android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt \ + android/app/src/main/res/drawable/ic_add.xml \ + android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt \ + android/app/src/main/java/org/terst/doot/widget/ui/QuickAddActivity.kt \ + android/app/src/main/AndroidManifest.xml +git commit -m "feat(widget): add quick-add button and entry sheet" +``` + +Manual on-device verification (release APK build, deploy, visual + interaction check, including keyboard behavior) is deferred to the controller. + +--- + +## Out of scope + +Recurrence display gets its own spec (the fifth and last feature). |
