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 f134617..c16ca37 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 @@ -170,3 +170,19 @@ Freeform Tags Metadata.md` for a later migration-backed feature. 4. Collapse and expand state is local to the Flutter widget. It hides body sections while leaving the summary header visible and does not write any scheduler state. + +## Block 07 Task Detail Drawer + +1. `BacklogBoardController` now keeps selected-task id, detail loading state, + and detail-read failures as UI-local state. Detail failures render in the + drawer and no longer replace the whole Backlog screen. +2. `BacklogBoardScreen` renders `BacklogTaskDrawer` in a right-anchored + `Stack` overlay. The board and summary row stay in their original layout, so + opening and closing the drawer does not shift cards. +3. `BacklogTaskDrawer` renders header metadata, empty or read-only notes, + project display, read-only tags, duration/reward/difficulty metrics, + suggested slot previews, and Block-8-ready action buttons from public + `BacklogTaskDetailReadModel` data. +4. Real notes and freeform tags remain deferred per the Block 02 metadata + decision. The drawer shows calm empty states when the read model has no + notes or tags instead of inventing Flutter-only persisted metadata. diff --git a/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart b/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart index 0c84a66..6ee14ac 100644 --- a/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart @@ -140,6 +140,10 @@ class BacklogBoardController extends ChangeNotifier { /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. BacklogTaskDetailReadModel? _selectedTaskDetail; + /// Private state stored as `_selectedTaskDetailError` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + String? _selectedTaskDetailError; + /// Private state stored as `_searchText` for this implementation. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. String? _searchText; @@ -179,6 +183,18 @@ class BacklogBoardController extends ChangeNotifier { /// Currently selected Backlog task id, if any. String? get selectedTaskId => _selectedTaskId; + /// Detail model for the currently selected Backlog task, if it has loaded. + BacklogTaskDetailReadModel? get selectedTaskDetail => _selectedTaskDetail; + + /// Drawer-local error message for the selected task detail read. + String? get selectedTaskDetailError => _selectedTaskDetailError; + + /// Whether a selected task detail read is currently unresolved. + bool get isSelectedTaskDetailLoading => + _selectedTaskId != null && + _selectedTaskDetail == null && + _selectedTaskDetailError == null; + /// Current search text applied to board reads. String? get searchText => _searchText; @@ -284,6 +300,7 @@ class BacklogBoardController extends ChangeNotifier { } _selectedTaskId = taskId; _selectedTaskDetail = null; + _selectedTaskDetailError = null; notifyListeners(); final sequence = _nextDetailSequence(); final date = selectedDates.date; @@ -291,30 +308,53 @@ class BacklogBoardController extends ChangeNotifier { () => 'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence', ); - final result = await readTaskDetail(taskId, date); - if (_isDetailStale(sequence, taskId)) { - logger.finer( - () => - 'Ignoring stale Backlog detail load result. ' - 'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence', - ); - return; - } - final failure = result.failure; - if (failure != null) { - logger.warn( - () => - 'Backlog detail load returned failure. taskId=$taskId ' - 'code=${failure.code.name} detailCode=${failure.detailCode}', - ); - _selectedTaskId = null; - _selectedTaskDetail = null; + try { + final result = await readTaskDetail(taskId, date); + if (_isDetailStale(sequence, taskId)) { + logger.finer( + () => + 'Ignoring stale Backlog detail load result. ' + 'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence', + ); + return; + } + final failure = result.failure; + if (failure != null) { + logger.warn( + () => + 'Backlog detail load returned failure. taskId=$taskId ' + 'code=${failure.code.name} detailCode=${failure.detailCode}', + ); + _selectedTaskDetail = null; + _selectedTaskDetailError = failure.detailCode ?? failure.code.name; + _refreshDataState(); + notifyListeners(); + return; + } + _selectedTaskDetail = result.requireValue; + _selectedTaskDetailError = null; + _refreshDataState(); + notifyListeners(); + } on Object catch (error, stackTrace) { + if (_isDetailStale(sequence, taskId)) { + logger.finer( + () => + 'Ignoring stale Backlog detail load exception. ' + 'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence', + ); + return; + } + logger.error( + () => + 'Backlog detail load threw unexpectedly. taskId=$taskId sequence=$sequence', + error: error, + stackTrace: stackTrace, + ); + _selectedTaskDetail = null; + _selectedTaskDetailError = error.toString(); + _refreshDataState(); notifyListeners(); - return; } - _selectedTaskDetail = result.requireValue; - _refreshDataState(); - notifyListeners(); } /// Clears UI-local task selection. @@ -324,6 +364,7 @@ class BacklogBoardController extends ChangeNotifier { } _selectedTaskId = null; _selectedTaskDetail = null; + _selectedTaskDetailError = null; _detailSequence += 1; _refreshDataState(); notifyListeners(); @@ -417,6 +458,7 @@ class BacklogBoardController extends ChangeNotifier { } _selectedTaskId = null; _selectedTaskDetail = null; + _selectedTaskDetailError = null; } /// Runs the `_refreshDataState` helper used inside this library. 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 3925a9a..71d3751 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 @@ -5,6 +5,7 @@ library; import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:scheduler_core/scheduler_core.dart'; @@ -15,6 +16,7 @@ import '../../theme/focus_flow_tokens.dart'; import 'backlog_board_column.dart'; import 'backlog_board_controls.dart'; import 'backlog_summary_panel.dart'; +import 'backlog_task_drawer.dart'; /// Backlog board screen backed by a public scheduler read controller. class BacklogBoardScreen extends StatelessWidget { @@ -51,10 +53,18 @@ class BacklogBoardScreen extends StatelessWidget { body: _BacklogReadyBody( data: data, selectedTaskId: controller.selectedTaskId, + selectedTaskDetail: controller.selectedTaskDetail, + isDetailLoading: controller.isSelectedTaskDetailLoading, + detailErrorMessage: controller.selectedTaskDetailError, onTaskSelected: (taskId) { unawaited(controller.selectTask(taskId)); }, + onCloseDrawer: controller.clearSelection, onAddTask: _handleAddTaskPlaceholder, + onSchedule: _handleDrawerActionPlaceholder, + onBreakUp: _handleDrawerActionPlaceholder, + onPushToSomeday: _handleDrawerActionPlaceholder, + onRemove: _handleDrawerActionPlaceholder, isEmpty: true, ), ), @@ -64,10 +74,18 @@ class BacklogBoardScreen extends StatelessWidget { body: _BacklogReadyBody( data: data, selectedTaskId: controller.selectedTaskId, + selectedTaskDetail: controller.selectedTaskDetail, + isDetailLoading: controller.isSelectedTaskDetailLoading, + detailErrorMessage: controller.selectedTaskDetailError, onTaskSelected: (taskId) { unawaited(controller.selectTask(taskId)); }, + onCloseDrawer: controller.clearSelection, onAddTask: _handleAddTaskPlaceholder, + onSchedule: _handleDrawerActionPlaceholder, + onBreakUp: _handleDrawerActionPlaceholder, + onPushToSomeday: _handleDrawerActionPlaceholder, + onRemove: _handleDrawerActionPlaceholder, ), ), }; @@ -79,6 +97,9 @@ class BacklogBoardScreen extends StatelessWidget { /// Handles pre-Block-8 add-task button presses without mutating scheduler data. void _handleAddTaskPlaceholder(BacklogBoardBucket bucket) {} +/// Handles pre-Block-8 drawer action presses without mutating scheduler data. +void _handleDrawerActionPlaceholder() {} + /// Main Backlog frame containing header, controls, body, and overlay slot. class _BacklogFrame extends StatelessWidget { /// Creates the Backlog frame. @@ -227,8 +248,16 @@ class _BacklogReadyBody extends StatelessWidget { const _BacklogReadyBody({ required this.data, required this.selectedTaskId, + required this.selectedTaskDetail, + required this.isDetailLoading, + required this.detailErrorMessage, required this.onTaskSelected, + required this.onCloseDrawer, required this.onAddTask, + required this.onSchedule, + required this.onBreakUp, + required this.onPushToSomeday, + required this.onRemove, this.isEmpty = false, }); @@ -238,12 +267,36 @@ class _BacklogReadyBody extends StatelessWidget { /// Currently selected task id, if any. final String? selectedTaskId; + /// Loaded selected task detail, if any. + final BacklogTaskDetailReadModel? selectedTaskDetail; + + /// Whether the selected task drawer is waiting for detail data. + final bool isDetailLoading; + + /// Drawer-local detail error message. + final String? detailErrorMessage; + /// Callback invoked when a task card is selected. final BacklogTaskSelected onTaskSelected; + /// Callback invoked when the drawer closes. + final VoidCallback onCloseDrawer; + /// Callback invoked when a column add-task control is pressed. final BacklogBucketAddRequested onAddTask; + /// Callback invoked when the drawer Schedule action is pressed. + final VoidCallback onSchedule; + + /// Callback invoked when the drawer Break Up action is pressed. + final VoidCallback onBreakUp; + + /// Callback invoked when the drawer Push to Someday action is pressed. + final VoidCallback onPushToSomeday; + + /// Callback invoked when the drawer Remove action is pressed. + final VoidCallback onRemove; + /// Whether the board has no tasks. final bool isEmpty; @@ -251,33 +304,79 @@ class _BacklogReadyBody extends StatelessWidget { /// 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.stretch, - children: [ - Expanded( - child: _BacklogBoardColumns( - columns: data.columns, - selectedTaskId: selectedTaskId, - onTaskSelected: onTaskSelected, - onAddTask: onAddTask, - ), - ), - const SizedBox(width: 16), - SizedBox( - width: 292, - child: isEmpty - ? const _BacklogStatePanel( - icon: Icons.check_circle_outline, - title: 'Backlog is clear', - message: 'New tasks will appear here.', - ) - : BacklogSummaryPanel(summary: data.summary), - ), - ], + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + fit: StackFit.expand, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: _BacklogBoardColumns( + columns: data.columns, + selectedTaskId: selectedTaskId, + onTaskSelected: onTaskSelected, + onAddTask: onAddTask, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 292, + child: isEmpty + ? const _BacklogStatePanel( + icon: Icons.check_circle_outline, + title: 'Backlog is clear', + message: 'New tasks will appear here.', + ) + : BacklogSummaryPanel(summary: data.summary), + ), + ], + ), + if (selectedTaskId != null) + Positioned( + top: 0, + right: 0, + bottom: 0, + width: _drawerWidthFor(constraints.maxWidth), + child: BacklogTaskDrawer( + key: const ValueKey('backlog-task-drawer'), + selectedTaskId: selectedTaskId, + detail: selectedTaskDetail, + loading: isDetailLoading, + errorMessage: detailErrorMessage, + onClose: onCloseDrawer, + onSchedule: onSchedule, + onBreakUp: onBreakUp, + onPushToSomeday: onPushToSomeday, + onRemove: onRemove, + onProjectPressed: _handleDrawerActionPlaceholder, + onAddTagPressed: _handleDrawerActionPlaceholder, + onViewDayPressed: _handleDrawerActionPlaceholder, + ), + ), + ], + ); + }, ); } } +/// Calculates the drawer width for [availableWidth] while leaving board context visible. +double _drawerWidthFor(double availableWidth) { + const preferredWidth = 466.0; + const minimumDrawerWidth = 320.0; + const safeVisibleBoardWidth = 360.0; + if (!availableWidth.isFinite) { + return preferredWidth; + } + final widthAllowedByBoard = math.max( + minimumDrawerWidth, + availableWidth - safeVisibleBoardWidth, + ); + return math.min(preferredWidth, widthAllowedByBoard); +} + /// Horizontally scrollable set of Backlog board columns. class _BacklogBoardColumns extends StatelessWidget { /// Creates a board column layout. diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart new file mode 100644 index 0000000..abe2024 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart @@ -0,0 +1,1221 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders the right-anchored Backlog task detail drawer. +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'; + +/// Right-side detail drawer for the currently selected Backlog task. +class BacklogTaskDrawer extends StatelessWidget { + /// Creates a Backlog task detail drawer. + const BacklogTaskDrawer({ + required this.selectedTaskId, + required this.detail, + required this.loading, + required this.errorMessage, + required this.onClose, + required this.onSchedule, + required this.onBreakUp, + required this.onPushToSomeday, + required this.onRemove, + required this.onProjectPressed, + required this.onAddTagPressed, + required this.onViewDayPressed, + super.key, + }); + + /// Selected task id while the drawer is open. + final String? selectedTaskId; + + /// Loaded task detail, if available. + final BacklogTaskDetailReadModel? detail; + + /// Whether the drawer should show a loading body. + final bool loading; + + /// Drawer-local error message for a failed detail read. + final String? errorMessage; + + /// Callback that closes the drawer and clears selection. + final VoidCallback onClose; + + /// Callback for the primary Schedule action. + final VoidCallback onSchedule; + + /// Callback for the Break Up action. + final VoidCallback onBreakUp; + + /// Callback for the Push to Someday action. + final VoidCallback onPushToSomeday; + + /// Callback for the Remove action. + final VoidCallback onRemove; + + /// Callback for the project selector placeholder. + final VoidCallback onProjectPressed; + + /// Callback for the tag add placeholder. + final VoidCallback onAddTagPressed; + + /// Callback for the suggested-slot View day placeholder. + final VoidCallback onViewDayPressed; + + /// 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 loadedDetail = detail; + return DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF10121B), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: FocusFlowTokens.subtleBorder), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.38), + blurRadius: 36, + offset: const Offset(-10, 0), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 18), + child: loadedDetail == null + ? _DrawerStatusContent( + selectedTaskId: selectedTaskId, + loading: loading, + errorMessage: errorMessage, + onClose: onClose, + ) + : _DrawerDetailContent( + detail: loadedDetail, + onClose: onClose, + onSchedule: onSchedule, + onBreakUp: onBreakUp, + onPushToSomeday: onPushToSomeday, + onRemove: onRemove, + onProjectPressed: onProjectPressed, + onAddTagPressed: onAddTagPressed, + onViewDayPressed: onViewDayPressed, + ), + ), + ); + } +} + +/// Status body shown before detail is available or after a detail error. +class _DrawerStatusContent extends StatelessWidget { + /// Creates a drawer status content body. + const _DrawerStatusContent({ + required this.selectedTaskId, + required this.loading, + required this.errorMessage, + required this.onClose, + }); + + /// Selected task id used in loading copy. + final String? selectedTaskId; + + /// Whether the detail read is still in progress. + final bool loading; + + /// Error message for failed reads. + final String? errorMessage; + + /// Callback that closes the drawer. + final VoidCallback onClose; + + /// 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: [ + _DrawerTopRow( + title: loading ? 'Loading task' : 'Could not load task details', + onClose: onClose, + ), + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (loading) + const SizedBox.square( + dimension: 30, + child: CircularProgressIndicator(strokeWidth: 2.4), + ) + else + const Icon( + Icons.info_outline, + color: FocusFlowTokens.warningYellow, + size: 30, + ), + const SizedBox(height: 14), + Text( + loading + ? 'Preparing ${selectedTaskId ?? 'task'} details.' + : errorMessage ?? 'The selected task is unavailable.', + textAlign: TextAlign.center, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + height: 1.35, + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +/// Fully loaded drawer content for one Backlog task. +class _DrawerDetailContent extends StatelessWidget { + /// Creates loaded drawer content. + const _DrawerDetailContent({ + required this.detail, + required this.onClose, + required this.onSchedule, + required this.onBreakUp, + required this.onPushToSomeday, + required this.onRemove, + required this.onProjectPressed, + required this.onAddTagPressed, + required this.onViewDayPressed, + }); + + /// Detail read model supplied by the scheduler application layer. + final BacklogTaskDetailReadModel detail; + + /// Callback that closes the drawer. + final VoidCallback onClose; + + /// Callback for Schedule. + final VoidCallback onSchedule; + + /// Callback for Break Up. + final VoidCallback onBreakUp; + + /// Callback for Push to Someday. + final VoidCallback onPushToSomeday; + + /// Callback for Remove. + final VoidCallback onRemove; + + /// Callback for the project selector placeholder. + final VoidCallback onProjectPressed; + + /// Callback for the tag add placeholder. + final VoidCallback onAddTagPressed; + + /// Callback for View day. + final VoidCallback onViewDayPressed; + + /// 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: [ + _DrawerHeader(detail: detail, onClose: onClose), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _NotesSection(notes: detail.notes), + const SizedBox(height: 16), + _ProjectAndTagsSection( + item: detail.item, + tags: detail.tags, + onProjectPressed: onProjectPressed, + onAddTagPressed: onAddTagPressed, + ), + const SizedBox(height: 16), + _MetricTiles(item: detail.item), + const SizedBox(height: 16), + _SuggestedSlotsSection( + slots: detail.suggestedSlots, + unavailableReason: detail.suggestedSlotUnavailableReason, + onViewDayPressed: onViewDayPressed, + ), + ], + ), + ), + ), + const SizedBox(height: 18), + _DrawerActions( + onSchedule: onSchedule, + onBreakUp: onBreakUp, + onPushToSomeday: onPushToSomeday, + onRemove: onRemove, + ), + ], + ); + } +} + +/// Shared title row with a close button. +class _DrawerTopRow extends StatelessWidget { + /// Creates a drawer top row. + const _DrawerTopRow({required this.title, required this.onClose}); + + /// Row title. + final String title; + + /// Close callback. + final VoidCallback onClose; + + /// 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: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const ValueKey('backlog-drawer-close-button'), + tooltip: 'Close task details', + onPressed: onClose, + icon: const Icon(Icons.close), + color: FocusFlowTokens.textMuted, + ), + ], + ); + } +} + +/// Header with bucket icon, title, subtitle, and age metadata. +class _DrawerHeader extends StatelessWidget { + /// Creates a drawer header. + const _DrawerHeader({required this.detail, required this.onClose}); + + /// Detail model to render. + final BacklogTaskDetailReadModel detail; + + /// Close callback. + final VoidCallback onClose; + + /// 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 item = detail.item; + final accent = BacklogBoardVisualTokens.bucketAccent(item.bucket); + final subtitle = item.subtitle ?? item.bucketReason; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: accent), + ), + child: Icon( + BacklogBoardVisualTokens.bucketIcon(item.bucket), + color: accent, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + item.title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 21, + fontWeight: FontWeight.w800, + height: 1.12, + ), + ), + ), + const SizedBox(width: 8), + const Padding( + padding: EdgeInsets.only(top: 2), + child: RewardIcon( + size: 18, + color: FocusFlowTokens.accentMagenta, + ), + ), + ], + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + subtitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + height: 1.32, + ), + ), + ], + const SizedBox(height: 10), + Text( + detail.addedLabel, + style: const TextStyle( + color: FocusFlowTokens.textFaint, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(width: 4), + IconButton( + key: const ValueKey('backlog-drawer-close-button'), + tooltip: 'Close task details', + onPressed: onClose, + icon: const Icon(Icons.close), + color: FocusFlowTokens.textMuted, + ), + ], + ), + ], + ); + } +} + +/// Notes section for the drawer. +class _NotesSection extends StatelessWidget { + /// Creates a notes section. + const _NotesSection({required this.notes}); + + /// Optional notes body. + final String? notes; + + /// 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 body = notes?.trim(); + return _SectionPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'Notes'), + const SizedBox(height: 10), + Text( + body == null || body.isEmpty ? 'No notes yet.' : body, + style: TextStyle( + color: body == null || body.isEmpty + ? FocusFlowTokens.textFaint + : FocusFlowTokens.textMuted, + height: 1.42, + ), + ), + ], + ), + ); + } +} + +/// Project and tags row. +class _ProjectAndTagsSection extends StatelessWidget { + /// Creates a project and tags section. + const _ProjectAndTagsSection({ + required this.item, + required this.tags, + required this.onProjectPressed, + required this.onAddTagPressed, + }); + + /// Selected task item data. + final BacklogBoardItemReadModel item; + + /// Detail tag labels. + final List tags; + + /// Project selector callback. + final VoidCallback onProjectPressed; + + /// Add-tag callback. + final VoidCallback onAddTagPressed; + + /// 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 LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 360) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ProjectTile(item: item, onPressed: onProjectPressed), + const SizedBox(height: 10), + _TagsTile(tags: tags, onAddTagPressed: onAddTagPressed), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _ProjectTile(item: item, onPressed: onProjectPressed), + ), + const SizedBox(width: 10), + Expanded( + child: _TagsTile(tags: tags, onAddTagPressed: onAddTagPressed), + ), + ], + ); + }, + ); + } +} + +/// Project selector tile. +class _ProjectTile extends StatelessWidget { + /// Creates a project tile. + const _ProjectTile({required this.item, required this.onPressed}); + + /// Selected task item data. + final BacklogBoardItemReadModel item; + + /// Placeholder selector callback. + final VoidCallback onPressed; + + /// 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 _SectionPanel( + onTap: onPressed, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'Project'), + const SizedBox(height: 13), + Row( + children: [ + _ColorDot( + color: BacklogBoardVisualTokens.projectColor( + item.projectColorToken, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.projectName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + const Icon( + Icons.keyboard_arrow_down, + color: FocusFlowTokens.textMuted, + ), + ], + ), + ], + ), + ); + } +} + +/// Tags tile with read-only chips and add placeholder. +class _TagsTile extends StatelessWidget { + /// Creates a tags tile. + const _TagsTile({required this.tags, required this.onAddTagPressed}); + + /// Detail tag labels. + final List tags; + + /// Add-tag callback. + final VoidCallback onAddTagPressed; + + /// 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 _SectionPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'Tags'), + const SizedBox(height: 10), + Wrap( + spacing: 7, + runSpacing: 7, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (tags.isEmpty) + const Text( + 'No tags', + style: TextStyle( + color: FocusFlowTokens.textFaint, + fontSize: 12, + ), + ) + else + for (final tag in tags) _TagChip(label: tag), + _SmallIconButton( + key: const ValueKey('backlog-drawer-add-tag-button'), + tooltip: 'Add tag', + icon: Icons.add, + onPressed: onAddTagPressed, + ), + ], + ), + ], + ), + ); + } +} + +/// Metric tile row for duration, reward, and difficulty. +class _MetricTiles extends StatelessWidget { + /// Creates metric tiles. + const _MetricTiles({required this.item}); + + /// Selected task item data. + 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) { + return LayoutBuilder( + builder: (context, constraints) { + final tileWidth = (constraints.maxWidth - 16) / 3; + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + SizedBox( + width: tileWidth, + child: _MetricTile( + title: 'Duration', + leading: const Icon( + Icons.schedule, + color: FocusFlowTokens.textMuted, + size: 18, + ), + value: _durationLabel(item.durationMinutes), + caption: item.durationMinutes == null + ? 'Add estimate' + : 'Estimated', + ), + ), + SizedBox( + width: tileWidth, + child: _MetricTile( + title: 'Reward', + leading: const RewardIcon( + size: 17, + color: FocusFlowTokens.accentMagenta, + ), + value: _rewardValue(item.reward), + caption: _rewardLabel(item.reward), + ), + ), + SizedBox( + width: tileWidth, + child: _MetricTile( + title: 'Difficulty', + leading: DifficultyBars.forLevel( + difficulty: item.difficulty, + color: BacklogBoardVisualTokens.bucketAccent(item.bucket), + size: const Size(20, 16), + ), + value: _difficultyValue(item.difficulty), + caption: _difficultyLabel(item.difficulty), + ), + ), + ], + ); + }, + ); + } +} + +/// One compact drawer metric tile. +class _MetricTile extends StatelessWidget { + /// Creates a metric tile. + const _MetricTile({ + required this.title, + required this.leading, + required this.value, + required this.caption, + }); + + /// Tile title. + final String title; + + /// Leading icon widget. + final Widget leading; + + /// Primary value. + final String value; + + /// Secondary caption. + final String caption; + + /// 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 _SectionPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + leading, + const SizedBox(width: 7), + Flexible( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + caption, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 12, + ), + ), + ], + ), + ); + } +} + +/// Suggested slots section. +class _SuggestedSlotsSection extends StatelessWidget { + /// Creates a suggested slots section. + const _SuggestedSlotsSection({ + required this.slots, + required this.unavailableReason, + required this.onViewDayPressed, + }); + + /// Suggested slot read models. + final List slots; + + /// Optional reason why suggestions are unavailable. + final String? unavailableReason; + + /// View-day callback. + final VoidCallback onViewDayPressed; + + /// 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 _SectionPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Expanded(child: _SectionLabel(label: 'Suggested slots')), + TextButton( + key: const ValueKey('backlog-drawer-view-day-button'), + onPressed: onViewDayPressed, + child: const Text('View day'), + ), + ], + ), + const SizedBox(height: 10), + if (slots.isEmpty) + Text( + unavailableReason ?? 'No suggested slots yet.', + style: const TextStyle( + color: FocusFlowTokens.textFaint, + height: 1.35, + ), + ) + else + for (final slot in slots) ...[ + _SuggestedSlotRow(slot: slot), + if (slot != slots.last) const SizedBox(height: 8), + ], + ], + ), + ); + } +} + +/// One suggested schedule slot row. +class _SuggestedSlotRow extends StatelessWidget { + /// Creates a suggested slot row. + const _SuggestedSlotRow({required this.slot}); + + /// Suggested slot data. + final SuggestedSlotReadModel slot; + + /// 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 severity = _fitSeverityColor(slot.fitSeverityToken); + return DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: slot.isPrimary + ? FocusFlowTokens.accentMagenta + : severity.withValues(alpha: 0.55), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + child: Row( + children: [ + const Icon( + Icons.calendar_today_outlined, + color: FocusFlowTokens.textMuted, + size: 17, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + slot.localDateLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: slot.isPrimary + ? BacklogBoardVisualTokens.doNextAccent + : FocusFlowTokens.textMuted, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + '${slot.startText} - ${slot.endText} | ${slot.durationMinutes} min', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + _FitBadge(label: slot.fitLabel, color: severity), + const SizedBox(width: 4), + const Icon( + Icons.chevron_right, + color: FocusFlowTokens.textMuted, + size: 20, + ), + ], + ), + ), + ); + } +} + +/// Drawer action area. +class _DrawerActions extends StatelessWidget { + /// Creates the drawer action button group. + const _DrawerActions({ + required this.onSchedule, + required this.onBreakUp, + required this.onPushToSomeday, + required this.onRemove, + }); + + /// Schedule callback. + final VoidCallback onSchedule; + + /// Break-up callback. + final VoidCallback onBreakUp; + + /// Push-to-someday callback. + final VoidCallback onPushToSomeday; + + /// Remove callback. + final VoidCallback onRemove; + + /// 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: [ + Expanded( + child: FilledButton.icon( + key: const ValueKey('backlog-drawer-schedule-button'), + onPressed: onSchedule, + icon: const Icon(Icons.check), + label: const Text('Schedule'), + style: FilledButton.styleFrom( + backgroundColor: FocusFlowTokens.accentMagenta, + foregroundColor: FocusFlowTokens.textPrimary, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + key: const ValueKey('backlog-drawer-break-up-button'), + onPressed: onBreakUp, + icon: const Icon(Icons.splitscreen), + label: const Text('Break Up'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + OutlinedButton.icon( + key: const ValueKey('backlog-drawer-push-someday-button'), + onPressed: onPushToSomeday, + icon: const Icon(Icons.arrow_forward), + label: const Text('Push to Someday'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + key: const ValueKey('backlog-drawer-remove-button'), + onPressed: onRemove, + icon: const Icon(Icons.delete_outline), + label: const Text('Remove'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ); + } +} + +/// Shared bordered section panel. +class _SectionPanel extends StatelessWidget { + /// Creates a bordered section panel. + const _SectionPanel({required this.child, this.onTap}); + + /// Panel contents. + final Widget child; + + /// Optional tap callback. + 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 content = DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.82), + ), + ), + child: Padding(padding: const EdgeInsets.all(14), child: child), + ); + final callback = onTap; + if (callback == null) { + return content; + } + return Material( + color: Colors.transparent, + child: InkWell( + onTap: callback, + borderRadius: BorderRadius.circular(8), + child: content, + ), + ); + } +} + +/// Small section label. +class _SectionLabel extends StatelessWidget { + /// Creates a section label. + const _SectionLabel({required this.label}); + + /// Label text. + 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 Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ); + } +} + +/// Small colored dot. +class _ColorDot extends StatelessWidget { + /// Creates a color dot. + const _ColorDot({required this.color}); + + /// Dot 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) { + return Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } +} + +/// Read-only tag chip. +class _TagChip extends StatelessWidget { + /// Creates a tag chip. + const _TagChip({required this.label}); + + /// Tag 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 Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: BacklogBoardVisualTokens.tagChipBackground(label), + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: BacklogBoardVisualTokens.tagChipForeground( + label, + ).withValues(alpha: 0.42), + ), + ), + child: Text( + label, + style: TextStyle( + color: BacklogBoardVisualTokens.tagChipForeground(label), + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +/// Compact icon button used inside chips and rows. +class _SmallIconButton extends StatelessWidget { + /// Creates a small icon button. + const _SmallIconButton({ + required this.tooltip, + required this.icon, + required this.onPressed, + super.key, + }); + + /// Tooltip text. + final String tooltip; + + /// Icon data. + final IconData icon; + + /// Press callback. + final VoidCallback onPressed; + + /// 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 SizedBox.square( + dimension: 30, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + icon: Icon(icon), + iconSize: 17, + color: FocusFlowTokens.textMuted, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ), + ); + } +} + +/// Fit badge for a suggested slot. +class _FitBadge extends StatelessWidget { + /// Creates a fit badge. + const _FitBadge({required this.label, required this.color}); + + /// Badge label. + final String label; + + /// Badge 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) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(7), + border: Border.all(color: color.withValues(alpha: 0.72)), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +/// Formats [minutes] for drawer display. +String _durationLabel(int? minutes) { + return minutes == null ? 'Not set' : '$minutes min'; +} + +/// Formats [reward] as a numeric rating. +String _rewardValue(RewardLevel reward) { + final count = BacklogBoardVisualTokens.rewardCount(reward); + return count == 0 ? 'Not set' : '$count/5'; +} + +/// Formats [reward] as a plain-language label. +String _rewardLabel(RewardLevel reward) { + return switch (reward) { + RewardLevel.notSet => 'Not set', + RewardLevel.veryLow => 'Tiny', + RewardLevel.low => 'Useful', + RewardLevel.medium => 'Satisfying', + RewardLevel.high => 'Strong', + RewardLevel.veryHigh => 'High reward', + }; +} + +/// Formats [difficulty] as a numeric rating. +String _difficultyValue(DifficultyLevel difficulty) { + final count = DifficultyBars.fillCountForLevel(difficulty); + return count == 0 ? 'Not set' : '$count/5'; +} + +/// Formats [difficulty] as a plain-language label. +String _difficultyLabel(DifficultyLevel difficulty) { + return switch (difficulty) { + DifficultyLevel.notSet => 'Not set', + DifficultyLevel.veryEasy => 'Very easy', + DifficultyLevel.easy => 'Easy', + DifficultyLevel.medium => 'Medium', + DifficultyLevel.hard => 'Hard', + DifficultyLevel.veryHard => 'Very hard', + }; +} + +/// Resolves [token] to a fit-badge color. +Color _fitSeverityColor(String token) { + return switch (token) { + 'fit-great' => BacklogBoardVisualTokens.doNextAccent, + 'fit-okay' => FocusFlowTokens.warningYellow, + _ => FocusFlowTokens.textMuted, + }; +} diff --git a/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart b/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart index c7939e1..6e683f4 100644 --- a/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart +++ b/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart @@ -108,6 +108,37 @@ void main() { final cleared = controller.state as BacklogBoardReady; expect(cleared.data.selectedTaskId, isNull); }); + + test('detail failures stay local to the selected drawer', () async { + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + readBoard: (_) async { + return ApplicationResult.success(_board(taskCount: 1)); + }, + readTaskDetail: (taskId, localDate) async { + return ApplicationResult.failure( + const ApplicationFailure( + code: ApplicationFailureCode.notFound, + detailCode: 'taskNotFound', + ), + ); + }, + ); + addTearDown(controller.dispose); + + await controller.load(); + await controller.selectTask('task-1'); + + expect(controller.state, isA()); + expect(controller.selectedTaskId, 'task-1'); + expect(controller.selectedTaskDetail, isNull); + expect(controller.isSelectedTaskDetailLoading, isFalse); + expect(controller.selectedTaskDetailError, 'taskNotFound'); + }); }); } 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 index 31f5fb0..1221cd8 100644 --- a/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart +++ b/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart @@ -88,6 +88,95 @@ void main() { expect(border.top.color, FocusFlowTokens.accentMagenta); }); + testWidgets('drawer overlays board and close clears selected state', ( + tester, + ) async { + 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 { + return ApplicationResult.success(_detailFor(item)); + }, + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + ); + addTearDown(controller.dispose); + addTearDown(selectedDates.dispose); + + await controller.load(); + await _pumpBacklogScreen(tester, controller); + + final cardFinder = find.byKey(const ValueKey('backlog-card-plan-week')); + final beforeOpen = tester.getTopLeft(cardFinder); + + await tester.tap(cardFinder); + await tester.pumpAndSettle(); + + final drawer = find.byKey(const ValueKey('backlog-task-drawer')); + expect(drawer, findsOneWidget); + expect( + find.descendant(of: drawer, matching: find.text('Plan next week')), + findsOneWidget, + ); + expect(tester.getTopLeft(cardFinder), beforeOpen); + expect(controller.selectedTaskId, 'plan-week'); + + await tester.tap(find.byKey(const ValueKey('backlog-drawer-close-button'))); + await tester.pumpAndSettle(); + + expect(drawer, findsNothing); + expect(controller.selectedTaskId, isNull); + 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, isNot(FocusFlowTokens.accentMagenta)); + }); + + testWidgets('detail read failure stays inside drawer overlay', ( + tester, + ) async { + 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 { + return ApplicationResult.failure( + const ApplicationFailure( + code: ApplicationFailureCode.notFound, + detailCode: 'taskNotFound', + ), + ); + }, + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + ); + addTearDown(controller.dispose); + addTearDown(selectedDates.dispose); + + await controller.load(); + await _pumpBacklogScreen(tester, controller); + + await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week'))); + await tester.pumpAndSettle(); + + final drawer = find.byKey(const ValueKey('backlog-task-drawer')); + expect(drawer, findsOneWidget); + expect(find.text('Could not load task details'), findsOneWidget); + expect(find.text('taskNotFound'), findsOneWidget); + expect(find.text('Could not load backlog'), findsNothing); + expect(find.text('Plan next week'), findsOneWidget); + expect(controller.selectedTaskId, 'plan-week'); + }); + testWidgets('controls update board read requests', (tester) async { final requests = []; final item = _boardItemWithChildren(); diff --git a/apps/focus_flow_flutter/test/widgets/backlog_task_drawer_test.dart b/apps/focus_flow_flutter/test/widgets/backlog_task_drawer_test.dart new file mode 100644 index 0000000..3e8c9e2 --- /dev/null +++ b/apps/focus_flow_flutter/test/widgets/backlog_task_drawer_test.dart @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests Backlog task detail drawer rendering and callbacks. +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/theme/focus_flow_theme.dart'; +import 'package:focus_flow_flutter/widgets/backlog/backlog_task_drawer.dart'; + +/// Runs Backlog task drawer widget tests. +void main() { + testWidgets('renders loaded detail sections and calls action callbacks', ( + tester, + ) async { + var closeCount = 0; + var scheduleCount = 0; + var breakUpCount = 0; + var somedayCount = 0; + var removeCount = 0; + var addTagCount = 0; + var viewDayCount = 0; + + await _pumpDrawer( + tester, + BacklogTaskDrawer( + selectedTaskId: 'review-q3-budget', + detail: _detail(), + loading: false, + errorMessage: null, + onClose: () { + closeCount += 1; + }, + onSchedule: () { + scheduleCount += 1; + }, + onBreakUp: () { + breakUpCount += 1; + }, + onPushToSomeday: () { + somedayCount += 1; + }, + onRemove: () { + removeCount += 1; + }, + onProjectPressed: () {}, + onAddTagPressed: () { + addTagCount += 1; + }, + onViewDayPressed: () { + viewDayCount += 1; + }, + ), + ); + + expect(find.text('Review Q3 budget'), findsOneWidget); + expect(find.text('Finance review and notes'), findsOneWidget); + expect(find.text('Added 2h ago'), findsOneWidget); + expect(find.text('Notes'), findsOneWidget); + expect(find.text(_notes), findsOneWidget); + expect(find.text('Project'), findsOneWidget); + expect(find.text('Work'), findsOneWidget); + expect(find.text('Tags'), findsOneWidget); + expect(find.text('finance'), findsOneWidget); + expect(find.text('review'), findsOneWidget); + expect(find.text('Duration'), findsOneWidget); + expect(find.text('60 min'), findsWidgets); + expect(find.text('Reward'), findsOneWidget); + expect(find.text('3/5'), findsOneWidget); + expect(find.text('Satisfying'), findsOneWidget); + expect(find.text('Difficulty'), findsOneWidget); + expect(find.text('2/5'), findsOneWidget); + expect(find.text('Easy'), findsOneWidget); + expect(find.text('Suggested slots'), findsOneWidget); + expect(find.text('Today'), findsNWidgets(2)); + expect(find.text('Tomorrow'), findsOneWidget); + expect(find.text('2:30 PM - 3:30 PM | 60 min'), findsOneWidget); + expect(find.text('Great Fit'), findsNWidgets(2)); + expect(find.text('Okay'), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-view-day-button')), + ); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-add-tag-button')), + ); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-schedule-button')), + ); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-break-up-button')), + ); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-push-someday-button')), + ); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-remove-button')), + ); + await tester.tap(find.byKey(const ValueKey('backlog-drawer-close-button'))); + + expect(viewDayCount, 1); + expect(addTagCount, 1); + expect(scheduleCount, 1); + expect(breakUpCount, 1); + expect(somedayCount, 1); + expect(removeCount, 1); + expect(closeCount, 1); + }); + + testWidgets('renders loading and error states without detail data', ( + tester, + ) async { + await _pumpDrawer( + tester, + BacklogTaskDrawer( + selectedTaskId: 'task-1', + detail: null, + loading: true, + errorMessage: null, + onClose: () {}, + onSchedule: () {}, + onBreakUp: () {}, + onPushToSomeday: () {}, + onRemove: () {}, + onProjectPressed: () {}, + onAddTagPressed: () {}, + onViewDayPressed: () {}, + ), + ); + + expect(find.text('Loading task'), findsOneWidget); + expect(find.text('Preparing task-1 details.'), findsOneWidget); + + await _pumpDrawer( + tester, + BacklogTaskDrawer( + selectedTaskId: 'task-1', + detail: null, + loading: false, + errorMessage: 'taskNotFound', + onClose: () {}, + onSchedule: () {}, + onBreakUp: () {}, + onPushToSomeday: () {}, + onRemove: () {}, + onProjectPressed: () {}, + onAddTagPressed: () {}, + onViewDayPressed: () {}, + ), + ); + + expect(find.text('Could not load task details'), findsOneWidget); + expect(find.text('taskNotFound'), findsOneWidget); + }); +} + +/// Notes fixture text used by the drawer test. +const _notes = + 'Scan numbers and compare against plan. Note any variances and prepare talking points.'; + +/// Pumps [drawer] in a fixed-size dark FocusFlow test harness. +Future _pumpDrawer(WidgetTester tester, Widget drawer) async { + await tester.binding.setSurfaceSize(const Size(520, 860)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: FocusFlowTheme.dark(), + home: Scaffold( + body: Align( + alignment: Alignment.centerRight, + child: SizedBox(width: 466, height: 820, child: drawer), + ), + ), + ), + ); + await tester.pump(); +} + +/// Builds a loaded drawer detail fixture. +BacklogTaskDetailReadModel _detail() { + return BacklogTaskDetailReadModel( + item: BacklogBoardItemReadModel( + taskId: 'review-q3-budget', + title: 'Review Q3 budget', + subtitle: 'Finance review and notes', + projectId: 'work', + projectName: 'Work', + projectColorToken: 'project-work', + durationMinutes: 60, + priority: PriorityLevel.high, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + bucket: BacklogBoardBucket.needTimeBlock, + bucketReason: 'Give this a protected block.', + createdAt: DateTime.utc(2026, 7, 7, 19), + updatedAt: DateTime.utc(2026, 7, 7, 19), + childPreview: const [], + tags: const ['finance', 'review'], + ), + notes: _notes, + addedLabel: 'Added 2h ago', + tags: const ['finance', 'review'], + projectOptions: const [ + ProjectOptionReadModel( + projectId: 'work', + name: 'Work', + colorToken: 'project-work', + isArchived: false, + ), + ], + suggestedSlots: const [ + SuggestedSlotReadModel( + id: 'slot-1', + localDateLabel: 'Today', + startText: '2:30 PM', + endText: '3:30 PM', + durationMinutes: 60, + fitLabel: 'Great Fit', + fitSeverityToken: 'fit-great', + isPrimary: true, + ), + SuggestedSlotReadModel( + id: 'slot-2', + localDateLabel: 'Today', + startText: '6:00 PM', + endText: '7:00 PM', + durationMinutes: 60, + fitLabel: 'Okay', + fitSeverityToken: 'fit-okay', + isPrimary: false, + ), + SuggestedSlotReadModel( + id: 'slot-3', + localDateLabel: 'Tomorrow', + startText: '10:00 AM', + endText: '11:00 AM', + durationMinutes: 60, + fitLabel: 'Great Fit', + fitSeverityToken: 'fit-great', + isPrimary: false, + ), + ], + ); +}