diff --git a/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md b/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md index 414539f..8ea4743 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md +++ b/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md @@ -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. diff --git a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index 3b36dc3..47bc0a7 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -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 createTodayController() { return ApplicationReadController( @@ -180,22 +213,6 @@ class DemoSchedulerComposition implements SchedulerAppComposition { ); } - /// Creates a generic read controller for the Backlog board. - UiReadController createBacklogBoardController() { - return ApplicationReadController( - 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({ diff --git a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart index 979169f..5db0339 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -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'; diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart index 15c23d8..fe6fe1b 100644 --- a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -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 { - /// 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 { /// 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 { 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 { 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 { taskId: card.id, expectedUpdatedAt: card.expectedUpdatedAt, ); - controller.clearSelection(); + todayController.clearSelection(); + } + + /// Refreshes every read surface affected by scheduler commands. + Future _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 { 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 { 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', + }; + } +} diff --git a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart index 1133e1e..7c91048 100644 --- a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart @@ -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({ diff --git a/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart b/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart index 2a30e39..f8d21f9 100644 --- a/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart +++ b/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart @@ -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, diff --git a/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart b/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart new file mode 100644 index 0000000..be2fe8a --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart @@ -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> Function( + BacklogBoardReadRequest request, + ); + +/// Reads one Backlog task detail model from the scheduler application layer. +typedef BacklogTaskDetailReader = + Future> 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 filters = const [], + this.sortKey = BacklogSortKey.priority, + this.groupMode = BacklogBoardGroupMode.none, + }) : filters = List.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 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 _filters = const []; + + /// 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 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 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 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 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 updateFilters(List value) async { + _filters = List.unmodifiable(value); + await load(); + } + + /// Updates sort key and reloads the board. + Future updateSortKey(BacklogSortKey value) async { + if (value == _sortKey) { + return; + } + _sortKey = value; + await load(); + } + + /// Updates group mode and reloads the board. + Future updateGroupMode(BacklogBoardGroupMode value) async { + if (value == _groupMode) { + return; + } + _groupMode = value; + await load(); + } + + /// Retries the latest board read. + Future 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; + } +} diff --git a/apps/focus_flow_flutter/lib/models/backlog/backlog_board_screen_data.dart b/apps/focus_flow_flutter/lib/models/backlog/backlog_board_screen_data.dart new file mode 100644 index 0000000..2d0c9ef --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/backlog/backlog_board_screen_data.dart @@ -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 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; +} diff --git a/apps/focus_flow_flutter/lib/models/navigation/focus_flow_section.dart b/apps/focus_flow_flutter/lib/models/navigation/focus_flow_section.dart new file mode 100644 index 0000000..ac7d295 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/navigation/focus_flow_section.dart @@ -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, +} diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart new file mode 100644 index 0000000..4c82f67 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart @@ -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 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, + }; +} diff --git a/apps/focus_flow_flutter/lib/widgets/sidebar.dart b/apps/focus_flow_flutter/lib/widgets/sidebar.dart index ab7a204..a2cc883 100644 --- a/apps/focus_flow_flutter/lib/widgets/sidebar.dart +++ b/apps/focus_flow_flutter/lib/widgets/sidebar.dart @@ -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 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 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), diff --git a/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart b/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart new file mode 100644 index 0000000..44ff677 --- /dev/null +++ b/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart @@ -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()); + 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()); + + 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()); + 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> _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( + 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 [], + ); +} diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 3f53e5d..51ddee8 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -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 {