forked from eva/focus-flow
feat: add selected date read controller
This commit is contained in:
parent
7a0bb72f43
commit
16c460db0a
13 changed files with 540 additions and 36 deletions
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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<FocusFlowHome> {
|
|||
@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<FocusFlowHome> {
|
|||
void dispose() {
|
||||
commandController.dispose();
|
||||
controller.dispose();
|
||||
selectedDateController.dispose();
|
||||
unawaited(widget.composition.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -75,10 +84,8 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -9,3 +9,6 @@ typedef OperationContextFactory =
|
|||
|
||||
/// Refreshes any reads that should reflect a completed command.
|
||||
typedef ReadRefresh = Future<void> Function();
|
||||
|
||||
/// Reads the owner-local date that commands should target at execution time.
|
||||
typedef SchedulerSelectedDateReader = CivilDate Function();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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.
|
||||
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<void> 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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ void main() {
|
|||
|
||||
final commandController = composition.createCommandController(
|
||||
refreshReads: () async {},
|
||||
selectedDate: () => composition.initialDate,
|
||||
);
|
||||
addTearDown(commandController.dispose);
|
||||
await tester.runAsync(
|
||||
|
|
|
|||
Loading…
Reference in a new issue