From 3b950358c27caf4945c5b722fa7c5065918a87f4 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 7 Jul 2026 14:44:34 -0700 Subject: [PATCH] feat(backlog): wire drawer action commands --- .../UI_PLAN_2_IMPLEMENTATION_NOTES.md | 21 +- .../controllers/backlog_board_controller.dart | 123 +++-- .../scheduler_command_controller_impl.dart | 78 +++ .../widgets/backlog/backlog_board_screen.dart | 519 ++++++++++++++++-- .../widgets/backlog_board_screen_test.dart | 204 +++++++ .../codes/application_command_code.dart | 8 + .../planning/planning_state.dart | 15 + .../v1_application_command_use_cases.dart | 100 ++++ .../test/application_commands_test.dart | 88 +++ 9 files changed, 1067 insertions(+), 89 deletions(-) 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 7aa1414..418ab0e 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 @@ -187,7 +187,7 @@ Freeform Tags Metadata.md` for a later migration-backed feature. decision. The drawer shows calm empty states when the read model has no notes or tags instead of inventing Flutter-only persisted metadata. -## Block 08 Commands Through Schedule +## Block 08 Commands Through Drawer Actions 1. Metadata edit controls remain safe non-mutating placeholders at this point because real notes and freeform tags were explicitly deferred in Block 02. @@ -203,6 +203,19 @@ Freeform Tags Metadata.md` for a later migration-backed feature. `scheduleBacklogItemToNextAvailableSlot` command through `SchedulerCommandController.scheduleBacklogItem`. Missing duration shows a drawer-local message and does not start a command. -5. The work is intentionally stopped before the Block 08 Break Up decision - point. Break Up, Push to Someday, and Remove remain unchanged until Ashley - chooses whether to include full child-entry UI now or defer it. +5. Drawer `Break Up` opens a focused child-entry dialog with two initial rows. + The dialog validates at least two titled rows, optional whole-minute + estimates, and submits `ApplicationChildTaskDraft` values through the + existing `breakUpTask` application command. Successful saves refresh the + board and selected detail so child previews come from scheduler read models. +6. Drawer `Push to Someday` now uses a Backlog-specific application command + that adds the existing `BacklogTag.wishlist` flag without scheduling or + deleting the task. The selected detail and board refresh into the `Not Now` + bucket. +7. Drawer `Remove` now confirms with the requested copy and archives the task + through a Backlog-specific application command. It marks the task + `TaskStatus.noLongerRelevant`, clears active Backlog selection on success, + and does not physically delete repository data. +8. The shared application-command change detector now includes Backlog metadata + such as `backlogTags`, so metadata-only commands are persisted even when the + command clock equals the task's previous `updatedAt`. 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 9edae09..426322c 100644 --- a/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart @@ -310,59 +310,21 @@ class BacklogBoardController extends ChangeNotifier { _selectedTaskDetailError = null; _selectedTaskActionMessage = null; notifyListeners(); - final sequence = _nextDetailSequence(); - final date = selectedDates.date; - logger.finer( - () => - 'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence', - ); - 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(); + await _loadSelectedTaskDetail(taskId); + } + + /// Reloads the currently selected task detail, if a task is selected. + Future refreshSelectedTaskDetail() async { + final taskId = _selectedTaskId; + if (taskId == null) { + return; } + _selectedTaskDetail = null; + _selectedTaskDetailError = null; + _selectedTaskActionMessage = null; + _refreshDataState(); + notifyListeners(); + await _loadSelectedTaskDetail(taskId); } /// Shows [message] in the selected task drawer without mutating scheduler data. @@ -450,6 +412,63 @@ class BacklogBoardController extends ChangeNotifier { super.dispose(); } + /// Loads detail data for the already-selected [taskId]. + Future _loadSelectedTaskDetail(String taskId) async { + final sequence = _nextDetailSequence(); + final date = selectedDates.date; + logger.finer( + () => + 'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence', + ); + 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(); + } + } + /// Runs the `_handleSelectedDateChanged` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. void _handleSelectedDateChanged() { diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart index 5cd86fd..ec293d4 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -134,6 +134,84 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// Breaks a Backlog task into direct child tasks. + Future breakUpBacklogTask({ + required String parentTaskId, + required List children, + required DateTime expectedUpdatedAt, + }) async { + logger.debug( + () => + 'UI command requested: break up backlog task. ' + 'parentTaskId=$parentTaskId childCount=${children.length} ' + 'expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}', + ); + await _run( + label: 'Breaking up task', + successMessage: 'Task broken up', + refreshAfterSuccess: true, + action: (operationId) { + final commandAt = now(); + return commands.breakUpTask( + context: contextFor(operationId, now: commandAt), + parentTaskId: parentTaskId, + children: children, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Moves a Backlog task to the Someday/Not Now board area. + Future pushBacklogTaskToSomeday({ + required String taskId, + required DateTime expectedUpdatedAt, + }) async { + logger.debug( + () => + 'UI command requested: push backlog task to someday. ' + 'taskId=$taskId expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}', + ); + await _run( + label: 'Moving task to Someday', + successMessage: 'Moved to Someday', + refreshAfterSuccess: true, + action: (operationId) { + final commandAt = now(); + return commands.pushBacklogTaskToSomeday( + context: contextFor(operationId, now: commandAt), + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Removes a task from the active Backlog without deleting its record. + Future archiveBacklogTask({ + required String taskId, + required DateTime expectedUpdatedAt, + }) async { + logger.debug( + () => + 'UI command requested: archive backlog task. ' + 'taskId=$taskId expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}', + ); + await _run( + label: 'Removing from Backlog', + successMessage: 'Removed from active Backlog.', + refreshAfterSuccess: true, + action: (operationId) { + final commandAt = now(); + return commands.archiveBacklogTask( + context: contextFor(operationId, now: commandAt), + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + /// Completes a planned flexible task from the Today view. Future completeFlexibleTask({ required String taskId, 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 e65817e..345c193 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 @@ -43,6 +43,64 @@ class BacklogBoardScreen extends StatelessWidget { builder: (context, _) { final commandState = commandController?.state ?? const SchedulerCommandIdle(); + void scheduleSelectedTask() { + final detail = controller.selectedTaskDetail; + if (detail == null) { + return; + } + unawaited( + _scheduleSelectedBacklogTask( + detail: detail, + boardController: controller, + commandController: commandController, + ), + ); + } + + void breakUpSelectedTask() { + final detail = controller.selectedTaskDetail; + if (detail == null) { + return; + } + unawaited( + _breakUpSelectedBacklogTask( + context: context, + detail: detail, + boardController: controller, + commandController: commandController, + ), + ); + } + + void pushSelectedTaskToSomeday() { + final detail = controller.selectedTaskDetail; + if (detail == null) { + return; + } + unawaited( + _pushSelectedBacklogTaskToSomeday( + detail: detail, + boardController: controller, + commandController: commandController, + ), + ); + } + + void removeSelectedTask() { + final detail = controller.selectedTaskDetail; + if (detail == null) { + return; + } + unawaited( + _removeSelectedBacklogTask( + context: context, + detail: detail, + boardController: controller, + commandController: commandController, + ), + ); + } + return switch (controller.state) { BacklogBoardLoading() => _BacklogFrame( taskCountLabel: '...', @@ -78,22 +136,10 @@ class BacklogBoardScreen extends StatelessWidget { }, onCloseDrawer: controller.clearSelection, onAddTask: _addTaskHandler(context, commandController), - onSchedule: () { - final detail = controller.selectedTaskDetail; - if (detail == null) { - return; - } - unawaited( - _scheduleSelectedBacklogTask( - detail: detail, - boardController: controller, - commandController: commandController, - ), - ); - }, - onBreakUp: _handleDrawerActionPlaceholder, - onPushToSomeday: _handleDrawerActionPlaceholder, - onRemove: _handleDrawerActionPlaceholder, + onSchedule: scheduleSelectedTask, + onBreakUp: breakUpSelectedTask, + onPushToSomeday: pushSelectedTaskToSomeday, + onRemove: removeSelectedTask, isEmpty: true, ), ), @@ -114,22 +160,10 @@ class BacklogBoardScreen extends StatelessWidget { }, onCloseDrawer: controller.clearSelection, onAddTask: _addTaskHandler(context, commandController), - onSchedule: () { - final detail = controller.selectedTaskDetail; - if (detail == null) { - return; - } - unawaited( - _scheduleSelectedBacklogTask( - detail: detail, - boardController: controller, - commandController: commandController, - ), - ); - }, - onBreakUp: _handleDrawerActionPlaceholder, - onPushToSomeday: _handleDrawerActionPlaceholder, - onRemove: _handleDrawerActionPlaceholder, + onSchedule: scheduleSelectedTask, + onBreakUp: breakUpSelectedTask, + onPushToSomeday: pushSelectedTaskToSomeday, + onRemove: removeSelectedTask, ), ), }; @@ -138,7 +172,7 @@ class BacklogBoardScreen extends StatelessWidget { } } -/// Handles pre-Block-8 drawer action presses without mutating scheduler data. +/// Handles deferred metadata drawer action presses without mutating scheduler data. void _handleDrawerActionPlaceholder() {} /// Builds the listenable used to refresh Backlog UI for reads and commands. @@ -197,6 +231,425 @@ Future _showNewBacklogTaskDialog( } } +/// Opens the Break Up dialog and persists child drafts through the command path. +Future _breakUpSelectedBacklogTask({ + required BuildContext context, + required BacklogTaskDetailReadModel detail, + required BacklogBoardController boardController, + required SchedulerCommandController? commandController, +}) async { + final commands = commandController; + if (commands == null) { + return; + } + final drafts = await _showBreakUpTaskDialog(context, detail); + if (drafts == null) { + return; + } + await commands.breakUpBacklogTask( + parentTaskId: detail.item.taskId, + children: drafts, + expectedUpdatedAt: detail.item.updatedAt, + ); + if (commands.state is! SchedulerCommandFailure) { + await boardController.refreshSelectedTaskDetail(); + } +} + +/// Moves the selected Backlog task to Someday through the command path. +Future _pushSelectedBacklogTaskToSomeday({ + required BacklogTaskDetailReadModel detail, + required BacklogBoardController boardController, + required SchedulerCommandController? commandController, +}) async { + final commands = commandController; + if (commands == null) { + return; + } + await commands.pushBacklogTaskToSomeday( + taskId: detail.item.taskId, + expectedUpdatedAt: detail.item.updatedAt, + ); + if (commands.state is! SchedulerCommandFailure) { + await boardController.refreshSelectedTaskDetail(); + } +} + +/// Confirms and archives the selected Backlog task through the command path. +Future _removeSelectedBacklogTask({ + required BuildContext context, + required BacklogTaskDetailReadModel detail, + required BacklogBoardController boardController, + required SchedulerCommandController? commandController, +}) async { + final commands = commandController; + if (commands == null) { + return; + } + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + backgroundColor: FocusFlowTokens.panelBackground, + title: const Text('Remove from Backlog?'), + content: const Text( + 'This hides the task from active planning. It will not schedule anything.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(false); + }, + child: const Text('Cancel'), + ), + FilledButton.icon( + key: const ValueKey('backlog-remove-confirm-button'), + onPressed: () { + Navigator.of(dialogContext).pop(true); + }, + icon: const Icon(Icons.archive_outlined), + label: const Text('Remove'), + ), + ], + ); + }, + ); + if (confirmed != true) { + return; + } + await commands.archiveBacklogTask( + taskId: detail.item.taskId, + expectedUpdatedAt: detail.item.updatedAt, + ); + if (commands.state is SchedulerCommandSuccess) { + boardController.clearSelection(); + } +} + +/// Opens the child-entry dialog used by the Break Up action. +Future?> _showBreakUpTaskDialog( + BuildContext context, + BacklogTaskDetailReadModel detail, +) { + return showDialog>( + context: context, + builder: (dialogContext) { + return _BreakUpTaskDialog(detail: detail); + }, + ); +} + +/// Dialog that collects child task drafts for a Backlog task. +class _BreakUpTaskDialog extends StatefulWidget { + /// Creates a child-entry dialog for [detail]. + const _BreakUpTaskDialog({required this.detail}); + + /// Parent task detail used for dialog title and default context. + final BacklogTaskDetailReadModel detail; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State<_BreakUpTaskDialog> createState() => _BreakUpTaskDialogState(); +} + +/// State for the Break Up child-entry dialog. +class _BreakUpTaskDialogState extends State<_BreakUpTaskDialog> { + /// Text controllers for child task titles. + final List _titleControllers = []; + + /// Text controllers for optional child duration estimates. + final List _durationControllers = []; + + /// Dialog-level validation message shown above the child rows. + String? _errorText; + + /// Initializes the dialog with the two child rows required by the V1 flow. + @override + void initState() { + super.initState(); + _addRow(); + _addRow(); + } + + /// Releases all child-row text controllers. + @override + void dispose() { + for (final controller in _titleControllers) { + controller.dispose(); + } + for (final controller in _durationControllers) { + controller.dispose(); + } + super.dispose(); + } + + /// 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 AlertDialog( + backgroundColor: FocusFlowTokens.panelBackground, + title: const Text('Break up task'), + content: SizedBox( + width: 520, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.detail.item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 12), + if (_errorText != null) ...[ + _BreakUpDialogError(message: _errorText!), + const SizedBox(height: 12), + ], + for (var index = 0; index < _titleControllers.length; index += 1) + Padding( + padding: EdgeInsets.only( + bottom: index == _titleControllers.length - 1 ? 0 : 10, + ), + child: _BreakUpChildRow( + index: index, + titleController: _titleControllers[index], + durationController: _durationControllers[index], + canRemove: _titleControllers.length > 2, + onRemove: () { + _removeRow(index); + }, + ), + ), + const SizedBox(height: 14), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + key: const ValueKey('backlog-break-up-add-row-button'), + onPressed: _addEditableRow, + icon: const Icon(Icons.add), + label: const Text('Add step'), + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + FilledButton.icon( + key: const ValueKey('backlog-break-up-save-button'), + onPressed: _submit, + icon: const Icon(Icons.call_split), + label: const Text('Save steps'), + ), + ], + ); + } + + /// Adds a row and rebuilds after the dialog is already mounted. + void _addEditableRow() { + setState(_addRow); + } + + /// Adds one child input row without triggering a rebuild by itself. + void _addRow() { + _titleControllers.add(TextEditingController()); + _durationControllers.add(TextEditingController()); + } + + /// Removes the child input row at [index]. + void _removeRow(int index) { + final title = _titleControllers.removeAt(index); + final duration = _durationControllers.removeAt(index); + title.dispose(); + duration.dispose(); + setState(() { + _errorText = null; + }); + } + + /// Validates child rows and closes the dialog with typed drafts. + void _submit() { + final drafts = []; + for (var index = 0; index < _titleControllers.length; index += 1) { + final title = _titleControllers[index].text.trim(); + final durationText = _durationControllers[index].text.trim(); + if (title.isEmpty && durationText.isEmpty) { + continue; + } + if (title.isEmpty) { + _showError('Add a title for each step with an estimate.'); + return; + } + final duration = _parseDuration(durationText); + if (durationText.isNotEmpty && duration == null) { + _showError('Use whole minutes for estimates.'); + return; + } + drafts.add( + ApplicationChildTaskDraft(title: title, durationMinutes: duration), + ); + } + if (drafts.length < 2) { + _showError('Add at least two titled steps.'); + return; + } + Navigator.of( + context, + ).pop(List.unmodifiable(drafts)); + } + + /// Shows [message] as the current dialog validation error. + void _showError(String message) { + setState(() { + _errorText = message; + }); + } + + /// Parses a positive whole-minute duration from [value]. + int? _parseDuration(String value) { + if (value.isEmpty) { + return null; + } + final parsed = int.tryParse(value); + if (parsed == null || parsed <= 0) { + return null; + } + return parsed; + } +} + +/// Error panel for Break Up dialog validation. +class _BreakUpDialogError extends StatelessWidget { + /// Creates an error panel with [message]. + const _BreakUpDialogError({required this.message}); + + /// Validation message to render. + final String message; + + /// 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( + color: FocusFlowTokens.warningYellow.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: FocusFlowTokens.warningYellow.withValues(alpha: 0.42), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + child: Row( + children: [ + const Icon( + Icons.info_outline, + color: FocusFlowTokens.warningYellow, + size: 18, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + message, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 12.5, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// One editable child row in the Break Up dialog. +class _BreakUpChildRow extends StatelessWidget { + /// Creates a child row for [index]. + const _BreakUpChildRow({ + required this.index, + required this.titleController, + required this.durationController, + required this.canRemove, + required this.onRemove, + }); + + /// Zero-based row index used for stable widget keys and labels. + final int index; + + /// Text controller for the child title. + final TextEditingController titleController; + + /// Text controller for optional duration minutes. + final TextEditingController durationController; + + /// Whether the remove button should be enabled for this row. + final bool canRemove; + + /// Callback that removes this row. + 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) { + final rowNumber = index + 1; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + key: ValueKey('backlog-break-up-title-field-$index'), + controller: titleController, + textInputAction: TextInputAction.next, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + decoration: InputDecoration( + labelText: 'Step $rowNumber', + hintText: 'Child task title', + ), + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 110, + child: TextField( + key: ValueKey('backlog-break-up-duration-field-$index'), + controller: durationController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + decoration: const InputDecoration( + labelText: 'Minutes', + hintText: 'Optional', + ), + ), + ), + const SizedBox(width: 6), + IconButton( + key: ValueKey('backlog-break-up-remove-row-button-$index'), + tooltip: 'Remove step', + onPressed: canRemove ? onRemove : null, + icon: const Icon(Icons.close), + ), + ], + ); + } +} + /// Dialog that collects the title for a new Backlog task. class _NewBacklogTaskDialog extends StatefulWidget { /// Creates a new Backlog task dialog. 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 de48a3c..a6a8095 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 @@ -273,6 +273,196 @@ void main() { }, ); + testWidgets('break up action creates child tasks and refreshes board', ( + tester, + ) async { + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'outline-report', + title: 'Outline report', + durationMinutes: 30, + ), + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-card-outline-report'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-break-up-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Break up task'), findsOneWidget); + await tester.enterText( + find.byKey(const ValueKey('backlog-break-up-title-field-0')), + 'Draft sections', + ); + await tester.enterText( + find.byKey(const ValueKey('backlog-break-up-duration-field-0')), + '15', + ); + await tester.enterText( + find.byKey(const ValueKey('backlog-break-up-title-field-1')), + 'Review outline', + ); + await tester.tap( + find.byKey(const ValueKey('backlog-break-up-save-button')), + ); + await tester.pumpAndSettle(); + + expect(harness.commandController.state, isA()); + expect(find.text('Task broken up'), findsOneWidget); + expect(find.text('Draft sections'), findsWidgets); + expect(find.text('Review outline'), findsWidgets); + expect( + harness.store.currentTasks.where((task) => task.parentTaskId != null), + hasLength(2), + ); + }); + + testWidgets('break up dialog validates two child titles', (tester) async { + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'plan-event', + title: 'Plan event', + durationMinutes: 30, + ), + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-card-plan-event'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-break-up-button')), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('backlog-break-up-title-field-0')), + 'Choose date', + ); + await tester.tap( + find.byKey(const ValueKey('backlog-break-up-save-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Add at least two titled steps.'), findsOneWidget); + expect(harness.commandController.state, isA()); + expect( + harness.store.currentTasks.where((task) => task.parentTaskId != null), + isEmpty, + ); + }); + + testWidgets('push to someday refreshes selected detail and board bucket', ( + tester, + ) async { + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'maybe-later', + title: 'Maybe later', + durationMinutes: 30, + ), + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-card-maybe-later'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-push-someday-button')), + ); + await _settleAsyncUi(tester); + + expect(harness.commandController.state, isA()); + expect(find.text('Moved to Someday'), findsOneWidget); + expect( + _taskById(harness.store.currentTasks, 'maybe-later').backlogTags, + contains(BacklogTag.wishlist), + ); + expect(harness.boardController.selectedTaskDetail?.tags, ['someday']); + final state = harness.boardController.state as BacklogBoardReady; + final notNow = state.data.columns.singleWhere( + (column) => column.bucket == BacklogBoardBucket.notNow, + ); + expect(notNow.items.single.taskId, 'maybe-later'); + }); + + testWidgets('remove action can cancel then archive active backlog item', ( + tester, + ) async { + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'archive-me', + title: 'Archive me', + durationMinutes: 30, + ), + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-card-archive-me'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-remove-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Remove from Backlog?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(harness.commandController.state, isA()); + expect( + _taskById(harness.store.currentTasks, 'archive-me').status, + TaskStatus.backlog, + ); + expect(find.byKey(const ValueKey('backlog-task-drawer')), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-remove-button')), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-remove-confirm-button')), + ); + await _settleAsyncUi(tester); + + expect(harness.commandController.state, isA()); + expect(find.text('Removed from active Backlog.'), findsOneWidget); + expect(find.byKey(const ValueKey('backlog-task-drawer')), findsNothing); + expect(find.byKey(const ValueKey('backlog-card-archive-me')), findsNothing); + expect( + _taskById(harness.store.currentTasks, 'archive-me').status, + TaskStatus.noLongerRelevant, + ); + }); + testWidgets('controls update board read requests', (tester) async { final requests = []; final item = _boardItemWithChildren(); @@ -408,6 +598,15 @@ Future _pumpBacklogScreen( await tester.pumpAndSettle(); } +/// Lets unawaited UI command futures finish and repaint their final state. +Future _settleAsyncUi(WidgetTester tester) async { + await tester.pumpAndSettle(); + await tester.runAsync(() async { + await Future.delayed(Duration.zero); + }); + await tester.pumpAndSettle(); +} + /// Test harness that wires Backlog UI controllers to in-memory scheduler use cases. class _BacklogCommandHarness { /// Creates a harness from initial [tasks]. @@ -542,6 +741,11 @@ Task _domainBacklogTask({ ); } +/// Finds the task with [taskId] in [tasks]. +Task _taskById(List tasks, String taskId) { + return tasks.singleWhere((task) => task.id == taskId); +} + /// Builds a board item with child preview rows for widget tests. BacklogBoardItemReadModel _boardItemWithChildren() { return BacklogBoardItemReadModel( diff --git a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart index 1068181..eed4c3f 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart @@ -29,6 +29,14 @@ enum ApplicationCommandCode { /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. moveFlexibleToBacklog, + /// Selects the `pushBacklogTaskToSomeday` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + pushBacklogTaskToSomeday, + + /// Selects the `archiveBacklogTask` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + archiveBacklogTask, + /// Selects the `removeTask` option from this enum. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. removeTask, diff --git a/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart b/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart index 9e1be77..4c4a794 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart @@ -229,6 +229,10 @@ List _changedTaskIds(List beforeTasks, List afterTasks) { /// Top-level helper that performs the `_taskChanged` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +/// +/// The comparison must include scheduler-visible metadata, not only scheduling +/// placement fields, because application commands persist only the tasks this +/// helper reports as changed. bool _taskChanged(Task before, Task after) { return before.title != after.title || before.projectId != after.projectId || @@ -243,11 +247,22 @@ bool _taskChanged(Task before, Task after) { !_sameNullableInstant(before.actualStart, after.actualStart) || !_sameNullableInstant(before.actualEnd, after.actualEnd) || !_sameNullableInstant(before.completedAt, after.completedAt) || + !_sameNullableInstant(before.backlogEnteredAt, after.backlogEnteredAt) || + before.backlogEnteredAtProvenance != after.backlogEnteredAtProvenance || before.parentTaskId != after.parentTaskId || + !_sameBacklogTags(before.backlogTags, after.backlogTags) || + before.reminderOverride != after.reminderOverride || + !_sameNullableInstant(before.createdAt, after.createdAt) || !_sameNullableInstant(before.updatedAt, after.updatedAt) || before.stats != after.stats; } +/// Top-level helper that performs the `_sameBacklogTags` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +bool _sameBacklogTags(Set before, Set after) { + return before.length == after.length && before.containsAll(after); +} + /// Top-level helper that performs the `_sameNullableInstant` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. bool _sameNullableInstant(DateTime? first, DateTime? second) { diff --git a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart index 15bc738..44bca0f 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart @@ -375,6 +375,106 @@ class V1ApplicationCommandUseCases { ); } + /// Add the Someday/Wishlist backlog tag to an active Backlog task. + Future> pushBacklogTaskToSomeday({ + required ApplicationOperationContext context, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.pushBacklogTaskToSomeday; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + logger.debug( + () => 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} taskId=$taskId'); + final task = await repositories.tasks.findById(taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + if (task.status != TaskStatus.backlog) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: 'notBacklogTask', + ); + } + final updatedTags = {...task.backlogTags, BacklogTag.wishlist}; + final updated = task.copyWith( + backlogTags: updatedTags, + updatedAt: context.now, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: [task], + afterTasks: [updated], + activities: const [], + readHint: ApplicationCommandReadHint( + affectedTaskIds: [taskId], + refreshToday: false, + ), + ); + }, + ); + } + + /// Hide an active Backlog task without deleting its persisted record. + Future> archiveBacklogTask({ + required ApplicationOperationContext context, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.archiveBacklogTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + logger.debug( + () => 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} taskId=$taskId'); + final task = await repositories.tasks.findById(taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + if (task.status != TaskStatus.backlog) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: 'notBacklogTask', + ); + } + final archived = task.copyWith( + status: TaskStatus.noLongerRelevant, + updatedAt: context.now, + clearSchedule: true, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: [task], + afterTasks: [archived], + activities: const [], + readHint: ApplicationCommandReadHint( + affectedTaskIds: [taskId], + refreshToday: false, + ), + ); + }, + ); + } + /// Permanently remove a task from persistence. Future> removeTask({ required ApplicationOperationContext context, diff --git a/packages/scheduler_core/test/application_commands_test.dart b/packages/scheduler_core/test/application_commands_test.dart index 60bae32..fce380b 100644 --- a/packages/scheduler_core/test/application_commands_test.dart +++ b/packages/scheduler_core/test/application_commands_test.dart @@ -316,6 +316,94 @@ void main() { ); }); + test('push backlog task to someday adds wishlist tag without scheduling', + () async { + final backlog = task( + id: 'someday', + title: 'Someday', + type: TaskType.flexible, + status: TaskStatus.backlog, + createdAt: createdAt, + durationMinutes: 25, + ); + final store = storeWithSettings(tasks: [backlog]); + + final result = await commands(store).pushBacklogTaskToSomeday( + context: appContext('push-someday', backlog.updatedAt), + taskId: backlog.id, + expectedUpdatedAt: backlog.updatedAt, + ); + + expect(result.isSuccess, isTrue); + final updated = taskById(store.currentTasks, backlog.id); + expect(updated.status, TaskStatus.backlog); + expect(updated.backlogTags, contains(BacklogTag.wishlist)); + expect(updated.scheduledStart, isNull); + expect(updated.updatedAt, backlog.updatedAt); + expect(result.requireValue.changedTasks, [updated]); + expect(store.currentOperations.single.operationId, 'push-someday'); + }); + + test('archive backlog task hides it without deleting persistence record', + () async { + final backlog = task( + id: 'archive-backlog', + title: 'Archive backlog', + type: TaskType.flexible, + status: TaskStatus.backlog, + createdAt: createdAt, + durationMinutes: 25, + ); + final store = storeWithSettings(tasks: [backlog]); + + final result = await commands(store).archiveBacklogTask( + context: appContext('archive-backlog', DateTime.utc(2026, 6, 25, 8)), + taskId: backlog.id, + expectedUpdatedAt: backlog.updatedAt, + ); + + expect(result.isSuccess, isTrue); + expect(store.currentTasks, hasLength(1)); + final archived = taskById(store.currentTasks, backlog.id); + expect(archived.status, TaskStatus.noLongerRelevant); + expect(archived.scheduledStart, isNull); + expect(archived.updatedAt, DateTime.utc(2026, 6, 25, 8)); + expect(result.requireValue.changedTasks, [archived]); + expect(store.currentOperations.single.operationId, 'archive-backlog'); + }); + + test('backlog-only commands reject planned tasks', () async { + final planned = task( + id: 'planned-only', + title: 'Planned only', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final store = storeWithSettings(tasks: [planned]); + final useCases = commands(store); + + final someday = await useCases.pushBacklogTaskToSomeday( + context: appContext('planned-someday', DateTime.utc(2026, 6, 25, 8)), + taskId: planned.id, + expectedUpdatedAt: planned.updatedAt, + ); + final archive = await useCases.archiveBacklogTask( + context: appContext('planned-archive', DateTime.utc(2026, 6, 25, 8)), + taskId: planned.id, + expectedUpdatedAt: planned.updatedAt, + ); + + expect(someday.failure!.code, ApplicationFailureCode.conflict); + expect(someday.failure!.detailCode, 'notBacklogTask'); + expect(archive.failure!.code, ApplicationFailureCode.conflict); + expect(archive.failure!.detailCode, 'notBacklogTask'); + expect(taskById(store.currentTasks, planned.id), planned); + expect(store.currentOperations, isEmpty); + }); + test('push next moves an overdue task after the command time', () async { final overdue = task( id: 'overdue',