feat: add selected date read controller

This commit is contained in:
Ashley Venn 2026-07-02 08:19:16 -07:00
parent 7a0bb72f43
commit 16c460db0a
13 changed files with 540 additions and 36 deletions

View file

@ -8,6 +8,7 @@ import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart'; import '../controllers/scheduler_read_controller.dart';
import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
import 'scheduler_app_composition.dart'; import 'scheduler_app_composition.dart';
@ -56,12 +57,28 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
/// Human-readable label for [date]. /// Human-readable label for [date].
@override @override
String get dateLabel => formatSchedulerDateLabel(date); String get dateLabel => dateLabelFor(date);
/// Initial selected owner-local planning date.
@override
CivilDate get initialDate => date;
/// Default project id used by quick capture. /// Default project id used by quick capture.
@override @override
String get defaultProjectId => projectId; String get defaultProjectId => projectId;
/// Formats [date] for compact UI date labels.
@override
String dateLabelFor(CivilDate date) {
return formatSchedulerDateLabel(date);
}
/// Creates the controller that owns the visible planning date.
@override
SelectedDateController createSelectedDateController() {
return SelectedDateController(initialDate: initialDate);
}
/// Creates a deterministic seeded composition for the demo app and tests. /// Creates a deterministic seeded composition for the demo app and tests.
factory DemoSchedulerComposition.seeded({ factory DemoSchedulerComposition.seeded({
Clock? clock, Clock? clock,
@ -122,9 +139,13 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
/// Creates the controller used by the compact Today screen mockup. /// Creates the controller used by the compact Today screen mockup.
@override @override
TodayScreenController createTodayScreenController() { TodayScreenController createTodayScreenController({
required SelectedDateController selectedDates,
}) {
return TodayScreenController( return TodayScreenController(
read: () { selectedDates: selectedDates,
dateLabelFor: dateLabelFor,
read: (date) {
return todayQuery.execute( return todayQuery.execute(
GetTodayStateRequest( GetTodayStateRequest(
context: context('ui-plan-1-read-today'), context: context('ui-plan-1-read-today'),
@ -173,11 +194,12 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
@override @override
SchedulerCommandController createCommandController({ SchedulerCommandController createCommandController({
required ReadRefresh refreshReads, required ReadRefresh refreshReads,
required SelectedDateReader selectedDate,
}) { }) {
return SchedulerCommandController( return SchedulerCommandController(
commands: commandUseCases, commands: commandUseCases,
contextFor: context, contextFor: context,
localDate: date, selectedDate: selectedDate,
refreshReads: refreshReads, refreshReads: refreshReads,
quickCaptureProjectId: defaultProjectId, quickCaptureProjectId: defaultProjectId,
); );

View file

@ -9,6 +9,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
import '../models/today_screen_models.dart'; import '../models/today_screen_models.dart';
import '../theme/focus_flow_theme.dart'; import '../theme/focus_flow_theme.dart';

View file

@ -10,6 +10,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. /// 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 controller;
/// 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.
late final SelectedDateController selectedDateController;
/// Stores the `commandController` value for this object. /// Stores the `commandController` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
late final SchedulerCommandController commandController; late final SchedulerCommandController commandController;
@ -19,9 +23,13 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = widget.composition.createTodayScreenController(); selectedDateController = widget.composition.createSelectedDateController();
controller = widget.composition.createTodayScreenController(
selectedDates: selectedDateController,
);
commandController = widget.composition.createCommandController( commandController = widget.composition.createCommandController(
refreshReads: controller.load, refreshReads: controller.load,
selectedDate: () => selectedDateController.date,
); );
controller.load(); controller.load();
} }
@ -32,6 +40,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
void dispose() { void dispose() {
commandController.dispose(); commandController.dispose();
controller.dispose(); controller.dispose();
selectedDateController.dispose();
unawaited(widget.composition.dispose()); unawaited(widget.composition.dispose());
super.dispose(); super.dispose();
} }
@ -75,10 +84,8 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
TodayScreenFailure(:final message) => _PlanIssue( TodayScreenFailure(:final message) => _PlanIssue(
message: message, message: message,
), ),
TodayScreenEmpty() => _TodayScreenScaffold( TodayScreenEmpty(:final data) => _TodayScreenScaffold(
data: TodayScreenData.empty( data: data,
dateLabel: widget.composition.dateLabel,
),
selectedCard: controller.selectedCard, selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard, onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion, onCompleteCard: _toggleCardCompletion,

View file

@ -27,6 +27,18 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
}); });
} }
/// Performs the `didUpdateWidget` behavior for this scheduler component.
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
@override
void didUpdateWidget(covariant _TodayScreenScaffold oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.data.dateLabel == widget.data.dateLabel) {
return;
}
_timelineScrollTargetCardId = null;
_timelineScrollRequest = 0;
}
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override

View file

@ -10,6 +10,7 @@ import 'package:scheduler_core/scheduler_core.dart';
import 'package:scheduler_persistence_sqlite/sqlite.dart'; import 'package:scheduler_persistence_sqlite/sqlite.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel; import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel;
import 'scheduler_app_composition.dart'; import 'scheduler_app_composition.dart';
@ -106,13 +107,33 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
/// Human-readable label for [date]. /// Human-readable label for [date].
@override @override
String get dateLabel => formatSchedulerDateLabel(date); String get dateLabel => dateLabelFor(date);
/// Initial selected owner-local planning date.
@override
CivilDate get initialDate => date;
/// Formats [date] for compact UI date labels.
@override
String dateLabelFor(CivilDate date) {
return formatSchedulerDateLabel(date);
}
/// Creates the controller that owns the visible planning date.
@override
SelectedDateController createSelectedDateController() {
return SelectedDateController(initialDate: initialDate);
}
/// Creates the controller used by the compact Today screen mockup. /// Creates the controller used by the compact Today screen mockup.
@override @override
TodayScreenController createTodayScreenController() { TodayScreenController createTodayScreenController({
required SelectedDateController selectedDates,
}) {
return TodayScreenController( return TodayScreenController(
read: () { selectedDates: selectedDates,
dateLabelFor: dateLabelFor,
read: (date) {
return todayQuery.execute( return todayQuery.execute(
GetTodayStateRequest( GetTodayStateRequest(
context: context('ui-plan-1-read-today'), context: context('ui-plan-1-read-today'),
@ -128,11 +149,12 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
@override @override
SchedulerCommandController createCommandController({ SchedulerCommandController createCommandController({
required ReadRefresh refreshReads, required ReadRefresh refreshReads,
required SelectedDateReader selectedDate,
}) { }) {
return SchedulerCommandController( return SchedulerCommandController(
commands: commandUseCases, commands: commandUseCases,
contextFor: context, contextFor: context,
localDate: date, selectedDate: selectedDate,
refreshReads: refreshReads, refreshReads: refreshReads,
quickCaptureProjectId: defaultProjectId, quickCaptureProjectId: defaultProjectId,
); );

View file

@ -4,7 +4,10 @@
/// Defines the app composition protocol used by FocusFlow widgets. /// Defines the app composition protocol used by FocusFlow widgets.
library; library;
import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
/// Runtime-independent composition surface consumed by the app shell. /// Runtime-independent composition surface consumed by the app shell.
@ -12,15 +15,27 @@ abstract interface class SchedulerAppComposition {
/// Human-readable label for the selected local date. /// Human-readable label for the selected local date.
String get dateLabel; String get dateLabel;
/// Initial selected owner-local planning date.
CivilDate get initialDate;
/// Default project id used by quick-capture commands. /// Default project id used by quick-capture commands.
String get defaultProjectId; String get defaultProjectId;
/// Formats [date] for compact UI date labels.
String dateLabelFor(CivilDate date);
/// Creates the controller that owns the visible planning date.
SelectedDateController createSelectedDateController();
/// Creates the controller used by the compact Today screen. /// Creates the controller used by the compact Today screen.
TodayScreenController createTodayScreenController(); TodayScreenController createTodayScreenController({
required SelectedDateController selectedDates,
});
/// Creates the command controller used by interactive scheduler controls. /// Creates the command controller used by interactive scheduler controls.
SchedulerCommandController createCommandController({ SchedulerCommandController createCommandController({
required ReadRefresh refreshReads, required ReadRefresh refreshReads,
required SelectedDateReader selectedDate,
}); });
/// Releases resources owned by the composition. /// Releases resources owned by the composition.

View file

@ -9,3 +9,6 @@ typedef OperationContextFactory =
/// Refreshes any reads that should reflect a completed command. /// Refreshes any reads that should reflect a completed command.
typedef ReadRefresh = Future<void> Function(); typedef ReadRefresh = Future<void> Function();
/// Reads the owner-local date that commands should target at execution time.
typedef SchedulerSelectedDateReader = CivilDate Function();

View file

@ -9,7 +9,7 @@ class SchedulerCommandController extends ChangeNotifier {
SchedulerCommandController({ SchedulerCommandController({
required this.commands, required this.commands,
required this.contextFor, required this.contextFor,
required this.localDate, required this.selectedDate,
required this.refreshReads, required this.refreshReads,
required this.quickCaptureProjectId, required this.quickCaptureProjectId,
DateTime Function()? now, DateTime Function()? now,
@ -21,8 +21,8 @@ class SchedulerCommandController extends ChangeNotifier {
/// Factory for command-scoped application operation contexts. /// Factory for command-scoped application operation contexts.
final OperationContextFactory contextFor; final OperationContextFactory contextFor;
/// Local date commands should operate against. /// Reads the selected local date commands should operate against.
final CivilDate localDate; final SchedulerSelectedDateReader selectedDate;
/// Refresh callback invoked after commands that mutate read state. /// Refresh callback invoked after commands that mutate read state.
final ReadRefresh refreshReads; final ReadRefresh refreshReads;
@ -57,8 +57,9 @@ class SchedulerCommandController extends ChangeNotifier {
successMessage: 'Task captured', successMessage: 'Task captured',
refreshAfterSuccess: true, refreshAfterSuccess: true,
action: (operationId) { action: (operationId) {
final commandAt = now();
return commands.quickCaptureToBacklog( return commands.quickCaptureToBacklog(
context: contextFor(operationId), context: contextFor(operationId, now: commandAt),
title: title, title: title,
projectId: quickCaptureProjectId, projectId: quickCaptureProjectId,
); );
@ -73,9 +74,10 @@ class SchedulerCommandController extends ChangeNotifier {
successMessage: 'Task added', successMessage: 'Task added',
refreshAfterSuccess: true, refreshAfterSuccess: true,
action: (operationId) { action: (operationId) {
final commandAt = now();
return commands.quickCaptureToNextAvailableSlot( return commands.quickCaptureToNextAvailableSlot(
context: contextFor(operationId), context: contextFor(operationId, now: commandAt),
localDate: localDate, localDate: selectedDate(),
title: _genericFlexibleTaskTitle, title: _genericFlexibleTaskTitle,
durationMinutes: _genericFlexibleTaskDurationMinutes, durationMinutes: _genericFlexibleTaskDurationMinutes,
projectId: quickCaptureProjectId, projectId: quickCaptureProjectId,
@ -95,9 +97,10 @@ class SchedulerCommandController extends ChangeNotifier {
successMessage: 'Task scheduled', successMessage: 'Task scheduled',
refreshAfterSuccess: true, refreshAfterSuccess: true,
action: (operationId) { action: (operationId) {
final commandAt = now();
return commands.scheduleBacklogItemToNextAvailableSlot( return commands.scheduleBacklogItemToNextAvailableSlot(
context: contextFor(operationId), context: contextFor(operationId, now: commandAt),
localDate: localDate, localDate: selectedDate(),
taskId: taskId, taskId: taskId,
durationMinutes: durationMinutes, durationMinutes: durationMinutes,
expectedUpdatedAt: expectedUpdatedAt, expectedUpdatedAt: expectedUpdatedAt,
@ -134,14 +137,14 @@ class SchedulerCommandController extends ChangeNotifier {
return switch (taskType) { return switch (taskType) {
TaskType.flexible => commands.completeFlexibleTask( TaskType.flexible => commands.completeFlexibleTask(
context: context, context: context,
localDate: localDate, localDate: selectedDate(),
taskId: taskId, taskId: taskId,
expectedUpdatedAt: expectedUpdatedAt, expectedUpdatedAt: expectedUpdatedAt,
), ),
TaskType.critical || TaskType.critical ||
TaskType.inflexible => commands.applyRequiredTaskAction( TaskType.inflexible => commands.applyRequiredTaskAction(
context: context, context: context,
localDate: localDate, localDate: selectedDate(),
taskId: taskId, taskId: taskId,
action: RequiredTaskAction.done, action: RequiredTaskAction.done,
expectedUpdatedAt: expectedUpdatedAt, expectedUpdatedAt: expectedUpdatedAt,
@ -175,7 +178,7 @@ class SchedulerCommandController extends ChangeNotifier {
action: (operationId) { action: (operationId) {
return commands.uncompleteTask( return commands.uncompleteTask(
context: contextFor(operationId, now: occurredAt), context: contextFor(operationId, now: occurredAt),
localDate: localDate, localDate: selectedDate(),
taskId: taskId, taskId: taskId,
expectedUpdatedAt: expectedUpdatedAt, expectedUpdatedAt: expectedUpdatedAt,
); );

View file

@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Coordinates selected planning date state for FocusFlow.
library;
import 'package:flutter/foundation.dart';
import 'package:scheduler_core/scheduler_core.dart';
/// Reads the currently selected owner-local planning date.
typedef SelectedDateReader = CivilDate Function();
/// Owns the visible owner-local planning day.
class SelectedDateController extends ChangeNotifier {
/// Creates a selected-date controller initialized to [initialDate].
SelectedDateController({required CivilDate initialDate})
: _date = initialDate;
/// Private state storing the currently selected date.
CivilDate _date;
/// Current visible owner-local planning date.
CivilDate get date => _date;
/// Selects [date] as the visible planning day.
bool selectDate(CivilDate date) {
if (date == _date) {
return false;
}
_date = date;
notifyListeners();
return true;
}
/// Moves the selected planning day backward by one civil day.
bool selectPreviousDay() {
return selectDate(_date.addDays(-1));
}
/// Moves the selected planning day forward by one civil day.
bool selectNextDay() {
return selectDate(_date.addDays(1));
}
}

View file

@ -4,13 +4,20 @@
/// Coordinates Today Screen Controller state for the FocusFlow Flutter UI. /// Coordinates Today Screen Controller state for the FocusFlow Flutter UI.
library; library;
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:scheduler_core/scheduler_core.dart'; import 'package:scheduler_core/scheduler_core.dart';
import '../models/today_screen_models.dart'; import '../models/today_screen_models.dart';
import 'selected_date_controller.dart';
/// Reads Today state from the scheduler application layer. /// Reads Today state from the scheduler application layer.
typedef TodayStateReader = Future<ApplicationResult<TodayState>> Function(); 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. /// Base state for the compact Today screen.
sealed class TodayScreenState { sealed class TodayScreenState {
@ -21,7 +28,10 @@ sealed class TodayScreenState {
/// Indicates that the Today screen is loading. /// Indicates that the Today screen is loading.
class TodayScreenLoading extends TodayScreenState { class TodayScreenLoading extends TodayScreenState {
/// Creates a loading Today screen state. /// Creates a loading Today screen state.
const TodayScreenLoading(); 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. /// Indicates that the Today screen has renderable timeline data.
@ -36,49 +46,86 @@ class TodayScreenReady extends TodayScreenState {
/// Indicates that the Today screen loaded successfully with no cards. /// Indicates that the Today screen loaded successfully with no cards.
class TodayScreenEmpty extends TodayScreenState { class TodayScreenEmpty extends TodayScreenState {
/// Creates an empty Today screen state. /// Creates an empty Today screen state.
const TodayScreenEmpty(); const TodayScreenEmpty(this.data);
/// Empty presentation model for the selected date.
final TodayScreenData data;
} }
/// Indicates that the Today screen failed to load. /// Indicates that the Today screen failed to load.
class TodayScreenFailure extends TodayScreenState { class TodayScreenFailure extends TodayScreenState {
/// Creates a failure state with a displayable [message]. /// Creates a failure state with a displayable [message].
const TodayScreenFailure(this.message); const TodayScreenFailure({required this.message, required this.data});
/// User-facing failure message or code. /// User-facing failure message or code.
final String message; 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. /// Loads Today screen data and tracks selected timeline cards.
class TodayScreenController extends ChangeNotifier { class TodayScreenController extends ChangeNotifier {
/// Creates a Today screen controller from a read callback. /// Creates a Today screen controller from a read callback.
TodayScreenController({required this.read}); TodayScreenController({
required this.read,
required this.selectedDates,
required this.dateLabelFor,
}) : _state = TodayScreenLoading(dateLabelFor(selectedDates.date)) {
selectedDates.addListener(_handleSelectedDateChanged);
}
/// Callback used to read Today state. /// Callback used to read Today state.
final TodayStateReader read; 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;
/// Private state stored as `_state` for this implementation. /// 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. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
TodayScreenState _state = const TodayScreenLoading(); TodayScreenState _state;
/// Private state stored as `_selectedCard` for this implementation. /// 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. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
TimelineCardModel? _selectedCard; 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. /// Current Today screen loading/data/failure state.
TodayScreenState get state => _state; TodayScreenState get state => _state;
/// Currently selected timeline card, if a modal should be visible. /// Currently selected timeline card, if a modal should be visible.
TimelineCardModel? get selectedCard => _selectedCard; TimelineCardModel? get selectedCard => _selectedCard;
/// Current selected owner-local planning date.
CivilDate get selectedDate => selectedDates.date;
/// Loads Today state and maps it into presentation data. /// Loads Today state and maps it into presentation data.
Future<void> load() async { Future<void> load() async {
_state = const TodayScreenLoading(); final date = selectedDates.date;
final sequence = _nextLoadSequence();
_state = TodayScreenLoading(dateLabelFor(date));
notifyListeners(); notifyListeners();
try { try {
final result = await read(); final result = await read(date);
if (_isStale(sequence)) {
return;
}
final failure = result.failure; final failure = result.failure;
if (failure != null) { if (failure != null) {
_state = TodayScreenFailure(failure.detailCode ?? failure.code.name); _state = TodayScreenFailure(
message: failure.detailCode ?? failure.code.name,
data: _emptyDataFor(date),
);
notifyListeners(); notifyListeners();
return; return;
} }
@ -86,11 +133,17 @@ class TodayScreenController extends ChangeNotifier {
final data = TodayScreenData.fromTodayState(result.requireValue); final data = TodayScreenData.fromTodayState(result.requireValue);
_syncSelectedCard(data); _syncSelectedCard(data);
_state = data.cards.isEmpty _state = data.cards.isEmpty
? const TodayScreenEmpty() ? TodayScreenEmpty(data)
: TodayScreenReady(data); : TodayScreenReady(data);
notifyListeners(); notifyListeners();
} on Object catch (error) { } on Object catch (error) {
_state = TodayScreenFailure(error.toString()); if (_isStale(sequence)) {
return;
}
_state = TodayScreenFailure(
message: error.toString(),
data: _emptyDataFor(date),
);
notifyListeners(); notifyListeners();
} }
} }
@ -113,6 +166,40 @@ class TodayScreenController extends ChangeNotifier {
notifyListeners(); 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() {
_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. /// 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. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
void _syncSelectedCard(TodayScreenData data) { void _syncSelectedCard(TodayScreenData data) {

View file

@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Tests selected date controller behavior.
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:scheduler_core/scheduler_core.dart';
import 'package:focus_flow_flutter/controllers/selected_date_controller.dart';
/// Runs selected date controller tests.
void main() {
test('selectDate is a no-op when the date is unchanged', () {
final controller = SelectedDateController(
initialDate: CivilDate(2026, 7, 2),
);
addTearDown(controller.dispose);
var notifications = 0;
controller.addListener(() {
notifications += 1;
});
final changed = controller.selectDate(CivilDate(2026, 7, 2));
expect(changed, isFalse);
expect(controller.date, CivilDate(2026, 7, 2));
expect(notifications, 0);
});
test('previous and next move exactly one civil day', () {
final controller = SelectedDateController(
initialDate: CivilDate(2026, 7, 2),
);
addTearDown(controller.dispose);
expect(controller.selectPreviousDay(), isTrue);
expect(controller.date, CivilDate(2026, 7, 1));
expect(controller.selectNextDay(), isTrue);
expect(controller.date, CivilDate(2026, 7, 2));
});
test('direct selection notifies listeners once for a changed date', () {
final controller = SelectedDateController(
initialDate: CivilDate(2026, 7, 2),
);
addTearDown(controller.dispose);
var notifications = 0;
controller.addListener(() {
notifications += 1;
});
final changed = controller.selectDate(CivilDate(2026, 8, 14));
expect(changed, isTrue);
expect(controller.date, CivilDate(2026, 8, 14));
expect(notifications, 1);
});
}

View file

@ -0,0 +1,225 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Tests Today screen controller date-aware read behavior.
library;
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:scheduler_core/scheduler_core.dart';
import 'package:focus_flow_flutter/controllers/selected_date_controller.dart';
import 'package:focus_flow_flutter/controllers/today_screen_controller.dart';
/// Runs Today screen controller tests.
void main() {
test(
'load reads the selected date and shows empty data for that date',
() async {
final selectedDates = SelectedDateController(initialDate: _july2);
addTearDown(selectedDates.dispose);
final reads = <CivilDate>[];
final controller = TodayScreenController(
selectedDates: selectedDates,
dateLabelFor: _label,
read: (date) async {
reads.add(date);
return ApplicationResult.success(_todayState(date));
},
);
addTearDown(controller.dispose);
await controller.load();
expect(reads, [_july2]);
final state = controller.state;
expect(state, isA<TodayScreenEmpty>());
expect((state as TodayScreenEmpty).data.dateLabel, 'July 2, 2026');
},
);
test(
'date changes trigger distinct reads and clear selected cards',
() async {
final selectedDates = SelectedDateController(initialDate: _july2);
addTearDown(selectedDates.dispose);
final reads = <CivilDate>[];
final controller = TodayScreenController(
selectedDates: selectedDates,
dateLabelFor: _label,
read: (date) async {
reads.add(date);
return ApplicationResult.success(
_todayState(
date,
items: date == _july2 ? [_timelineItem('first-task', date)] : [],
),
);
},
);
addTearDown(controller.dispose);
await controller.load();
final ready = controller.state as TodayScreenReady;
controller.selectCard(ready.data.cards.single);
expect(controller.selectedCard?.id, 'first-task');
selectedDates.selectNextDay();
await _flushAsyncWork();
expect(reads, [_july2, _july3]);
expect(controller.selectedCard, isNull);
final state = controller.state;
expect(state, isA<TodayScreenEmpty>());
expect((state as TodayScreenEmpty).data.dateLabel, 'July 3, 2026');
},
);
test('read failures keep selected date without showing old data', () async {
final selectedDates = SelectedDateController(initialDate: _july2);
addTearDown(selectedDates.dispose);
final controller = TodayScreenController(
selectedDates: selectedDates,
dateLabelFor: _label,
read: (date) async {
return ApplicationResult.failure(
const ApplicationFailure(
code: ApplicationFailureCode.persistenceFailure,
detailCode: 'readFailed',
),
);
},
);
addTearDown(controller.dispose);
await controller.load();
expect(controller.selectedDate, _july2);
final state = controller.state;
expect(state, isA<TodayScreenFailure>());
expect((state as TodayScreenFailure).message, 'readFailed');
expect(state.data.dateLabel, 'July 2, 2026');
});
test(
'older in-flight reads cannot overwrite the latest selected date',
() async {
final selectedDates = SelectedDateController(initialDate: _july2);
addTearDown(selectedDates.dispose);
final firstRead = Completer<ApplicationResult<TodayState>>();
final secondRead = Completer<ApplicationResult<TodayState>>();
final controller = TodayScreenController(
selectedDates: selectedDates,
dateLabelFor: _label,
read: (date) {
if (date == _july2) {
return firstRead.future;
}
if (date == _july3) {
return secondRead.future;
}
throw StateError('Unexpected read date: ${date.toIsoString()}');
},
);
addTearDown(controller.dispose);
final firstLoad = controller.load();
selectedDates.selectNextDay();
await _flushAsyncWork();
secondRead.complete(ApplicationResult.success(_todayState(_july3)));
await _flushAsyncWork();
final latest = controller.state;
expect(latest, isA<TodayScreenEmpty>());
expect((latest as TodayScreenEmpty).data.dateLabel, 'July 3, 2026');
firstRead.complete(ApplicationResult.success(_todayState(_july2)));
await firstLoad;
await _flushAsyncWork();
final unchanged = controller.state;
expect(unchanged, isA<TodayScreenEmpty>());
expect((unchanged as TodayScreenEmpty).data.dateLabel, 'July 3, 2026');
},
);
}
/// First controller test date.
final _july2 = CivilDate(2026, 7, 2);
/// Next controller test date.
final _july3 = CivilDate(2026, 7, 3);
/// Formats [date] as a long month/day/year label.
String _label(CivilDate date) {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
/// Waits for microtask-scheduled controller reloads.
Future<void> _flushAsyncWork() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
/// Builds a lightweight Today state for [date].
TodayState _todayState(
CivilDate date, {
List<TodayTimelineItem> items = const [],
}) {
final dayStart = DateTime.utc(date.year, date.month, date.day);
return TodayState(
date: date,
ownerId: 'owner-1',
timeZoneId: 'UTC',
readAt: dayStart.add(const Duration(hours: 12)),
dayStart: dayStart,
dayEnd: dayStart.add(const Duration(days: 1)),
ownerSettings: OwnerSettings(ownerId: 'owner-1', timeZoneId: 'UTC'),
timelineItems: items,
compactState: const TodayCompactState(
manualCompactModeEnabled: true,
fullTimelineExpanded: false,
),
pendingNotices: const [],
);
}
/// Builds one planned timeline item on [date].
TodayTimelineItem _timelineItem(String id, CivilDate date) {
final start = DateTime.utc(date.year, date.month, date.day, 9);
return TodayTimelineItem(
item: TimelineItem(
id: id,
displayTitle: 'Task $id',
taskType: TaskType.flexible,
projectColorToken: 'project-home',
backgroundToken: TimelineBackgroundToken.flexible,
rewardIconToken: TimelineRewardIconToken.medium,
difficultyIconToken: TimelineDifficultyIconToken.medium,
showsExplicitTime: true,
quickActions: const [],
category: TimelineItemCategory.taskCard,
start: start,
end: start.add(const Duration(minutes: 30)),
durationMinutes: 30,
),
source: TodayTimelineItemSource.task,
taskStatus: TaskStatus.planned,
taskId: id,
);
}

View file

@ -40,6 +40,7 @@ void main() {
final commandController = composition.createCommandController( final commandController = composition.createCommandController(
refreshReads: () async {}, refreshReads: () async {},
selectedDate: () => composition.initialDate,
); );
addTearDown(commandController.dispose); addTearDown(commandController.dispose);
await tester.runAsync( await tester.runAsync(