From 02c08b445d99808716101d998320dd19315f87b8 Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Fri, 17 Jul 2026 17:18:58 +0530 Subject: [PATCH 01/11] Add opt-in Suggested section + always-on "Also done today" to Today's 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the Today tab into independent stacked sections (Today's 5 / Suggested / Also done today) so any can be absent without an awkward layout. - "Also done today" now shows even when Today's 5 is empty, not just when populated — done-today work is always visible. - New opt-in "Suggested" section, collapsed behind a "Show suggestions" button so unrelated tasks don't clutter the focus screen. On expand it offers up to 4 picks from the existing weighted algorithm (pickWeightedN with schedule/deadline/priority/staleness + normalization), excluding tasks already pinned, done today, or dismissed. Add pins into Today's 5; "Not now" dismisses; both backfill a fresh pick. Hidden when Today's 5 is full. Suggestions computed lazily on first expand. Co-Authored-By: Claude Opus 4.8 --- docs/UI_VIEWS.md | 6 +- lib/screens/todays_five_screen.dart | 456 ++++++++++++++++++---- test/screens/todays_five_screen_test.dart | 115 +++++- 3 files changed, 499 insertions(+), 78 deletions(-) diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index 920a078..f7cf714 100644 --- a/docs/UI_VIEWS.md +++ b/docs/UI_VIEWS.md @@ -11,7 +11,9 @@ Reference for manual test instructions. Use these names consistently. ### Today tab (Today's 5) - **Manual-pick model, with deadline-today auto-pin.** Today's 5 starts empty each day. Tasks appear when the user explicitly pins them — via the **+ FAB** on the Today tab itself, the pin icon on any task in the All Tasks tab, or the per-task bottom sheet — **plus** any leaf that is **due today**, which is auto-pinned on every load. "Due today" means the leaf's own deadline is today **or** it inherits a today deadline from an ancestor (a leaf under a parent due today also auto-pins). There is no random pick, no reroll, no per-card swap, and no schedule auto-pin; overdue/future deadlines are **not** auto-pinned. - **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. -- **Empty state** — shown when nothing is pinned **and** nothing is due today. Icon: `push_pin_outlined`. Heading: "Nothing pinned yet". Body: "Tap the + button to pick a task to focus on today." Centered on the page. +- **Layout** — the Today tab is a single scrolling column of independent stacked sections: **Today's 5** → **Suggested** (opt-in) → **Also done today**. Each section renders only when it has content, so any can be absent without an awkward gap. The full-page centered hero (below) is used **only** when the whole screen is empty (nothing pinned, nothing done today, suggestions not open); otherwise the "nothing pinned" prompt renders as a compact block at the top of the section stack. +- **Empty state** — the centered hero: shown when nothing is pinned, nothing was done today, **and** the Suggested section isn't open. Icon: `push_pin_outlined`. Heading: "Nothing pinned yet". Body: "Tap the + button to pick a task to focus on today." When Today's 5 has a free slot it also shows a **"Show suggestions"** outlined button (`lightbulb_outline`). Centered on the page. +- **Suggested section (opt-in)** — an algorithm-driven nudge, hidden by default so unrelated tasks don't clutter the focus screen. Collapsed, it's a subtle **"Show suggestions"** text button (`lightbulb_outline`); tapping it computes and reveals up to **4** leaf-task suggestions, each a card with the task name + hierarchy path, an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are only computed on first expand. - **+ 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. @@ -19,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", 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** — expandable area below the main list showing tasks worked on today outside the Today's 5 set, plus a "+N others" badge on the progress line. Shown **only when Today's 5 has at least one pinned task** (not in the empty state). +- **"Also done today" section** — expandable area at the bottom 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). 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. diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index 2772c9f..6739075 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -67,6 +67,21 @@ class TodaysFiveScreenState extends State /// Other tasks completed/worked-on today, outside the Today's 5 set. List _otherDoneToday = []; bool _otherDoneExpanded = false; + /// Algorithm-driven suggestions shown below Today's 5, generated by the same + /// weighted selection as the All Tasks roulette. A nudge, not the list — the + /// user chooses whether to accept one. Stable across refreshes (no reshuffle); + /// only consumed/ineligible entries are replaced, backfilled to + /// [_suggestionTarget]. See [_refreshSuggestions]. + List _suggestions = []; + /// Suggestions the user dismissed this session ("Not now") — excluded from + /// re-selection so a dismissed task doesn't immediately reappear. + final Set _dismissedSuggestionIds = {}; + /// How many suggestions to surface at a time (user spec: 3-4). + static const int _suggestionTarget = 4; + /// Suggestions are OPT-IN: hidden behind a "Show suggestions" affordance so + /// the focus screen isn't cluttered with unrelated tasks. Only computed and + /// shown once the user expands them. + bool _suggestionsExpanded = false; bool _loading = true; /// The date key that was last loaded, used to detect midnight rollover. String _loadedDateKey = ''; @@ -189,6 +204,9 @@ class TodaysFiveScreenState extends State _completedIds.clear(); _workedOnIds.clear(); await _loadOtherDoneToday(); + await _refreshSuggestions(); + if (!mounted) return; + await _loadTaskPaths(); if (!mounted) return; setState(() => _loading = false); return; @@ -227,6 +245,7 @@ class TodaysFiveScreenState extends State if (!mounted) return; _todaysTasks = tasks; await _loadOtherDoneToday(); + await _refreshSuggestions(); await _loadTaskPaths(); if (!mounted) return; setState(() { @@ -311,6 +330,9 @@ class TodaysFiveScreenState extends State // Nothing to refresh — but still update the "+N others done today" badge. if (!mounted) return; await _loadOtherDoneToday(); + await _refreshSuggestions(); + if (!mounted) return; + await _loadTaskPaths(); if (!mounted) return; setState(() {}); return; @@ -358,6 +380,7 @@ class TodaysFiveScreenState extends State if (!mounted) return; _todaysTasks = refreshed; await _loadOtherDoneToday(); + await _refreshSuggestions(); await _loadTaskPaths(); if (!mounted) return; setState(() {}); @@ -402,7 +425,7 @@ class TodaysFiveScreenState extends State Future _loadTaskPaths() async { final db = DatabaseHelper(); final paths = {}; - final allTasks = [..._todaysTasks, ..._otherDoneToday]; + final allTasks = [..._todaysTasks, ..._otherDoneToday, ..._suggestions]; for (final task in allTasks) { final ancestors = await db.getAncestorPath(task.id!); if (ancestors.isNotEmpty) { @@ -423,6 +446,107 @@ class TodaysFiveScreenState extends State ); } + /// Toggles the opt-in suggestions section. Suggestions are only computed on + /// first expand so the weighted-selection queries don't run for users who + /// never open them (keeps the focus screen uncluttered by default). + Future _toggleSuggestions() async { + final expanding = !_suggestionsExpanded; + setState(() => _suggestionsExpanded = expanding); + if (expanding) { + await _refreshSuggestions(); + if (!mounted) return; + // Load ancestor paths / deadline / schedule metadata for the freshly + // picked suggestion cards (they weren't in _loadTaskPaths' scope while + // collapsed). + await _loadTaskPaths(); + if (mounted) setState(() {}); + } else { + // Free the picks so a later re-expand offers a fresh set. + setState(() => _suggestions = []); + } + } + + /// Rebuilds the algorithm-driven suggestion list using the SAME weighted + /// selection as the All Tasks roulette (schedule 2.5x / deadline up to 8x / + /// priority / staleness boosts + root-size normalization via + /// [TaskProvider.pickWeightedN]). + /// + /// Behaviour: + /// - No-op while collapsed (opt-in) or when Today's 5 is full — nothing + /// could be accepted, so suggesting is pointless. + /// - Existing suggestions stay put (no reshuffle on every refresh); only + /// entries that were accepted, completed, blocked, or otherwise became + /// ineligible are pruned. + /// - Backfills fresh picks up to [_suggestionTarget], excluding tasks in + /// Today's 5, done today, already suggested, or dismissed this session. + Future _refreshSuggestions() async { + if (!_suggestionsExpanded || _todaysTasks.length >= maxPins) { + if (_suggestions.isNotEmpty) _suggestions = []; + return; + } + final provider = context.read(); + + // Cheap in-memory prune: drop anything that landed in Today's 5 or was + // completed today (accepted here, or finished from another surface). + final inTodaysFive = _todaysTasks.map((t) => t.id).toSet(); + final doneToday = _otherDoneToday.map((t) => t.id).toSet(); + _suggestions.removeWhere((t) => + inTodaysFive.contains(t.id) || + doneToday.contains(t.id) || + _completedIds.contains(t.id)); + + // Already at target after prune → keep as-is (avoid per-refresh DB churn). + if (_suggestions.length >= _suggestionTarget) return; + + final allLeaves = await provider.getAllLeafTasks(); + final leafIds = allLeaves.map((t) => t.id!).toList(); + final blockedIds = await provider.getBlockedChildIds(leafIds); + final scheduleBoostedIds = await provider.getScheduleBoostedLeafIds(); + final deadlineDaysMap = await provider.getDeadlineBoostedLeafData(); + final normData = await provider.getNormalizationData(leafIds); + + final exclude = { + ...inTodaysFive, + ...doneToday, + ..._completedIds, + ..._dismissedSuggestionIds, + ..._suggestions.map((t) => t.id), + }; + final pool = allLeaves + .where((t) => !blockedIds.contains(t.id) && !exclude.contains(t.id)) + .toList(); + if (pool.isEmpty) return; + + final needed = _suggestionTarget - _suggestions.length; + final picked = provider.pickWeightedN( + pool, + needed, + scheduleBoostedIds: scheduleBoostedIds, + deadlineDaysMap: deadlineDaysMap, + normData: normData, + ); + _suggestions = [..._suggestions, ...picked]; + } + + /// Accepts a suggestion into Today's 5 (implicit pin). Reuses the shared + /// idempotent-add path; the follow-up reload prunes the accepted task from + /// the suggestion list and backfills a fresh pick. + Future _acceptSuggestion(Task task) async { + setState(() => _suggestions.removeWhere((t) => t.id == task.id)); + await _pinTaskInTodaysFive(task.id!); + } + + /// Dismisses a suggestion ("Not now") and backfills a fresh one. Dismissed + /// tasks are suppressed for the session so they don't immediately reappear. + Future _dismissSuggestion(Task task) async { + _dismissedSuggestionIds.add(task.id!); + setState(() => _suggestions.removeWhere((t) => t.id == task.id)); + await _refreshSuggestions(); + if (!mounted) return; + await _loadTaskPaths(); + if (mounted) setState(() {}); + } + /// Truncates a hierarchy path to keep the last 2 segments when there /// are more than 3, so the immediate parent is always visible. /// e.g. "Coding › App › Enhancements › Random" → "… › Enhancements › Random" @@ -1032,86 +1156,116 @@ class TodaysFiveScreenState extends State ), ], ), - // Empty state: a clean centered hero. "Also done today" is intentionally - // NOT shown here — it only appears once Today's 5 has at least one entry - // (the populated branch below). - body: _todaysTasks.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.push_pin_outlined, size: 64, color: colorScheme.primary), - const SizedBox(height: 16), - Text( - 'Nothing pinned yet', - style: textTheme.headlineSmall, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'Tap the + button to pick a task to focus on today.', - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ) - : Padding( - padding: const EdgeInsets.all(16), + body: _buildBody(context, colorScheme, textTheme, totalCount, completedCount), + ); + } + + /// Sectioned body: Today's 5 → Suggested → Also done today. Each section is + /// independent so any can be absent without leaving the layout awkward (e.g. + /// "Also done today" now shows even when nothing is pinned). When literally + /// everything is empty and suggestions aren't open, show a centered hero. + Widget _buildBody(BuildContext context, ColorScheme colorScheme, + TextTheme textTheme, int totalCount, int completedCount) { + // No room to accept a suggestion once Today's 5 is full → don't offer them. + final showSuggestions = _todaysTasks.length < maxPins; + final everythingEmpty = _todaysTasks.isEmpty && + _otherDoneToday.isEmpty && + !_suggestionsExpanded; + if (everythingEmpty) { + return _buildEmptyHero(context, colorScheme, textTheme, + showSuggestions: showSuggestions); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // --- Today's 5 --- + if (_todaysTasks.isEmpty) + _buildTodaysFiveEmpty(context, colorScheme, textTheme) + else + _buildTodaysFivePopulated( + context, colorScheme, textTheme, totalCount, completedCount), + // --- Suggested (opt-in, collapsed by default) --- + if (showSuggestions) + _buildSuggestionsSection(context, colorScheme, textTheme), + // --- Also done today (independent of whether Today's 5 has entries) --- + if (_otherDoneToday.isNotEmpty) + _buildOtherDoneBox(context, textTheme, colorScheme), + ], + ); + } + + /// Full-height centered hero for a completely empty screen (nothing pinned, + /// nothing done today, suggestions not open). + Widget _buildEmptyHero(BuildContext context, ColorScheme colorScheme, + TextTheme textTheme, {required bool showSuggestions}) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - // Segmented progress bar - _buildSegmentedProgress(colorScheme, totalCount, completedCount), - const SizedBox(height: 4), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: completedCount == 0 - ? _motivationalText() - : completedCount == totalCount - ? 'All $totalCount done!' - : '$completedCount of $totalCount done', - style: completedCount == totalCount && totalCount > 0 - ? const TextStyle(color: Color(0xFF66BB6A), fontWeight: FontWeight.w500) - : null, - ), - if (_otherDoneToday.isNotEmpty) - TextSpan( - text: ' +${_otherDoneToday.length} ${_otherDoneToday.length == 1 ? 'other' : 'others'}', - style: TextStyle( - color: completedCount == totalCount && totalCount > 0 - ? const Color(0xFF66BB6A).withAlpha(140) - : colorScheme.primary.withAlpha(180), - fontWeight: FontWeight.w500, - ), - ), - ], - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), + Icon(Icons.push_pin_outlined, size: 64, color: colorScheme.primary), const SizedBox(height: 16), - // Task list — pinned undone tasks on top as "Must do" - Expanded( - child: _buildTaskList(context, colorScheme, textTheme), + Text( + 'Nothing pinned yet', + style: textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Tap the + button to pick a task to focus on today.', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, ), + if (showSuggestions) ...[ + const SizedBox(height: 20), + OutlinedButton.icon( + onPressed: _toggleSuggestions, + icon: const Icon(Icons.lightbulb_outline, size: 18), + label: const Text('Show suggestions'), + ), + ], ], ), ), ); } - Widget _buildTaskList(BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { - // Implicit-pin model: every task is "pinned" by membership, so the - // only distinction left is undone vs done. + /// Compact "nothing pinned" prompt for the top of the sectioned layout (when + /// suggestions or done-today content sits below it). + Widget _buildTodaysFiveEmpty( + BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + Icon(Icons.push_pin_outlined, size: 44, color: colorScheme.primary), + const SizedBox(height: 12), + Text( + 'Nothing pinned yet', + style: textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + 'Tap the + button to pick a task to focus on today.', + style: textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + /// Populated Today's 5: progress bar, summary line, then undone + done cards. + Widget _buildTodaysFivePopulated(BuildContext context, + ColorScheme colorScheme, TextTheme textTheme, int totalCount, + int completedCount) { + // Implicit-pin model: every task is "pinned" by membership, so the only + // distinction left is undone vs done. final undone = []; final done = []; for (int i = 0; i < _todaysTasks.length; i++) { @@ -1121,9 +1275,43 @@ class TodaysFiveScreenState extends State undone.add(i); } } - - return ListView( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildSegmentedProgress(colorScheme, totalCount, completedCount), + const SizedBox(height: 4), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: completedCount == 0 + ? _motivationalText() + : completedCount == totalCount + ? 'All $totalCount done!' + : '$completedCount of $totalCount done', + style: completedCount == totalCount && totalCount > 0 + ? const TextStyle( + color: Color(0xFF66BB6A), fontWeight: FontWeight.w500) + : null, + ), + if (_otherDoneToday.isNotEmpty) + TextSpan( + text: + ' +${_otherDoneToday.length} ${_otherDoneToday.length == 1 ? 'other' : 'others'}', + style: TextStyle( + color: completedCount == totalCount && totalCount > 0 + ? const Color(0xFF66BB6A).withAlpha(140) + : colorScheme.primary.withAlpha(180), + fontWeight: FontWeight.w500, + ), + ), + ], + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 16), for (final i in undone) _buildTaskCard(context, _todaysTasks[i], false), if (done.isNotEmpty) ...[ @@ -1137,12 +1325,130 @@ class TodaysFiveScreenState extends State for (final i in done) _buildTaskCard(context, _todaysTasks[i], true), ], - if (_otherDoneToday.isNotEmpty) - _buildOtherDoneBox(context, textTheme, colorScheme), ], ); } + /// Opt-in "Suggested" section. Collapsed by default (an unobtrusive + /// "Show suggestions" button) so unrelated tasks don't clutter the focus + /// screen; expands into up to [_suggestionTarget] weighted-pick cards. + Widget _buildSuggestionsSection( + BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { + if (!_suggestionsExpanded) { + return Padding( + padding: EdgeInsets.only(top: _todaysTasks.isEmpty ? 4 : 20), + child: Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _toggleSuggestions, + icon: const Icon(Icons.lightbulb_outline, size: 18), + label: const Text('Show suggestions'), + style: TextButton.styleFrom( + foregroundColor: colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + return Padding( + padding: EdgeInsets.only(top: _todaysTasks.isEmpty ? 8 : 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, + size: 18, color: colorScheme.primary), + const SizedBox(width: 6), + Text( + 'Suggested', + style: textTheme.labelLarge + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const Spacer(), + TextButton( + onPressed: _toggleSuggestions, + child: const Text('Hide'), + ), + ], + ), + const SizedBox(height: 4), + if (_suggestions.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No suggestions right now.', + style: textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ) + else + for (final task in _suggestions) + _buildSuggestionCard(context, task, colorScheme, textTheme), + ], + ), + ); + } + + /// A single suggestion card: name + hierarchy path, with "Add to Today's 5" + /// and "Not now" (dismiss) actions. Tapping opens the task in All Tasks. + Widget _buildSuggestionCard(BuildContext context, Task task, + ColorScheme colorScheme, TextTheme textTheme) { + final path = _taskPaths[task.id]; + return Card( + key: ValueKey('suggestion_${task.id}'), + color: colorScheme.surfaceContainerHigh, + elevation: 0, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: colorScheme.onSurface.withAlpha(20)), + ), + child: ListTile( + leading: Icon(Icons.lightbulb_outline, + color: colorScheme.primary.withAlpha(180)), + title: Text( + task.name, + style: textTheme.bodyLarge, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: path != null + ? Text( + _shortenPath(path), + style: textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => _dismissSuggestion(task), + tooltip: 'Not now', + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + IconButton( + icon: const Icon(Icons.add_circle, size: 24), + color: colorScheme.primary, + onPressed: () => _acceptSuggestion(task), + tooltip: "Add to Today’s 5", + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + ], + ), + onTap: widget.onNavigateToTask != null + ? () => widget.onNavigateToTask!(task) + : null, + ), + ); + } + Widget _buildSegmentedProgress(ColorScheme colorScheme, int total, int completed) { if (total == 0) return const SizedBox.shrink(); diff --git a/test/screens/todays_five_screen_test.dart b/test/screens/todays_five_screen_test.dart index be59d00..0d1d763 100644 --- a/test/screens/todays_five_screen_test.dart +++ b/test/screens/todays_five_screen_test.dart @@ -1351,11 +1351,15 @@ void main() { await pumpAndLoad(tester, buildTestWidget()); - expect(find.text('Done deadline'), findsNothing); + // Not auto-pinned into Today's 5 (the point of this test)… expect(find.text('Nothing pinned yet'), findsOneWidget); final saved = await tester.runAsync(() => db.loadTodaysFiveState(_todayKey())); expect(saved?.taskIds ?? const [], isEmpty); + // …but since it was completed today it now surfaces in the "Also done + // today" section, which shows even when Today's 5 is empty. + expect(find.text('Also done today'), findsOneWidget); + expect(find.text('Done deadline'), findsOneWidget); }); // [Regression] Manually re-pinning a previously-suppressed deadline task @@ -1699,4 +1703,113 @@ void main() { expect(find.byIcon(Icons.info_outline), findsNothing); }); }); + + // Task 1: "Also done today" surfaces even when Today's 5 has no entries + // (previously it only appeared once Today's 5 was populated). + group('Also done today in empty state', () { + testWidgets('shows the section and the done task with nothing pinned', + (tester) async { + await tester.runAsync(() async { + final id = await db.insertTask(Task(name: 'Finished thing')); + await db.completeTask(id); + }); + + await pumpAndLoad(tester, buildTestWidget()); + + // Empty Today's 5 prompt still shows… + expect(find.text('Nothing pinned yet'), findsOneWidget); + // …alongside the independent "Also done today" section. + expect(find.text('Also done today'), findsOneWidget); + expect(find.text('Finished thing'), findsOneWidget); + }); + }); + + // Task 2: opt-in algorithm-driven "Suggested" section. + group('Suggested section', () { + testWidgets('is opt-in — hidden behind "Show suggestions" by default', + (tester) async { + await tester.runAsync(() async { + await db.insertTask(Task(name: 'Candidate task')); + }); + + await pumpAndLoad(tester, buildTestWidget()); + + // The affordance is present, but the candidate isn't shown until asked. + expect(find.text('Show suggestions'), findsOneWidget); + expect(find.text('Candidate task'), findsNothing); + }); + + testWidgets('expanding surfaces weighted-pick suggestions', (tester) async { + await tester.runAsync(() async { + await db.insertTask(Task(name: 'Candidate task')); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + expect(find.text('Suggested'), findsOneWidget); + expect(find.text('Candidate task'), 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')); + await db.insertTask(Task(name: 'Free candidate')); + await seedTodaysFive(db, [pinned]); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + // The pinned task appears once (as its Today's 5 card), never as a + // duplicate suggestion; the free leaf is offered. + expect(find.text('Already pinned'), findsOneWidget); + expect(find.text('Free candidate'), findsOneWidget); + }); + + testWidgets('accepting a suggestion pins it into Today\'s 5', + (tester) async { + late int id; + await tester.runAsync(() async { + id = await db.insertTask(Task(name: 'Candidate task')); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + await tester.tap(find.byIcon(Icons.add_circle)); + await pumpAsync(tester); + + final saved = + await tester.runAsync(() => db.loadTodaysFiveState(_todayKey())); + expect(saved!.taskIds, contains(id)); + // Now a pinned Today's 5 card, not a suggestion. + expect(find.text('Nothing pinned yet'), findsNothing); + }); + + testWidgets('dismissing a suggestion backfills a fresh one', + (tester) async { + // 5 leaves > the 4-at-a-time target, so a dismissal has something to + // backfill with. + await tester.runAsync(() async { + for (var i = 0; i < 5; i++) { + await db.insertTask(Task(name: 'Leaf $i')); + } + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + // 4 suggestions shown (each card has an add_circle accept button). + expect(find.byIcon(Icons.add_circle), findsNWidgets(4)); + + // Dismiss one → the backfill keeps the count at 4. + await tester.tap(find.byIcon(Icons.close).first); + await pumpAsync(tester); + expect(find.byIcon(Icons.add_circle), findsNWidgets(4)); + }); + }); } From 193fa9d7d64f371556a11d797e14b71be2c88833 Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Sat, 18 Jul 2026 10:02:13 +0530 Subject: [PATCH 02/11] Refine Today's 5 suggestions UI: anchored hero + sleek left-aligned pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP checkpoint on the layout. Keeps the big centered "Nothing pinned yet" hero anchored at a fixed offset so expanding "Show suggestions" grows the list below it instead of jerking the hero upward. Suggestions render as sleek, content-sized, left-aligned pills (name + immediate parent, hard- truncated) rather than full-width cards. Also adds a CLAUDE.md guideline: gather all UI requirements up front before doing layout work — don't assume or half-read. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 1 + docs/UI_VIEWS.md | 6 +- lib/screens/todays_five_screen.dart | 280 +++++++++++++++------------- 3 files changed, 151 insertions(+), 136 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 75dd4a5..56081b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ When mutating a task that is `_currentParent` (e.g. rename, start, unstart), the - When writing tests in bulk, use `flutter test --coverage` to find gaps. Parse `coverage/lcov.info` directly (`genhtml` may not be installed). - Capture any user-mentioned future work items as todo tasks immediately. - **When changing weighting logic**, update `docs/TODAYS_FIVE_ALGORITHM.md` to keep the algorithm doc in sync. +- **Before any UI design/layout work, ask for ALL the user's requirements up front — never assume or half-read.** UI intent is easy to misread (position, sizing, whether a section should move, what "compact" means, etc.), and guessing wastes iterations. When the user requests a UI change, restate your understanding and ask clarifying questions about layout, positioning, sizing, and behavior *before* editing widgets. Only proceed once the requirements are confirmed. - **When changing UI** (adding/removing/modifying dialogs, buttons, toggles, screens, or interaction patterns), update `docs/UI_VIEWS.md` to keep the UI reference in sync, and re-check this at commit time — if any staged files under `lib/screens/` or `lib/widgets/` touched the UI, confirm `docs/UI_VIEWS.md` was updated and remind the user if not. For UI bugs, read `docs/UI_VIEWS.md` first to understand the current UI context before investigating. - **When a manual test case is reported as wrong** (user says the test doesn't match actual behavior), immediately update `docs/UI_VIEWS.md` to reflect the correct behavior before correcting the test case. The test was wrong because the docs were incomplete — fix the source of truth first. - When setup instructions change (new deps, build steps), ask user if they want to update README. diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index f7cf714..c38ea1e 100644 --- a/docs/UI_VIEWS.md +++ b/docs/UI_VIEWS.md @@ -11,9 +11,9 @@ Reference for manual test instructions. Use these names consistently. ### Today tab (Today's 5) - **Manual-pick model, with deadline-today auto-pin.** Today's 5 starts empty each day. Tasks appear when the user explicitly pins them — via the **+ FAB** on the Today tab itself, the pin icon on any task in the All Tasks tab, or the per-task bottom sheet — **plus** any leaf that is **due today**, which is auto-pinned on every load. "Due today" means the leaf's own deadline is today **or** it inherits a today deadline from an ancestor (a leaf under a parent due today also auto-pins). There is no random pick, no reroll, no per-card swap, and no schedule auto-pin; overdue/future deadlines are **not** auto-pinned. - **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 single scrolling column of independent stacked sections: **Today's 5** → **Suggested** (opt-in) → **Also done today**. Each section renders only when it has content, so any can be absent without an awkward gap. The full-page centered hero (below) is used **only** when the whole screen is empty (nothing pinned, nothing done today, suggestions not open); otherwise the "nothing pinned" prompt renders as a compact block at the top of the section stack. -- **Empty state** — the centered hero: shown when nothing is pinned, nothing was done today, **and** the Suggested section isn't open. Icon: `push_pin_outlined`. Heading: "Nothing pinned yet". Body: "Tap the + button to pick a task to focus on today." When Today's 5 has a free slot it also shows a **"Show suggestions"** outlined button (`lightbulb_outline`). Centered on the page. -- **Suggested section (opt-in)** — an algorithm-driven nudge, hidden by default so unrelated tasks don't clutter the focus screen. Collapsed, it's a subtle **"Show suggestions"** text button (`lightbulb_outline`); tapping it computes and reveals up to **4** leaf-task suggestions, each a card with the task name + hierarchy path, an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are only computed on first expand. +- **Layout** — the Today tab stacks independent sections: **Today's 5** → **Suggested** (opt-in) → **Also done today**. Each renders only when it has content. When Today's 5 is **empty**, the big centered "Nothing pinned yet" hero is **anchored at a fixed offset** (~26% down, not vertically re-centered) with Suggested/Also-done flowing **below** it — so expanding "Show suggestions" grows the list in place and never shoves the hero upward. When Today's 5 is **populated**, the sections render as a normal top-aligned scrolling list. +- **Empty state** — the centered, anchored hero. Icon: `push_pin_outlined` (64). Heading: "Nothing pinned yet". Body: "Tap the + button to pick a task to focus on today." When Today's 5 has a free slot the (left-aligned) **"Show suggestions"** outlined button (`lightbulb_outline`) sits below it. +- **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 computes and reveals up to **4** leaf-task suggestions as **sleek content-sized pills that auto-flow in a `Wrap`** (stadium-shaped, `lightbulb_outline` leading, left-aligned). Each pill shows the **truncated task name** (…-clipped past ~24 chars) and its **immediate parent** ("· parent", ~16-char clip), an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button; tapping the pill body opens the task in All Tasks. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are only computed on first expand. - **+ 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. diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index 6739075..aa24ae8 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -1162,28 +1162,47 @@ class TodaysFiveScreenState extends State /// Sectioned body: Today's 5 → Suggested → Also done today. Each section is /// independent so any can be absent without leaving the layout awkward (e.g. - /// "Also done today" now shows even when nothing is pinned). When literally - /// everything is empty and suggestions aren't open, show a centered hero. + /// "Also done today" now shows even when nothing is pinned). Widget _buildBody(BuildContext context, ColorScheme colorScheme, TextTheme textTheme, int totalCount, int completedCount) { // No room to accept a suggestion once Today's 5 is full → don't offer them. final showSuggestions = _todaysTasks.length < maxPins; - final everythingEmpty = _todaysTasks.isEmpty && - _otherDoneToday.isEmpty && - !_suggestionsExpanded; - if (everythingEmpty) { - return _buildEmptyHero(context, colorScheme, textTheme, - showSuggestions: showSuggestions); + + // Empty Today's 5: keep the big centered hero and let Suggested/also-done + // grow BELOW it. The hero is anchored at a fixed offset (not re-centered by + // remaining space) so expanding "Show suggestions" never shoves it upward. + if (_todaysTasks.isEmpty) { + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: constraints.maxHeight * 0.26), + _buildEmptyHero(context, colorScheme, textTheme), + const SizedBox(height: 24), + if (showSuggestions) + _buildSuggestionsSection(context, colorScheme, textTheme), + if (_otherDoneToday.isNotEmpty) + _buildOtherDoneBox(context, textTheme, colorScheme), + ], + ), + ), + ), + ); + }, + ); } + return ListView( padding: const EdgeInsets.all(16), children: [ - // --- Today's 5 --- - if (_todaysTasks.isEmpty) - _buildTodaysFiveEmpty(context, colorScheme, textTheme) - else - _buildTodaysFivePopulated( - context, colorScheme, textTheme, totalCount, completedCount), + _buildTodaysFivePopulated( + context, colorScheme, textTheme, totalCount, completedCount), // --- Suggested (opt-in, collapsed by default) --- if (showSuggestions) _buildSuggestionsSection(context, colorScheme, textTheme), @@ -1194,69 +1213,27 @@ class TodaysFiveScreenState extends State ); } - /// Full-height centered hero for a completely empty screen (nothing pinned, - /// nothing done today, suggestions not open). - Widget _buildEmptyHero(BuildContext context, ColorScheme colorScheme, - TextTheme textTheme, {required bool showSuggestions}) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.push_pin_outlined, size: 64, color: colorScheme.primary), - const SizedBox(height: 16), - Text( - 'Nothing pinned yet', - style: textTheme.headlineSmall, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'Tap the + button to pick a task to focus on today.', - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - if (showSuggestions) ...[ - const SizedBox(height: 20), - OutlinedButton.icon( - onPressed: _toggleSuggestions, - icon: const Icon(Icons.lightbulb_outline, size: 18), - label: const Text('Show suggestions'), - ), - ], - ], - ), - ), - ); - } - - /// Compact "nothing pinned" prompt for the top of the sectioned layout (when - /// suggestions or done-today content sits below it). - Widget _buildTodaysFiveEmpty( + /// The big centered "Nothing pinned yet" hero shown when Today's 5 is empty. + Widget _buildEmptyHero( BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Column( - children: [ - Icon(Icons.push_pin_outlined, size: 44, color: colorScheme.primary), - const SizedBox(height: 12), - Text( - 'Nothing pinned yet', - style: textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Text( - 'Tap the + button to pick a task to focus on today.', - style: textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - ], - ), + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.push_pin_outlined, size: 64, color: colorScheme.primary), + const SizedBox(height: 16), + Text( + 'Nothing pinned yet', + style: textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Tap the + button to pick a task to focus on today.', + style: textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], ); } @@ -1334,19 +1311,28 @@ class TodaysFiveScreenState extends State /// screen; expands into up to [_suggestionTarget] weighted-pick cards. Widget _buildSuggestionsSection( BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { + final isEmpty = _todaysTasks.isEmpty; if (!_suggestionsExpanded) { + // Under the centered empty hero, use an outlined pill button; below a + // populated list, a subtle text button. Left-aligned in both cases. return Padding( - padding: EdgeInsets.only(top: _todaysTasks.isEmpty ? 4 : 20), + padding: EdgeInsets.only(top: isEmpty ? 12 : 20), child: Align( alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: _toggleSuggestions, - icon: const Icon(Icons.lightbulb_outline, size: 18), - label: const Text('Show suggestions'), - style: TextButton.styleFrom( - foregroundColor: colorScheme.onSurfaceVariant, - ), - ), + child: isEmpty + ? OutlinedButton.icon( + onPressed: _toggleSuggestions, + icon: const Icon(Icons.lightbulb_outline, size: 18), + label: const Text('Show suggestions'), + ) + : TextButton.icon( + onPressed: _toggleSuggestions, + icon: const Icon(Icons.lightbulb_outline, size: 18), + label: const Text('Show suggestions'), + style: TextButton.styleFrom( + foregroundColor: colorScheme.onSurfaceVariant, + ), + ), ), ); } @@ -1383,68 +1369,96 @@ class TodaysFiveScreenState extends State ), ) else - for (final task in _suggestions) - _buildSuggestionCard(context, task, colorScheme, textTheme), + // Sleek content-sized pills that auto-flow — just the task name and + // its immediate parent, so a handful of suggestions stay + // unobtrusive on the focus screen. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final task in _suggestions) + _buildSuggestionCard(context, task, colorScheme, textTheme), + ], + ), ], ), ); } - /// A single suggestion card: name + hierarchy path, with "Add to Today's 5" - /// and "Not now" (dismiss) actions. Tapping opens the task in All Tasks. + /// Truncates [s] to [max] characters with an ellipsis so suggestion pills + /// stay compact even for long task/parent names. + String _truncateLabel(String s, int max) => + s.length <= max ? s : '${s.substring(0, max).trimRight()}…'; + + /// The immediate parent name for a task (last segment of its cached ancestor + /// path), or null for a root-level task. + String? _immediateParentName(int? taskId) { + final path = _taskPaths[taskId]; + if (path == null || path.isEmpty) return null; + final segments = path.split(' › '); + return segments.isEmpty ? null : segments.last; + } + + /// A single compact suggestion pill (content-sized, flows in a [Wrap]): + /// lightbulb + truncated task name + "· parent", with "Add to Today's 5" + /// (`add_circle`) and "Not now" (`close`) actions. Tapping the pill body + /// opens the task in All Tasks. Widget _buildSuggestionCard(BuildContext context, Task task, ColorScheme colorScheme, TextTheme textTheme) { - final path = _taskPaths[task.id]; - return Card( + final parent = _immediateParentName(task.id); + return Material( key: ValueKey('suggestion_${task.id}'), color: colorScheme.surfaceContainerHigh, - elevation: 0, - margin: const EdgeInsets.only(bottom: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + shape: StadiumBorder( side: BorderSide(color: colorScheme.onSurface.withAlpha(20)), ), - child: ListTile( - leading: Icon(Icons.lightbulb_outline, - color: colorScheme.primary.withAlpha(180)), - title: Text( - task.name, - style: textTheme.bodyLarge, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: path != null - ? Text( - _shortenPath(path), - style: textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.close, size: 18), - onPressed: () => _dismissSuggestion(task), - tooltip: 'Not now', - visualDensity: VisualDensity.compact, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - ), - IconButton( - icon: const Icon(Icons.add_circle, size: 24), - color: colorScheme.primary, - onPressed: () => _acceptSuggestion(task), - tooltip: "Add to Today’s 5", - visualDensity: VisualDensity.compact, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - ), - ], - ), + child: InkWell( onTap: widget.onNavigateToTask != null ? () => widget.onNavigateToTask!(task) : null, + child: Padding( + padding: const EdgeInsets.only(left: 14, right: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lightbulb_outline, + size: 18, color: colorScheme.primary.withAlpha(200)), + const SizedBox(width: 8), + Text( + _truncateLabel(task.name, 24), + style: textTheme.bodyMedium, + ), + if (parent != null) ...[ + const SizedBox(width: 5), + Text( + '· ${_truncateLabel(parent, 16)}', + style: textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => _dismissSuggestion(task), + tooltip: 'Not now', + color: colorScheme.onSurfaceVariant, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + IconButton( + icon: const Icon(Icons.add_circle, size: 24), + onPressed: () => _acceptSuggestion(task), + tooltip: "Add to Today’s 5", + color: colorScheme.primary, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + ], + ), + ), ), ); } From df59ede1d1ab9881621ce31037ed745e68da864b Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Sat, 18 Jul 2026 10:42:44 +0530 Subject: [PATCH 03/11] Polish Today's 5 empty state: header-style Suggested + hero placement WIP. Makes the "Suggested" divider a proper section header (label + rule, no leading icon) so it no longer reads like another pill, and positions the empty-state hero ~2/3 of the way toward the suggestions affordance while keeping that affordance anchored in place. Co-Authored-By: Claude Opus 4.8 --- docs/UI_VIEWS.md | 2 +- lib/screens/todays_five_screen.dart | 29 +++++++++++++++++++++-------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index c38ea1e..7e9fa9d 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 stacks independent sections: **Today's 5** → **Suggested** (opt-in) → **Also done today**. Each renders only when it has content. When Today's 5 is **empty**, the big centered "Nothing pinned yet" hero is **anchored at a fixed offset** (~26% down, not vertically re-centered) with Suggested/Also-done flowing **below** it — so expanding "Show suggestions" grows the list in place and never shoves the hero upward. When Today's 5 is **populated**, the sections render as a normal top-aligned scrolling list. - **Empty state** — the centered, anchored hero. Icon: `push_pin_outlined` (64). Heading: "Nothing pinned yet". Body: "Tap the + button to pick a task to focus on today." When Today's 5 has a free slot the (left-aligned) **"Show suggestions"** outlined button (`lightbulb_outline`) sits below it. -- **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 computes and reveals up to **4** leaf-task suggestions as **sleek content-sized pills that auto-flow in a `Wrap`** (stadium-shaped, `lightbulb_outline` leading, left-aligned). Each pill shows the **truncated task name** (…-clipped past ~24 chars) and its **immediate parent** ("· parent", ~16-char clip), an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button; tapping the pill body opens the task in All Tasks. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. 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 **"Suggested" section header** (label + a divider rule filling the row, with **Hide** on the right — styled as a header, not a row) followed by up to **4** leaf-task suggestions as **sleek content-sized pills that auto-flow in a `Wrap`** (stadium-shaped, `lightbulb_outline` leading, left-aligned). Each pill shows the **truncated task name** (…-clipped past ~24 chars) and its **immediate parent** ("· parent", ~16-char clip), an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button; tapping the pill body opens the task in All Tasks. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are only computed on first expand. - **+ 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. diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index aa24ae8..be6ef9a 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -1182,9 +1182,15 @@ class TodaysFiveScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox(height: constraints.maxHeight * 0.26), + // Hero sits ~2/3 of the way between the top and the + // suggestions affordance: the spacers below/above are sized + // to keep the suggestions anchored where they are while the + // hero rides to that spot. Fixed offsets (not vertical + // centering) so expanding "Show suggestions" never shoves + // the hero around. + SizedBox(height: constraints.maxHeight * 0.22), _buildEmptyHero(context, colorScheme, textTheme), - const SizedBox(height: 24), + SizedBox(height: constraints.maxHeight * 0.10), if (showSuggestions) _buildSuggestionsSection(context, colorScheme, textTheme), if (_otherDoneToday.isNotEmpty) @@ -1341,17 +1347,24 @@ class TodaysFiveScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Section header: label + divider rule (distinct from the pills). Row( children: [ - Icon(Icons.lightbulb_outline, - size: 18, color: colorScheme.primary), - const SizedBox(width: 6), Text( 'Suggested', - style: textTheme.labelLarge - ?.copyWith(color: colorScheme.onSurfaceVariant), + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), ), - const Spacer(), + const SizedBox(width: 10), + Expanded( + child: Divider( + color: colorScheme.outlineVariant.withAlpha(140), + thickness: 1, + ), + ), + const SizedBox(width: 4), TextButton( onPressed: _toggleSuggestions, child: const Text('Hide'), From 451543156f29d8963d5a1b24460e21f63b238156 Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Sat, 18 Jul 2026 10:46:03 +0530 Subject: [PATCH 04/11] Tune Today's 5 empty-state hero/suggestions vertical placement WIP. Adjusts the fixed spacers so the empty-state hero and the "Show suggestions" affordance sit where they read best. Co-Authored-By: Claude Opus 4.8 --- lib/screens/todays_five_screen.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index be6ef9a..675aecf 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -1188,9 +1188,9 @@ class TodaysFiveScreenState extends State // hero rides to that spot. Fixed offsets (not vertical // centering) so expanding "Show suggestions" never shoves // the hero around. - SizedBox(height: constraints.maxHeight * 0.22), + SizedBox(height: constraints.maxHeight * 0.24), _buildEmptyHero(context, colorScheme, textTheme), - SizedBox(height: constraints.maxHeight * 0.10), + SizedBox(height: constraints.maxHeight * 0.20), if (showSuggestions) _buildSuggestionsSection(context, colorScheme, textTheme), if (_otherDoneToday.isNotEmpty) From 14654d635250b9ebb9ded6f2386c463fd6dfb936 Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Sat, 18 Jul 2026 11:43:54 +0530 Subject: [PATCH 05/11] Rework Today's 5 empty/populated layout for consistency WIP. Unifies the two states into one structure so sections don't jump around: Today's-5 area (centered "Nothing pinned yet" hero when empty / task cards when populated) takes the flexible top region, and Suggested + Also done today are pinned at the bottom in both states. - Suggested: wraps across ~2 rows of sleek pills. - Also done today: single row that shares the bottom row with the "+" FAB, ending just left of it (no tall clearance gap). - Both strips fade at the right edge to signal sideways overflow instead of chopping a chip mid-word (done-box scrolls horizontally). - Removes the bespoke anchored-hero / Stack / fraction-spacer experiments and the also-done expand/collapse machinery. Co-Authored-By: Claude Opus 4.8 --- docs/UI_VIEWS.md | 8 +- lib/screens/todays_five_screen.dart | 310 +++++++++++----------------- 2 files changed, 129 insertions(+), 189 deletions(-) diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index 7e9fa9d..5ae1dea 100644 --- a/docs/UI_VIEWS.md +++ b/docs/UI_VIEWS.md @@ -11,9 +11,9 @@ Reference for manual test instructions. Use these names consistently. ### Today tab (Today's 5) - **Manual-pick model, with deadline-today auto-pin.** Today's 5 starts empty each day. Tasks appear when the user explicitly pins them — via the **+ FAB** on the Today tab itself, the pin icon on any task in the All Tasks tab, or the per-task bottom sheet — **plus** any leaf that is **due today**, which is auto-pinned on every load. "Due today" means the leaf's own deadline is today **or** it inherits a today deadline from an ancestor (a leaf under a parent due today also auto-pins). There is no random pick, no reroll, no per-card swap, and no schedule auto-pin; overdue/future deadlines are **not** auto-pinned. - **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 stacks independent sections: **Today's 5** → **Suggested** (opt-in) → **Also done today**. Each renders only when it has content. When Today's 5 is **empty**, the big centered "Nothing pinned yet" hero is **anchored at a fixed offset** (~26% down, not vertically re-centered) with Suggested/Also-done flowing **below** it — so expanding "Show suggestions" grows the list in place and never shoves the hero upward. When Today's 5 is **populated**, the sections render as a normal top-aligned scrolling list. -- **Empty state** — the centered, anchored hero. Icon: `push_pin_outlined` (64). Heading: "Nothing pinned yet". Body: "Tap the + button to pick a task to focus on today." When Today's 5 has a free slot the (left-aligned) **"Show suggestions"** outlined button (`lightbulb_outline`) sits below it. -- **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 **"Suggested" section header** (label + a divider rule filling the row, with **Hide** on the right — styled as a header, not a row) followed by up to **4** leaf-task suggestions as **sleek content-sized pills that auto-flow in a `Wrap`** (stadium-shaped, `lightbulb_outline` leading, left-aligned). Each pill shows the **truncated task name** (…-clipped past ~24 chars) and its **immediate parent** ("· parent", ~16-char clip), an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button; tapping the pill body opens the task in All Tasks. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are only computed on first expand. +- **Layout** — the Today tab is **one top-aligned scrolling list** in both the empty and populated states: **Today's 5 area** → **Suggested** (opt-in) → **Also done today**, each rendering only when it has content. The **only** thing that differs between states is the first section — a roomy "Nothing pinned yet" hero (empty) vs. the progress bar + task cards (populated). "Suggested" and "Also done today" therefore sit in the **same on-screen position** whether or not anything is pinned; leftover space always falls at the bottom. (There is no bespoke empty layout — no centered/anchored hero, no bottom-pinned footer.) +- **Empty state** — a roomy top block (not vertically centered). Icon: `push_pin_outlined` (64). Heading: "Nothing pinned yet". Body: "Tap the + button to pick a task to focus on today." When Today's 5 has a free slot, the (left-aligned) **"Show suggestions"** outlined button (`lightbulb_outline`) sits below it. +- **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 **"Suggested" section header** (label + a divider rule filling the row, with **Hide** on the right — styled as a header, not a row) followed by up to **4** leaf-task suggestions as **sleek content-sized pills on a single horizontal row that scrolls sideways** (stadium-shaped, `lightbulb_outline` leading; never wraps). Each pill shows the **truncated task name** (…-clipped past ~24 chars) and its **immediate parent** ("· parent", ~16-char clip), an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button; tapping the pill body opens the task in All Tasks. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are only computed on first expand. - **+ 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", 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** — expandable area at the bottom 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). 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) 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. diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index 675aecf..a4eef8b 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -66,7 +66,6 @@ class TodaysFiveScreenState extends State Set _deadlineTodayIds = {}; /// Other tasks completed/worked-on today, outside the Today's 5 set. List _otherDoneToday = []; - bool _otherDoneExpanded = false; /// Algorithm-driven suggestions shown below Today's 5, generated by the same /// weighted selection as the All Tasks roulette. A nudge, not the list — the /// user chooses whether to accept one. Stable across refreshes (no reshuffle); @@ -1160,86 +1159,93 @@ class TodaysFiveScreenState extends State ); } - /// Sectioned body: Today's 5 → Suggested → Also done today. Each section is - /// independent so any can be absent without leaving the layout awkward (e.g. - /// "Also done today" now shows even when nothing is pinned). + /// Body layout — constant structure across empty & populated states: + /// [ Today's-5 area (flexible, scrolls) ] → [ Suggested + Also-done pinned + /// to the bottom ]. + /// The bottom sections are single-row (constant height), so they always sit + /// in the same spot regardless of state or item count. The Today's-5 area + /// takes the flexible top space: task cards top-aligned when populated, the + /// "Nothing pinned yet" hero centered when empty (fills the space so there's + /// no dead void). Widget _buildBody(BuildContext context, ColorScheme colorScheme, TextTheme textTheme, int totalCount, int completedCount) { // No room to accept a suggestion once Today's 5 is full → don't offer them. final showSuggestions = _todaysTasks.length < maxPins; - - // Empty Today's 5: keep the big centered hero and let Suggested/also-done - // grow BELOW it. The hero is anchored at a fixed offset (not re-centered by - // remaining space) so expanding "Show suggestions" never shoves it upward. - if (_todaysTasks.isEmpty) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Hero sits ~2/3 of the way between the top and the - // suggestions affordance: the spacers below/above are sized - // to keep the suggestions anchored where they are while the - // hero rides to that spot. Fixed offsets (not vertical - // centering) so expanding "Show suggestions" never shoves - // the hero around. - SizedBox(height: constraints.maxHeight * 0.24), - _buildEmptyHero(context, colorScheme, textTheme), - SizedBox(height: constraints.maxHeight * 0.20), - if (showSuggestions) - _buildSuggestionsSection(context, colorScheme, textTheme), - if (_otherDoneToday.isNotEmpty) - _buildOtherDoneBox(context, textTheme, colorScheme), - ], + final hasBottom = showSuggestions || _otherDoneToday.isNotEmpty; + return Column( + children: [ + // --- Today's 5 area (flexible top region) --- + Expanded( + child: _todaysTasks.isEmpty + ? LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: _buildEmptyHero(context, colorScheme, textTheme), + ), + ), + ), + ) + : SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: _buildTodaysFivePopulated( + context, colorScheme, textTheme, totalCount, completedCount), ), - ), + ), + // --- Suggested + Also done today, pinned to the bottom --- + // Suggested spans full width; "Also done today" is the bottom-most row + // and shares it with the "+" FAB (bottom-right) — it ends just left of + // the FAB (right inset) instead of leaving a tall clearance below. + if (hasBottom) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showSuggestions) + _buildSuggestionsSection(context, colorScheme, textTheme), + if (_otherDoneToday.isNotEmpty) + Padding( + // Leave room for the FAB when it's visible (hidden at max + // pins) so the done-box ends just before it. + padding: EdgeInsets.only(right: showSuggestions ? 64 : 0), + child: _buildOtherDoneBox(context, textTheme, colorScheme), + ), + ], ), - ); - }, - ); - } - - return ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildTodaysFivePopulated( - context, colorScheme, textTheme, totalCount, completedCount), - // --- Suggested (opt-in, collapsed by default) --- - if (showSuggestions) - _buildSuggestionsSection(context, colorScheme, textTheme), - // --- Also done today (independent of whether Today's 5 has entries) --- - if (_otherDoneToday.isNotEmpty) - _buildOtherDoneBox(context, textTheme, colorScheme), + ), ], ); } - /// The big centered "Nothing pinned yet" hero shown when Today's 5 is empty. + /// The roomy "Nothing pinned yet" hero for the empty state. Centered in the + /// flexible top region (see [_buildBody]). Widget _buildEmptyHero( BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.push_pin_outlined, size: 64, color: colorScheme.primary), - const SizedBox(height: 16), - Text( - 'Nothing pinned yet', - style: textTheme.headlineSmall, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'Tap the + button to pick a task to focus on today.', - style: textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - ], + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.push_pin_outlined, size: 64, color: colorScheme.primary), + const SizedBox(height: 16), + Text( + 'Nothing pinned yet', + style: textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Tap the + button to pick a task to focus on today.', + style: textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), ); } @@ -1382,9 +1388,9 @@ class TodaysFiveScreenState extends State ), ) else - // Sleek content-sized pills that auto-flow — just the task name and - // its immediate parent, so a handful of suggestions stay - // unobtrusive on the focus screen. + // Sleek content-sized pills that wrap across up to ~2 rows (there's + // vertical room above the pinned bottom). With ~4 suggestions this + // reads as two rows; the page stays vertically scrollable. Wrap( spacing: 8, runSpacing: 8, @@ -1398,6 +1404,23 @@ 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, + ); + } + /// Truncates [s] to [max] characters with an ellipsis so suggestion pills /// stay compact even for long task/parent names. String _truncateLabel(String s, int max) => @@ -1706,7 +1729,12 @@ class TodaysFiveScreenState extends State ); } - Widget _buildOtherDoneBox(BuildContext context, TextTheme textTheme, ColorScheme colorScheme) { + /// "Also done today": a header + a single horizontal strip of chips that + /// scrolls sideways. One row tall regardless of count — no expand/collapse, + /// so its vertical position is stable and identical in the empty and + /// populated states. + Widget _buildOtherDoneBox( + BuildContext context, TextTheme textTheme, ColorScheme colorScheme) { return Padding( padding: const EdgeInsets.only(top: 16), child: Container( @@ -1717,125 +1745,37 @@ class TodaysFiveScreenState extends State borderRadius: BorderRadius.circular(12), border: Border.all(color: colorScheme.outlineVariant.withAlpha(60)), ), - child: LayoutBuilder( - builder: (context, constraints) { - final hasOverflow = _otherDoneExpanded || _chipsOverflow(context, constraints.maxWidth); - return GestureDetector( - onTap: hasOverflow ? () => setState(() => _otherDoneExpanded = !_otherDoneExpanded) : null, - behavior: HitTestBehavior.opaque, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Also done today', - style: textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Also done today', + style: textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + _fadeRightEdge( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final task in _otherDoneToday) + Padding( + padding: const EdgeInsets.only(right: 6), + child: _buildOtherDoneChip(context, task), ), - if (hasOverflow) ...[ - const SizedBox(width: 4), - Icon( - _otherDoneExpanded ? Icons.expand_less : Icons.expand_more, - size: 18, - color: colorScheme.onSurfaceVariant, - ), - ], - ], - ), - const SizedBox(height: 8), - _buildOtherDoneChips(context), - ], + ], + ), ), - ); - }, + ), + ], ), ), ); } - /// Returns true if the chips won't all fit in a single row. - bool _chipsOverflow(BuildContext context, double maxWidth) { - const spacing = 6.0; - const maxChipWidth = 160.0; - var usedWidth = 0.0; - - for (final task in _otherDoneToday) { - final textPainter = TextPainter( - text: TextSpan( - text: task.name, - style: Theme.of(context).textTheme.bodySmall, - ), - maxLines: 1, - textDirection: TextDirection.ltr, - )..layout(); - final rawChipWidth = 14 + 4 + textPainter.width + 20 + 2; - textPainter.dispose(); - final chipWidth = rawChipWidth.clamp(0.0, maxChipWidth); - final neededWidth = usedWidth > 0 ? chipWidth + spacing : chipWidth; - - if (usedWidth + neededWidth > maxWidth) return true; - usedWidth += neededWidth; - } - return false; - } - - Widget _buildOtherDoneChips(BuildContext context) { - if (_otherDoneExpanded) { - return Wrap( - spacing: 6, - runSpacing: 6, - children: _otherDoneToday.map((task) => - _buildOtherDoneChip(context, task), - ).toList(), - ); - } - - // Collapsed: show chips that fit in one row - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - const spacing = 6.0; - const maxChipWidth = 160.0; - var usedWidth = 0.0; - var visibleCount = 0; - - for (final task in _otherDoneToday) { - final textPainter = TextPainter( - text: TextSpan( - text: task.name, - style: Theme.of(context).textTheme.bodySmall, - ), - maxLines: 1, - textDirection: TextDirection.ltr, - )..layout(); - final rawChipWidth = 14 + 4 + textPainter.width + 20 + 2; - textPainter.dispose(); - final chipWidth = rawChipWidth.clamp(0.0, maxChipWidth); - final neededWidth = usedWidth > 0 ? chipWidth + spacing : chipWidth; - - if (usedWidth + neededWidth <= maxWidth) { - usedWidth += neededWidth; - visibleCount++; - } else { - break; - } - } - - if (visibleCount == 0) visibleCount = 1; - - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: _otherDoneToday.take(visibleCount).map((task) => - _buildOtherDoneChip(context, task), - ).toList(), - ); - }, - ); - } - Widget _buildOtherDoneChip(BuildContext context, Task task) { final doneForGood = task.isCompleted; final chipColor = doneForGood ? Colors.green : Colors.lightGreen; From 79d028ec14742a14235f174c04a8a2f5a4eec377 Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Sat, 18 Jul 2026 16:24:00 +0530 Subject: [PATCH 06/11] Box the Today's 5 Suggested section + fix suggestion bugs Make Suggested a bordered box matching "Also done today" so the two bottom sections read as consistent cards, and fix three issues found in manual testing: - Unpinning after a full Today's 5 left the reopened Suggested section showing "No suggestions right now." until a tab switch, because the picks were cleared while full and never refreshed on removal. Refresh them inline in _confirmRemoveFromTodaysFive. - A long suggestion name + parent overflowed the pill's Row (the Text widgets weren't Flexible, so truncation alone couldn't fit them). Wrap both in Flexible + ellipsis; keep _truncateLabel as the soft cap. - The bottom-most Suggested box now takes the same right inset as the done-box so its pills end before the + FAB instead of behind it. Header sized to match the "Hide" action; suggestion pills wrap to ~2 rows (capped at 4, so all stay on-screen). Adds regression tests for each fix and keeps UI_VIEWS/TEST_COVERAGE in sync. Also updates the manual-test skill: design-check step, one /cleardb per round, "Add multiple" for bulk setup, and always re-printing full test text instead of referring to items by ID. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/manual-test/SKILL.md | 21 +- docs/TEST_COVERAGE.md | 4 +- docs/UI_VIEWS.md | 6 +- lib/screens/todays_five_screen.dart | 160 +++++++++----- test/screens/todays_five_screen_test.dart | 242 ++++++++++++++++++++++ 5 files changed, 370 insertions(+), 63 deletions(-) diff --git a/.claude/skills/manual-test/SKILL.md b/.claude/skills/manual-test/SKILL.md index c433ce4..94aa82c 100644 --- a/.claude/skills/manual-test/SKILL.md +++ b/.claude/skills/manual-test/SKILL.md @@ -20,7 +20,7 @@ Generate a checklist of manual tests the user should run for recent changes. 2. Categorize the changes: - **Algorithm/logic changes** — things that affect behavior but not UI - - **UI changes** — new widgets, layout changes, label changes + - **UI changes** — new widgets, layout changes, label changes. If the change affects how a screen *looks* (layout, positioning, spacing, sizing, colors, empty/populated states, overflow), it also needs **design checks** (see step 7), not just functional tests. - **Data/DB changes** — schema migrations, new queries - **Edge cases** — boundary conditions, empty states, error paths @@ -43,7 +43,8 @@ Generate a checklist of manual tests the user should run for recent changes. - **Specify the variant under test.** When a feature has distinct subtypes or modes (e.g. "due by" vs "scheduled on" deadlines, pinned vs unpinned tasks), each test case must state which variant it uses. Don't just say "a task with a deadline" — say "a task with a 'Due by' deadline". - **Always state the Inbox toggle state per task.** For EVERY task the user creates where the Inbox toggle is shown — at root level on All Tasks, via the Starred FAB, the **create-from-empty-search** flow (All Tasks search *and* Today's 5 "Pick existing task"), or any future surface that shows it — explicitly say whether Inbox should be **ON** or **OFF** for that specific task (e.g. "tap + FAB, **turn Inbox OFF**, type 'Container'" or "tap + FAB, **keep Inbox ON (default)**"). The toggle defaults ON, so a created-at-root/Starred task with no Inbox instruction is genuinely ambiguous — it changes whether the task lands in the Inbox section or the main list. State it per task in the setup, not just once. Tasks created *inside a parent* (drilled in) don't show the Inbox toggle, so no Inbox mention is needed there. - **Inbox-ON tasks are root-level — so never assert "at root" alone.** Inbox tasks *are* at the root, so an expected outcome of "the task is at root" is ambiguous and will read as a bug when the task correctly lands in the Inbox. When a test's expected result is where a created task ends up, distinguish the two root buckets explicitly: an Inbox-ON task lands "**in the Inbox section**"; an Inbox-OFF root task lands "**in the main root list (not Inbox)**". This is the outcome-side twin of the rule above: state the Inbox toggle in the action *and* make the expected result name the correct bucket. (Orthogonality note: pinning to Today's 5 is independent of Inbox membership — a task can be both in the Inbox and pinned, so "in Inbox + pinned" is a valid, non-contradictory outcome.) - - **State the expected starting state** before the first test. Tell the user whether their existing app data is fine, or if they need a clean slate. Example: "Your existing tasks/pins won't interfere — these tests create new tasks." or "Clear Today's 5 first (New set → Replace) to start fresh." + - **Bulk fixture creation — use "Add multiple" (brain dump), not N separate adds.** When a setup needs several tasks at the same level, tell the user to use the **"Add multiple"** flow (**+ FAB → "Add multiple" → one task name per line → Add**) — do NOT ask them to repeat the single-add flow N times. It's available on the **All Tasks root** add and the **Starred FAB** (the Today's 5 "Create new task" flow hides it). State the **Inbox** toggle **once for the whole batch** (it applies to every line). Fall back to individual single adds only when tasks need **different per-task settings** the batch can't express (mixed Inbox states, per-task pin/deadline, or nesting under different parents). + - **State the expected starting state** before the first test — and by default that is a **clean slate via `/cleardb`** (see the `/cleardb` rule below). Tell the user to run `/cleardb` and relaunch `./dev.sh` before Section 1 unless there is an explicit reason to keep existing data (in which case say so, and explain why the existing data is safe — e.g. "Your existing tasks/pins won't interfere — these tests create new tasks."). - **Tests that verify a refresh or side-effect MUST spell out the mutation steps.** If a test asks the user to "switch to tab X and confirm Y reflects the latest state", you must include the exact mutation that produces that new state — never assume the user knows what "recently completed", "recently edited", or "the latest" means in context. Bad: "switch to Today → Today's 5 reflects latest state". Good: "On All Tasks, tap any Today's 5 task → tap 'Done today' → switch to Today tab → that task now appears under the Done section". This rule applies to all "X refreshes on entry", "Y reloads on tab switch", "Z reflects external changes" tests. 5. Prioritize tests by risk: @@ -53,6 +54,13 @@ Generate a checklist of manual tests the user should run for recent changes. 6. Keep the list practical — aim for 5-10 tests, not an exhaustive matrix. Focus on things automated tests can't easily cover (visual correctness, interaction feel, real data scenarios). +7. **Design / visual checks (whenever a UI screen, layout, or styling changed).** Functional test cases verify *behavior* — they do NOT confirm a screen actually *looks* right. When the change touches how a screen looks (new/repositioned/removed widgets, layout, spacing, sizing, alignment, colors, empty vs populated states, overflow handling), add a separate **Design checks** section that walks the user through *seeing* each visual case and asks whether it looks good / matches their expectations. This is a yes/no aesthetic judgement, not a pass/fail action. + - **Enumerate the distinct visual states the change can produce — not just the happy one.** Typical axes: **empty vs populated**, **1 item vs many / overflowing**, **short vs long text (truncation / ellipsis)**, **each optional section present vs absent**, **light vs dark theme**, and any state-specific layout the change introduced. One check per distinct visual case, so nothing that only appears in an edge state goes unseen. + - **Phrase each as: reach the state → "does it look good / as you expect?"** e.g. "On the Today tab with 0 pins → the 'Nothing pinned yet' hero is centered and the Suggested / Also-done row sits pinned at the bottom → does the empty layout look right to you?" + - For each check, spell out exactly how to reach that visual state (reuse the setup discipline from step 4) and **what to look at** (alignment, spacing, no overflow/clipping, position stays consistent across states, icons are self-explanatory, etc.). + - The user drives the app on Linux (`./dev.sh`) and judges by eye. **Offer to review it yourself too via `/check-screenshot`** — invite them to share a screenshot of any state they're unsure about, and inspect it against the expectation they stated. Never claim a screen looks correct without actually seeing it. + - These are subjective/aesthetic — **never assert the outcome for the user** (per the "no coin flipping" rule). Present the state, say what you intended it to look like, and let them decide. + ## Output Format ``` @@ -67,6 +75,10 @@ Create [whatever state is needed for the tests below]. ### Regression 3. Do X → still works as before + +### Design checks (only when a screen's look changed) +D1. Reach [empty state] → [what it should look like] → does it look good / as you expect? +D2. Reach [populated / overflowing state] → [what to look at] → does it look right? ``` ## Rules @@ -78,7 +90,9 @@ Create [whatever state is needed for the tests below]. - **Never instruct the user to create state the UI cannot produce.** Some fixtures can't be made through the app: most notably, **the deadline date picker disallows past dates**, so an "overdue" / past-deadline task **cannot** be set up via the UI. Before putting any setup step in the checklist, confirm the UI can actually produce that state (check the picker/dialog constraints in the widget code). When a required fixture is UI-impossible (past-date deadline, a specific `created_at`/`last_worked_at`, a forced sync state, etc.), do NOT write a UI setup step for it. Instead: flag that it needs **direct DB insertion** (`~/.local/share/com.taskroulette.task_roulette/task_roulette.db` via `sqlite3`), and **ask the user for confirmation before writing to the DB**. Note that the running app must be restarted (or the affected tab reloaded) to pick up direct DB changes. If a test exists only to verify the UI-impossible case, still include it — just make its setup the DB-insert path, not a UI path. - Don't suggest tests that duplicate what automated tests already cover — check the test files first. - **Never include sync/auth tests when running on test data.** The user uses test data on their dev machine — don't suggest signing in, triggering sync, or testing sync status. Only include sync tests if the change is sync-specific AND the user explicitly says they want to test sync. -- If a change is purely algorithmic with no UI impact, say so and focus tests on observable outcomes. +- If a change is purely algorithmic with no UI impact, say so and focus tests on observable outcomes — and skip the Design checks section (there's nothing new to look at). +- **Design checks are their own section and follow the batching rule** — present them one at a time like any other section, after (or alongside) the functional sections, and don't advance until the user has judged every visual case in the batch. They belong ONLY when a screen's look actually changed; a pure logic/data change gets none. When the user is unsure about a state, proactively offer to look via `/check-screenshot` and compare it to what the design was meant to be — do not vouch for a screen you haven't seen. +- **Prefer a clean-slate `/cleardb` before the round starts — ONCE, not per-section.** Unless there is an explicit reason to keep the existing data, recommend the user run **`/cleardb`** (then relaunch `./dev.sh`) **before Section 1** so the whole round runs against an empty, predictable DB — existing pins/tasks won't interfere with setup or outcomes. Do this **once per round, before the first section only** — do NOT ask for a `/cleardb` before Section 2, Section 3, etc.; later sections build on the same fresh DB plus whatever earlier sections created. `/cleardb` is `disable-model-invocation` and destructive (it backs up first, stops `./dev.sh`, and needs a relaunch), so **you cannot run it yourself** — instruct the user to run it. Reasons to skip/defer it (call them out explicitly): the change is specifically about **existing-data / migration / sync-merge** behavior, the test needs pre-existing fixtures that only live in the current DB, or the user says to keep their data. - Mention which platform to test on (Linux via `./dev.sh` unless the change is mobile-specific). - **Present tests in batches by section.** Don't dump all test cases at once — show one section at a time (e.g. "Today's 5" tests first, then "All Tasks" after the user reports results). This prevents the list from feeling overwhelming and lets the user focus. **This applies to the caller too** — when presenting the agent's results, you MUST show only the first section and hold back the rest until the user reports results. Never flatten all sections into one list or present them all at once, even as a "summary". **Do NOT move to the next section until the user has reported results for ALL test cases in the current section.** If the user reports on only some tests (e.g. "1. works" but there are 10 tests), re-display the remaining tests from that section — do NOT advance to the next section. - **Snackbar undo tests are time-sensitive.** The undo snackbar only lasts 5 seconds. Rules for undo tests: @@ -87,5 +101,6 @@ Create [whatever state is needed for the tests below]. - If the undo test requires the same setup as a non-undo test, tell the user to **re-do the setup** (or use a separate task) rather than chaining it after the non-undo test's verification steps. - Warn the user about the 5s window, and suggest a manual fallback path (e.g. unarchive + re-add state) if they miss it. - The user reports results like "1. works / 2. works". If their list is incomplete (doesn't cover all test cases), don't assume they're skipping the rest. Ask (via AskUserQuestion, with "continue" as the default) whether they want to carry on or skip. If they want to carry on, re-display the remaining test cases so they don't have to scroll up. +- **Always re-print the FULL text of any test cases you're asking the user to run or re-run — never refer to them by ID/number alone** (e.g. "continue with D5 and D6", "re-check test 3"). Restate the complete item: its setup, action, and expected result, verbatim. This matters most when **intervening work** (bug fixes, screenshot reviews, discussion) has pushed the original checklist several messages up — the user must never have to scroll back to find what a test said. When in doubt, re-print. Cost of re-printing a few lines ≪ cost of the user hunting for them. The same applies when you re-surface remaining tests after a partial report, or resume a section after a fix. - **When a test case turns out to be wrong or its precondition was invalidated (e.g. by state a prior test mutated), FIX it — never silently skip it.** The fix is to re-establish the needed state (spin up a fresh dedicated fixture and re-run the test), not to delete the test. Do NOT rationalize dropping coverage by arguing the test is "redundant" or "the same code path" — that is not your call to make unilaterally. If you genuinely believe a test adds no coverage, say so explicitly, explain why, and ask the user whether to drop or keep it. The default is to keep and fix. Skipping a test to save effort is never acceptable; the point of the checklist is coverage, not closing it out fast. - **When the bug is in the checklist itself (bad sequencing, stale precondition, wrong UI reference), own it and correct the checklist** — and if the same flaw could recur, update this skill so future checklists avoid it. A wrong test case is a defect in the test, not a reason to abandon the behavior being tested. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index b40dda2..81f6f52 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -1,6 +1,6 @@ # Test Coverage Inventory -Last updated: 2026-07-11 +Last updated: 2026-07-18 ## Summary @@ -27,7 +27,7 @@ Note: `database_helper_test.dart` also covers **deadline auto-pin suppression** - **test/providers/auth_provider_test.dart** (6 tests) — `setSyncStatus` updates/notifications, `isConfigured`, initial state. ### Screens (partial) -- **test/screens/todays_five_screen_test.dart** (~64 tests) — Empty state, task rendering (max 5, leaf-only, blocked excluded), bottom sheet options, swap/navigate buttons, progress bar, priority icon, refresh dialog, DB state restore, SharedPrefs→DB migration, sync reload (task list replacement, completed status from remote), **deadline auto-pin override** (unpinned deadline stays unpinned on reload, generate doesn't re-pin suppressed tasks, suppression cleared on re-pin, re-pin persists across reload), **first generation auto-pin** (deadline task auto-pinned, `on` deadline auto-pinned on day, suppressed not pinned, max 5 respected, midnight rollover triggers auto-pin, reroll/New set does NOT auto-pin), **scheduled-today icon** (shown/hidden based on schedule, combined with deadline icon), **backfill params** (restore/refreshSnapshots/pinned-descendant backfill passes schedule+deadline+norm to `pickWeightedN`, lazy `_fetchSelectionContext` in `refreshSnapshots`, `_replaceIfNoLongerLeaf` on uncomplete), **reserved slots** (single scheduled source guaranteed slot, two sources each get a slot, ≥1 general-pool slot always preserved, cap at 4 reserved when 5+ sources), **manual model** (empty even when leaf tasks exist but no saved state — no auto-population), **implicit-pin remove** (Remove tile + card X both show a confirm dialog; Cancel keeps, Confirm removes from Today's 5 while task survives in DB), **Add to Today's 5 FAB sheet** (FAB opens sheet with "Create new task" / "Pick existing task"; "Pick existing" opens the browse-mode `TaskPickerDialog`; selecting a task pins it into Today's 5 and persists taskIds+pinnedIds; picker excludes tasks already in Today's 5), **+ FAB visibility at max pins** (FAB hidden once Today's 5 holds 5 tasks, shown below the limit), **midnight rollover** (debug night-icon rollover clears yesterday's lingering tasks to the empty "Nothing pinned yet" state — regression for the empty-load path not resetting `_todaysTasks`), **deadline-today auto-pin** (`feature/deadline-today-autopin` — manual-model exception that force-pins leaf tasks whose deadline is EXACTLY today into Today's 5 on every load): auto-pins a leaf due today with no saved state and persists it; overdue (yesterday) deadline NOT auto-pinned; suppressed task not re-pinned on reconcile; removing one deadline task still auto-pins a different one; removing an auto-pinned task records a suppression; **merges deadline auto-pin with an existing manual pin** (both appear — merge, not replace); **auto-pins every deadline task even beyond the maxPins cap** (the merge loop has NO cap — documents actual behavior, see finding below); **does NOT auto-pin a completed deadline-today task** (`getDeadlinePinLeafIds` excludes completed leaves); **re-pinning a suppressed deadline task clears its suppression** (exercises `_pinTaskInTodaysFive` → `unsuppressDeadlineAutoPin` via the Pick-existing flow); **suppression persists across a reload after removal** (remove via X → rebuild screen → still gone); **refreshSnapshots auto-pins a task that became due today** (deadline set to today while app open pins via the in-place refresh path, no full reload/restart); **refreshSnapshots does NOT re-pin a suppressed deadline task** (suppression respected on the refresh path too), **duplicate-task suggestion → Pin instead (create-new flow)** (`feature/duplicate-task-suggestion`: in the FAB → "Create new task" dialog, typing a name matching an existing task and tapping the "Pin instead" suggestion pins the EXISTING task into Today's 5 (`_pinTaskInTodaysFive`) with no duplicate; when the match is already in Today's 5, tapping it shows the "already in Today's 5" snackbar and does not re-add a duplicate slot; a same-named NON-leaf parent is NOT offered as a "Pin instead" suggestion — the match pool is leaves only (`getAllLeafTasks`), so typing a non-leaf's name surfaces no "Did you mean" panel). Note: first-gen/reroll auto-pin and the prior deadline auto-pin-override tests describe the older automated model and may no longer apply under the manual model. +- **test/screens/todays_five_screen_test.dart** (~64 tests) — Empty state, task rendering (max 5, leaf-only, blocked excluded), bottom sheet options, swap/navigate buttons, progress bar, priority icon, refresh dialog, DB state restore, SharedPrefs→DB migration, sync reload (task list replacement, completed status from remote), **deadline auto-pin override** (unpinned deadline stays unpinned on reload, generate doesn't re-pin suppressed tasks, suppression cleared on re-pin, re-pin persists across reload), **first generation auto-pin** (deadline task auto-pinned, `on` deadline auto-pinned on day, suppressed not pinned, max 5 respected, midnight rollover triggers auto-pin, reroll/New set does NOT auto-pin), **scheduled-today icon** (shown/hidden based on schedule, combined with deadline icon), **backfill params** (restore/refreshSnapshots/pinned-descendant backfill passes schedule+deadline+norm to `pickWeightedN`, lazy `_fetchSelectionContext` in `refreshSnapshots`, `_replaceIfNoLongerLeaf` on uncomplete), **reserved slots** (single scheduled source guaranteed slot, two sources each get a slot, ≥1 general-pool slot always preserved, cap at 4 reserved when 5+ sources), **manual model** (empty even when leaf tasks exist but no saved state — no auto-population), **implicit-pin remove** (Remove tile + card X both show a confirm dialog; Cancel keeps, Confirm removes from Today's 5 while task survives in DB), **Add to Today's 5 FAB sheet** (FAB opens sheet with "Create new task" / "Pick existing task"; "Pick existing" opens the browse-mode `TaskPickerDialog`; selecting a task pins it into Today's 5 and persists taskIds+pinnedIds; picker excludes tasks already in Today's 5), **+ FAB visibility at max pins** (FAB hidden once Today's 5 holds 5 tasks, shown below the limit), **midnight rollover** (debug night-icon rollover clears yesterday's lingering tasks to the empty "Nothing pinned yet" state — regression for the empty-load path not resetting `_todaysTasks`), **deadline-today auto-pin** (`feature/deadline-today-autopin` — manual-model exception that force-pins leaf tasks whose deadline is EXACTLY today into Today's 5 on every load): auto-pins a leaf due today with no saved state and persists it; overdue (yesterday) deadline NOT auto-pinned; suppressed task not re-pinned on reconcile; removing one deadline task still auto-pins a different one; removing an auto-pinned task records a suppression; **merges deadline auto-pin with an existing manual pin** (both appear — merge, not replace); **auto-pins every deadline task even beyond the maxPins cap** (the merge loop has NO cap — documents actual behavior, see finding below); **does NOT auto-pin a completed deadline-today task** (`getDeadlinePinLeafIds` excludes completed leaves); **re-pinning a suppressed deadline task clears its suppression** (exercises `_pinTaskInTodaysFive` → `unsuppressDeadlineAutoPin` via the Pick-existing flow); **suppression persists across a reload after removal** (remove via X → rebuild screen → still gone); **refreshSnapshots auto-pins a task that became due today** (deadline set to today while app open pins via the in-place refresh path, no full reload/restart); **refreshSnapshots does NOT re-pin a suppressed deadline task** (suppression respected on the refresh path too), **duplicate-task suggestion → Pin instead (create-new flow)** (`feature/duplicate-task-suggestion`: in the FAB → "Create new task" dialog, typing a name matching an existing task and tapping the "Pin instead" suggestion pins the EXISTING task into Today's 5 (`_pinTaskInTodaysFive`) with no duplicate; when the match is already in Today's 5, tapping it shows the "already in Today's 5" snackbar and does not re-add a duplicate slot; a same-named NON-leaf parent is NOT offered as a "Pin instead" suggestion — the match pool is leaves only (`getAllLeafTasks`), so typing a non-leaf's name surfaces no "Did you mean" panel). Note: first-gen/reroll auto-pin and the prior deadline auto-pin-override tests describe the older automated model and may no longer apply under the manual model. **Sectioned Today tab (`feature/todays-five-suggestions`)** — the Today body is now a constant-structure Column (flexible Today's-5 area on top + bottom-pinned "Suggested"/"Also done today"). **Opt-in Suggested section**: hidden behind "Show suggestions" by default; expanding surfaces weighted `pickWeightedN` picks; excludes tasks already in Today's 5; accepting pins into Today's 5; dismissing ("Not now") backfills a fresh pick; **unpinning after a full Today's 5 re-populates suggestions inline** (regression — the removal path (`_confirmRemoveFromTodaysFive`) now calls `_refreshSuggestions()`+`_loadTaskPaths()`+`setState`, so the re-shown section backfills at once instead of showing stale "No suggestions right now." until a tab switch, since `_suggestions` had been cleared by the `>= maxPins` guard while full); **a full Today's 5 (5 pins) hides the entire section** (baseline — the `showSuggestions = < maxPins` gate, complement to the unpin regression); **Hide collapses and clears the picks; re-expanding recomputes** (mechanism — `_toggleSuggestions` free-on-collapse/compute-on-expand); **a dismissed suggestion is suppressed for the session and does not reappear** (edge — lone candidate → dismiss exhausts the pool + records `_dismissedSuggestionIds`, section falls to empty text; distinct from the count-only backfill test); **a blocked leaf is not offered** (edge — `_refreshSuggestions` filters `getBlockedChildIds`). **Always-on "Also done today"**: surfaces even with nothing pinned (empty-state test), and **renders every done chip regardless of count** (regression — the fit-one-row `_chipsOverflow`/`_otherDoneExpanded` collapse was replaced by a single sideways-scrolling strip that builds every chip; expand/collapse chevrons gone). - **test/screens/completed_tasks_screen_test.dart** (~15 tests) — Empty state, completed/skipped display, today/older labels, restore/delete buttons, parent context, AppBar title. - **test/screens/starred_screen_test.dart** (44 tests) — Empty state, card display (single/multiple/subtitle/in-progress/tree preview/badge count), long-press navigation, drag handle, **tap expanded view** (dialog open, lazy-expand direct children on tap, collapse hides grandchildren, "No sub-tasks" leaf, star icon confirmation dialog, cancel keeps starred, confirm unstar + undo snackbar, undo re-stars, long-press tree node navigates, tap leaf navigates directly, dismiss by tapping outside, **child count badge**, **no chevron on leaves**, **header navigate icon**, **leaf navigate icon**), **unified tap** (leaf and non-leaf cards both open the expanded dialog, no navigate), **Add subtask FAB** (FAB opens AddTaskDialog, creates a subtask persisted under the starred card, "Add multiple" brain-dump creates several subtasks parented to the card — regression for "add multiple did nothing", cancel adds nothing, pinned starred task warns but does NOT transfer its pin to a child — manual model: the now-non-leaf parent drops out of Today's 5 instead, unpinned task shows no warning; **"Pin for today" toggle** — the subtask dialog shows a "Pin for today" chip (no Inbox chip); toggling it on pins the new subtask into Today's 5; the chip is **hidden** when the starred parent is itself pinned (drops out anyway) or when Today's 5 is already full; **cross-add refresh regression** — with 4 pinned fillers (one free slot) the toggle shows on the first add, and once pinning the new subtask fills Today's 5 to 5 the toggle is gone on the SECOND add within the same dialog, guarding `_reloadAfterAdd`→`_loadTodays5PinState` + the `onTodaysFiveChanged` count update against a stale `_todays5PinnedCount`). **screen-level Add task FAB** (FAB shown on the empty state, opens AddTaskDialog with the Inbox toggle; creates an auto-starred root task that appears in `getStarredTasks` with no parent). **starOrder preservation** (explicit starOrder on re-star, appends to end without), **grandchild DRY-rule styling (I-53)** (a high-priority grandchild is accent-tinted in the tree-preview card — not just the expanded dialog — via the shared `childTextStyle`; a blocked grandchild is dimmed (alpha 100) in the card, which required adding shown-grandchild ids to the `_blockedInfo` fetch that previously covered only direct children). **duplicate-task suggestion (`feature/duplicate-task-suggestion`)** — screen-level Add FAB "Star instead": tapping the suggestion stars the EXISTING task (`updateTaskStarred`) so it appears on the page with no duplicate, and an already-starred match shows the "already starred" snackbar as a no-op; expanded-dialog "Add subtask" FAB "Add here": tapping the suggestion links the existing task as a subtask (`addParentToTask`) with no duplicate, typing the starred parent's own name refuses to self-parent ("that's this task", no self-loop edge), and linking an ancestor of the starred parent surfaces the "would create a loop" warning and leaves the graph unchanged (distinct starred code path from the All Tasks cycle guard); the "Star instead" and "Add here" snackbars offer **Undo** (unstar / `removeParentFromTask`); and the **Codex P2 already-subtask guard** — an already-existing subtask short-circuits with an "already a subtask" message and NO destructive Undo, preserving the pre-existing edge. - **test/screens/starred_screen_reorder_test.dart** (12 tests) — `reorderByDependencyChains`: empty deps no-op, single blocker→dependent pair, blocked-before-blocker reorder, transitive chain (A→B→C), reverse-order chain, unrelated tasks preserved, multiple dependents per blocker, empty task list, single task, missing task IDs in deps, independent chains, deep 4-task chain. diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index 5ae1dea..bc0bd74 100644 --- a/docs/UI_VIEWS.md +++ b/docs/UI_VIEWS.md @@ -11,9 +11,9 @@ Reference for manual test instructions. Use these names consistently. ### Today tab (Today's 5) - **Manual-pick model, with deadline-today auto-pin.** Today's 5 starts empty each day. Tasks appear when the user explicitly pins them — via the **+ FAB** on the Today tab itself, the pin icon on any task in the All Tasks tab, or the per-task bottom sheet — **plus** any leaf that is **due today**, which is auto-pinned on every load. "Due today" means the leaf's own deadline is today **or** it inherits a today deadline from an ancestor (a leaf under a parent due today also auto-pins). There is no random pick, no reroll, no per-card swap, and no schedule auto-pin; overdue/future deadlines are **not** auto-pinned. - **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 **one top-aligned scrolling list** in both the empty and populated states: **Today's 5 area** → **Suggested** (opt-in) → **Also done today**, each rendering only when it has content. The **only** thing that differs between states is the first section — a roomy "Nothing pinned yet" hero (empty) vs. the progress bar + task cards (populated). "Suggested" and "Also done today" therefore sit in the **same on-screen position** whether or not anything is pinned; leftover space always falls at the bottom. (There is no bespoke empty layout — no centered/anchored hero, no bottom-pinned footer.) -- **Empty state** — a roomy top block (not vertically centered). Icon: `push_pin_outlined` (64). Heading: "Nothing pinned yet". Body: "Tap the + button to pick a task to focus on today." When Today's 5 has a free slot, the (left-aligned) **"Show suggestions"** outlined button (`lightbulb_outline`) sits below it. -- **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 **"Suggested" section header** (label + a divider rule filling the row, with **Hide** on the right — styled as a header, not a row) followed by up to **4** leaf-task suggestions as **sleek content-sized pills on a single horizontal row that scrolls sideways** (stadium-shaped, `lightbulb_outline` leading; never wraps). Each pill shows the **truncated task name** (…-clipped past ~24 chars) and its **immediate parent** ("· parent", ~16-char clip), an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button; tapping the pill body opens the task in All Tasks. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are only computed on first expand. +- **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 wraps ~2 rows; 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 up to **4** leaf-task suggestions as **sleek content-sized stadium pills that wrap across up to ~2 rows** (`lightbulb_outline` leading). Each pill shows the **truncated task name** (…-clipped past ~24 chars) and its **immediate parent** ("· parent", ~16-char clip), an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button; tapping the pill body opens the task in All Tasks. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are only computed on first expand. - **+ 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. diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index a4eef8b..047a8da 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -705,6 +705,18 @@ class TodaysFiveScreenState extends State _preWorkedOnLastWorkedAt.remove(task.id); _explicitlyUncompletedIds.remove(task.id); }); + // Bug fix: removing a task drops Today's 5 below the cap, so the opt-in + // suggestions section becomes visible again. But `_suggestions` was cleared + // to empty while the list was full (the `>= maxPins` guard in + // _refreshSuggestions), and this removal path never refreshed them. + // Before: the section reappeared showing "No suggestions right now." until a + // tab switch fired refreshSnapshots → _refreshSuggestions. After: backfill + // inline (and load paths for the new pills) so suggestions repopulate at once. + await _refreshSuggestions(); + if (!mounted) return; + await _loadTaskPaths(); + if (!mounted) return; + setState(() {}); await _persist(); } @@ -1195,9 +1207,11 @@ class TodaysFiveScreenState extends State ), ), // --- Suggested + Also done today, pinned to the bottom --- - // Suggested spans full width; "Also done today" is the bottom-most row - // and shares it with the "+" FAB (bottom-right) — it ends just left of - // the FAB (right inset) instead of leaving a tall clearance below. + // Whichever of these is the BOTTOM-most section sits beside the "+" FAB + // (bottom-right) and gets a right inset so it ends just left of the FAB. + // "Also done today" (when present) is always bottom-most and takes the + // inset; otherwise the Suggested pills take it (see fabClearance in + // _buildSuggestionsSection). if (hasBottom) Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), @@ -1348,58 +1362,77 @@ class TodaysFiveScreenState extends State ), ); } + // FAB clearance: when the Suggested box is the bottom-most content (no + // "Also done today" box below to take the inset instead), it sits beside the + // "+" FAB and must end before it — the same right inset the done-box uses. + // (This section only renders when the FAB is visible, so no extra + // FAB-visibility guard is needed.) + final fabClearance = _otherDoneToday.isEmpty ? 64.0 : 0.0; return Padding( - padding: EdgeInsets.only(top: _todaysTasks.isEmpty ? 8 : 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Section header: label + divider rule (distinct from the pills). - Row( - children: [ - Text( - 'Suggested', - style: textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurfaceVariant, + key: const ValueKey('suggestions_box_pad'), + padding: EdgeInsets.only( + top: _todaysTasks.isEmpty ? 8 : 20, right: fabClearance), + // Bordered box mirroring "Also done today" so the two bottom sections read + // as matching cards (user's choice). Same surface / border / radius / + // padding as _buildOtherDoneBox. + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withAlpha(40), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.outlineVariant.withAlpha(60)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Plain label + Hide action (no divider rule, per design choice). + // The header matches the "Hide" button's size/weight (labelLarge, + // the TextButton default) so the two are balanced — just the muted + // label colour instead of the primary action colour (user's choice). + Row( + children: [ + Text( + 'Suggested', + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - ), - const SizedBox(width: 10), - Expanded( - child: Divider( - color: colorScheme.outlineVariant.withAlpha(140), - thickness: 1, + const Spacer(), + TextButton( + onPressed: _toggleSuggestions, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: colorScheme.primary, + ), + child: const Text('Hide'), ), - ), - const SizedBox(width: 4), - TextButton( - onPressed: _toggleSuggestions, - child: const Text('Hide'), - ), - ], - ), - const SizedBox(height: 4), - if (_suggestions.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( + ], + ), + const SizedBox(height: 8), + if (_suggestions.isEmpty) + Text( 'No suggestions right now.', style: textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), + ) + else + // Sleek content-sized pills that wrap across up to ~2 rows. With + // ~4 suggestions this reads as two rows. The box already clears the + // FAB (fabClearance on the outer Padding), so no per-pill inset. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final task in _suggestions) + _buildSuggestionCard(context, task, colorScheme, textTheme), + ], ), - ) - else - // Sleek content-sized pills that wrap across up to ~2 rows (there's - // vertical room above the pinned bottom). With ~4 suggestions this - // reads as two rows; the page stays vertically scrollable. - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final task in _suggestions) - _buildSuggestionCard(context, task, colorScheme, textTheme), - ], - ), - ], + ], + ), ), ); } @@ -1461,16 +1494,33 @@ class TodaysFiveScreenState extends State Icon(Icons.lightbulb_outline, size: 18, color: colorScheme.primary.withAlpha(200)), const SizedBox(width: 8), - Text( - _truncateLabel(task.name, 24), - style: textTheme.bodyMedium, + // Bug fix (RenderFlex overflow): the name/parent Text widgets used + // to be un-flexed, so `_truncateLabel`'s char cap alone had to keep + // the pill within bounds. Before: a long name + long parent (even + // after truncation) made the fixed-width Row wider than the + // available space and overflowed on the right (the ✓/add buttons + // were pushed off-screen). After: both are Flexible with + // ellipsis, so the pill can never exceed its available width — the + // text ellipsizes to fit. `_truncateLabel` is kept purely as the + // soft "compact/sleek" cap when there IS room. + Flexible( + child: Text( + _truncateLabel(task.name, 24), + style: textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), if (parent != null) ...[ const SizedBox(width: 5), - Text( - '· ${_truncateLabel(parent, 16)}', - style: textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), + Flexible( + child: Text( + '· ${_truncateLabel(parent, 16)}', + style: textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), ], const SizedBox(width: 4), diff --git a/test/screens/todays_five_screen_test.dart b/test/screens/todays_five_screen_test.dart index 0d1d763..4b8f56f 100644 --- a/test/screens/todays_five_screen_test.dart +++ b/test/screens/todays_five_screen_test.dart @@ -1722,6 +1722,31 @@ void main() { expect(find.text('Also done today'), findsOneWidget); expect(find.text('Finished thing'), findsOneWidget); }); + + // [Regression] The branch replaced the fit-one-row + tap-to-expand collapse + // (`_chipsOverflow`/`_otherDoneExpanded`) with a single sideways-scrolling + // strip that builds EVERY chip. With many done tasks all names must be in + // the tree (a Row builds all children eagerly inside the horizontal scroll), + // and the old expand/collapse chevrons must be gone. + testWidgets('renders every done chip regardless of count (no one-row ' + 'collapse)', (tester) async { + await tester.runAsync(() async { + for (var i = 0; i < 8; i++) { + final id = await db.insertTask(Task(name: 'Done $i')); + await db.completeTask(id); + } + }); + + await pumpAndLoad(tester, buildTestWidget()); + + expect(find.text('Also done today'), findsOneWidget); + for (var i = 0; i < 8; i++) { + expect(find.text('Done $i'), findsOneWidget); + } + // The old expand/collapse affordance is gone. + expect(find.byIcon(Icons.expand_more), findsNothing); + expect(find.byIcon(Icons.expand_less), findsNothing); + }); }); // Task 2: opt-in algorithm-driven "Suggested" section. @@ -1811,5 +1836,222 @@ void main() { await pumpAsync(tester); expect(find.byIcon(Icons.add_circle), findsNWidgets(4)); }); + + testWidgets( + 'unpinning after a full Today\'s 5 re-populates suggestions inline ' + '(regression: showed "No suggestions right now")', (tester) async { + // 4 pinned + spare leaves: the Suggested section is visible (4 < 5) and + // has picks to offer. + await tester.runAsync(() async { + final a = await db.insertTask(Task(name: 'Pinned A')); + final b = await db.insertTask(Task(name: 'Pinned B')); + final c = await db.insertTask(Task(name: 'Pinned C')); + final d = await db.insertTask(Task(name: 'Pinned D')); + await db.insertTask(Task(name: 'Free 1')); + await db.insertTask(Task(name: 'Free 2')); + await seedTodaysFive(db, [a, b, c, d]); + }); + + await pumpAndLoad(tester, buildTestWidget()); + + // Expand suggestions, then accept one to fill Today's 5 to the cap (5). + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + expect(find.byIcon(Icons.add_circle), findsWidgets); + await tester.tap(find.byIcon(Icons.add_circle).first); + await pumpAsync(tester); + + // Full → the whole Suggested section is hidden (suggestions were cleared). + expect(find.text('Show suggestions'), findsNothing); + expect(find.text('Suggested'), findsNothing); + + // Unpin one → drops back below the cap, so the (still-expanded) Suggested + // section returns. It must backfill immediately, NOT show the stale empty + // "No suggestions right now." text until a tab switch. + await tester.tap(find.text('Pinned A')); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.tap(find.text("Remove from Today’s 5")); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.tap(find.text('Remove')); + await pumpAsync(tester, rounds: 20); + + expect(find.text('Suggested'), findsOneWidget); + expect(find.text('No suggestions right now.'), findsNothing); + expect(find.byIcon(Icons.add_circle), findsWidgets); + }); + + // [Baseline] Complement to the unpin regression above: at the cap the + // `showSuggestions` gate (_todaysTasks.length < maxPins) suppresses the + // whole section — there's no "Show suggestions" affordance at all, since a + // suggestion couldn't be accepted anyway. + testWidgets('a full Today\'s 5 hides the entire Suggested section', + (tester) async { + await tester.runAsync(() async { + final ids = []; + for (var i = 0; i < maxPins; i++) { + ids.add(await db.insertTask(Task(name: 'Pinned $i'))); + } + await db.insertTask(Task(name: 'Free leaf')); + await seedTodaysFive(db, ids); + }); + + await pumpAndLoad(tester, buildTestWidget()); + + expect(find.text('Show suggestions'), findsNothing); + expect(find.text('Suggested'), findsNothing); + }); + + // [Mechanism] Collapsing via "Hide" clears the picks (_toggleSuggestions + // else-branch sets _suggestions = []) and re-expanding recomputes a set — + // guards the opt-in toggle's compute-on-expand / free-on-collapse contract. + testWidgets('Hide collapses the section and clears the picks; re-expanding ' + 'recomputes them', (tester) async { + await tester.runAsync(() async { + await db.insertTask(Task(name: 'Candidate task')); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + expect(find.text('Candidate task'), findsOneWidget); + + await tester.tap(find.text('Hide')); + await pumpAsync(tester); + expect(find.text('Show suggestions'), findsOneWidget); + expect(find.text('Suggested'), findsNothing); + expect(find.text('Candidate task'), findsNothing); + + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + expect(find.text('Suggested'), findsOneWidget); + expect(find.text('Candidate task'), findsOneWidget); + }); + + // [Edge case] A dismissed suggestion is suppressed for the session + // (_dismissedSuggestionIds) so the backfill can't re-offer it. With a lone + // candidate, dismissing exhausts the eligible pool and the section falls to + // its empty text rather than immediately re-surfacing the dismissed task. + // (Distinct from "dismissing backfills a fresh one", which only asserts the + // count stays at target and doesn't prove the dismissed task stays gone.) + testWidgets('a dismissed suggestion is suppressed and does not reappear', + (tester) async { + await tester.runAsync(() async { + await db.insertTask(Task(name: 'Lonely candidate')); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + expect(find.text('Lonely candidate'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close)); + await pumpAsync(tester); + + expect(find.text('Lonely candidate'), findsNothing); + expect(find.text('No suggestions right now.'), findsOneWidget); + }); + + // [Edge case] _refreshSuggestions filters out blocked leaves + // (getBlockedChildIds). "Blocked leaf" depends on "Blocker leaf" and so is + // ineligible while the blocker is incomplete; only the unblocked blocker is + // offered. + testWidgets('a blocked leaf is not offered as a suggestion', (tester) async { + await tester.runAsync(() async { + final blocker = await db.insertTask(Task(name: 'Blocker leaf')); + final blocked = await db.insertTask(Task(name: 'Blocked leaf')); + await db.addDependency(blocked, blocker); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + expect(find.text('Blocker leaf'), findsOneWidget); + expect(find.text('Blocked leaf'), findsNothing); + }); + + // [Regression] A long task name under a long-named parent used to make the + // suggestion pill's Row wider than the available space (the name/parent + // Text widgets weren't Flexible), throwing a "RenderFlex overflowed" + // exception and pushing the ✓/add buttons off-screen. The Text widgets are + // now Flexible + ellipsis, so the pill fits its width with no overflow. + testWidgets('a long-named suggestion pill does not overflow its Row', + (tester) async { + // Reproduce the narrow (phone-width) window where the overflow occurred — + // at the default 800px test surface the long pill fits and nothing + // overflows. ~380px logical width mirrors the real device. + tester.view.physicalSize = const Size(380, 820); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.runAsync(() async { + final parentId = await db + .insertTask(Task(name: 'Really Long Parent Category Name Here')); + final childId = await db.insertTask(Task( + name: 'This Is An Extremely Long Leaf Task Name For Truncation')); + // Child is the sole leaf → the only suggestion candidate. + await db.addRelationship(parentId, childId); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + // The pill renders (its accept button is present) and — critically — no + // RenderFlex overflow exception was thrown during layout. + expect(find.byIcon(Icons.add_circle), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + // [Regression] When the Suggested section is the BOTTOM-most content (no + // "Also done today" box below it), its pills sit beside the "+" FAB and must + // end before it. Before the fix the pills spanned full width and rendered + // *behind* the FAB. The pills' Wrap now carries a right inset (fabClearance) + // in that case. + double suggestionsBoxRightInset(WidgetTester tester) { + final padding = tester.widget( + find.byKey(const ValueKey('suggestions_box_pad')), + ); + return (padding.padding.resolve(TextDirection.ltr)).right; + } + + testWidgets('bottom-most Suggested box clears the FAB (right inset)', + (tester) async { + await tester.runAsync(() async { + await db.insertTask(Task(name: 'Lone candidate')); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + // No "Also done today" box → Suggested is bottom-most → box inset. + expect(find.text('Also done today'), findsNothing); + expect(suggestionsBoxRightInset(tester), 64); + }); + + testWidgets( + 'Suggested pills are NOT inset when "Also done today" is present ' + '(the done-box takes the FAB clearance instead)', (tester) async { + await tester.runAsync(() async { + await db.insertTask(Task(name: 'Lone candidate')); + final done = await db.insertTask(Task(name: 'Finished thing')); + await db.completeTask(done); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + // "Also done today" is bottom-most and takes the inset, so the Suggested + // box above it spans full width (no double inset / gap). + expect(find.text('Also done today'), findsOneWidget); + expect(suggestionsBoxRightInset(tester), 0); + }); }); } From dd26ae5e536eb3b07f3ef7abeba91f48e0068526 Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Sat, 18 Jul 2026 17:40:19 +0530 Subject: [PATCH 07/11] Lay Suggested pills out as a 2-row sideways-scrolling band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone the content-sized pills wrapped one-per-row, so 4 suggestions stacked into 4 rows and the box grew tall — the earlier "4 pills fit in ~2 rows" assumption only held at desktop width. Replace the Wrap with a fixed 2-row band that scrolls sideways (row-major, right-edge fade), so the box stays ~2 rows tall on any width and the overflow is swipeable. Pills are width-capped (260) so their Flexible texts stay bounded inside the horizontal scroll. Also add a CLAUDE.md note to reason about both form factors (wide desktop and narrow phone) for width-dependent layout decisions and to recommend /debug-build when a change may behave differently on-device. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 1 + docs/UI_VIEWS.md | 4 +- lib/screens/todays_five_screen.dart | 65 ++++++++++++++++++++++------- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 56081b4..c621af8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,7 @@ When mutating a task that is `_currentParent` (e.g. rename, start, unstart), the - Capture any user-mentioned future work items as todo tasks immediately. - **When changing weighting logic**, update `docs/TODAYS_FIVE_ALGORITHM.md` to keep the algorithm doc in sync. - **Before any UI design/layout work, ask for ALL the user's requirements up front — never assume or half-read.** UI intent is easy to misread (position, sizing, whether a section should move, what "compact" means, etc.), and guessing wastes iterations. When the user requests a UI change, restate your understanding and ask clarifying questions about layout, positioning, sizing, and behavior *before* editing widgets. Only proceed once the requirements are confirmed. +- **Reason about BOTH form factors (wide desktop AND narrow phone) for every layout decision.** The app runs on Linux desktop (wide) and Android (narrow). Layout reasoning that holds at one width can break at the other — e.g. "N content-sized pills fit in ~2 rows" is true at desktop width but wraps to N rows on a phone; a row that fits desktop may overflow or need scroll on mobile; FAB overlap, truncation, and columns-per-row all shift with width. Whenever a decision depends on available width (wrapping, columns-per-row, whether something scrolls, truncation, overlap), explicitly check the **phone-width** case too before concluding — never reason only from the desktop window you can see. **Strongly recommend the user run `/debug-build`** to verify on-device whenever a UI change could behave differently on phone vs desktop (wrap/row counts, scroll, overflow, spacing, FAB clearance). Say so proactively. - **When changing UI** (adding/removing/modifying dialogs, buttons, toggles, screens, or interaction patterns), update `docs/UI_VIEWS.md` to keep the UI reference in sync, and re-check this at commit time — if any staged files under `lib/screens/` or `lib/widgets/` touched the UI, confirm `docs/UI_VIEWS.md` was updated and remind the user if not. For UI bugs, read `docs/UI_VIEWS.md` first to understand the current UI context before investigating. - **When a manual test case is reported as wrong** (user says the test doesn't match actual behavior), immediately update `docs/UI_VIEWS.md` to reflect the correct behavior before correcting the test case. The test was wrong because the docs were incomplete — fix the source of truth first. - When setup instructions change (new deps, build steps), ask user if they want to update README. diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index bc0bd74..c286552 100644 --- a/docs/UI_VIEWS.md +++ b/docs/UI_VIEWS.md @@ -11,9 +11,9 @@ Reference for manual test instructions. Use these names consistently. ### Today tab (Today's 5) - **Manual-pick model, with deadline-today auto-pin.** Today's 5 starts empty each day. Tasks appear when the user explicitly pins them — via the **+ FAB** on the Today tab itself, the pin icon on any task in the All Tasks tab, or the per-task bottom sheet — **plus** any leaf that is **due today**, which is auto-pinned on every load. "Due today" means the leaf's own deadline is today **or** it inherits a today deadline from an ancestor (a leaf under a parent due today also auto-pins). There is no random pick, no reroll, no per-card swap, and no schedule auto-pin; overdue/future deadlines are **not** auto-pinned. - **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 wraps ~2 rows; 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). +- **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 up to **4** leaf-task suggestions as **sleek content-sized stadium pills that wrap across up to ~2 rows** (`lightbulb_outline` leading). Each pill shows the **truncated task name** (…-clipped past ~24 chars) and its **immediate parent** ("· parent", ~16-char clip), an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button; tapping the pill body opens the task in All Tasks. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. 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 up to **4** leaf-task suggestions as **sleek width-capped stadium pills laid out in a 2-row band that scrolls sideways** (`lightbulb_outline` leading; row-major — first half on top, rest on the bottom row; a right-edge fade hints there's more to swipe). This keeps the box a bounded ~2 rows tall on **any** width instead of stacking one-pill-per-row into many rows on a narrow phone. Each pill shows the **truncated task name** (…-clipped past ~24 chars) and its **immediate parent** ("· parent", ~16-char clip), an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button; tapping the pill body opens the task in All Tasks. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. The section is **not shown when Today's 5 is full** (nothing could be accepted). Suggestions are only computed on first expand. - **+ 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. diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index 047a8da..e3da79d 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -1420,17 +1420,7 @@ class TodaysFiveScreenState extends State ?.copyWith(color: colorScheme.onSurfaceVariant), ) else - // Sleek content-sized pills that wrap across up to ~2 rows. With - // ~4 suggestions this reads as two rows. The box already clears the - // FAB (fabClearance on the outer Padding), so no per-pill inset. - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final task in _suggestions) - _buildSuggestionCard(context, task, colorScheme, textTheme), - ], - ), + _buildSuggestionStrip(context, colorScheme, textTheme), ], ), ), @@ -1468,15 +1458,60 @@ class TodaysFiveScreenState extends State return segments.isEmpty ? null : segments.last; } - /// A single compact suggestion pill (content-sized, flows in a [Wrap]): + /// The suggestion pills laid out as a **2-row band that scrolls sideways**, + /// so the height stays bounded on any screen: ~4 content-sized pills read as + /// two rows on desktop and as two rows that scroll horizontally on a phone — + /// instead of a `Wrap` stacking one-per-row into N tall rows on narrow width + /// (which is what happens on mobile). Pills fill row-major: first half on top, + /// the rest on the bottom row. A right-edge fade hints there's more to swipe. + Widget _buildSuggestionStrip( + BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { + final half = (_suggestions.length / 2).ceil(); + final topRow = _suggestions.take(half).toList(); + final bottomRow = _suggestions.skip(half).toList(); + Widget rowOf(List tasks) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final task in tasks) + Padding( + padding: const EdgeInsets.only(right: 8), + child: + _buildSuggestionCard(context, task, colorScheme, textTheme), + ), + ], + ); + return _fadeRightEdge( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + rowOf(topRow), + if (bottomRow.isNotEmpty) ...[ + const SizedBox(height: 8), + rowOf(bottomRow), + ], + ], + ), + ), + ); + } + + /// A single compact suggestion pill (content-sized, width-capped): /// lightbulb + truncated task name + "· parent", with "Add to Today's 5" /// (`add_circle`) and "Not now" (`close`) actions. Tapping the pill body /// opens the task in All Tasks. Widget _buildSuggestionCard(BuildContext context, Task task, ColorScheme colorScheme, TextTheme textTheme) { final parent = _immediateParentName(task.id); - return Material( - key: ValueKey('suggestion_${task.id}'), + // Width cap: keeps each pill compact AND — inside the unbounded horizontal + // scroll strip — gives its Flexible name/parent texts a bounded box to + // ellipsize within (a Flexible in an unbounded Row would otherwise throw). + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Material( + key: ValueKey('suggestion_${task.id}'), color: colorScheme.surfaceContainerHigh, clipBehavior: Clip.antiAlias, shape: StadiumBorder( @@ -1546,7 +1581,7 @@ class TodaysFiveScreenState extends State ), ), ), - ); + )); } From b066a2347991e2a85a151f0a4e190aa6cc5c040e Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Sat, 18 Jul 2026 18:29:27 +0530 Subject: [PATCH 08/11] Suggestion pills: options sheet, show-all, lazy sideways scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Suggested strip around on-device feedback: - Pill body now opens an options sheet (Add / Go to task / Dismiss suggestion) with explicit, subtitled actions instead of a bare navigate — "Not now" read as "just close", so it's now "Dismiss suggestion". The sheet header (left-aligned) shows the full task name (emphasised) and full ancestor path, since the pill clips both. - Drop the parent from the pill and give the whole width to the task name (the parent lives in the sheet now). - Surface ALL eligible tasks (weighted, best first) instead of a fixed 4, so scrolling keeps revealing more; the ordered set is stable and only recomputed when emptied. - Lazy-load the strip via a horizontal ListView.builder (column-major, 2 pills per column, fixed pill height) so a large eligible set only builds the columns near the viewport. Tests updated (all eligible shown / dismiss removes one; options sheet opens + navigates + pins) and docs kept in sync. Co-Authored-By: Claude Opus 4.8 --- docs/UI_VIEWS.md | 2 +- lib/screens/todays_five_screen.dart | 290 ++++++++++++++-------- test/screens/todays_five_screen_test.dart | 70 +++++- 3 files changed, 248 insertions(+), 114 deletions(-) diff --git a/docs/UI_VIEWS.md b/docs/UI_VIEWS.md index c286552..47e404b 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 up to **4** leaf-task suggestions as **sleek width-capped stadium pills laid out in a 2-row band that scrolls sideways** (`lightbulb_outline` leading; row-major — first half on top, rest on the bottom row; a right-edge fade hints there's more to swipe). This keeps the box a bounded ~2 rows tall on **any** width instead of stacking one-pill-per-row into many rows on a narrow phone. Each pill shows the **truncated task name** (…-clipped past ~24 chars) and its **immediate parent** ("· parent", ~16-char clip), an **add (`add_circle`)** button to add it to Today's 5, and a **"Not now" (X)** dismiss button; tapping the pill body opens the task in All Tasks. 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, or dismissed this session. Accepting or dismissing one **backfills** a fresh pick so the count stays at ~4. 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). 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. - **+ 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. diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index e3da79d..3cb2b3d 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -67,16 +67,16 @@ class TodaysFiveScreenState extends State /// Other tasks completed/worked-on today, outside the Today's 5 set. List _otherDoneToday = []; /// Algorithm-driven suggestions shown below Today's 5, generated by the same - /// weighted selection as the All Tasks roulette. A nudge, not the list — the - /// user chooses whether to accept one. Stable across refreshes (no reshuffle); - /// only consumed/ineligible entries are replaced, backfilled to - /// [_suggestionTarget]. See [_refreshSuggestions]. + /// weighted selection as the All Tasks roulette. A nudge, not a mandate — the + /// user chooses whether to accept one. Holds **all** eligible leaf tasks in + /// weighted order (best first), so the sideways strip scrolls through the whole + /// set. Stable across refreshes (no reshuffle); consumed/ineligible entries are + /// pruned, and the full set is recomputed only when empty. See + /// [_refreshSuggestions]. List _suggestions = []; - /// Suggestions the user dismissed this session ("Not now") — excluded from - /// re-selection so a dismissed task doesn't immediately reappear. + /// Suggestions the user dismissed this session ("Dismiss suggestion") — + /// excluded from re-selection so a dismissed task doesn't reappear. final Set _dismissedSuggestionIds = {}; - /// How many suggestions to surface at a time (user spec: 3-4). - static const int _suggestionTarget = 4; /// Suggestions are OPT-IN: hidden behind a "Show suggestions" affordance so /// the focus screen isn't cluttered with unrelated tasks. Only computed and /// shown once the user expands them. @@ -473,11 +473,12 @@ class TodaysFiveScreenState extends State /// Behaviour: /// - No-op while collapsed (opt-in) or when Today's 5 is full — nothing /// could be accepted, so suggesting is pointless. - /// - Existing suggestions stay put (no reshuffle on every refresh); only - /// entries that were accepted, completed, blocked, or otherwise became - /// ineligible are pruned. - /// - Backfills fresh picks up to [_suggestionTarget], excluding tasks in - /// Today's 5, done today, already suggested, or dismissed this session. + /// - Shows **all** eligible leaf tasks (weighted order, best first) so the + /// sideways strip keeps revealing more as the user scrolls, rather than a + /// fixed handful. Order is stable: consumed/ineligible entries are pruned + /// on each refresh, and the full set is (re)computed only when the list is + /// empty (first expand, or after everything was accepted/dismissed) — never + /// reshuffled underfoot. Future _refreshSuggestions() async { if (!_suggestionsExpanded || _todaysTasks.length >= maxPins) { if (_suggestions.isNotEmpty) _suggestions = []; @@ -494,8 +495,9 @@ class TodaysFiveScreenState extends State doneToday.contains(t.id) || _completedIds.contains(t.id)); - // Already at target after prune → keep as-is (avoid per-refresh DB churn). - if (_suggestions.length >= _suggestionTarget) return; + // Keep the existing (stable) order; only (re)compute the full list when we + // have nothing to show — avoids reshuffling and per-refresh DB churn. + if (_suggestions.isNotEmpty) return; final allLeaves = await provider.getAllLeafTasks(); final leafIds = allLeaves.map((t) => t.id!).toList(); @@ -509,22 +511,21 @@ class TodaysFiveScreenState extends State ...doneToday, ..._completedIds, ..._dismissedSuggestionIds, - ..._suggestions.map((t) => t.id), }; final pool = allLeaves .where((t) => !blockedIds.contains(t.id) && !exclude.contains(t.id)) .toList(); if (pool.isEmpty) return; - final needed = _suggestionTarget - _suggestions.length; - final picked = provider.pickWeightedN( + // Show ALL eligible, weighted-ordered (best first) — the strip scrolls + // sideways through the whole set instead of a fixed target count. + _suggestions = provider.pickWeightedN( pool, - needed, + pool.length, scheduleBoostedIds: scheduleBoostedIds, deadlineDaysMap: deadlineDaysMap, normData: normData, ); - _suggestions = [..._suggestions, ...picked]; } /// Accepts a suggestion into Today's 5 (implicit pin). Reuses the shared @@ -1334,7 +1335,8 @@ class TodaysFiveScreenState extends State /// Opt-in "Suggested" section. Collapsed by default (an unobtrusive /// "Show suggestions" button) so unrelated tasks don't clutter the focus - /// screen; expands into up to [_suggestionTarget] weighted-pick cards. + /// screen; expands into a sideways-scrolling strip of all eligible + /// weighted-pick suggestions. Widget _buildSuggestionsSection( BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { final isEmpty = _todaysTasks.isEmpty; @@ -1444,70 +1446,150 @@ class TodaysFiveScreenState extends State ); } - /// Truncates [s] to [max] characters with an ellipsis so suggestion pills - /// stay compact even for long task/parent names. - String _truncateLabel(String s, int max) => - s.length <= max ? s : '${s.substring(0, max).trimRight()}…'; - - /// The immediate parent name for a task (last segment of its cached ancestor - /// path), or null for a root-level task. - String? _immediateParentName(int? taskId) { - final path = _taskPaths[taskId]; - if (path == null || path.isEmpty) return null; - final segments = path.split(' › '); - return segments.isEmpty ? null : segments.last; + /// 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". + /// (Explicit labels + subtitles: "Not now" read as "just close the sheet", + /// especially beside the X.) The pill also + /// keeps its inline + (pin) and X (dismiss) quick actions; this sheet adds the + /// explicit "Go to task" path and a mis-tap-proof menu. + void _showSuggestionOptions(Task task) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + // Stretch so the header block fills the width and its start-aligned + // name/path sit flush left (matching the ListTiles), not centered. + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Full task name, bigger + emphasised (the pill truncates it, + // so the sheet is where the whole name is shown). + Text( + task.name, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + // Full ancestor path (untruncated) — the pill only shows a + // clipped immediate parent, so surface the whole location here. + if (_taskPaths[task.id]?.isNotEmpty ?? false) ...[ + const SizedBox(height: 3), + Text( + _taskPaths[task.id]!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ListTile( + leading: Icon(Icons.add_circle, color: colorScheme.primary), + title: const Text("Add to Today’s 5"), + subtitle: const Text('Pin it to today’s focus'), + onTap: () { + Navigator.pop(ctx); + _acceptSuggestion(task); + }, + ), + if (widget.onNavigateToTask != null) + ListTile( + leading: Icon(Icons.open_in_new, color: colorScheme.tertiary), + title: const Text('Go to task'), + subtitle: const Text('Open it in All Tasks'), + onTap: () { + Navigator.pop(ctx); + widget.onNavigateToTask!(task); + }, + ), + ListTile( + leading: Icon(Icons.close, color: colorScheme.onSurfaceVariant), + title: const Text('Dismiss suggestion'), + subtitle: const Text('Hide this one and suggest another'), + onTap: () { + Navigator.pop(ctx); + _dismissSuggestion(task); + }, + ), + ], + ), + ), + ), + ); } /// The suggestion pills laid out as a **2-row band that scrolls sideways**, - /// so the height stays bounded on any screen: ~4 content-sized pills read as - /// two rows on desktop and as two rows that scroll horizontally on a phone — - /// instead of a `Wrap` stacking one-per-row into N tall rows on narrow width - /// (which is what happens on mobile). Pills fill row-major: first half on top, - /// the rest on the bottom row. A right-edge fade hints there's more to swipe. + /// bounded to ~2 rows tall on any screen (instead of a `Wrap` stacking + /// one-per-row into N tall rows on a narrow phone). + /// + /// Uses a **horizontal `ListView.builder`** for **lazy loading**: each item is + /// one column of up to two pills (column-major — pills fill top-then-bottom), + /// so only the columns near the viewport are built and more materialise as the + /// user scrolls (the full eligible set can be large). A right-edge fade hints + /// there's more to swipe. Pills are pinned to a fixed height so the strip's + /// bounded height (required by a horizontal list) never clips them. Widget _buildSuggestionStrip( BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { - final half = (_suggestions.length / 2).ceil(); - final topRow = _suggestions.take(half).toList(); - final bottomRow = _suggestions.skip(half).toList(); - Widget rowOf(List tasks) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final task in tasks) - Padding( - padding: const EdgeInsets.only(right: 8), - child: - _buildSuggestionCard(context, task, colorScheme, textTheme), - ), - ], + const pillHeight = 36.0; + const rowGap = 8.0; + final columnCount = (_suggestions.length / 2).ceil(); + Widget pill(Task task) => SizedBox( + height: pillHeight, + child: _buildSuggestionCard(context, task, colorScheme, textTheme), ); - return _fadeRightEdge( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - rowOf(topRow), - if (bottomRow.isNotEmpty) ...[ - const SizedBox(height: 8), - rowOf(bottomRow), - ], - ], + return SizedBox( + height: pillHeight * 2 + rowGap, + child: _fadeRightEdge( + child: ListView.builder( + scrollDirection: Axis.horizontal, + // Renders ~4 pills at a time (2 columns) and lazily builds more as the + // strip is scrolled sideways. + itemCount: columnCount, + itemBuilder: (context, col) { + final topIdx = col * 2; + final bottomIdx = topIdx + 1; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + pill(_suggestions[topIdx]), + if (bottomIdx < _suggestions.length) ...[ + const SizedBox(height: rowGap), + pill(_suggestions[bottomIdx]), + ], + ], + ), + ); + }, ), ), ); } - /// A single compact suggestion pill (content-sized, width-capped): - /// lightbulb + truncated task name + "· parent", with "Add to Today's 5" - /// (`add_circle`) and "Not now" (`close`) actions. Tapping the pill body - /// opens the task in All Tasks. + /// A single compact suggestion pill (content-sized, width-capped): lightbulb + /// + the task name (as much as fits), with "Add to Today's 5" (`add_circle`) + /// and "Dismiss suggestion" (`close`) actions. The parent is NOT shown here — + /// it lives in the tap sheet — so the pill gives its whole width to the name. + /// Tapping the pill body opens that options sheet — see + /// [_showSuggestionOptions]. Widget _buildSuggestionCard(BuildContext context, Task task, ColorScheme colorScheme, TextTheme textTheme) { - final parent = _immediateParentName(task.id); // Width cap: keeps each pill compact AND — inside the unbounded horizontal - // scroll strip — gives its Flexible name/parent texts a bounded box to - // ellipsize within (a Flexible in an unbounded Row would otherwise throw). + // scroll strip — gives its Flexible name text a bounded box to ellipsize + // within (a Flexible in an unbounded Row would otherwise throw). return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 260), child: Material( @@ -1518,64 +1600,58 @@ class TodaysFiveScreenState extends State side: BorderSide(color: colorScheme.onSurface.withAlpha(20)), ), child: InkWell( - onTap: widget.onNavigateToTask != null - ? () => widget.onNavigateToTask!(task) - : null, + onTap: () => _showSuggestionOptions(task), child: Padding( - padding: const EdgeInsets.only(left: 14, right: 4), + padding: const EdgeInsets.only(left: 12, right: 2), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.lightbulb_outline, size: 18, color: colorScheme.primary.withAlpha(200)), const SizedBox(width: 8), - // Bug fix (RenderFlex overflow): the name/parent Text widgets used - // to be un-flexed, so `_truncateLabel`'s char cap alone had to keep - // the pill within bounds. Before: a long name + long parent (even - // after truncation) made the fixed-width Row wider than the - // available space and overflowed on the right (the ✓/add buttons - // were pushed off-screen). After: both are Flexible with - // ellipsis, so the pill can never exceed its available width — the - // text ellipsizes to fit. `_truncateLabel` is kept purely as the - // soft "compact/sleek" cap when there IS room. + // Name only (parent moved to the tap sheet). Flexible + ellipsis + // shows as much of the name as fits and truncates at the pill's + // 260px cap — the ConstrainedBox above gives the Flexible a bounded + // box to ellipsize within inside the unbounded scroll Row. Flexible( child: Text( - _truncateLabel(task.name, 24), + task.name, style: textTheme.bodyMedium, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - if (parent != null) ...[ - const SizedBox(width: 5), - Flexible( - child: Text( - '· ${_truncateLabel(parent, 16)}', - style: textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - const SizedBox(width: 4), + // Tight trailing actions: the pill body already opens a full + // options sheet, so these inline shortcuts hug the text instead of + // carrying the default roomy 36px tap boxes (which read as empty + // spacing inside a compact pill). + const SizedBox(width: 2), IconButton( icon: const Icon(Icons.close, size: 18), onPressed: () => _dismissSuggestion(task), - tooltip: 'Not now', - color: colorScheme.onSurfaceVariant, - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + tooltip: 'Dismiss suggestion', + // shrinkWrap tap target so the button is exactly its 32px min + // height and fits the fixed-height pill (no clipping) — see + // _buildSuggestionStrip. + style: IconButton.styleFrom( + foregroundColor: colorScheme.onSurfaceVariant, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(28, 32), + ), ), IconButton( icon: const Icon(Icons.add_circle, size: 24), onPressed: () => _acceptSuggestion(task), tooltip: "Add to Today’s 5", - color: colorScheme.primary, - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + style: IconButton.styleFrom( + foregroundColor: colorScheme.primary, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(34, 32), + ), ), ], ), diff --git a/test/screens/todays_five_screen_test.dart b/test/screens/todays_five_screen_test.dart index 4b8f56f..495764d 100644 --- a/test/screens/todays_five_screen_test.dart +++ b/test/screens/todays_five_screen_test.dart @@ -1814,10 +1814,11 @@ void main() { expect(find.text('Nothing pinned yet'), findsNothing); }); - testWidgets('dismissing a suggestion backfills a fresh one', + testWidgets('shows ALL eligible tasks; dismissing one removes it', (tester) async { - // 5 leaves > the 4-at-a-time target, so a dismissal has something to - // backfill with. + // Show-all: every eligible leaf appears (weighted order) in the sideways + // strip — not a fixed handful. (SingleChildScrollView builds all pills + // eagerly, so off-screen ones are still found.) await tester.runAsync(() async { for (var i = 0; i < 5; i++) { await db.insertTask(Task(name: 'Leaf $i')); @@ -1828,10 +1829,11 @@ void main() { await tester.tap(find.text('Show suggestions')); await pumpAsync(tester); - // 4 suggestions shown (each card has an add_circle accept button). - expect(find.byIcon(Icons.add_circle), findsNWidgets(4)); + // All 5 eligible leaves shown (each pill has an add_circle accept button). + expect(find.byIcon(Icons.add_circle), findsNWidgets(5)); - // Dismiss one → the backfill keeps the count at 4. + // Dismiss one → it's removed (and suppressed); the rest stay. No backfill + // is needed since every eligible task was already shown. await tester.tap(find.byIcon(Icons.close).first); await pumpAsync(tester); expect(find.byIcon(Icons.add_circle), findsNWidgets(4)); @@ -2053,5 +2055,61 @@ void main() { expect(find.text('Also done today'), findsOneWidget); expect(suggestionsBoxRightInset(tester), 0); }); + + // Tapping a suggestion pill's BODY opens an options sheet (user's chosen + // behaviour over a bare navigate/pin): Add / Go to task / Not now. + testWidgets('tapping a suggestion pill body opens the options sheet, and ' + '"Go to task" navigates', (tester) async { + Task? navigated; + await tester.runAsync(() async { + await db.insertTask(Task(name: 'Candidate task')); + }); + + await pumpAndLoad( + tester, + buildTestWidget(onNavigateToTask: (t) => navigated = t), + ); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + // Tap the pill body (its name) → sheet with all three options. Settle the + // modal-sheet animation with the pump loop this file uses for bottom + // sheets (pumpAsync alone leaves the tiles mid-animation / off-screen). + await tester.tap(find.text('Candidate task')); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect(find.text("Add to Today’s 5"), findsOneWidget); + expect(find.text('Go to task'), findsOneWidget); + expect(find.text('Dismiss suggestion'), findsOneWidget); + + await tester.tap(find.text('Go to task')); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect(navigated?.name, 'Candidate task'); + }); + + testWidgets('options sheet "Add to Today\'s 5" pins the suggestion', + (tester) async { + late int id; + await tester.runAsync(() async { + id = await db.insertTask(Task(name: 'Candidate task')); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + await tester.tap(find.text('Candidate task')); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.tap(find.text("Add to Today’s 5")); + await pumpAsync(tester); + + final saved = + await tester.runAsync(() => db.loadTodaysFiveState(_todayKey())); + expect(saved!.taskIds, contains(id)); + }); }); } From dbcf7b6dacfbb5a39adb3d22ab3fb7cb47521a36 Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Sat, 18 Jul 2026 18:46:17 +0530 Subject: [PATCH 09/11] Address Codex P2s: lazy path load + revalidate suggestions - Don't preload ancestor paths / deadline / schedule for every suggestion. Pills show only the name; the tap sheet now loads the ancestor path on demand for the single tapped task. Preloading over the whole (now unbounded) eligible set was wasted work on large DBs. - Revalidate cached suggestions on every refresh: drop entries that became blocked or non-leaf (not just those pinned/done), so a stale pill can't pin a task the algorithm excludes. Adds regression tests for both (blocked cached suggestion pruned on refresh; options sheet shows the on-demand ancestor path). Co-Authored-By: Claude Opus 4.8 --- lib/screens/todays_five_screen.dart | 50 ++++++++++++------ test/screens/todays_five_screen_test.dart | 63 +++++++++++++++++++++-- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index 3cb2b3d..58a6186 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -419,12 +419,17 @@ class TodaysFiveScreenState extends State } } - /// Fetches ancestor paths for all tasks in [_todaysTasks] + [_otherDoneToday] - /// and caches them. + /// Fetches ancestor paths + deadline/schedule metadata for the tasks that + /// actually render them — [_todaysTasks] and [_otherDoneToday]. Suggestions + /// are deliberately excluded: their pills show only the name, and the one + /// place that needs a path (the tap sheet) loads it on demand for the single + /// tapped task (see [_showSuggestionOptions]). Preloading paths + deadline + + /// schedule for the whole eligible set — which can be large now that + /// suggestions show all eligible tasks — would be wasted work. Future _loadTaskPaths() async { final db = DatabaseHelper(); final paths = {}; - final allTasks = [..._todaysTasks, ..._otherDoneToday, ..._suggestions]; + final allTasks = [..._todaysTasks, ..._otherDoneToday]; for (final task in allTasks) { final ancestors = await db.getAncestorPath(task.id!); if (ancestors.isNotEmpty) { @@ -486,22 +491,31 @@ class TodaysFiveScreenState extends State } final provider = context.read(); - // Cheap in-memory prune: drop anything that landed in Today's 5 or was - // completed today (accepted here, or finished from another surface). + // Current eligibility, fetched once and reused for both the revalidation + // prune below and (if the list ends up empty) the recompute. final inTodaysFive = _todaysTasks.map((t) => t.id).toSet(); final doneToday = _otherDoneToday.map((t) => t.id).toSet(); + final allLeaves = await provider.getAllLeafTasks(); + final leafIds = allLeaves.map((t) => t.id!).toList(); + final leafIdSet = leafIds.toSet(); + final blockedIds = await provider.getBlockedChildIds(leafIds); + + // Prune consumed AND now-INELIGIBLE entries. Besides tasks that landed in + // Today's 5 / were completed today, a cached suggestion can silently become + // blocked or stop being a leaf (edited from All Tasks or via sync) while the + // strip is open. Dropping those here stops a stale pill from pinning a task + // the algorithm explicitly excludes. _suggestions.removeWhere((t) => inTodaysFive.contains(t.id) || doneToday.contains(t.id) || - _completedIds.contains(t.id)); + _completedIds.contains(t.id) || + !leafIdSet.contains(t.id) || + blockedIds.contains(t.id)); // Keep the existing (stable) order; only (re)compute the full list when we - // have nothing to show — avoids reshuffling and per-refresh DB churn. + // have nothing to show — avoids reshuffling. if (_suggestions.isNotEmpty) return; - final allLeaves = await provider.getAllLeafTasks(); - final leafIds = allLeaves.map((t) => t.id!).toList(); - final blockedIds = await provider.getBlockedChildIds(leafIds); final scheduleBoostedIds = await provider.getScheduleBoostedLeafIds(); final deadlineDaysMap = await provider.getDeadlineBoostedLeafData(); final normData = await provider.getNormalizationData(leafIds); @@ -1452,7 +1466,13 @@ class TodaysFiveScreenState extends State /// especially beside the X.) The pill also /// keeps its inline + (pin) and X (dismiss) quick actions; this sheet adds the /// explicit "Go to task" path and a mis-tap-proof menu. - void _showSuggestionOptions(Task task) { + Future _showSuggestionOptions(Task task) async { + // Load the full ancestor path for just this task, on demand — suggestion + // pills don't preload paths (see [_loadTaskPaths]). + final ancestors = await DatabaseHelper().getAncestorPath(task.id!); + final path = + ancestors.isEmpty ? null : ancestors.map((t) => t.name).join(' › '); + if (!mounted) return; final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, @@ -1480,12 +1500,12 @@ class TodaysFiveScreenState extends State color: colorScheme.onSurface, ), ), - // Full ancestor path (untruncated) — the pill only shows a - // clipped immediate parent, so surface the whole location here. - if (_taskPaths[task.id]?.isNotEmpty ?? false) ...[ + // Full ancestor path (untruncated) — the pill omits the + // parent, so surface the whole location here. + if (path != null) ...[ const SizedBox(height: 3), Text( - _taskPaths[task.id]!, + path, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/test/screens/todays_five_screen_test.dart b/test/screens/todays_five_screen_test.dart index 495764d..563630f 100644 --- a/test/screens/todays_five_screen_test.dart +++ b/test/screens/todays_five_screen_test.dart @@ -2072,10 +2072,12 @@ void main() { await tester.tap(find.text('Show suggestions')); await pumpAsync(tester); - // Tap the pill body (its name) → sheet with all three options. Settle the - // modal-sheet animation with the pump loop this file uses for bottom - // sheets (pumpAsync alone leaves the tiles mid-animation / off-screen). + // Tap the pill body (its name) → sheet with all three options. The sheet + // now loads the ancestor path on demand (async DB) before opening, so + // pumpAsync first (runAsync resolves the query + opens the sheet), then the + // pump loop settles the modal-sheet animation. await tester.tap(find.text('Candidate task')); + await pumpAsync(tester); for (var i = 0; i < 10; i++) { await tester.pump(const Duration(milliseconds: 50)); } @@ -2101,6 +2103,7 @@ void main() { await tester.tap(find.text('Show suggestions')); await pumpAsync(tester); await tester.tap(find.text('Candidate task')); + await pumpAsync(tester); for (var i = 0; i < 10; i++) { await tester.pump(const Duration(milliseconds: 50)); } @@ -2111,5 +2114,59 @@ void main() { await tester.runAsync(() => db.loadTodaysFiveState(_todayKey())); expect(saved!.taskIds, contains(id)); }); + + // [Regression — Codex PR #78 P2] A cached suggestion that later becomes + // blocked (or non-leaf) must be revalidated and dropped on refresh, so a + // stale pill can't pin a task the algorithm explicitly excludes. + testWidgets('a cached suggestion that becomes blocked is pruned on refresh', + (tester) async { + late int blocker, blockable; + await tester.runAsync(() async { + blocker = await db.insertTask(Task(name: 'Blocker leaf')); + blockable = await db.insertTask(Task(name: 'Blockable leaf')); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + // Both eligible and shown initially. + expect(find.text('Blockable leaf'), findsOneWidget); + + // Block it externally, then refresh — the now-blocked cached suggestion is + // pruned instead of remaining pinnable. + final state = + tester.state(find.byType(TodaysFiveScreen)); + await tester.runAsync(() => db.addDependency(blockable, blocker)); + await tester.runAsync(() => state.refreshSnapshots()); + await pumpAsync(tester); + + expect(find.text('Blockable leaf'), findsNothing); + expect(find.text('Blocker leaf'), findsOneWidget); + }); + + // [Regression — Codex PR #78 P2] Pills preload no path; the options sheet + // loads the ancestor path on demand for the tapped task and shows it. + testWidgets('options sheet shows the ancestor path (loaded on demand)', + (tester) async { + await tester.runAsync(() async { + final parent = await db.insertTask(Task(name: 'Big Parent')); + final child = await db.insertTask(Task(name: 'Nested leaf')); + await db.addRelationship(parent, child); + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + // Child is the only leaf → the only suggestion. + await tester.tap(find.text('Nested leaf')); + await pumpAsync(tester); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } + + // Sheet header surfaces the full ancestor path (non-leaf parent isn't shown + // anywhere else — it's excluded from suggestions). + expect(find.text('Big Parent'), findsOneWidget); + }); }); } From 8d9f8fa8fd189690421ff1bf04b6f7b7cc405a73 Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Sat, 18 Jul 2026 20:24:17 +0530 Subject: [PATCH 10/11] =?UTF-8?q?Stream=20suggestion=20picks=20in=20batche?= =?UTF-8?q?s=20(Codex=20P2:=20avoid=20O(n=C2=B2))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weighted-picking every eligible leaf up front is O(n²) in pickWeightedN and can freeze the Today tab on a large tree. Materialise only the first batch (24) on expand and page in further batches from the remaining pool as the strip scrolls near its end (ScrollController listener), so the up-front cost is bounded to O(batch·n). Adds @visibleForTesting hooks and a regression test (30 leaves → 24 up front, load-more → 30, then drained). Also updates the merge-check skill: don't loop on Codex indefinitely — it re-reviews each push and may keep surfacing nits, so poll ~5 min per round and, on diminishing returns, ask the user (offer to read the Codex thread in the browser) instead of fix-looping forever. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/merge-check/SKILL.md | 2 + lib/screens/todays_five_screen.dart | 118 ++++++++++++++++++---- test/screens/todays_five_screen_test.dart | 43 ++++++-- 3 files changed, 137 insertions(+), 26 deletions(-) diff --git a/.claude/skills/merge-check/SKILL.md b/.claude/skills/merge-check/SKILL.md index 28f3f30..eba4fbb 100644 --- a/.claude/skills/merge-check/SKILL.md +++ b/.claude/skills/merge-check/SKILL.md @@ -51,5 +51,7 @@ Both agents MUST run in parallel (launched in a single message with two Agent to - Do NOT merge if CI has failed. - Do NOT skip the review comment check — always wait or ask. +- **After you push a fix for review comments, WAIT for a re-review — the review is not "done" after one round.** Codex (and Claude, if enabled) **re-reviews each push**, so a fix commit can trigger a **new** round of comments (possibly on the fix itself). Do NOT treat the first round as final and do NOT merge straight after pushing a fix. After committing + pushing fixes, re-run the review-comments poll (Phase 1 step 2 — ~5 min / 10 polls of the three comment endpoints on the **new** head commit for Codex, or wait on `claude-review` if enabled) **and** re-check CI on the new commit. +- **Do NOT loop on Codex indefinitely — it may never come back "clean."** Codex re-reviews every push and often keeps surfacing fresh nitpicks/P2s round after round, so "keep fixing until no new actionable comments" can run forever. Bound it: poll ~5 min for the new round after a fix push; if a new actionable comment arrives, you may address it **once or twice**, but once rounds show **diminishing returns** (new comments are minor/nitpicky, contentious, or conflict with an explicit user decision), **STOP auto-looping and ask the user what to do.** Offer to let them **review the Codex thread directly in the browser** (the PR's "Codex Review" comments / the linked chatgpt.com/codex settings) and decide whether to address, defer (log a TODO), or merge as-is. Never silently merge over unaddressed actionable comments, and never keep fix-looping without checking in. - Do NOT force merge or use `--admin`. - Phase 1 MUST run in background so the user can do other work while waiting. diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index 58a6186..0f405e6 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' show kDebugMode, setEquals; +import 'package:flutter/foundation.dart' + show kDebugMode, setEquals, visibleForTesting; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../data/database_helper.dart'; @@ -68,15 +69,27 @@ class TodaysFiveScreenState extends State List _otherDoneToday = []; /// Algorithm-driven suggestions shown below Today's 5, generated by the same /// weighted selection as the All Tasks roulette. A nudge, not a mandate — the - /// user chooses whether to accept one. Holds **all** eligible leaf tasks in - /// weighted order (best first), so the sideways strip scrolls through the whole - /// set. Stable across refreshes (no reshuffle); consumed/ineligible entries are - /// pruned, and the full set is recomputed only when empty. See - /// [_refreshSuggestions]. + /// user chooses whether to accept one. Materialised **incrementally**: the + /// first [_suggestionBatch] are weighted-picked on expand and more batches are + /// appended (from the remaining eligible pool) as the strip is scrolled — so a + /// huge task tree never pays for a single O(n²) `pickWeightedN` up front. + /// Stable across refreshes (no reshuffle); consumed/ineligible entries are + /// pruned. See [_refreshSuggestions] / [_loadMoreSuggestions]. List _suggestions = []; /// Suggestions the user dismissed this session ("Dismiss suggestion") — /// excluded from re-selection so a dismissed task doesn't reappear. final Set _dismissedSuggestionIds = {}; + /// How many suggestions to weighted-pick per batch (initial + each scroll + /// load-more). Bounds the up-front `pickWeightedN` cost to O(batch·n). + static const int _suggestionBatch = 24; + /// Guards re-entrant scroll-triggered batch loads. + bool _loadingMoreSuggestions = false; + /// True once a batch pick returned fewer than [_suggestionBatch] — the pool is + /// drained, so stop triggering further load-more. + bool _suggestionsExhausted = false; + /// Drives the horizontal suggestion strip; its listener pages in more batches + /// as the user nears the end. + final ScrollController _suggestionsScrollController = ScrollController(); /// Suggestions are OPT-IN: hidden behind a "Show suggestions" affordance so /// the focus screen isn't cluttered with unrelated tasks. Only computed and /// shown once the user expands them. @@ -98,15 +111,26 @@ class TodaysFiveScreenState extends State // Listen for sync completion to reload Today's 5 from DB after pull. _authProvider = context.read(); _authProvider!.addListener(_onSyncStatusChanged); + _suggestionsScrollController.addListener(_onSuggestionsScroll); } @override void dispose() { _provider?.removeListener(_onProviderChanged); _authProvider?.removeListener(_onSyncStatusChanged); + _suggestionsScrollController.dispose(); super.dispose(); } + /// Pages in another suggestion batch when the strip is scrolled near its end. + void _onSuggestionsScroll() { + if (_loadingMoreSuggestions || _suggestionsExhausted) return; + final pos = _suggestionsScrollController.position; + if (pos.pixels >= pos.maxScrollExtent - 240) { + _loadMoreSuggestions(); + } + } + void _onProviderChanged() { if (!mounted || _loading) return; if (_todayKey() != _loadedDateKey) { @@ -478,15 +502,16 @@ class TodaysFiveScreenState extends State /// Behaviour: /// - No-op while collapsed (opt-in) or when Today's 5 is full — nothing /// could be accepted, so suggesting is pointless. - /// - Shows **all** eligible leaf tasks (weighted order, best first) so the - /// sideways strip keeps revealing more as the user scrolls, rather than a - /// fixed handful. Order is stable: consumed/ineligible entries are pruned - /// on each refresh, and the full set is (re)computed only when the list is - /// empty (first expand, or after everything was accepted/dismissed) — never - /// reshuffled underfoot. + /// - Materialises only the FIRST [_suggestionBatch] on expand (weighted + /// order, best first); the strip pages in further batches as it's scrolled + /// ([_loadMoreSuggestions]). Order is stable: consumed/ineligible entries + /// are pruned on each refresh, and the batch is (re)picked only when the + /// list is empty (first expand, or after everything was accepted/dismissed) + /// — never reshuffled underfoot. Future _refreshSuggestions() async { if (!_suggestionsExpanded || _todaysTasks.length >= maxPins) { if (_suggestions.isNotEmpty) _suggestions = []; + _suggestionsExhausted = false; return; } final provider = context.read(); @@ -516,32 +541,83 @@ class TodaysFiveScreenState extends State // have nothing to show — avoids reshuffling. if (_suggestions.isNotEmpty) return; + // Nothing to show → materialise the FIRST batch only. Further batches page + // in on scroll (see [_loadMoreSuggestions]); weighted-picking the whole pool + // at once is O(n²) in pickWeightedN and can freeze a large tree. + _suggestionsExhausted = false; + final picked = await _pickSuggestionBatch(provider, _suggestionBatch); + _suggestions = picked; + _suggestionsExhausted = picked.length < _suggestionBatch; + } + + /// Weighted-picks up to [count] fresh suggestions from the currently eligible + /// pool (leaf, unblocked, not pinned/done/dismissed, and not already in + /// [_suggestions]). Capping the pick at [count] keeps each call O(count·n) + /// instead of the O(n²) a full-pool pick would cost. + Future> _pickSuggestionBatch( + TaskProvider provider, int count) async { + final inTodaysFive = _todaysTasks.map((t) => t.id).toSet(); + final doneToday = _otherDoneToday.map((t) => t.id).toSet(); + final allLeaves = await provider.getAllLeafTasks(); + final leafIds = allLeaves.map((t) => t.id!).toList(); + final blockedIds = await provider.getBlockedChildIds(leafIds); final scheduleBoostedIds = await provider.getScheduleBoostedLeafIds(); final deadlineDaysMap = await provider.getDeadlineBoostedLeafData(); final normData = await provider.getNormalizationData(leafIds); - final exclude = { ...inTodaysFive, ...doneToday, ..._completedIds, ..._dismissedSuggestionIds, + ..._suggestions.map((t) => t.id), }; final pool = allLeaves .where((t) => !blockedIds.contains(t.id) && !exclude.contains(t.id)) .toList(); - if (pool.isEmpty) return; - - // Show ALL eligible, weighted-ordered (best first) — the strip scrolls - // sideways through the whole set instead of a fixed target count. - _suggestions = provider.pickWeightedN( + if (pool.isEmpty) return const []; + return provider.pickWeightedN( pool, - pool.length, + count, scheduleBoostedIds: scheduleBoostedIds, deadlineDaysMap: deadlineDaysMap, normData: normData, ); } + /// Appends the next weighted batch of suggestions — triggered when the strip + /// is scrolled near its end ([_onSuggestionsScroll]). Guarded against + /// re-entrancy and stops once the pool is drained ([_suggestionsExhausted]). + Future _loadMoreSuggestions() async { + if (_loadingMoreSuggestions || + _suggestionsExhausted || + !_suggestionsExpanded || + _todaysTasks.length >= maxPins) { + return; + } + _loadingMoreSuggestions = true; + try { + final provider = context.read(); + final picked = await _pickSuggestionBatch(provider, _suggestionBatch); + if (!mounted) return; + if (picked.length < _suggestionBatch) _suggestionsExhausted = true; + if (picked.isNotEmpty) { + setState(() => _suggestions = [..._suggestions, ...picked]); + } + } finally { + _loadingMoreSuggestions = false; + } + } + + /// Test hook: number of suggestions currently materialised (a batch grows on + /// scroll rather than the whole pool up front). + @visibleForTesting + int get materializedSuggestionCount => _suggestions.length; + + /// Test hook: force a scroll-triggered batch load without driving the + /// ScrollController. + @visibleForTesting + Future loadMoreSuggestionsForTest() => _loadMoreSuggestions(); + /// Accepts a suggestion into Today's 5 (implicit pin). Reuses the shared /// idempotent-add path; the follow-up reload prunes the accepted task from /// the suggestion list and backfills a fresh pick. @@ -1572,9 +1648,11 @@ class TodaysFiveScreenState extends State height: pillHeight * 2 + rowGap, child: _fadeRightEdge( child: ListView.builder( + controller: _suggestionsScrollController, scrollDirection: Axis.horizontal, // Renders ~4 pills at a time (2 columns) and lazily builds more as the - // strip is scrolled sideways. + // strip is scrolled sideways. The controller's listener also pages in + // the next weighted batch as the end nears (see _onSuggestionsScroll). itemCount: columnCount, itemBuilder: (context, col) { final topIdx = col * 2; diff --git a/test/screens/todays_five_screen_test.dart b/test/screens/todays_five_screen_test.dart index 563630f..3ac06e2 100644 --- a/test/screens/todays_five_screen_test.dart +++ b/test/screens/todays_five_screen_test.dart @@ -1814,11 +1814,11 @@ void main() { expect(find.text('Nothing pinned yet'), findsNothing); }); - testWidgets('shows ALL eligible tasks; dismissing one removes it', + testWidgets('shows all eligible tasks (within a batch); dismissing removes one', (tester) async { - // Show-all: every eligible leaf appears (weighted order) in the sideways - // strip — not a fixed handful. (SingleChildScrollView builds all pills - // eagerly, so off-screen ones are still found.) + // 5 eligible leaves < the 24-batch, so all materialise and render (the + // 800x600 test surface fits every column, so ListView.builder builds them + // all). await tester.runAsync(() async { for (var i = 0; i < 5; i++) { await db.insertTask(Task(name: 'Leaf $i')); @@ -1832,13 +1832,44 @@ void main() { // All 5 eligible leaves shown (each pill has an add_circle accept button). expect(find.byIcon(Icons.add_circle), findsNWidgets(5)); - // Dismiss one → it's removed (and suppressed); the rest stay. No backfill - // is needed since every eligible task was already shown. + // Dismiss one → it's removed (and suppressed); the rest stay. await tester.tap(find.byIcon(Icons.close).first); await pumpAsync(tester); expect(find.byIcon(Icons.add_circle), findsNWidgets(4)); }); + // [Regression — Codex PR #78 P2] Suggestions materialise a batch at a time + // (not the whole pool), so a large tree never pays an O(n²) pickWeightedN up + // front; scrolling pages in the rest. + testWidgets('suggestions materialise in batches, not all at once', + (tester) async { + // 30 eligible leaves > one 24-batch. + await tester.runAsync(() async { + for (var i = 0; i < 30; i++) { + await db.insertTask(Task(name: 'Leaf $i')); + } + }); + + await pumpAndLoad(tester, buildTestWidget()); + await tester.tap(find.text('Show suggestions')); + await pumpAsync(tester); + + final state = + tester.state(find.byType(TodaysFiveScreen)); + // Only the first batch (24 of 30) is materialised up front. + expect(state.materializedSuggestionCount, 24); + + // Paging in the next batch appends the remaining 6. + await tester.runAsync(() => state.loadMoreSuggestionsForTest()); + await pumpAsync(tester); + expect(state.materializedSuggestionCount, 30); + + // Pool drained → no further growth. + await tester.runAsync(() => state.loadMoreSuggestionsForTest()); + await pumpAsync(tester); + expect(state.materializedSuggestionCount, 30); + }); + testWidgets( 'unpinning after a full Today\'s 5 re-populates suggestions inline ' '(regression: showed "No suggestions right now")', (tester) async { From dda65ebd17ba8c0b2440461293c2aa9239cdf886 Mon Sep 17 00:00:00 2001 From: Soham Marik Date: Sat, 18 Jul 2026 20:56:20 +0530 Subject: [PATCH 11/11] Fix suggestion deadlock: bounded pick instead of scroll-streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroll-triggered batch loader (8d9f8fa) did DB queries from the ScrollController listener. Accepting a suggestion reflows the strip, which fires the listener mid-accept, so _loadMoreSuggestions ran its queries concurrently with the accept's DB reload/persist → SQLite "locked for 10s" → the widget test (and CI) hung on "accepting a suggestion". Replace the scroll-streaming with a single bounded pick: materialise up to _suggestionCap (40) eligible leaves in one weighted pass on expand and render them lazily via the horizontal ListView.builder. This still bounds the pick to O(cap·n) — the Codex O(n²) concern — with no scroll-triggered DB, no ScrollController, no re-entrancy, so no deadlock. Keeps the earlier Codex fixes (mounted guard, blocked/leaf revalidation, on-demand path). Drops the now-moot batch/load-more test. Co-Authored-By: Claude Opus 4.8 --- lib/screens/todays_five_screen.dart | 134 +++++----------------- test/screens/todays_five_screen_test.dart | 31 ----- 2 files changed, 31 insertions(+), 134 deletions(-) diff --git a/lib/screens/todays_five_screen.dart b/lib/screens/todays_five_screen.dart index 0f405e6..5e7bee0 100644 --- a/lib/screens/todays_five_screen.dart +++ b/lib/screens/todays_five_screen.dart @@ -1,7 +1,6 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' - show kDebugMode, setEquals, visibleForTesting; +import 'package:flutter/foundation.dart' show kDebugMode, setEquals; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../data/database_helper.dart'; @@ -69,27 +68,21 @@ class TodaysFiveScreenState extends State List _otherDoneToday = []; /// Algorithm-driven suggestions shown below Today's 5, generated by the same /// weighted selection as the All Tasks roulette. A nudge, not a mandate — the - /// user chooses whether to accept one. Materialised **incrementally**: the - /// first [_suggestionBatch] are weighted-picked on expand and more batches are - /// appended (from the remaining eligible pool) as the strip is scrolled — so a - /// huge task tree never pays for a single O(n²) `pickWeightedN` up front. - /// Stable across refreshes (no reshuffle); consumed/ineligible entries are - /// pruned. See [_refreshSuggestions] / [_loadMoreSuggestions]. + /// user chooses whether to accept one. Holds up to [_suggestionCap] eligible + /// leaves in weighted order (best first), picked in ONE pass on expand and + /// rendered lazily (a horizontal `ListView.builder` only builds the pills near + /// the viewport). Stable across refreshes (no reshuffle); consumed/ineligible + /// entries are pruned, and the set is re-picked only when empty. See + /// [_refreshSuggestions]. List _suggestions = []; /// Suggestions the user dismissed this session ("Dismiss suggestion") — /// excluded from re-selection so a dismissed task doesn't reappear. final Set _dismissedSuggestionIds = {}; - /// How many suggestions to weighted-pick per batch (initial + each scroll - /// load-more). Bounds the up-front `pickWeightedN` cost to O(batch·n). - static const int _suggestionBatch = 24; - /// Guards re-entrant scroll-triggered batch loads. - bool _loadingMoreSuggestions = false; - /// True once a batch pick returned fewer than [_suggestionBatch] — the pool is - /// drained, so stop triggering further load-more. - bool _suggestionsExhausted = false; - /// Drives the horizontal suggestion strip; its listener pages in more batches - /// as the user nears the end. - final ScrollController _suggestionsScrollController = ScrollController(); + /// Upper bound on how many suggestions are materialised. Caps the weighted + /// pick to O(cap·n) instead of the O(n²) a full-pool pick costs, while still + /// offering far more than fits a focus screen. (Rendered lazily, so the cap is + /// about the *pick* cost, not render.) + static const int _suggestionCap = 40; /// Suggestions are OPT-IN: hidden behind a "Show suggestions" affordance so /// the focus screen isn't cluttered with unrelated tasks. Only computed and /// shown once the user expands them. @@ -111,26 +104,15 @@ class TodaysFiveScreenState extends State // Listen for sync completion to reload Today's 5 from DB after pull. _authProvider = context.read(); _authProvider!.addListener(_onSyncStatusChanged); - _suggestionsScrollController.addListener(_onSuggestionsScroll); } @override void dispose() { _provider?.removeListener(_onProviderChanged); _authProvider?.removeListener(_onSyncStatusChanged); - _suggestionsScrollController.dispose(); super.dispose(); } - /// Pages in another suggestion batch when the strip is scrolled near its end. - void _onSuggestionsScroll() { - if (_loadingMoreSuggestions || _suggestionsExhausted) return; - final pos = _suggestionsScrollController.position; - if (pos.pixels >= pos.maxScrollExtent - 240) { - _loadMoreSuggestions(); - } - } - void _onProviderChanged() { if (!mounted || _loading) return; if (_todayKey() != _loadedDateKey) { @@ -502,22 +484,23 @@ class TodaysFiveScreenState extends State /// Behaviour: /// - No-op while collapsed (opt-in) or when Today's 5 is full — nothing /// could be accepted, so suggesting is pointless. - /// - Materialises only the FIRST [_suggestionBatch] on expand (weighted - /// order, best first); the strip pages in further batches as it's scrolled - /// ([_loadMoreSuggestions]). Order is stable: consumed/ineligible entries - /// are pruned on each refresh, and the batch is (re)picked only when the - /// list is empty (first expand, or after everything was accepted/dismissed) - /// — never reshuffled underfoot. + /// - Picks up to [_suggestionCap] eligible leaves in ONE weighted pass on + /// expand (weighted order, best first) and renders them lazily. Capping the + /// pick keeps it O(cap·n) rather than the O(n²) a full-pool pick costs. + /// - Order is stable: consumed/ineligible entries (in Today's 5, done today, + /// or now blocked / no longer a leaf) are pruned on each refresh, and the + /// set is re-picked only when empty — never reshuffled underfoot. Future _refreshSuggestions() async { if (!_suggestionsExpanded || _todaysTasks.length >= maxPins) { if (_suggestions.isNotEmpty) _suggestions = []; - _suggestionsExhausted = false; return; } + // Callers reach here after awaiting DB work (e.g. _loadOtherDoneToday) with + // no mounted check in between — bail if the screen was disposed in that gap + // so we don't read a deactivated BuildContext. + if (!mounted) return; final provider = context.read(); - // Current eligibility, fetched once and reused for both the revalidation - // prune below and (if the list ends up empty) the recompute. final inTodaysFive = _todaysTasks.map((t) => t.id).toSet(); final doneToday = _otherDoneToday.map((t) => t.id).toSet(); final allLeaves = await provider.getAllLeafTasks(); @@ -537,30 +520,9 @@ class TodaysFiveScreenState extends State !leafIdSet.contains(t.id) || blockedIds.contains(t.id)); - // Keep the existing (stable) order; only (re)compute the full list when we - // have nothing to show — avoids reshuffling. + // Keep the existing (stable) order; only re-pick when nothing is left. if (_suggestions.isNotEmpty) return; - // Nothing to show → materialise the FIRST batch only. Further batches page - // in on scroll (see [_loadMoreSuggestions]); weighted-picking the whole pool - // at once is O(n²) in pickWeightedN and can freeze a large tree. - _suggestionsExhausted = false; - final picked = await _pickSuggestionBatch(provider, _suggestionBatch); - _suggestions = picked; - _suggestionsExhausted = picked.length < _suggestionBatch; - } - - /// Weighted-picks up to [count] fresh suggestions from the currently eligible - /// pool (leaf, unblocked, not pinned/done/dismissed, and not already in - /// [_suggestions]). Capping the pick at [count] keeps each call O(count·n) - /// instead of the O(n²) a full-pool pick would cost. - Future> _pickSuggestionBatch( - TaskProvider provider, int count) async { - final inTodaysFive = _todaysTasks.map((t) => t.id).toSet(); - final doneToday = _otherDoneToday.map((t) => t.id).toSet(); - final allLeaves = await provider.getAllLeafTasks(); - final leafIds = allLeaves.map((t) => t.id!).toList(); - final blockedIds = await provider.getBlockedChildIds(leafIds); final scheduleBoostedIds = await provider.getScheduleBoostedLeafIds(); final deadlineDaysMap = await provider.getDeadlineBoostedLeafData(); final normData = await provider.getNormalizationData(leafIds); @@ -569,55 +531,23 @@ class TodaysFiveScreenState extends State ...doneToday, ..._completedIds, ..._dismissedSuggestionIds, - ..._suggestions.map((t) => t.id), }; final pool = allLeaves .where((t) => !blockedIds.contains(t.id) && !exclude.contains(t.id)) .toList(); - if (pool.isEmpty) return const []; - return provider.pickWeightedN( + if (pool.isEmpty) return; + + // Cap the pick so a large tree never triggers a full-pool O(n²) pick; the + // strip renders these lazily and the user scrolls through them. + _suggestions = provider.pickWeightedN( pool, - count, + pool.length < _suggestionCap ? pool.length : _suggestionCap, scheduleBoostedIds: scheduleBoostedIds, deadlineDaysMap: deadlineDaysMap, normData: normData, ); } - /// Appends the next weighted batch of suggestions — triggered when the strip - /// is scrolled near its end ([_onSuggestionsScroll]). Guarded against - /// re-entrancy and stops once the pool is drained ([_suggestionsExhausted]). - Future _loadMoreSuggestions() async { - if (_loadingMoreSuggestions || - _suggestionsExhausted || - !_suggestionsExpanded || - _todaysTasks.length >= maxPins) { - return; - } - _loadingMoreSuggestions = true; - try { - final provider = context.read(); - final picked = await _pickSuggestionBatch(provider, _suggestionBatch); - if (!mounted) return; - if (picked.length < _suggestionBatch) _suggestionsExhausted = true; - if (picked.isNotEmpty) { - setState(() => _suggestions = [..._suggestions, ...picked]); - } - } finally { - _loadingMoreSuggestions = false; - } - } - - /// Test hook: number of suggestions currently materialised (a batch grows on - /// scroll rather than the whole pool up front). - @visibleForTesting - int get materializedSuggestionCount => _suggestions.length; - - /// Test hook: force a scroll-triggered batch load without driving the - /// ScrollController. - @visibleForTesting - Future loadMoreSuggestionsForTest() => _loadMoreSuggestions(); - /// Accepts a suggestion into Today's 5 (implicit pin). Reuses the shared /// idempotent-add path; the follow-up reload prunes the accepted task from /// the suggestion list and backfills a fresh pick. @@ -1648,11 +1578,9 @@ class TodaysFiveScreenState extends State height: pillHeight * 2 + rowGap, child: _fadeRightEdge( child: ListView.builder( - controller: _suggestionsScrollController, scrollDirection: Axis.horizontal, - // Renders ~4 pills at a time (2 columns) and lazily builds more as the - // strip is scrolled sideways. The controller's listener also pages in - // the next weighted batch as the end nears (see _onSuggestionsScroll). + // Lazily builds only the columns near the viewport; scrolls sideways + // through the (capped) materialised set. itemCount: columnCount, itemBuilder: (context, col) { final topIdx = col * 2; diff --git a/test/screens/todays_five_screen_test.dart b/test/screens/todays_five_screen_test.dart index 3ac06e2..2a5e3f6 100644 --- a/test/screens/todays_five_screen_test.dart +++ b/test/screens/todays_five_screen_test.dart @@ -1838,37 +1838,6 @@ void main() { expect(find.byIcon(Icons.add_circle), findsNWidgets(4)); }); - // [Regression — Codex PR #78 P2] Suggestions materialise a batch at a time - // (not the whole pool), so a large tree never pays an O(n²) pickWeightedN up - // front; scrolling pages in the rest. - testWidgets('suggestions materialise in batches, not all at once', - (tester) async { - // 30 eligible leaves > one 24-batch. - await tester.runAsync(() async { - for (var i = 0; i < 30; i++) { - await db.insertTask(Task(name: 'Leaf $i')); - } - }); - - await pumpAndLoad(tester, buildTestWidget()); - await tester.tap(find.text('Show suggestions')); - await pumpAsync(tester); - - final state = - tester.state(find.byType(TodaysFiveScreen)); - // Only the first batch (24 of 30) is materialised up front. - expect(state.materializedSuggestionCount, 24); - - // Paging in the next batch appends the remaining 6. - await tester.runAsync(() => state.loadMoreSuggestionsForTest()); - await pumpAsync(tester); - expect(state.materializedSuggestionCount, 30); - - // Pool drained → no further growth. - await tester.runAsync(() => state.loadMoreSuggestionsForTest()); - await pumpAsync(tester); - expect(state.materializedSuggestionCount, 30); - }); testWidgets( 'unpinning after a full Today\'s 5 re-populates suggestions inline '