forked from eva/focus-flow
feat(backlog): add board navigation shell
This commit is contained in:
parent
82f1fd43b7
commit
73ef8c2543
13 changed files with 1960 additions and 81 deletions
|
|
@ -100,3 +100,19 @@ Freeform Tags Metadata.md` for a later migration-backed feature.
|
||||||
medium reward, and easy difficulty. Finance/review freeform tags, notes text,
|
medium reward, and easy difficulty. Finance/review freeform tags, notes text,
|
||||||
and exact drawer slot copy remain deferred with the approved metadata scope
|
and exact drawer slot copy remain deferred with the approved metadata scope
|
||||||
decision above.
|
decision above.
|
||||||
|
|
||||||
|
## Block 03 Navigation and Screen Shell
|
||||||
|
|
||||||
|
1. `FocusFlowSection` now owns app-level navigation state for Today, Backlog,
|
||||||
|
Projects, Reports, and Settings. The sidebar is controlled by this state and
|
||||||
|
exposes stable active keys for Today and Backlog tests.
|
||||||
|
2. `BacklogBoardController` loads `BacklogBoardQueryResult`, tracks local
|
||||||
|
search/filter/sort/group values, and keeps selected task detail as UI-local
|
||||||
|
state. It uses only public scheduler application read models.
|
||||||
|
3. Demo and persistent compositions both create the Backlog controller from the
|
||||||
|
same underlying application store used by Today, so future commands can
|
||||||
|
refresh both surfaces without creating separate fake state.
|
||||||
|
4. `BacklogBoardScreen` renders the desktop Backlog header, controls, column
|
||||||
|
shells, loading/empty/failure states, and a compact summary panel shell.
|
||||||
|
Detailed cards, charts, and drawer contents remain for later UI Plan 2
|
||||||
|
blocks.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ library;
|
||||||
|
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
import '../controllers/backlog_board_controller.dart';
|
||||||
import '../controllers/scheduler_command_controller.dart';
|
import '../controllers/scheduler_command_controller.dart';
|
||||||
import '../controllers/scheduler_read_controller.dart';
|
import '../controllers/scheduler_read_controller.dart';
|
||||||
import '../controllers/selected_date_controller.dart';
|
import '../controllers/selected_date_controller.dart';
|
||||||
|
|
@ -147,6 +148,38 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates the controller used by the Backlog board screen.
|
||||||
|
@override
|
||||||
|
BacklogBoardController createBacklogBoardController({
|
||||||
|
required SelectedDateController selectedDates,
|
||||||
|
}) {
|
||||||
|
return BacklogBoardController(
|
||||||
|
selectedDates: selectedDates,
|
||||||
|
dateLabelFor: dateLabelFor,
|
||||||
|
readBoard: (request) {
|
||||||
|
return managementUseCases.getBacklogBoard(
|
||||||
|
GetBacklogBoardRequest(
|
||||||
|
context: context('ui-read-backlog-board'),
|
||||||
|
localDate: request.localDate,
|
||||||
|
searchText: request.searchText,
|
||||||
|
filters: request.filters,
|
||||||
|
sortKey: request.sortKey,
|
||||||
|
groupMode: request.groupMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
readTaskDetail: (taskId, localDate) {
|
||||||
|
return managementUseCases.getBacklogTaskDetail(
|
||||||
|
GetBacklogTaskDetailRequest(
|
||||||
|
context: context('ui-read-backlog-detail'),
|
||||||
|
localDate: localDate,
|
||||||
|
taskId: taskId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates a generic read controller for Today-state panes.
|
/// Creates a generic read controller for Today-state panes.
|
||||||
UiReadController<TodayState> createTodayController() {
|
UiReadController<TodayState> createTodayController() {
|
||||||
return ApplicationReadController<TodayState>(
|
return ApplicationReadController<TodayState>(
|
||||||
|
|
@ -180,22 +213,6 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a generic read controller for the Backlog board.
|
|
||||||
UiReadController<BacklogBoardQueryResult> createBacklogBoardController() {
|
|
||||||
return ApplicationReadController<BacklogBoardQueryResult>(
|
|
||||||
read: () {
|
|
||||||
return managementUseCases.getBacklogBoard(
|
|
||||||
GetBacklogBoardRequest(
|
|
||||||
context: context('ui-read-backlog-board'),
|
|
||||||
localDate: date,
|
|
||||||
sortKey: BacklogSortKey.age,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isEmpty: (state) => state.summary.totalTaskCount == 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates the command controller used by interactive scheduler controls.
|
/// Creates the command controller used by interactive scheduler controls.
|
||||||
@override
|
@override
|
||||||
SchedulerCommandController createCommandController({
|
SchedulerCommandController createCommandController({
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,16 @@ import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
import '../controllers/backlog_board_controller.dart';
|
||||||
import '../controllers/scheduler_command_controller.dart';
|
import '../controllers/scheduler_command_controller.dart';
|
||||||
import '../controllers/selected_date_controller.dart';
|
import '../controllers/selected_date_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
|
import '../models/navigation/focus_flow_section.dart';
|
||||||
import '../models/today_screen_models.dart';
|
import '../models/today_screen_models.dart';
|
||||||
import '../theme/focus_flow_theme.dart';
|
import '../theme/focus_flow_theme.dart';
|
||||||
import '../theme/focus_flow_tokens.dart';
|
import '../theme/focus_flow_tokens.dart';
|
||||||
import '../widgets/app_shell.dart';
|
import '../widgets/app_shell.dart';
|
||||||
|
import '../widgets/backlog/backlog_board_screen.dart';
|
||||||
import '../widgets/required_banner.dart';
|
import '../widgets/required_banner.dart';
|
||||||
import '../widgets/sidebar.dart';
|
import '../widgets/sidebar.dart';
|
||||||
import '../widgets/task_selection_modal.dart';
|
import '../widgets/task_selection_modal.dart';
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,13 @@ part of '../focus_flow_app.dart';
|
||||||
/// Private implementation type for `_FocusFlowHomeState` in this library.
|
/// Private implementation type for `_FocusFlowHomeState` in this library.
|
||||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||||
class _FocusFlowHomeState extends State<FocusFlowHome> {
|
class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
/// Stores the `controller` value for this object.
|
/// Stores the `todayController` value for this object.
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
late final TodayScreenController controller;
|
late final TodayScreenController todayController;
|
||||||
|
|
||||||
|
/// Stores the `backlogController` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
late final BacklogBoardController backlogController;
|
||||||
|
|
||||||
/// Stores the `selectedDateController` value for this object.
|
/// Stores the `selectedDateController` value for this object.
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
|
@ -18,6 +22,10 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
late final SchedulerCommandController commandController;
|
late final SchedulerCommandController commandController;
|
||||||
|
|
||||||
|
/// Private state stored as `_activeSection` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
FocusFlowSection _activeSection = FocusFlowSection.today;
|
||||||
|
|
||||||
/// Initializes state owned by this object before the first build.
|
/// Initializes state owned by this object before the first build.
|
||||||
/// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
|
/// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
|
||||||
@override
|
@override
|
||||||
|
|
@ -25,15 +33,18 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
super.initState();
|
super.initState();
|
||||||
logger.debug(() => 'FocusFlow home initializing controllers.');
|
logger.debug(() => 'FocusFlow home initializing controllers.');
|
||||||
selectedDateController = widget.composition.createSelectedDateController();
|
selectedDateController = widget.composition.createSelectedDateController();
|
||||||
controller = widget.composition.createTodayScreenController(
|
todayController = widget.composition.createTodayScreenController(
|
||||||
|
selectedDates: selectedDateController,
|
||||||
|
);
|
||||||
|
backlogController = widget.composition.createBacklogBoardController(
|
||||||
selectedDates: selectedDateController,
|
selectedDates: selectedDateController,
|
||||||
);
|
);
|
||||||
commandController = widget.composition.createCommandController(
|
commandController = widget.composition.createCommandController(
|
||||||
refreshReads: controller.load,
|
refreshReads: _refreshReads,
|
||||||
selectedDate: () => selectedDateController.date,
|
selectedDate: () => selectedDateController.date,
|
||||||
);
|
);
|
||||||
logger.finer(() => 'FocusFlow home triggering initial Today load.');
|
logger.finer(() => 'FocusFlow home triggering initial Today load.');
|
||||||
controller.load();
|
todayController.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Releases resources owned by this object before it leaves the tree.
|
/// Releases resources owned by this object before it leaves the tree.
|
||||||
|
|
@ -42,7 +53,8 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
logger.debug(() => 'FocusFlow home disposing controllers.');
|
logger.debug(() => 'FocusFlow home disposing controllers.');
|
||||||
commandController.dispose();
|
commandController.dispose();
|
||||||
controller.dispose();
|
backlogController.dispose();
|
||||||
|
todayController.dispose();
|
||||||
selectedDateController.dispose();
|
selectedDateController.dispose();
|
||||||
unawaited(widget.composition.dispose());
|
unawaited(widget.composition.dispose());
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -114,7 +126,26 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
taskId: card.id,
|
taskId: card.id,
|
||||||
expectedUpdatedAt: card.expectedUpdatedAt,
|
expectedUpdatedAt: card.expectedUpdatedAt,
|
||||||
);
|
);
|
||||||
controller.clearSelection();
|
todayController.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes every read surface affected by scheduler commands.
|
||||||
|
Future<void> _refreshReads() async {
|
||||||
|
await Future.wait([todayController.load(), backlogController.load()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects the visible app [section].
|
||||||
|
void _selectSection(FocusFlowSection section) {
|
||||||
|
if (section == _activeSection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.debug(() => 'FocusFlow section selected. section=${section.name}');
|
||||||
|
setState(() {
|
||||||
|
_activeSection = section;
|
||||||
|
});
|
||||||
|
if (section == FocusFlowSection.backlog) {
|
||||||
|
unawaited(backlogController.load());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves the visible planning date backward by one day.
|
/// Moves the visible planning date backward by one day.
|
||||||
|
|
@ -156,58 +187,74 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: FocusFlowTokens.appBackground,
|
backgroundColor: FocusFlowTokens.appBackground,
|
||||||
body: AppShell(
|
body: AppShell(
|
||||||
sidebar: const Sidebar(),
|
sidebar: Sidebar(
|
||||||
child: AnimatedBuilder(
|
activeSection: _activeSection,
|
||||||
animation: controller,
|
onSectionSelected: _selectSection,
|
||||||
builder: (context, _) {
|
|
||||||
return switch (controller.state) {
|
|
||||||
TodayScreenLoading() => const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
TodayScreenFailure(:final message) => _PlanIssue(
|
|
||||||
message: message,
|
|
||||||
),
|
|
||||||
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
|
|
||||||
data: data,
|
|
||||||
selectedCard: controller.selectedCard,
|
|
||||||
onSelectCard: controller.selectCard,
|
|
||||||
onCompleteCard: _toggleCardCompletion,
|
|
||||||
onPushToNext: _pushCardToNext,
|
|
||||||
onPushToTomorrow: _pushCardToTomorrow,
|
|
||||||
onPushToBacklog: _pushCardToBacklog,
|
|
||||||
onRemoveCard: _removeCard,
|
|
||||||
onAddTask: _addGenericFlexibleTask,
|
|
||||||
onClearSelection: controller.clearSelection,
|
|
||||||
onPreviousDate: _selectPreviousDate,
|
|
||||||
onNextDate: _selectNextDate,
|
|
||||||
onChooseDate: () {
|
|
||||||
unawaited(_chooseDate());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
TodayScreenReady(:final data) => _TodayScreenScaffold(
|
|
||||||
data: data,
|
|
||||||
selectedCard: controller.selectedCard,
|
|
||||||
onSelectCard: controller.selectCard,
|
|
||||||
onCompleteCard: _toggleCardCompletion,
|
|
||||||
onPushToNext: _pushCardToNext,
|
|
||||||
onPushToTomorrow: _pushCardToTomorrow,
|
|
||||||
onPushToBacklog: _pushCardToBacklog,
|
|
||||||
onRemoveCard: _removeCard,
|
|
||||||
onAddTask: _addGenericFlexibleTask,
|
|
||||||
onClearSelection: controller.clearSelection,
|
|
||||||
onPreviousDate: _selectPreviousDate,
|
|
||||||
onNextDate: _selectNextDate,
|
|
||||||
onChooseDate: () {
|
|
||||||
unawaited(_chooseDate());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
child: switch (_activeSection) {
|
||||||
|
FocusFlowSection.today => _buildTodayScreen(),
|
||||||
|
FocusFlowSection.backlog => BacklogBoardScreen(
|
||||||
|
controller: backlogController,
|
||||||
|
),
|
||||||
|
FocusFlowSection.projects ||
|
||||||
|
FocusFlowSection.reports ||
|
||||||
|
FocusFlowSection.settings => _SectionPlaceholder(
|
||||||
|
section: _activeSection,
|
||||||
|
),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the current Today screen branch.
|
||||||
|
Widget _buildTodayScreen() {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: todayController,
|
||||||
|
builder: (context, _) {
|
||||||
|
return switch (todayController.state) {
|
||||||
|
TodayScreenLoading() => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
TodayScreenFailure(:final message) => _PlanIssue(message: message),
|
||||||
|
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
|
||||||
|
data: data,
|
||||||
|
selectedCard: todayController.selectedCard,
|
||||||
|
onSelectCard: todayController.selectCard,
|
||||||
|
onCompleteCard: _toggleCardCompletion,
|
||||||
|
onPushToNext: _pushCardToNext,
|
||||||
|
onPushToTomorrow: _pushCardToTomorrow,
|
||||||
|
onPushToBacklog: _pushCardToBacklog,
|
||||||
|
onRemoveCard: _removeCard,
|
||||||
|
onAddTask: _addGenericFlexibleTask,
|
||||||
|
onClearSelection: todayController.clearSelection,
|
||||||
|
onPreviousDate: _selectPreviousDate,
|
||||||
|
onNextDate: _selectNextDate,
|
||||||
|
onChooseDate: () {
|
||||||
|
unawaited(_chooseDate());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TodayScreenReady(:final data) => _TodayScreenScaffold(
|
||||||
|
data: data,
|
||||||
|
selectedCard: todayController.selectedCard,
|
||||||
|
onSelectCard: todayController.selectCard,
|
||||||
|
onCompleteCard: _toggleCardCompletion,
|
||||||
|
onPushToNext: _pushCardToNext,
|
||||||
|
onPushToTomorrow: _pushCardToTomorrow,
|
||||||
|
onPushToBacklog: _pushCardToBacklog,
|
||||||
|
onRemoveCard: _removeCard,
|
||||||
|
onAddTask: _addGenericFlexibleTask,
|
||||||
|
onClearSelection: todayController.clearSelection,
|
||||||
|
onPreviousDate: _selectPreviousDate,
|
||||||
|
onNextDate: _selectNextDate,
|
||||||
|
onChooseDate: () {
|
||||||
|
unawaited(_chooseDate());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the dark theme wrapper used by the date picker dialog.
|
/// Builds the dark theme wrapper used by the date picker dialog.
|
||||||
Widget _buildDatePickerTheme(BuildContext context, Widget? child) {
|
Widget _buildDatePickerTheme(BuildContext context, Widget? child) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -237,3 +284,48 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
return CivilDate(dateTime.year, dateTime.month, dateTime.day);
|
return CivilDate(dateTime.year, dateTime.month, dateTime.day);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Placeholder for navigation sections that are not part of the V1 screen work.
|
||||||
|
class _SectionPlaceholder extends StatelessWidget {
|
||||||
|
/// Creates a placeholder for [section].
|
||||||
|
const _SectionPlaceholder({required this.section});
|
||||||
|
|
||||||
|
/// Section represented by this placeholder.
|
||||||
|
final FocusFlowSection section;
|
||||||
|
|
||||||
|
/// 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 Center(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: FocusFlowTokens.panelBackground,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||||
|
child: Text(
|
||||||
|
_labelFor(section),
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves display text for [section].
|
||||||
|
String _labelFor(FocusFlowSection section) {
|
||||||
|
return switch (section) {
|
||||||
|
FocusFlowSection.today => 'Today',
|
||||||
|
FocusFlowSection.backlog => 'Backlog',
|
||||||
|
FocusFlowSection.projects => 'Projects',
|
||||||
|
FocusFlowSection.reports => 'Reports',
|
||||||
|
FocusFlowSection.settings => 'Settings',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.dart'
|
||||||
show logger;
|
show logger;
|
||||||
import 'package:scheduler_persistence_sqlite/sqlite.dart';
|
import 'package:scheduler_persistence_sqlite/sqlite.dart';
|
||||||
|
|
||||||
|
import '../controllers/backlog_board_controller.dart';
|
||||||
import '../controllers/scheduler_command_controller.dart';
|
import '../controllers/scheduler_command_controller.dart';
|
||||||
import '../controllers/selected_date_controller.dart';
|
import '../controllers/selected_date_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
|
|
@ -204,6 +205,41 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates the controller used by the Backlog board screen.
|
||||||
|
@override
|
||||||
|
BacklogBoardController createBacklogBoardController({
|
||||||
|
required SelectedDateController selectedDates,
|
||||||
|
}) {
|
||||||
|
scheduler_core.logger.finer(
|
||||||
|
() => 'Creating backlog board controller. date=${date.toIsoString()}',
|
||||||
|
);
|
||||||
|
return BacklogBoardController(
|
||||||
|
selectedDates: selectedDates,
|
||||||
|
dateLabelFor: dateLabelFor,
|
||||||
|
readBoard: (request) {
|
||||||
|
return managementUseCases.getBacklogBoard(
|
||||||
|
GetBacklogBoardRequest(
|
||||||
|
context: context('ui-read-backlog-board'),
|
||||||
|
localDate: request.localDate,
|
||||||
|
searchText: request.searchText,
|
||||||
|
filters: request.filters,
|
||||||
|
sortKey: request.sortKey,
|
||||||
|
groupMode: request.groupMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
readTaskDetail: (taskId, localDate) {
|
||||||
|
return managementUseCases.getBacklogTaskDetail(
|
||||||
|
GetBacklogTaskDetailRequest(
|
||||||
|
context: context('ui-read-backlog-detail'),
|
||||||
|
localDate: localDate,
|
||||||
|
taskId: taskId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates the command controller used by interactive scheduler controls.
|
/// Creates the command controller used by interactive scheduler controls.
|
||||||
@override
|
@override
|
||||||
SchedulerCommandController createCommandController({
|
SchedulerCommandController createCommandController({
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ library;
|
||||||
|
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
import '../controllers/backlog_board_controller.dart';
|
||||||
import '../controllers/scheduler_command_controller.dart';
|
import '../controllers/scheduler_command_controller.dart';
|
||||||
import '../controllers/selected_date_controller.dart';
|
import '../controllers/selected_date_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
|
|
@ -32,6 +33,11 @@ abstract interface class SchedulerAppComposition {
|
||||||
required SelectedDateController selectedDates,
|
required SelectedDateController selectedDates,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Creates the controller used by the Backlog board screen.
|
||||||
|
BacklogBoardController createBacklogBoardController({
|
||||||
|
required SelectedDateController selectedDates,
|
||||||
|
});
|
||||||
|
|
||||||
/// Creates the command controller used by interactive scheduler controls.
|
/// Creates the command controller used by interactive scheduler controls.
|
||||||
SchedulerCommandController createCommandController({
|
SchedulerCommandController createCommandController({
|
||||||
required ReadRefresh refreshReads,
|
required ReadRefresh refreshReads,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,443 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Coordinates Backlog board controller state for the FocusFlow Flutter UI.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
import '../models/backlog/backlog_board_screen_data.dart';
|
||||||
|
import 'selected_date_controller.dart';
|
||||||
|
|
||||||
|
/// Reads Backlog board state from the scheduler application layer.
|
||||||
|
typedef BacklogBoardReader =
|
||||||
|
Future<ApplicationResult<BacklogBoardQueryResult>> Function(
|
||||||
|
BacklogBoardReadRequest request,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Reads one Backlog task detail model from the scheduler application layer.
|
||||||
|
typedef BacklogTaskDetailReader =
|
||||||
|
Future<ApplicationResult<BacklogTaskDetailReadModel>> Function(
|
||||||
|
String taskId,
|
||||||
|
CivilDate localDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Formats a selected date for Backlog loading and empty states.
|
||||||
|
typedef BacklogDateLabelFormatter = String Function(CivilDate date);
|
||||||
|
|
||||||
|
/// Immutable controller request for a Backlog board read.
|
||||||
|
class BacklogBoardReadRequest {
|
||||||
|
/// Creates a Backlog board read request from UI filter state.
|
||||||
|
BacklogBoardReadRequest({
|
||||||
|
required this.localDate,
|
||||||
|
this.searchText,
|
||||||
|
List<BacklogFilter> filters = const <BacklogFilter>[],
|
||||||
|
this.sortKey = BacklogSortKey.priority,
|
||||||
|
this.groupMode = BacklogBoardGroupMode.none,
|
||||||
|
}) : filters = List<BacklogFilter>.unmodifiable(filters);
|
||||||
|
|
||||||
|
/// Owner-local date used for slot preview context.
|
||||||
|
final CivilDate localDate;
|
||||||
|
|
||||||
|
/// Optional search text entered by the user.
|
||||||
|
final String? searchText;
|
||||||
|
|
||||||
|
/// User-selected backlog filters.
|
||||||
|
final List<BacklogFilter> filters;
|
||||||
|
|
||||||
|
/// User-selected task ordering.
|
||||||
|
final BacklogSortKey sortKey;
|
||||||
|
|
||||||
|
/// User-selected grouping mode.
|
||||||
|
final BacklogBoardGroupMode groupMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base state for the Backlog board screen.
|
||||||
|
sealed class BacklogBoardScreenState {
|
||||||
|
/// Creates a Backlog board screen state.
|
||||||
|
const BacklogBoardScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates that the Backlog board is loading.
|
||||||
|
class BacklogBoardLoading extends BacklogBoardScreenState {
|
||||||
|
/// Creates a loading Backlog state.
|
||||||
|
const BacklogBoardLoading(this.dateLabel);
|
||||||
|
|
||||||
|
/// Display-ready date label for the read in progress.
|
||||||
|
final String dateLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates that the Backlog board has renderable data.
|
||||||
|
class BacklogBoardReady extends BacklogBoardScreenState {
|
||||||
|
/// Creates a ready Backlog state.
|
||||||
|
const BacklogBoardReady(this.data);
|
||||||
|
|
||||||
|
/// Presentation data for the Backlog board.
|
||||||
|
final BacklogBoardScreenData data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates that the Backlog board loaded with no tasks.
|
||||||
|
class BacklogBoardEmpty extends BacklogBoardScreenState {
|
||||||
|
/// Creates an empty Backlog state.
|
||||||
|
const BacklogBoardEmpty(this.data);
|
||||||
|
|
||||||
|
/// Presentation data for the empty Backlog board.
|
||||||
|
final BacklogBoardScreenData data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates that the Backlog board failed to load.
|
||||||
|
class BacklogBoardFailure extends BacklogBoardScreenState {
|
||||||
|
/// Creates a failed Backlog state.
|
||||||
|
const BacklogBoardFailure({required this.message, required this.dateLabel});
|
||||||
|
|
||||||
|
/// User-facing failure message or code.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Display-ready date label for the failed read.
|
||||||
|
final String dateLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads Backlog board data and tracks UI-local selection/filter state.
|
||||||
|
class BacklogBoardController extends ChangeNotifier {
|
||||||
|
/// Creates a Backlog board controller from read callbacks.
|
||||||
|
BacklogBoardController({
|
||||||
|
required this.readBoard,
|
||||||
|
required this.readTaskDetail,
|
||||||
|
required this.selectedDates,
|
||||||
|
required this.dateLabelFor,
|
||||||
|
}) : _state = BacklogBoardLoading(dateLabelFor(selectedDates.date)) {
|
||||||
|
selectedDates.addListener(_handleSelectedDateChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback used to read board state.
|
||||||
|
final BacklogBoardReader readBoard;
|
||||||
|
|
||||||
|
/// Callback used to read selected task detail state.
|
||||||
|
final BacklogTaskDetailReader readTaskDetail;
|
||||||
|
|
||||||
|
/// Selected planning date used for board and suggestion reads.
|
||||||
|
final SelectedDateController selectedDates;
|
||||||
|
|
||||||
|
/// Formats dates before a backend read has returned board data.
|
||||||
|
final BacklogDateLabelFormatter dateLabelFor;
|
||||||
|
|
||||||
|
/// Private state stored as `_state` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
BacklogBoardScreenState _state;
|
||||||
|
|
||||||
|
/// Private state stored as `_selectedTaskId` 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? _selectedTaskId;
|
||||||
|
|
||||||
|
/// Private state stored as `_selectedTaskDetail` for this implementation.
|
||||||
|
/// 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 `_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;
|
||||||
|
|
||||||
|
/// Private state stored as `_filters` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
List<BacklogFilter> _filters = const <BacklogFilter>[];
|
||||||
|
|
||||||
|
/// Private state stored as `_sortKey` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
BacklogSortKey _sortKey = BacklogSortKey.priority;
|
||||||
|
|
||||||
|
/// Private state stored as `_groupMode` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
BacklogBoardGroupMode _groupMode = BacklogBoardGroupMode.none;
|
||||||
|
|
||||||
|
/// Private state stored as `_loadSequence` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
var _loadSequence = 0;
|
||||||
|
|
||||||
|
/// Private state stored as `_detailSequence` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
var _detailSequence = 0;
|
||||||
|
|
||||||
|
/// Private state stored as `_isDisposed` for this implementation.
|
||||||
|
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||||
|
var _isDisposed = false;
|
||||||
|
|
||||||
|
/// Current Backlog board screen state.
|
||||||
|
BacklogBoardScreenState get state => _state;
|
||||||
|
|
||||||
|
/// Currently selected Backlog task id, if any.
|
||||||
|
String? get selectedTaskId => _selectedTaskId;
|
||||||
|
|
||||||
|
/// Current search text applied to board reads.
|
||||||
|
String? get searchText => _searchText;
|
||||||
|
|
||||||
|
/// Current filters applied to board reads.
|
||||||
|
List<BacklogFilter> get filters => _filters;
|
||||||
|
|
||||||
|
/// Current sort key applied to board reads.
|
||||||
|
BacklogSortKey get sortKey => _sortKey;
|
||||||
|
|
||||||
|
/// Current group mode applied to board reads.
|
||||||
|
BacklogBoardGroupMode get groupMode => _groupMode;
|
||||||
|
|
||||||
|
/// Loads the current Backlog board state.
|
||||||
|
Future<void> load() async {
|
||||||
|
final date = selectedDates.date;
|
||||||
|
final sequence = _nextLoadSequence();
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'Backlog board load started. date=${date.toIsoString()} sequence=$sequence',
|
||||||
|
);
|
||||||
|
_state = BacklogBoardLoading(dateLabelFor(date));
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final result = await readBoard(
|
||||||
|
BacklogBoardReadRequest(
|
||||||
|
localDate: date,
|
||||||
|
searchText: _searchText,
|
||||||
|
filters: _filters,
|
||||||
|
sortKey: _sortKey,
|
||||||
|
groupMode: _groupMode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (_isStale(sequence)) {
|
||||||
|
logger.finer(
|
||||||
|
() =>
|
||||||
|
'Ignoring stale Backlog board load result. '
|
||||||
|
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final failure = result.failure;
|
||||||
|
if (failure != null) {
|
||||||
|
logger.warn(
|
||||||
|
() =>
|
||||||
|
'Backlog board load returned failure. '
|
||||||
|
'date=${date.toIsoString()} code=${failure.code.name} '
|
||||||
|
'detailCode=${failure.detailCode} entityId=${failure.entityId}',
|
||||||
|
);
|
||||||
|
_state = BacklogBoardFailure(
|
||||||
|
message: failure.detailCode ?? failure.code.name,
|
||||||
|
dateLabel: dateLabelFor(date),
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final board = result.requireValue;
|
||||||
|
_syncSelectedTask(board);
|
||||||
|
final data = BacklogBoardScreenData(
|
||||||
|
board: board,
|
||||||
|
selectedTaskDetail: _selectedTaskDetail,
|
||||||
|
);
|
||||||
|
_state = board.summary.totalTaskCount == 0
|
||||||
|
? BacklogBoardEmpty(data)
|
||||||
|
: BacklogBoardReady(data);
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'Backlog board load finished. date=${date.toIsoString()} '
|
||||||
|
'sequence=$sequence taskCount=${board.summary.totalTaskCount}',
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
} on Object catch (error, stackTrace) {
|
||||||
|
if (_isStale(sequence)) {
|
||||||
|
logger.finer(
|
||||||
|
() =>
|
||||||
|
'Ignoring stale Backlog board load exception. '
|
||||||
|
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error(
|
||||||
|
() =>
|
||||||
|
'Backlog board load threw unexpectedly. date=${date.toIsoString()} sequence=$sequence',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
_state = BacklogBoardFailure(
|
||||||
|
message: error.toString(),
|
||||||
|
dateLabel: dateLabelFor(date),
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects [taskId] and requests drawer detail data.
|
||||||
|
Future<void> selectTask(String taskId) async {
|
||||||
|
if (_selectedTaskId == taskId && _selectedTaskDetail != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_selectedTaskId = taskId;
|
||||||
|
_selectedTaskDetail = null;
|
||||||
|
notifyListeners();
|
||||||
|
final sequence = _nextDetailSequence();
|
||||||
|
final date = selectedDates.date;
|
||||||
|
logger.finer(
|
||||||
|
() =>
|
||||||
|
'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;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_selectedTaskDetail = result.requireValue;
|
||||||
|
_refreshDataState();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears UI-local task selection.
|
||||||
|
void clearSelection() {
|
||||||
|
if (_selectedTaskId == null && _selectedTaskDetail == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_selectedTaskId = null;
|
||||||
|
_selectedTaskDetail = null;
|
||||||
|
_detailSequence += 1;
|
||||||
|
_refreshDataState();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates search text and reloads the board.
|
||||||
|
Future<void> updateSearchText(String? value) async {
|
||||||
|
final trimmed = value?.trim();
|
||||||
|
final next = trimmed == null || trimmed.isEmpty ? null : trimmed;
|
||||||
|
if (next == _searchText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_searchText = next;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates selected filters and reloads the board.
|
||||||
|
Future<void> updateFilters(List<BacklogFilter> value) async {
|
||||||
|
_filters = List<BacklogFilter>.unmodifiable(value);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates sort key and reloads the board.
|
||||||
|
Future<void> updateSortKey(BacklogSortKey value) async {
|
||||||
|
if (value == _sortKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_sortKey = value;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates group mode and reloads the board.
|
||||||
|
Future<void> updateGroupMode(BacklogBoardGroupMode value) async {
|
||||||
|
if (value == _groupMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_groupMode = value;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retries the latest board read.
|
||||||
|
Future<void> retry() => load();
|
||||||
|
|
||||||
|
/// Releases selected-date listeners owned by this controller.
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_isDisposed = true;
|
||||||
|
selectedDates.removeListener(_handleSelectedDateChanged);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the `_handleSelectedDateChanged` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
void _handleSelectedDateChanged() {
|
||||||
|
logger.debug(
|
||||||
|
() =>
|
||||||
|
'Backlog board selected date changed. date=${selectedDates.date.toIsoString()}',
|
||||||
|
);
|
||||||
|
clearSelection();
|
||||||
|
unawaited(load());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the `_syncSelectedTask` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
void _syncSelectedTask(BacklogBoardQueryResult board) {
|
||||||
|
final selectedId = _selectedTaskId;
|
||||||
|
if (selectedId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final stillVisible = board.columns
|
||||||
|
.expand((column) => column.items)
|
||||||
|
.any((item) => item.taskId == selectedId);
|
||||||
|
if (stillVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_selectedTaskId = null;
|
||||||
|
_selectedTaskDetail = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the `_refreshDataState` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
void _refreshDataState() {
|
||||||
|
switch (_state) {
|
||||||
|
case BacklogBoardReady(:final data):
|
||||||
|
_state = BacklogBoardReady(
|
||||||
|
BacklogBoardScreenData(
|
||||||
|
board: data.board,
|
||||||
|
selectedTaskDetail: _selectedTaskDetail,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BacklogBoardEmpty(:final data):
|
||||||
|
_state = BacklogBoardEmpty(
|
||||||
|
BacklogBoardScreenData(
|
||||||
|
board: data.board,
|
||||||
|
selectedTaskDetail: _selectedTaskDetail,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case BacklogBoardLoading() || BacklogBoardFailure():
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the `_nextLoadSequence` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
int _nextLoadSequence() {
|
||||||
|
_loadSequence += 1;
|
||||||
|
return _loadSequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the `_nextDetailSequence` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
int _nextDetailSequence() {
|
||||||
|
_detailSequence += 1;
|
||||||
|
return _detailSequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the `_isStale` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
bool _isStale(int sequence) {
|
||||||
|
return _isDisposed || sequence != _loadSequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the `_isDetailStale` helper used inside this library.
|
||||||
|
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||||
|
bool _isDetailStale(int sequence, String taskId) {
|
||||||
|
return _isDisposed ||
|
||||||
|
sequence != _detailSequence ||
|
||||||
|
taskId != _selectedTaskId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Defines Backlog board screen presentation data.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
/// Presentation data consumed by the Backlog board screen shell.
|
||||||
|
class BacklogBoardScreenData {
|
||||||
|
/// Creates Backlog screen data from a board query result and optional detail.
|
||||||
|
const BacklogBoardScreenData({required this.board, this.selectedTaskDetail});
|
||||||
|
|
||||||
|
/// Public scheduler read model for the current Backlog board.
|
||||||
|
final BacklogBoardQueryResult board;
|
||||||
|
|
||||||
|
/// Public scheduler read model for the selected task drawer, if loaded.
|
||||||
|
final BacklogTaskDetailReadModel? selectedTaskDetail;
|
||||||
|
|
||||||
|
/// Stable board columns in display order.
|
||||||
|
List<BacklogBoardColumnReadModel> get columns => board.columns;
|
||||||
|
|
||||||
|
/// Summary read model for the current board filters.
|
||||||
|
BacklogBoardSummaryReadModel get summary => board.summary;
|
||||||
|
|
||||||
|
/// Selected task id if a drawer should be considered open.
|
||||||
|
String? get selectedTaskId => selectedTaskDetail?.item.taskId;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Defines primary FocusFlow app sections.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Top-level sections selectable from the FocusFlow sidebar.
|
||||||
|
enum FocusFlowSection {
|
||||||
|
/// Today timeline and compact planning surface.
|
||||||
|
today,
|
||||||
|
|
||||||
|
/// Backlog board and backlog task detail surface.
|
||||||
|
backlog,
|
||||||
|
|
||||||
|
/// Project setup and project defaults surface.
|
||||||
|
projects,
|
||||||
|
|
||||||
|
/// Reports and review surface.
|
||||||
|
reports,
|
||||||
|
|
||||||
|
/// Settings surface.
|
||||||
|
settings,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,941 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Renders the Backlog board screen shell for the FocusFlow Flutter UI.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
import '../../controllers/backlog_board_controller.dart';
|
||||||
|
import '../../models/backlog/backlog_board_screen_data.dart';
|
||||||
|
import '../../theme/focus_flow_tokens.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});
|
||||||
|
|
||||||
|
/// Controller that owns Backlog board read and selection state.
|
||||||
|
final BacklogBoardController controller;
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
builder: (context, _) {
|
||||||
|
return switch (controller.state) {
|
||||||
|
BacklogBoardLoading() => _BacklogFrame(
|
||||||
|
taskCountLabel: '...',
|
||||||
|
controller: controller,
|
||||||
|
body: const _BacklogLoadingBody(),
|
||||||
|
),
|
||||||
|
BacklogBoardFailure(:final message) => _BacklogFrame(
|
||||||
|
taskCountLabel: '...',
|
||||||
|
controller: controller,
|
||||||
|
body: _BacklogFailureBody(
|
||||||
|
message: message,
|
||||||
|
onRetry: controller.retry,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BacklogBoardEmpty(:final data) => _BacklogFrame(
|
||||||
|
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
||||||
|
controller: controller,
|
||||||
|
body: _BacklogReadyBody(data: data, isEmpty: true),
|
||||||
|
),
|
||||||
|
BacklogBoardReady(:final data) => _BacklogFrame(
|
||||||
|
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
||||||
|
controller: controller,
|
||||||
|
body: _BacklogReadyBody(data: data),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Count label displayed beside the page title.
|
||||||
|
final String taskCountLabel;
|
||||||
|
|
||||||
|
/// Controller used by search and filter controls.
|
||||||
|
final BacklogBoardController controller;
|
||||||
|
|
||||||
|
/// Body rendered below the header controls.
|
||||||
|
final Widget body;
|
||||||
|
|
||||||
|
/// 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 Stack(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(28, 26, 24, 28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_BacklogHeader(taskCountLabel: taskCountLabel),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_BacklogControls(controller: controller),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Expanded(child: body),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: IgnorePointer(child: SizedBox.shrink())),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header row for the Backlog board shell.
|
||||||
|
class _BacklogHeader extends StatelessWidget {
|
||||||
|
/// Creates the Backlog header.
|
||||||
|
const _BacklogHeader({required this.taskCountLabel});
|
||||||
|
|
||||||
|
/// Count label displayed beside the page title.
|
||||||
|
final String taskCountLabel;
|
||||||
|
|
||||||
|
/// 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 Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Backlog',
|
||||||
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_CountPill(label: taskCountLabel),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'Choose work that fits your current energy.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: FocusFlowTokens.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
const _ModeAndSettingsControls(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header controls for Compact/Board mode and settings.
|
||||||
|
class _ModeAndSettingsControls extends StatelessWidget {
|
||||||
|
/// Creates mode and settings controls.
|
||||||
|
const _ModeAndSettingsControls();
|
||||||
|
|
||||||
|
/// 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 Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
_ModeSegment(label: 'Compact', active: false),
|
||||||
|
_ModeSegment(label: 'Board', active: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(Icons.wb_sunny_outlined, size: 18),
|
||||||
|
label: const Text('Settings'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
fixedSize: const Size(122, 42),
|
||||||
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
|
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One segment in the Backlog mode toggle.
|
||||||
|
class _ModeSegment extends StatelessWidget {
|
||||||
|
/// Creates a mode segment.
|
||||||
|
const _ModeSegment({required this.label, required this.active});
|
||||||
|
|
||||||
|
/// Segment label.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Whether this segment is selected.
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
/// 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 Container(
|
||||||
|
width: 78,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? FocusFlowTokens.glassPanelStrong : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: active
|
||||||
|
? Border.all(color: FocusFlowTokens.navBorder)
|
||||||
|
: Border.all(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: active
|
||||||
|
? FocusFlowTokens.accentMagenta
|
||||||
|
: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search, filter, sort, group, and new-task controls.
|
||||||
|
class _BacklogControls extends StatelessWidget {
|
||||||
|
/// Creates Backlog controls.
|
||||||
|
const _BacklogControls({required this.controller});
|
||||||
|
|
||||||
|
/// Controller updated by shell controls.
|
||||||
|
final BacklogBoardController controller;
|
||||||
|
|
||||||
|
/// 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 Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: TextField(
|
||||||
|
key: const ValueKey('backlog-search-field'),
|
||||||
|
onSubmitted: (value) {
|
||||||
|
unawaited(controller.updateSearchText(value));
|
||||||
|
},
|
||||||
|
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(
|
||||||
|
Icons.search,
|
||||||
|
color: FocusFlowTokens.textMuted,
|
||||||
|
),
|
||||||
|
hintText: 'Search backlog...',
|
||||||
|
hintStyle: const TextStyle(color: FocusFlowTokens.textFaint),
|
||||||
|
filled: true,
|
||||||
|
fillColor: FocusFlowTokens.panelBackground,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: FocusFlowTokens.subtleBorder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: FocusFlowTokens.accentMagenta,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const _BacklogControlButton(
|
||||||
|
icon: Icons.filter_alt_outlined,
|
||||||
|
label: 'Filters',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const _BacklogControlButton(icon: Icons.tune, label: 'Sort: Custom'),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const _BacklogControlButton(
|
||||||
|
icon: Icons.dashboard_customize_outlined,
|
||||||
|
label: 'Group: None',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const ValueKey('backlog-new-task-button'),
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('New Task'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
fixedSize: const Size(146, 44),
|
||||||
|
backgroundColor: FocusFlowTokens.accentMagenta,
|
||||||
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact outlined control button used by the Backlog shell.
|
||||||
|
class _BacklogControlButton extends StatelessWidget {
|
||||||
|
/// Creates a Backlog control button.
|
||||||
|
const _BacklogControlButton({required this.icon, required this.label});
|
||||||
|
|
||||||
|
/// Leading icon for the control.
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Control label.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// 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 OutlinedButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: Icon(icon, size: 18),
|
||||||
|
label: Text(label),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
fixedSize: const Size(138, 44),
|
||||||
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
|
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body shown while the Backlog board loads.
|
||||||
|
class _BacklogLoadingBody extends StatelessWidget {
|
||||||
|
/// Creates a loading body.
|
||||||
|
const _BacklogLoadingBody();
|
||||||
|
|
||||||
|
/// 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 const _BacklogStatePanel(
|
||||||
|
icon: Icons.hourglass_top,
|
||||||
|
title: 'Loading backlog',
|
||||||
|
message: 'Preparing your board.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body shown when the Backlog board fails to load.
|
||||||
|
class _BacklogFailureBody extends StatelessWidget {
|
||||||
|
/// Creates a failure body.
|
||||||
|
const _BacklogFailureBody({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
/// Failure message or code.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Retry callback.
|
||||||
|
final Future<void> Function() onRetry;
|
||||||
|
|
||||||
|
/// 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 _BacklogStatePanel(
|
||||||
|
icon: Icons.refresh,
|
||||||
|
title: 'Could not load backlog',
|
||||||
|
message: 'Status: $message',
|
||||||
|
action: OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(onRetry());
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ready or empty Backlog board body.
|
||||||
|
class _BacklogReadyBody extends StatelessWidget {
|
||||||
|
/// Creates a ready body.
|
||||||
|
const _BacklogReadyBody({required this.data, this.isEmpty = false});
|
||||||
|
|
||||||
|
/// Backlog board data.
|
||||||
|
final BacklogBoardScreenData data;
|
||||||
|
|
||||||
|
/// Whether the board has no tasks.
|
||||||
|
final bool isEmpty;
|
||||||
|
|
||||||
|
/// 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 Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (final column in data.columns) ...[
|
||||||
|
Expanded(child: _BacklogColumnShell(column: column)),
|
||||||
|
if (column != data.columns.last) const SizedBox(width: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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.',
|
||||||
|
)
|
||||||
|
: _BacklogSummaryShell(summary: data.summary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One Backlog board column shell.
|
||||||
|
class _BacklogColumnShell extends StatelessWidget {
|
||||||
|
/// Creates a column shell.
|
||||||
|
const _BacklogColumnShell({required this.column});
|
||||||
|
|
||||||
|
/// Column read model.
|
||||||
|
final BacklogBoardColumnReadModel column;
|
||||||
|
|
||||||
|
/// 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 accent = _accentColor(column.bucket);
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: FocusFlowTokens.panelBackground,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: accent.withValues(alpha: 0.62)),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(_bucketIcon(column.bucket), color: accent, size: 22),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
column.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_CountPill(label: '${column.count}', color: accent),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
color: FocusFlowTokens.textMuted,
|
||||||
|
tooltip: 'Add task',
|
||||||
|
iconSize: 18,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
column.subtitle,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: column.items.isEmpty
|
||||||
|
? 2
|
||||||
|
: column.items.take(4).length,
|
||||||
|
separatorBuilder: (context, index) =>
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final hasItem = column.items.isNotEmpty;
|
||||||
|
return _ColumnSkeletonCard(
|
||||||
|
accent: accent,
|
||||||
|
active: hasItem,
|
||||||
|
title: hasItem ? column.items[index].title : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: Icon(Icons.add, color: accent),
|
||||||
|
label: Text('Add task', style: TextStyle(color: accent)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder card used until Block 04 fills in card details.
|
||||||
|
class _ColumnSkeletonCard extends StatelessWidget {
|
||||||
|
/// Creates a skeleton card.
|
||||||
|
const _ColumnSkeletonCard({
|
||||||
|
required this.accent,
|
||||||
|
this.title,
|
||||||
|
this.active = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Accent color inherited from the column.
|
||||||
|
final Color accent;
|
||||||
|
|
||||||
|
/// Optional task title for a loaded board item.
|
||||||
|
final String? title;
|
||||||
|
|
||||||
|
/// Whether this skeleton represents a loaded item.
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
/// 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 Container(
|
||||||
|
height: 86,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: FocusFlowTokens.elevatedPanel.withValues(
|
||||||
|
alpha: active ? 1 : 0.55,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
border: Border.all(color: FocusFlowTokens.textFaint),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: title == null
|
||||||
|
? _SkeletonLine(widthFactor: 0.7, color: accent)
|
||||||
|
: Text(
|
||||||
|
title!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_SkeletonLine(widthFactor: active ? 0.55 : 0.42, color: accent),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_SkeletonLine(widthFactor: active ? 0.78 : 0.58, color: accent),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary panel shell for loaded board data.
|
||||||
|
class _BacklogSummaryShell extends StatelessWidget {
|
||||||
|
/// Creates a summary shell.
|
||||||
|
const _BacklogSummaryShell({required this.summary});
|
||||||
|
|
||||||
|
/// Summary read model.
|
||||||
|
final BacklogBoardSummaryReadModel summary;
|
||||||
|
|
||||||
|
/// 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.panelBackground,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.auto_awesome, color: FocusFlowTokens.accentMagenta),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Backlog summary',
|
||||||
|
style: TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.expand_less, color: FocusFlowTokens.textMuted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryMetric(
|
||||||
|
value: '${summary.totalTaskCount}',
|
||||||
|
label: 'Total tasks',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryMetric(
|
||||||
|
value: _estimatedTimeText(summary.totalEstimatedMinutes),
|
||||||
|
label: 'Estimated time',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
_SummaryDistribution(distribution: summary.priorityDistribution),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_SummaryDistribution(distribution: summary.projectDistribution),
|
||||||
|
const Spacer(),
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: FocusFlowTokens.elevatedPanel,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Text(
|
||||||
|
summary.planningTip,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textMuted,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats [minutes] as compact hour/minute text.
|
||||||
|
String _estimatedTimeText(int minutes) {
|
||||||
|
final hours = minutes ~/ 60;
|
||||||
|
final remainder = minutes % 60;
|
||||||
|
if (hours == 0) {
|
||||||
|
return '${remainder}m';
|
||||||
|
}
|
||||||
|
if (remainder == 0) {
|
||||||
|
return '${hours}h';
|
||||||
|
}
|
||||||
|
return '${hours}h ${remainder}m';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One numeric metric in the summary shell.
|
||||||
|
class _SummaryMetric extends StatelessWidget {
|
||||||
|
/// Creates a summary metric.
|
||||||
|
const _SummaryMetric({required this.value, required this.label});
|
||||||
|
|
||||||
|
/// Metric value.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
/// Metric label.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// 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 Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textFaint,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact list form of a summary distribution.
|
||||||
|
class _SummaryDistribution extends StatelessWidget {
|
||||||
|
/// Creates a summary distribution.
|
||||||
|
const _SummaryDistribution({required this.distribution});
|
||||||
|
|
||||||
|
/// Distribution read model.
|
||||||
|
final BacklogDistributionReadModel distribution;
|
||||||
|
|
||||||
|
/// 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 Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
distribution.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final entry in distribution.entries.take(5))
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
entry.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: FocusFlowTokens.textMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${entry.count}',
|
||||||
|
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reusable centered state panel.
|
||||||
|
class _BacklogStatePanel extends StatelessWidget {
|
||||||
|
/// Creates a state panel.
|
||||||
|
const _BacklogStatePanel({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.message,
|
||||||
|
this.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Leading icon.
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Panel title.
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Panel message.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Optional action widget.
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
|
/// 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 Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 360),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: FocusFlowTokens.panelBackground,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: FocusFlowTokens.accentMagenta, size: 28),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: FocusFlowTokens.textMuted),
|
||||||
|
),
|
||||||
|
if (action != null) ...[const SizedBox(height: 18), action!],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small count pill used by headers.
|
||||||
|
class _CountPill extends StatelessWidget {
|
||||||
|
/// Creates a count pill.
|
||||||
|
const _CountPill({required this.label, this.color});
|
||||||
|
|
||||||
|
/// Pill label.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Optional accent color.
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
/// 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 accent = color ?? FocusFlowTokens.textMuted;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(alpha: 0.16),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color ?? FocusFlowTokens.textPrimary,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skeleton line used by placeholder cards.
|
||||||
|
class _SkeletonLine extends StatelessWidget {
|
||||||
|
/// Creates a skeleton line.
|
||||||
|
const _SkeletonLine({required this.widthFactor, required this.color});
|
||||||
|
|
||||||
|
/// Fractional width for the line.
|
||||||
|
final double widthFactor;
|
||||||
|
|
||||||
|
/// Accent color for the line.
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// 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 FractionallySizedBox(
|
||||||
|
widthFactor: widthFactor,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.22),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a display color for [bucket].
|
||||||
|
Color _accentColor(BacklogBoardBucket bucket) {
|
||||||
|
return switch (bucket) {
|
||||||
|
BacklogBoardBucket.doNext => FocusFlowTokens.flexibleGreen,
|
||||||
|
BacklogBoardBucket.needTimeBlock => FocusFlowTokens.warningYellow,
|
||||||
|
BacklogBoardBucket.breakUpFirst => FocusFlowTokens.restPurple,
|
||||||
|
BacklogBoardBucket.notNow => FocusFlowTokens.appointmentBlue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a display icon for [bucket].
|
||||||
|
IconData _bucketIcon(BacklogBoardBucket bucket) {
|
||||||
|
return switch (bucket) {
|
||||||
|
BacklogBoardBucket.doNext => Icons.check_circle_outline,
|
||||||
|
BacklogBoardBucket.needTimeBlock => Icons.schedule,
|
||||||
|
BacklogBoardBucket.breakUpFirst => Icons.adjust,
|
||||||
|
BacklogBoardBucket.notNow => Icons.cloud_outlined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -6,12 +6,23 @@ library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/navigation/focus_flow_section.dart';
|
||||||
import '../theme/focus_flow_tokens.dart';
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
/// Static sidebar for primary FocusFlow navigation.
|
/// Sidebar for primary FocusFlow navigation.
|
||||||
class Sidebar extends StatelessWidget {
|
class Sidebar extends StatelessWidget {
|
||||||
/// Creates the FocusFlow sidebar.
|
/// Creates the FocusFlow sidebar.
|
||||||
const Sidebar({super.key});
|
const Sidebar({
|
||||||
|
required this.activeSection,
|
||||||
|
required this.onSectionSelected,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Currently active app section.
|
||||||
|
final FocusFlowSection activeSection;
|
||||||
|
|
||||||
|
/// Callback invoked when a section is selected.
|
||||||
|
final ValueChanged<FocusFlowSection> onSectionSelected;
|
||||||
|
|
||||||
/// Builds the widget subtree for this component.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
|
|
@ -29,19 +40,44 @@ class Sidebar extends StatelessWidget {
|
||||||
_NavItem(
|
_NavItem(
|
||||||
label: 'Today',
|
label: 'Today',
|
||||||
icon: Icons.calendar_today,
|
icon: Icons.calendar_today,
|
||||||
active: true,
|
section: FocusFlowSection.today,
|
||||||
onTap: () {},
|
active: activeSection == FocusFlowSection.today,
|
||||||
|
onTap: onSectionSelected,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_NavItem(label: 'Backlog', icon: Icons.layers, onTap: () {}),
|
_NavItem(
|
||||||
|
label: 'Backlog',
|
||||||
|
icon: Icons.layers,
|
||||||
|
section: FocusFlowSection.backlog,
|
||||||
|
active: activeSection == FocusFlowSection.backlog,
|
||||||
|
onTap: onSectionSelected,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_NavItem(label: 'Projects', icon: Icons.work_outline, onTap: () {}),
|
_NavItem(
|
||||||
|
label: 'Projects',
|
||||||
|
icon: Icons.work_outline,
|
||||||
|
section: FocusFlowSection.projects,
|
||||||
|
active: activeSection == FocusFlowSection.projects,
|
||||||
|
onTap: onSectionSelected,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_NavItem(label: 'Reports', icon: Icons.bar_chart, onTap: () {}),
|
_NavItem(
|
||||||
|
label: 'Reports',
|
||||||
|
icon: Icons.bar_chart,
|
||||||
|
section: FocusFlowSection.reports,
|
||||||
|
active: activeSection == FocusFlowSection.reports,
|
||||||
|
onTap: onSectionSelected,
|
||||||
|
),
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
const Divider(color: FocusFlowTokens.subtleBorder),
|
const Divider(color: FocusFlowTokens.subtleBorder),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_NavItem(label: 'Settings', icon: Icons.settings, onTap: () {}),
|
_NavItem(
|
||||||
|
label: 'Settings',
|
||||||
|
icon: Icons.settings,
|
||||||
|
section: FocusFlowSection.settings,
|
||||||
|
active: activeSection == FocusFlowSection.settings,
|
||||||
|
onTap: onSectionSelected,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -97,6 +133,7 @@ class _NavItem extends StatelessWidget {
|
||||||
const _NavItem({
|
const _NavItem({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
required this.section,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.active = false,
|
this.active = false,
|
||||||
});
|
});
|
||||||
|
|
@ -109,9 +146,13 @@ class _NavItem extends StatelessWidget {
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Stores the `section` value for this object.
|
||||||
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
final FocusFlowSection section;
|
||||||
|
|
||||||
/// Stores the `onTap` value for this object.
|
/// Stores the `onTap` value for this object.
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final VoidCallback onTap;
|
final ValueChanged<FocusFlowSection> onTap;
|
||||||
|
|
||||||
/// Stores the `active` value for this object.
|
/// Stores the `active` value for this object.
|
||||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
|
|
@ -127,9 +168,9 @@ class _NavItem extends StatelessWidget {
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
key: active ? const ValueKey('nav-today-active') : null,
|
key: active ? ValueKey('nav-${section.name}-active') : null,
|
||||||
borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius),
|
borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius),
|
||||||
onTap: onTap,
|
onTap: () => onTap(section),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 54,
|
height: 54,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/// Tests Backlog board controller behavior.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:focus_flow_flutter/controllers/backlog_board_controller.dart';
|
||||||
|
import 'package:focus_flow_flutter/controllers/selected_date_controller.dart';
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
/// Runs Backlog board controller tests.
|
||||||
|
void main() {
|
||||||
|
group('BacklogBoardController', () {
|
||||||
|
test('load reads selected date and exposes ready data', () async {
|
||||||
|
BacklogBoardReadRequest? capturedRequest;
|
||||||
|
final selectedDates = SelectedDateController(
|
||||||
|
initialDate: CivilDate(2026, 7, 7),
|
||||||
|
);
|
||||||
|
final controller = BacklogBoardController(
|
||||||
|
selectedDates: selectedDates,
|
||||||
|
dateLabelFor: (date) => date.toIsoString(),
|
||||||
|
readBoard: (request) async {
|
||||||
|
capturedRequest = request;
|
||||||
|
return ApplicationResult.success(_board(taskCount: 1));
|
||||||
|
},
|
||||||
|
readTaskDetail: _detailReader,
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
expect(capturedRequest!.localDate, CivilDate(2026, 7, 7));
|
||||||
|
final state = controller.state;
|
||||||
|
expect(state, isA<BacklogBoardReady>());
|
||||||
|
expect((state as BacklogBoardReady).data.summary.totalTaskCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('load maps empty and failure states', () 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: 0));
|
||||||
|
},
|
||||||
|
readTaskDetail: _detailReader,
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
expect(controller.state, isA<BacklogBoardEmpty>());
|
||||||
|
|
||||||
|
final failing = BacklogBoardController(
|
||||||
|
selectedDates: selectedDates,
|
||||||
|
dateLabelFor: (date) => date.toIsoString(),
|
||||||
|
readBoard: (_) async {
|
||||||
|
return ApplicationResult.failure(
|
||||||
|
const ApplicationFailure(
|
||||||
|
code: ApplicationFailureCode.validation,
|
||||||
|
detailCode: 'readFailed',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
readTaskDetail: _detailReader,
|
||||||
|
);
|
||||||
|
addTearDown(failing.dispose);
|
||||||
|
|
||||||
|
await failing.load();
|
||||||
|
final state = failing.state;
|
||||||
|
expect(state, isA<BacklogBoardFailure>());
|
||||||
|
expect((state as BacklogBoardFailure).message, 'readFailed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection loads detail without persisting selection', () async {
|
||||||
|
final selectedDates = SelectedDateController(
|
||||||
|
initialDate: CivilDate(2026, 7, 7),
|
||||||
|
);
|
||||||
|
var detailReads = 0;
|
||||||
|
final controller = BacklogBoardController(
|
||||||
|
selectedDates: selectedDates,
|
||||||
|
dateLabelFor: (date) => date.toIsoString(),
|
||||||
|
readBoard: (_) async {
|
||||||
|
return ApplicationResult.success(_board(taskCount: 1));
|
||||||
|
},
|
||||||
|
readTaskDetail: (taskId, localDate) async {
|
||||||
|
detailReads += 1;
|
||||||
|
expect(taskId, 'task-1');
|
||||||
|
expect(localDate, CivilDate(2026, 7, 7));
|
||||||
|
return ApplicationResult.success(_detail());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
await controller.selectTask('task-1');
|
||||||
|
|
||||||
|
final state = controller.state as BacklogBoardReady;
|
||||||
|
expect(detailReads, 1);
|
||||||
|
expect(state.data.selectedTaskId, 'task-1');
|
||||||
|
|
||||||
|
controller.clearSelection();
|
||||||
|
|
||||||
|
final cleared = controller.state as BacklogBoardReady;
|
||||||
|
expect(cleared.data.selectedTaskId, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test detail reader that returns a stable detail model.
|
||||||
|
Future<ApplicationResult<BacklogTaskDetailReadModel>> _detailReader(
|
||||||
|
String taskId,
|
||||||
|
CivilDate localDate,
|
||||||
|
) async {
|
||||||
|
return ApplicationResult.success(_detail());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a Backlog board query result with [taskCount].
|
||||||
|
BacklogBoardQueryResult _board({required int taskCount}) {
|
||||||
|
final items = [
|
||||||
|
for (var index = 0; index < taskCount; index += 1)
|
||||||
|
_item(id: 'task-${index + 1}'),
|
||||||
|
];
|
||||||
|
final totalEstimatedMinutes = items.fold<int>(
|
||||||
|
0,
|
||||||
|
(sum, item) => sum + (item.durationMinutes ?? 0),
|
||||||
|
);
|
||||||
|
return BacklogBoardQueryResult(
|
||||||
|
ownerId: 'owner-1',
|
||||||
|
localDate: CivilDate(2026, 7, 7),
|
||||||
|
columns: [
|
||||||
|
BacklogBoardColumnReadModel(
|
||||||
|
bucket: BacklogBoardBucket.doNext,
|
||||||
|
title: 'Do Next',
|
||||||
|
subtitle: 'Ready.',
|
||||||
|
accentToken: 'green',
|
||||||
|
items: items,
|
||||||
|
),
|
||||||
|
BacklogBoardColumnReadModel(
|
||||||
|
bucket: BacklogBoardBucket.needTimeBlock,
|
||||||
|
title: 'Need Time Block',
|
||||||
|
subtitle: 'Block.',
|
||||||
|
accentToken: 'yellow',
|
||||||
|
items: const [],
|
||||||
|
),
|
||||||
|
BacklogBoardColumnReadModel(
|
||||||
|
bucket: BacklogBoardBucket.breakUpFirst,
|
||||||
|
title: 'Break Up First',
|
||||||
|
subtitle: 'Split.',
|
||||||
|
accentToken: 'purple',
|
||||||
|
items: const [],
|
||||||
|
),
|
||||||
|
BacklogBoardColumnReadModel(
|
||||||
|
bucket: BacklogBoardBucket.notNow,
|
||||||
|
title: 'Not Now',
|
||||||
|
subtitle: 'Later.',
|
||||||
|
accentToken: 'blue',
|
||||||
|
items: const [],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
summary: BacklogBoardSummaryReadModel(
|
||||||
|
totalTaskCount: taskCount,
|
||||||
|
totalEstimatedMinutes: totalEstimatedMinutes,
|
||||||
|
priorityDistribution: BacklogDistributionReadModel(
|
||||||
|
title: 'Priority',
|
||||||
|
entries: const [],
|
||||||
|
),
|
||||||
|
projectDistribution: BacklogDistributionReadModel(
|
||||||
|
title: 'Project',
|
||||||
|
entries: const [],
|
||||||
|
),
|
||||||
|
durationDistribution: BacklogDistributionReadModel(
|
||||||
|
title: 'Duration',
|
||||||
|
entries: const [],
|
||||||
|
),
|
||||||
|
planningTip: 'Pick one task.',
|
||||||
|
),
|
||||||
|
projectOptions: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a Backlog board item for tests.
|
||||||
|
BacklogBoardItemReadModel _item({required String id}) {
|
||||||
|
return BacklogBoardItemReadModel(
|
||||||
|
taskId: id,
|
||||||
|
title: 'Task $id',
|
||||||
|
projectId: 'home',
|
||||||
|
projectName: 'Home',
|
||||||
|
projectColorToken: 'project-home',
|
||||||
|
durationMinutes: 30,
|
||||||
|
priority: PriorityLevel.medium,
|
||||||
|
reward: RewardLevel.low,
|
||||||
|
difficulty: DifficultyLevel.easy,
|
||||||
|
bucket: BacklogBoardBucket.doNext,
|
||||||
|
bucketReason: 'Ready.',
|
||||||
|
createdAt: DateTime.utc(2026, 7, 7),
|
||||||
|
updatedAt: DateTime.utc(2026, 7, 7),
|
||||||
|
childPreview: const [],
|
||||||
|
tags: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a Backlog task detail model for tests.
|
||||||
|
BacklogTaskDetailReadModel _detail() {
|
||||||
|
return BacklogTaskDetailReadModel(
|
||||||
|
item: _item(id: 'task-1'),
|
||||||
|
addedLabel: 'Added today',
|
||||||
|
tags: const [],
|
||||||
|
projectOptions: const [],
|
||||||
|
suggestedSlots: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,25 @@ void main() {
|
||||||
expect(find.byKey(const ValueKey('nav-today-active')), findsOneWidget);
|
expect(find.byKey(const ValueKey('nav-today-active')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('sidebar switches to Backlog shell', (tester) async {
|
||||||
|
await _pumpPlanApp(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Backlog').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('nav-backlog-active')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('nav-today-active')), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.text('Choose work that fits your current energy.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('backlog-search-field')), findsOneWidget);
|
||||||
|
expect(find.text('Board'), findsOneWidget);
|
||||||
|
expect(find.text('New Task'), findsOneWidget);
|
||||||
|
expect(find.text('Backlog summary'), findsOneWidget);
|
||||||
|
expect(find.text('24 tasks'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('seeded Today renders backend-backed compact mockup data', (
|
testWidgets('seeded Today renders backend-backed compact mockup data', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue