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

276 lines
9.3 KiB
Dart

// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Coordinates Today Screen 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/today_screen_models.dart';
import 'selected_date_controller.dart';
/// Reads Today state from the scheduler application layer.
typedef TodayStateReader =
Future<ApplicationResult<TodayState>> Function(CivilDate date);
/// Formats a selected date for Today screen loading and empty states.
typedef TodayDateLabelFormatter = String Function(CivilDate date);
/// Base state for the compact Today screen.
sealed class TodayScreenState {
/// Creates a Today screen state.
const TodayScreenState();
}
/// Indicates that the Today screen is loading.
class TodayScreenLoading extends TodayScreenState {
/// Creates a loading Today screen state.
const TodayScreenLoading(this.dateLabel);
/// Display-ready date label for the read in progress.
final String dateLabel;
}
/// Indicates that the Today screen has renderable timeline data.
class TodayScreenReady extends TodayScreenState {
/// Creates a ready state with mapped screen [data].
const TodayScreenReady(this.data);
/// Presentation model for the Today screen.
final TodayScreenData data;
}
/// Indicates that the Today screen loaded successfully with no cards.
class TodayScreenEmpty extends TodayScreenState {
/// Creates an empty Today screen state.
const TodayScreenEmpty(this.data);
/// Empty presentation model for the selected date.
final TodayScreenData data;
}
/// Indicates that the Today screen failed to load.
class TodayScreenFailure extends TodayScreenState {
/// Creates a failure state with a displayable [message].
const TodayScreenFailure({required this.message, required this.data});
/// User-facing failure message or code.
final String message;
/// Empty presentation model for the selected date that failed to load.
final TodayScreenData data;
}
/// Loads Today screen data and tracks selected timeline cards.
class TodayScreenController extends ChangeNotifier {
/// Creates a Today screen controller from a read callback.
TodayScreenController({
required this.read,
required this.selectedDates,
required this.dateLabelFor,
DateTime Function()? now,
}) : now = now ?? _utcNow,
_state = TodayScreenLoading(dateLabelFor(selectedDates.date)) {
selectedDates.addListener(_handleSelectedDateChanged);
}
/// Callback used to read Today state.
final TodayStateReader read;
/// Selected planning date that drives Today reads.
final SelectedDateController selectedDates;
/// Formats dates before a backend read has returned screen data.
final TodayDateLabelFormatter dateLabelFor;
/// Clock used while mapping time-sensitive presentation state.
final DateTime Function() now;
/// 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.
TodayScreenState _state;
/// Private state stored as `_selectedCard` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
TimelineCardModel? _selectedCard;
/// 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 `_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 Today screen loading/data/failure state.
TodayScreenState get state => _state;
/// Currently selected timeline card, if a modal should be visible.
TimelineCardModel? get selectedCard => _selectedCard;
/// Current selected owner-local planning date.
CivilDate get selectedDate => selectedDates.date;
/// Loads Today state and maps it into presentation data.
Future<void> load() async {
final date = selectedDates.date;
final sequence = _nextLoadSequence();
logger.debug(
() =>
'Today screen load started. date=${date.toIsoString()} sequence=$sequence',
);
_state = TodayScreenLoading(dateLabelFor(date));
notifyListeners();
try {
final result = await read(date);
if (_isStale(sequence)) {
logger.finer(
() =>
'Ignoring stale Today screen load result. '
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
);
return;
}
final failure = result.failure;
if (failure != null) {
logger.warn(
() =>
'Today screen load returned failure. '
'date=${date.toIsoString()} code=${failure.code.name} '
'detailCode=${failure.detailCode} entityId=${failure.entityId}',
);
_state = TodayScreenFailure(
message: failure.detailCode ?? failure.code.name,
data: _emptyDataFor(date),
);
notifyListeners();
return;
}
final data = TodayScreenData.fromTodayState(
result.requireValue,
now: now(),
);
_syncSelectedCard(data);
_state = data.cards.isEmpty
? TodayScreenEmpty(data)
: TodayScreenReady(data);
logger.debug(
() =>
'Today screen load finished. date=${date.toIsoString()} '
'sequence=$sequence cardCount=${data.cards.length}',
);
notifyListeners();
} on Object catch (error, stackTrace) {
if (_isStale(sequence)) {
logger.finer(
() =>
'Ignoring stale Today screen load exception. '
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
);
return;
}
logger.error(
() =>
'Today screen load threw unexpectedly. date=${date.toIsoString()} sequence=$sequence',
error: error,
stackTrace: stackTrace,
);
_state = TodayScreenFailure(
message: error.toString(),
data: _emptyDataFor(date),
);
notifyListeners();
}
}
/// Selects [card] when the card allows selection.
void selectCard(TimelineCardModel card) {
if (!card.isSelectable) {
logger.finer(
() => 'Today screen card selection ignored. cardId=${card.id}',
);
return;
}
logger.finer(() => 'Today screen card selected. cardId=${card.id}');
_selectedCard = card;
notifyListeners();
}
/// Clears the selected timeline card.
void clearSelection() {
if (_selectedCard == null) {
return;
}
logger.finer(
() => 'Today screen card selection cleared. cardId=${_selectedCard!.id}',
);
_selectedCard = null;
notifyListeners();
}
/// 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(
() =>
'Today screen selected date changed. date=${selectedDates.date.toIsoString()}',
);
_selectedCard = null;
unawaited(load());
}
/// 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 `_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 `_emptyDataFor` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
TodayScreenData _emptyDataFor(CivilDate date) {
return TodayScreenData.empty(dateLabel: dateLabelFor(date));
}
/// Runs the `_syncSelectedCard` 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 _syncSelectedCard(TodayScreenData data) {
final selected = _selectedCard;
if (selected == null) {
return;
}
for (final card in data.cards) {
if (card.id == selected.id) {
_selectedCard = card;
return;
}
}
logger.finer(
() =>
'Today screen selected card no longer present. cardId=${selected.id}',
);
_selectedCard = null;
}
/// Runs the `_utcNow` helper used inside this library.
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
static DateTime _utcNow() => DateTime.now().toUtc();
}