Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20
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
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
return;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
_selectedTaskDetail = null;
|
||||||
|
_selectedTaskDetailError = null;
|
||||||
|
_selectedTaskActionMessage = null;
|
||||||
|
_refreshDataState();
|
||||||
|
notifyListeners();
|
||||||
|
await _loadSelectedTaskDetail(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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() {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue