472 lines
16 KiB
Dart
472 lines
16 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 `_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;
|
|
|
|
/// 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;
|
|
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 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;
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|