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
7 changed files with 1790 additions and 45 deletions
Showing only changes of commit 8a1d2bfc9d - Show all commits

View file

@ -170,3 +170,19 @@ Freeform Tags Metadata.md` for a later migration-backed feature.
4. Collapse and expand state is local to the Flutter widget. It hides body 4. Collapse and expand state is local to the Flutter widget. It hides body
sections while leaving the summary header visible and does not write any sections while leaving the summary header visible and does not write any
scheduler state. scheduler state.
## Block 07 Task Detail Drawer
1. `BacklogBoardController` now keeps selected-task id, detail loading state,
and detail-read failures as UI-local state. Detail failures render in the
drawer and no longer replace the whole Backlog screen.
2. `BacklogBoardScreen` renders `BacklogTaskDrawer` in a right-anchored
`Stack` overlay. The board and summary row stay in their original layout, so
opening and closing the drawer does not shift cards.
3. `BacklogTaskDrawer` renders header metadata, empty or read-only notes,
project display, read-only tags, duration/reward/difficulty metrics,
suggested slot previews, and Block-8-ready action buttons from public
`BacklogTaskDetailReadModel` data.
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.

View file

@ -140,6 +140,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.
BacklogTaskDetailReadModel? _selectedTaskDetail; BacklogTaskDetailReadModel? _selectedTaskDetail;
/// Private state stored as `_selectedTaskDetailError` 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? _selectedTaskDetailError;
/// 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;
@ -179,6 +183,18 @@ class BacklogBoardController extends ChangeNotifier {
/// Currently selected Backlog task id, if any. /// Currently selected Backlog task id, if any.
String? get selectedTaskId => _selectedTaskId; String? get selectedTaskId => _selectedTaskId;
/// Detail model for the currently selected Backlog task, if it has loaded.
BacklogTaskDetailReadModel? get selectedTaskDetail => _selectedTaskDetail;
/// Drawer-local error message for the selected task detail read.
String? get selectedTaskDetailError => _selectedTaskDetailError;
/// Whether a selected task detail read is currently unresolved.
bool get isSelectedTaskDetailLoading =>
_selectedTaskId != null &&
_selectedTaskDetail == null &&
_selectedTaskDetailError == null;
/// Current search text applied to board reads. /// Current search text applied to board reads.
String? get searchText => _searchText; String? get searchText => _searchText;
@ -284,6 +300,7 @@ class BacklogBoardController extends ChangeNotifier {
} }
_selectedTaskId = taskId; _selectedTaskId = taskId;
_selectedTaskDetail = null; _selectedTaskDetail = null;
_selectedTaskDetailError = null;
notifyListeners(); notifyListeners();
final sequence = _nextDetailSequence(); final sequence = _nextDetailSequence();
final date = selectedDates.date; final date = selectedDates.date;
@ -291,6 +308,7 @@ class BacklogBoardController extends ChangeNotifier {
() => () =>
'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence', 'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence',
); );
try {
final result = await readTaskDetail(taskId, date); final result = await readTaskDetail(taskId, date);
if (_isDetailStale(sequence, taskId)) { if (_isDetailStale(sequence, taskId)) {
logger.finer( logger.finer(
@ -307,14 +325,36 @@ class BacklogBoardController extends ChangeNotifier {
'Backlog detail load returned failure. taskId=$taskId ' 'Backlog detail load returned failure. taskId=$taskId '
'code=${failure.code.name} detailCode=${failure.detailCode}', 'code=${failure.code.name} detailCode=${failure.detailCode}',
); );
_selectedTaskId = null;
_selectedTaskDetail = null; _selectedTaskDetail = null;
_selectedTaskDetailError = failure.detailCode ?? failure.code.name;
_refreshDataState();
notifyListeners(); notifyListeners();
return; return;
} }
_selectedTaskDetail = result.requireValue; _selectedTaskDetail = result.requireValue;
_selectedTaskDetailError = null;
_refreshDataState(); _refreshDataState();
notifyListeners(); notifyListeners();
} on Object catch (error, stackTrace) {
if (_isDetailStale(sequence, taskId)) {
logger.finer(
() =>
'Ignoring stale Backlog detail load exception. '
'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence',
);
return;
}
logger.error(
() =>
'Backlog detail load threw unexpectedly. taskId=$taskId sequence=$sequence',
error: error,
stackTrace: stackTrace,
);
_selectedTaskDetail = null;
_selectedTaskDetailError = error.toString();
_refreshDataState();
notifyListeners();
}
} }
/// Clears UI-local task selection. /// Clears UI-local task selection.
@ -324,6 +364,7 @@ class BacklogBoardController extends ChangeNotifier {
} }
_selectedTaskId = null; _selectedTaskId = null;
_selectedTaskDetail = null; _selectedTaskDetail = null;
_selectedTaskDetailError = null;
_detailSequence += 1; _detailSequence += 1;
_refreshDataState(); _refreshDataState();
notifyListeners(); notifyListeners();
@ -417,6 +458,7 @@ class BacklogBoardController extends ChangeNotifier {
} }
_selectedTaskId = null; _selectedTaskId = null;
_selectedTaskDetail = null; _selectedTaskDetail = null;
_selectedTaskDetailError = null;
} }
/// Runs the `_refreshDataState` helper used inside this library. /// Runs the `_refreshDataState` helper used inside this library.

View file

@ -5,6 +5,7 @@
library; library;
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart'; import 'package:scheduler_core/scheduler_core.dart';
@ -15,6 +16,7 @@ import '../../theme/focus_flow_tokens.dart';
import 'backlog_board_column.dart'; import 'backlog_board_column.dart';
import 'backlog_board_controls.dart'; import 'backlog_board_controls.dart';
import 'backlog_summary_panel.dart'; import 'backlog_summary_panel.dart';
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 {
@ -51,10 +53,18 @@ class BacklogBoardScreen extends StatelessWidget {
body: _BacklogReadyBody( body: _BacklogReadyBody(
data: data, data: data,
selectedTaskId: controller.selectedTaskId, selectedTaskId: controller.selectedTaskId,
selectedTaskDetail: controller.selectedTaskDetail,
isDetailLoading: controller.isSelectedTaskDetailLoading,
detailErrorMessage: controller.selectedTaskDetailError,
onTaskSelected: (taskId) { onTaskSelected: (taskId) {
unawaited(controller.selectTask(taskId)); unawaited(controller.selectTask(taskId));
}, },
onCloseDrawer: controller.clearSelection,
onAddTask: _handleAddTaskPlaceholder, onAddTask: _handleAddTaskPlaceholder,
onSchedule: _handleDrawerActionPlaceholder,
onBreakUp: _handleDrawerActionPlaceholder,
onPushToSomeday: _handleDrawerActionPlaceholder,
onRemove: _handleDrawerActionPlaceholder,
isEmpty: true, isEmpty: true,
), ),
), ),
@ -64,10 +74,18 @@ class BacklogBoardScreen extends StatelessWidget {
body: _BacklogReadyBody( body: _BacklogReadyBody(
data: data, data: data,
selectedTaskId: controller.selectedTaskId, selectedTaskId: controller.selectedTaskId,
selectedTaskDetail: controller.selectedTaskDetail,
isDetailLoading: controller.isSelectedTaskDetailLoading,
detailErrorMessage: controller.selectedTaskDetailError,
onTaskSelected: (taskId) { onTaskSelected: (taskId) {
unawaited(controller.selectTask(taskId)); unawaited(controller.selectTask(taskId));
}, },
onCloseDrawer: controller.clearSelection,
onAddTask: _handleAddTaskPlaceholder, onAddTask: _handleAddTaskPlaceholder,
onSchedule: _handleDrawerActionPlaceholder,
onBreakUp: _handleDrawerActionPlaceholder,
onPushToSomeday: _handleDrawerActionPlaceholder,
onRemove: _handleDrawerActionPlaceholder,
), ),
), ),
}; };
@ -79,6 +97,9 @@ class BacklogBoardScreen extends StatelessWidget {
/// Handles pre-Block-8 add-task button presses without mutating scheduler data. /// Handles pre-Block-8 add-task button presses without mutating scheduler data.
void _handleAddTaskPlaceholder(BacklogBoardBucket bucket) {} void _handleAddTaskPlaceholder(BacklogBoardBucket bucket) {}
/// Handles pre-Block-8 drawer action presses without mutating scheduler data.
void _handleDrawerActionPlaceholder() {}
/// 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.
@ -227,8 +248,16 @@ class _BacklogReadyBody extends StatelessWidget {
const _BacklogReadyBody({ const _BacklogReadyBody({
required this.data, required this.data,
required this.selectedTaskId, required this.selectedTaskId,
required this.selectedTaskDetail,
required this.isDetailLoading,
required this.detailErrorMessage,
required this.onTaskSelected, required this.onTaskSelected,
required this.onCloseDrawer,
required this.onAddTask, required this.onAddTask,
required this.onSchedule,
required this.onBreakUp,
required this.onPushToSomeday,
required this.onRemove,
this.isEmpty = false, this.isEmpty = false,
}); });
@ -238,12 +267,36 @@ class _BacklogReadyBody extends StatelessWidget {
/// Currently selected task id, if any. /// Currently selected task id, if any.
final String? selectedTaskId; final String? selectedTaskId;
/// Loaded selected task detail, if any.
final BacklogTaskDetailReadModel? selectedTaskDetail;
/// Whether the selected task drawer is waiting for detail data.
final bool isDetailLoading;
/// Drawer-local detail error message.
final String? detailErrorMessage;
/// Callback invoked when a task card is selected. /// Callback invoked when a task card is selected.
final BacklogTaskSelected onTaskSelected; final BacklogTaskSelected onTaskSelected;
/// Callback invoked when the drawer closes.
final VoidCallback onCloseDrawer;
/// Callback invoked when a column add-task control is pressed. /// Callback invoked when a column add-task control is pressed.
final BacklogBucketAddRequested onAddTask; final BacklogBucketAddRequested onAddTask;
/// Callback invoked when the drawer Schedule action is pressed.
final VoidCallback onSchedule;
/// Callback invoked when the drawer Break Up action is pressed.
final VoidCallback onBreakUp;
/// Callback invoked when the drawer Push to Someday action is pressed.
final VoidCallback onPushToSomeday;
/// Callback invoked when the drawer Remove action is pressed.
final VoidCallback onRemove;
/// Whether the board has no tasks. /// Whether the board has no tasks.
final bool isEmpty; final bool isEmpty;
@ -251,7 +304,12 @@ class _BacklogReadyBody extends StatelessWidget {
/// 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 Row( return LayoutBuilder(
builder: (context, constraints) {
return Stack(
fit: StackFit.expand,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Expanded(
@ -274,10 +332,51 @@ class _BacklogReadyBody extends StatelessWidget {
: BacklogSummaryPanel(summary: data.summary), : BacklogSummaryPanel(summary: data.summary),
), ),
], ],
),
if (selectedTaskId != null)
Positioned(
top: 0,
right: 0,
bottom: 0,
width: _drawerWidthFor(constraints.maxWidth),
child: BacklogTaskDrawer(
key: const ValueKey('backlog-task-drawer'),
selectedTaskId: selectedTaskId,
detail: selectedTaskDetail,
loading: isDetailLoading,
errorMessage: detailErrorMessage,
onClose: onCloseDrawer,
onSchedule: onSchedule,
onBreakUp: onBreakUp,
onPushToSomeday: onPushToSomeday,
onRemove: onRemove,
onProjectPressed: _handleDrawerActionPlaceholder,
onAddTagPressed: _handleDrawerActionPlaceholder,
onViewDayPressed: _handleDrawerActionPlaceholder,
),
),
],
);
},
); );
} }
} }
/// Calculates the drawer width for [availableWidth] while leaving board context visible.
double _drawerWidthFor(double availableWidth) {
const preferredWidth = 466.0;
const minimumDrawerWidth = 320.0;
const safeVisibleBoardWidth = 360.0;
if (!availableWidth.isFinite) {
return preferredWidth;
}
final widthAllowedByBoard = math.max(
minimumDrawerWidth,
availableWidth - safeVisibleBoardWidth,
);
return math.min(preferredWidth, widthAllowedByBoard);
}
/// Horizontally scrollable set of Backlog board columns. /// Horizontally scrollable set of Backlog board columns.
class _BacklogBoardColumns extends StatelessWidget { class _BacklogBoardColumns extends StatelessWidget {
/// Creates a board column layout. /// Creates a board column layout.

File diff suppressed because it is too large Load diff

View file

@ -108,6 +108,37 @@ void main() {
final cleared = controller.state as BacklogBoardReady; final cleared = controller.state as BacklogBoardReady;
expect(cleared.data.selectedTaskId, isNull); expect(cleared.data.selectedTaskId, isNull);
}); });
test('detail failures stay local to the selected drawer', () async {
final selectedDates = SelectedDateController(
initialDate: CivilDate(2026, 7, 7),
);
final controller = BacklogBoardController(
selectedDates: selectedDates,
dateLabelFor: (date) => date.toIsoString(),
readBoard: (_) async {
return ApplicationResult.success(_board(taskCount: 1));
},
readTaskDetail: (taskId, localDate) async {
return ApplicationResult.failure(
const ApplicationFailure(
code: ApplicationFailureCode.notFound,
detailCode: 'taskNotFound',
),
);
},
);
addTearDown(controller.dispose);
await controller.load();
await controller.selectTask('task-1');
expect(controller.state, isA<BacklogBoardReady>());
expect(controller.selectedTaskId, 'task-1');
expect(controller.selectedTaskDetail, isNull);
expect(controller.isSelectedTaskDetailLoading, isFalse);
expect(controller.selectedTaskDetailError, 'taskNotFound');
});
}); });
} }

View file

@ -88,6 +88,95 @@ void main() {
expect(border.top.color, FocusFlowTokens.accentMagenta); expect(border.top.color, FocusFlowTokens.accentMagenta);
}); });
testWidgets('drawer overlays board and close clears selected state', (
tester,
) async {
final item = _boardItemWithChildren();
final board = _boardWithItem(item);
final selectedDates = SelectedDateController(
initialDate: CivilDate(2026, 7, 7),
);
final controller = BacklogBoardController(
readBoard: (_) async => ApplicationResult.success(board),
readTaskDetail: (taskId, localDate) async {
return ApplicationResult.success(_detailFor(item));
},
selectedDates: selectedDates,
dateLabelFor: (date) => date.toIsoString(),
);
addTearDown(controller.dispose);
addTearDown(selectedDates.dispose);
await controller.load();
await _pumpBacklogScreen(tester, controller);
final cardFinder = find.byKey(const ValueKey('backlog-card-plan-week'));
final beforeOpen = tester.getTopLeft(cardFinder);
await tester.tap(cardFinder);
await tester.pumpAndSettle();
final drawer = find.byKey(const ValueKey('backlog-task-drawer'));
expect(drawer, findsOneWidget);
expect(
find.descendant(of: drawer, matching: find.text('Plan next week')),
findsOneWidget,
);
expect(tester.getTopLeft(cardFinder), beforeOpen);
expect(controller.selectedTaskId, 'plan-week');
await tester.tap(find.byKey(const ValueKey('backlog-drawer-close-button')));
await tester.pumpAndSettle();
expect(drawer, findsNothing);
expect(controller.selectedTaskId, isNull);
final cardContainer = tester.widget<AnimatedContainer>(
find.byKey(const ValueKey('backlog-card-container-plan-week')),
);
final decoration = cardContainer.decoration! as BoxDecoration;
final border = decoration.border! as Border;
expect(border.top.color, isNot(FocusFlowTokens.accentMagenta));
});
testWidgets('detail read failure stays inside drawer overlay', (
tester,
) async {
final item = _boardItemWithChildren();
final board = _boardWithItem(item);
final selectedDates = SelectedDateController(
initialDate: CivilDate(2026, 7, 7),
);
final controller = BacklogBoardController(
readBoard: (_) async => ApplicationResult.success(board),
readTaskDetail: (taskId, localDate) async {
return ApplicationResult.failure(
const ApplicationFailure(
code: ApplicationFailureCode.notFound,
detailCode: 'taskNotFound',
),
);
},
selectedDates: selectedDates,
dateLabelFor: (date) => date.toIsoString(),
);
addTearDown(controller.dispose);
addTearDown(selectedDates.dispose);
await controller.load();
await _pumpBacklogScreen(tester, controller);
await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week')));
await tester.pumpAndSettle();
final drawer = find.byKey(const ValueKey('backlog-task-drawer'));
expect(drawer, findsOneWidget);
expect(find.text('Could not load task details'), findsOneWidget);
expect(find.text('taskNotFound'), findsOneWidget);
expect(find.text('Could not load backlog'), findsNothing);
expect(find.text('Plan next week'), findsOneWidget);
expect(controller.selectedTaskId, 'plan-week');
});
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();

View file

@ -0,0 +1,247 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Tests Backlog task detail drawer rendering and callbacks.
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scheduler_core/scheduler_core.dart';
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
import 'package:focus_flow_flutter/widgets/backlog/backlog_task_drawer.dart';
/// Runs Backlog task drawer widget tests.
void main() {
testWidgets('renders loaded detail sections and calls action callbacks', (
tester,
) async {
var closeCount = 0;
var scheduleCount = 0;
var breakUpCount = 0;
var somedayCount = 0;
var removeCount = 0;
var addTagCount = 0;
var viewDayCount = 0;
await _pumpDrawer(
tester,
BacklogTaskDrawer(
selectedTaskId: 'review-q3-budget',
detail: _detail(),
loading: false,
errorMessage: null,
onClose: () {
closeCount += 1;
},
onSchedule: () {
scheduleCount += 1;
},
onBreakUp: () {
breakUpCount += 1;
},
onPushToSomeday: () {
somedayCount += 1;
},
onRemove: () {
removeCount += 1;
},
onProjectPressed: () {},
onAddTagPressed: () {
addTagCount += 1;
},
onViewDayPressed: () {
viewDayCount += 1;
},
),
);
expect(find.text('Review Q3 budget'), findsOneWidget);
expect(find.text('Finance review and notes'), findsOneWidget);
expect(find.text('Added 2h ago'), findsOneWidget);
expect(find.text('Notes'), findsOneWidget);
expect(find.text(_notes), findsOneWidget);
expect(find.text('Project'), findsOneWidget);
expect(find.text('Work'), findsOneWidget);
expect(find.text('Tags'), findsOneWidget);
expect(find.text('finance'), findsOneWidget);
expect(find.text('review'), findsOneWidget);
expect(find.text('Duration'), findsOneWidget);
expect(find.text('60 min'), findsWidgets);
expect(find.text('Reward'), findsOneWidget);
expect(find.text('3/5'), findsOneWidget);
expect(find.text('Satisfying'), findsOneWidget);
expect(find.text('Difficulty'), findsOneWidget);
expect(find.text('2/5'), findsOneWidget);
expect(find.text('Easy'), findsOneWidget);
expect(find.text('Suggested slots'), findsOneWidget);
expect(find.text('Today'), findsNWidgets(2));
expect(find.text('Tomorrow'), findsOneWidget);
expect(find.text('2:30 PM - 3:30 PM | 60 min'), findsOneWidget);
expect(find.text('Great Fit'), findsNWidgets(2));
expect(find.text('Okay'), findsOneWidget);
await tester.tap(
find.byKey(const ValueKey('backlog-drawer-view-day-button')),
);
await tester.tap(
find.byKey(const ValueKey('backlog-drawer-add-tag-button')),
);
await tester.tap(
find.byKey(const ValueKey('backlog-drawer-schedule-button')),
);
await tester.tap(
find.byKey(const ValueKey('backlog-drawer-break-up-button')),
);
await tester.tap(
find.byKey(const ValueKey('backlog-drawer-push-someday-button')),
);
await tester.tap(
find.byKey(const ValueKey('backlog-drawer-remove-button')),
);
await tester.tap(find.byKey(const ValueKey('backlog-drawer-close-button')));
expect(viewDayCount, 1);
expect(addTagCount, 1);
expect(scheduleCount, 1);
expect(breakUpCount, 1);
expect(somedayCount, 1);
expect(removeCount, 1);
expect(closeCount, 1);
});
testWidgets('renders loading and error states without detail data', (
tester,
) async {
await _pumpDrawer(
tester,
BacklogTaskDrawer(
selectedTaskId: 'task-1',
detail: null,
loading: true,
errorMessage: null,
onClose: () {},
onSchedule: () {},
onBreakUp: () {},
onPushToSomeday: () {},
onRemove: () {},
onProjectPressed: () {},
onAddTagPressed: () {},
onViewDayPressed: () {},
),
);
expect(find.text('Loading task'), findsOneWidget);
expect(find.text('Preparing task-1 details.'), findsOneWidget);
await _pumpDrawer(
tester,
BacklogTaskDrawer(
selectedTaskId: 'task-1',
detail: null,
loading: false,
errorMessage: 'taskNotFound',
onClose: () {},
onSchedule: () {},
onBreakUp: () {},
onPushToSomeday: () {},
onRemove: () {},
onProjectPressed: () {},
onAddTagPressed: () {},
onViewDayPressed: () {},
),
);
expect(find.text('Could not load task details'), findsOneWidget);
expect(find.text('taskNotFound'), findsOneWidget);
});
}
/// Notes fixture text used by the drawer test.
const _notes =
'Scan numbers and compare against plan. Note any variances and prepare talking points.';
/// Pumps [drawer] in a fixed-size dark FocusFlow test harness.
Future<void> _pumpDrawer(WidgetTester tester, Widget drawer) async {
await tester.binding.setSurfaceSize(const Size(520, 860));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
theme: FocusFlowTheme.dark(),
home: Scaffold(
body: Align(
alignment: Alignment.centerRight,
child: SizedBox(width: 466, height: 820, child: drawer),
),
),
),
);
await tester.pump();
}
/// Builds a loaded drawer detail fixture.
BacklogTaskDetailReadModel _detail() {
return BacklogTaskDetailReadModel(
item: BacklogBoardItemReadModel(
taskId: 'review-q3-budget',
title: 'Review Q3 budget',
subtitle: 'Finance review and notes',
projectId: 'work',
projectName: 'Work',
projectColorToken: 'project-work',
durationMinutes: 60,
priority: PriorityLevel.high,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.easy,
bucket: BacklogBoardBucket.needTimeBlock,
bucketReason: 'Give this a protected block.',
createdAt: DateTime.utc(2026, 7, 7, 19),
updatedAt: DateTime.utc(2026, 7, 7, 19),
childPreview: const [],
tags: const ['finance', 'review'],
),
notes: _notes,
addedLabel: 'Added 2h ago',
tags: const ['finance', 'review'],
projectOptions: const [
ProjectOptionReadModel(
projectId: 'work',
name: 'Work',
colorToken: 'project-work',
isArchived: false,
),
],
suggestedSlots: const [
SuggestedSlotReadModel(
id: 'slot-1',
localDateLabel: 'Today',
startText: '2:30 PM',
endText: '3:30 PM',
durationMinutes: 60,
fitLabel: 'Great Fit',
fitSeverityToken: 'fit-great',
isPrimary: true,
),
SuggestedSlotReadModel(
id: 'slot-2',
localDateLabel: 'Today',
startText: '6:00 PM',
endText: '7:00 PM',
durationMinutes: 60,
fitLabel: 'Okay',
fitSeverityToken: 'fit-okay',
isPrimary: false,
),
SuggestedSlotReadModel(
id: 'slot-3',
localDateLabel: 'Tomorrow',
startText: '10:00 AM',
endText: '11:00 AM',
durationMinutes: 60,
fitLabel: 'Great Fit',
fitSeverityToken: 'fit-great',
isPrimary: false,
),
],
);
}