diff --git a/.claude/skills/cleardb/SKILL.md b/.claude/skills/cleardb/SKILL.md index a394169..b85f216 100644 --- a/.claude/skills/cleardb/SKILL.md +++ b/.claude/skills/cleardb/SKILL.md @@ -24,6 +24,17 @@ launch — deleting the file is equivalent to clearing all tables.) `pgrep -af "task_roulette|dev.sh"`. Kill the matched PIDs (`kill `). This ends the `./dev.sh` hot-reload session too — that's expected; a full restart is needed for the empty DB to take effect anyway. + + **Pre-flight: read the binary's PATH before you kill anything.** The DB path + is keyed to the app ID, so **every** checkout — including + `~/projects/personal/app-worktrees//` worktrees — shares this ONE + database. `pgrep -af` prints the full path; check which checkout each match + actually came from. If a running app (or any `dev.sh`) belongs to a + **different worktree/branch** than the one you're working in, or if you find + **more than one** `dev.sh`, **stop and tell the user before killing or + deleting** — name the worktree, and confirm they're fine with that session's + app being killed and its data wiped. Another session may be mid-test against + that data, and neither the kill nor the clear is scoped to your checkout. 3. **Back up first (safety, always).** This is a dev machine — the local DB is often the only copy. Copy before deleting: `cp -v "$DB" "$DBDIR/task_roulette.db.preclear_$(date +%Y%m%d_%H%M%S)"`. @@ -42,5 +53,9 @@ launch — deleting the file is equivalent to clearing all tables.) or `shared_preferences.json` (app settings, not task data). - Don't run this while a release/manual test that depends on existing data is mid-flight without confirming — clearing is only for a deliberate clean slate. +- **One DB, all checkouts.** There is a single DB per app ID, so a clear hits + every worktree, not just the current one. Never assume the running app is + yours — verify via the process path (see step 2) and confirm with the user + when it isn't. - To restore: `cp "$DBDIR/task_roulette.db.preclear_" "$DBDIR/task_roulette.db"` (with the app stopped), then relaunch. diff --git a/.claude/skills/manual-test/SKILL.md b/.claude/skills/manual-test/SKILL.md index 99b144e..7b29f35 100644 --- a/.claude/skills/manual-test/SKILL.md +++ b/.claude/skills/manual-test/SKILL.md @@ -45,7 +45,10 @@ Generate a checklist of manual tests the user should run for recent changes. - **Always state the Inbox toggle state per task.** For EVERY task the user creates where the Inbox toggle is shown — at root level on All Tasks, via the Starred FAB, the **create-from-empty-search** flow (All Tasks search *and* Today's 5 "Pick existing task"), or any future surface that shows it — explicitly say whether Inbox should be **ON** or **OFF** for that specific task (e.g. "tap + FAB, **turn Inbox OFF**, type 'Container'" or "tap + FAB, **keep Inbox ON (default)**"). The toggle defaults ON, so a created-at-root/Starred task with no Inbox instruction is genuinely ambiguous — it changes whether the task lands in the Inbox section or the main list. State it per task in the setup, not just once. Tasks created *inside a parent* (drilled in) don't show the Inbox toggle, so no Inbox mention is needed there. - **Inbox-ON tasks are root-level — so never assert "at root" alone.** Inbox tasks *are* at the root, so an expected outcome of "the task is at root" is ambiguous and will read as a bug when the task correctly lands in the Inbox. When a test's expected result is where a created task ends up, distinguish the two root buckets explicitly: an Inbox-ON task lands "**in the Inbox section**"; an Inbox-OFF root task lands "**in the main root list (not Inbox)**". This is the outcome-side twin of the rule above: state the Inbox toggle in the action *and* make the expected result name the correct bucket. (Orthogonality note: pinning to Today's 5 is independent of Inbox membership — a task can be both in the Inbox and pinned, so "in Inbox + pinned" is a valid, non-contradictory outcome.) - **Bulk fixture creation — use "Add multiple" (brain dump), not N separate adds.** When a setup needs several tasks at the same level, tell the user to use the **"Add multiple"** flow (**+ FAB → "Add multiple" → one task name per line → Add**) — do NOT ask them to repeat the single-add flow N times. It's available on the **All Tasks root** add and the **Starred FAB** (the Today's 5 "Create new task" flow hides it). State the **Inbox** toggle **once for the whole batch** (it applies to every line). Fall back to individual single adds only when tasks need **different per-task settings** the batch can't express (mixed Inbox states, per-task pin/deadline, or nesting under different parents). - - **State the expected starting state** before the first test — and by default that is a **clean slate via `/cleardb`** (see the `/cleardb` rule below). Tell the user to run `/cleardb` and relaunch `./dev.sh` before Section 1 unless there is an explicit reason to keep existing data (in which case say so, and explain why the existing data is safe — e.g. "Your existing tasks/pins won't interfere — these tests create new tasks."). + - **State the expected starting state** before the first test — and by default that is a **clean slate via `/cleardb`** (see the `/cleardb` rule below). **Never present a `/cleardb` as a done deal — ASK first** (see the rule below for how); the user may have data they need. If there is an explicit reason to keep existing data, say so and explain why it's safe — e.g. "Your existing tasks/pins won't interfere — these tests create new tasks." + - **"Go to root" is the breadcrumb, NOT the back arrow.** On All Tasks these are two different controls (see the All Tasks section of `docs/UI_VIEWS.md`): the app bar's **back arrow pops exactly one level**, while the **breadcrumb strip below the app bar** jumps straight to any ancestor — tapping its **"Task Roulette"** crumb is the one-tap way to reach root. So never write "back arrow to root": it is wrong from two-plus levels deep, and it only appears to work one level down. Write **"tap 'Task Roulette' in the breadcrumb to return to root"**, or, when the point is genuinely to go up a single level, "tap the back arrow once (up one level)". Name the exact control whenever a step involves changing level. + - **Never open a step with a bare imperative that summarizes it — every instruction must be actionable on the spot.** A step that begins "Fill Today's 5, then check the Pin chip disappears" reads as a command the user is expected to carry out, but names no method — they stop and ask "how do I fill Today's 5?". Anything phrased as an order IS an order. If you want to state the step's purpose up front, mark it unmistakably as a label — `*Goal:* the pin toggle must vanish once there's no free slot` — or, better, drop the preamble and open with the first concrete tap, letting the expected result carry the intent. Never let a summary and an instruction share the same imperative voice. Same rule for mid-step conditions: "with Today's 5 full, …" must be preceded by the taps that fill it. + - **A negative or elsewhere outcome MUST come with the navigation to go check it.** Never assert "no duplicate was created", "no task named X exists", "the count is unchanged", or "it did NOT appear in Y" without telling the user *where to look and how to get there* — after the final action they are usually parked on some other screen (a leaf detail view, a drilled-in parent, another tab), where the thing to verify simply isn't visible. Spell out the return trip and what to count. Bad: "no duplicate (still exactly one 'Bravo leaf' at root) and no task named 'qqq'". Good: "tap the back arrow until you're at the All Tasks root list → scan it: exactly one 'Bravo leaf' row, and no 'qqq' row anywhere (check the Inbox section too)". The same applies to absence-on-another-surface claims — say "switch to the Starred tab → no card for it" rather than just "not starred". Absence is only verifiable somewhere specific; name that place. - **Tests that verify a refresh or side-effect MUST spell out the mutation steps.** If a test asks the user to "switch to tab X and confirm Y reflects the latest state", you must include the exact mutation that produces that new state — never assume the user knows what "recently completed", "recently edited", or "the latest" means in context. Bad: "switch to Today → Today's 5 reflects latest state". Good: "On All Tasks, tap any Today's 5 task → tap 'Done today' → switch to Today tab → that task now appears under the Done section". This rule applies to all "X refreshes on entry", "Y reloads on tab switch", "Z reflects external changes" tests. 5. Prioritize tests by risk: @@ -93,7 +96,9 @@ D2. Reach [populated / overflowing state] → [what to look at] → does it look - **Never include sync/auth tests when running on test data.** The user uses test data on their dev machine — don't suggest signing in, triggering sync, or testing sync status. Only include sync tests if the change is sync-specific AND the user explicitly says they want to test sync. - If a change is purely algorithmic with no UI impact, say so and focus tests on observable outcomes — and skip the Design checks section (there's nothing new to look at). - **Design checks are their own section and follow the batching rule** — present them one at a time like any other section, after (or alongside) the functional sections, and don't advance until the user has judged every visual case in the batch. They belong ONLY when a screen's look actually changed; a pure logic/data change gets none. When the user is unsure about a state, proactively offer to look via `/check-screenshot` and compare it to what the design was meant to be — do not vouch for a screen you haven't seen. -- **Prefer a clean-slate `/cleardb` before the round starts — ONCE, not per-section.** Unless there is an explicit reason to keep the existing data, recommend the user run **`/cleardb`** (then relaunch `./dev.sh`) **before Section 1** so the whole round runs against an empty, predictable DB — existing pins/tasks won't interfere with setup or outcomes. Do this **once per round, before the first section only** — do NOT ask for a `/cleardb` before Section 2, Section 3, etc.; later sections build on the same fresh DB plus whatever earlier sections created. `/cleardb` is `disable-model-invocation` and destructive (it backs up first, stops `./dev.sh`, and needs a relaunch), so **you cannot run it yourself** — instruct the user to run it. Reasons to skip/defer it (call them out explicitly): the change is specifically about **existing-data / migration / sync-merge** behavior, the test needs pre-existing fixtures that only live in the current DB, or the user says to keep their data. +- **Prefer a clean-slate `/cleardb` before the round starts — ONCE, not per-section.** Unless there is an explicit reason to keep the existing data, recommend the user run **`/cleardb`** (then relaunch `./dev.sh`) **before Section 1** so the whole round runs against an empty, predictable DB — existing pins/tasks won't interfere with setup or outcomes. Do this **once per round, before the first section only** — do NOT ask for a `/cleardb` before Section 2, Section 3, etc.; later sections build on the same fresh DB plus whatever earlier sections created. `/cleardb` is `disable-model-invocation` and destructive (it backs up first, stops `./dev.sh`, and needs a relaunch), so **you cannot run it yourself** — the user runs it. + + **ASK before any `/cleardb` — never just instruct.** Whenever you're about to recommend one, put the question to the user with **`AskUserQuestion`** (not plain text) and let them decide, offering at minimum: **clear it** (clean slate, backed up first), **keep the data** (you adapt the setup steps to coexist with what's already there), and where useful **back up only**. Their local DB is often the only copy of real data, and only they know whether it matters right now — so this is their call, not a default you apply on their behalf. Say plainly what a clear costs them (it stops the running app, needs a `./dev.sh` relaunch, and is restorable from the timestamped `.preclear_*` copy) so the choice is informed. If they decline, do NOT re-ask in later sections — adapt the checklist and move on. Reasons to skip/defer it (call them out explicitly): the change is specifically about **existing-data / migration / sync-merge** behavior, the test needs pre-existing fixtures that only live in the current DB, or the user says to keep their data. - Mention which platform to test on (Linux via `./dev.sh` unless the change is mobile-specific). - **Present tests in batches by section.** Don't dump all test cases at once — show one section at a time (e.g. "Today's 5" tests first, then "All Tasks" after the user reports results). This prevents the list from feeling overwhelming and lets the user focus. **This applies to the caller too** — when presenting the agent's results, you MUST show only the first section and hold back the rest until the user reports results. Never flatten all sections into one list or present them all at once, even as a "summary". **Do NOT move to the next section until the user has reported results for ALL test cases in the current section.** If the user reports on only some tests (e.g. "1. works" but there are 10 tests), re-display the remaining tests from that section — do NOT advance to the next section. - **Snackbar undo tests are time-sensitive.** The undo snackbar only lasts 5 seconds. Rules for undo tests: diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 7b5f3fe..cc803d6 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -27,9 +27,9 @@ 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** (~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/todays_five_screen_test.dart** (~71 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). **App bar search (`feature/search-in-all-tabs`)** — the magnifying-glass action opens the shared global `showTaskSearch` picker ("Search tasks"), NOT the leaves-only "Pin a task to Today's 5" browse picker; picking a result (including a NON-leaf parent, which the pin picker excludes) navigates via `onNavigateToTask` and leaves Today's 5 empty — it opens, never pins; and create-from-search files a root task with the Pin toggle merely OFFERED (a free slot) but left off, so nothing is pinned — distinct from the FAB's create-new flow where the pin is implicit. - **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_test.dart** (47 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. **App bar search (`feature/search-in-all-tabs`)** — the magnifying-glass action opens the shared global `showTaskSearch` picker ("Search tasks"), picking an UNSTARRED result hands it to `onNavigateToTask` (AppShell drills in + slides to All Tasks) proving the pool is every task and not just starred ones, and an empty search's Create button opens the shared root add flow with the query pre-filled + Inbox toggle shown — filing a **plain, unstarred root task** (unlike the screen "+" FAB, which auto-stars; search is global so it does not). - **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. - **test/screens/starred_screen_child_text_style_test.dart** (6 tests) — `childTextStyle`: blocked dimming, high-priority accent tint (no bold), normal style, blocked precedence over priority, priority 1 treated as high, custom fontSize. - **test/screens/task_list_screen_test.dart** (21 tests) — Root state (title, empty state, FABs, flare FAB visibility, task graph button, star button hidden, task cards in grid), navigation (AppBar title changes, back button show/hide/returns to root, breadcrumb with chevrons, task graph hidden when non-root, link FAB shown non-root, star button shown non-root), leaf detail (navigating into leaf shows detail), inbox (inbox section shown at root), link button (shown with URL+children, hidden without URL), **pin-for-today on add** (regression: pinning a new task on an empty day actually bootstraps Today's 5 and pins it, rather than silently no-op'ing), **`_runAddFlow` nesting** (PR #73 review-fix: the "+" FAB add was unified with create-from-search into `_runAddFlow({atRoot})` — a task added via the FAB while drilled into a parent nests under that parent, `atRoot: false` → `currentParent.id`, regression guarding against the FAB wrongly filing at root like the create-from-search path; plus baseline that a root-level FAB add files at root), **deadline-today suppression from All Tasks unpin** (Codex P2 regression: unpinning a due-today task from the All Tasks leaf detail records a suppression so the next Today reconcile doesn't re-auto-pin it), **duplicate-task suggestion per-surface action (`_runAddFlow`)** (`feature/duplicate-task-suggestion`: at ROOT, tapping the "Open" suggestion navigates to the existing task (`navigateToTask`, becomes `currentParent`) with no duplicate; drilled into a parent, "Add here" links the existing task as a child (`addParentToTask`) with no duplicate; typing the drilled-in parent's OWN name refuses to self-parent ("that's the task you're already in", no self-loop edge); linking an ancestor of the current parent surfaces the "would create a loop" warning and leaves the graph unchanged; the "Add here" snackbar offers **Undo** (`removeParentFromTask`) that removes the link; and the **Codex P2 already-child guard** — typing the name of a task that is already a child of the drilled-in parent short-circuits with an "already listed here" message and NO destructive Undo, preserving the pre-existing edge). diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index e40a8ed..11b3066 100644 --- a/docs/UI_VIEWS.md +++ b/docs/UI_VIEWS.md @@ -37,6 +37,9 @@ Reference for manual test instructions. Use these names consistently. - **Task card** — grid card for each task. Shows task name, top-left indicator icons (Today's 5, worked-on, started, priority, someday, deadline, scheduled today, starred). **Tap** navigates into the task. **Long-press** opens a context-menu bottom sheet. Items: **Rename**, **Also show under...** (add another parent), **Do after...** (add/change dependency), **Schedule**, **Delete**, plus two that appear **only when drilled into a parent (non-root), never at root level**: **Move to...** (`drive_file_move_outline` — move from the current list to another) and **Remove from here** (unlink from the current parent). At root level a task has no "current list" to move from or unlink from, so both are hidden. - **Known limitation — "Also show under..." on a root task removes it from the root list.** The root level shows only **parentless** tasks (root = the *absence* of a parent, not a parent itself). So the first time you "Also show under..." a task that currently sits at root, it gains a parent and therefore **disappears from the root list**, appearing under that parent instead — despite the "*also* show under" wording implying it would stay at root as well. A second "Also show under..." then correctly adds an *additional* parent (the task is already non-root). To keep a copy visible at the top level you would need a genuine root pseudo-parent, which does not exist. This is pre-existing DAG behavior, unrelated to any specific fix — noted here to avoid manual-test confusion. - **Task list** — hierarchical list. Shows children of the current parent. Root level shows top-level tasks + Inbox. +- **Going back up: two distinct controls, only shown when drilled in (never at root).** + - **Back arrow** — leftmost item in the app bar (replaces the app bar's normal leading slot). Pops **exactly one level** (`navigateBack`). Repeated taps walk up one ancestor at a time. + - **Breadcrumb bar** — a tinted strip directly *below* the app bar showing the full path, e.g. `Task Roulette › Some parent › Current task`, separated by chevrons. Every crumb **except the current level** is a tappable link (in the primary/accent colour) that jumps **straight to that level** (`navigateToLevel`); the current level is bold and inert. The root crumb is labelled **"Task Roulette"**, so **tapping "Task Roulette" is the one-tap "go to root"** — the back arrow is *not* a jump-to-root control. The strip scrolls horizontally (reversed, so a deep path keeps the current end visible). - **Inbox section** — collapsible section at top showing unorganized (inbox) tasks. Tapping the section header toggles expand/collapse; a **"File all"** action files every inbox task in one pass. **Interactions on an inbox task row:** a **tap (single press)** opens the **"File under…"** (triage) dialog to file it under a parent (or keep it at top level, which dismisses it from the inbox); a **long-press** drills **into** the task (navigateInto). This is the inverse of the main list rows, where a tap navigates in — inbox tasks prioritize filing, so tap = file, long-press = open. - **Leaf detail view** — appears when navigating into a leaf task (a task with no children). Shows task name, "Done today" button, "Done for good!" button, "Start"/"Started" toggle, priority selector, schedule/deadline info, dependencies, parent breadcrumbs. This is NOT the same as the Today's 5 bottom sheet. - **"Do after..." icon (add_task)** — shown when the task has **no** dependency. **Tap** opens the "Do X after..." picker dialog to add a dependency. @@ -69,4 +72,5 @@ Reference for manual test instructions. Use these names consistently. - **Snackbar** — appears at bottom after actions. May include "Undo" button (5s timeout) and close icon. - **Archive screen** — accessed via the archive icon in the app bar. Shows completed and skipped tasks. Each entry displays task name, completion status ("Completed today" / "Skipped"), parent context ("Was under X"), and inline action icons: trash (permanent delete) and restore (undo clock icon). No tap needed to reveal actions — they're always visible. -- **Search** — magnifying glass icon in All Tasks app bar. Opens a task picker dialog. When the query matches no tasks, the empty state shows a **Create "<query>"** button. Tapping it opens the Add Task dialog with the name pre-filled; the task is always filed at the **root** (Inbox toggle shown, default ON) regardless of the currently open task — search is global, so create is too. +- **Two-line tab title (Starred + Today's 5)** — both tabs show the app name **"Task Roulette"** over the tab name ("Starred" / "Today's 5"), Starred adding a count badge after its tab name. Built by the shared `TabAppBarTitle` widget so the two can't drift apart. The app-name line is **responsive**: 30px normally, stepping down to **22px below 420 logical px** of window width, because the four action icons (five on Today's 5 in debug, which adds the rollover icon) leave only ~150dp for the title at phone width and the 30px line ellipsised to "Task Rou…". Ellipsis remains the last-resort fallback at extreme narrowness — it degrades, never overflows. The All Tasks tab has a different, single-line title (app name at root, or the open task's name when drilled in). +- **Search** — magnifying glass icon in the app bar of **all three tabs** (Starred, Today's 5, All Tasks), in the same slot: right after the profile icon. Opens the same "Search tasks" picker dialog (searches every task, matching task name and parent names). Selecting a result **opens the task in All Tasks**: from All Tasks it drills in place; from Starred / Today's 5 it drills in and slides over to the All Tasks tab (same behavior as "Go to task"). When the query matches no tasks, the empty state shows a **Create "<query>"** button. Tapping it opens the Add Task dialog with the name pre-filled; the task is always filed at the **root** (Inbox toggle shown, default ON) regardless of the currently open task or the tab it was launched from — search is global, so create is too. A task created from Starred search is **not** auto-starred, and one created from Today's 5 search is **not** auto-pinned (the dialog's pin toggle — labelled just **"Pin"** here, since the Inbox chip is always shown alongside it — is offered while a pin slot is free). If the typed name matches an existing task, the suggestion's action is **Open** (opens that task in All Tasks) rather than creating a duplicate. diff --git a/lib/screens/starred_screen.dart b/lib/screens/starred_screen.dart index 42ba972..74cdabf 100644 --- a/lib/screens/starred_screen.dart +++ b/lib/screens/starred_screen.dart @@ -8,6 +8,8 @@ import '../providers/task_provider.dart'; import '../utils/display_utils.dart'; import '../widgets/add_task_flow.dart'; import '../widgets/profile_icon.dart'; +import '../widgets/tab_app_bar_title.dart'; +import '../widgets/task_search.dart'; import '../providers/theme_provider.dart'; import 'completed_tasks_screen.dart'; @@ -144,6 +146,19 @@ class StarredScreenState extends State ).run(context); } + /// Global search (shared with the All Tasks and Today's 5 tabs). A picked + /// task opens in the All Tasks tab via [StarredScreen.onNavigateToTask] — + /// results can be any task, most of which have no place on this page. + Future _searchTask() => showTaskSearch( + context, + onSelected: (selected) async => widget.onNavigateToTask?.call(selected), + onCreateTask: (name) => showRootAddFromSearch( + context, + initialName: name, + onOpenExisting: (existing) => widget.onNavigateToTask?.call(existing), + ), + ); + void _onProviderChanged() { if (!mounted || _loading) return; _debounce?.cancel(); @@ -295,58 +310,36 @@ class StarredScreenState extends State return AppBar( titleSpacing: 16, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Task Roulette', - style: TextStyle( - fontFamily: 'Outfit', - fontSize: 30, - fontWeight: FontWeight.w400, - letterSpacing: -0.3, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - Text( - 'Starred', - style: TextStyle( - fontFamily: 'Outfit', - fontSize: 16, - fontWeight: FontWeight.w300, - color: colorScheme.onSurfaceVariant, - letterSpacing: 1.0, + title: TabAppBarTitle( + subtitle: 'Starred', + trailing: _starredTasks.isEmpty + ? null + : Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, ), - ), - if (_starredTasks.isNotEmpty) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 1, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '${_starredTasks.length}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onPrimaryContainer, - fontSize: 10, - ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${_starredTasks.length}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onPrimaryContainer, + fontSize: 10, ), ), - ], - ], - ), - ], + ), ), toolbarHeight: 72, actions: [ const ProfileIcon(), + IconButton( + icon: const Icon(Icons.search, size: 22), + onPressed: _searchTask, + tooltip: 'Search', + ), Consumer( builder: (context, themeProvider, _) { return IconButton( diff --git a/lib/screens/task_list_screen.dart b/lib/screens/task_list_screen.dart index cd6bdf8..bb2abf8 100644 --- a/lib/screens/task_list_screen.dart +++ b/lib/screens/task_list_screen.dart @@ -20,6 +20,7 @@ import '../utils/display_utils.dart'; import '../widgets/delete_task_dialog.dart'; import '../widgets/schedule_dialog.dart'; import '../widgets/task_picker_dialog.dart'; +import '../widgets/task_search.dart'; import '../widgets/triage_dialog.dart'; import '../services/backup_service.dart'; import '../widgets/profile_icon.dart'; @@ -928,29 +929,14 @@ class TaskListScreenState extends State ); } - Future _searchTask() async { - final provider = context.read(); - final (allTasks, parentNamesMap) = await _fetchCandidateData(); - - if (!mounted) return; - - final selected = await showDialog( - context: context, - builder: (dialogCtx) => TaskPickerDialog( - candidates: allTasks, - title: 'Search tasks', - parentNamesMap: parentNamesMap, - // Empty results → offer to create a task named after the search term. - onCreateTask: (name) { - Navigator.of(dialogCtx).pop(); - _createTaskFromSearch(name); - }, - ), - ); - - if (selected == null || !mounted) return; - await provider.navigateToTask(selected); - } + /// Search is the shared [showTaskSearch] flow (same dialog the Starred and + /// Today's 5 tabs open); selecting a result drills into it right here. + Future _searchTask() => showTaskSearch( + context, + onSelected: (selected) => + context.read().navigateToTask(selected), + onCreateTask: _createTaskFromSearch, + ); /// Creates a task from an empty search result, named after the search term. /// Search is a global action, so the task is always filed at the root (Inbox diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index 3443584..debb9d1 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -14,6 +14,8 @@ import '../utils/display_utils.dart'; import '../widgets/add_task_dialog.dart'; import '../widgets/completion_animation.dart'; import '../widgets/task_picker_dialog.dart'; +import '../widgets/tab_app_bar_title.dart'; +import '../widgets/task_search.dart'; import '../widgets/profile_icon.dart'; import 'completed_tasks_screen.dart'; @@ -1035,6 +1037,23 @@ class TodaysFiveScreenState extends State } } + /// Global search (shared with the All Tasks and Starred tabs). Distinct from + /// [_handlePickExistingForToday], which is scoped to pinnable leaves and pins + /// the pick: search spans EVERY task and opens the result in the All Tasks + /// tab via [TodaysFiveScreen.onNavigateToTask]. + Future _searchTask() => showTaskSearch( + context, + onSelected: (selected) async => widget.onNavigateToTask?.call(selected), + onCreateTask: (name) => showRootAddFromSearch( + context, + initialName: name, + onOpenExisting: (existing) => widget.onNavigateToTask?.call(existing), + // The add dialog offers "Pin for today" while a slot is free, so the + // new task may land in Today's 5 — reload so it shows immediately. + onCompleted: refreshSnapshots, + ), + ); + /// Pick-existing flow: opens TaskPickerDialog filtered to leaf tasks not /// already in Today's 5, then pins the selection. Future _handlePickExistingForToday() async { @@ -1131,31 +1150,7 @@ class TodaysFiveScreenState extends State child: const Icon(Icons.add), ), appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Task Roulette', - style: const TextStyle( - fontFamily: 'Outfit', - fontSize: 30, - fontWeight: FontWeight.w400, - letterSpacing: -0.3, - ), - ), - const SizedBox(height: 2), - Text( - "Today\u2019s 5", - style: TextStyle( - fontFamily: 'Outfit', - fontSize: 16, - fontWeight: FontWeight.w300, - color: colorScheme.onSurfaceVariant, - letterSpacing: 1.0, - ), - ), - ], - ), + title: const TabAppBarTitle(subtitle: "Today\u2019s 5"), toolbarHeight: 72, actions: [ if (kDebugMode) @@ -1182,6 +1177,11 @@ class TodaysFiveScreenState extends State tooltip: 'Simulate midnight rollover', ), const ProfileIcon(), + IconButton( + icon: const Icon(Icons.search, size: 22), + onPressed: _searchTask, + tooltip: 'Search', + ), Consumer( builder: (context, themeProvider, _) { return IconButton( diff --git a/lib/widgets/tab_app_bar_title.dart b/lib/widgets/tab_app_bar_title.dart new file mode 100644 index 0000000..3905c9f --- /dev/null +++ b/lib/widgets/tab_app_bar_title.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +/// The two-line app bar title shared by the Starred and Today's 5 tabs — the +/// app name over the tab name, with an optional trailing badge beside the tab +/// name (Starred uses it for its count). +/// +/// The app-name line steps down from 30px to 22px below [_compactWidth]. Both +/// tabs carry four action icons beside this title (five on Today's 5 in debug, +/// which adds the rollover icon), and once search was added the 30px line ran +/// out of room at phone width and ellipsised to "Task Rou…". Ellipsis is kept as +/// the last-resort fallback for extremely narrow windows, so the degradation is +/// still graceful rather than a RenderFlex overflow. +/// +/// Lives in one place so the two tabs can't drift apart — they must look +/// identical apart from the tab name and badge. +class TabAppBarTitle extends StatelessWidget { + const TabAppBarTitle({super.key, required this.subtitle, this.trailing}); + + /// The tab name shown on the second line (e.g. 'Starred', "Today's 5"). + final String subtitle; + + /// Optional widget shown just after [subtitle] (e.g. the starred count badge). + final Widget? trailing; + + /// Below this logical width the app name uses the compact size. Phone width + /// plus headroom: at 360dp, four 48dp actions leave only ~150dp for the title, + /// which the 30px line overruns. + static const double _compactWidth = 420; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final compact = MediaQuery.sizeOf(context).width < _compactWidth; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Task Roulette', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'Outfit', + fontSize: compact ? 22 : 30, + fontWeight: FontWeight.w400, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Flexible( + child: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'Outfit', + fontSize: 16, + fontWeight: FontWeight.w300, + color: colorScheme.onSurfaceVariant, + letterSpacing: 1.0, + ), + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 6), + trailing!, + ], + ], + ), + ], + ); + } +} diff --git a/lib/widgets/task_search.dart b/lib/widgets/task_search.dart new file mode 100644 index 0000000..2017760 --- /dev/null +++ b/lib/widgets/task_search.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../data/database_helper.dart'; +import '../data/todays_five_pin_helper.dart'; +import '../models/task.dart'; +import '../providers/task_provider.dart'; +import '../utils/display_utils.dart'; +import 'add_task_flow.dart'; +import 'task_picker_dialog.dart'; + +/// Global task search, shared by all three tabs (Starred, Today's 5, All Tasks) +/// so the affordance behaves identically wherever it is tapped: +/// +/// * searches every task (flat-mode [TaskPickerDialog], matching name + parent +/// names), +/// * selecting a result hands the task to [onSelected] — every caller opens it +/// in the All Tasks tab, +/// * an empty result offers "Create ..." named after the query, which runs the +/// shared root add flow ([showRootAddFromSearch]). +/// +/// Search is a GLOBAL action, so creation always files at root (Inbox toggle +/// default on) regardless of which tab it was launched from or where the All +/// Tasks tab happens to be drilled into. +Future showTaskSearch( + BuildContext context, { + required Future Function(Task selected) onSelected, + required void Function(String query) onCreateTask, +}) async { + final data = await fetchSearchCandidates(context); + if (data == null || !context.mounted) return; + final (allTasks, parentNamesMap) = data; + + final selected = await showDialog( + context: context, + builder: (dialogCtx) => TaskPickerDialog( + candidates: allTasks, + title: 'Search tasks', + parentNamesMap: parentNamesMap, + // Empty results → offer to create a task named after the search term. + onCreateTask: (name) { + Navigator.of(dialogCtx).pop(); + onCreateTask(name); + }, + ), + ); + + if (selected == null || !context.mounted) return; + await onSelected(selected); +} + +/// Fetches the search pool (all tasks + parent names) behind a modal spinner. +/// Returns null if the widget went away mid-fetch. +Future<(List, Map>)?> fetchSearchCandidates( + BuildContext context, +) async { + final navigator = Navigator.of(context); + final provider = context.read(); + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + try { + late List allTasks; + late Map> parentNamesMap; + await Future.wait([ + provider.getAllTasks().then((v) => allTasks = v), + provider.getParentNamesMap().then((v) => parentNamesMap = v), + ]); + return (allTasks, parentNamesMap); + } finally { + if (navigator.mounted) navigator.pop(); + } +} + +/// Creates a task from an empty search result, named after the search term. +/// +/// Always files at root (`atRoot: true`) with the Inbox toggle shown and +/// default-on — search is global, so the currently open task in All Tasks (a +/// provider-level, cross-tab value) must not capture the new task. The term +/// pre-fills the Add dialog so the user can tweak the name / options first. +/// +/// Used by the Starred and Today's 5 tabs. (All Tasks routes its own +/// create-from-search through `_runAddFlow(atRoot: true)`, which additionally +/// handles the drilled-in parent case and the Inbox badge refresh.) +Future showRootAddFromSearch( + BuildContext context, { + required String initialName, + required void Function(Task existing) onOpenExisting, + Future Function()? onCompleted, +}) async { + final provider = context.read(); + // Offer "Pin for today" only while a pin slot is free — the add lands at + // root, so there is no pinned-parent case to suppress it for. + final todaysFive = + await DatabaseHelper().getTodaysFiveTaskAndPinIds(todayDateKey()); + if (!context.mounted) return; + final showPin = todaysFive.pinnedIds.length < maxPins; + try { + // For the "already exists" suggestion: creation is at root, so there is no + // parent to file a match under — tapping a match just opens it. + final allTasks = await provider.getAllTasks(); + final parentNames = await provider.getParentNamesMap(); + if (!context.mounted) return; + await AddTaskFlow( + initialName: initialName, + showInboxOption: true, + showPinOption: showPin, + existingTasks: allTasks, + existingActionIcon: Icons.open_in_new, + existingActionLabel: 'Open', + existingParentNames: parentNames, + onUseExisting: (existing) async => onOpenExisting(existing), + addSingle: + ({required name, url, required isInbox, required deferNotify}) => + provider.addTask( + name, + url: url, + isInbox: isInbox, + deferNotify: deferNotify, + atRoot: true, + ), + addBatch: (names, {required isInbox}) => + provider.addTasksBatch(names, isInbox: isInbox, atRoot: true), + onProviderRefresh: provider.refreshAfterMutation, + onCompleted: (_) async => onCompleted?.call(), + ).run(context); + } catch (e) { + // Fired un-awaited from the picker's "Create" callback, so a DB/sync throw + // would otherwise escape to FlutterError.onError with the user just seeing + // the dialog vanish. + if (context.mounted) { + showInfoSnackBar(context, "Couldn't add task — please retry"); + } + } +} diff --git a/test/screens/starred_screen_test.dart b/test/screens/starred_screen_test.dart index bf36585..891cfb7 100644 --- a/test/screens/starred_screen_test.dart +++ b/test/screens/starred_screen_test.dart @@ -1015,6 +1015,166 @@ void main() { }); }); + group('StarredScreen - search', () { + /// Types into the picker's search field and lets its 200ms debounce fire. + /// pumpAsync alone pumps without advancing the fake clock, so the debounce + /// Timer would never run and the filter would stay stale. + Future search(WidgetTester tester, String query) async { + await tester.enterText(find.byType(TextField).first, query); + await tester.pump(const Duration(milliseconds: 300)); + await pumpAsync(tester); + } + + // [Mechanism] The app bar search icon opens the shared global search + // picker — the same "Search tasks" dialog the All Tasks tab opens. + testWidgets('app bar search icon opens the global search dialog', + (tester) async { + await tester.runAsync(() => createStarredTask('Guitar practice')); + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + + expect(find.text('Search tasks'), findsOneWidget); + }); + + // [Mechanism] Search spans EVERY task, not just starred ones — the whole + // point of putting it on this tab. Picking a result hands it to + // onNavigateToTask (AppShell drills in + slides to the All Tasks tab). + testWidgets('picking an unstarred result navigates to it', (tester) async { + await tester.runAsync(() async { + await createStarredTask('Guitar practice'); + await db.insertTask(Task(name: 'Buy strings')); + }); + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + + await search(tester, 'strings'); + + await tester.tap(find.text('Buy strings').last); + await pumpAsync(tester); + + expect(navigatedTask, isNotNull, + reason: 'a search result must open in the All Tasks tab'); + expect(navigatedTask!.name, 'Buy strings'); + }); + + // [Mechanism] Empty search → "Create ..." routes into the shared root add + // flow with the query pre-filled and the Inbox toggle shown. + testWidgets('empty search offers create with the query pre-filled', + (tester) async { + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + + await search(tester, 'Nothing matches'); + + expect(find.textContaining('Create'), findsOneWidget); + await tester.tap(find.textContaining('Create')); + await pumpAsync(tester); + + expect(find.text('Add Task'), findsOneWidget); + expect(find.text('Inbox'), findsOneWidget); + // Name pre-filled from the search term. + final field = tester.widget(find.byType(TextField).first); + expect(field.controller?.text, 'Nothing matches'); + }); + + // [Mechanism] Search is a GLOBAL action, so unlike the screen "+" FAB it + // does NOT auto-star what it creates — it files a plain root task (the + // user picked "same as All Tasks" behavior). + testWidgets('create-from-search files a plain root task, not starred', + (tester) async { + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + await search(tester, 'Fresh capture'); + await tester.tap(find.textContaining('Create')); + await pumpAsync(tester); + + await tester.runAsync(() async { + await tester.tap(find.text('Add')); + }); + await pumpAsync(tester); + + final created = await tester.runAsync(() async { + final all = await db.getAllTasks(); + return all.firstWhere((t) => t.name == 'Fresh capture'); + }); + expect(created!.isStarred, isFalse, + reason: 'search create must not auto-star'); + final parents = + await tester.runAsync(() => provider.getParentIds(created.id!)); + expect(parents, isEmpty, reason: 'search create files at root'); + }); + + // [Mechanism] showTaskSearch pre-loads getParentNamesMap() and hands it to + // the picker, so a query can match a task by its PARENT's name and the row + // shows the "under X" context. Without that wiring the search would be + // name-only and buried subtasks would be unreachable from this tab. + testWidgets('matches a task by its parent name and shows "under X"', + (tester) async { + await tester.runAsync(() async { + final parentId = await db.insertTask(Task(name: 'Guitar practice')); + final childId = await db.insertTask(Task(name: 'Restring')); + await db.addRelationship(parentId, childId); + }); + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + + // Query matches the PARENT's name only — "Restring" contains none of it. + await search(tester, 'Guitar'); + + expect(find.text('Restring'), findsOneWidget, + reason: 'child must surface via its parent name'); + expect(find.text('under Guitar practice'), findsOneWidget); + }); + + // [Edge case] The pre-filled name can be edited into one that already + // exists (the search that opened this dialog found nothing, but the user + // retypes). showRootAddFromSearch wires onUseExisting → onOpenExisting, so + // tapping the match OPENS the existing task instead of creating a duplicate + // — there is no parent to file it under at root. + testWidgets('create-from-search "Open" suggestion opens existing, ' + 'no duplicate', (tester) async { + await tester.runAsync(() => db.insertTask(Task(name: 'Write report'))); + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + await search(tester, 'Nothing matches'); + await tester.tap(find.textContaining('Create')); + await pumpAsync(tester); + + // Retype the name of an existing task → in-field suggestion indicator. + await tester.enterText(find.byType(TextField).first, 'write REPORT'); + await pumpAsync(tester); + expect(find.byIcon(Icons.info_outline), findsOneWidget); + + // pumpAndSettle is needed for the popup's open animation (pumpAsync does + // not advance the fake clock, leaving the menu collapsed and unhittable). + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + await tester.runAsync(() async { + await tester.tap(find.byWidgetPredicate( + (w) => w is PopupMenuItem && w.enabled)); + }); + await pumpAsync(tester); + + expect(navigatedTask, isNotNull); + expect(navigatedTask!.name, 'Write report'); + final all = await tester.runAsync(() => db.getAllTasks()) ?? []; + expect(all.where((t) => t.name.toLowerCase() == 'write report').length, 1, + reason: 'no duplicate created'); + }); + }); + group('TaskProvider - starOrder preservation', () { test('updateTaskStarred with explicit starOrder preserves position', () async { diff --git a/test/screens/task_list_screen_test.dart b/test/screens/task_list_screen_test.dart index 35c7e99..92d525c 100644 --- a/test/screens/task_list_screen_test.dart +++ b/test/screens/task_list_screen_test.dart @@ -722,4 +722,108 @@ void main() { expect(gpParents, isNot(contains(parentId))); }); }); + + // --------------------------------------------------------------------------- + // App bar search — routed through the shared showTaskSearch helper + // --------------------------------------------------------------------------- + // `feature/search-in-all-tabs` moved this screen's home-grown search body + // into `lib/widgets/task_search.dart` (`showTaskSearch`) so the Starred and + // Today's 5 tabs could reuse it. All Tasks is the ORIGINAL caller and the one + // whose behaviour must not have drifted: it navigates in place (no + // `onNavigateToTask` callback) and its create-from-search still goes through + // `_runAddFlow(atRoot: true)`. These are refactor guards. + group('TaskListScreen - search (shared showTaskSearch flow)', () { + /// Types into the picker's search field and lets its 200ms debounce fire. + /// pumpAsync pumps without advancing the fake clock, so the debounce Timer + /// would never run and the filter would stay stale. + Future search(WidgetTester tester, String query) async { + await tester.enterText(find.byType(TextField).first, query); + await tester.pump(const Duration(milliseconds: 300)); + await pumpAsync(tester); + } + + // [Regression] The app bar search action still opens the "Search tasks" + // picker after the body was extracted into the shared helper. + testWidgets('app bar search icon opens the "Search tasks" dialog', + (tester) async { + await tester.runAsync(() async { + await db.insertTask(Task(name: 'Write report')); + await provider.loadRootTasks(); + }); + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + + expect(find.text('Search tasks'), findsOneWidget); + }); + + // [Regression] All Tasks drills into the pick ITSELF (currentParent), unlike + // the other two tabs which hand it to onNavigateToTask. Guards the + // `onSelected: navigateToTask` wiring of the extracted helper. + testWidgets('picking a result drills into it in place', (tester) async { + await tester.runAsync(() async { + final parentId = await db.insertTask(Task(name: 'Deep parent')); + final childId = await db.insertTask(Task(name: 'Deep child')); + await db.addRelationship(parentId, childId); + await provider.loadRootTasks(); + }); + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + await search(tester, 'Deep child'); + await tester.tap(find.text('Deep child').last); + await pumpAsync(tester); + + expect(provider.currentParent, isNotNull, + reason: 'All Tasks opens the result in place, not via a callback'); + expect(provider.currentParent!.name, 'Deep child'); + }); + + // [Regression] Search is GLOBAL, so create-from-search files at root even + // while the tab is drilled into a parent (`_runAddFlow(atRoot: true)`) — the + // opposite of the "+" FAB, which nests under the open task. This is the one + // behaviour most easily lost when rerouting through a shared helper. + testWidgets('create-from-search files at root while drilled into a parent', + (tester) async { + late int parentId; + await tester.runAsync(() async { + parentId = await db.insertTask(Task(name: 'Open project')); + final childId = await db.insertTask(Task(name: 'Existing child')); + await db.addRelationship(parentId, childId); + await provider.loadRootTasks(); + }); + await pumpAndLoad(tester, buildTestWidget()); + + // Drill in so there IS a current parent to (wrongly) capture the add. + await tester.tap(find.text('Open project')); + await pumpAsync(tester); + expect(provider.currentParent!.name, 'Open project'); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + await search(tester, 'Global capture'); + await tester.tap(find.textContaining('Create')); + await pumpAsync(tester); + + expect(find.text('Add Task'), findsOneWidget); + // Inbox toggle is offered because the add lands at root. + expect(find.text('Inbox'), findsOneWidget); + await tester.runAsync(() async { + await tester.tap(find.text('Add')); + }); + await pumpAsync(tester); + + final created = await tester.runAsync(() async { + final all = await db.getAllTasks(); + return all.firstWhere((t) => t.name == 'Global capture'); + }); + final parents = + await tester.runAsync(() => db.getParentIds(created!.id!)) ?? []; + expect(parents, isEmpty, + reason: 'search create must ignore the drilled-in parent'); + expect(parents, isNot(contains(parentId))); + }); + }); } diff --git a/test/screens/todays_five_screen_test.dart b/test/screens/todays_five_screen_test.dart index 42c9427..1fe30bd 100644 --- a/test/screens/todays_five_screen_test.dart +++ b/test/screens/todays_five_screen_test.dart @@ -1662,6 +1662,188 @@ void main() { }); }); + group('TodaysFiveScreen - search', () { + /// Types into the picker's search field and lets its 200ms debounce fire. + /// pumpAsync alone pumps without advancing the fake clock, so the debounce + /// Timer would never run and the filter would stay stale. + Future search(WidgetTester tester, String query) async { + await tester.enterText(find.byType(TextField).first, query); + await tester.pump(const Duration(milliseconds: 300)); + await pumpAsync(tester); + } + + // [Mechanism] The app bar search icon opens the shared global search + // picker — the same "Search tasks" dialog the other two tabs open. It is + // NOT the "Pin a task to Today's 5" picker (which is leaves-only and pins + // the pick); search spans every task and opens the result instead. + testWidgets('app bar search icon opens the global search dialog', + (tester) async { + await tester.runAsync(() => db.insertTask(Task(name: 'Some task'))); + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + + expect(find.text('Search tasks'), findsOneWidget); + expect(find.text("Pin a task to Today’s 5"), findsNothing); + }); + + // [Mechanism] Search results include non-leaf parents (unpinnable), which + // the pin picker excludes — picking one navigates rather than pinning. + testWidgets('picking a result navigates instead of pinning', (tester) async { + Task? navigated; + await tester.runAsync(() async { + final parentId = await db.insertTask(Task(name: 'Parent project')); + final childId = await db.insertTask(Task(name: 'A child')); + await db.addRelationship(parentId, childId); + }); + await pumpAndLoad( + tester, + buildTestWidget(onNavigateToTask: (t) => navigated = t), + ); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + await search(tester, 'Parent project'); + await tester.tap(find.text('Parent project').last); + await pumpAsync(tester); + + expect(navigated, isNotNull, + reason: 'a search result must open in the All Tasks tab'); + expect(navigated!.name, 'Parent project'); + // Navigating must not pin it — Today's 5 stays empty. + final state = await tester.runAsync( + () => db.getTodaysFiveTaskAndPinIds(_todayKey())); + expect(state!.taskIds, isEmpty); + }); + + // [Mechanism] Empty search → "Create ..." opens the shared root add flow + // with the query pre-filled. Unlike the FAB's create-new flow (pin is + // implicit there), the pin is OPTIONAL here — the toggle is shown while a + // slot is free, and the task is not pinned unless the user asks. + testWidgets('create-from-search files a root task without pinning it', + (tester) async { + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + await search(tester, 'Fresh capture'); + await tester.tap(find.textContaining('Create')); + await pumpAsync(tester); + + expect(find.text('Add Task'), findsOneWidget); + expect(find.text('Inbox'), findsOneWidget); + // Pin toggle offered (a slot is free) but left off. + expect(find.text('Pin'), findsOneWidget); + + await tester.runAsync(() async { + await tester.tap(find.text('Add')); + }); + await pumpAsync(tester); + + final created = await tester.runAsync(() async { + final all = await db.getAllTasks(); + return all.firstWhere((t) => t.name == 'Fresh capture'); + }); + final parents = + await tester.runAsync(() => provider.getParentIds(created!.id!)); + expect(parents, isEmpty, reason: 'search create files at root'); + final state = await tester.runAsync( + () => db.getTodaysFiveTaskAndPinIds(_todayKey())); + expect(state!.taskIds, isEmpty, + reason: 'pin toggle left off → not pinned'); + }); + + // [Mechanism] Turning the optional pin toggle ON must both persist the pin + // AND make the new task visible right away: only the Today's 5 caller passes + // `onCompleted: refreshSnapshots` to showRootAddFromSearch. Drop that wiring + // and the task is pinned in the DB but the list still reads "Nothing pinned + // yet" until a tab switch. + testWidgets('create-from-search with the pin toggle on pins it and shows ' + 'it immediately', (tester) async { + await pumpAndLoad(tester, buildTestWidget()); + expect(find.text('Nothing pinned yet'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + await search(tester, 'Pin me now'); + await tester.tap(find.textContaining('Create')); + await pumpAsync(tester); + + await tester.tap(find.text('Pin')); + await pumpAsync(tester); + await tester.runAsync(() async { + await tester.tap(find.text('Add')); + }); + // The pin path (defer-notify add → pin write → refreshAfterMutation → + // refreshSnapshots reload) needs the fake clock advanced as well as real + // async drained; pumpAsync alone starves the timers those DB locks wait + // on and the reload never lands. + for (var i = 0; i < 30; i++) { + await tester.runAsync(() => Future.delayed( + const Duration(milliseconds: 10))); + await tester.pump(const Duration(milliseconds: 50)); + } + + final created = await tester.runAsync(() async { + final all = await db.getAllTasks(); + return all.firstWhere((t) => t.name == 'Pin me now'); + }); + final state = await tester.runAsync( + () => db.getTodaysFiveTaskAndPinIds(_todayKey())); + expect(state!.pinnedIds, contains(created!.id), + reason: 'pin toggle on → persisted as a pin'); + // Rendered without any reload/tab switch. + expect(find.text('Nothing pinned yet'), findsNothing); + expect(find.text('Pin me now'), findsOneWidget); + }); + + // [Edge case] `showRootAddFromSearch` gates the pin toggle on a free slot + // (`pinnedIds.length < maxPins`). With Today's 5 already full the toggle must + // be absent — otherwise it would be a control that can only fail. + // + // Deliberately ends at Cancel rather than Add: committing the add here + // deadlocks the WIDGET-TEST HARNESS (not the app). Under FakeAsync + + // databaseFactoryFfiNoIsolate, the reload that a full Today's 5 triggers + // holds sqflite's `synchronized` lock while waiting on timers pumpAsync + // never advances ("database has been locked for 0:00:10"), and tearDown's + // db.reset() then blocks forever. The same flow completes in ~2s against + // the real DB factory, and creating-at-root is already covered by the + // no-pin test above, so nothing is lost by stopping at the gate. + testWidgets('create-from-search hides the pin toggle when Today\'s 5 is ' + 'full', (tester) async { + await tester.runAsync(() async { + final ids = []; + for (var i = 1; i <= 5; i++) { + ids.add(await db.insertTask(Task(name: 'Pinned $i'))); + } + await seedTodaysFive(db, ids); + }); + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byIcon(Icons.search)); + await pumpAsync(tester); + await search(tester, 'Overflow capture'); + await tester.tap(find.textContaining('Create')); + await pumpAsync(tester); + + expect(find.text('Add Task'), findsOneWidget); + expect(find.text('Inbox'), findsOneWidget); + expect(find.text('Pin'), findsNothing, + reason: 'no free slot → no pin toggle'); + + // `.last` = the Add Task dialog's Cancel; the search picker's own Cancel + // is still in the tree while its pop animation settles. + await tester.tap(find.text('Cancel').last); + await pumpAsync(tester); + + final state = await tester.runAsync( + () => db.getTodaysFiveTaskAndPinIds(_todayKey())); + expect(state!.pinnedIds.length, 5, + reason: 'the full Today\'s 5 is untouched'); + }); + }); + group('+ FAB visibility at max pins', () { // [Mechanism] Once Today's 5 holds maxPins tasks, the + FAB is hidden so // there's never an add button that can only fail with a "full" message.