diff --git a/.claude/skills/manual-test/SKILL.md b/.claude/skills/manual-test/SKILL.md index 7b29f35..bafe55c 100644 --- a/.claude/skills/manual-test/SKILL.md +++ b/.claude/skills/manual-test/SKILL.md @@ -98,8 +98,13 @@ D2. Reach [populated / overflowing state] → [what to look at] → does it look - **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** — 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. + **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 the decision is theirs. 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. + + **Always give a recommendation — asking is not the same as abstaining.** You have read the diff and written the checklist, so you know better than the user whether these particular tests need a predictable DB. Put your recommended option **first** in the `AskUserQuestion` list with **"(Recommended)"** in the label, and give the **actual reason from this checklist** in its description — name the test that needs it (e.g. "Section 3 needs a known, small set of eligible leaf tasks, otherwise the 'few pills fit' case can't happen"), not a generic "cleaner is better". This is a testability call, not a taste call, so the no-coin-flipping rule does not apply — never present the options flat and leave the user to guess. + + **Which way to lean.** Recommend **clearing** when the checklist depends on counts, ordering, emptiness, or "not enough items to fill X" states — anything a large existing task set would drown out (Today's 5 empty state, suggestion pools, overflow/no-overflow thresholds, "N others" badges). Recommend **keeping** when the change is about **existing-data / migration / sync-merge** behavior, when the tests need pre-existing fixtures that only live in the current DB, when every test creates its own tasks and is indifferent to what else is there, or when the user has already said to keep their data. Whichever you recommend, state in the same breath what it costs: if keeping, name the specific test that will be weakened and how to judge it anyway. - Mention which platform to test on (Linux via `./dev.sh` unless the change is mobile-specific). +- **Width-dependent tests: use maximise/restore, not "drag the window to half width".** On the dev desktop the app launches at a deliberately phone-shaped **411x820** (`linux/runner/my_application.cc` sets that default size), so the wide and narrow cases are **maximise** vs **un-maximise**. Important caveat: un-maximising returns the window to **whatever size it was last dragged to**, not to 411x820 — the phone-shaped size only comes back on a **fresh `./dev.sh` launch**. So if a section needs the true narrow case and the user has resized during the round, tell them to **relaunch `./dev.sh`** rather than assuming restore is phone-width. Asking the user to "drag the right edge until X no longer fits" makes them fight the window manager for an arbitrary intermediate width. Phrase width tests as **"maximise the window"** / **"un-maximise (restore) it"**, and pick task names long enough that the wide state genuinely fits and the restored state genuinely overflows, so the two built-in states are all that's needed. Only ask for a manual drag when a test truly needs a specific in-between width — and say why. - **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: - An undo test must be a **separate, self-contained test case** — never an addendum tagged onto a non-undo test ("now tap Undo"). diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index cc803d6..6c2c430 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -4,7 +4,7 @@ Last updated: 2026-07-25 ## 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`. @@ -27,7 +27,7 @@ Note: `database_helper_test.dart` also covers **deadline auto-pin suppression** - **test/providers/auth_provider_test.dart** (6 tests) — `setSyncStatus` updates/notifications, `isConfigured`, initial state. ### Screens (partial) -- **test/screens/todays_five_screen_test.dart** (~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/todays_five_screen_test.dart** (91 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. **Right-edge fade (`bugfix/also-done-fade`)**: no `ShaderMask` when the chips fit on screen (regression — the fade used to be applied unconditionally, dimming the last chip even with room to spare); a `ShaderMask` IS applied when the chips overflow (mechanism); and dragging the strip to its right-hand end clears the fade, with `extentAfter == 0` asserted (regression — also guards the `GlobalKey` re-parent: without it, toggling the mask rebuilt the `Scrollable` and reset the offset to 0, so the fade could never clear). Swiping back left from the far right re-shows the fade and the scroll offset survives BOTH mask toggles (mechanism — asserts `pixels == maxScrollExtent` after un-masking and `0 < pixels < maxScrollExtent` after re-masking; fails if the `GlobalKey`/`KeyedSubtree` is removed). Three chips that fit on the default 800px surface DO fade at phone width (360x640) with no interaction (edge case — the fade is driven by `ScrollMetricsNotification` viewport/content size, not only user scrolling). Same two cases for the **Suggested pill band**, which shares the fade helper: no `ShaderMask` with 4 leaves (2 columns, fits 800px), `ShaderMask` present with 24 long-named leaves. - **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_reorder_test.dart** (12 tests) — `reorderByDependencyChains`: empty deps no-op, single blocker→dependent pair, blocked-before-blocker reorder, transitive chain (A→B→C), reverse-order chain, unrelated tasks preserved, multiple dependents per blocker, empty task list, single task, missing task IDs in deps, independent chains, deep 4-task chain. diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index 11b3066..f587390 100644 --- a/docs/UI_VIEWS.md +++ b/docs/UI_VIEWS.md @@ -13,7 +13,7 @@ Reference for manual test instructions. Use these names consistently. - **Deadline-today auto-pin behavior.** A task due today is force-pinned each time the Today tab loads (and on in-place refreshes, so a deadline set to today while the app is open pins without a restart). If the user **removes** it — the **X button / "Remove" tile** on the Today tab, *or* the **unpin icon on the All Tasks tab** — it is suppressed and won't be re-pinned for the rest of that day; a *different* task that becomes due today still auto-pins (suppression is per task). Manually pinning the removed task back (from either tab) clears the suppression. The card uses the existing deadline clock icon (deepOrange when due within 2 days) — there is no separate "Today" badge. - **Layout** — the Today tab is a **flexible top region + bottom-pinned sections**, structurally identical in the empty and populated states: the **Today's 5 area** takes the scrollable top region, and **Suggested** (opt-in) + **Also done today** are **pinned to the bottom** (each rendering only when it has content). Because the bottom sections are compact bottom-pinned **boxes** (Suggested is a 2-row sideways-scrolling band; Also done today is one row), they sit in the **same on-screen position** whether or not anything is pinned; leftover space always falls between the top area and the bottom sections. Whichever section is **bottom-most** (Also done today when present, else the Suggested box) gets a **right inset so it ends just before the + FAB** — it never renders behind it. The **only** thing that differs between states is the top region — a "Nothing pinned yet" hero **centered** in the space (empty) vs. the progress bar + task cards top-aligned (populated). - **Empty state** — a hero **centered in the flexible top region** (fills the space so there's no dead void above the bottom-pinned sections). Icon: `push_pin_outlined` (64). Heading: "Nothing pinned yet". Body: "Tap the + button to pick a task to focus on today." The (left-aligned) **"Show suggestions"** control lives in the bottom-pinned Suggested section, not inside the hero. -- **Suggested section (opt-in)** — an algorithm-driven nudge, hidden by default so unrelated tasks don't clutter the focus screen. Collapsed, it's a left-aligned **"Show suggestions"** button (`lightbulb_outline` — outlined under the empty hero, a subtle text button below a populated list); tapping it reveals a **bordered box** (same surface / border / radius as the "Also done today" box, so the two bottom sections read as matching cards) headed by a **"Suggested" label** matching the **Hide** button's size/weight (`labelLarge`, just the muted label colour vs Hide's primary) with **Hide** on the right (no divider rule), followed by **all eligible leaf-task suggestions** (weighted order, best first) as **sleek width-capped stadium pills laid out in a 2-row band that scrolls sideways** (`lightbulb_outline` leading; column-major — each column holds up to two pills, top then bottom; a right-edge fade hints there's more to swipe). The strip scrolls sideways through the **whole** eligible set — not a fixed handful — so more tasks appear as you swipe, while staying a bounded ~2 rows tall on **any** width (instead of stacking one-pill-per-row into many rows on a narrow phone). It's **lazily loaded** — a horizontal `ListView.builder` builds only the columns near the viewport (~4 pills at a time) and materialises more as you scroll — so a large eligible set stays cheap. Each pill shows just the **task name** (as much as fits, ellipsised at the pill's ~260px cap — **no parent on the pill**), an **add (`add_circle`)** button to add it to Today's 5, and an **(X) "Dismiss suggestion"** button. **Tapping the pill body** opens an **options bottom sheet**, left-aligned, headed by the **full task name** (bigger/emphasised) and the **full untruncated ancestor path** (the pill omits the parent, so the sheet is where the location shows), then three actions — **Add to Today's 5** / **Go to task** (open in All Tasks) / **Dismiss suggestion** — each with a clarifying subtitle (chosen over a bare navigate/pin so the action is explicit and mis-tap-proof). A **"Hide"** action collapses the section again. Suggestions use the **same weighted selection as the All Tasks roulette** (schedule/deadline/priority/staleness boosts + root-size normalization via `pickWeightedN`), excluding tasks already in Today's 5, done today, blocked, or dismissed this session. Accepting or dismissing one just **removes** it (all eligible were already shown — no backfill); the ordered set is stable and only recomputed when the list empties. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are only computed on first expand. +- **Suggested section (opt-in)** — an algorithm-driven nudge, hidden by default so unrelated tasks don't clutter the focus screen. Collapsed, it's a left-aligned **"Show suggestions"** button (`lightbulb_outline` — outlined under the empty hero, a subtle text button below a populated list); tapping it reveals a **bordered box** (same surface / border / radius as the "Also done today" box, so the two bottom sections read as matching cards) headed by a **"Suggested" label** matching the **Hide** button's size/weight (`labelLarge`, just the muted label colour vs Hide's primary) with **Hide** on the right (no divider rule), followed by **all eligible leaf-task suggestions** (weighted order, best first) as **sleek width-capped stadium pills laid out in a 2-row band that scrolls sideways** (`lightbulb_outline` leading; column-major — each column holds up to two pills, top then bottom; a right-edge fade hints there's more to swipe, shown only while pills are actually scrolled off past the right edge). The strip scrolls sideways through the **whole** eligible set — not a fixed handful — so more tasks appear as you swipe, while staying a bounded ~2 rows tall on **any** width (instead of stacking one-pill-per-row into many rows on a narrow phone). It's **lazily loaded** — a horizontal `ListView.builder` builds only the columns near the viewport (~4 pills at a time) and materialises more as you scroll — so a large eligible set stays cheap. Each pill shows just the **task name** (as much as fits, ellipsised at the pill's ~260px cap — **no parent on the pill**), an **add (`add_circle`)** button to add it to Today's 5, and an **(X) "Dismiss suggestion"** button. **Tapping the pill body** opens an **options bottom sheet**, left-aligned, headed by the **full task name** (bigger/emphasised) and the **full untruncated ancestor path** (the pill omits the parent, so the sheet is where the location shows), then three actions — **Add to Today's 5** / **Go to task** (open in All Tasks) / **Dismiss suggestion** — each with a clarifying subtitle (chosen over a bare navigate/pin so the action is explicit and mis-tap-proof). A **"Hide"** action collapses the section again. Suggestions use the **same weighted selection as the All Tasks roulette** (schedule/deadline/priority/staleness boosts + root-size normalization via `pickWeightedN`), excluding tasks already in Today's 5, done today, blocked, or dismissed this session. Accepting or dismissing one just **removes** it (all eligible were already shown — no backfill); the ordered set is stable and only recomputed when the list empties. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are computed on **every expand**, not just the first: **Hide** frees the current picks, so tapping "Show suggestions" again runs a fresh weighted pass and can surface a different set (`_toggleSuggestions` → `_refreshSuggestions`). Newly created tasks therefore become eligible after a Hide + Show — no app restart needed. While the section stays expanded the set is stable. - **+ FAB (`Icons.add`)** — bottom-right floating action button, shown whenever Today's 5 has a free slot. It is **hidden once Today's 5 is full (5 tasks)** and reappears when the user removes one (so there's never an add button that can only fail). Tap opens the **"Add to Today's 5" bottom sheet** with two options: - **Create new task** (`add_circle_outline`) — opens the standard Add Task dialog (no "Pin" toggle since pin is implicit; "Inbox" toggle is shown and defaults ON; **no "Add multiple" button** — this flow only accepts a single task, so the brain-dump button is hidden to avoid silently discarding the task). Created at root level regardless of where the All Tasks tab is currently navigated. The new task is auto-pinned into Today's 5. - **Pick existing task** (`search`) — opens the **"Pin a task to Today's 5" dialog**, modeled on the triage dialog: always-visible search bar at the top, browse tree below. **Search mode** (when filter is non-empty) lists all leaf tasks matching the query (matches against task name and parent names). **Browse mode** (default) shows root-level tasks (excluding inbox tasks); tapping a non-leaf (`folder_outlined` icon, chevron trailing) drills into its children, tapping a leaf (`push_pin_outlined` icon, check trailing) pins it. A back arrow returns up the browse stack. Tasks already in Today's 5 are hidden from both views. When a search matches nothing, the empty state shows a **Create "<query>"** button — tapping it routes into the same **Create new task** flow above (Add Task dialog pre-filled with the query, created at root and auto-pinned into Today's 5). Exception: if the query **exactly matches a task already in Today's 5** (those are hidden from the search pool), the empty state instead reads **"<name> is already in Today's 5"** with **no Create button** — preventing an accidental duplicate. When Today's 5 is already full (5 tasks), the + FAB is hidden (see above), so neither option is reachable in the first place; the "Today's 5 is full — remove one first" snackbar is only a defensive guard for the rare case a slot fills (deadline-today auto-pin or a sync pull) after the sheet/dialog is already open. @@ -21,7 +21,7 @@ Reference for manual test instructions. Use these names consistently. - **Remove (X) button** — trailing icon on the right edge of the card (vertically centered, just before the "Go to task" `open_in_new` icon), undone tasks only. Tap shows the **"Remove from Today's 5?" confirmation dialog** (Cancel / Remove). On confirm, the task is dropped from Today's 5. The task itself is not deleted — it remains in All Tasks and can be re-added. - **Bottom sheet** — appears when **tapping** an undone task card. Options: "Done today", "Done for good!", "In progress"/"Stop working", **"Go to task"** (opens it in All Tasks — same action as the card's inline `open_in_new` icon), and "Remove from Today's 5" (which also goes through the same confirmation dialog). - **Completion animation** — brief celebration overlay after marking done. -- **"Also done today" section** — a bordered box (label + a **single horizontal row of green ✓ chips that scrolls sideways**; one row tall, no expand/collapse) showing tasks worked on today outside the Today's 5 set. Shown **whenever something was done today**, independent of whether Today's 5 has any pinned tasks (so it appears in the empty state too, in the same position as when populated). When Today's 5 is populated, a "+N others" badge also appears on the progress line. +- **"Also done today" section** — a bordered box (label + a **single horizontal row of green ✓ chips that scrolls sideways**; one row tall, no expand/collapse; a right-edge fade hints there's more to swipe — shown **only** while chips are actually scrolled off past the right edge, so the last chip isn't dimmed when the row fits and the fade clears once you scroll to the end). Each chip's full task name is available as a **tooltip**: on **desktop it appears on hover**, on **mobile on tap** (`TooltipTriggerMode.tap` governs touch only — pointer hover always triggers a Flutter tooltip) showing tasks worked on today outside the Today's 5 set. Shown **whenever something was done today**, independent of whether Today's 5 has any pinned tasks (so it appears in the empty state too, in the same position as when populated). When Today's 5 is populated, a "+N others" badge also appears on the progress line. - **Progress bar** — segmented progress bar at top showing done/total. - **Sections** — flat list of undone tasks (no header), then a "Done" section header (with check-circle icon) for completed ones if any exist. There is no longer a "Must do" or "Also on the table" header — every task in Today's 5 is implicitly "must-do" in the manual model. @@ -44,8 +44,8 @@ Reference for manual test instructions. Use these names consistently. - **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. - **Dependency icon (hourglass)** — replaces the "Do after..." icon when the task has a dependency. **Tap** navigates to the blocker task. **Long-press** opens the "Do X after..." picker dialog to change or remove the dependency. Color behavior: **primary color** when actively blocked, **greyed out** when the blocker is no longer blocking (e.g. marked "Done today"). When the blocker is completed ("Done for good") or skipped, the dependency row is deleted from the DB, so the hourglass **disappears entirely**. -- **"Done today" button** — filled purple button on the leaf detail view. Marks the task as worked on. -- **"Worked on today" button** — outlined button that replaces "Done today" after marking. Acts as undo for the worked-on status. +- **"Done today" button** — filled purple button on the leaf detail view. Marks the task as worked on, then **automatically navigates back to the task's parent** (root if the task was at root) — you do NOT tap the back arrow yourself, and you land on the parent list, not the leaf view (`markWorkedOnAndNavigateBack` → `navigateBack()`). **"Done for good!"** and **Skip** exit the leaf view the same way. +- **"Worked on today" button** — outlined button that replaces "Done today" after marking. Acts as undo for the worked-on status. Because marking navigated you away, this state is only visible after **re-entering** the task's detail view; tapping it un-marks **in place** and leaves you in the leaf detail view (no navigation), so you return to the parent with the back arrow. - **Flare FAB** (`Icons.flare`) — bottom-right FAB on non-leaf views. Triggers the **spotlight** random pick animation. - **Spotlight overlay** — dims all task cards and spotlights (glow + scale-up) a randomly picked task. Tapping the spotlighted card navigates into it. The FAB column changes during spotlight: "Spin Again" (flare, rerolls), "Open" (`open_in_new`, navigates into task), "Spin Deeper" (`keyboard_double_arrow_down`, shown if task has children — navigates in and auto-spotlights a child). Tapping the dim backdrop dismisses. Back button dismisses. - **Lucky Pick dialog** — fallback dialog shown if spotlight can't render (e.g. task not in grid). Title: "Lucky Pick". Actions: "Spin Again", "Go Deeper", "Go to Task". diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index debb9d1..910d529 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -1469,20 +1469,10 @@ class TodaysFiveScreenState extends State /// Wraps a left-packed horizontal strip with a soft right-edge fade so an /// overflowing item fades out (a "scroll sideways →" hint) instead of being - /// chopped mid-word. When the content doesn't overflow, the right edge is - /// empty so the fade is invisible. - Widget _fadeRightEdge({required Widget child}) { - return ShaderMask( - shaderCallback: (Rect bounds) => const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - stops: [0.0, 0.88, 1.0], - colors: [Colors.black, Colors.black, Colors.transparent], - ).createShader(bounds), - blendMode: BlendMode.dstIn, - child: child, - ); - } + /// chopped mid-word. The fade is only painted while there is content past the + /// right edge — see [_FadeRightEdge]. + Widget _fadeRightEdge({required Widget child}) => + _FadeRightEdge(child: child); /// Options sheet for a suggestion pill's body tap (user's choice over a bare /// navigate/pin): "Add to Today's 5" / "Go to task" / "Dismiss suggestion". @@ -2021,3 +2011,80 @@ class TodaysFiveScreenState extends State ); } } + +/// Soft right-edge fade for a horizontally scrolling strip ("Also done today" +/// chips, the Suggested pills), painted **only while there is content scrolled +/// off past the right edge**. +/// +/// Bug fix: this used to be a bare [ShaderMask] applied unconditionally, so the +/// rightmost ~12% of the strip was always faded — the last "Also done today" +/// chip looked dimmed even when the whole row fit on screen with room to spare, +/// and it stayed dimmed once the user scrolled to the end. Now the strip's +/// scroll metrics drive the fade: it appears only when `extentAfter > 0` (there +/// really is more to swipe to) and disappears at the end of the scroll. +class _FadeRightEdge extends StatefulWidget { + const _FadeRightEdge({required this.child}); + + final Widget child; + + @override + State<_FadeRightEdge> createState() => _FadeRightEdgeState(); +} + +class _FadeRightEdgeState extends State<_FadeRightEdge> { + bool _hasMoreRight = false; + + /// Toggling the fade adds/removes the [ShaderMask] above the strip, which + /// re-parents it. Without a [GlobalKey] the strip's `Scrollable` would be + /// rebuilt from scratch on every toggle and snap back to offset 0 — so the + /// first swipe would reset itself and the fade could never clear. The key + /// carries the live scroll position across the re-parent. + final GlobalKey _stripKey = GlobalKey(); + + /// Scroll metrics arrive during layout, so defer the rebuild to after the + /// current frame rather than calling `setState` mid-layout. + void _syncFade(ScrollMetrics metrics) { + final hasMoreRight = metrics.extentAfter > 0.5; + if (hasMoreRight == _hasMoreRight) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || hasMoreRight == _hasMoreRight) return; + setState(() => _hasMoreRight = hasMoreRight); + }); + } + + @override + Widget build(BuildContext context) { + // Both notifications matter: metrics fire when the content or viewport size + // changes (chips added/removed, window resized), scroll fires as the user + // swipes towards the end. + final strip = KeyedSubtree( + key: _stripKey, + child: NotificationListener( + onNotification: (n) { + _syncFade(n.metrics); + return false; + }, + child: NotificationListener( + onNotification: (n) { + _syncFade(n.metrics); + return false; + }, + child: widget.child, + ), + ), + ); + + if (!_hasMoreRight) return strip; + + return ShaderMask( + shaderCallback: (Rect bounds) => const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + stops: [0.0, 0.88, 1.0], + colors: [Colors.black, Colors.black, Colors.transparent], + ).createShader(bounds), + blendMode: BlendMode.dstIn, + child: strip, + ); + } +} diff --git a/test/screens/todays_five_screen_test.dart b/test/screens/todays_five_screen_test.dart index 1fe30bd..e65a3f3 100644 --- a/test/screens/todays_five_screen_test.dart +++ b/test/screens/todays_five_screen_test.dart @@ -2054,6 +2054,149 @@ void main() { expect(find.byIcon(Icons.expand_more), findsNothing); expect(find.byIcon(Icons.expand_less), findsNothing); }); + + // [Regression] The right-edge "scroll sideways" fade was a bare ShaderMask + // applied unconditionally, so the last chip was always dimmed — even when + // the whole strip fit on screen with room to spare. The fade must only be + // painted when there is content scrolled off past the right edge. + testWidgets('does not fade the last chip when the strip fits on screen', + (tester) async { + await tester.runAsync(() async { + final id = await db.insertTask(Task(name: 'Solo done')); + await db.completeTask(id); + }); + + await pumpAndLoad(tester, buildTestWidget()); + + expect(find.text('Solo done'), findsOneWidget); + expect(find.byType(ShaderMask), findsNothing); + }); + + testWidgets('fades the right edge when the chips overflow the strip', + (tester) async { + await tester.runAsync(() async { + for (var i = 0; i < 12; i++) { + final id = await db.insertTask( + Task(name: 'A rather long finished task name $i')); + await db.completeTask(id); + } + }); + + await pumpAndLoad(tester, buildTestWidget()); + // The fade is applied one frame after the scroll metrics land. + await tester.pump(); + + expect(find.byType(ShaderMask), findsOneWidget); + }); + + // [Regression] Second half of the same bug: because the old fade was + // unconditional, it stayed painted after the user had scrolled all the way + // right — the last chip was dimmed exactly when there was nothing more to + // scroll to. The fade must clear once `extentAfter` hits 0. + testWidgets('clears the fade once the strip is scrolled to the far right', + (tester) async { + await tester.runAsync(() async { + for (var i = 0; i < 12; i++) { + final id = await db.insertTask( + Task(name: 'A rather long finished task name $i')); + await db.completeTask(id); + } + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.pump(); + expect(find.byType(ShaderMask), findsOneWidget); + + // Swipe the chip strip (the only right-scrolling list on screen — the + // Suggested band is collapsed) to its right-hand end. + final strip = find.byWidgetPredicate( + (w) => w is Scrollable && w.axisDirection == AxisDirection.right, + ); + expect(strip, findsOneWidget); + await tester.drag(strip, const Offset(-3000, 0)); + await tester.pumpAndSettle(); + + // Scrolled to the end… + expect(tester.state(strip).position.extentAfter, 0); + // …so nothing is cut off and the fade is gone. + expect(find.byType(ShaderMask), findsNothing); + }); + + // [Mechanism] The other half of the toggle: once the user swipes back away + // from the right-hand end there IS content cut off again, so the hint must + // come back. This also guards the `GlobalKey`/`KeyedSubtree` in + // `_FadeRightEdge`: adding/removing the ShaderMask re-parents the strip, and + // without the key the Scrollable would be rebuilt from scratch and snap back + // to offset 0 — so the scroll offset must survive both toggles. + testWidgets('re-shows the fade when scrolled back from the far right, ' + 'keeping the scroll offset', (tester) async { + await tester.runAsync(() async { + for (var i = 0; i < 12; i++) { + final id = await db.insertTask( + Task(name: 'A rather long finished task name $i')); + await db.completeTask(id); + } + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.pump(); + + final strip = find.byWidgetPredicate( + (w) => w is Scrollable && w.axisDirection == AxisDirection.right, + ); + + // To the end: the mask comes off (the strip is re-parented). + await tester.drag(strip, const Offset(-3000, 0)); + await tester.pumpAndSettle(); + final maxExtent = + tester.state(strip).position.maxScrollExtent; + expect(maxExtent, greaterThan(0)); + // Offset survived the un-masking rather than resetting to 0. + expect(tester.state(strip).position.pixels, maxExtent); + expect(find.byType(ShaderMask), findsNothing); + + // Back left a little: the mask goes back on (re-parented again). + await tester.drag(strip, const Offset(200, 0)); + await tester.pumpAndSettle(); + + final position = tester.state(strip).position; + expect(position.extentAfter, greaterThan(0)); + // Still mid-strip — the re-masking didn't reset the offset either. + expect(position.pixels, greaterThan(0)); + expect(position.pixels, lessThan(maxExtent)); + expect(find.byType(ShaderMask), findsOneWidget); + }); + + // [Edge case] Whether the strip overflows is a function of the viewport, not + // just the chip count: the same three chips that fit on a desktop-width + // window run off the right edge on a phone. The fade is driven by + // `ScrollMetricsNotification` (viewport/content size) — not only by the user + // scrolling — so a narrow screen must show it with no interaction at all. + testWidgets('fades at phone width for chips that fit at desktop width', + (tester) async { + await tester.runAsync(() async { + for (var i = 0; i < 3; i++) { + final id = await db.insertTask( + Task(name: 'A rather long finished task name $i')); + await db.completeTask(id); + } + }); + + // Desktop-width baseline (default 800x600 test surface): all three fit. + await pumpAndLoad(tester, buildTestWidget()); + await tester.pump(); + expect(find.byType(ShaderMask), findsNothing); + + // Same three chips on a phone-width screen: now they overflow. + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.pump(); + expect(find.byType(ShaderMask), findsOneWidget); + }); }); // Task 2: opt-in algorithm-driven "Suggested" section. @@ -2084,6 +2227,41 @@ void main() { expect(find.text('Candidate task'), findsOneWidget); }); + // [Regression] The Suggested pill band shares the same right-edge fade + // helper as "Also done today", so it carried the same bug: the rightmost + // pills were dimmed even when the whole band fit on screen. + testWidgets('does not fade the pill band when it fits on screen', + (tester) async { + await tester.runAsync(() async { + for (var i = 0; i < 4; i++) { + await db.insertTask(Task(name: 'Leaf $i')); + } + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + // 2 columns of pills on an 800px-wide surface — nothing off the right. + expect(find.text('Leaf 0'), findsOneWidget); + expect(find.byType(ShaderMask), findsNothing); + }); + + testWidgets('fades the pill band when the columns overflow', + (tester) async { + await tester.runAsync(() async { + for (var i = 0; i < 24; i++) { + await db.insertTask(Task(name: 'A fairly long candidate name $i')); + } + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + expect(find.byType(ShaderMask), findsOneWidget); + }); + testWidgets('does not suggest tasks already in Today\'s 5', (tester) async { await tester.runAsync(() async { final pinned = await db.insertTask(Task(name: 'Already pinned'));