diff --git a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index 0d3a169..3509ce6 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -8,6 +8,7 @@ import 'package:scheduler_core/scheduler_core.dart'; import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_read_controller.dart'; +import '../controllers/selected_date_controller.dart'; import '../controllers/today_screen_controller.dart'; import 'scheduler_app_composition.dart'; @@ -56,12 +57,28 @@ class DemoSchedulerComposition implements SchedulerAppComposition { /// Human-readable label for [date]. @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. @override 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. factory DemoSchedulerComposition.seeded({ Clock? clock, @@ -122,9 +139,13 @@ class DemoSchedulerComposition implements SchedulerAppComposition { /// Creates the controller used by the compact Today screen mockup. @override - TodayScreenController createTodayScreenController() { + TodayScreenController createTodayScreenController({ + required SelectedDateController selectedDates, + }) { return TodayScreenController( - read: () { + selectedDates: selectedDates, + dateLabelFor: dateLabelFor, + read: (date) { return todayQuery.execute( GetTodayStateRequest( context: context('ui-plan-1-read-today'), @@ -173,11 +194,12 @@ class DemoSchedulerComposition implements SchedulerAppComposition { @override SchedulerCommandController createCommandController({ required ReadRefresh refreshReads, + required SelectedDateReader selectedDate, }) { return SchedulerCommandController( commands: commandUseCases, contextFor: context, - localDate: date, + selectedDate: selectedDate, refreshReads: refreshReads, quickCaptureProjectId: defaultProjectId, ); diff --git a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart index 482df64..8466ff4 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -9,6 +9,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../controllers/scheduler_command_controller.dart'; +import '../controllers/selected_date_controller.dart'; import '../controllers/today_screen_controller.dart'; import '../models/today_screen_models.dart'; import '../theme/focus_flow_theme.dart'; diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart index 27428c7..8b6ec14 100644 --- a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -10,6 +10,10 @@ class _FocusFlowHomeState extends State { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. late final SchedulerCommandController commandController; @@ -19,9 +23,13 @@ class _FocusFlowHomeState extends State { @override void initState() { super.initState(); - controller = widget.composition.createTodayScreenController(); + selectedDateController = widget.composition.createSelectedDateController(); + controller = widget.composition.createTodayScreenController( + selectedDates: selectedDateController, + ); commandController = widget.composition.createCommandController( refreshReads: controller.load, + selectedDate: () => selectedDateController.date, ); controller.load(); } @@ -32,6 +40,7 @@ class _FocusFlowHomeState extends State { void dispose() { commandController.dispose(); controller.dispose(); + selectedDateController.dispose(); unawaited(widget.composition.dispose()); super.dispose(); } @@ -75,10 +84,8 @@ class _FocusFlowHomeState extends State { TodayScreenFailure(:final message) => _PlanIssue( message: message, ), - TodayScreenEmpty() => _TodayScreenScaffold( - data: TodayScreenData.empty( - dateLabel: widget.composition.dateLabel, - ), + TodayScreenEmpty(:final data) => _TodayScreenScaffold( + data: data, selectedCard: controller.selectedCard, onSelectCard: controller.selectCard, onCompleteCard: _toggleCardCompletion, diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart index 89d618f..8682272 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart @@ -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. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override diff --git a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart index 857f81c..ee1fb48 100644 --- a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart @@ -10,6 +10,7 @@ import 'package:scheduler_core/scheduler_core.dart'; import 'package:scheduler_persistence_sqlite/sqlite.dart'; import '../controllers/scheduler_command_controller.dart'; +import '../controllers/selected_date_controller.dart'; import '../controllers/today_screen_controller.dart'; import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel; import 'scheduler_app_composition.dart'; @@ -106,13 +107,33 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { /// Human-readable label for [date]. @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. @override - TodayScreenController createTodayScreenController() { + TodayScreenController createTodayScreenController({ + required SelectedDateController selectedDates, + }) { return TodayScreenController( - read: () { + selectedDates: selectedDates, + dateLabelFor: dateLabelFor, + read: (date) { return todayQuery.execute( GetTodayStateRequest( context: context('ui-plan-1-read-today'), @@ -128,11 +149,12 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { @override SchedulerCommandController createCommandController({ required ReadRefresh refreshReads, + required SelectedDateReader selectedDate, }) { return SchedulerCommandController( commands: commandUseCases, contextFor: context, - localDate: date, + selectedDate: selectedDate, refreshReads: refreshReads, quickCaptureProjectId: defaultProjectId, ); diff --git a/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart b/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart index 9fa817b..2a30e39 100644 --- a/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart +++ b/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart @@ -4,7 +4,10 @@ /// Defines the app composition protocol used by FocusFlow widgets. library; +import 'package:scheduler_core/scheduler_core.dart'; + import '../controllers/scheduler_command_controller.dart'; +import '../controllers/selected_date_controller.dart'; import '../controllers/today_screen_controller.dart'; /// 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. String get dateLabel; + /// Initial selected owner-local planning date. + CivilDate get initialDate; + /// Default project id used by quick-capture commands. 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. - TodayScreenController createTodayScreenController(); + TodayScreenController createTodayScreenController({ + required SelectedDateController selectedDates, + }); /// Creates the command controller used by interactive scheduler controls. SchedulerCommandController createCommandController({ required ReadRefresh refreshReads, + required SelectedDateReader selectedDate, }); /// Releases resources owned by the composition. diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart index 275fd9b..8da3be4 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart @@ -9,3 +9,6 @@ typedef OperationContextFactory = /// Refreshes any reads that should reflect a completed command. typedef ReadRefresh = Future Function(); + +/// Reads the owner-local date that commands should target at execution time. +typedef SchedulerSelectedDateReader = CivilDate Function(); diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart index 33e2527..1714de7 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -9,7 +9,7 @@ class SchedulerCommandController extends ChangeNotifier { SchedulerCommandController({ required this.commands, required this.contextFor, - required this.localDate, + required this.selectedDate, required this.refreshReads, required this.quickCaptureProjectId, DateTime Function()? now, @@ -21,8 +21,8 @@ class SchedulerCommandController extends ChangeNotifier { /// Factory for command-scoped application operation contexts. final OperationContextFactory contextFor; - /// Local date commands should operate against. - final CivilDate localDate; + /// Reads the selected local date commands should operate against. + final SchedulerSelectedDateReader selectedDate; /// Refresh callback invoked after commands that mutate read state. final ReadRefresh refreshReads; @@ -57,8 +57,9 @@ class SchedulerCommandController extends ChangeNotifier { successMessage: 'Task captured', refreshAfterSuccess: true, action: (operationId) { + final commandAt = now(); return commands.quickCaptureToBacklog( - context: contextFor(operationId), + context: contextFor(operationId, now: commandAt), title: title, projectId: quickCaptureProjectId, ); @@ -73,9 +74,10 @@ class SchedulerCommandController extends ChangeNotifier { successMessage: 'Task added', refreshAfterSuccess: true, action: (operationId) { + final commandAt = now(); return commands.quickCaptureToNextAvailableSlot( - context: contextFor(operationId), - localDate: localDate, + context: contextFor(operationId, now: commandAt), + localDate: selectedDate(), title: _genericFlexibleTaskTitle, durationMinutes: _genericFlexibleTaskDurationMinutes, projectId: quickCaptureProjectId, @@ -95,9 +97,10 @@ class SchedulerCommandController extends ChangeNotifier { successMessage: 'Task scheduled', refreshAfterSuccess: true, action: (operationId) { + final commandAt = now(); return commands.scheduleBacklogItemToNextAvailableSlot( - context: contextFor(operationId), - localDate: localDate, + context: contextFor(operationId, now: commandAt), + localDate: selectedDate(), taskId: taskId, durationMinutes: durationMinutes, expectedUpdatedAt: expectedUpdatedAt, @@ -134,14 +137,14 @@ class SchedulerCommandController extends ChangeNotifier { return switch (taskType) { TaskType.flexible => commands.completeFlexibleTask( context: context, - localDate: localDate, + localDate: selectedDate(), taskId: taskId, expectedUpdatedAt: expectedUpdatedAt, ), TaskType.critical || TaskType.inflexible => commands.applyRequiredTaskAction( context: context, - localDate: localDate, + localDate: selectedDate(), taskId: taskId, action: RequiredTaskAction.done, expectedUpdatedAt: expectedUpdatedAt, @@ -175,7 +178,7 @@ class SchedulerCommandController extends ChangeNotifier { action: (operationId) { return commands.uncompleteTask( context: contextFor(operationId, now: occurredAt), - localDate: localDate, + localDate: selectedDate(), taskId: taskId, expectedUpdatedAt: expectedUpdatedAt, ); diff --git a/apps/focus_flow_flutter/lib/controllers/selected_date_controller.dart b/apps/focus_flow_flutter/lib/controllers/selected_date_controller.dart new file mode 100644 index 0000000..72ad816 --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/selected_date_controller.dart @@ -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)); + } +} diff --git a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart index 6994850..25927a3 100644 --- a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart @@ -4,13 +4,20 @@ /// 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> Function(); +typedef TodayStateReader = + Future> 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 { @@ -21,7 +28,10 @@ sealed class TodayScreenState { /// Indicates that the Today screen is loading. class TodayScreenLoading extends TodayScreenState { /// 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. @@ -36,49 +46,86 @@ class TodayScreenReady extends TodayScreenState { /// Indicates that the Today screen loaded successfully with no cards. class TodayScreenEmpty extends TodayScreenState { /// 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. class TodayScreenFailure extends TodayScreenState { /// 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. 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}); + TodayScreenController({ + required this.read, + required this.selectedDates, + required this.dateLabelFor, + }) : _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; + /// 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 = const TodayScreenLoading(); + 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 load() async { - _state = const TodayScreenLoading(); + final date = selectedDates.date; + final sequence = _nextLoadSequence(); + _state = TodayScreenLoading(dateLabelFor(date)); notifyListeners(); try { - final result = await read(); + final result = await read(date); + if (_isStale(sequence)) { + return; + } final failure = result.failure; if (failure != null) { - _state = TodayScreenFailure(failure.detailCode ?? failure.code.name); + _state = TodayScreenFailure( + message: failure.detailCode ?? failure.code.name, + data: _emptyDataFor(date), + ); notifyListeners(); return; } @@ -86,11 +133,17 @@ class TodayScreenController extends ChangeNotifier { final data = TodayScreenData.fromTodayState(result.requireValue); _syncSelectedCard(data); _state = data.cards.isEmpty - ? const TodayScreenEmpty() + ? TodayScreenEmpty(data) : TodayScreenReady(data); notifyListeners(); } on Object catch (error) { - _state = TodayScreenFailure(error.toString()); + if (_isStale(sequence)) { + return; + } + _state = TodayScreenFailure( + message: error.toString(), + data: _emptyDataFor(date), + ); notifyListeners(); } } @@ -113,6 +166,40 @@ class TodayScreenController extends ChangeNotifier { 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. /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. void _syncSelectedCard(TodayScreenData data) { diff --git a/apps/focus_flow_flutter/test/controllers/selected_date_controller_test.dart b/apps/focus_flow_flutter/test/controllers/selected_date_controller_test.dart new file mode 100644 index 0000000..d04e821 --- /dev/null +++ b/apps/focus_flow_flutter/test/controllers/selected_date_controller_test.dart @@ -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); + }); +} diff --git a/apps/focus_flow_flutter/test/controllers/today_screen_controller_test.dart b/apps/focus_flow_flutter/test/controllers/today_screen_controller_test.dart new file mode 100644 index 0000000..5d69921 --- /dev/null +++ b/apps/focus_flow_flutter/test/controllers/today_screen_controller_test.dart @@ -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 = []; + 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()); + 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 = []; + 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()); + 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()); + 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>(); + final secondRead = Completer>(); + 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()); + 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()); + 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 _flushAsyncWork() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +/// Builds a lightweight Today state for [date]. +TodayState _todayState( + CivilDate date, { + List 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, + ); +} diff --git a/apps/focus_flow_flutter/test/persistent_composition_test.dart b/apps/focus_flow_flutter/test/persistent_composition_test.dart index dacf0bf..8227869 100644 --- a/apps/focus_flow_flutter/test/persistent_composition_test.dart +++ b/apps/focus_flow_flutter/test/persistent_composition_test.dart @@ -40,6 +40,7 @@ void main() { final commandController = composition.createCommandController( refreshReads: () async {}, + selectedDate: () => composition.initialDate, ); addTearDown(commandController.dispose); await tester.runAsync(