Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20
8 changed files with 658 additions and 15 deletions
|
|
@ -186,3 +186,23 @@ Freeform Tags Metadata.md` for a later migration-backed feature.
|
||||||
4. Real notes and freeform tags remain deferred per the Block 02 metadata
|
4. Real notes and freeform tags remain deferred per the Block 02 metadata
|
||||||
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
|
||||||
|
|
||||||
|
1. Metadata edit controls remain safe non-mutating placeholders at this point
|
||||||
|
because real notes and freeform tags were explicitly deferred in Block 02.
|
||||||
|
Project and tag editing should be revisited with a migration-backed metadata
|
||||||
|
command rather than Flutter-only state.
|
||||||
|
2. `BacklogBoardScreen` now accepts the app `SchedulerCommandController`.
|
||||||
|
Backlog command state is rendered in a compact status banner below the
|
||||||
|
controls.
|
||||||
|
3. Top-level `New Task` and column `Add task` open a title-first creation
|
||||||
|
dialog. Successful submission calls the existing `quickCaptureToBacklog`
|
||||||
|
command and refreshes Backlog through the shared command controller.
|
||||||
|
4. Drawer `Schedule` calls the existing
|
||||||
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
FocusFlowSection.today => _buildTodayScreen(),
|
FocusFlowSection.today => _buildTodayScreen(),
|
||||||
FocusFlowSection.backlog => BacklogBoardScreen(
|
FocusFlowSection.backlog => BacklogBoardScreen(
|
||||||
controller: backlogController,
|
controller: backlogController,
|
||||||
|
commandController: commandController,
|
||||||
),
|
),
|
||||||
FocusFlowSection.projects ||
|
FocusFlowSection.projects ||
|
||||||
FocusFlowSection.reports ||
|
FocusFlowSection.reports ||
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,10 @@ class BacklogBoardController extends ChangeNotifier {
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
String? _selectedTaskDetailError;
|
String? _selectedTaskDetailError;
|
||||||
|
|
||||||
|
/// Private state stored as `_selectedTaskActionMessage` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
String? _selectedTaskActionMessage;
|
||||||
|
|
||||||
/// Private state stored as `_searchText` for this implementation.
|
/// Private state stored as `_searchText` for this implementation.
|
||||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
String? _searchText;
|
String? _searchText;
|
||||||
|
|
@ -189,6 +193,9 @@ class BacklogBoardController extends ChangeNotifier {
|
||||||
/// Drawer-local error message for the selected task detail read.
|
/// Drawer-local error message for the selected task detail read.
|
||||||
String? get selectedTaskDetailError => _selectedTaskDetailError;
|
String? get selectedTaskDetailError => _selectedTaskDetailError;
|
||||||
|
|
||||||
|
/// Drawer-local action message for the selected task.
|
||||||
|
String? get selectedTaskActionMessage => _selectedTaskActionMessage;
|
||||||
|
|
||||||
/// Whether a selected task detail read is currently unresolved.
|
/// Whether a selected task detail read is currently unresolved.
|
||||||
bool get isSelectedTaskDetailLoading =>
|
bool get isSelectedTaskDetailLoading =>
|
||||||
_selectedTaskId != null &&
|
_selectedTaskId != null &&
|
||||||
|
|
@ -301,6 +308,7 @@ class BacklogBoardController extends ChangeNotifier {
|
||||||
_selectedTaskId = taskId;
|
_selectedTaskId = taskId;
|
||||||
_selectedTaskDetail = null;
|
_selectedTaskDetail = null;
|
||||||
_selectedTaskDetailError = null;
|
_selectedTaskDetailError = null;
|
||||||
|
_selectedTaskActionMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
final sequence = _nextDetailSequence();
|
final sequence = _nextDetailSequence();
|
||||||
final date = selectedDates.date;
|
final date = selectedDates.date;
|
||||||
|
|
@ -357,6 +365,15 @@ class BacklogBoardController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows [message] in the selected task drawer without mutating scheduler data.
|
||||||
|
void showSelectedTaskActionMessage(String message) {
|
||||||
|
if (_selectedTaskId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_selectedTaskActionMessage = message;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Clears UI-local task selection.
|
/// Clears UI-local task selection.
|
||||||
void clearSelection() {
|
void clearSelection() {
|
||||||
if (_selectedTaskId == null && _selectedTaskDetail == null) {
|
if (_selectedTaskId == null && _selectedTaskDetail == null) {
|
||||||
|
|
@ -365,6 +382,7 @@ class BacklogBoardController extends ChangeNotifier {
|
||||||
_selectedTaskId = null;
|
_selectedTaskId = null;
|
||||||
_selectedTaskDetail = null;
|
_selectedTaskDetail = null;
|
||||||
_selectedTaskDetailError = null;
|
_selectedTaskDetailError = null;
|
||||||
|
_selectedTaskActionMessage = null;
|
||||||
_detailSequence += 1;
|
_detailSequence += 1;
|
||||||
_refreshDataState();
|
_refreshDataState();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
@ -459,6 +477,7 @@ class BacklogBoardController extends ChangeNotifier {
|
||||||
_selectedTaskId = null;
|
_selectedTaskId = null;
|
||||||
_selectedTaskDetail = null;
|
_selectedTaskDetail = null;
|
||||||
_selectedTaskDetailError = null;
|
_selectedTaskDetailError = null;
|
||||||
|
_selectedTaskActionMessage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the `_refreshDataState` helper used inside this library.
|
/// Runs the `_refreshDataState` helper used inside this library.
|
||||||
|
|
|
||||||
|
|
@ -60,11 +60,18 @@ class BacklogModeAndSettingsControls extends StatelessWidget {
|
||||||
/// Search, filter, sort, group, and new-task controls.
|
/// Search, filter, sort, group, and new-task controls.
|
||||||
class BacklogBoardTopControls extends StatefulWidget {
|
class BacklogBoardTopControls extends StatefulWidget {
|
||||||
/// Creates Backlog board top controls.
|
/// Creates Backlog board top controls.
|
||||||
const BacklogBoardTopControls({required this.controller, super.key});
|
const BacklogBoardTopControls({
|
||||||
|
required this.controller,
|
||||||
|
required this.onNewTask,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
/// Controller updated by shell controls.
|
/// Controller updated by shell controls.
|
||||||
final BacklogBoardController controller;
|
final BacklogBoardController controller;
|
||||||
|
|
||||||
|
/// Callback invoked by the New Task button.
|
||||||
|
final VoidCallback? onNewTask;
|
||||||
|
|
||||||
/// Creates the mutable state object used by this stateful widget.
|
/// 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.
|
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||||
@override
|
@override
|
||||||
|
|
@ -194,7 +201,7 @@ class _BacklogBoardTopControlsState extends State<BacklogBoardTopControls> {
|
||||||
const SizedBox(width: 18),
|
const SizedBox(width: 18),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('backlog-new-task-button'),
|
key: const ValueKey('backlog-new-task-button'),
|
||||||
onPressed: () {},
|
onPressed: widget.onNewTask,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
fixedSize: const Size(146, 44),
|
fixedSize: const Size(146, 44),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
import '../../controllers/backlog_board_controller.dart';
|
import '../../controllers/backlog_board_controller.dart';
|
||||||
|
import '../../controllers/scheduler_command_controller.dart';
|
||||||
import '../../models/backlog/backlog_board_screen_data.dart';
|
import '../../models/backlog/backlog_board_screen_data.dart';
|
||||||
import '../../theme/focus_flow_tokens.dart';
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
import 'backlog_board_column.dart';
|
import 'backlog_board_column.dart';
|
||||||
|
|
@ -21,27 +22,40 @@ import 'backlog_task_drawer.dart';
|
||||||
/// Backlog board screen backed by a public scheduler read controller.
|
/// Backlog board screen backed by a public scheduler read controller.
|
||||||
class BacklogBoardScreen extends StatelessWidget {
|
class BacklogBoardScreen extends StatelessWidget {
|
||||||
/// Creates a Backlog board screen.
|
/// Creates a Backlog board screen.
|
||||||
const BacklogBoardScreen({required this.controller, super.key});
|
const BacklogBoardScreen({
|
||||||
|
required this.controller,
|
||||||
|
this.commandController,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
/// Controller that owns Backlog board read and selection state.
|
/// Controller that owns Backlog board read and selection state.
|
||||||
final BacklogBoardController controller;
|
final BacklogBoardController controller;
|
||||||
|
|
||||||
|
/// Optional command controller used by Backlog actions.
|
||||||
|
final SchedulerCommandController? commandController;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: controller,
|
animation: _screenAnimation(controller, commandController),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
|
final commandState =
|
||||||
|
commandController?.state ?? const SchedulerCommandIdle();
|
||||||
return switch (controller.state) {
|
return switch (controller.state) {
|
||||||
BacklogBoardLoading() => _BacklogFrame(
|
BacklogBoardLoading() => _BacklogFrame(
|
||||||
taskCountLabel: '...',
|
taskCountLabel: '...',
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
commandState: commandState,
|
||||||
|
onNewTask: _newTaskHandler(context, commandController),
|
||||||
body: const _BacklogLoadingBody(),
|
body: const _BacklogLoadingBody(),
|
||||||
),
|
),
|
||||||
BacklogBoardFailure(:final message) => _BacklogFrame(
|
BacklogBoardFailure(:final message) => _BacklogFrame(
|
||||||
taskCountLabel: '...',
|
taskCountLabel: '...',
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
commandState: commandState,
|
||||||
|
onNewTask: _newTaskHandler(context, commandController),
|
||||||
body: _BacklogFailureBody(
|
body: _BacklogFailureBody(
|
||||||
message: message,
|
message: message,
|
||||||
onRetry: controller.retry,
|
onRetry: controller.retry,
|
||||||
|
|
@ -50,18 +64,33 @@ class BacklogBoardScreen extends StatelessWidget {
|
||||||
BacklogBoardEmpty(:final data) => _BacklogFrame(
|
BacklogBoardEmpty(:final data) => _BacklogFrame(
|
||||||
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
commandState: commandState,
|
||||||
|
onNewTask: _newTaskHandler(context, commandController),
|
||||||
body: _BacklogReadyBody(
|
body: _BacklogReadyBody(
|
||||||
data: data,
|
data: data,
|
||||||
selectedTaskId: controller.selectedTaskId,
|
selectedTaskId: controller.selectedTaskId,
|
||||||
selectedTaskDetail: controller.selectedTaskDetail,
|
selectedTaskDetail: controller.selectedTaskDetail,
|
||||||
isDetailLoading: controller.isSelectedTaskDetailLoading,
|
isDetailLoading: controller.isSelectedTaskDetailLoading,
|
||||||
detailErrorMessage: controller.selectedTaskDetailError,
|
detailErrorMessage: controller.selectedTaskDetailError,
|
||||||
|
detailActionMessage: controller.selectedTaskActionMessage,
|
||||||
onTaskSelected: (taskId) {
|
onTaskSelected: (taskId) {
|
||||||
unawaited(controller.selectTask(taskId));
|
unawaited(controller.selectTask(taskId));
|
||||||
},
|
},
|
||||||
onCloseDrawer: controller.clearSelection,
|
onCloseDrawer: controller.clearSelection,
|
||||||
onAddTask: _handleAddTaskPlaceholder,
|
onAddTask: _addTaskHandler(context, commandController),
|
||||||
onSchedule: _handleDrawerActionPlaceholder,
|
onSchedule: () {
|
||||||
|
final detail = controller.selectedTaskDetail;
|
||||||
|
if (detail == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(
|
||||||
|
_scheduleSelectedBacklogTask(
|
||||||
|
detail: detail,
|
||||||
|
boardController: controller,
|
||||||
|
commandController: commandController,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
onBreakUp: _handleDrawerActionPlaceholder,
|
onBreakUp: _handleDrawerActionPlaceholder,
|
||||||
onPushToSomeday: _handleDrawerActionPlaceholder,
|
onPushToSomeday: _handleDrawerActionPlaceholder,
|
||||||
onRemove: _handleDrawerActionPlaceholder,
|
onRemove: _handleDrawerActionPlaceholder,
|
||||||
|
|
@ -71,18 +100,33 @@ class BacklogBoardScreen extends StatelessWidget {
|
||||||
BacklogBoardReady(:final data) => _BacklogFrame(
|
BacklogBoardReady(:final data) => _BacklogFrame(
|
||||||
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
commandState: commandState,
|
||||||
|
onNewTask: _newTaskHandler(context, commandController),
|
||||||
body: _BacklogReadyBody(
|
body: _BacklogReadyBody(
|
||||||
data: data,
|
data: data,
|
||||||
selectedTaskId: controller.selectedTaskId,
|
selectedTaskId: controller.selectedTaskId,
|
||||||
selectedTaskDetail: controller.selectedTaskDetail,
|
selectedTaskDetail: controller.selectedTaskDetail,
|
||||||
isDetailLoading: controller.isSelectedTaskDetailLoading,
|
isDetailLoading: controller.isSelectedTaskDetailLoading,
|
||||||
detailErrorMessage: controller.selectedTaskDetailError,
|
detailErrorMessage: controller.selectedTaskDetailError,
|
||||||
|
detailActionMessage: controller.selectedTaskActionMessage,
|
||||||
onTaskSelected: (taskId) {
|
onTaskSelected: (taskId) {
|
||||||
unawaited(controller.selectTask(taskId));
|
unawaited(controller.selectTask(taskId));
|
||||||
},
|
},
|
||||||
onCloseDrawer: controller.clearSelection,
|
onCloseDrawer: controller.clearSelection,
|
||||||
onAddTask: _handleAddTaskPlaceholder,
|
onAddTask: _addTaskHandler(context, commandController),
|
||||||
onSchedule: _handleDrawerActionPlaceholder,
|
onSchedule: () {
|
||||||
|
final detail = controller.selectedTaskDetail;
|
||||||
|
if (detail == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(
|
||||||
|
_scheduleSelectedBacklogTask(
|
||||||
|
detail: detail,
|
||||||
|
boardController: controller,
|
||||||
|
commandController: commandController,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
onBreakUp: _handleDrawerActionPlaceholder,
|
onBreakUp: _handleDrawerActionPlaceholder,
|
||||||
onPushToSomeday: _handleDrawerActionPlaceholder,
|
onPushToSomeday: _handleDrawerActionPlaceholder,
|
||||||
onRemove: _handleDrawerActionPlaceholder,
|
onRemove: _handleDrawerActionPlaceholder,
|
||||||
|
|
@ -94,18 +138,187 @@ class BacklogBoardScreen extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles pre-Block-8 add-task button presses without mutating scheduler data.
|
|
||||||
void _handleAddTaskPlaceholder(BacklogBoardBucket bucket) {}
|
|
||||||
|
|
||||||
/// Handles pre-Block-8 drawer action presses without mutating scheduler data.
|
/// Handles pre-Block-8 drawer action presses without mutating scheduler data.
|
||||||
void _handleDrawerActionPlaceholder() {}
|
void _handleDrawerActionPlaceholder() {}
|
||||||
|
|
||||||
|
/// Builds the listenable used to refresh Backlog UI for reads and commands.
|
||||||
|
Listenable _screenAnimation(
|
||||||
|
BacklogBoardController controller,
|
||||||
|
SchedulerCommandController? commandController,
|
||||||
|
) {
|
||||||
|
final commands = commandController;
|
||||||
|
if (commands == null) {
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
return Listenable.merge([controller, commands]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the New Task handler for the current command controller.
|
||||||
|
VoidCallback? _newTaskHandler(
|
||||||
|
BuildContext context,
|
||||||
|
SchedulerCommandController? commandController,
|
||||||
|
) {
|
||||||
|
final commands = commandController;
|
||||||
|
if (commands == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return () {
|
||||||
|
unawaited(_showNewBacklogTaskDialog(context, commands));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the Add Task handler shared by Backlog columns.
|
||||||
|
BacklogBucketAddRequested _addTaskHandler(
|
||||||
|
BuildContext context,
|
||||||
|
SchedulerCommandController? commandController,
|
||||||
|
) {
|
||||||
|
return (bucket) {
|
||||||
|
final commands = commandController;
|
||||||
|
if (commands == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(_showNewBacklogTaskDialog(context, commands));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a minimal title-first Backlog task creation dialog.
|
||||||
|
Future<void> _showNewBacklogTaskDialog(
|
||||||
|
BuildContext context,
|
||||||
|
SchedulerCommandController commandController,
|
||||||
|
) async {
|
||||||
|
final submittedTitle = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return const _NewBacklogTaskDialog();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (submittedTitle != null) {
|
||||||
|
await commandController.quickCaptureToBacklog(submittedTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dialog that collects the title for a new Backlog task.
|
||||||
|
class _NewBacklogTaskDialog extends StatefulWidget {
|
||||||
|
/// Creates a new Backlog task dialog.
|
||||||
|
const _NewBacklogTaskDialog();
|
||||||
|
|
||||||
|
/// 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<_NewBacklogTaskDialog> createState() => _NewBacklogTaskDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for the new Backlog task dialog.
|
||||||
|
class _NewBacklogTaskDialogState extends State<_NewBacklogTaskDialog> {
|
||||||
|
/// Text controller for the required task title.
|
||||||
|
late final TextEditingController _titleController;
|
||||||
|
|
||||||
|
/// Inline validation text for the title field.
|
||||||
|
String? _errorText;
|
||||||
|
|
||||||
|
/// Initializes title input state before the dialog first builds.
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_titleController = TextEditingController();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases the dialog-owned text controller.
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.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('New Backlog task'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 360,
|
||||||
|
child: TextField(
|
||||||
|
key: const ValueKey('backlog-new-task-title-field'),
|
||||||
|
controller: _titleController,
|
||||||
|
autofocus: true,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) {
|
||||||
|
_submit();
|
||||||
|
},
|
||||||
|
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Title',
|
||||||
|
errorText: _errorText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const ValueKey('backlog-new-task-create-button'),
|
||||||
|
onPressed: _submit,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Create'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates and returns the entered title to the dialog caller.
|
||||||
|
void _submit() {
|
||||||
|
final title = _titleController.text.trim();
|
||||||
|
if (title.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_errorText = 'Title is required.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules the selected Backlog [detail] through the application command path.
|
||||||
|
Future<void> _scheduleSelectedBacklogTask({
|
||||||
|
required BacklogTaskDetailReadModel detail,
|
||||||
|
required BacklogBoardController boardController,
|
||||||
|
required SchedulerCommandController? commandController,
|
||||||
|
}) async {
|
||||||
|
final commands = commandController;
|
||||||
|
if (commands == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final durationMinutes = detail.item.durationMinutes;
|
||||||
|
if (durationMinutes == null) {
|
||||||
|
boardController.showSelectedTaskActionMessage(
|
||||||
|
'Add an estimate before scheduling.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await commands.scheduleBacklogItem(
|
||||||
|
taskId: detail.item.taskId,
|
||||||
|
durationMinutes: durationMinutes,
|
||||||
|
expectedUpdatedAt: detail.item.updatedAt,
|
||||||
|
);
|
||||||
|
if (commands.state is SchedulerCommandSuccess) {
|
||||||
|
boardController.clearSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Main Backlog frame containing header, controls, body, and overlay slot.
|
/// Main Backlog frame containing header, controls, body, and overlay slot.
|
||||||
class _BacklogFrame extends StatelessWidget {
|
class _BacklogFrame extends StatelessWidget {
|
||||||
/// Creates the Backlog frame.
|
/// Creates the Backlog frame.
|
||||||
const _BacklogFrame({
|
const _BacklogFrame({
|
||||||
required this.taskCountLabel,
|
required this.taskCountLabel,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
|
required this.commandState,
|
||||||
|
required this.onNewTask,
|
||||||
required this.body,
|
required this.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -115,6 +328,12 @@ class _BacklogFrame extends StatelessWidget {
|
||||||
/// Controller used by search and filter controls.
|
/// Controller used by search and filter controls.
|
||||||
final BacklogBoardController controller;
|
final BacklogBoardController controller;
|
||||||
|
|
||||||
|
/// Current scheduler command state.
|
||||||
|
final SchedulerCommandState commandState;
|
||||||
|
|
||||||
|
/// Callback for New Task, or null when commands are unavailable.
|
||||||
|
final VoidCallback? onNewTask;
|
||||||
|
|
||||||
/// Body rendered below the header controls.
|
/// Body rendered below the header controls.
|
||||||
final Widget body;
|
final Widget body;
|
||||||
|
|
||||||
|
|
@ -131,7 +350,14 @@ class _BacklogFrame extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
_BacklogHeader(taskCountLabel: taskCountLabel),
|
_BacklogHeader(taskCountLabel: taskCountLabel),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
BacklogBoardTopControls(controller: controller),
|
BacklogBoardTopControls(
|
||||||
|
controller: controller,
|
||||||
|
onNewTask: onNewTask,
|
||||||
|
),
|
||||||
|
if (commandState is! SchedulerCommandIdle) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_BacklogCommandStatus(state: commandState),
|
||||||
|
],
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Expanded(child: body),
|
Expanded(child: body),
|
||||||
],
|
],
|
||||||
|
|
@ -143,6 +369,74 @@ class _BacklogFrame extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compact command state banner for Backlog actions.
|
||||||
|
class _BacklogCommandStatus extends StatelessWidget {
|
||||||
|
/// Creates a Backlog command status banner.
|
||||||
|
const _BacklogCommandStatus({required this.state});
|
||||||
|
|
||||||
|
/// Current scheduler command state.
|
||||||
|
final SchedulerCommandState state;
|
||||||
|
|
||||||
|
/// 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 (icon, label, color) = switch (state) {
|
||||||
|
SchedulerCommandRunning(:final label) => (
|
||||||
|
Icons.hourglass_top,
|
||||||
|
label,
|
||||||
|
FocusFlowTokens.warningYellow,
|
||||||
|
),
|
||||||
|
SchedulerCommandSuccess(:final message) => (
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
message,
|
||||||
|
FocusFlowTokens.flexibleGreen,
|
||||||
|
),
|
||||||
|
SchedulerCommandFailure(:final codeLabel) => (
|
||||||
|
Icons.info_outline,
|
||||||
|
'Could not complete action: $codeLabel',
|
||||||
|
FocusFlowTokens.warningYellow,
|
||||||
|
),
|
||||||
|
SchedulerCommandIdle() => (
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
'',
|
||||||
|
FocusFlowTokens.textMuted,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (state is SchedulerCommandIdle) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.34)),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 17),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Header row for the Backlog board shell.
|
/// Header row for the Backlog board shell.
|
||||||
class _BacklogHeader extends StatelessWidget {
|
class _BacklogHeader extends StatelessWidget {
|
||||||
/// Creates the Backlog header.
|
/// Creates the Backlog header.
|
||||||
|
|
@ -251,6 +545,7 @@ class _BacklogReadyBody extends StatelessWidget {
|
||||||
required this.selectedTaskDetail,
|
required this.selectedTaskDetail,
|
||||||
required this.isDetailLoading,
|
required this.isDetailLoading,
|
||||||
required this.detailErrorMessage,
|
required this.detailErrorMessage,
|
||||||
|
required this.detailActionMessage,
|
||||||
required this.onTaskSelected,
|
required this.onTaskSelected,
|
||||||
required this.onCloseDrawer,
|
required this.onCloseDrawer,
|
||||||
required this.onAddTask,
|
required this.onAddTask,
|
||||||
|
|
@ -276,6 +571,9 @@ class _BacklogReadyBody extends StatelessWidget {
|
||||||
/// Drawer-local detail error message.
|
/// Drawer-local detail error message.
|
||||||
final String? detailErrorMessage;
|
final String? detailErrorMessage;
|
||||||
|
|
||||||
|
/// Drawer-local selected-task action message.
|
||||||
|
final String? detailActionMessage;
|
||||||
|
|
||||||
/// Callback invoked when a task card is selected.
|
/// Callback invoked when a task card is selected.
|
||||||
final BacklogTaskSelected onTaskSelected;
|
final BacklogTaskSelected onTaskSelected;
|
||||||
|
|
||||||
|
|
@ -345,6 +643,7 @@ class _BacklogReadyBody extends StatelessWidget {
|
||||||
detail: selectedTaskDetail,
|
detail: selectedTaskDetail,
|
||||||
loading: isDetailLoading,
|
loading: isDetailLoading,
|
||||||
errorMessage: detailErrorMessage,
|
errorMessage: detailErrorMessage,
|
||||||
|
actionMessage: detailActionMessage,
|
||||||
onClose: onCloseDrawer,
|
onClose: onCloseDrawer,
|
||||||
onSchedule: onSchedule,
|
onSchedule: onSchedule,
|
||||||
onBreakUp: onBreakUp,
|
onBreakUp: onBreakUp,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class BacklogTaskDrawer extends StatelessWidget {
|
||||||
required this.detail,
|
required this.detail,
|
||||||
required this.loading,
|
required this.loading,
|
||||||
required this.errorMessage,
|
required this.errorMessage,
|
||||||
|
required this.actionMessage,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
required this.onSchedule,
|
required this.onSchedule,
|
||||||
required this.onBreakUp,
|
required this.onBreakUp,
|
||||||
|
|
@ -43,6 +44,9 @@ class BacklogTaskDrawer extends StatelessWidget {
|
||||||
/// Drawer-local error message for a failed detail read.
|
/// Drawer-local error message for a failed detail read.
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// Drawer-local action message, such as a missing-duration prompt.
|
||||||
|
final String? actionMessage;
|
||||||
|
|
||||||
/// Callback that closes the drawer and clears selection.
|
/// Callback that closes the drawer and clears selection.
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
|
@ -104,6 +108,7 @@ class BacklogTaskDrawer extends StatelessWidget {
|
||||||
onProjectPressed: onProjectPressed,
|
onProjectPressed: onProjectPressed,
|
||||||
onAddTagPressed: onAddTagPressed,
|
onAddTagPressed: onAddTagPressed,
|
||||||
onViewDayPressed: onViewDayPressed,
|
onViewDayPressed: onViewDayPressed,
|
||||||
|
actionMessage: actionMessage,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -192,6 +197,7 @@ class _DrawerDetailContent extends StatelessWidget {
|
||||||
required this.onProjectPressed,
|
required this.onProjectPressed,
|
||||||
required this.onAddTagPressed,
|
required this.onAddTagPressed,
|
||||||
required this.onViewDayPressed,
|
required this.onViewDayPressed,
|
||||||
|
required this.actionMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Detail read model supplied by the scheduler application layer.
|
/// Detail read model supplied by the scheduler application layer.
|
||||||
|
|
@ -221,6 +227,9 @@ class _DrawerDetailContent extends StatelessWidget {
|
||||||
/// Callback for View day.
|
/// Callback for View day.
|
||||||
final VoidCallback onViewDayPressed;
|
final VoidCallback onViewDayPressed;
|
||||||
|
|
||||||
|
/// Drawer-local action message.
|
||||||
|
final String? actionMessage;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
@ -256,6 +265,10 @@ class _DrawerDetailContent extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
|
if (actionMessage != null) ...[
|
||||||
|
_DrawerActionMessage(message: actionMessage!),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
_DrawerActions(
|
_DrawerActions(
|
||||||
onSchedule: onSchedule,
|
onSchedule: onSchedule,
|
||||||
onBreakUp: onBreakUp,
|
onBreakUp: onBreakUp,
|
||||||
|
|
@ -267,6 +280,53 @@ class _DrawerDetailContent extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drawer-local action message panel.
|
||||||
|
class _DrawerActionMessage extends StatelessWidget {
|
||||||
|
/// Creates an action message panel.
|
||||||
|
const _DrawerActionMessage({required this.message});
|
||||||
|
|
||||||
|
/// Message body.
|
||||||
|
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: 10),
|
||||||
|
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,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared title row with a close button.
|
/// Shared title row with a close button.
|
||||||
class _DrawerTopRow extends StatelessWidget {
|
class _DrawerTopRow extends StatelessWidget {
|
||||||
/// Creates a drawer top row.
|
/// Creates a drawer top row.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
import 'package:focus_flow_flutter/controllers/backlog_board_controller.dart';
|
import 'package:focus_flow_flutter/controllers/backlog_board_controller.dart';
|
||||||
|
import 'package:focus_flow_flutter/controllers/scheduler_command_controller.dart';
|
||||||
import 'package:focus_flow_flutter/controllers/selected_date_controller.dart';
|
import 'package:focus_flow_flutter/controllers/selected_date_controller.dart';
|
||||||
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
|
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
|
||||||
import 'package:focus_flow_flutter/theme/focus_flow_tokens.dart';
|
import 'package:focus_flow_flutter/theme/focus_flow_tokens.dart';
|
||||||
|
|
@ -177,6 +178,101 @@ void main() {
|
||||||
expect(controller.selectedTaskId, 'plan-week');
|
expect(controller.selectedTaskId, 'plan-week');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('new task creates backlog item through command controller', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final harness = _BacklogCommandHarness.empty();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
|
||||||
|
await harness.boardController.load();
|
||||||
|
await _pumpBacklogScreen(
|
||||||
|
tester,
|
||||||
|
harness.boardController,
|
||||||
|
commandController: harness.commandController,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('backlog-new-task-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('backlog-new-task-title-field')),
|
||||||
|
'Draft outline',
|
||||||
|
);
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('backlog-new-task-create-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(harness.commandController.state, isA<SchedulerCommandSuccess>());
|
||||||
|
expect(find.text('Task captured'), findsOneWidget);
|
||||||
|
expect(find.text('Draft outline'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('schedule action uses command controller and refreshes board', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final harness = _BacklogCommandHarness.withTasks([
|
||||||
|
_domainBacklogTask(
|
||||||
|
id: 'plan-week',
|
||||||
|
title: 'Plan next week',
|
||||||
|
durationMinutes: 30,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
|
||||||
|
await harness.boardController.load();
|
||||||
|
await _pumpBacklogScreen(
|
||||||
|
tester,
|
||||||
|
harness.boardController,
|
||||||
|
commandController: harness.commandController,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Plan next week'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('backlog-drawer-schedule-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(harness.commandController.state, isA<SchedulerCommandSuccess>());
|
||||||
|
expect(find.text('Task scheduled'), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('backlog-task-drawer')), findsNothing);
|
||||||
|
expect(find.byKey(const ValueKey('backlog-card-plan-week')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'schedule action explains missing duration without command write',
|
||||||
|
(tester) async {
|
||||||
|
final harness = _BacklogCommandHarness.withTasks([
|
||||||
|
_domainBacklogTask(
|
||||||
|
id: 'missing-duration',
|
||||||
|
title: 'Estimate me',
|
||||||
|
durationMinutes: null,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
|
||||||
|
await harness.boardController.load();
|
||||||
|
await _pumpBacklogScreen(
|
||||||
|
tester,
|
||||||
|
harness.boardController,
|
||||||
|
commandController: harness.commandController,
|
||||||
|
);
|
||||||
|
|
||||||
|
await harness.boardController.selectTask('missing-duration');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const ValueKey('backlog-drawer-schedule-button')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Add an estimate before scheduling.'), findsOneWidget);
|
||||||
|
expect(harness.commandController.state, isA<SchedulerCommandIdle>());
|
||||||
|
expect(find.byKey(const ValueKey('backlog-task-drawer')), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
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();
|
||||||
|
|
@ -289,8 +385,9 @@ void main() {
|
||||||
/// Pumps [controller] inside the dark FocusFlow app shell test harness.
|
/// Pumps [controller] inside the dark FocusFlow app shell test harness.
|
||||||
Future<void> _pumpBacklogScreen(
|
Future<void> _pumpBacklogScreen(
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
BacklogBoardController controller,
|
BacklogBoardController controller, {
|
||||||
) async {
|
SchedulerCommandController? commandController,
|
||||||
|
}) async {
|
||||||
await tester.binding.setSurfaceSize(const Size(1320, 820));
|
await tester.binding.setSurfaceSize(const Size(1320, 820));
|
||||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|
@ -300,7 +397,10 @@ Future<void> _pumpBacklogScreen(
|
||||||
body: SizedBox(
|
body: SizedBox(
|
||||||
width: 1300,
|
width: 1300,
|
||||||
height: 780,
|
height: 780,
|
||||||
child: BacklogBoardScreen(controller: controller),
|
child: BacklogBoardScreen(
|
||||||
|
controller: controller,
|
||||||
|
commandController: commandController,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -308,6 +408,140 @@ Future<void> _pumpBacklogScreen(
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test harness that wires Backlog UI controllers to in-memory scheduler use cases.
|
||||||
|
class _BacklogCommandHarness {
|
||||||
|
/// Creates a harness from initial [tasks].
|
||||||
|
_BacklogCommandHarness._({required List<Task> tasks})
|
||||||
|
: selectedDates = SelectedDateController(initialDate: _testDate),
|
||||||
|
store = InMemoryApplicationUnitOfWork(
|
||||||
|
initialProjects: [
|
||||||
|
ProjectProfile(id: 'home', name: 'Home', colorKey: 'home'),
|
||||||
|
],
|
||||||
|
initialOwnerSettings: [
|
||||||
|
OwnerSettings(ownerId: _ownerId, timeZoneId: 'UTC'),
|
||||||
|
],
|
||||||
|
initialTasks: tasks,
|
||||||
|
) {
|
||||||
|
managementUseCases = V1ApplicationManagementUseCases(
|
||||||
|
applicationStore: store,
|
||||||
|
);
|
||||||
|
commandUseCases = V1ApplicationCommandUseCases(
|
||||||
|
applicationStore: store,
|
||||||
|
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||||
|
);
|
||||||
|
boardController = BacklogBoardController(
|
||||||
|
selectedDates: selectedDates,
|
||||||
|
dateLabelFor: (date) => date.toIsoString(),
|
||||||
|
readBoard: (request) {
|
||||||
|
return managementUseCases.getBacklogBoard(
|
||||||
|
GetBacklogBoardRequest(
|
||||||
|
context: _context('read-board'),
|
||||||
|
localDate: request.localDate,
|
||||||
|
searchText: request.searchText,
|
||||||
|
filters: request.filters,
|
||||||
|
boardFilters: request.boardFilters,
|
||||||
|
sortKey: request.sortKey,
|
||||||
|
groupMode: request.groupMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
readTaskDetail: (taskId, localDate) {
|
||||||
|
return managementUseCases.getBacklogTaskDetail(
|
||||||
|
GetBacklogTaskDetailRequest(
|
||||||
|
context: _context('read-detail-$taskId'),
|
||||||
|
localDate: localDate,
|
||||||
|
taskId: taskId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
commandController = SchedulerCommandController(
|
||||||
|
commands: commandUseCases,
|
||||||
|
contextFor: _context,
|
||||||
|
selectedDate: () => selectedDates.date,
|
||||||
|
refreshReads: boardController.load,
|
||||||
|
quickCaptureProjectId: 'home',
|
||||||
|
operationIdPrefix: 'backlog-widget-command',
|
||||||
|
now: () => _now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an empty harness.
|
||||||
|
factory _BacklogCommandHarness.empty() {
|
||||||
|
return _BacklogCommandHarness._(tasks: const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a harness seeded with [tasks].
|
||||||
|
factory _BacklogCommandHarness.withTasks(List<Task> tasks) {
|
||||||
|
return _BacklogCommandHarness._(tasks: tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selected date controller.
|
||||||
|
final SelectedDateController selectedDates;
|
||||||
|
|
||||||
|
/// In-memory scheduler application store.
|
||||||
|
final InMemoryApplicationUnitOfWork store;
|
||||||
|
|
||||||
|
/// Management use cases.
|
||||||
|
late final V1ApplicationManagementUseCases managementUseCases;
|
||||||
|
|
||||||
|
/// Command use cases.
|
||||||
|
late final V1ApplicationCommandUseCases commandUseCases;
|
||||||
|
|
||||||
|
/// Backlog board controller.
|
||||||
|
late final BacklogBoardController boardController;
|
||||||
|
|
||||||
|
/// Scheduler command controller.
|
||||||
|
late final SchedulerCommandController commandController;
|
||||||
|
|
||||||
|
/// Releases harness-owned controllers.
|
||||||
|
void dispose() {
|
||||||
|
commandController.dispose();
|
||||||
|
boardController.dispose();
|
||||||
|
selectedDates.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner id used by command integration widget tests.
|
||||||
|
const _ownerId = 'owner-1';
|
||||||
|
|
||||||
|
/// Selected date used by command integration widget tests.
|
||||||
|
final _testDate = CivilDate(2026, 7, 7);
|
||||||
|
|
||||||
|
/// Stable command clock used by command integration widget tests.
|
||||||
|
final _now = DateTime.utc(2026, 7, 7, 15);
|
||||||
|
|
||||||
|
/// Creates an operation context for [operationId].
|
||||||
|
ApplicationOperationContext _context(String operationId, {DateTime? now}) {
|
||||||
|
return ApplicationOperationContext.start(
|
||||||
|
operationId: operationId,
|
||||||
|
ownerTimeZone: OwnerTimeZoneContext(ownerId: _ownerId, timeZoneId: 'UTC'),
|
||||||
|
clock: FixedClock(now ?? _now),
|
||||||
|
idGenerator: SequentialIdGenerator(prefix: operationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a domain Backlog task for command integration widget tests.
|
||||||
|
Task _domainBacklogTask({
|
||||||
|
required String id,
|
||||||
|
required String title,
|
||||||
|
required int? durationMinutes,
|
||||||
|
}) {
|
||||||
|
return Task(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
projectId: 'home',
|
||||||
|
type: TaskType.flexible,
|
||||||
|
status: TaskStatus.backlog,
|
||||||
|
priority: PriorityLevel.medium,
|
||||||
|
reward: RewardLevel.medium,
|
||||||
|
difficulty: DifficultyLevel.easy,
|
||||||
|
durationMinutes: durationMinutes,
|
||||||
|
createdAt: _now,
|
||||||
|
updatedAt: _now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 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(
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ void main() {
|
||||||
detail: _detail(),
|
detail: _detail(),
|
||||||
loading: false,
|
loading: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
|
actionMessage: null,
|
||||||
onClose: () {
|
onClose: () {
|
||||||
closeCount += 1;
|
closeCount += 1;
|
||||||
},
|
},
|
||||||
|
|
@ -120,6 +121,7 @@ void main() {
|
||||||
detail: null,
|
detail: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
|
actionMessage: null,
|
||||||
onClose: () {},
|
onClose: () {},
|
||||||
onSchedule: () {},
|
onSchedule: () {},
|
||||||
onBreakUp: () {},
|
onBreakUp: () {},
|
||||||
|
|
@ -141,6 +143,7 @@ void main() {
|
||||||
detail: null,
|
detail: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
errorMessage: 'taskNotFound',
|
errorMessage: 'taskNotFound',
|
||||||
|
actionMessage: null,
|
||||||
onClose: () {},
|
onClose: () {},
|
||||||
onSchedule: () {},
|
onSchedule: () {},
|
||||||
onBreakUp: () {},
|
onBreakUp: () {},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue