Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20
13 changed files with 1960 additions and 81 deletions
|
|
@ -100,3 +100,19 @@ Freeform Tags Metadata.md` for a later migration-backed feature.
|
|||
medium reward, and easy difficulty. Finance/review freeform tags, notes text,
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,28 +187,46 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
return Scaffold(
|
||||
backgroundColor: FocusFlowTokens.appBackground,
|
||||
body: AppShell(
|
||||
sidebar: const Sidebar(),
|
||||
child: AnimatedBuilder(
|
||||
animation: controller,
|
||||
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 (controller.state) {
|
||||
return switch (todayController.state) {
|
||||
TodayScreenLoading() => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
TodayScreenFailure(:final message) => _PlanIssue(
|
||||
message: message,
|
||||
),
|
||||
TodayScreenFailure(:final message) => _PlanIssue(message: message),
|
||||
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
|
||||
data: data,
|
||||
selectedCard: controller.selectedCard,
|
||||
onSelectCard: controller.selectCard,
|
||||
selectedCard: todayController.selectedCard,
|
||||
onSelectCard: todayController.selectCard,
|
||||
onCompleteCard: _toggleCardCompletion,
|
||||
onPushToNext: _pushCardToNext,
|
||||
onPushToTomorrow: _pushCardToTomorrow,
|
||||
onPushToBacklog: _pushCardToBacklog,
|
||||
onRemoveCard: _removeCard,
|
||||
onAddTask: _addGenericFlexibleTask,
|
||||
onClearSelection: controller.clearSelection,
|
||||
onClearSelection: todayController.clearSelection,
|
||||
onPreviousDate: _selectPreviousDate,
|
||||
onNextDate: _selectNextDate,
|
||||
onChooseDate: () {
|
||||
|
|
@ -186,15 +235,15 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
),
|
||||
TodayScreenReady(:final data) => _TodayScreenScaffold(
|
||||
data: data,
|
||||
selectedCard: controller.selectedCard,
|
||||
onSelectCard: controller.selectCard,
|
||||
selectedCard: todayController.selectedCard,
|
||||
onSelectCard: todayController.selectCard,
|
||||
onCompleteCard: _toggleCardCompletion,
|
||||
onPushToNext: _pushCardToNext,
|
||||
onPushToTomorrow: _pushCardToTomorrow,
|
||||
onPushToBacklog: _pushCardToBacklog,
|
||||
onRemoveCard: _removeCard,
|
||||
onAddTask: _addGenericFlexibleTask,
|
||||
onClearSelection: controller.clearSelection,
|
||||
onClearSelection: todayController.clearSelection,
|
||||
onPreviousDate: _selectPreviousDate,
|
||||
onNextDate: _selectNextDate,
|
||||
onChooseDate: () {
|
||||
|
|
@ -203,8 +252,6 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,443 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Coordinates Backlog board controller state for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../models/backlog/backlog_board_screen_data.dart';
|
||||
import 'selected_date_controller.dart';
|
||||
|
||||
/// Reads Backlog board state from the scheduler application layer.
|
||||
typedef BacklogBoardReader =
|
||||
Future<ApplicationResult<BacklogBoardQueryResult>> Function(
|
||||
BacklogBoardReadRequest request,
|
||||
);
|
||||
|
||||
/// Reads one Backlog task detail model from the scheduler application layer.
|
||||
typedef BacklogTaskDetailReader =
|
||||
Future<ApplicationResult<BacklogTaskDetailReadModel>> Function(
|
||||
String taskId,
|
||||
CivilDate localDate,
|
||||
);
|
||||
|
||||
/// Formats a selected date for Backlog loading and empty states.
|
||||
typedef BacklogDateLabelFormatter = String Function(CivilDate date);
|
||||
|
||||
/// Immutable controller request for a Backlog board read.
|
||||
class BacklogBoardReadRequest {
|
||||
/// Creates a Backlog board read request from UI filter state.
|
||||
BacklogBoardReadRequest({
|
||||
required this.localDate,
|
||||
this.searchText,
|
||||
List<BacklogFilter> filters = const <BacklogFilter>[],
|
||||
this.sortKey = BacklogSortKey.priority,
|
||||
this.groupMode = BacklogBoardGroupMode.none,
|
||||
}) : filters = List<BacklogFilter>.unmodifiable(filters);
|
||||
|
||||
/// Owner-local date used for slot preview context.
|
||||
final CivilDate localDate;
|
||||
|
||||
/// Optional search text entered by the user.
|
||||
final String? searchText;
|
||||
|
||||
/// User-selected backlog filters.
|
||||
final List<BacklogFilter> filters;
|
||||
|
||||
/// User-selected task ordering.
|
||||
final BacklogSortKey sortKey;
|
||||
|
||||
/// User-selected grouping mode.
|
||||
final BacklogBoardGroupMode groupMode;
|
||||
}
|
||||
|
||||
/// Base state for the Backlog board screen.
|
||||
sealed class BacklogBoardScreenState {
|
||||
/// Creates a Backlog board screen state.
|
||||
const BacklogBoardScreenState();
|
||||
}
|
||||
|
||||
/// Indicates that the Backlog board is loading.
|
||||
class BacklogBoardLoading extends BacklogBoardScreenState {
|
||||
/// Creates a loading Backlog state.
|
||||
const BacklogBoardLoading(this.dateLabel);
|
||||
|
||||
/// Display-ready date label for the read in progress.
|
||||
final String dateLabel;
|
||||
}
|
||||
|
||||
/// Indicates that the Backlog board has renderable data.
|
||||
class BacklogBoardReady extends BacklogBoardScreenState {
|
||||
/// Creates a ready Backlog state.
|
||||
const BacklogBoardReady(this.data);
|
||||
|
||||
/// Presentation data for the Backlog board.
|
||||
final BacklogBoardScreenData data;
|
||||
}
|
||||
|
||||
/// Indicates that the Backlog board loaded with no tasks.
|
||||
class BacklogBoardEmpty extends BacklogBoardScreenState {
|
||||
/// Creates an empty Backlog state.
|
||||
const BacklogBoardEmpty(this.data);
|
||||
|
||||
/// Presentation data for the empty Backlog board.
|
||||
final BacklogBoardScreenData data;
|
||||
}
|
||||
|
||||
/// Indicates that the Backlog board failed to load.
|
||||
class BacklogBoardFailure extends BacklogBoardScreenState {
|
||||
/// Creates a failed Backlog state.
|
||||
const BacklogBoardFailure({required this.message, required this.dateLabel});
|
||||
|
||||
/// User-facing failure message or code.
|
||||
final String message;
|
||||
|
||||
/// Display-ready date label for the failed read.
|
||||
final String dateLabel;
|
||||
}
|
||||
|
||||
/// Loads Backlog board data and tracks UI-local selection/filter state.
|
||||
class BacklogBoardController extends ChangeNotifier {
|
||||
/// Creates a Backlog board controller from read callbacks.
|
||||
BacklogBoardController({
|
||||
required this.readBoard,
|
||||
required this.readTaskDetail,
|
||||
required this.selectedDates,
|
||||
required this.dateLabelFor,
|
||||
}) : _state = BacklogBoardLoading(dateLabelFor(selectedDates.date)) {
|
||||
selectedDates.addListener(_handleSelectedDateChanged);
|
||||
}
|
||||
|
||||
/// Callback used to read board state.
|
||||
final BacklogBoardReader readBoard;
|
||||
|
||||
/// Callback used to read selected task detail state.
|
||||
final BacklogTaskDetailReader readTaskDetail;
|
||||
|
||||
/// Selected planning date used for board and suggestion reads.
|
||||
final SelectedDateController selectedDates;
|
||||
|
||||
/// Formats dates before a backend read has returned board data.
|
||||
final BacklogDateLabelFormatter dateLabelFor;
|
||||
|
||||
/// Private state stored as `_state` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
BacklogBoardScreenState _state;
|
||||
|
||||
/// Private state stored as `_selectedTaskId` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
String? _selectedTaskId;
|
||||
|
||||
/// Private state stored as `_selectedTaskDetail` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
BacklogTaskDetailReadModel? _selectedTaskDetail;
|
||||
|
||||
/// Private state stored as `_searchText` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
String? _searchText;
|
||||
|
||||
/// Private state stored as `_filters` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
List<BacklogFilter> _filters = const <BacklogFilter>[];
|
||||
|
||||
/// Private state stored as `_sortKey` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
BacklogSortKey _sortKey = BacklogSortKey.priority;
|
||||
|
||||
/// Private state stored as `_groupMode` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
BacklogBoardGroupMode _groupMode = BacklogBoardGroupMode.none;
|
||||
|
||||
/// Private state stored as `_loadSequence` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
var _loadSequence = 0;
|
||||
|
||||
/// Private state stored as `_detailSequence` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
var _detailSequence = 0;
|
||||
|
||||
/// Private state stored as `_isDisposed` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
var _isDisposed = false;
|
||||
|
||||
/// Current Backlog board screen state.
|
||||
BacklogBoardScreenState get state => _state;
|
||||
|
||||
/// Currently selected Backlog task id, if any.
|
||||
String? get selectedTaskId => _selectedTaskId;
|
||||
|
||||
/// Current search text applied to board reads.
|
||||
String? get searchText => _searchText;
|
||||
|
||||
/// Current filters applied to board reads.
|
||||
List<BacklogFilter> get filters => _filters;
|
||||
|
||||
/// Current sort key applied to board reads.
|
||||
BacklogSortKey get sortKey => _sortKey;
|
||||
|
||||
/// Current group mode applied to board reads.
|
||||
BacklogBoardGroupMode get groupMode => _groupMode;
|
||||
|
||||
/// Loads the current Backlog board state.
|
||||
Future<void> load() async {
|
||||
final date = selectedDates.date;
|
||||
final sequence = _nextLoadSequence();
|
||||
logger.debug(
|
||||
() =>
|
||||
'Backlog board load started. date=${date.toIsoString()} sequence=$sequence',
|
||||
);
|
||||
_state = BacklogBoardLoading(dateLabelFor(date));
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await readBoard(
|
||||
BacklogBoardReadRequest(
|
||||
localDate: date,
|
||||
searchText: _searchText,
|
||||
filters: _filters,
|
||||
sortKey: _sortKey,
|
||||
groupMode: _groupMode,
|
||||
),
|
||||
);
|
||||
if (_isStale(sequence)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Backlog board load result. '
|
||||
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final failure = result.failure;
|
||||
if (failure != null) {
|
||||
logger.warn(
|
||||
() =>
|
||||
'Backlog board load returned failure. '
|
||||
'date=${date.toIsoString()} code=${failure.code.name} '
|
||||
'detailCode=${failure.detailCode} entityId=${failure.entityId}',
|
||||
);
|
||||
_state = BacklogBoardFailure(
|
||||
message: failure.detailCode ?? failure.code.name,
|
||||
dateLabel: dateLabelFor(date),
|
||||
);
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final board = result.requireValue;
|
||||
_syncSelectedTask(board);
|
||||
final data = BacklogBoardScreenData(
|
||||
board: board,
|
||||
selectedTaskDetail: _selectedTaskDetail,
|
||||
);
|
||||
_state = board.summary.totalTaskCount == 0
|
||||
? BacklogBoardEmpty(data)
|
||||
: BacklogBoardReady(data);
|
||||
logger.debug(
|
||||
() =>
|
||||
'Backlog board load finished. date=${date.toIsoString()} '
|
||||
'sequence=$sequence taskCount=${board.summary.totalTaskCount}',
|
||||
);
|
||||
notifyListeners();
|
||||
} on Object catch (error, stackTrace) {
|
||||
if (_isStale(sequence)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Backlog board load exception. '
|
||||
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.error(
|
||||
() =>
|
||||
'Backlog board load threw unexpectedly. date=${date.toIsoString()} sequence=$sequence',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_state = BacklogBoardFailure(
|
||||
message: error.toString(),
|
||||
dateLabel: dateLabelFor(date),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects [taskId] and requests drawer detail data.
|
||||
Future<void> selectTask(String taskId) async {
|
||||
if (_selectedTaskId == taskId && _selectedTaskDetail != null) {
|
||||
return;
|
||||
}
|
||||
_selectedTaskId = taskId;
|
||||
_selectedTaskDetail = null;
|
||||
notifyListeners();
|
||||
final sequence = _nextDetailSequence();
|
||||
final date = selectedDates.date;
|
||||
logger.finer(
|
||||
() =>
|
||||
'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence',
|
||||
);
|
||||
final result = await readTaskDetail(taskId, date);
|
||||
if (_isDetailStale(sequence, taskId)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Backlog detail load result. '
|
||||
'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final failure = result.failure;
|
||||
if (failure != null) {
|
||||
logger.warn(
|
||||
() =>
|
||||
'Backlog detail load returned failure. taskId=$taskId '
|
||||
'code=${failure.code.name} detailCode=${failure.detailCode}',
|
||||
);
|
||||
_selectedTaskId = null;
|
||||
_selectedTaskDetail = null;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_selectedTaskDetail = result.requireValue;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clears UI-local task selection.
|
||||
void clearSelection() {
|
||||
if (_selectedTaskId == null && _selectedTaskDetail == null) {
|
||||
return;
|
||||
}
|
||||
_selectedTaskId = null;
|
||||
_selectedTaskDetail = null;
|
||||
_detailSequence += 1;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Updates search text and reloads the board.
|
||||
Future<void> updateSearchText(String? value) async {
|
||||
final trimmed = value?.trim();
|
||||
final next = trimmed == null || trimmed.isEmpty ? null : trimmed;
|
||||
if (next == _searchText) {
|
||||
return;
|
||||
}
|
||||
_searchText = next;
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Updates selected filters and reloads the board.
|
||||
Future<void> updateFilters(List<BacklogFilter> value) async {
|
||||
_filters = List<BacklogFilter>.unmodifiable(value);
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Updates sort key and reloads the board.
|
||||
Future<void> updateSortKey(BacklogSortKey value) async {
|
||||
if (value == _sortKey) {
|
||||
return;
|
||||
}
|
||||
_sortKey = value;
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Updates group mode and reloads the board.
|
||||
Future<void> updateGroupMode(BacklogBoardGroupMode value) async {
|
||||
if (value == _groupMode) {
|
||||
return;
|
||||
}
|
||||
_groupMode = value;
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Retries the latest board read.
|
||||
Future<void> retry() => load();
|
||||
|
||||
/// Releases selected-date listeners owned by this controller.
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
selectedDates.removeListener(_handleSelectedDateChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Runs the `_handleSelectedDateChanged` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
void _handleSelectedDateChanged() {
|
||||
logger.debug(
|
||||
() =>
|
||||
'Backlog board selected date changed. date=${selectedDates.date.toIsoString()}',
|
||||
);
|
||||
clearSelection();
|
||||
unawaited(load());
|
||||
}
|
||||
|
||||
/// Runs the `_syncSelectedTask` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
void _syncSelectedTask(BacklogBoardQueryResult board) {
|
||||
final selectedId = _selectedTaskId;
|
||||
if (selectedId == null) {
|
||||
return;
|
||||
}
|
||||
final stillVisible = board.columns
|
||||
.expand((column) => column.items)
|
||||
.any((item) => item.taskId == selectedId);
|
||||
if (stillVisible) {
|
||||
return;
|
||||
}
|
||||
_selectedTaskId = null;
|
||||
_selectedTaskDetail = null;
|
||||
}
|
||||
|
||||
/// Runs the `_refreshDataState` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
void _refreshDataState() {
|
||||
switch (_state) {
|
||||
case BacklogBoardReady(:final data):
|
||||
_state = BacklogBoardReady(
|
||||
BacklogBoardScreenData(
|
||||
board: data.board,
|
||||
selectedTaskDetail: _selectedTaskDetail,
|
||||
),
|
||||
);
|
||||
case BacklogBoardEmpty(:final data):
|
||||
_state = BacklogBoardEmpty(
|
||||
BacklogBoardScreenData(
|
||||
board: data.board,
|
||||
selectedTaskDetail: _selectedTaskDetail,
|
||||
),
|
||||
);
|
||||
case BacklogBoardLoading() || BacklogBoardFailure():
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the `_nextLoadSequence` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
int _nextLoadSequence() {
|
||||
_loadSequence += 1;
|
||||
return _loadSequence;
|
||||
}
|
||||
|
||||
/// Runs the `_nextDetailSequence` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
int _nextDetailSequence() {
|
||||
_detailSequence += 1;
|
||||
return _detailSequence;
|
||||
}
|
||||
|
||||
/// Runs the `_isStale` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
bool _isStale(int sequence) {
|
||||
return _isDisposed || sequence != _loadSequence;
|
||||
}
|
||||
|
||||
/// Runs the `_isDetailStale` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
bool _isDetailStale(int sequence, String taskId) {
|
||||
return _isDisposed ||
|
||||
sequence != _detailSequence ||
|
||||
taskId != _selectedTaskId;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Defines Backlog board screen presentation data.
|
||||
library;
|
||||
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
/// Presentation data consumed by the Backlog board screen shell.
|
||||
class BacklogBoardScreenData {
|
||||
/// Creates Backlog screen data from a board query result and optional detail.
|
||||
const BacklogBoardScreenData({required this.board, this.selectedTaskDetail});
|
||||
|
||||
/// Public scheduler read model for the current Backlog board.
|
||||
final BacklogBoardQueryResult board;
|
||||
|
||||
/// Public scheduler read model for the selected task drawer, if loaded.
|
||||
final BacklogTaskDetailReadModel? selectedTaskDetail;
|
||||
|
||||
/// Stable board columns in display order.
|
||||
List<BacklogBoardColumnReadModel> get columns => board.columns;
|
||||
|
||||
/// Summary read model for the current board filters.
|
||||
BacklogBoardSummaryReadModel get summary => board.summary;
|
||||
|
||||
/// Selected task id if a drawer should be considered open.
|
||||
String? get selectedTaskId => selectedTaskDetail?.item.taskId;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Defines primary FocusFlow app sections.
|
||||
library;
|
||||
|
||||
/// Top-level sections selectable from the FocusFlow sidebar.
|
||||
enum FocusFlowSection {
|
||||
/// Today timeline and compact planning surface.
|
||||
today,
|
||||
|
||||
/// Backlog board and backlog task detail surface.
|
||||
backlog,
|
||||
|
||||
/// Project setup and project defaults surface.
|
||||
projects,
|
||||
|
||||
/// Reports and review surface.
|
||||
reports,
|
||||
|
||||
/// Settings surface.
|
||||
settings,
|
||||
}
|
||||
|
|
@ -0,0 +1,941 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Renders the Backlog board screen shell for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../../controllers/backlog_board_controller.dart';
|
||||
import '../../models/backlog/backlog_board_screen_data.dart';
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
|
||||
/// Backlog board screen backed by a public scheduler read controller.
|
||||
class BacklogBoardScreen extends StatelessWidget {
|
||||
/// Creates a Backlog board screen.
|
||||
const BacklogBoardScreen({required this.controller, super.key});
|
||||
|
||||
/// Controller that owns Backlog board read and selection state.
|
||||
final BacklogBoardController controller;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
return switch (controller.state) {
|
||||
BacklogBoardLoading() => _BacklogFrame(
|
||||
taskCountLabel: '...',
|
||||
controller: controller,
|
||||
body: const _BacklogLoadingBody(),
|
||||
),
|
||||
BacklogBoardFailure(:final message) => _BacklogFrame(
|
||||
taskCountLabel: '...',
|
||||
controller: controller,
|
||||
body: _BacklogFailureBody(
|
||||
message: message,
|
||||
onRetry: controller.retry,
|
||||
),
|
||||
),
|
||||
BacklogBoardEmpty(:final data) => _BacklogFrame(
|
||||
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
||||
controller: controller,
|
||||
body: _BacklogReadyBody(data: data, isEmpty: true),
|
||||
),
|
||||
BacklogBoardReady(:final data) => _BacklogFrame(
|
||||
taskCountLabel: '${data.summary.totalTaskCount} tasks',
|
||||
controller: controller,
|
||||
body: _BacklogReadyBody(data: data),
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Main Backlog frame containing header, controls, body, and overlay slot.
|
||||
class _BacklogFrame extends StatelessWidget {
|
||||
/// Creates the Backlog frame.
|
||||
const _BacklogFrame({
|
||||
required this.taskCountLabel,
|
||||
required this.controller,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
/// Count label displayed beside the page title.
|
||||
final String taskCountLabel;
|
||||
|
||||
/// Controller used by search and filter controls.
|
||||
final BacklogBoardController controller;
|
||||
|
||||
/// Body rendered below the header controls.
|
||||
final Widget body;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 26, 24, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_BacklogHeader(taskCountLabel: taskCountLabel),
|
||||
const SizedBox(height: 22),
|
||||
_BacklogControls(controller: controller),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Positioned.fill(child: IgnorePointer(child: SizedBox.shrink())),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Header row for the Backlog board shell.
|
||||
class _BacklogHeader extends StatelessWidget {
|
||||
/// Creates the Backlog header.
|
||||
const _BacklogHeader({required this.taskCountLabel});
|
||||
|
||||
/// Count label displayed beside the page title.
|
||||
final String taskCountLabel;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'Backlog',
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
_CountPill(label: taskCountLabel),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Choose work that fits your current energy.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
const _ModeAndSettingsControls(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Header controls for Compact/Board mode and settings.
|
||||
class _ModeAndSettingsControls extends StatelessWidget {
|
||||
/// Creates mode and settings controls.
|
||||
const _ModeAndSettingsControls();
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: const [
|
||||
_ModeSegment(label: 'Compact', active: false),
|
||||
_ModeSegment(label: 'Board', active: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.wb_sunny_outlined, size: 18),
|
||||
label: const Text('Settings'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
fixedSize: const Size(122, 42),
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One segment in the Backlog mode toggle.
|
||||
class _ModeSegment extends StatelessWidget {
|
||||
/// Creates a mode segment.
|
||||
const _ModeSegment({required this.label, required this.active});
|
||||
|
||||
/// Segment label.
|
||||
final String label;
|
||||
|
||||
/// Whether this segment is selected.
|
||||
final bool active;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 78,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? FocusFlowTokens.glassPanelStrong : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: active
|
||||
? Border.all(color: FocusFlowTokens.navBorder)
|
||||
: Border.all(color: Colors.transparent),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: active
|
||||
? FocusFlowTokens.accentMagenta
|
||||
: FocusFlowTokens.textPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Search, filter, sort, group, and new-task controls.
|
||||
class _BacklogControls extends StatelessWidget {
|
||||
/// Creates Backlog controls.
|
||||
const _BacklogControls({required this.controller});
|
||||
|
||||
/// Controller updated by shell controls.
|
||||
final BacklogBoardController controller;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: TextField(
|
||||
key: const ValueKey('backlog-search-field'),
|
||||
onSubmitted: (value) {
|
||||
unawaited(controller.updateSearchText(value));
|
||||
},
|
||||
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: FocusFlowTokens.textMuted,
|
||||
),
|
||||
hintText: 'Search backlog...',
|
||||
hintStyle: const TextStyle(color: FocusFlowTokens.textFaint),
|
||||
filled: true,
|
||||
fillColor: FocusFlowTokens.panelBackground,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(
|
||||
color: FocusFlowTokens.subtleBorder,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(
|
||||
color: FocusFlowTokens.accentMagenta,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const _BacklogControlButton(
|
||||
icon: Icons.filter_alt_outlined,
|
||||
label: 'Filters',
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const _BacklogControlButton(icon: Icons.tune, label: 'Sort: Custom'),
|
||||
const SizedBox(width: 14),
|
||||
const _BacklogControlButton(
|
||||
icon: Icons.dashboard_customize_outlined,
|
||||
label: 'Group: None',
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('backlog-new-task-button'),
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Task'),
|
||||
style: FilledButton.styleFrom(
|
||||
fixedSize: const Size(146, 44),
|
||||
backgroundColor: FocusFlowTokens.accentMagenta,
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact outlined control button used by the Backlog shell.
|
||||
class _BacklogControlButton extends StatelessWidget {
|
||||
/// Creates a Backlog control button.
|
||||
const _BacklogControlButton({required this.icon, required this.label});
|
||||
|
||||
/// Leading icon for the control.
|
||||
final IconData icon;
|
||||
|
||||
/// Control label.
|
||||
final String label;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: Icon(icon, size: 18),
|
||||
label: Text(label),
|
||||
style: OutlinedButton.styleFrom(
|
||||
fixedSize: const Size(138, 44),
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Body shown while the Backlog board loads.
|
||||
class _BacklogLoadingBody extends StatelessWidget {
|
||||
/// Creates a loading body.
|
||||
const _BacklogLoadingBody();
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const _BacklogStatePanel(
|
||||
icon: Icons.hourglass_top,
|
||||
title: 'Loading backlog',
|
||||
message: 'Preparing your board.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Body shown when the Backlog board fails to load.
|
||||
class _BacklogFailureBody extends StatelessWidget {
|
||||
/// Creates a failure body.
|
||||
const _BacklogFailureBody({required this.message, required this.onRetry});
|
||||
|
||||
/// Failure message or code.
|
||||
final String message;
|
||||
|
||||
/// Retry callback.
|
||||
final Future<void> Function() onRetry;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _BacklogStatePanel(
|
||||
icon: Icons.refresh,
|
||||
title: 'Could not load backlog',
|
||||
message: 'Status: $message',
|
||||
action: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
unawaited(onRetry());
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ready or empty Backlog board body.
|
||||
class _BacklogReadyBody extends StatelessWidget {
|
||||
/// Creates a ready body.
|
||||
const _BacklogReadyBody({required this.data, this.isEmpty = false});
|
||||
|
||||
/// Backlog board data.
|
||||
final BacklogBoardScreenData data;
|
||||
|
||||
/// Whether the board has no tasks.
|
||||
final bool isEmpty;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final column in data.columns) ...[
|
||||
Expanded(child: _BacklogColumnShell(column: column)),
|
||||
if (column != data.columns.last) const SizedBox(width: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(
|
||||
width: 292,
|
||||
child: isEmpty
|
||||
? const _BacklogStatePanel(
|
||||
icon: Icons.check_circle_outline,
|
||||
title: 'Backlog is clear',
|
||||
message: 'New tasks will appear here.',
|
||||
)
|
||||
: _BacklogSummaryShell(summary: data.summary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One Backlog board column shell.
|
||||
class _BacklogColumnShell extends StatelessWidget {
|
||||
/// Creates a column shell.
|
||||
const _BacklogColumnShell({required this.column});
|
||||
|
||||
/// Column read model.
|
||||
final BacklogBoardColumnReadModel column;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = _accentColor(column.bucket);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.62)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(_bucketIcon(column.bucket), color: accent, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
column.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
_CountPill(label: '${column.count}', color: accent),
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add),
|
||||
color: FocusFlowTokens.textMuted,
|
||||
tooltip: 'Add task',
|
||||
iconSize: 18,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
column.subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: column.items.isEmpty
|
||||
? 2
|
||||
: column.items.take(4).length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final hasItem = column.items.isNotEmpty;
|
||||
return _ColumnSkeletonCard(
|
||||
accent: accent,
|
||||
active: hasItem,
|
||||
title: hasItem ? column.items[index].title : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () {},
|
||||
icon: Icon(Icons.add, color: accent),
|
||||
label: Text('Add task', style: TextStyle(color: accent)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder card used until Block 04 fills in card details.
|
||||
class _ColumnSkeletonCard extends StatelessWidget {
|
||||
/// Creates a skeleton card.
|
||||
const _ColumnSkeletonCard({
|
||||
required this.accent,
|
||||
this.title,
|
||||
this.active = false,
|
||||
});
|
||||
|
||||
/// Accent color inherited from the column.
|
||||
final Color accent;
|
||||
|
||||
/// Optional task title for a loaded board item.
|
||||
final String? title;
|
||||
|
||||
/// Whether this skeleton represents a loaded item.
|
||||
final bool active;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 86,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.elevatedPanel.withValues(
|
||||
alpha: active ? 1 : 0.55,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(color: FocusFlowTokens.textFaint),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: title == null
|
||||
? _SkeletonLine(widthFactor: 0.7, color: accent)
|
||||
: Text(
|
||||
title!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
_SkeletonLine(widthFactor: active ? 0.55 : 0.42, color: accent),
|
||||
const SizedBox(height: 8),
|
||||
_SkeletonLine(widthFactor: active ? 0.78 : 0.58, color: accent),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary panel shell for loaded board data.
|
||||
class _BacklogSummaryShell extends StatelessWidget {
|
||||
/// Creates a summary shell.
|
||||
const _BacklogSummaryShell({required this.summary});
|
||||
|
||||
/// Summary read model.
|
||||
final BacklogBoardSummaryReadModel summary;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, color: FocusFlowTokens.accentMagenta),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Backlog summary',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(Icons.expand_less, color: FocusFlowTokens.textMuted),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryMetric(
|
||||
value: '${summary.totalTaskCount}',
|
||||
label: 'Total tasks',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _SummaryMetric(
|
||||
value: _estimatedTimeText(summary.totalEstimatedMinutes),
|
||||
label: 'Estimated time',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
_SummaryDistribution(distribution: summary.priorityDistribution),
|
||||
const SizedBox(height: 20),
|
||||
_SummaryDistribution(distribution: summary.projectDistribution),
|
||||
const Spacer(),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.elevatedPanel,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Text(
|
||||
summary.planningTip,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats [minutes] as compact hour/minute text.
|
||||
String _estimatedTimeText(int minutes) {
|
||||
final hours = minutes ~/ 60;
|
||||
final remainder = minutes % 60;
|
||||
if (hours == 0) {
|
||||
return '${remainder}m';
|
||||
}
|
||||
if (remainder == 0) {
|
||||
return '${hours}h';
|
||||
}
|
||||
return '${hours}h ${remainder}m';
|
||||
}
|
||||
}
|
||||
|
||||
/// One numeric metric in the summary shell.
|
||||
class _SummaryMetric extends StatelessWidget {
|
||||
/// Creates a summary metric.
|
||||
const _SummaryMetric({required this.value, required this.label});
|
||||
|
||||
/// Metric value.
|
||||
final String value;
|
||||
|
||||
/// Metric label.
|
||||
final String label;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textFaint,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact list form of a summary distribution.
|
||||
class _SummaryDistribution extends StatelessWidget {
|
||||
/// Creates a summary distribution.
|
||||
const _SummaryDistribution({required this.distribution});
|
||||
|
||||
/// Distribution read model.
|
||||
final BacklogDistributionReadModel distribution;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
distribution.title,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
for (final entry in distribution.entries.take(5))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: FocusFlowTokens.textMuted),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${entry.count}',
|
||||
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable centered state panel.
|
||||
class _BacklogStatePanel extends StatelessWidget {
|
||||
/// Creates a state panel.
|
||||
const _BacklogStatePanel({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.action,
|
||||
});
|
||||
|
||||
/// Leading icon.
|
||||
final IconData icon;
|
||||
|
||||
/// Panel title.
|
||||
final String title;
|
||||
|
||||
/// Panel message.
|
||||
final String message;
|
||||
|
||||
/// Optional action widget.
|
||||
final Widget? action;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: FocusFlowTokens.accentMagenta, size: 28),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: FocusFlowTokens.textMuted),
|
||||
),
|
||||
if (action != null) ...[const SizedBox(height: 18), action!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small count pill used by headers.
|
||||
class _CountPill extends StatelessWidget {
|
||||
/// Creates a count pill.
|
||||
const _CountPill({required this.label, this.color});
|
||||
|
||||
/// Pill label.
|
||||
final String label;
|
||||
|
||||
/// Optional accent color.
|
||||
final Color? color;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = color ?? FocusFlowTokens.textMuted;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color ?? FocusFlowTokens.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton line used by placeholder cards.
|
||||
class _SkeletonLine extends StatelessWidget {
|
||||
/// Creates a skeleton line.
|
||||
const _SkeletonLine({required this.widthFactor, required this.color});
|
||||
|
||||
/// Fractional width for the line.
|
||||
final double widthFactor;
|
||||
|
||||
/// Accent color for the line.
|
||||
final Color color;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FractionallySizedBox(
|
||||
widthFactor: widthFactor,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.22),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a display color for [bucket].
|
||||
Color _accentColor(BacklogBoardBucket bucket) {
|
||||
return switch (bucket) {
|
||||
BacklogBoardBucket.doNext => FocusFlowTokens.flexibleGreen,
|
||||
BacklogBoardBucket.needTimeBlock => FocusFlowTokens.warningYellow,
|
||||
BacklogBoardBucket.breakUpFirst => FocusFlowTokens.restPurple,
|
||||
BacklogBoardBucket.notNow => FocusFlowTokens.appointmentBlue,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a display icon for [bucket].
|
||||
IconData _bucketIcon(BacklogBoardBucket bucket) {
|
||||
return switch (bucket) {
|
||||
BacklogBoardBucket.doNext => Icons.check_circle_outline,
|
||||
BacklogBoardBucket.needTimeBlock => Icons.schedule,
|
||||
BacklogBoardBucket.breakUpFirst => Icons.adjust,
|
||||
BacklogBoardBucket.notNow => Icons.cloud_outlined,
|
||||
};
|
||||
}
|
||||
|
|
@ -6,12 +6,23 @@ library;
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests Backlog board controller behavior.
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_flow_flutter/controllers/backlog_board_controller.dart';
|
||||
import 'package:focus_flow_flutter/controllers/selected_date_controller.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
/// Runs Backlog board controller tests.
|
||||
void main() {
|
||||
group('BacklogBoardController', () {
|
||||
test('load reads selected date and exposes ready data', () async {
|
||||
BacklogBoardReadRequest? capturedRequest;
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
final controller = BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
readBoard: (request) async {
|
||||
capturedRequest = request;
|
||||
return ApplicationResult.success(_board(taskCount: 1));
|
||||
},
|
||||
readTaskDetail: _detailReader,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(capturedRequest!.localDate, CivilDate(2026, 7, 7));
|
||||
final state = controller.state;
|
||||
expect(state, isA<BacklogBoardReady>());
|
||||
expect((state as BacklogBoardReady).data.summary.totalTaskCount, 1);
|
||||
});
|
||||
|
||||
test('load maps empty and failure states', () async {
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
final controller = BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
readBoard: (_) async {
|
||||
return ApplicationResult.success(_board(taskCount: 0));
|
||||
},
|
||||
readTaskDetail: _detailReader,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.load();
|
||||
expect(controller.state, isA<BacklogBoardEmpty>());
|
||||
|
||||
final failing = BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
readBoard: (_) async {
|
||||
return ApplicationResult.failure(
|
||||
const ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
detailCode: 'readFailed',
|
||||
),
|
||||
);
|
||||
},
|
||||
readTaskDetail: _detailReader,
|
||||
);
|
||||
addTearDown(failing.dispose);
|
||||
|
||||
await failing.load();
|
||||
final state = failing.state;
|
||||
expect(state, isA<BacklogBoardFailure>());
|
||||
expect((state as BacklogBoardFailure).message, 'readFailed');
|
||||
});
|
||||
|
||||
test('selection loads detail without persisting selection', () async {
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
var detailReads = 0;
|
||||
final controller = BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
readBoard: (_) async {
|
||||
return ApplicationResult.success(_board(taskCount: 1));
|
||||
},
|
||||
readTaskDetail: (taskId, localDate) async {
|
||||
detailReads += 1;
|
||||
expect(taskId, 'task-1');
|
||||
expect(localDate, CivilDate(2026, 7, 7));
|
||||
return ApplicationResult.success(_detail());
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.load();
|
||||
await controller.selectTask('task-1');
|
||||
|
||||
final state = controller.state as BacklogBoardReady;
|
||||
expect(detailReads, 1);
|
||||
expect(state.data.selectedTaskId, 'task-1');
|
||||
|
||||
controller.clearSelection();
|
||||
|
||||
final cleared = controller.state as BacklogBoardReady;
|
||||
expect(cleared.data.selectedTaskId, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Test detail reader that returns a stable detail model.
|
||||
Future<ApplicationResult<BacklogTaskDetailReadModel>> _detailReader(
|
||||
String taskId,
|
||||
CivilDate localDate,
|
||||
) async {
|
||||
return ApplicationResult.success(_detail());
|
||||
}
|
||||
|
||||
/// Builds a Backlog board query result with [taskCount].
|
||||
BacklogBoardQueryResult _board({required int taskCount}) {
|
||||
final items = [
|
||||
for (var index = 0; index < taskCount; index += 1)
|
||||
_item(id: 'task-${index + 1}'),
|
||||
];
|
||||
final totalEstimatedMinutes = items.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + (item.durationMinutes ?? 0),
|
||||
);
|
||||
return BacklogBoardQueryResult(
|
||||
ownerId: 'owner-1',
|
||||
localDate: CivilDate(2026, 7, 7),
|
||||
columns: [
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.doNext,
|
||||
title: 'Do Next',
|
||||
subtitle: 'Ready.',
|
||||
accentToken: 'green',
|
||||
items: items,
|
||||
),
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.needTimeBlock,
|
||||
title: 'Need Time Block',
|
||||
subtitle: 'Block.',
|
||||
accentToken: 'yellow',
|
||||
items: const [],
|
||||
),
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.breakUpFirst,
|
||||
title: 'Break Up First',
|
||||
subtitle: 'Split.',
|
||||
accentToken: 'purple',
|
||||
items: const [],
|
||||
),
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.notNow,
|
||||
title: 'Not Now',
|
||||
subtitle: 'Later.',
|
||||
accentToken: 'blue',
|
||||
items: const [],
|
||||
),
|
||||
],
|
||||
summary: BacklogBoardSummaryReadModel(
|
||||
totalTaskCount: taskCount,
|
||||
totalEstimatedMinutes: totalEstimatedMinutes,
|
||||
priorityDistribution: BacklogDistributionReadModel(
|
||||
title: 'Priority',
|
||||
entries: const [],
|
||||
),
|
||||
projectDistribution: BacklogDistributionReadModel(
|
||||
title: 'Project',
|
||||
entries: const [],
|
||||
),
|
||||
durationDistribution: BacklogDistributionReadModel(
|
||||
title: 'Duration',
|
||||
entries: const [],
|
||||
),
|
||||
planningTip: 'Pick one task.',
|
||||
),
|
||||
projectOptions: const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a Backlog board item for tests.
|
||||
BacklogBoardItemReadModel _item({required String id}) {
|
||||
return BacklogBoardItemReadModel(
|
||||
taskId: id,
|
||||
title: 'Task $id',
|
||||
projectId: 'home',
|
||||
projectName: 'Home',
|
||||
projectColorToken: 'project-home',
|
||||
durationMinutes: 30,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
bucket: BacklogBoardBucket.doNext,
|
||||
bucketReason: 'Ready.',
|
||||
createdAt: DateTime.utc(2026, 7, 7),
|
||||
updatedAt: DateTime.utc(2026, 7, 7),
|
||||
childPreview: const [],
|
||||
tags: const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a Backlog task detail model for tests.
|
||||
BacklogTaskDetailReadModel _detail() {
|
||||
return BacklogTaskDetailReadModel(
|
||||
item: _item(id: 'task-1'),
|
||||
addedLabel: 'Added today',
|
||||
tags: const [],
|
||||
projectOptions: const [],
|
||||
suggestedSlots: const [],
|
||||
);
|
||||
}
|
||||
|
|
@ -41,6 +41,25 @@ void main() {
|
|||
expect(find.byKey(const ValueKey('nav-today-active')), findsOneWidget);
|
||||
});
|
||||
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue