feat(backlog): wire create and schedule actions
This commit is contained in:
parent
8a1d2bfc9d
commit
1c945fad3f
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
|
||||
decision. The drawer shows calm empty states when the read model has no
|
||||
notes or tags instead of inventing Flutter-only persisted metadata.
|
||||
|
||||
## Block 08 Commands Through Schedule
|
||||
|
||||
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.backlog => BacklogBoardScreen(
|
||||
controller: backlogController,
|
||||
commandController: commandController,
|
||||
),
|
||||
FocusFlowSection.projects ||
|
||||
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.
|
||||
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.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
String? _searchText;
|
||||
|
|
@ -189,6 +193,9 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
/// Drawer-local error message for the selected task detail read.
|
||||
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.
|
||||
bool get isSelectedTaskDetailLoading =>
|
||||
_selectedTaskId != null &&
|
||||
|
|
@ -301,6 +308,7 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
_selectedTaskId = taskId;
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = null;
|
||||
_selectedTaskActionMessage = null;
|
||||
notifyListeners();
|
||||
final sequence = _nextDetailSequence();
|
||||
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.
|
||||
void clearSelection() {
|
||||
if (_selectedTaskId == null && _selectedTaskDetail == null) {
|
||||
|
|
@ -365,6 +382,7 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
_selectedTaskId = null;
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = null;
|
||||
_selectedTaskActionMessage = null;
|
||||
_detailSequence += 1;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
|
|
@ -459,6 +477,7 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
_selectedTaskId = null;
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = null;
|
||||
_selectedTaskActionMessage = null;
|
||||
}
|
||||
|
||||
/// Runs the `_refreshDataState` helper used inside this library.
|
||||
|
|
|
|||
|
|
@ -60,11 +60,18 @@ class BacklogModeAndSettingsControls extends StatelessWidget {
|
|||
/// Search, filter, sort, group, and new-task controls.
|
||||
class BacklogBoardTopControls extends StatefulWidget {
|
||||
/// 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.
|
||||
final BacklogBoardController controller;
|
||||
|
||||
/// Callback invoked by the New Task button.
|
||||
final VoidCallback? onNewTask;
|
||||
|
||||
/// 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
|
||||
|
|
@ -194,7 +201,7 @@ class _BacklogBoardTopControlsState extends State<BacklogBoardTopControls> {
|
|||
const SizedBox(width: 18),
|
||||
FilledButton(
|
||||
key: const ValueKey('backlog-new-task-button'),
|
||||
onPressed: () {},
|
||||
onPressed: widget.onNewTask,
|
||||
style: FilledButton.styleFrom(
|
||||
fixedSize: const Size(146, 44),
|
||||
padding: EdgeInsets.zero,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../../controllers/backlog_board_controller.dart';
|
||||
import '../../controllers/scheduler_command_controller.dart';
|
||||
import '../../models/backlog/backlog_board_screen_data.dart';
|
||||
import '../../theme/focus_flow_tokens.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.
|
||||
class BacklogBoardScreen extends StatelessWidget {
|
||||
/// 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.
|
||||
final BacklogBoardController controller;
|
||||
|
||||
/// Optional command controller used by Backlog actions.
|
||||
final SchedulerCommandController? commandController;
|
||||
|
||||
/// 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 AnimatedBuilder(
|
||||
animation: controller,
|
||||
animation: _screenAnimation(controller, commandController),
|
||||
builder: (context, _) {
|
||||
final commandState =
|
||||
commandController?.state ?? const SchedulerCommandIdle();
|
||||
return switch (controller.state) {
|
||||
BacklogBoardLoading() => _BacklogFrame(
|
||||
taskCountLabel: '...',
|
||||
controller: controller,
|
||||
commandState: commandState,
|
||||
onNewTask: _newTaskHandler(context, commandController),
|
||||
body: const _BacklogLoadingBody(),
|
||||
),
|
||||
BacklogBoardFailure(:final message) => _BacklogFrame(
|
||||
taskCountLabel: '...',
|
||||
controller: controller,
|
||||
commandState: commandState,
|
||||
onNewTask: _newTaskHandler(context, commandController),
|
||||
body: _BacklogFailureBody(
|
||||
message: message,
|
||||
onRetry: controller.retry,
|
||||
|
|
@ -50,18 +64,33 @@ class BacklogBoardScreen extends StatelessWidget {
|
|||
BacklogBoardEmpty(:final data) => _BacklogFrame(
|
||||
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
||||
controller: controller,
|
||||
commandState: commandState,
|
||||
onNewTask: _newTaskHandler(context, commandController),
|
||||
body: _BacklogReadyBody(
|
||||
data: data,
|
||||
selectedTaskId: controller.selectedTaskId,
|
||||
selectedTaskDetail: controller.selectedTaskDetail,
|
||||
isDetailLoading: controller.isSelectedTaskDetailLoading,
|
||||
detailErrorMessage: controller.selectedTaskDetailError,
|
||||
detailActionMessage: controller.selectedTaskActionMessage,
|
||||
onTaskSelected: (taskId) {
|
||||
unawaited(controller.selectTask(taskId));
|
||||
},
|
||||
onCloseDrawer: controller.clearSelection,
|
||||
onAddTask: _handleAddTaskPlaceholder,
|
||||
onSchedule: _handleDrawerActionPlaceholder,
|
||||
onAddTask: _addTaskHandler(context, commandController),
|
||||
onSchedule: () {
|
||||
final detail = controller.selectedTaskDetail;
|
||||
if (detail == null) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
_scheduleSelectedBacklogTask(
|
||||
detail: detail,
|
||||
boardController: controller,
|
||||
commandController: commandController,
|
||||
),
|
||||
);
|
||||
},
|
||||
onBreakUp: _handleDrawerActionPlaceholder,
|
||||
onPushToSomeday: _handleDrawerActionPlaceholder,
|
||||
onRemove: _handleDrawerActionPlaceholder,
|
||||
|
|
@ -71,18 +100,33 @@ class BacklogBoardScreen extends StatelessWidget {
|
|||
BacklogBoardReady(:final data) => _BacklogFrame(
|
||||
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
||||
controller: controller,
|
||||
commandState: commandState,
|
||||
onNewTask: _newTaskHandler(context, commandController),
|
||||
body: _BacklogReadyBody(
|
||||
data: data,
|
||||
selectedTaskId: controller.selectedTaskId,
|
||||
selectedTaskDetail: controller.selectedTaskDetail,
|
||||
isDetailLoading: controller.isSelectedTaskDetailLoading,
|
||||
detailErrorMessage: controller.selectedTaskDetailError,
|
||||
detailActionMessage: controller.selectedTaskActionMessage,
|
||||
onTaskSelected: (taskId) {
|
||||
unawaited(controller.selectTask(taskId));
|
||||
},
|
||||
onCloseDrawer: controller.clearSelection,
|
||||
onAddTask: _handleAddTaskPlaceholder,
|
||||
onSchedule: _handleDrawerActionPlaceholder,
|
||||
onAddTask: _addTaskHandler(context, commandController),
|
||||
onSchedule: () {
|
||||
final detail = controller.selectedTaskDetail;
|
||||
if (detail == null) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
_scheduleSelectedBacklogTask(
|
||||
detail: detail,
|
||||
boardController: controller,
|
||||
commandController: commandController,
|
||||
),
|
||||
);
|
||||
},
|
||||
onBreakUp: _handleDrawerActionPlaceholder,
|
||||
onPushToSomeday: _handleDrawerActionPlaceholder,
|
||||
onRemove: _handleDrawerActionPlaceholder,
|
||||
|
|
@ -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.
|
||||
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.
|
||||
class _BacklogFrame extends StatelessWidget {
|
||||
/// Creates the Backlog frame.
|
||||
const _BacklogFrame({
|
||||
required this.taskCountLabel,
|
||||
required this.controller,
|
||||
required this.commandState,
|
||||
required this.onNewTask,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
|
|
@ -115,6 +328,12 @@ class _BacklogFrame extends StatelessWidget {
|
|||
/// Controller used by search and filter controls.
|
||||
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.
|
||||
final Widget body;
|
||||
|
||||
|
|
@ -131,7 +350,14 @@ class _BacklogFrame extends StatelessWidget {
|
|||
children: [
|
||||
_BacklogHeader(taskCountLabel: taskCountLabel),
|
||||
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),
|
||||
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.
|
||||
class _BacklogHeader extends StatelessWidget {
|
||||
/// Creates the Backlog header.
|
||||
|
|
@ -251,6 +545,7 @@ class _BacklogReadyBody extends StatelessWidget {
|
|||
required this.selectedTaskDetail,
|
||||
required this.isDetailLoading,
|
||||
required this.detailErrorMessage,
|
||||
required this.detailActionMessage,
|
||||
required this.onTaskSelected,
|
||||
required this.onCloseDrawer,
|
||||
required this.onAddTask,
|
||||
|
|
@ -276,6 +571,9 @@ class _BacklogReadyBody extends StatelessWidget {
|
|||
/// Drawer-local detail error message.
|
||||
final String? detailErrorMessage;
|
||||
|
||||
/// Drawer-local selected-task action message.
|
||||
final String? detailActionMessage;
|
||||
|
||||
/// Callback invoked when a task card is selected.
|
||||
final BacklogTaskSelected onTaskSelected;
|
||||
|
||||
|
|
@ -345,6 +643,7 @@ class _BacklogReadyBody extends StatelessWidget {
|
|||
detail: selectedTaskDetail,
|
||||
loading: isDetailLoading,
|
||||
errorMessage: detailErrorMessage,
|
||||
actionMessage: detailActionMessage,
|
||||
onClose: onCloseDrawer,
|
||||
onSchedule: onSchedule,
|
||||
onBreakUp: onBreakUp,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class BacklogTaskDrawer extends StatelessWidget {
|
|||
required this.detail,
|
||||
required this.loading,
|
||||
required this.errorMessage,
|
||||
required this.actionMessage,
|
||||
required this.onClose,
|
||||
required this.onSchedule,
|
||||
required this.onBreakUp,
|
||||
|
|
@ -43,6 +44,9 @@ class BacklogTaskDrawer extends StatelessWidget {
|
|||
/// Drawer-local error message for a failed detail read.
|
||||
final String? errorMessage;
|
||||
|
||||
/// Drawer-local action message, such as a missing-duration prompt.
|
||||
final String? actionMessage;
|
||||
|
||||
/// Callback that closes the drawer and clears selection.
|
||||
final VoidCallback onClose;
|
||||
|
||||
|
|
@ -104,6 +108,7 @@ class BacklogTaskDrawer extends StatelessWidget {
|
|||
onProjectPressed: onProjectPressed,
|
||||
onAddTagPressed: onAddTagPressed,
|
||||
onViewDayPressed: onViewDayPressed,
|
||||
actionMessage: actionMessage,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -192,6 +197,7 @@ class _DrawerDetailContent extends StatelessWidget {
|
|||
required this.onProjectPressed,
|
||||
required this.onAddTagPressed,
|
||||
required this.onViewDayPressed,
|
||||
required this.actionMessage,
|
||||
});
|
||||
|
||||
/// Detail read model supplied by the scheduler application layer.
|
||||
|
|
@ -221,6 +227,9 @@ class _DrawerDetailContent extends StatelessWidget {
|
|||
/// Callback for View day.
|
||||
final VoidCallback onViewDayPressed;
|
||||
|
||||
/// Drawer-local action message.
|
||||
final String? actionMessage;
|
||||
|
||||
/// 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
|
||||
|
|
@ -256,6 +265,10 @@ class _DrawerDetailContent extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (actionMessage != null) ...[
|
||||
_DrawerActionMessage(message: actionMessage!),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
_DrawerActions(
|
||||
onSchedule: onSchedule,
|
||||
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.
|
||||
class _DrawerTopRow extends StatelessWidget {
|
||||
/// 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: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/theme/focus_flow_theme.dart';
|
||||
import 'package:focus_flow_flutter/theme/focus_flow_tokens.dart';
|
||||
|
|
@ -177,6 +178,101 @@ void main() {
|
|||
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 {
|
||||
final requests = <BacklogBoardReadRequest>[];
|
||||
final item = _boardItemWithChildren();
|
||||
|
|
@ -289,8 +385,9 @@ void main() {
|
|||
/// Pumps [controller] inside the dark FocusFlow app shell test harness.
|
||||
Future<void> _pumpBacklogScreen(
|
||||
WidgetTester tester,
|
||||
BacklogBoardController controller,
|
||||
) async {
|
||||
BacklogBoardController controller, {
|
||||
SchedulerCommandController? commandController,
|
||||
}) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1320, 820));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
|
|
@ -300,7 +397,10 @@ Future<void> _pumpBacklogScreen(
|
|||
body: SizedBox(
|
||||
width: 1300,
|
||||
height: 780,
|
||||
child: BacklogBoardScreen(controller: controller),
|
||||
child: BacklogBoardScreen(
|
||||
controller: controller,
|
||||
commandController: commandController,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -308,6 +408,140 @@ Future<void> _pumpBacklogScreen(
|
|||
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.
|
||||
BacklogBoardItemReadModel _boardItemWithChildren() {
|
||||
return BacklogBoardItemReadModel(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ void main() {
|
|||
detail: _detail(),
|
||||
loading: false,
|
||||
errorMessage: null,
|
||||
actionMessage: null,
|
||||
onClose: () {
|
||||
closeCount += 1;
|
||||
},
|
||||
|
|
@ -120,6 +121,7 @@ void main() {
|
|||
detail: null,
|
||||
loading: true,
|
||||
errorMessage: null,
|
||||
actionMessage: null,
|
||||
onClose: () {},
|
||||
onSchedule: () {},
|
||||
onBreakUp: () {},
|
||||
|
|
@ -141,6 +143,7 @@ void main() {
|
|||
detail: null,
|
||||
loading: false,
|
||||
errorMessage: 'taskNotFound',
|
||||
actionMessage: null,
|
||||
onClose: () {},
|
||||
onSchedule: () {},
|
||||
onBreakUp: () {},
|
||||
|
|
|
|||
Loading…
Reference in a new issue