feat(backlog): add board navigation shell

This commit is contained in:
Ashley Venn 2026-07-07 13:04:40 -07:00
parent 82f1fd43b7
commit 73ef8c2543
13 changed files with 1960 additions and 81 deletions

View file

@ -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,
and exact drawer slot copy remain deferred with the approved metadata scope
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.

View file

@ -6,6 +6,7 @@ library;
import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/backlog_board_controller.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_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.
UiReadController<TodayState> createTodayController() {
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.
@override
SchedulerCommandController createCommandController({

View file

@ -9,13 +9,16 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/backlog_board_controller.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart';
import '../models/navigation/focus_flow_section.dart';
import '../models/today_screen_models.dart';
import '../theme/focus_flow_theme.dart';
import '../theme/focus_flow_tokens.dart';
import '../widgets/app_shell.dart';
import '../widgets/backlog/backlog_board_screen.dart';
import '../widgets/required_banner.dart';
import '../widgets/sidebar.dart';
import '../widgets/task_selection_modal.dart';

View file

@ -6,9 +6,13 @@ part of '../focus_flow_app.dart';
/// 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.
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.
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.
/// 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.
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.
/// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
@override
@ -25,15 +33,18 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
super.initState();
logger.debug(() => 'FocusFlow home initializing controllers.');
selectedDateController = widget.composition.createSelectedDateController();
controller = widget.composition.createTodayScreenController(
todayController = widget.composition.createTodayScreenController(
selectedDates: selectedDateController,
);
backlogController = widget.composition.createBacklogBoardController(
selectedDates: selectedDateController,
);
commandController = widget.composition.createCommandController(
refreshReads: controller.load,
refreshReads: _refreshReads,
selectedDate: () => selectedDateController.date,
);
logger.finer(() => 'FocusFlow home triggering initial Today load.');
controller.load();
todayController.load();
}
/// Releases resources owned by this object before it leaves the tree.
@ -42,7 +53,8 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
void dispose() {
logger.debug(() => 'FocusFlow home disposing controllers.');
commandController.dispose();
controller.dispose();
backlogController.dispose();
todayController.dispose();
selectedDateController.dispose();
unawaited(widget.composition.dispose());
super.dispose();
@ -114,7 +126,26 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
taskId: card.id,
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.
@ -156,58 +187,74 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
return Scaffold(
backgroundColor: FocusFlowTokens.appBackground,
body: AppShell(
sidebar: const Sidebar(),
child: AnimatedBuilder(
animation: controller,
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());
},
),
};
},
sidebar: Sidebar(
activeSection: _activeSection,
onSectionSelected: _selectSection,
),
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.
Widget _buildDatePickerTheme(BuildContext context, Widget? child) {
final theme = Theme.of(context);
@ -237,3 +284,48 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
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',
};
}
}

View file

@ -12,6 +12,7 @@ import 'package:scheduler_core/scheduler_core.dart'
show logger;
import 'package:scheduler_persistence_sqlite/sqlite.dart';
import '../controllers/backlog_board_controller.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/selected_date_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.
@override
SchedulerCommandController createCommandController({

View file

@ -6,6 +6,7 @@ library;
import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/backlog_board_controller.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart';
@ -32,6 +33,11 @@ abstract interface class SchedulerAppComposition {
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.
SchedulerCommandController createCommandController({
required ReadRefresh refreshReads,

View file

@ -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;
}
}

View file

@ -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;
}

View file

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

View file

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

View file

@ -6,12 +6,23 @@ library;
import 'package:flutter/material.dart';
import '../models/navigation/focus_flow_section.dart';
import '../theme/focus_flow_tokens.dart';
/// Static sidebar for primary FocusFlow navigation.
/// Sidebar for primary FocusFlow navigation.
class Sidebar extends StatelessWidget {
/// 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.
/// 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(
label: 'Today',
icon: Icons.calendar_today,
active: true,
onTap: () {},
section: FocusFlowSection.today,
active: activeSection == FocusFlowSection.today,
onTap: onSectionSelected,
),
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),
_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),
_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 Divider(color: FocusFlowTokens.subtleBorder),
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({
required this.label,
required this.icon,
required this.section,
required this.onTap,
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.
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.
/// 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.
/// 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(
color: Colors.transparent,
child: InkWell(
key: active ? const ValueKey('nav-today-active') : null,
key: active ? ValueKey('nav-${section.name}-active') : null,
borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius),
onTap: onTap,
onTap: () => onTap(section),
child: Container(
height: 54,
padding: const EdgeInsets.symmetric(horizontal: 16),

View file

@ -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 [],
);
}

View file

@ -41,6 +41,25 @@ void main() {
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', (
tester,
) async {