diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index cc803d6..3dcd5b7 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -1,10 +1,10 @@ # Test Coverage Inventory -Last updated: 2026-07-25 +Last updated: 2026-07-26 ## Summary -~1469 tests across 36 active test files. Models and data layer are well-covered. Task card at 100%. Triage dialog at 93%. Screens and services have remaining gaps, though `firestore_service` now has HTTP-mocked tests (sync tombstones, delta pulls, soft-delete). `notification_service_test.dart` is block-commented out because the notification service itself is disabled. +~1566 tests across 36 active test files. Models and data layer are well-covered. Task card at 100%. Triage dialog at 93%. Screens and services have remaining gaps, though `firestore_service` now has HTTP-mocked tests (sync tombstones, delta pulls, soft-delete). `notification_service_test.dart` is block-commented out because the notification service itself is disabled. **Manual Today's 5 model (`feature/manual-todays-five`):** Today's 5 is now fully manual — auto-pick/reroll/per-card-swap removed; tasks are pinned via a FAB sheet ("Create new task" / "Pick existing task"). The old `PickTaskForTodayDialog` was merged into a single `TaskPickerDialog` with an opt-in `browse: TaskBrowseConfig` param (flat mode = static candidates + ranking; browse mode = live provider tree + leaf-select); its browse-mode tests live in `test/widgets/task_picker_dialog_browse_test.dart` (renamed from `pick_task_for_today_dialog_test.dart`). A **create-task-from-empty-search** affordance (`onCreateTask` on the picker, `PickerSearchEmptyState`, `AddTaskDialog.initialName`/`AddTaskFlow.initialName`) lets an empty search spin up a task named after the query — filed at root (All Tasks) or created-and-pinned (Today's 5). FAB sheet + pick-existing integration covered in `todays_five_screen_test.dart`. **PR #73 review-fix regressions:** `AddTaskDialog.showAddMultiple:false` hides the brain-dump button for the Today's-5 create flow (which only accepts a `SingleTask`, so the button would silently discard the task) — `small_widgets_test.dart`; browse-mode empty search whose query exactly matches an excluded (already-pinned) leaf shows *"…already in Today's 5"* and suppresses Create so no duplicate is made — `task_picker_dialog_browse_test.dart`; clearing the flat search field applies immediately (no debounce window where a stale Create button lingers) — `task_picker_dialog_test.dart`; `PickerTaskCard` name wraps to 2 lines before truncating — `task_picker_parts_test.dart`. The old `_generateNewSet` auto-pick simulation tests were removed from `task_provider_test.dart`. @@ -29,10 +29,10 @@ Note: `database_helper_test.dart` also covers **deadline auto-pin suppression** ### Screens (partial) - **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** (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_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). Also **screen-FAB Inbox OFF survives "Add multiple"** (`bugfix/preserve-inbox-toggle-into-brain-dump` regression — end-to-end through the Starred screen's own `AddTaskFlow` closure (`isStarred` + `atRoot`): both brain-dumped tasks land as starred ROOT tasks with `isInbox == false` instead of being swept into the Inbox by the brain dump's default-ON toggle). **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). +- **test/screens/task_list_screen_test.dart** (22 tests) — **Inbox OFF survives the switch to "Add multiple"** (`bugfix/preserve-inbox-toggle-into-brain-dump` regression — end-to-end through the real "+" FAB → `AddTaskDialog` → `SwitchToBrainDump` → `AddTaskFlow` → `BrainDumpDialog` → DB chain: both created tasks have `isInbox == false`). 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). - **test/screens/task_list_screen_overflow_menu_test.dart** (7 tests) — Overflow menu: root shows export/import only (no task items), non-root with children shows Rename/Do after/Schedule/Delete/export/import, leaf shows "Also show under..." instead of Rename/Do after, "Add link" vs "Edit link" based on URL presence, divider count (2 for non-root, 1 for root on non-web). ### Services (minimal) @@ -46,7 +46,7 @@ Note: `database_helper_test.dart` also covers **deadline auto-pin suppression** - **test/widgets/task_picker_dialog_browse_test.dart** (23 tests — renamed from `pick_task_for_today_dialog_test.dart` after `PickTaskForTodayDialog` was merged into `TaskPickerDialog(browse:)`) — the unified picker's **browse mode** ("pick existing task" → pin to Today's 5). **Browse** (root tasks shown with inbox hidden, `excludeIds` hides already-pinned tasks, "No tasks here" empty state, tapping a leaf returns the Task, tapping a non-leaf drills in and shows children + back button, drill-then-tap-child returns the child, back button returns to root, Cancel pops null, "Show all N items" toggle for >6 children expanding the list). **Search** (filter by name, match by parent name with "under X" subtitle, `excludeIds` honored in search, only leaves returned (parents excluded), "No matching tasks" empty, clearing search restores browse view, tapping a search result returns the Task). **Create from empty search** (`browse:` + opt-in `onCreateTask`: shows `Create ""` button when search matches nothing, tapping fires `onCreateTask` with the trimmed query, no button when `onCreateTask` null). - **test/widgets/leaf_task_detail_test.dart** (~52 tests) — Name display, rename, URL icon states, Done/Skip buttons, Start/Stop buttons, priority/someday toggle icons and callbacks, "Done today"/"Worked on today" toggle, **dependency icon** (add_task vs hourglass, tap navigates to dependency, long-press opens edit picker, **isBlocked color**: primary when blocked, greyed out when resolved), **pin button** (pinned/unpinned/hidden), **"Done today" fallback** to onDone when onWorkedOn null, **formatTimeAgo** (days/minutes). - **test/widgets/pin_button_test.dart** (~15 tests) — Pin/unpin icons, tooltips, max-pins disabled, muted alpha, callbacks. -- **test/widgets/small_widgets_test.dart** (~38 tests) — `EmptyState` (root/non-root), `DeleteTaskDialog` (cancel/keep-subtrees/delete-everything), `AddTaskDialog` (submit, empty/whitespace rejection, "Add multiple" with text preservation and trimming, pin toggle, **`initialName` pre-fill** — create-from-search seeds the name field and submitting as-is keeps it; **`initialName` carries into brain dump** when "Add multiple" is tapped without typing — validates the `AddTaskFlow` seeding claim), **`AddTaskDialog` toggle placement (`showAddMultiple`)** (`bugfix/todays-five-add-dialog-empty-space`: the Inbox/Pin chips were extracted into a shared `_buildToggles()` helper — "Add multiple" button shown when `showAddMultiple:true`/hidden when false; with `showAddMultiple:true` the chips live in the content "Add multiple" row and are NOT descendants of the actions `OverflowBar`; with `showAddMultiple:false` the row is dropped and the chips fold into the actions `OverflowBar` beside Cancel/Add; toggle state — pin on / inbox off, and the inbox-default-ON baseline — is still reflected in the returned `SingleTask` under **both** placements; pin label reads "Pin" when the inbox chip is shown and "Pin for today" when it's hidden, under the false placement), `BrainDumpDialog` (line counting, whitespace trimming, submit, disabled state, initialText pre-fill), **`AddTaskDialog` duplicate-task suggestion** (`feature/duplicate-task-suggestion` — FINAL design: in-field `info_outline` indicator + `PopupMenuButton` "did you mean" popup, so a match never resizes the dialog; 17 tests. Indicator: no indicator until a matching name is typed, exact case/whitespace-insensitive match shows the indicator (details stay in the popup — `Did you mean:` header absent until opened), substring/non-exact match shows NO indicator, empty `existingTasks` never shows an indicator, **internal double-space is NOT normalized** (`Buy milk` ≠ `Buy milk`, both directions), **single match shows the bare info icon with no count digit**, multiple matches show the count badge (`find.text('2')`), **indicator tooltip singular vs plural** on match count. Popup (opened by tapping `info_outline`): lists the match name + `_locationHint` (`(under Parent)`, `(under Parent +N)`, `(under Inbox)`, or none for a plain root task) + the surface's `existingActionIcon` with `existingActionLabel` as the icon tooltip (not visible text), **all** matches are listed (height-bounded, scrollable popup — no truncation; a **large 6-match set** renders every row as a live selectable `PopupMenuItem` and a **post-cap match** — beyond the old 3-row cap — is still selectable and pops `UseExisting` with the correct task) and **tapping the disabled `Did you mean:` header selects nothing** (no `UseExisting` pop), selecting a match row pops `UseExisting(task)`). +- **test/widgets/small_widgets_test.dart** (~38 tests) — `EmptyState` (root/non-root), `DeleteTaskDialog` (cancel/keep-subtrees/delete-everything), `AddTaskDialog` (submit, empty/whitespace rejection, "Add multiple" with text preservation and trimming, pin toggle, **`initialName` pre-fill** — create-from-search seeds the name field and submitting as-is keeps it; **`initialName` carries into brain dump** when "Add multiple" is tapped without typing — validates the `AddTaskFlow` seeding claim), **`AddTaskDialog` toggle placement (`showAddMultiple`)** (`bugfix/todays-five-add-dialog-empty-space`: the Inbox/Pin chips were extracted into a shared `_buildToggles()` helper — "Add multiple" button shown when `showAddMultiple:true`/hidden when false; with `showAddMultiple:true` the chips live in the content "Add multiple" row and are NOT descendants of the actions `OverflowBar`; with `showAddMultiple:false` the row is dropped and the chips fold into the actions `OverflowBar` beside Cancel/Add; toggle state — pin on / inbox off, and the inbox-default-ON baseline — is still reflected in the returned `SingleTask` under **both** placements; pin label reads "Pin" when the inbox chip is shown and "Pin for today" when it's hidden, under the false placement), `BrainDumpDialog` (line counting, whitespace trimming, submit, disabled state, initialText pre-fill), **Inbox toggle carried into the brain dump (`bugfix/preserve-inbox-toggle-into-brain-dump`)** — `SwitchToBrainDump.addToInbox` + `BrainDumpDialog.initialInbox`: turning Inbox OFF then tapping "Add multiple" pops `SwitchToBrainDump` with `addToInbox: false` (regression — the toggle state used to die at the hand-off, so the brain dump reopened at its own default-ON and filed the batch to the Inbox), the untouched default carries as `true` (baseline), `BrainDumpDialog(initialInbox: false)` starts with Inbox off and returns `addToInbox: false` (mechanism), Pin ON + Inbox OFF still carries Inbox OFF — the Pin choice has no brain-dump equivalent and is dropped (edge case, documents actual behaviour), and a `showInboxOption: false` surface (e.g. Starred "Add subtask") ignores an `initialInbox: true` seed — no chip, `addToInbox: false` (edge case guarding the double gate, since the hand-off passes `_inbox` ungated), **`AddTaskDialog` duplicate-task suggestion** (`feature/duplicate-task-suggestion` — FINAL design: in-field `info_outline` indicator + `PopupMenuButton` "did you mean" popup, so a match never resizes the dialog; 17 tests. Indicator: no indicator until a matching name is typed, exact case/whitespace-insensitive match shows the indicator (details stay in the popup — `Did you mean:` header absent until opened), substring/non-exact match shows NO indicator, empty `existingTasks` never shows an indicator, **internal double-space is NOT normalized** (`Buy milk` ≠ `Buy milk`, both directions), **single match shows the bare info icon with no count digit**, multiple matches show the count badge (`find.text('2')`), **indicator tooltip singular vs plural** on match count. Popup (opened by tapping `info_outline`): lists the match name + `_locationHint` (`(under Parent)`, `(under Parent +N)`, `(under Inbox)`, or none for a plain root task) + the surface's `existingActionIcon` with `existingActionLabel` as the icon tooltip (not visible text), **all** matches are listed (height-bounded, scrollable popup — no truncation; a **large 6-match set** renders every row as a live selectable `PopupMenuItem` and a **post-cap match** — beyond the old 3-row cap — is still selectable and pops `UseExisting` with the correct task) and **tapping the disabled `Did you mean:` header selects nothing** (no `UseExisting` pop), selecting a match row pops `UseExisting(task)`). - **test/widgets/task_card_icons_test.dart** (~12 tests) — Pin vs fire icon, color/size. - **test/widgets/task_card_test.dart** (~46 tests, **100% coverage**) — In-progress icon, long-press menu (all 7 options: Rename, Also show under, Do after, Schedule, Move to, Remove from here, Stop working, Delete), parent tags, pin+priority coexistence, someday bedtime badge, **URL display** (link icon + text), **blocked state** ("After:" text + hourglass), **worked-on-today** (check_circle icon), hidden menu items when callbacks null, **deadline icon** (own deadline, inherited via `effectiveDeadline`, no icon when absent, own takes priority for color). - **test/widgets/schedule_dialog_test.dart** (~25 tests, **87.4% coverage**) — Day chip rendering, Schedule header, Save button enable/disable, pre-selected days, toggle on/off, source labels ("Repeat weekly"/"Inherited from:"/"Custom schedule"), inherited mode (chip selection, tap-to-override, Clear all), override mode (Clear override restores inheritance), ScheduleDialogResult constructor (with deadline field), **deadline section** ("Set deadline" text, formatted date display, inherited deadline read-only with source name, own deadline overrides inherited, Save enabled on deadline-only change). diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index 11b3066..8cc198e 100644 --- a/docs/UI_VIEWS.md +++ b/docs/UI_VIEWS.md @@ -60,7 +60,7 @@ Reference for manual test instructions. Use these names consistently. - **Starred, subtask add** (inside a card's expanded dialog) — `add_link` ("Add here") links the existing task as a subtask of the starred parent (multi-parent link). Typing the parent's own name shows a "That's this task" snackbar; a link that would form a loop shows "Couldn't add — it would create a loop". These snackbars (and the "Added … here" one, with **Undo**) render **inside the expanded dialog** — the dialog hosts its own `ScaffoldMessenger` so they aren't hidden behind it on the page. This is the inverse of the **create-from-search** affordance (which offers to *create* when a search matches nothing). The two are complementary — search matching is substring-based, so a create-from-search entry never simultaneously shows an exact-match suggestion. -- **Brain dump dialog** — opened via the "Add multiple" toggle in the Add Task dialog. Multi-line text field, one task per line (blank lines ignored). Shows a live "N tasks" count. The action button is **disabled and labelled "Add"** when no non-blank lines exist; once there's input it enables and shows the count ("Add N"). "Inbox" toggle shown only at root level. On submit, creates all tasks at once with an "Added N tasks" snackbar. +- **Brain dump dialog** — opened via the "Add multiple" toggle in the Add Task dialog. Multi-line text field, one task per line (blank lines ignored). Shows a live "N tasks" count. The action button is **disabled and labelled "Add"** when no non-blank lines exist; once there's input it enables and shows the count ("Add N"). "Inbox" toggle shown only at root level. **The Inbox toggle opens with whatever state the user had set in the Add Task dialog before tapping "Add multiple"** — turn Inbox off there and the brain dump opens with it off, so the batch honours the choice. (Bug fix: the state used to be dropped at the switch, and the brain dump reopened with its own default-ON, silently filing the batch into the Inbox.) A brain dump not reached via that switch still defaults ON. There is **no "Pin" toggle** here — bulk add never pins, so any pin choice made in the Add Task dialog is deliberately discarded when switching. On submit, creates all tasks at once with an "Added N tasks" snackbar. - **Delete task dialog** — appears when deleting a non-leaf task. Options: "Keep sub-tasks" (reparents children to deleted task's parent) or "Delete everything" (deletes entire subtree). Leaf tasks delete immediately with undo snackbar, no dialog. - **"This task is pinned" warning dialog** — appears when tapping the + FAB on a task that is pinned in Today's 5. Title: "This task is pinned", body: explains that adding a subtask makes it a parent, so it will drop out of Today's 5 (the pin is not transferred to a child). Buttons: "Cancel" / "Add anyway". Shown before the Add Task dialog opens. - **Schedule dialog** — opened via the calendar icon on a task card or leaf detail. Has deadline picker (date + "Due by"/"On" toggle), recurrence settings. diff --git a/lib/widgets/add_task_dialog.dart b/lib/widgets/add_task_dialog.dart index 4834252..9ebf94f 100644 --- a/lib/widgets/add_task_dialog.dart +++ b/lib/widgets/add_task_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../models/task.dart'; import '../utils/display_utils.dart' show normalizeUrl, isAllowedUrl, showInfoSnackBar, UrlTextField; +import 'inbox_toggle_chip.dart'; /// Result from AddTaskDialog: a single task name, a request to switch to brain /// dump mode, or a request to use an already-existing task instead of creating @@ -17,7 +18,17 @@ class SingleTask extends AddTaskResult { class SwitchToBrainDump extends AddTaskResult { final String initialText; - SwitchToBrainDump({this.initialText = ''}); + + /// The Inbox toggle's state when the user tapped "Add multiple", carried over + /// so the brain dump opens with the choice they already made. + /// + /// Bug fix: this used to be dropped. Before — turn Inbox OFF, tap "Add + /// multiple", and the brain dump opened with Inbox back ON (its own default), + /// silently filing the batch into the Inbox against the user's choice. After — + /// the toggle state carries across the switch. + final bool addToInbox; + + SwitchToBrainDump({this.initialText = '', this.addToInbox = true}); } /// The user tapped an inline "already exists" suggestion — they want to act on @@ -238,33 +249,9 @@ class _AddTaskDialogState extends State { List _buildToggles(ColorScheme colorScheme) { return [ if (widget.showInboxOption) - InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () => setState(() => _inbox = !_inbox), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _inbox ? Icons.inbox : Icons.inbox_outlined, - size: 16, - color: _inbox - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - 'Inbox', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: _inbox - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), + InboxToggleChip( + value: _inbox, + onChanged: (v) => setState(() => _inbox = v), ), if (widget.showPinOption) InkWell( @@ -364,7 +351,15 @@ class _AddTaskDialogState extends State { Row( children: [ TextButton( - onPressed: () => Navigator.pop(context, SwitchToBrainDump(initialText: _controller.text.trim())), + // Carry _inbox across so the brain dump keeps the user's Inbox + // choice instead of resetting to its own default-ON. + onPressed: () => Navigator.pop( + context, + SwitchToBrainDump( + initialText: _controller.text.trim(), + addToInbox: _inbox, + ), + ), style: TextButton.styleFrom( foregroundColor: colorScheme.onSurfaceVariant, textStyle: Theme.of(context).textTheme.bodySmall, diff --git a/lib/widgets/add_task_flow.dart b/lib/widgets/add_task_flow.dart index 6e4148e..2b9443a 100644 --- a/lib/widgets/add_task_flow.dart +++ b/lib/widgets/add_task_flow.dart @@ -152,7 +152,8 @@ class AddTaskFlow { if (result is SingleTask) { await _addOne(context, result); } else if (result is SwitchToBrainDump) { - await _addMany(context, result.initialText); + await _addMany(context, result.initialText, + initialInbox: result.addToInbox); } else if (result is UseExisting) { await onUseExisting?.call(result.task); } @@ -208,12 +209,17 @@ class AddTaskFlow { await onCompleted?.call(1); } - Future _addMany(BuildContext context, String initialText) async { + /// [initialInbox] is the Inbox toggle state the user had set in the Add Task + /// dialog before tapping "Add multiple" — forwarded so the brain dump opens + /// with that choice rather than its own default-ON. + Future _addMany(BuildContext context, String initialText, + {bool initialInbox = true}) async { final result = await showDialog( context: context, builder: (_) => BrainDumpDialog( initialText: initialText, showInboxOption: showInboxOption, + initialInbox: initialInbox, ), ); if (!context.mounted || result == null || result.names.isEmpty) return; diff --git a/lib/widgets/brain_dump_dialog.dart b/lib/widgets/brain_dump_dialog.dart index a298003..ad9fd52 100644 --- a/lib/widgets/brain_dump_dialog.dart +++ b/lib/widgets/brain_dump_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'inbox_toggle_chip.dart'; /// Dialog for rapid multi-task entry. Each line becomes a separate task. /// Returns a list of task names (non-empty, trimmed). @@ -13,7 +14,18 @@ class BrainDumpDialog extends StatefulWidget { final String initialText; final bool showInboxOption; - const BrainDumpDialog({super.key, this.initialText = '', this.showInboxOption = false}); + /// Starting state of the Inbox toggle. Defaults ON (a fresh brain dump files + /// to the Inbox), but callers arriving from the Add Task dialog's "Add + /// multiple" pass the toggle state the user had already set there, so the + /// choice survives the switch instead of silently reverting to ON. + final bool initialInbox; + + const BrainDumpDialog({ + super.key, + this.initialText = '', + this.showInboxOption = false, + this.initialInbox = true, + }); @override State createState() => _BrainDumpDialogState(); @@ -22,11 +34,12 @@ class BrainDumpDialog extends StatefulWidget { class _BrainDumpDialogState extends State { final _controller = TextEditingController(); int _lineCount = 0; - bool _inbox = true; + late bool _inbox; @override void initState() { super.initState(); + _inbox = widget.initialInbox; if (widget.initialText.isNotEmpty) { _controller.text = widget.initialText; } @@ -107,33 +120,9 @@ class _BrainDumpDialogState extends State { ), const Spacer(), if (widget.showInboxOption) - InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () => setState(() => _inbox = !_inbox), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _inbox ? Icons.inbox : Icons.inbox_outlined, - size: 16, - color: _inbox - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurfaceVariant.withAlpha(120), - ), - const SizedBox(width: 4), - Text( - 'Inbox', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: _inbox - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurfaceVariant.withAlpha(120), - ), - ), - ], - ), - ), + InboxToggleChip( + value: _inbox, + onChanged: (v) => setState(() => _inbox = v), ), ], ), diff --git a/lib/widgets/inbox_toggle_chip.dart b/lib/widgets/inbox_toggle_chip.dart new file mode 100644 index 0000000..f099e9f --- /dev/null +++ b/lib/widgets/inbox_toggle_chip.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +/// The "Inbox" toggle chip shown by both the Add Task dialog and the Brain dump +/// dialog. +/// +/// Extracted because the two had drifted: the ON state matched, but the OFF +/// state used `onSurfaceVariant` in the Add Task dialog and +/// `onSurfaceVariant.withAlpha(120)` in the brain dump — so the same chip +/// visibly dimmed when the user tapped "Add multiple". That was easy to miss +/// while the brain dump's OFF state was only reachable by tapping, but once the +/// Inbox choice began carrying across the switch, OFF became the brain dump's +/// *opening* state and the mismatch showed on every use. The extra fade also +/// read as "disabled" rather than "off". +/// +/// One widget, so the two placements cannot diverge again. +class InboxToggleChip extends StatelessWidget { + const InboxToggleChip({ + super.key, + required this.value, + required this.onChanged, + }); + + /// Whether the task(s) will be filed in the Inbox. + final bool value; + + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + // ON = accent; OFF = plain muted (NOT further faded — that reads as + // disabled, and the chip is always tappable). + final color = value ? colorScheme.primary : colorScheme.onSurfaceVariant; + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => onChanged(!value), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + value ? Icons.inbox : Icons.inbox_outlined, + size: 16, + color: color, + ), + const SizedBox(width: 4), + Text( + 'Inbox', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: color), + ), + ], + ), + ), + ); + } +} diff --git a/test/screens/starred_screen_test.dart b/test/screens/starred_screen_test.dart index 891cfb7..17fccbc 100644 --- a/test/screens/starred_screen_test.dart +++ b/test/screens/starred_screen_test.dart @@ -1013,6 +1013,48 @@ void main() { expect(parents, isEmpty, reason: 'screen FAB must add at root, not under any task'); }); + + // [Regression] Same dropped-Inbox-toggle bug as the All Tasks "+" FAB, but + // through the Starred screen's own AddTaskFlow (a different addBatch closure + // — isStarred + atRoot). Before: turning Inbox OFF and tapping "Add + // multiple" reopened the brain dump with Inbox back ON, so the whole batch + // was filed into the Inbox against the user's choice. After: the batch lands + // as plain starred root tasks. + testWidgets('screen FAB: Inbox OFF survives "Add multiple"', + (tester) async { + await pumpAndLoad(tester, buildTestWidget()); + + await tester.tap(find.byType(FloatingActionButton)); + await pumpAsync(tester); + expect(find.text('Inbox'), findsOneWidget); + + // Turn Inbox OFF, then switch to the brain dump. + await tester.tap(find.text('Inbox')); + await pumpAsync(tester); + await tester.tap(find.text('Add multiple')); + await pumpAsync(tester); + expect(find.text('Brain dump'), findsOneWidget); + + await tester.enterText( + find.byType(TextField).first, 'Starred one\nStarred two'); + await pumpAsync(tester); + await tester.runAsync(() async { + await tester.tap(find.text('Add 2')); + }); + await pumpAsync(tester); + + final batch = await tester.runAsync(() async { + final all = await db.getAllTasks(); + return all.where((t) => t.name.startsWith('Starred ')).toList(); + }); + expect(batch, hasLength(2), reason: 'both lines created'); + for (final t in batch!) { + expect(t.isInbox, isFalse, + reason: '"${t.name}" must honour the Inbox-OFF choice'); + expect(t.isStarred, isTrue, + reason: 'screen FAB auto-stars the batch too'); + } + }); }); group('StarredScreen - search', () { diff --git a/test/screens/task_list_screen_test.dart b/test/screens/task_list_screen_test.dart index 92d525c..f7e0d16 100644 --- a/test/screens/task_list_screen_test.dart +++ b/test/screens/task_list_screen_test.dart @@ -742,6 +742,46 @@ void main() { await pumpAsync(tester); } + // [Regression] End-to-end for the dropped-Inbox-toggle bug, through the real + // AddTaskDialog → SwitchToBrainDump → AddTaskFlow → BrainDumpDialog chain + // (the unit tests in small_widgets_test.dart cover each seam in isolation). + // Before: turning Inbox OFF at root and then tapping "Add multiple" reopened + // the brain dump with Inbox back ON, so the whole batch was filed into the + // Inbox against the user's choice. After: every task lands outside the Inbox. + testWidgets('Inbox OFF survives the switch to "Add multiple"', + (tester) async { + await tester.runAsync(() => provider.loadRootTasks()); + await pumpAndLoad(tester, buildTestWidget()); + + // Root level, so the "+" FAB's dialog offers the Inbox toggle. + await tester.tap(find.byType(FloatingActionButton)); + await pumpAsync(tester); + expect(find.text('Inbox'), findsOneWidget); + + // Turn Inbox OFF, then switch to the brain dump. + await tester.tap(find.text('Inbox')); + await pumpAsync(tester); + await tester.tap(find.text('Add multiple')); + await pumpAsync(tester); + + await tester.enterText( + find.byType(TextField).first, 'Batch one\nBatch two'); + await pumpAsync(tester); + await tester.runAsync(() async { + await tester.tap(find.textContaining('Add')); + }); + await pumpAsync(tester); + + final all = await tester.runAsync(() => db.getAllTasks()) ?? []; + final batch = + all.where((t) => t.name.startsWith('Batch ')).toList(); + expect(batch.length, 2, reason: 'both lines created'); + for (final t in batch) { + expect(t.isInbox, isFalse, + reason: '"${t.name}" must honour the Inbox-OFF choice'); + } + }); + // [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', diff --git a/test/widgets/small_widgets_test.dart b/test/widgets/small_widgets_test.dart index f2db084..7522ecc 100644 --- a/test/widgets/small_widgets_test.dart +++ b/test/widgets/small_widgets_test.dart @@ -272,6 +272,153 @@ void main() { expect((result as SwitchToBrainDump).initialText, 'buy milk'); }); + // [Regression] Inbox OFF → "Add multiple" used to drop the toggle state: + // SwitchToBrainDump carried only the text, so the brain dump reopened with + // its own default-ON and filed the batch to the Inbox against the user's + // choice. The state must ride along on the result object. + testWidgets('"Add multiple" carries the Inbox toggle state (OFF)', + (tester) async { + AddTaskResult? result; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + result = await showDialog( + context: context, + builder: (_) => const AddTaskDialog(showInboxOption: true), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // Inbox defaults ON — turn it OFF before switching to the brain dump. + await tester.tap(find.text('Inbox')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add multiple')); + await tester.pumpAndSettle(); + + expect(result, isA()); + expect((result as SwitchToBrainDump).addToInbox, isFalse, + reason: 'the OFF choice must survive the switch'); + }); + + // [Baseline] The untouched default (ON) must also carry over, so the fix + // isn't just inverting the value. + testWidgets('"Add multiple" carries the Inbox toggle state (ON)', + (tester) async { + AddTaskResult? result; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + result = await showDialog( + context: context, + builder: (_) => const AddTaskDialog(showInboxOption: true), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add multiple')); + await tester.pumpAndSettle(); + + expect((result as SwitchToBrainDump).addToInbox, isTrue); + }); + + // [Mechanism] The receiving end: BrainDumpDialog must honour initialInbox + // instead of always starting ON. + testWidgets('BrainDumpDialog honours initialInbox: false', (tester) async { + BrainDumpResult? result; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + result = await showDialog( + context: context, + builder: (_) => const BrainDumpDialog( + showInboxOption: true, + initialInbox: false, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'one\ntwo'); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('Add')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.names, ['one', 'two']); + expect(result!.addToInbox, isFalse, + reason: 'opened with Inbox OFF, so the batch must not go to Inbox'); + }); + + // [Edge case] Pin ON + Inbox OFF: the Pin choice has no brain-dump + // equivalent (BrainDumpDialog offers no pin toggle) and is dropped, but that + // must not disturb the Inbox state riding across. Documents actual + // behaviour so a future "carry the pin too" change is a deliberate one. + testWidgets('"Add multiple" carries Inbox OFF even with Pin toggled on', + (tester) async { + AddTaskResult? result; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + result = await showDialog( + context: context, + builder: (_) => const AddTaskDialog( + showInboxOption: true, + showPinOption: true, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pin')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Inbox')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add multiple')); + await tester.pumpAndSettle(); + + expect((result as SwitchToBrainDump).addToInbox, isFalse); + }); + testWidgets('"Add multiple" is hidden when showAddMultiple is false', (tester) async { // Bug fix: the Today's 5 create flow only handles SingleTask, so it must @@ -791,7 +938,8 @@ void main() { group('BrainDumpDialog inbox toggle', () { Future openBrainDumpWithInbox(WidgetTester tester, {required ValueChanged onResult, - bool showInboxOption = true}) async { + bool showInboxOption = true, + bool initialInbox = true}) async { await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -800,8 +948,10 @@ void main() { onPressed: () async { final result = await showDialog( context: context, - builder: (_) => - BrainDumpDialog(showInboxOption: showInboxOption), + builder: (_) => BrainDumpDialog( + showInboxOption: showInboxOption, + initialInbox: initialInbox, + ), ); onResult(result); }, @@ -864,6 +1014,34 @@ void main() { expect(result, isNotNull); expect(result!.addToInbox, isFalse); }); + + // [Edge case] A caller with showInboxOption:false (e.g. the Starred + // "Add subtask" flow, where inbox filing makes no sense) is unaffected by + // the new initialInbox seed: no chip is offered and nothing is filed to the + // Inbox, even though the seed arrives ON. Guards the double gate — the + // "Add multiple" hand-off passes _inbox UNgated, so BrainDumpDialog's own + // `showInboxOption && _inbox` gate is what keeps subtask batches out of the + // Inbox. + testWidgets('showInboxOption:false ignores an initialInbox:true seed', + (tester) async { + BrainDumpResult? result; + await openBrainDumpWithInbox(tester, + onResult: (r) => result = r, + showInboxOption: false, + initialInbox: true); + + await tester.enterText(find.byType(TextField), 'Sub A\nSub B'); + await tester.pumpAndSettle(); + + expect(find.text('Inbox'), findsNothing, + reason: 'no Inbox chip when the surface does not offer inbox filing'); + + await tester.tap(find.text('Add 2')); + await tester.pumpAndSettle(); + + expect(result!.names, ['Sub A', 'Sub B']); + expect(result!.addToInbox, isFalse); + }); }); // ---------------------------------------------------------------------------