feat(backlog): wire drawer action commands

This commit is contained in:
Ashley Venn 2026-07-07 14:44:34 -07:00
parent 1c945fad3f
commit 3b950358c2
9 changed files with 1067 additions and 89 deletions

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
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`.

View file

@ -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<void> 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<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.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
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.
Future<void> completeFlexibleTask({
required String taskId,

View file

@ -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<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.
class _NewBacklogTaskDialog extends StatefulWidget {
/// 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 {
final requests = <BacklogBoardReadRequest>[];
final item = _boardItemWithChildren();
@ -408,6 +598,15 @@ Future<void> _pumpBacklogScreen(
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.
class _BacklogCommandHarness {
/// 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.
BacklogBoardItemReadModel _boardItemWithChildren() {
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.
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,

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.
/// 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<BacklogTag> before, Set<BacklogTag> 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) {

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.
Future<ApplicationResult<ApplicationCommandResult>> removeTask({
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 {
final overdue = task(
id: 'overdue',