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 3509ce6..efef249 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -202,6 +202,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition { selectedDate: selectedDate, refreshReads: refreshReads, quickCaptureProjectId: defaultProjectId, + operationIdPrefix: 'ui-command-${readAt.microsecondsSinceEpoch}', ); } 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 ee1fb48..a4a5343 100644 --- a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart @@ -157,6 +157,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { selectedDate: selectedDate, refreshReads: refreshReads, quickCaptureProjectId: defaultProjectId, + operationIdPrefix: 'ui-command-${readAt.microsecondsSinceEpoch}', ); } 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 1714de7..7579371 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 @@ -12,8 +12,10 @@ class SchedulerCommandController extends ChangeNotifier { required this.selectedDate, required this.refreshReads, required this.quickCaptureProjectId, + String operationIdPrefix = 'ui-command', DateTime Function()? now, - }) : now = now ?? _utcNow; + }) : _operationIdPrefix = operationIdPrefix, + now = now ?? _utcNow; /// Scheduler write use cases invoked by this controller. final V1ApplicationCommandUseCases commands; @@ -30,6 +32,10 @@ class SchedulerCommandController extends ChangeNotifier { /// Project id used by quick-capture commands. final String quickCaptureProjectId; + /// Private state stored as `_operationIdPrefix` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + final String _operationIdPrefix; + /// Clock used by direct UI commands such as marking a task complete. final DateTime Function() now; @@ -219,7 +225,7 @@ class SchedulerCommandController extends ChangeNotifier { /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. String _nextOperationId() { _sequence += 1; - return 'ui-command-$_sequence'; + return '$_operationIdPrefix-$_sequence'; } /// Runs the `_setState` helper used inside this library. diff --git a/apps/focus_flow_flutter/test/persistent_composition_test.dart b/apps/focus_flow_flutter/test/persistent_composition_test.dart index 8227869..1070f3d 100644 --- a/apps/focus_flow_flutter/test/persistent_composition_test.dart +++ b/apps/focus_flow_flutter/test/persistent_composition_test.dart @@ -80,6 +80,118 @@ void main() { ); }, ); + + testWidgets( + 'selected future-day schedule completion and uncomplete persist across reopen', + (tester) async { + final path = await _runAsync(tester, _tempDatabasePath); + final tomorrow = _date.addDays(1); + const title = 'Schedule tomorrow from selected date'; + + final selectedDate = tomorrow; + var composition = await _openComposition( + tester, + path: path, + selectedDate: selectedDate, + clock: FixedClock(_instant(8)), + ); + var commandController = composition.createCommandController( + refreshReads: () async {}, + selectedDate: () => selectedDate, + ); + await tester.runAsync( + () => commandController.quickCaptureToBacklog(title), + ); + expect(commandController.state, isA()); + + final backlogBeforeSchedule = await _readBacklog(tester, composition); + final captured = backlogBeforeSchedule.items.single.task; + await tester.runAsync( + () => commandController.scheduleBacklogItem( + taskId: captured.id, + durationMinutes: 30, + expectedUpdatedAt: captured.updatedAt, + ), + ); + expect(commandController.state, isA()); + expect((await _readBacklog(tester, composition)).items, isEmpty); + + commandController.dispose(); + await _closeComposition(tester, composition); + + composition = await _openComposition( + tester, + path: path, + selectedDate: selectedDate, + clock: FixedClock(_instant(8, 10)), + ); + var scheduled = await _readTodayItem( + tester, + composition, + tomorrow, + title, + ); + expect(scheduled.taskStatus, TaskStatus.planned); + expect(CivilDate.fromDateTime(scheduled.start!.toUtc()), tomorrow); + + commandController = composition.createCommandController( + refreshReads: () async {}, + selectedDate: () => selectedDate, + ); + await tester.runAsync( + () => commandController.completeTask( + taskId: scheduled.id, + taskType: scheduled.taskType, + ), + ); + expect(commandController.state, isA()); + commandController.dispose(); + await _closeComposition(tester, composition); + + composition = await _openComposition( + tester, + path: path, + selectedDate: selectedDate, + clock: FixedClock(_instant(8, 20)), + ); + final completed = await _readTodayItem( + tester, + composition, + tomorrow, + title, + ); + expect(completed.taskStatus, TaskStatus.completed); + expect(completed.item.completedAt, isNotNull); + + commandController = composition.createCommandController( + refreshReads: () async {}, + selectedDate: () => selectedDate, + ); + await tester.runAsync( + () => commandController.uncompleteTask(taskId: completed.id), + ); + expect(commandController.state, isA()); + commandController.dispose(); + await _closeComposition(tester, composition); + + composition = await _openComposition( + tester, + path: path, + selectedDate: selectedDate, + clock: FixedClock(_instant(8, 30)), + ); + addTearDown(() => tester.runAsync(composition.dispose)); + final reopened = await _readTodayItem( + tester, + composition, + tomorrow, + title, + ); + expect(reopened.taskStatus, TaskStatus.planned); + expect(reopened.item.completedAt, isNull); + expect((await _readBacklog(tester, composition)).items, isEmpty); + }, + ); } /// Fixed date used by persistent Flutter tests. @@ -99,6 +211,82 @@ Future _tempDatabasePath() async { return '${directory.path}/scheduler.sqlite'; } +/// Opens a persistent composition for a test database. +Future _openComposition( + WidgetTester tester, { + required String path, + required CivilDate selectedDate, + required Clock clock, +}) { + return _runAsync( + tester, + () => PersistentSchedulerComposition.open( + sqlitePath: path, + selectedDate: selectedDate, + clock: clock, + ), + ); +} + +/// Closes [composition] outside the widget-test fake async zone. +Future _closeComposition( + WidgetTester tester, + PersistentSchedulerComposition composition, +) async { + await tester.runAsync(composition.dispose); +} + +/// Reads the backlog through the management use case. +Future _readBacklog( + WidgetTester tester, + PersistentSchedulerComposition composition, +) async { + final result = await _runAsync( + tester, + () => composition.managementUseCases.getBacklog( + GetBacklogRequest( + context: composition.context('flutter-read-backlog'), + sortKey: BacklogSortKey.age, + ), + ), + ); + return result.requireValue; +} + +/// Reads one Today timeline item by [title]. +Future _readTodayItem( + WidgetTester tester, + PersistentSchedulerComposition composition, + CivilDate date, + String title, +) async { + final state = await _readToday(tester, composition, date); + return state.timelineItems.singleWhere( + (item) => item.item.displayTitle == title, + ); +} + +/// Reads Today state for [date]. +Future _readToday( + WidgetTester tester, + PersistentSchedulerComposition composition, + CivilDate date, +) async { + final result = await _runAsync( + tester, + () => composition.todayQuery.execute( + GetTodayStateRequest( + context: composition.context( + 'flutter-read-today-${date.toIsoString()}', + ), + date: date, + includeNextFlexibleItem: true, + ), + ), + ); + return result.requireValue; +} + /// Runs real async work outside the widget-test fake async zone. Future _runAsync( WidgetTester tester,