feat(backlog): add task detail drawer
This commit is contained in:
parent
51ab9aa014
commit
8a1d2bfc9d
7 changed files with 1790 additions and 45 deletions
|
|
@ -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
|
||||
sections while leaving the summary header visible and does not write any
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
String? _searchText;
|
||||
|
|
@ -179,6 +183,18 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
/// Currently selected Backlog task id, if any.
|
||||
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.
|
||||
String? get searchText => _searchText;
|
||||
|
||||
|
|
@ -284,6 +300,7 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
}
|
||||
_selectedTaskId = taskId;
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = null;
|
||||
notifyListeners();
|
||||
final sequence = _nextDetailSequence();
|
||||
final date = selectedDates.date;
|
||||
|
|
@ -291,30 +308,53 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
() =>
|
||||
'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence',
|
||||
);
|
||||
final result = await readTaskDetail(taskId, date);
|
||||
if (_isDetailStale(sequence, taskId)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Backlog detail load result. '
|
||||
'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final failure = result.failure;
|
||||
if (failure != null) {
|
||||
logger.warn(
|
||||
() =>
|
||||
'Backlog detail load returned failure. taskId=$taskId '
|
||||
'code=${failure.code.name} detailCode=${failure.detailCode}',
|
||||
);
|
||||
_selectedTaskId = null;
|
||||
_selectedTaskDetail = null;
|
||||
try {
|
||||
final result = await readTaskDetail(taskId, date);
|
||||
if (_isDetailStale(sequence, taskId)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Backlog detail load result. '
|
||||
'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final failure = result.failure;
|
||||
if (failure != null) {
|
||||
logger.warn(
|
||||
() =>
|
||||
'Backlog detail load returned failure. taskId=$taskId '
|
||||
'code=${failure.code.name} detailCode=${failure.detailCode}',
|
||||
);
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = failure.detailCode ?? failure.code.name;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_selectedTaskDetail = result.requireValue;
|
||||
_selectedTaskDetailError = null;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
} on Object catch (error, stackTrace) {
|
||||
if (_isDetailStale(sequence, taskId)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Backlog detail load exception. '
|
||||
'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.error(
|
||||
() =>
|
||||
'Backlog detail load threw unexpectedly. taskId=$taskId sequence=$sequence',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = error.toString();
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_selectedTaskDetail = result.requireValue;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clears UI-local task selection.
|
||||
|
|
@ -324,6 +364,7 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
}
|
||||
_selectedTaskId = null;
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = null;
|
||||
_detailSequence += 1;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
|
|
@ -417,6 +458,7 @@ class BacklogBoardController extends ChangeNotifier {
|
|||
}
|
||||
_selectedTaskId = null;
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = null;
|
||||
}
|
||||
|
||||
/// Runs the `_refreshDataState` helper used inside this library.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.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_controls.dart';
|
||||
import 'backlog_summary_panel.dart';
|
||||
import 'backlog_task_drawer.dart';
|
||||
|
||||
/// Backlog board screen backed by a public scheduler read controller.
|
||||
class BacklogBoardScreen extends StatelessWidget {
|
||||
|
|
@ -51,10 +53,18 @@ class BacklogBoardScreen extends StatelessWidget {
|
|||
body: _BacklogReadyBody(
|
||||
data: data,
|
||||
selectedTaskId: controller.selectedTaskId,
|
||||
selectedTaskDetail: controller.selectedTaskDetail,
|
||||
isDetailLoading: controller.isSelectedTaskDetailLoading,
|
||||
detailErrorMessage: controller.selectedTaskDetailError,
|
||||
onTaskSelected: (taskId) {
|
||||
unawaited(controller.selectTask(taskId));
|
||||
},
|
||||
onCloseDrawer: controller.clearSelection,
|
||||
onAddTask: _handleAddTaskPlaceholder,
|
||||
onSchedule: _handleDrawerActionPlaceholder,
|
||||
onBreakUp: _handleDrawerActionPlaceholder,
|
||||
onPushToSomeday: _handleDrawerActionPlaceholder,
|
||||
onRemove: _handleDrawerActionPlaceholder,
|
||||
isEmpty: true,
|
||||
),
|
||||
),
|
||||
|
|
@ -64,10 +74,18 @@ class BacklogBoardScreen extends StatelessWidget {
|
|||
body: _BacklogReadyBody(
|
||||
data: data,
|
||||
selectedTaskId: controller.selectedTaskId,
|
||||
selectedTaskDetail: controller.selectedTaskDetail,
|
||||
isDetailLoading: controller.isSelectedTaskDetailLoading,
|
||||
detailErrorMessage: controller.selectedTaskDetailError,
|
||||
onTaskSelected: (taskId) {
|
||||
unawaited(controller.selectTask(taskId));
|
||||
},
|
||||
onCloseDrawer: controller.clearSelection,
|
||||
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.
|
||||
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.
|
||||
class _BacklogFrame extends StatelessWidget {
|
||||
/// Creates the Backlog frame.
|
||||
|
|
@ -227,8 +248,16 @@ class _BacklogReadyBody extends StatelessWidget {
|
|||
const _BacklogReadyBody({
|
||||
required this.data,
|
||||
required this.selectedTaskId,
|
||||
required this.selectedTaskDetail,
|
||||
required this.isDetailLoading,
|
||||
required this.detailErrorMessage,
|
||||
required this.onTaskSelected,
|
||||
required this.onCloseDrawer,
|
||||
required this.onAddTask,
|
||||
required this.onSchedule,
|
||||
required this.onBreakUp,
|
||||
required this.onPushToSomeday,
|
||||
required this.onRemove,
|
||||
this.isEmpty = false,
|
||||
});
|
||||
|
||||
|
|
@ -238,12 +267,36 @@ class _BacklogReadyBody extends StatelessWidget {
|
|||
/// Currently selected task id, if any.
|
||||
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.
|
||||
final BacklogTaskSelected onTaskSelected;
|
||||
|
||||
/// Callback invoked when the drawer closes.
|
||||
final VoidCallback onCloseDrawer;
|
||||
|
||||
/// Callback invoked when a column add-task control is pressed.
|
||||
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.
|
||||
final bool isEmpty;
|
||||
|
||||
|
|
@ -251,33 +304,79 @@ class _BacklogReadyBody extends StatelessWidget {
|
|||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _BacklogBoardColumns(
|
||||
columns: data.columns,
|
||||
selectedTaskId: selectedTaskId,
|
||||
onTaskSelected: onTaskSelected,
|
||||
onAddTask: onAddTask,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(
|
||||
width: 292,
|
||||
child: isEmpty
|
||||
? const _BacklogStatePanel(
|
||||
icon: Icons.check_circle_outline,
|
||||
title: 'Backlog is clear',
|
||||
message: 'New tasks will appear here.',
|
||||
)
|
||||
: BacklogSummaryPanel(summary: data.summary),
|
||||
),
|
||||
],
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _BacklogBoardColumns(
|
||||
columns: data.columns,
|
||||
selectedTaskId: selectedTaskId,
|
||||
onTaskSelected: onTaskSelected,
|
||||
onAddTask: onAddTask,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(
|
||||
width: 292,
|
||||
child: isEmpty
|
||||
? const _BacklogStatePanel(
|
||||
icon: Icons.check_circle_outline,
|
||||
title: 'Backlog is clear',
|
||||
message: 'New tasks will appear here.',
|
||||
)
|
||||
: 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.
|
||||
class _BacklogBoardColumns extends StatelessWidget {
|
||||
/// Creates a board column layout.
|
||||
|
|
|
|||
1221
apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart
Normal file
1221
apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -108,6 +108,37 @@ void main() {
|
|||
final cleared = controller.state as BacklogBoardReady;
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,95 @@ void main() {
|
|||
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 {
|
||||
final requests = <BacklogBoardReadRequest>[];
|
||||
final item = _boardItemWithChildren();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue