From 2bcbba46b60a6bb31b5a858c858b4cdac44dcbe9 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 7 Jul 2026 13:18:42 -0700 Subject: [PATCH] feat(backlog): render board task cards --- .../UI_PLAN_2_IMPLEMENTATION_NOTES.md | 21 + .../theme/backlog_board_visual_tokens.dart | 150 +++++ .../widgets/backlog/backlog_board_column.dart | 269 +++++++++ .../widgets/backlog/backlog_board_screen.dart | 326 ++++------- .../widgets/backlog/backlog_task_card.dart | 516 ++++++++++++++++++ .../lib/widgets/timeline/difficulty_bars.dart | 27 + .../widgets/backlog_board_screen_test.dart | 224 ++++++++ 7 files changed, 1313 insertions(+), 220 deletions(-) create mode 100644 apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_column.dart create mode 100644 apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_card.dart create mode 100644 apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart diff --git a/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md b/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md index 8ea4743..3bc4fea 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md +++ b/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md @@ -116,3 +116,24 @@ Freeform Tags Metadata.md` for a later migration-backed feature. shells, loading/empty/failure states, and a compact summary panel shell. Detailed cards, charts, and drawer contents remain for later UI Plan 2 blocks. + +## Block 04 Board Columns and Cards + +1. Backlog bucket, priority, project, reward, and future tag-chip colors now + live in `apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart` + instead of inline card mappings. +2. `BacklogBoardScreen` delegates column rendering to reusable board column and + task card widgets. The board keeps the summary panel fixed on the right and + horizontally scrolls the four-column board if the available desktop width is + constrained. +3. `BacklogTaskCard` renders only public `BacklogBoardItemReadModel` data: + title, subtitle or bucket reason, duration, project dot/name, priority flag, + difficulty bars, solid reward marks, selected outline, and direct child + previews. +4. Add-task controls intentionally call a no-op bucket callback until Block 8 + wires the create-task command flow. Card taps select through + `BacklogBoardController.selectTask`, which performs a read-only detail query. +5. The current demo seed still represents mockup child rows as normal backlog + tasks rather than parent-child relationships. Child preview rendering is + covered by a focused widget test using handcrafted public read models so the + seed scope does not expand in this block. diff --git a/apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart b/apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart new file mode 100644 index 0000000..8fc2a65 --- /dev/null +++ b/apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Defines Backlog board visual tokens for the FocusFlow Flutter UI. +library; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'focus_flow_tokens.dart'; + +/// Maps Backlog board read-model tokens to Flutter colors and icons. +abstract final class BacklogBoardVisualTokens { + /// Accent color for the Do Next bucket. + static const doNextAccent = Color(0xFF24E65A); + + /// Accent color for the Need Time Block bucket. + static const needTimeBlockAccent = Color(0xFFFFA51F); + + /// Accent color for the Break Up First bucket. + static const breakUpFirstAccent = Color(0xFFB45CFF); + + /// Accent color for the Not Now bucket. + static const notNowAccent = Color(0xFF35BFFF); + + /// Red flag color for high-priority Backlog cards. + static const priorityHigh = Color(0xFFFF3F5B); + + /// Yellow flag color for medium-priority Backlog cards. + static const priorityMedium = Color(0xFFFFD447); + + /// Green flag color for low-priority Backlog cards. + static const priorityLow = Color(0xFF4BE064); + + /// Project dot color for Home. + static const projectHome = Color(0xFF4D86FF); + + /// Project dot color for Work. + static const projectWork = Color(0xFFFF7A1A); + + /// Project dot color for Personal. + static const projectPersonal = Color(0xFFB45CFF); + + /// Project dot color for Learning. + static const projectLearning = Color(0xFF38BDF8); + + /// Project dot color for Side Project. + static const projectSideProject = Color(0xFF2F6BFF); + + /// Resolves a Backlog column accent token to a display color. + static Color accentTokenColor( + String token, { + BacklogBoardBucket? fallbackBucket, + }) { + return switch (token) { + 'backlog-do-next' => doNextAccent, + 'backlog-time-block' => needTimeBlockAccent, + 'backlog-break-up' => breakUpFirstAccent, + 'backlog-not-now' => notNowAccent, + 'green' => doNextAccent, + 'yellow' => needTimeBlockAccent, + 'purple' => breakUpFirstAccent, + 'blue' => notNowAccent, + _ => + fallbackBucket == null + ? FocusFlowTokens.textMuted + : bucketAccent(fallbackBucket), + }; + } + + /// Resolves a board bucket to its accent color. + static Color bucketAccent(BacklogBoardBucket bucket) { + return switch (bucket) { + BacklogBoardBucket.doNext => doNextAccent, + BacklogBoardBucket.needTimeBlock => needTimeBlockAccent, + BacklogBoardBucket.breakUpFirst => breakUpFirstAccent, + BacklogBoardBucket.notNow => notNowAccent, + }; + } + + /// Resolves a board bucket to its header icon. + static IconData bucketIcon(BacklogBoardBucket bucket) { + return switch (bucket) { + BacklogBoardBucket.doNext => Icons.check_circle_outline, + BacklogBoardBucket.needTimeBlock => Icons.schedule, + BacklogBoardBucket.breakUpFirst => Icons.adjust, + BacklogBoardBucket.notNow => Icons.cloud_outlined, + }; + } + + /// Resolves a task priority to a flag color. + static Color priorityColor(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryHigh || PriorityLevel.high => priorityHigh, + PriorityLevel.medium => priorityMedium, + PriorityLevel.low || PriorityLevel.veryLow => priorityLow, + null => FocusFlowTokens.textFaint, + }; + } + + /// Resolves a task priority to a compact card label. + static String priorityLabel(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryHigh || PriorityLevel.high => 'High', + PriorityLevel.medium => 'Med', + PriorityLevel.low || PriorityLevel.veryLow => 'Low', + null => 'None', + }; + } + + /// Resolves a project color token to the Backlog board dot color. + static Color projectColor(String token) { + return switch (token) { + 'project-home' || 'home' => projectHome, + 'project-work' || 'work' => projectWork, + 'project-personal' || 'personal' => projectPersonal, + 'project-learning' || 'learning' => projectLearning, + 'project-side-project' || 'side-project' => projectSideProject, + _ => FocusFlowTokens.textMuted, + }; + } + + /// Resolves a reward level to the number of solid reward marks to render. + static int rewardCount(RewardLevel reward) { + return switch (reward) { + RewardLevel.notSet => 0, + RewardLevel.veryLow => 1, + RewardLevel.low => 2, + RewardLevel.medium => 3, + RewardLevel.high => 4, + RewardLevel.veryHigh => 5, + }; + } + + /// Resolves a Backlog tag chip background color for drawer surfaces. + static Color tagChipBackground(String label) { + return switch (label.toLowerCase()) { + 'someday' => notNowAccent.withValues(alpha: 0.16), + _ => FocusFlowTokens.elevatedPanel, + }; + } + + /// Resolves a Backlog tag chip foreground color for drawer surfaces. + static Color tagChipForeground(String label) { + return switch (label.toLowerCase()) { + 'someday' => notNowAccent, + _ => FocusFlowTokens.textMuted, + }; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_column.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_column.dart new file mode 100644 index 0000000..cf554e1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_column.dart @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders Backlog board columns for the FocusFlow Flutter UI. +library; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../theme/backlog_board_visual_tokens.dart'; +import '../../theme/focus_flow_tokens.dart'; +import 'backlog_task_card.dart'; + +/// Callback invoked when a Backlog task should become the selected task. +typedef BacklogTaskSelected = void Function(String taskId); + +/// Callback invoked when the user requests a new task for a bucket. +typedef BacklogBucketAddRequested = void Function(BacklogBoardBucket bucket); + +/// Board column containing a Backlog bucket header, task cards, and footer. +class BacklogBoardColumn extends StatelessWidget { + /// Creates a Backlog board column. + const BacklogBoardColumn({ + required this.column, + required this.selectedTaskId, + required this.onTaskSelected, + required this.onAddTask, + super.key, + }); + + /// Column read model supplied by scheduler core. + final BacklogBoardColumnReadModel column; + + /// Currently selected task id, if any. + final String? selectedTaskId; + + /// Selection callback for task cards. + final BacklogTaskSelected onTaskSelected; + + /// Add-task callback shared by header and footer controls. + final BacklogBucketAddRequested onAddTask; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final accent = BacklogBoardVisualTokens.accentTokenColor( + column.accentToken, + fallbackBucket: column.bucket, + ); + return DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: accent.withValues(alpha: 0.64)), + boxShadow: [ + BoxShadow( + color: accent.withValues(alpha: 0.06), + blurRadius: 28, + spreadRadius: -10, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BacklogColumnHeader( + column: column, + accent: accent, + onAddTask: () { + onAddTask(column.bucket); + }, + ), + const SizedBox(height: 14), + Expanded( + child: column.items.isEmpty + ? _EmptyColumnHint(accent: accent) + : ListView.separated( + padding: EdgeInsets.zero, + itemCount: column.items.length, + separatorBuilder: (context, index) => + const SizedBox(height: 8), + itemBuilder: (context, index) { + final item = column.items[index]; + return BacklogTaskCard( + item: item, + selected: item.taskId == selectedTaskId, + onTap: () { + onTaskSelected(item.taskId); + }, + ); + }, + ), + ), + const SizedBox(height: 9), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () { + onAddTask(column.bucket); + }, + icon: Icon(Icons.add, color: accent, size: 18), + label: Text('Add task', style: TextStyle(color: accent)), + ), + ), + ], + ), + ), + ); + } +} + +/// Header for a Backlog board column. +class BacklogColumnHeader extends StatelessWidget { + /// Creates a Backlog column header. + const BacklogColumnHeader({ + required this.column, + required this.accent, + required this.onAddTask, + super.key, + }); + + /// Column read model that supplies title, subtitle, bucket, and count. + final BacklogBoardColumnReadModel column; + + /// Accent color resolved from the column token. + final Color accent; + + /// Callback invoked by the header add button. + final VoidCallback onAddTask; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + BacklogBoardVisualTokens.bucketIcon(column.bucket), + color: accent, + size: 22, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + column.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 14.5, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 8), + _ColumnCountPill(label: '${column.count}', color: accent), + const SizedBox(width: 4), + IconButton( + onPressed: onAddTask, + icon: const Icon(Icons.add), + color: FocusFlowTokens.textMuted, + tooltip: 'Add task', + iconSize: 18, + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 7), + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + column.subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 11, + height: 1.25, + ), + ), + ), + ], + ); + } +} + +/// Small count pill used by Backlog column headers. +class _ColumnCountPill extends StatelessWidget { + /// Creates a column count pill. + const _ColumnCountPill({required this.label, required this.color}); + + /// Count label. + final String label; + + /// Accent color for the pill. + final Color color; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(7), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +/// Placeholder content for an empty board column. +class _EmptyColumnHint extends StatelessWidget { + /// Creates an empty-column hint. + const _EmptyColumnHint({required this.accent}); + + /// Accent color for the empty state icon. + final Color accent; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.elevatedPanel.withValues(alpha: 0.42), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.62), + ), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox_outlined, color: accent, size: 22), + const SizedBox(height: 8), + const Text( + 'No tasks here', + textAlign: TextAlign.center, + style: TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart index 4c82f67..79c3f32 100644 --- a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart @@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.dart'; import '../../controllers/backlog_board_controller.dart'; import '../../models/backlog/backlog_board_screen_data.dart'; import '../../theme/focus_flow_tokens.dart'; +import 'backlog_board_column.dart'; /// Backlog board screen backed by a public scheduler read controller. class BacklogBoardScreen extends StatelessWidget { @@ -45,12 +46,27 @@ class BacklogBoardScreen extends StatelessWidget { BacklogBoardEmpty(:final data) => _BacklogFrame( taskCountLabel: '${data.summary.totalTaskCount} tasks', controller: controller, - body: _BacklogReadyBody(data: data, isEmpty: true), + body: _BacklogReadyBody( + data: data, + selectedTaskId: controller.selectedTaskId, + onTaskSelected: (taskId) { + unawaited(controller.selectTask(taskId)); + }, + onAddTask: _handleAddTaskPlaceholder, + isEmpty: true, + ), ), BacklogBoardReady(:final data) => _BacklogFrame( taskCountLabel: '${data.summary.totalTaskCount} tasks', controller: controller, - body: _BacklogReadyBody(data: data), + body: _BacklogReadyBody( + data: data, + selectedTaskId: controller.selectedTaskId, + onTaskSelected: (taskId) { + unawaited(controller.selectTask(taskId)); + }, + onAddTask: _handleAddTaskPlaceholder, + ), ), }; }, @@ -58,6 +74,9 @@ class BacklogBoardScreen extends StatelessWidget { } } +/// Handles pre-Block-8 add-task button presses without mutating scheduler data. +void _handleAddTaskPlaceholder(BacklogBoardBucket bucket) {} + /// Main Backlog frame containing header, controls, body, and overlay slot. class _BacklogFrame extends StatelessWidget { /// Creates the Backlog frame. @@ -396,11 +415,26 @@ class _BacklogFailureBody extends StatelessWidget { /// Ready or empty Backlog board body. class _BacklogReadyBody extends StatelessWidget { /// Creates a ready body. - const _BacklogReadyBody({required this.data, this.isEmpty = false}); + const _BacklogReadyBody({ + required this.data, + required this.selectedTaskId, + required this.onTaskSelected, + required this.onAddTask, + this.isEmpty = false, + }); /// Backlog board data. final BacklogBoardScreenData data; + /// Currently selected task id, if any. + final String? selectedTaskId; + + /// Callback invoked when a task card is selected. + final BacklogTaskSelected onTaskSelected; + + /// Callback invoked when a column add-task control is pressed. + final BacklogBucketAddRequested onAddTask; + /// Whether the board has no tasks. final bool isEmpty; @@ -412,14 +446,11 @@ class _BacklogReadyBody extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final column in data.columns) ...[ - Expanded(child: _BacklogColumnShell(column: column)), - if (column != data.columns.last) const SizedBox(width: 10), - ], - ], + child: _BacklogBoardColumns( + columns: data.columns, + selectedTaskId: selectedTaskId, + onTaskSelected: onTaskSelected, + onAddTask: onAddTask, ), ), const SizedBox(width: 16), @@ -438,169 +469,76 @@ class _BacklogReadyBody extends StatelessWidget { } } -/// One Backlog board column shell. -class _BacklogColumnShell extends StatelessWidget { - /// Creates a column shell. - const _BacklogColumnShell({required this.column}); - - /// Column read model. - final BacklogBoardColumnReadModel column; - - /// Builds the widget subtree for this component. - /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. - @override - Widget build(BuildContext context) { - final accent = _accentColor(column.bucket); - return DecoratedBox( - decoration: BoxDecoration( - color: FocusFlowTokens.panelBackground, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: accent.withValues(alpha: 0.62)), - ), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon(_bucketIcon(column.bucket), color: accent, size: 22), - const SizedBox(width: 10), - Expanded( - child: Text( - column.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ), - _CountPill(label: '${column.count}', color: accent), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.add), - color: FocusFlowTokens.textMuted, - tooltip: 'Add task', - iconSize: 18, - visualDensity: VisualDensity.compact, - ), - ], - ), - const SizedBox(height: 8), - Text( - column.subtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: FocusFlowTokens.textMuted, - fontSize: 12, - ), - ), - const SizedBox(height: 16), - Expanded( - child: ListView.separated( - physics: const NeverScrollableScrollPhysics(), - itemCount: column.items.isEmpty - ? 2 - : column.items.take(4).length, - separatorBuilder: (context, index) => - const SizedBox(height: 10), - itemBuilder: (context, index) { - final hasItem = column.items.isNotEmpty; - return _ColumnSkeletonCard( - accent: accent, - active: hasItem, - title: hasItem ? column.items[index].title : null, - ); - }, - ), - ), - const SizedBox(height: 10), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: () {}, - icon: Icon(Icons.add, color: accent), - label: Text('Add task', style: TextStyle(color: accent)), - ), - ), - ], - ), - ), - ); - } -} - -/// Placeholder card used until Block 04 fills in card details. -class _ColumnSkeletonCard extends StatelessWidget { - /// Creates a skeleton card. - const _ColumnSkeletonCard({ - required this.accent, - this.title, - this.active = false, +/// Horizontally scrollable set of Backlog board columns. +class _BacklogBoardColumns extends StatelessWidget { + /// Creates a board column layout. + const _BacklogBoardColumns({ + required this.columns, + required this.selectedTaskId, + required this.onTaskSelected, + required this.onAddTask, }); - /// Accent color inherited from the column. - final Color accent; + /// Columns to render in scheduler-provided display order. + final List columns; - /// Optional task title for a loaded board item. - final String? title; + /// Currently selected task id, if any. + final String? selectedTaskId; - /// Whether this skeleton represents a loaded item. - final bool active; + /// Callback invoked when a task card is selected. + final BacklogTaskSelected onTaskSelected; + + /// Callback invoked when an add-task control is pressed. + final BacklogBucketAddRequested onAddTask; /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { - return Container( - height: 86, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: FocusFlowTokens.elevatedPanel.withValues( - alpha: active ? 1 : 0.55, - ), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: FocusFlowTokens.subtleBorder), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 14, - height: 14, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(3), - border: Border.all(color: FocusFlowTokens.textFaint), - ), - ), - const SizedBox(width: 10), - Expanded( - child: title == null - ? _SkeletonLine(widthFactor: 0.7, color: accent) - : Text( - title!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - ), - ], + if (columns.isEmpty) { + return const SizedBox.shrink(); + } + return LayoutBuilder( + builder: (context, constraints) { + const columnGap = 10.0; + const minColumnWidth = 260.0; + const maxColumnWidth = 300.0; + final gapWidth = columnGap * (columns.length - 1); + final availableWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : maxColumnWidth * columns.length + gapWidth; + final columnWidth = ((availableWidth - gapWidth) / columns.length) + .clamp(minColumnWidth, maxColumnWidth) + .toDouble(); + final boardWidth = columnWidth * columns.length + gapWidth; + final contentWidth = boardWidth < availableWidth + ? availableWidth + : boardWidth; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: contentWidth, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < columns.length; index += 1) ...[ + SizedBox( + width: columnWidth, + child: BacklogBoardColumn( + column: columns[index], + selectedTaskId: selectedTaskId, + onTaskSelected: onTaskSelected, + onAddTask: onAddTask, + ), + ), + if (index != columns.length - 1) + const SizedBox(width: columnGap), + ], + ], + ), ), - const Spacer(), - _SkeletonLine(widthFactor: active ? 0.55 : 0.42, color: accent), - const SizedBox(height: 8), - _SkeletonLine(widthFactor: active ? 0.78 : 0.58, color: accent), - ], - ), + ); + }, ); } } @@ -860,19 +798,16 @@ class _BacklogStatePanel extends StatelessWidget { /// Small count pill used by headers. class _CountPill extends StatelessWidget { /// Creates a count pill. - const _CountPill({required this.label, this.color}); + const _CountPill({required this.label}); /// Pill label. final String label; - /// Optional accent color. - final Color? color; - /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { - final accent = color ?? FocusFlowTokens.textMuted; + const accent = FocusFlowTokens.textMuted; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( @@ -881,8 +816,8 @@ class _CountPill extends StatelessWidget { ), child: Text( label, - style: TextStyle( - color: color ?? FocusFlowTokens.textPrimary, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, fontSize: 12, fontWeight: FontWeight.w800, ), @@ -890,52 +825,3 @@ class _CountPill extends StatelessWidget { ); } } - -/// Skeleton line used by placeholder cards. -class _SkeletonLine extends StatelessWidget { - /// Creates a skeleton line. - const _SkeletonLine({required this.widthFactor, required this.color}); - - /// Fractional width for the line. - final double widthFactor; - - /// Accent color for the line. - final Color color; - - /// Builds the widget subtree for this component. - /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. - @override - Widget build(BuildContext context) { - return FractionallySizedBox( - widthFactor: widthFactor, - alignment: Alignment.centerLeft, - child: Container( - height: 6, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.22), - borderRadius: BorderRadius.circular(999), - ), - ), - ); - } -} - -/// Resolves a display color for [bucket]. -Color _accentColor(BacklogBoardBucket bucket) { - return switch (bucket) { - BacklogBoardBucket.doNext => FocusFlowTokens.flexibleGreen, - BacklogBoardBucket.needTimeBlock => FocusFlowTokens.warningYellow, - BacklogBoardBucket.breakUpFirst => FocusFlowTokens.restPurple, - BacklogBoardBucket.notNow => FocusFlowTokens.appointmentBlue, - }; -} - -/// Resolves a display icon for [bucket]. -IconData _bucketIcon(BacklogBoardBucket bucket) { - return switch (bucket) { - BacklogBoardBucket.doNext => Icons.check_circle_outline, - BacklogBoardBucket.needTimeBlock => Icons.schedule, - BacklogBoardBucket.breakUpFirst => Icons.adjust, - BacklogBoardBucket.notNow => Icons.cloud_outlined, - }; -} diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_card.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_card.dart new file mode 100644 index 0000000..cb006b4 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_card.dart @@ -0,0 +1,516 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders Backlog board task cards for the FocusFlow Flutter UI. +library; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../theme/backlog_board_visual_tokens.dart'; +import '../../theme/focus_flow_tokens.dart'; +import '../timeline/difficulty_bars.dart'; +import '../timeline/reward_icon.dart'; + +/// Card widget for one Backlog board task read model. +class BacklogTaskCard extends StatelessWidget { + /// Creates a Backlog task card. + const BacklogTaskCard({ + required this.item, + required this.selected, + required this.onTap, + super.key, + }); + + /// Read model that supplies all card content. + final BacklogBoardItemReadModel item; + + /// Whether this card is the current UI selection. + final bool selected; + + /// Callback invoked when the user selects the card. + final VoidCallback onTap; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final accent = BacklogBoardVisualTokens.bucketAccent(item.bucket); + final subtitle = item.subtitle ?? item.bucketReason; + return Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey('backlog-card-${item.taskId}'), + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + key: ValueKey('backlog-card-container-${item.taskId}'), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + padding: const EdgeInsets.fromLTRB(10, 10, 10, 11), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + FocusFlowTokens.elevatedPanel, + accent.withValues(alpha: 0.06), + ], + ), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected + ? FocusFlowTokens.accentMagenta + : FocusFlowTokens.subtleBorder, + width: selected ? 2 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CardTitleRow(item: item, selected: selected), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 5), + Padding( + padding: const EdgeInsets.only(left: 24), + child: Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 11, + height: 1.22, + ), + ), + ), + ], + const SizedBox(height: 10), + _CardMetadataRow(item: item), + const SizedBox(height: 10), + Divider( + height: 1, + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.55), + ), + const SizedBox(height: 9), + _CardEffortRow(item: item, accent: accent), + if (item.childPreview.isNotEmpty) ...[ + const SizedBox(height: 10), + _ChildPreviewSection(children: item.childPreview), + ], + ], + ), + ), + ), + ); + } +} + +/// Header row with checkbox-like selection square and task title. +class _CardTitleRow extends StatelessWidget { + /// Creates a title row for [item]. + const _CardTitleRow({required this.item, required this.selected}); + + /// Task card data to display. + final BacklogBoardItemReadModel item; + + /// Whether this card is selected. + final bool selected; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 15, + height: 15, + margin: const EdgeInsets.only(top: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + border: Border.all( + color: selected + ? FocusFlowTokens.accentMagenta + : FocusFlowTokens.textFaint, + width: selected ? 1.8 : 1.2, + ), + color: selected + ? FocusFlowTokens.accentMagenta.withValues(alpha: 0.12) + : Colors.transparent, + ), + child: selected + ? const Icon( + Icons.check, + color: FocusFlowTokens.accentMagenta, + size: 11, + ) + : null, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 13.5, + fontWeight: FontWeight.w800, + height: 1.18, + ), + ), + ), + ], + ); + } +} + +/// Row of duration, project, and priority metadata. +class _CardMetadataRow extends StatelessWidget { + /// Creates a metadata row for [item]. + const _CardMetadataRow({required this.item}); + + /// Task card data to display. + final BacklogBoardItemReadModel item; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final projectColor = BacklogBoardVisualTokens.projectColor( + item.projectColorToken, + ); + final priorityColor = BacklogBoardVisualTokens.priorityColor(item.priority); + return Wrap( + spacing: 12, + runSpacing: 7, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _IconMetadata( + icon: Icons.schedule, + color: FocusFlowTokens.textMuted, + label: _durationLabel(item.durationMinutes), + ), + _DotMetadata(color: projectColor, label: item.projectName), + _IconMetadata( + icon: Icons.flag, + color: priorityColor, + label: BacklogBoardVisualTokens.priorityLabel(item.priority), + ), + ], + ); + } +} + +/// Compact metadata item with a Material icon. +class _IconMetadata extends StatelessWidget { + /// Creates icon metadata. + const _IconMetadata({ + required this.icon, + required this.color, + required this.label, + }); + + /// Icon shown before the label. + final IconData icon; + + /// Color used by the icon. + final Color color; + + /// Display label. + final String label; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 13), + const SizedBox(width: 5), + Text( + label, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +/// Compact metadata item with a color dot. +class _DotMetadata extends StatelessWidget { + /// Creates dot metadata. + const _DotMetadata({required this.color, required this.label}); + + /// Dot color. + final Color color; + + /// Display label. + final String label; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +/// Footer row with difficulty bars and reward marks. +class _CardEffortRow extends StatelessWidget { + /// Creates an effort row for [item]. + const _CardEffortRow({required this.item, required this.accent}); + + /// Task card data to display. + final BacklogBoardItemReadModel item; + + /// Bucket accent used for compact indicators. + final Color accent; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _MetricCluster( + label: 'Difficulty', + child: DifficultyBars.forLevel( + difficulty: item.difficulty, + color: accent, + size: const Size(36, 18), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricCluster( + label: 'Reward', + alignment: MainAxisAlignment.end, + child: _RewardMarks(reward: item.reward, color: accent), + ), + ), + ], + ); + } +} + +/// Label-and-value cluster used by the card footer. +class _MetricCluster extends StatelessWidget { + /// Creates a compact metric cluster. + const _MetricCluster({ + required this.label, + required this.child, + this.alignment = MainAxisAlignment.start, + }); + + /// Metric label. + final String label; + + /// Indicator widget. + final Widget child; + + /// Horizontal alignment for the cluster content. + final MainAxisAlignment alignment; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: alignment, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 10.5, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 6), + child, + ], + ); + } +} + +/// Solid reward marks rendered from a domain reward level. +class _RewardMarks extends StatelessWidget { + /// Creates reward marks. + const _RewardMarks({required this.reward, required this.color}); + + /// Reward level to display. + final RewardLevel reward; + + /// Mark color. + final Color color; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final count = BacklogBoardVisualTokens.rewardCount(reward); + if (count == 0) { + return const Text( + 'None', + style: TextStyle(color: FocusFlowTokens.textFaint, fontSize: 10.5), + ); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < count; index += 1) + Padding( + padding: EdgeInsets.only(left: index == 0 ? 0 : 1.5), + child: RewardIcon(color: color, size: 11), + ), + ], + ); + } +} + +/// Nested child preview rows for parent Backlog cards. +class _ChildPreviewSection extends StatelessWidget { + /// Creates a child preview section. + const _ChildPreviewSection({required this.children}); + + /// Child preview read models. + final List children; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.75), + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only(left: 10), + child: Column( + children: [ + for (final child in children) + _ChildPreviewRow( + key: ValueKey('backlog-child-${child.taskId}'), + child: child, + ), + ], + ), + ), + ); + } +} + +/// One nested child preview row inside a Backlog task card. +class _ChildPreviewRow extends StatelessWidget { + /// Creates a child preview row. + const _ChildPreviewRow({required this.child, super.key}); + + /// Child preview data. + final BacklogChildPreviewReadModel child; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 7), + child: Row( + children: [ + Icon( + child.isCompleted + ? Icons.check_box_outlined + : Icons.check_box_outline_blank, + color: FocusFlowTokens.textFaint, + size: 14, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + child.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: child.isCompleted + ? FocusFlowTokens.completedText + : FocusFlowTokens.textMuted, + fontSize: 11, + decoration: child.isCompleted + ? TextDecoration.lineThrough + : TextDecoration.none, + ), + ), + ), + if (child.durationMinutes != null) ...[ + const SizedBox(width: 8), + _IconMetadata( + icon: Icons.schedule, + color: FocusFlowTokens.textFaint, + label: _durationLabel(child.durationMinutes), + ), + ], + ], + ), + ); + } +} + +/// Formats [durationMinutes] as compact card metadata. +String _durationLabel(int? durationMinutes) { + if (durationMinutes == null) { + return 'No estimate'; + } + if (durationMinutes < 60) { + return '$durationMinutes min'; + } + final hours = durationMinutes ~/ 60; + final minutes = durationMinutes % 60; + if (minutes == 0) { + return '${hours}h'; + } + return '${hours}h ${minutes}m'; +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart b/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart index 3580a23..86262d5 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart @@ -19,6 +19,21 @@ class DifficultyBars extends StatelessWidget { super.key, }); + /// Creates difficulty bars directly from a domain difficulty level. + factory DifficultyBars.forLevel({ + required DifficultyLevel difficulty, + Color color = FocusFlowTokens.restPurple, + Size size = const Size(54, 34), + Key? key, + }) { + return DifficultyBars( + key: key, + difficulty: tokenForLevel(difficulty), + color: color, + size: size, + ); + } + /// Difficulty token that determines how many bars are filled. final TimelineDifficultyIconToken difficulty; @@ -52,6 +67,18 @@ class DifficultyBars extends StatelessWidget { }; } + /// Converts a domain difficulty level to a timeline difficulty token. + static TimelineDifficultyIconToken tokenForLevel(DifficultyLevel difficulty) { + return switch (difficulty) { + DifficultyLevel.notSet => TimelineDifficultyIconToken.notSet, + DifficultyLevel.veryEasy => TimelineDifficultyIconToken.veryEasy, + DifficultyLevel.easy => TimelineDifficultyIconToken.easy, + DifficultyLevel.medium => TimelineDifficultyIconToken.medium, + DifficultyLevel.hard => TimelineDifficultyIconToken.hard, + DifficultyLevel.veryHard => TimelineDifficultyIconToken.veryHard, + }; + } + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override diff --git a/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart b/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart new file mode 100644 index 0000000..3a983c5 --- /dev/null +++ b/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart @@ -0,0 +1,224 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests Backlog board screen card rendering. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'package:focus_flow_flutter/controllers/backlog_board_controller.dart'; +import 'package:focus_flow_flutter/controllers/selected_date_controller.dart'; +import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; +import 'package:focus_flow_flutter/theme/focus_flow_tokens.dart'; +import 'package:focus_flow_flutter/widgets/backlog/backlog_board_screen.dart'; + +/// Runs Backlog board widget tests. +void main() { + testWidgets('renders card metadata child previews and selected state', ( + tester, + ) async { + final detailReads = []; + final item = _boardItemWithChildren(); + final board = _boardWithItem(item); + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + readBoard: (_) async => ApplicationResult.success(board), + readTaskDetail: (taskId, localDate) async { + detailReads.add(taskId); + return ApplicationResult.success(_detailFor(item)); + }, + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + ); + addTearDown(controller.dispose); + addTearDown(selectedDates.dispose); + + await controller.load(); + await _pumpBacklogScreen(tester, controller); + + expect(find.text('Do Next'), findsOneWidget); + expect(find.text('Need Time Block'), findsOneWidget); + expect(find.text('Break Up First'), findsOneWidget); + expect(find.text('Not Now'), findsOneWidget); + final card = find.byKey(const ValueKey('backlog-card-plan-week')); + expect(card, findsOneWidget); + expect(find.text('Plan next week'), findsOneWidget); + expect(find.text('Outline priorities + tasks'), findsOneWidget); + expect( + find.descendant(of: card, matching: find.text('30 min')), + findsOneWidget, + ); + expect( + find.descendant(of: card, matching: find.text('Work')), + findsOneWidget, + ); + expect( + find.descendant(of: card, matching: find.text('High')), + findsOneWidget, + ); + expect( + find.descendant(of: card, matching: find.text('Difficulty')), + findsOneWidget, + ); + expect( + find.descendant(of: card, matching: find.text('Reward')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('backlog-child-define-weekly-goals')), + findsOneWidget, + ); + expect(find.text('Define weekly goals'), findsOneWidget); + expect(find.text('15 min'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week'))); + await tester.pumpAndSettle(); + + expect(detailReads, ['plan-week']); + expect(controller.selectedTaskId, 'plan-week'); + final cardContainer = tester.widget( + find.byKey(const ValueKey('backlog-card-container-plan-week')), + ); + final decoration = cardContainer.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.top.color, FocusFlowTokens.accentMagenta); + }); +} + +/// Pumps [controller] inside the dark FocusFlow app shell test harness. +Future _pumpBacklogScreen( + WidgetTester tester, + BacklogBoardController controller, +) async { + await tester.binding.setSurfaceSize(const Size(1320, 820)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: FocusFlowTheme.dark(), + home: Scaffold( + body: SizedBox( + width: 1300, + height: 780, + child: BacklogBoardScreen(controller: controller), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +/// Builds a board item with child preview rows for widget tests. +BacklogBoardItemReadModel _boardItemWithChildren() { + return BacklogBoardItemReadModel( + taskId: 'plan-week', + title: 'Plan next week', + subtitle: 'Outline priorities + tasks', + projectId: 'work', + projectName: 'Work', + projectColorToken: 'project-work', + durationMinutes: 30, + priority: PriorityLevel.high, + reward: RewardLevel.high, + difficulty: DifficultyLevel.medium, + bucket: BacklogBoardBucket.breakUpFirst, + bucketReason: 'Already has child-task planning.', + createdAt: DateTime.utc(2026, 7, 7), + updatedAt: DateTime.utc(2026, 7, 7), + childPreview: const [ + BacklogChildPreviewReadModel( + taskId: 'define-weekly-goals', + title: 'Define weekly goals', + durationMinutes: 15, + isCompleted: false, + ), + ], + tags: const ['someday'], + ); +} + +/// Builds a Backlog board containing [item] and the other stable empty columns. +BacklogBoardQueryResult _boardWithItem(BacklogBoardItemReadModel item) { + return BacklogBoardQueryResult( + ownerId: 'owner-1', + localDate: CivilDate(2026, 7, 7), + columns: [ + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.doNext, + title: 'Do Next', + subtitle: 'Ready when you have a small opening.', + accentToken: 'backlog-do-next', + items: const [], + ), + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.needTimeBlock, + title: 'Need Time Block', + subtitle: 'Give these a protected block.', + accentToken: 'backlog-time-block', + items: const [], + ), + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.breakUpFirst, + title: 'Break Up First', + subtitle: 'Split these before scheduling.', + accentToken: 'backlog-break-up', + items: [item], + ), + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.notNow, + title: 'Not Now', + subtitle: 'Keep these out of today pressure.', + accentToken: 'backlog-not-now', + items: const [], + ), + ], + summary: BacklogBoardSummaryReadModel( + totalTaskCount: 1, + totalEstimatedMinutes: item.durationMinutes ?? 0, + priorityDistribution: BacklogDistributionReadModel( + title: 'By priority', + entries: const [ + BacklogDistributionEntryReadModel( + id: 'high', + label: 'High', + count: 1, + percentage: 1, + colorToken: 'priority-high', + ), + ], + ), + projectDistribution: BacklogDistributionReadModel( + title: 'By project', + entries: const [ + BacklogDistributionEntryReadModel( + id: 'work', + label: 'Work', + count: 1, + percentage: 1, + colorToken: 'project-work', + ), + ], + ), + durationDistribution: BacklogDistributionReadModel( + title: 'Duration distribution', + entries: const [], + ), + planningTip: 'Pick one task.', + ), + projectOptions: const [], + ); +} + +/// Builds selected-task detail for [item]. +BacklogTaskDetailReadModel _detailFor(BacklogBoardItemReadModel item) { + return BacklogTaskDetailReadModel( + item: item, + addedLabel: 'Added today', + tags: item.tags, + projectOptions: const [], + suggestedSlots: const [], + ); +}