Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20

Open
pyr0ball wants to merge 26 commits from Circuit-Forge/focus-flow:feat/9-12-13-wire-backlog-nav-and-modal-actions into main
9 changed files with 1067 additions and 89 deletions
Showing only changes of commit 3b950358c2 - Show all commits

View file

@ -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 decision. The drawer shows calm empty states when the read model has no
notes or tags instead of inventing Flutter-only persisted metadata. 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 1. Metadata edit controls remain safe non-mutating placeholders at this point
because real notes and freeform tags were explicitly deferred in Block 02. 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 `scheduleBacklogItemToNextAvailableSlot` command through
`SchedulerCommandController.scheduleBacklogItem`. Missing duration shows a `SchedulerCommandController.scheduleBacklogItem`. Missing duration shows a
drawer-local message and does not start a command. drawer-local message and does not start a command.
5. The work is intentionally stopped before the Block 08 Break Up decision 5. Drawer `Break Up` opens a focused child-entry dialog with two initial rows.
point. Break Up, Push to Someday, and Remove remain unchanged until Ashley The dialog validates at least two titled rows, optional whole-minute
chooses whether to include full child-entry UI now or defer it. 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`.

View file

@ -310,59 +310,21 @@ class BacklogBoardController extends ChangeNotifier {
_selectedTaskDetailError = null; _selectedTaskDetailError = null;
_selectedTaskActionMessage = null; _selectedTaskActionMessage = null;
notifyListeners(); notifyListeners();
final sequence = _nextDetailSequence(); await _loadSelectedTaskDetail(taskId);
final date = selectedDates.date; }
logger.finer(
() => /// Reloads the currently selected task detail, if a task is selected.
'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence', Future<void> refreshSelectedTaskDetail() async {
); final taskId = _selectedTaskId;
try { if (taskId == null) {
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; 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; _selectedTaskDetail = null;
_selectedTaskDetailError = failure.detailCode ?? failure.code.name;
_refreshDataState();
notifyListeners();
return;
}
_selectedTaskDetail = result.requireValue;
_selectedTaskDetailError = null; _selectedTaskDetailError = null;
_selectedTaskActionMessage = null;
_refreshDataState(); _refreshDataState();
notifyListeners(); notifyListeners();
} on Object catch (error, stackTrace) { await _loadSelectedTaskDetail(taskId);
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();
}
} }
/// Shows [message] in the selected task drawer without mutating scheduler data. /// Shows [message] in the selected task drawer without mutating scheduler data.
@ -450,6 +412,63 @@ class BacklogBoardController extends ChangeNotifier {
super.dispose(); super.dispose();
} }
/// Loads detail data for the already-selected [taskId].
Future<void> _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. /// 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. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _handleSelectedDateChanged() { void _handleSelectedDateChanged() {

View file

@ -134,6 +134,84 @@ class SchedulerCommandController extends ChangeNotifier {
); );
} }
/// Breaks a Backlog task into direct child tasks.
Future<void> breakUpBacklogTask({
required String parentTaskId,
required List<ApplicationChildTaskDraft> 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<void> 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<void> 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. /// Completes a planned flexible task from the Today view.
Future<void> completeFlexibleTask({ Future<void> completeFlexibleTask({
required String taskId, required String taskId,

View file

@ -43,6 +43,64 @@ class BacklogBoardScreen extends StatelessWidget {
builder: (context, _) { builder: (context, _) {
final commandState = final commandState =
commandController?.state ?? const SchedulerCommandIdle(); 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) { return switch (controller.state) {
BacklogBoardLoading() => _BacklogFrame( BacklogBoardLoading() => _BacklogFrame(
taskCountLabel: '...', taskCountLabel: '...',
@ -78,22 +136,10 @@ class BacklogBoardScreen extends StatelessWidget {
}, },
onCloseDrawer: controller.clearSelection, onCloseDrawer: controller.clearSelection,
onAddTask: _addTaskHandler(context, commandController), onAddTask: _addTaskHandler(context, commandController),
onSchedule: () { onSchedule: scheduleSelectedTask,
final detail = controller.selectedTaskDetail; onBreakUp: breakUpSelectedTask,
if (detail == null) { onPushToSomeday: pushSelectedTaskToSomeday,
return; onRemove: removeSelectedTask,
}
unawaited(
_scheduleSelectedBacklogTask(
detail: detail,
boardController: controller,
commandController: commandController,
),
);
},
onBreakUp: _handleDrawerActionPlaceholder,
onPushToSomeday: _handleDrawerActionPlaceholder,
onRemove: _handleDrawerActionPlaceholder,
isEmpty: true, isEmpty: true,
), ),
), ),
@ -114,22 +160,10 @@ class BacklogBoardScreen extends StatelessWidget {
}, },
onCloseDrawer: controller.clearSelection, onCloseDrawer: controller.clearSelection,
onAddTask: _addTaskHandler(context, commandController), onAddTask: _addTaskHandler(context, commandController),
onSchedule: () { onSchedule: scheduleSelectedTask,
final detail = controller.selectedTaskDetail; onBreakUp: breakUpSelectedTask,
if (detail == null) { onPushToSomeday: pushSelectedTaskToSomeday,
return; onRemove: removeSelectedTask,
}
unawaited(
_scheduleSelectedBacklogTask(
detail: detail,
boardController: controller,
commandController: commandController,
),
);
},
onBreakUp: _handleDrawerActionPlaceholder,
onPushToSomeday: _handleDrawerActionPlaceholder,
onRemove: _handleDrawerActionPlaceholder,
), ),
), ),
}; };
@ -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() {} void _handleDrawerActionPlaceholder() {}
/// Builds the listenable used to refresh Backlog UI for reads and commands. /// Builds the listenable used to refresh Backlog UI for reads and commands.
@ -197,6 +231,425 @@ Future<void> _showNewBacklogTaskDialog(
} }
} }
/// Opens the Break Up dialog and persists child drafts through the command path.
Future<void> _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<void> _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<void> _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<bool>(
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<List<ApplicationChildTaskDraft>?> _showBreakUpTaskDialog(
BuildContext context,
BacklogTaskDetailReadModel detail,
) {
return showDialog<List<ApplicationChildTaskDraft>>(
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<TextEditingController> _titleControllers = [];
/// Text controllers for optional child duration estimates.
final List<TextEditingController> _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 = <ApplicationChildTaskDraft>[];
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<ApplicationChildTaskDraft>.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. /// Dialog that collects the title for a new Backlog task.
class _NewBacklogTaskDialog extends StatefulWidget { class _NewBacklogTaskDialog extends StatefulWidget {
/// Creates a new Backlog task dialog. /// Creates a new Backlog task dialog.

View file

@ -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<SchedulerCommandSuccess>());
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<SchedulerCommandIdle>());
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<SchedulerCommandSuccess>());
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<SchedulerCommandIdle>());
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<SchedulerCommandSuccess>());
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 { testWidgets('controls update board read requests', (tester) async {
final requests = <BacklogBoardReadRequest>[]; final requests = <BacklogBoardReadRequest>[];
final item = _boardItemWithChildren(); final item = _boardItemWithChildren();
@ -408,6 +598,15 @@ Future<void> _pumpBacklogScreen(
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
/// Lets unawaited UI command futures finish and repaint their final state.
Future<void> _settleAsyncUi(WidgetTester tester) async {
await tester.pumpAndSettle();
await tester.runAsync(() async {
await Future<void>.delayed(Duration.zero);
});
await tester.pumpAndSettle();
}
/// Test harness that wires Backlog UI controllers to in-memory scheduler use cases. /// Test harness that wires Backlog UI controllers to in-memory scheduler use cases.
class _BacklogCommandHarness { class _BacklogCommandHarness {
/// Creates a harness from initial [tasks]. /// Creates a harness from initial [tasks].
@ -542,6 +741,11 @@ Task _domainBacklogTask({
); );
} }
/// Finds the task with [taskId] in [tasks].
Task _taskById(List<Task> tasks, String taskId) {
return tasks.singleWhere((task) => task.id == taskId);
}
/// Builds a board item with child preview rows for widget tests. /// Builds a board item with child preview rows for widget tests.
BacklogBoardItemReadModel _boardItemWithChildren() { BacklogBoardItemReadModel _boardItemWithChildren() {
return BacklogBoardItemReadModel( return BacklogBoardItemReadModel(

View file

@ -29,6 +29,14 @@ enum ApplicationCommandCode {
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
moveFlexibleToBacklog, 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. /// 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. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
removeTask, removeTask,

View file

@ -229,6 +229,10 @@ List<String> _changedTaskIds(List<Task> beforeTasks, List<Task> afterTasks) {
/// Top-level helper that performs the `_taskChanged` operation for this file. /// 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. /// 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) { bool _taskChanged(Task before, Task after) {
return before.title != after.title || return before.title != after.title ||
before.projectId != after.projectId || before.projectId != after.projectId ||
@ -243,11 +247,22 @@ bool _taskChanged(Task before, Task after) {
!_sameNullableInstant(before.actualStart, after.actualStart) || !_sameNullableInstant(before.actualStart, after.actualStart) ||
!_sameNullableInstant(before.actualEnd, after.actualEnd) || !_sameNullableInstant(before.actualEnd, after.actualEnd) ||
!_sameNullableInstant(before.completedAt, after.completedAt) || !_sameNullableInstant(before.completedAt, after.completedAt) ||
!_sameNullableInstant(before.backlogEnteredAt, after.backlogEnteredAt) ||
before.backlogEnteredAtProvenance != after.backlogEnteredAtProvenance ||
before.parentTaskId != after.parentTaskId || before.parentTaskId != after.parentTaskId ||
!_sameBacklogTags(before.backlogTags, after.backlogTags) ||
before.reminderOverride != after.reminderOverride ||
!_sameNullableInstant(before.createdAt, after.createdAt) ||
!_sameNullableInstant(before.updatedAt, after.updatedAt) || !_sameNullableInstant(before.updatedAt, after.updatedAt) ||
before.stats != after.stats; 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<BacklogTag> before, Set<BacklogTag> after) {
return before.length == after.length && before.containsAll(after);
}
/// Top-level helper that performs the `_sameNullableInstant` operation for this file. /// 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. /// 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) { bool _sameNullableInstant(DateTime? first, DateTime? second) {

View file

@ -375,6 +375,106 @@ class V1ApplicationCommandUseCases {
); );
} }
/// Add the Someday/Wishlist backlog tag to an active Backlog task.
Future<ApplicationResult<ApplicationCommandResult>> pushBacklogTaskToSomeday({
required ApplicationOperationContext context,
required String taskId,
DateTime? expectedUpdatedAt,
}) {
const commandCode = ApplicationCommandCode.pushBacklogTaskToSomeday;
return applicationStore.run<ApplicationCommandResult>(
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 <TaskActivity>[],
readHint: ApplicationCommandReadHint(
affectedTaskIds: [taskId],
refreshToday: false,
),
);
},
);
}
/// Hide an active Backlog task without deleting its persisted record.
Future<ApplicationResult<ApplicationCommandResult>> archiveBacklogTask({
required ApplicationOperationContext context,
required String taskId,
DateTime? expectedUpdatedAt,
}) {
const commandCode = ApplicationCommandCode.archiveBacklogTask;
return applicationStore.run<ApplicationCommandResult>(
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 <TaskActivity>[],
readHint: ApplicationCommandReadHint(
affectedTaskIds: [taskId],
refreshToday: false,
),
);
},
);
}
/// Permanently remove a task from persistence. /// Permanently remove a task from persistence.
Future<ApplicationResult<ApplicationCommandResult>> removeTask({ Future<ApplicationResult<ApplicationCommandResult>> removeTask({
required ApplicationOperationContext context, required ApplicationOperationContext context,

View file

@ -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 { test('push next moves an overdue task after the command time', () async {
final overdue = task( final overdue = task(
id: 'overdue', id: 'overdue',