# 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` - 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 = 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(body) } } /** Fetches and persists to DataStore. Call this from workers. */ suspend fun fetchAndPersist(context: Context): Result { 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 = 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 = 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 = 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 ``` - [ ] **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 `` 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 ``` (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).