diff --git a/.claude/skills/debug-build/SKILL.md b/.claude/skills/debug-build/SKILL.md index eaebfe9..7bf2232 100644 --- a/.claude/skills/debug-build/SKILL.md +++ b/.claude/skills/debug-build/SKILL.md @@ -21,14 +21,32 @@ Build a debug APK and sideload it onto the connected Android device. Always recommend wireless debugging over USB when a device connection is needed. -To connect wirelessly: -1. On the phone: Settings → Developer Options → Wireless debugging → Enable it. -2. Tap "Pair device with pairing code" to get the IP, port, and pairing code. -3. Run `adb pair :` and enter the pairing code. -4. Then run `adb connect :` (the port shown on the Wireless debugging main screen, NOT the pairing port). -5. Verify with `adb devices`. - -If the device was previously paired, `adb connect :` alone should reconnect (no re-pairing needed), as long as both devices are on the same network. +**Ask about pairing status FIRST — don't assume either way.** Before giving any +connect instructions, have the user open **Settings → Developer Options → +Wireless debugging** on the phone. That screen shows, top to bottom: a +**"Use wireless debugging"** toggle, **"Device name"**, **"IP address and +port"** (e.g. `192.168.1.5:42173`), then **"Pair device with QR code"**, +**"Pair device with pairing code"**, and finally a **"Paired devices"** section +at the bottom listing this machine by its hostname (e.g. +`soham@soham-Dell-...`), with **"Currently connected"** underneath when a live +connection exists. + +Ask in ONE message for both: (a) whether this machine is listed under **"Paired +devices"**, and (b) the **"IP address and port"** — the user may answer with the +full `:` or just the port, whichever is easier; the port rotates every +session while the IP usually doesn't, so a bare port is a perfectly normal +answer. Pair it with the last known IP from memory, and only ask for the IP +explicitly if there's no saved one or the connect fails. Then branch: + +**Already paired** — no re-pairing needed: +1. Run `adb connect :` with the "IP address and port" value (the port changes every session; the IP usually doesn't). +2. Verify with `adb devices`. + +**Not paired** (or the connect above fails): +1. On the phone, tap **"Pair device with pairing code"** — it opens a dialog with its own IP, a *pairing* port, and a 6-digit code. +2. Run `adb pair :` and enter the pairing code. +3. Then run `adb connect :` using the **"IP address and port"** value from the main screen — NOT the pairing port (they differ). +4. Verify with `adb devices`. When no device is connected, check your memory for the last known IP. Try `adb connect :` with the user-provided port. If the connection fails, ask the user to verify the IP hasn't changed. When a connection succeeds, save the IP to memory for next time. diff --git a/.claude/skills/manual-test/SKILL.md b/.claude/skills/manual-test/SKILL.md index 94aa82c..99b144e 100644 --- a/.claude/skills/manual-test/SKILL.md +++ b/.claude/skills/manual-test/SKILL.md @@ -36,6 +36,7 @@ Generate a checklist of manual tests the user should run for recent changes. - **Each section must be self-contained, with its OWN setup.** Tests within a section can build on each other, but a new section/subheading must never assume state from a previous section — including the shared "Setup" block, which only bootstraps the FIRST section. Every section that needs a precondition MUST carry its own setup steps (a per-section "Setup" sub-block, or fold the steps into the section) that create/guarantee that precondition from scratch. Walk each section as if the user jumped straight to it: if it needs a **free Today's 5 slot**, a **non-leaf task to drill into**, an **existing dependency**, an **unpinned leaf**, etc., spell out how to reach that state right there. Do not make the user hunt through their existing data for a task in the right shape, and do not make them undo leftover state from an earlier section (e.g. "you had to unpin an already-pinned task") — the section's own setup owns that. - **Every test case must name the screen/tab** where the action starts (e.g. "On Today's 5 tab, tap..." not just "Tap..."). Never assume the user knows which screen you mean from context. - **Name every control by its exact label/icon — never a pronoun.** Do not write "tap it", "select that", or "confirm it" — even when you named the control earlier in the same line. Re-state the literal label or icon the user must tap (e.g. "tap **'Remove from Today's 5'**", not "tap it"). A pronoun forces the user to guess which of several controls you mean. + - **Never name an icon by its code identifier alone — describe it the way the user sees it.** The tester is looking at the screen, not the source: `open_in_new`, `Icons.today`, `archiveIcon` mean nothing to them. Always lead with a **plain-language description of the icon's appearance and/or purpose**, and append the code name in parentheses only when it adds clarity for a control that's otherwise hard to pin down. Write "the **open-in-new-window icon** (a small box with an arrow leaving it)" or "the **'Go to task' arrow icon**" — NOT "the `open_in_new` icon". Same for any other identifier that only exists in code (widget class names, constants, callback names): if it isn't visible on screen, it can't be the primary way you point at something. - **State-dependent steps must guarantee the precondition, not assume it.** When a step needs a task to be *unpinned* (or pinned, leaf, empty, etc.), do not name a specific task that an earlier step or the user's existing data may have already moved out of that state. Either use a dedicated fixture created in Setup for exactly this state, or phrase it state-relative — e.g. "pick **any task not currently in Today's 5**" instead of "tap into `Bravo leaf`". Trace cumulative state through all prior steps before naming a task in a precondition. - **One test = one flow.** Each test case should verify a single behavior. Don't combine multiple if-else outcomes into one test (e.g. "should show X if Y, otherwise Z"). Split into separate tests with clear preconditions. - **Account for state consumed by prior tests.** If test 1 uses a task with a deadline and removes it, test 2 can't reuse that same task for a "keep deadline" test. Either specify separate tasks in the setup, or tell the user to undo/re-add state between tests. **This includes structural state:** if a test relies on a task being a *leaf* (childless), *non-leaf*, *pinned*, *empty*, etc., do NOT reuse a task that an earlier test mutates out of that state (e.g. a "leaf" test must not use a task that an earlier test added a child to). Use a **dedicated fixture task per structural precondition**, named so it's obvious (e.g. "Solo leaf task — never gets children"), and trace the cumulative state through every prior step before asserting a precondition. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 81f6f52..7b5f3fe 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -1,6 +1,6 @@ # Test Coverage Inventory -Last updated: 2026-07-18 +Last updated: 2026-07-25 ## Summary @@ -27,7 +27,7 @@ Note: `database_helper_test.dart` also covers **deadline auto-pin suppression** - **test/providers/auth_provider_test.dart** (6 tests) — `setSyncStatus` updates/notifications, `isConfigured`, initial state. ### Screens (partial) -- **test/screens/todays_five_screen_test.dart** (~64 tests) — Empty state, task rendering (max 5, leaf-only, blocked excluded), bottom sheet options, swap/navigate buttons, progress bar, priority icon, refresh dialog, DB state restore, SharedPrefs→DB migration, sync reload (task list replacement, completed status from remote), **deadline auto-pin override** (unpinned deadline stays unpinned on reload, generate doesn't re-pin suppressed tasks, suppression cleared on re-pin, re-pin persists across reload), **first generation auto-pin** (deadline task auto-pinned, `on` deadline auto-pinned on day, suppressed not pinned, max 5 respected, midnight rollover triggers auto-pin, reroll/New set does NOT auto-pin), **scheduled-today icon** (shown/hidden based on schedule, combined with deadline icon), **backfill params** (restore/refreshSnapshots/pinned-descendant backfill passes schedule+deadline+norm to `pickWeightedN`, lazy `_fetchSelectionContext` in `refreshSnapshots`, `_replaceIfNoLongerLeaf` on uncomplete), **reserved slots** (single scheduled source guaranteed slot, two sources each get a slot, ≥1 general-pool slot always preserved, cap at 4 reserved when 5+ sources), **manual model** (empty even when leaf tasks exist but no saved state — no auto-population), **implicit-pin remove** (Remove tile + card X both show a confirm dialog; Cancel keeps, Confirm removes from Today's 5 while task survives in DB), **Add to Today's 5 FAB sheet** (FAB opens sheet with "Create new task" / "Pick existing task"; "Pick existing" opens the browse-mode `TaskPickerDialog`; selecting a task pins it into Today's 5 and persists taskIds+pinnedIds; picker excludes tasks already in Today's 5), **+ FAB visibility at max pins** (FAB hidden once Today's 5 holds 5 tasks, shown below the limit), **midnight rollover** (debug night-icon rollover clears yesterday's lingering tasks to the empty "Nothing pinned yet" state — regression for the empty-load path not resetting `_todaysTasks`), **deadline-today auto-pin** (`feature/deadline-today-autopin` — manual-model exception that force-pins leaf tasks whose deadline is EXACTLY today into Today's 5 on every load): auto-pins a leaf due today with no saved state and persists it; overdue (yesterday) deadline NOT auto-pinned; suppressed task not re-pinned on reconcile; removing one deadline task still auto-pins a different one; removing an auto-pinned task records a suppression; **merges deadline auto-pin with an existing manual pin** (both appear — merge, not replace); **auto-pins every deadline task even beyond the maxPins cap** (the merge loop has NO cap — documents actual behavior, see finding below); **does NOT auto-pin a completed deadline-today task** (`getDeadlinePinLeafIds` excludes completed leaves); **re-pinning a suppressed deadline task clears its suppression** (exercises `_pinTaskInTodaysFive` → `unsuppressDeadlineAutoPin` via the Pick-existing flow); **suppression persists across a reload after removal** (remove via X → rebuild screen → still gone); **refreshSnapshots auto-pins a task that became due today** (deadline set to today while app open pins via the in-place refresh path, no full reload/restart); **refreshSnapshots does NOT re-pin a suppressed deadline task** (suppression respected on the refresh path too), **duplicate-task suggestion → Pin instead (create-new flow)** (`feature/duplicate-task-suggestion`: in the FAB → "Create new task" dialog, typing a name matching an existing task and tapping the "Pin instead" suggestion pins the EXISTING task into Today's 5 (`_pinTaskInTodaysFive`) with no duplicate; when the match is already in Today's 5, tapping it shows the "already in Today's 5" snackbar and does not re-add a duplicate slot; a same-named NON-leaf parent is NOT offered as a "Pin instead" suggestion — the match pool is leaves only (`getAllLeafTasks`), so typing a non-leaf's name surfaces no "Did you mean" panel). Note: first-gen/reroll auto-pin and the prior deadline auto-pin-override tests describe the older automated model and may no longer apply under the manual model. **Sectioned Today tab (`feature/todays-five-suggestions`)** — the Today body is now a constant-structure Column (flexible Today's-5 area on top + bottom-pinned "Suggested"/"Also done today"). **Opt-in Suggested section**: hidden behind "Show suggestions" by default; expanding surfaces weighted `pickWeightedN` picks; excludes tasks already in Today's 5; accepting pins into Today's 5; dismissing ("Not now") backfills a fresh pick; **unpinning after a full Today's 5 re-populates suggestions inline** (regression — the removal path (`_confirmRemoveFromTodaysFive`) now calls `_refreshSuggestions()`+`_loadTaskPaths()`+`setState`, so the re-shown section backfills at once instead of showing stale "No suggestions right now." until a tab switch, since `_suggestions` had been cleared by the `>= maxPins` guard while full); **a full Today's 5 (5 pins) hides the entire section** (baseline — the `showSuggestions = < maxPins` gate, complement to the unpin regression); **Hide collapses and clears the picks; re-expanding recomputes** (mechanism — `_toggleSuggestions` free-on-collapse/compute-on-expand); **a dismissed suggestion is suppressed for the session and does not reappear** (edge — lone candidate → dismiss exhausts the pool + records `_dismissedSuggestionIds`, section falls to empty text; distinct from the count-only backfill test); **a blocked leaf is not offered** (edge — `_refreshSuggestions` filters `getBlockedChildIds`). **Always-on "Also done today"**: surfaces even with nothing pinned (empty-state test), and **renders every done chip regardless of count** (regression — the fit-one-row `_chipsOverflow`/`_otherDoneExpanded` collapse was replaced by a single sideways-scrolling strip that builds every chip; expand/collapse chevrons gone). +- **test/screens/todays_five_screen_test.dart** (~68 tests) — Empty state, task rendering (max 5, leaf-only, blocked excluded), bottom sheet options, swap/navigate buttons, progress bar, priority icon, refresh dialog, DB state restore, SharedPrefs→DB migration, sync reload (task list replacement, completed status from remote), **deadline auto-pin override** (unpinned deadline stays unpinned on reload, generate doesn't re-pin suppressed tasks, suppression cleared on re-pin, re-pin persists across reload), **first generation auto-pin** (deadline task auto-pinned, `on` deadline auto-pinned on day, suppressed not pinned, max 5 respected, midnight rollover triggers auto-pin, reroll/New set does NOT auto-pin), **scheduled-today icon** (shown/hidden based on schedule, combined with deadline icon), **backfill params** (restore/refreshSnapshots/pinned-descendant backfill passes schedule+deadline+norm to `pickWeightedN`, lazy `_fetchSelectionContext` in `refreshSnapshots`, `_replaceIfNoLongerLeaf` on uncomplete), **reserved slots** (single scheduled source guaranteed slot, two sources each get a slot, ≥1 general-pool slot always preserved, cap at 4 reserved when 5+ sources), **manual model** (empty even when leaf tasks exist but no saved state — no auto-population), **implicit-pin remove** (Remove tile + card X both show a confirm dialog; Cancel keeps, Confirm removes from Today's 5 while task survives in DB), **Add to Today's 5 FAB sheet** (FAB opens sheet with "Create new task" / "Pick existing task"; "Pick existing" opens the browse-mode `TaskPickerDialog`; selecting a task pins it into Today's 5 and persists taskIds+pinnedIds; picker excludes tasks already in Today's 5), **+ FAB visibility at max pins** (FAB hidden once Today's 5 holds 5 tasks, shown below the limit), **midnight rollover** (debug night-icon rollover clears yesterday's lingering tasks to the empty "Nothing pinned yet" state — regression for the empty-load path not resetting `_todaysTasks`), **deadline-today auto-pin** (`feature/deadline-today-autopin` — manual-model exception that force-pins leaf tasks whose deadline is EXACTLY today into Today's 5 on every load): auto-pins a leaf due today with no saved state and persists it; overdue (yesterday) deadline NOT auto-pinned; suppressed task not re-pinned on reconcile; removing one deadline task still auto-pins a different one; removing an auto-pinned task records a suppression; **merges deadline auto-pin with an existing manual pin** (both appear — merge, not replace); **auto-pins every deadline task even beyond the maxPins cap** (the merge loop has NO cap — documents actual behavior, see finding below); **does NOT auto-pin a completed deadline-today task** (`getDeadlinePinLeafIds` excludes completed leaves); **re-pinning a suppressed deadline task clears its suppression** (exercises `_pinTaskInTodaysFive` → `unsuppressDeadlineAutoPin` via the Pick-existing flow); **suppression persists across a reload after removal** (remove via X → rebuild screen → still gone); **refreshSnapshots auto-pins a task that became due today** (deadline set to today while app open pins via the in-place refresh path, no full reload/restart); **refreshSnapshots does NOT re-pin a suppressed deadline task** (suppression respected on the refresh path too), **duplicate-task suggestion → Pin instead (create-new flow)** (`feature/duplicate-task-suggestion`: in the FAB → "Create new task" dialog, typing a name matching an existing task and tapping the "Pin instead" suggestion pins the EXISTING task into Today's 5 (`_pinTaskInTodaysFive`) with no duplicate; when the match is already in Today's 5, tapping it shows the "already in Today's 5" snackbar and does not re-add a duplicate slot; a same-named NON-leaf parent is NOT offered as a "Pin instead" suggestion — the match pool is leaves only (`getAllLeafTasks`), so typing a non-leaf's name surfaces no "Did you mean" panel). **Pinned task options sheet "Go to task" (`feature/pinned-task-navigate-option`)**: tapping a pinned card opens the options sheet, which shows "Go to task" (gated on `onNavigateToTask != null`) and tapping it navigates and closes the sheet; the tile is absent when `onNavigateToTask` is null (edge case — gating regression); all other options (Done today/Done for good!/In progress/Remove from Today's 5) still coexist alongside it (baseline — inserted, not swapped in); and on a short/narrow window (360x560) the now-`SingleChildScrollView`-wrapped sheet body lays out and scrolls to the last option ("Remove from Today's 5") with no `RenderFlex overflowed` exception (regression — the prior fixed-height `Padding`+`Column` overflowed by ~47px once the 5th two-line option was added). Note: first-gen/reroll auto-pin and the prior deadline auto-pin-override tests describe the older automated model and may no longer apply under the manual model. **Sectioned Today tab (`feature/todays-five-suggestions`)** — the Today body is now a constant-structure Column (flexible Today's-5 area on top + bottom-pinned "Suggested"/"Also done today"). **Opt-in Suggested section**: hidden behind "Show suggestions" by default; expanding surfaces weighted `pickWeightedN` picks; excludes tasks already in Today's 5; accepting pins into Today's 5; dismissing ("Not now") backfills a fresh pick; **unpinning after a full Today's 5 re-populates suggestions inline** (regression — the removal path (`_confirmRemoveFromTodaysFive`) now calls `_refreshSuggestions()`+`_loadTaskPaths()`+`setState`, so the re-shown section backfills at once instead of showing stale "No suggestions right now." until a tab switch, since `_suggestions` had been cleared by the `>= maxPins` guard while full); **a full Today's 5 (5 pins) hides the entire section** (baseline — the `showSuggestions = < maxPins` gate, complement to the unpin regression); **Hide collapses and clears the picks; re-expanding recomputes** (mechanism — `_toggleSuggestions` free-on-collapse/compute-on-expand); **a dismissed suggestion is suppressed for the session and does not reappear** (edge — lone candidate → dismiss exhausts the pool + records `_dismissedSuggestionIds`, section falls to empty text; distinct from the count-only backfill test); **a blocked leaf is not offered** (edge — `_refreshSuggestions` filters `getBlockedChildIds`). **Always-on "Also done today"**: surfaces even with nothing pinned (empty-state test), and **renders every done chip regardless of count** (regression — the fit-one-row `_chipsOverflow`/`_otherDoneExpanded` collapse was replaced by a single sideways-scrolling strip that builds every chip; expand/collapse chevrons gone). - **test/screens/completed_tasks_screen_test.dart** (~15 tests) — Empty state, completed/skipped display, today/older labels, restore/delete buttons, parent context, AppBar title. - **test/screens/starred_screen_test.dart** (44 tests) — Empty state, card display (single/multiple/subtitle/in-progress/tree preview/badge count), long-press navigation, drag handle, **tap expanded view** (dialog open, lazy-expand direct children on tap, collapse hides grandchildren, "No sub-tasks" leaf, star icon confirmation dialog, cancel keeps starred, confirm unstar + undo snackbar, undo re-stars, long-press tree node navigates, tap leaf navigates directly, dismiss by tapping outside, **child count badge**, **no chevron on leaves**, **header navigate icon**, **leaf navigate icon**), **unified tap** (leaf and non-leaf cards both open the expanded dialog, no navigate), **Add subtask FAB** (FAB opens AddTaskDialog, creates a subtask persisted under the starred card, "Add multiple" brain-dump creates several subtasks parented to the card — regression for "add multiple did nothing", cancel adds nothing, pinned starred task warns but does NOT transfer its pin to a child — manual model: the now-non-leaf parent drops out of Today's 5 instead, unpinned task shows no warning; **"Pin for today" toggle** — the subtask dialog shows a "Pin for today" chip (no Inbox chip); toggling it on pins the new subtask into Today's 5; the chip is **hidden** when the starred parent is itself pinned (drops out anyway) or when Today's 5 is already full; **cross-add refresh regression** — with 4 pinned fillers (one free slot) the toggle shows on the first add, and once pinning the new subtask fills Today's 5 to 5 the toggle is gone on the SECOND add within the same dialog, guarding `_reloadAfterAdd`→`_loadTodays5PinState` + the `onTodaysFiveChanged` count update against a stale `_todays5PinnedCount`). **screen-level Add task FAB** (FAB shown on the empty state, opens AddTaskDialog with the Inbox toggle; creates an auto-starred root task that appears in `getStarredTasks` with no parent). **starOrder preservation** (explicit starOrder on re-star, appends to end without), **grandchild DRY-rule styling (I-53)** (a high-priority grandchild is accent-tinted in the tree-preview card — not just the expanded dialog — via the shared `childTextStyle`; a blocked grandchild is dimmed (alpha 100) in the card, which required adding shown-grandchild ids to the `_blockedInfo` fetch that previously covered only direct children). **duplicate-task suggestion (`feature/duplicate-task-suggestion`)** — screen-level Add FAB "Star instead": tapping the suggestion stars the EXISTING task (`updateTaskStarred`) so it appears on the page with no duplicate, and an already-starred match shows the "already starred" snackbar as a no-op; expanded-dialog "Add subtask" FAB "Add here": tapping the suggestion links the existing task as a subtask (`addParentToTask`) with no duplicate, typing the starred parent's own name refuses to self-parent ("that's this task", no self-loop edge), and linking an ancestor of the starred parent surfaces the "would create a loop" warning and leaves the graph unchanged (distinct starred code path from the All Tasks cycle guard); the "Star instead" and "Add here" snackbars offer **Undo** (unstar / `removeParentFromTask`); and the **Codex P2 already-subtask guard** — an already-existing subtask short-circuits with an "already a subtask" message and NO destructive Undo, preserving the pre-existing edge. - **test/screens/starred_screen_reorder_test.dart** (12 tests) — `reorderByDependencyChains`: empty deps no-op, single blocker→dependent pair, blocked-before-blocker reorder, transitive chain (A→B→C), reverse-order chain, unrelated tasks preserved, multiple dependents per blocker, empty task list, single task, missing task IDs in deps, independent chains, deep 4-task chain. diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index 47e404b..e40a8ed 100644 --- a/docs/UI_VIEWS.md +++ b/docs/UI_VIEWS.md @@ -19,7 +19,7 @@ Reference for manual test instructions. Use these names consistently. - **Pick existing task** (`search`) — opens the **"Pin a task to Today's 5" dialog**, modeled on the triage dialog: always-visible search bar at the top, browse tree below. **Search mode** (when filter is non-empty) lists all leaf tasks matching the query (matches against task name and parent names). **Browse mode** (default) shows root-level tasks (excluding inbox tasks); tapping a non-leaf (`folder_outlined` icon, chevron trailing) drills into its children, tapping a leaf (`push_pin_outlined` icon, check trailing) pins it. A back arrow returns up the browse stack. Tasks already in Today's 5 are hidden from both views. When a search matches nothing, the empty state shows a **Create "<query>"** button — tapping it routes into the same **Create new task** flow above (Add Task dialog pre-filled with the query, created at root and auto-pinned into Today's 5). Exception: if the query **exactly matches a task already in Today's 5** (those are hidden from the search pool), the empty state instead reads **"<name> is already in Today's 5"** with **no Create button** — preventing an accidental duplicate. When Today's 5 is already full (5 tasks), the + FAB is hidden (see above), so neither option is reachable in the first place; the "Today's 5 is full — remove one first" snackbar is only a defensive guard for the rare case a slot fills (deadline-today auto-pin or a sync pull) after the sheet/dialog is already open. - **Task card** — each task in the Today's 5 list. Shows task name, subtitle icons (priority, deadline, scheduled today, started, someday). Done tasks are faded with strikethrough. Trailing icons depend on state: **undone** tasks show the **remove (X)** button + **"Go to task"** (`open_in_new`); **"Done today"** (worked-on, not completed) tasks keep **"Go to task"** but have no X; **"Done for good"** (completed) tasks show a **"View in archive"** archive icon instead of "Go to task" and have no X. No done task shows the X — uncomplete a "Done today" task by tapping it to take it off the list. - **Remove (X) button** — trailing icon on the right edge of the card (vertically centered, just before the "Go to task" `open_in_new` icon), undone tasks only. Tap shows the **"Remove from Today's 5?" confirmation dialog** (Cancel / Remove). On confirm, the task is dropped from Today's 5. The task itself is not deleted — it remains in All Tasks and can be re-added. -- **Bottom sheet** — appears when **tapping** an undone task card. Options: "Done today", "Done for good!", "In progress"/"Stop working", and "Remove from Today's 5" (which also goes through the same confirmation dialog). +- **Bottom sheet** — appears when **tapping** an undone task card. Options: "Done today", "Done for good!", "In progress"/"Stop working", **"Go to task"** (opens it in All Tasks — same action as the card's inline `open_in_new` icon), and "Remove from Today's 5" (which also goes through the same confirmation dialog). - **Completion animation** — brief celebration overlay after marking done. - **"Also done today" section** — a bordered box (label + a **single horizontal row of green ✓ chips that scrolls sideways**; one row tall, no expand/collapse) showing tasks worked on today outside the Today's 5 set. Shown **whenever something was done today**, independent of whether Today's 5 has any pinned tasks (so it appears in the empty state too, in the same position as when populated). When Today's 5 is populated, a "+N others" badge also appears on the progress line. - **Progress bar** — segmented progress bar at top showing done/total. diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index 5e7bee0..3443584 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -617,14 +617,19 @@ class TodaysFiveScreenState extends State await _persistAndTrim(); } - /// Shows a bottom sheet: "In progress" / "Done today" / "Done for good!" / - /// "Remove from Today's 5". + /// Shows a bottom sheet: "Done today" / "Done for good!" / "In progress" + /// (or "Stop working") / "Go to task" / "Remove from Today's 5". void _showTaskOptions(Task task) { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, builder: (ctx) => SafeArea( - child: Padding( + // Scrollable: with 5 two-line options the column exceeds the default + // bottom-sheet height cap (9/16 of the screen) on short screens — a + // phone in landscape or a small device overflowed by ~47px once + // "Go to task" was added. Scrolling keeps every option reachable + // while still sizing to content when there's room. + child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, @@ -667,6 +672,19 @@ class TodaysFiveScreenState extends State _stopWorking(task); }, ), + // Same "Go to task" affordance the suggestion sheet offers — the + // card's inline open_in_new icon is easy to miss (and easy to + // mis-tap next to the X), so the sheet gets an explicit path too. + if (widget.onNavigateToTask != null) + ListTile( + leading: Icon(Icons.open_in_new, color: colorScheme.tertiary), + title: const Text('Go to task'), + subtitle: const Text('Open it in All Tasks'), + onTap: () { + Navigator.pop(ctx); + widget.onNavigateToTask!(task); + }, + ), ListTile( leading: Icon(Icons.close, color: colorScheme.onSurfaceVariant), title: const Text("Remove from Today’s 5"), diff --git a/test/screens/todays_five_screen_test.dart b/test/screens/todays_five_screen_test.dart index 2a5e3f6..42c9427 100644 --- a/test/screens/todays_five_screen_test.dart +++ b/test/screens/todays_five_screen_test.dart @@ -233,6 +233,131 @@ void main() { expect(navigatedTask!.name, 'Navigate me'); }); + testWidgets('pinned task options sheet has "Go to task" and it navigates', + (tester) async { + await tester.runAsync(() async { + final id = await db.insertTask(Task(name: 'Navigate me')); + await seedTodaysFive(db, [id]); + }); + Task? navigatedTask; + + await pumpAndLoad( + tester, + buildTestWidget(onNavigateToTask: (task) => navigatedTask = task), + ); + + // Tap the card body → options sheet. + await tester.tap(find.text('Navigate me')); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect(find.text('Go to task'), findsOneWidget); + + await tester.tap(find.text('Go to task')); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect(navigatedTask?.name, 'Navigate me'); + }); + + testWidgets( + 'options sheet has no "Go to task" tile when onNavigateToTask is null', + (tester) async { + await tester.runAsync(() async { + final id = await db.insertTask(Task(name: 'No nav')); + await seedTodaysFive(db, [id]); + }); + + // No onNavigateToTask callback supplied. + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.text('No nav')); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + + // The other options are still present... + expect(find.text('Done today'), findsOneWidget); + expect(find.text('Done for good!'), findsOneWidget); + expect(find.text('In progress'), findsOneWidget); + expect(find.text("Remove from Today’s 5"), findsOneWidget); + // ...but the gated "Go to task" tile is absent. + expect(find.text('Go to task'), findsNothing); + }); + + testWidgets( + 'options sheet keeps every other option alongside "Go to task"', + (tester) async { + await tester.runAsync(() async { + final id = await db.insertTask(Task(name: 'All options')); + await seedTodaysFive(db, [id]); + }); + + await pumpAndLoad( + tester, + buildTestWidget(onNavigateToTask: (_) {}), + ); + + await tester.tap(find.text('All options')); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + + // All five options coexist — "Go to task" was inserted, not swapped in + // for an existing option. + expect(find.text('Done today'), findsOneWidget); + expect(find.text('Done for good!'), findsOneWidget); + expect(find.text('In progress'), findsOneWidget); + expect(find.text('Go to task'), findsOneWidget); + expect(find.text('Open it in All Tasks'), findsOneWidget); + expect(find.text("Remove from Today’s 5"), findsOneWidget); + }); + + // [Regression] Before the fix, the sheet body was a fixed-size + // Padding+Column: with all 5 two-line options present (including the new + // "Go to task" tile), the content exceeded the default bottom-sheet + // height cap (9/16 of the screen) on short screens, throwing a + // "RenderFlex overflowed by ~47px" layout exception. The body is now a + // SingleChildScrollView, so it must lay out (and be scrollable to the + // last option) without overflowing even on a short/narrow window. + testWidgets( + 'options sheet does not overflow on a short screen with all 5 options', + (tester) async { + // Mirror a small phone window where the previous fixed-height Column + // overflowed — the default 800x600 test surface has room to spare. + tester.view.physicalSize = const Size(360, 560); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.runAsync(() async { + final id = await db.insertTask(Task(name: 'Short screen task')); + await seedTodaysFive(db, [id]); + }); + + await pumpAndLoad( + tester, + buildTestWidget(onNavigateToTask: (_) {}), + ); + + await tester.tap(find.text('Short screen task')); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + + // No overflow exception was thrown during layout of the 5-option sheet. + expect(tester.takeException(), isNull); + + // The last option is reachable by scrolling the sheet body. + await tester.dragUntilVisible( + find.text("Remove from Today’s 5"), + find.byType(SingleChildScrollView), + const Offset(0, -50), + ); + expect(find.text("Remove from Today’s 5"), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('shows progress bar', (tester) async { await tester.runAsync(() async { final id = await db.insertTask(Task(name: 'Task 1'));