feat(backlog): wire drawer action commands
This commit is contained in:
parent
1c945fad3f
commit
3b950358c2
9 changed files with 1067 additions and 89 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
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;
|
||||
}
|
||||
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;
|
||||
_selectedTaskActionMessage = 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);
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue