focus-flow/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart

514 lines
18 KiB
Dart

// 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.boardFilters = const BacklogBoardFilterSelection.empty(),
this.sortKey = BacklogSortKey.custom,
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 board-specific filters.
final BacklogBoardFilterSelection boardFilters;
/// 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 `_selectedTaskDetailError` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
String? _selectedTaskDetailError;
/// Private state stored as `_searchText` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
String? _searchText;
/// 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 `_boardFilters` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
BacklogBoardFilterSelection _boardFilters =
const BacklogBoardFilterSelection.empty();
/// 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.custom;
/// 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;
/// Detail model for the currently selected Backlog task, if it has loaded.
BacklogTaskDetailReadModel? get selectedTaskDetail => _selectedTaskDetail;
/// Drawer-local error message for the selected task detail read.
String? get selectedTaskDetailError => _selectedTaskDetailError;
/// Whether a selected task detail read is currently unresolved.
bool get isSelectedTaskDetailLoading =>
_selectedTaskId != null &&
_selectedTaskDetail == null &&
_selectedTaskDetailError == null;
/// Current search text applied to board reads.
String? get searchText => _searchText;
/// Current filters applied to board reads.
List<BacklogFilter> get filters => _filters;
/// Current board-specific filters applied to board reads.
BacklogBoardFilterSelection get boardFilters => _boardFilters;
/// 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,
boardFilters: _boardFilters,
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;
_selectedTaskDetailError = null;
notifyListeners();
final sequence = _nextDetailSequence();
final date = selectedDates.date;
logger.finer(
() =>
'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence',
);
try {
final result = await readTaskDetail(taskId, date);
if (_isDetailStale(sequence, taskId)) {
logger.finer(
() =>
'Ignoring stale Backlog detail load result. '
'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence',
);
return;
}
final failure = result.failure;
if (failure != null) {
logger.warn(
() =>
'Backlog detail load returned failure. taskId=$taskId '
'code=${failure.code.name} detailCode=${failure.detailCode}',
);
_selectedTaskDetail = null;
_selectedTaskDetailError = failure.detailCode ?? failure.code.name;
_refreshDataState();
notifyListeners();
return;
}
_selectedTaskDetail = result.requireValue;
_selectedTaskDetailError = null;
_refreshDataState();
notifyListeners();
} on Object catch (error, stackTrace) {
if (_isDetailStale(sequence, taskId)) {
logger.finer(
() =>
'Ignoring stale Backlog detail load exception. '
'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence',
);
return;
}
logger.error(
() =>
'Backlog detail load threw unexpectedly. taskId=$taskId sequence=$sequence',
error: error,
stackTrace: stackTrace,
);
_selectedTaskDetail = null;
_selectedTaskDetailError = error.toString();
_refreshDataState();
notifyListeners();
}
}
/// Clears UI-local task selection.
void clearSelection() {
if (_selectedTaskId == null && _selectedTaskDetail == null) {
return;
}
_selectedTaskId = null;
_selectedTaskDetail = null;
_selectedTaskDetailError = 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 legacy and board-specific filters together and reloads once.
Future<void> updateFilterState({
required List<BacklogFilter> filters,
required BacklogBoardFilterSelection boardFilters,
}) async {
_filters = List<BacklogFilter>.unmodifiable(filters);
_boardFilters = boardFilters;
await load();
}
/// Updates board-specific filters and reloads the board.
Future<void> updateBoardFilters(BacklogBoardFilterSelection value) async {
_boardFilters = 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;
_selectedTaskDetailError = 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;
}
}