// SPDX-FileCopyrightText: 2026 FocusFlow contributors // SPDX-License-Identifier: AGPL-3.0-only /// Coordinates Backlog board controller state for the FocusFlow Flutter UI. library; import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:scheduler_core/scheduler_core.dart'; import '../models/backlog/backlog_board_screen_data.dart'; import 'selected_date_controller.dart'; /// Reads Backlog board state from the scheduler application layer. typedef BacklogBoardReader = Future> Function( BacklogBoardReadRequest request, ); /// Reads one Backlog task detail model from the scheduler application layer. typedef BacklogTaskDetailReader = Future> Function( String taskId, CivilDate localDate, ); /// Formats a selected date for Backlog loading and empty states. typedef BacklogDateLabelFormatter = String Function(CivilDate date); /// Immutable controller request for a Backlog board read. class BacklogBoardReadRequest { /// Creates a Backlog board read request from UI filter state. BacklogBoardReadRequest({ required this.localDate, this.searchText, List filters = const [], this.boardFilters = const BacklogBoardFilterSelection.empty(), this.sortKey = BacklogSortKey.custom, this.groupMode = BacklogBoardGroupMode.none, }) : filters = List.unmodifiable(filters); /// Owner-local date used for slot preview context. final CivilDate localDate; /// Optional search text entered by the user. final String? searchText; /// User-selected backlog filters. final List filters; /// User-selected 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 _filters = const []; /// 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 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 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 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 updateSearchText(String? value) async { final trimmed = value?.trim(); final next = trimmed == null || trimmed.isEmpty ? null : trimmed; if (next == _searchText) { return; } _searchText = next; await load(); } /// Updates selected filters and reloads the board. Future updateFilters(List value) async { _filters = List.unmodifiable(value); await load(); } /// Updates legacy and board-specific filters together and reloads once. Future updateFilterState({ required List filters, required BacklogBoardFilterSelection boardFilters, }) async { _filters = List.unmodifiable(filters); _boardFilters = boardFilters; await load(); } /// Updates board-specific filters and reloads the board. Future updateBoardFilters(BacklogBoardFilterSelection value) async { _boardFilters = value; await load(); } /// Updates sort key and reloads the board. Future updateSortKey(BacklogSortKey value) async { if (value == _sortKey) { return; } _sortKey = value; await load(); } /// Updates group mode and reloads the board. Future updateGroupMode(BacklogBoardGroupMode value) async { if (value == _groupMode) { return; } _groupMode = value; await load(); } /// Retries the latest board read. Future retry() => load(); /// Releases selected-date listeners owned by this controller. @override void dispose() { _isDisposed = true; selectedDates.removeListener(_handleSelectedDateChanged); super.dispose(); } /// Runs the `_handleSelectedDateChanged` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. void _handleSelectedDateChanged() { logger.debug( () => 'Backlog board selected date changed. date=${selectedDates.date.toIsoString()}', ); clearSelection(); unawaited(load()); } /// Runs the `_syncSelectedTask` helper used inside this library. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. void _syncSelectedTask(BacklogBoardQueryResult board) { final selectedId = _selectedTaskId; if (selectedId == null) { return; } final stillVisible = board.columns .expand((column) => column.items) .any((item) => item.taskId == selectedId); if (stillVisible) { return; } _selectedTaskId = null; _selectedTaskDetail = null; _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; } }