summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-07-12-widget-quick-add.md
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-12 09:52:49 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-12 09:52:49 +0000
commit53e49d526d57961cfe826dc96044b59e2a8e8980 (patch)
tree83ae178adaba8c2d853a50196e2c6489c9b4a036 /docs/superpowers/plans/2026-07-12-widget-quick-add.md
parent5ed9f04225e69a0d1bdc23c9da5761b663c18271 (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/plans/2026-07-12-widget-quick-add.md')
-rw-r--r--docs/superpowers/plans/2026-07-12-widget-quick-add.md562
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).