Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20

Open
pyr0ball wants to merge 26 commits from Circuit-Forge/focus-flow:feat/9-12-13-wire-backlog-nav-and-modal-actions into main
8 changed files with 658 additions and 15 deletions
Showing only changes of commit 1c945fad3f - Show all commits

View file

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

View file

@ -195,6 +195,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
FocusFlowSection.today => _buildTodayScreen(),
FocusFlowSection.backlog => BacklogBoardScreen(
controller: backlogController,
commandController: commandController,
),
FocusFlowSection.projects ||
FocusFlowSection.reports ||

View file

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

View file

@ -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,

View file

@ -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,

View file

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

View file

@ -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(

View file

@ -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: () {},