diff --git a/CONTEXT.md b/CONTEXT.md index cd85fd5e..8d50ce86 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -153,6 +153,14 @@ _Avoid_: Fallback, degraded, time sensitive The user-facing status for schedule delivery through an Alarm. _Avoid_: Notification, native alarm +**Preparation Run**: +One user attempt to prepare for a scheduled commitment. +_Avoid_: Timer session, countdown session + +**Preparation Action Event**: +A user-performed action that changes a Preparation Run. +_Avoid_: Timer tick, automatic step transition, elapsed-time snapshot + **Schedule**: A user commitment with a planned time and preparation context in OnTime. _Avoid_: Appointment, event, task @@ -209,6 +217,13 @@ _Avoid_: Loaded range, stream range, cached range - iOS with an **iOS AlarmKit Alarm** should show **Alarm Status**. - iOS without an available **iOS AlarmKit Alarm** but with notification permission should show **Notification Status**. - **No Scheduled Notification** should be the user-facing empty state across platforms, even when future delivery may use an **Alarm**. +- A **Preparation Run** records **Preparation Action Events**, not timer ticks or automatic step transitions. +- A **Preparation Action Event** may represent starting preparation, skipping a preparation step, or finishing preparation. +- A **Preparation Run** begins when the user performs the starting **Preparation Action Event**. +- A skipped preparation step ends at the skip **Preparation Action Event** time; its unused planned duration is not treated as elapsed preparation time. +- The current step and completion state of a **Preparation Run** are derived from its starting action, user-performed **Preparation Action Events**, and the current time. +- Automatic step transitions are derived states, not **Preparation Action Events**. +- A **Preparation Run** must not outlive the scheduled commitment it belongs to. - Active first-release **Analytics Purposes** are product improvement, debugging and operations, and experimentation. - A first-release **Experiment** must not be used for marketing targeting, sensitive segmentation, or personalized treatment. - Marketing and personalization are **Deferred Analytics Purposes**. @@ -260,5 +275,6 @@ _Avoid_: Loaded range, stream range, cached range - "Time Sensitive" was too platform-specific for default user-facing status; resolved: fallback iOS delivery should be called notification. - "Status label" was ambiguous across platforms; resolved: Android uses precise notification or notification status, while iOS uses alarm status only for **iOS AlarmKit Alarm**. - "No scheduled alarm" was too capability-specific for an empty state; resolved: use **No Scheduled Notification** across platforms. +- "Step action events" was ambiguous as either user actions or automatic timer movement; resolved: the canonical term is **Preparation Action Event**, and automatic step transitions are derived rather than recorded as events. - "Preparation chain" describes storage reconstruction, not product language; resolved: the domain concept is an ordered **Preparation** made of **Preparation Steps**. - "Total duration" was ambiguous between **Preparation Duration** and the broader preparation-start timing calculation; resolved: **Preparation Duration** is steps only, while move time and **Schedule Spare Time** are separate inputs. diff --git a/docs/adr/0010-derive-preparation-runs-from-action-events.md b/docs/adr/0010-derive-preparation-runs-from-action-events.md new file mode 100644 index 00000000..7afb6083 --- /dev/null +++ b/docs/adr/0010-derive-preparation-runs-from-action-events.md @@ -0,0 +1,9 @@ +# Derive Preparation Runs from Action Events + +OnTime will model local preparation progress as a Preparation Run that starts at the user's start action and is adjusted by user-performed Preparation Action Events such as skipping a step or finishing preparation. The app will derive the current step, elapsed time, and completion state from the run start time, action events, current time, and the current schedule fingerprint instead of treating timer ticks or elapsed-time snapshots as the source of truth. This prevents sleep, background suspension, and delayed timers from making preparation progress drift while keeping the first implementation local to the app rather than expanding the backend API. + +## Consequences + +- Timer ticks may refresh UI state, but they must not be persisted as preparation history. +- Automatic step transitions are derived during restore, resume, and tick refresh; they are not stored as events. +- Stored Preparation Runs are valid only while the schedule fingerprint still matches and must be cleared when the schedule is finished or deleted. diff --git a/lib/data/data_sources/preparation_with_time_local_data_source.dart b/lib/data/data_sources/preparation_with_time_local_data_source.dart index 5c6f3ca7..b41e155a 100644 --- a/lib/data/data_sources/preparation_with_time_local_data_source.dart +++ b/lib/data/data_sources/preparation_with_time_local_data_source.dart @@ -2,13 +2,16 @@ import 'dart:convert'; import 'package:injectable/injectable.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; abstract interface class PreparationWithTimeLocalDataSource { Future savePreparation( - String scheduleId, TimedPreparationSnapshotEntity snapshot); + String scheduleId, + TimedPreparationSnapshotEntity snapshot, + ); Future loadPreparation(String scheduleId); Future clearPreparation(String scheduleId); } @@ -20,23 +23,37 @@ class PreparationWithTimeLocalDataSourceImpl @override Future savePreparation( - String scheduleId, TimedPreparationSnapshotEntity snapshot) async { + String scheduleId, + TimedPreparationSnapshotEntity snapshot, + ) async { final prefs = await SharedPreferences.getInstance(); final key = '$_prefsKeyPrefix$scheduleId'; final jsonMap = { 'savedAt': snapshot.savedAt.millisecondsSinceEpoch, + 'startedAt': snapshot.startedAt?.millisecondsSinceEpoch, 'scheduleFingerprint': snapshot.scheduleFingerprint, + 'actionEvents': snapshot.actionEvents + .map( + (event) => { + 'type': event.type.name, + 'occurredAt': event.occurredAt.millisecondsSinceEpoch, + 'stepId': event.stepId, + }, + ) + .toList(), 'steps': snapshot.preparation.preparationStepList - .map((s) => { - 'id': s.id, - 'name': s.preparationName, - 'time': s.preparationTime.inMilliseconds, - 'nextId': s.nextPreparationId, - 'elapsed': s.elapsedTime.inMilliseconds, - 'isDone': s.isDone, - }) - .toList() + .map( + (s) => { + 'id': s.id, + 'name': s.preparationName, + 'time': s.preparationTime.inMilliseconds, + 'nextId': s.nextPreparationId, + 'elapsed': s.elapsedTime.inMilliseconds, + 'isDone': s.isDone, + }, + ) + .toList(), }; await prefs.setString(key, jsonEncode(jsonMap)); @@ -44,7 +61,8 @@ class PreparationWithTimeLocalDataSourceImpl @override Future loadPreparation( - String scheduleId) async { + String scheduleId, + ) async { final prefs = await SharedPreferences.getInstance(); final key = '$_prefsKeyPrefix$scheduleId'; final jsonString = prefs.getString(key); @@ -67,15 +85,22 @@ class PreparationWithTimeLocalDataSourceImpl }).toList(); final savedAtMillis = (map['savedAt'] as num?)?.toInt(); + final startedAtMillis = (map['startedAt'] as num?)?.toInt(); final scheduleFingerprint = map['scheduleFingerprint'] as String? ?? ''; + final actionEvents = _actionEventsFromJson(map['actionEvents']); return TimedPreparationSnapshotEntity( - preparation: - PreparationWithTimeEntity(preparationStepList: stepEntities), + preparation: PreparationWithTimeEntity( + preparationStepList: stepEntities, + ), savedAt: savedAtMillis == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(savedAtMillis), + startedAt: startedAtMillis == null + ? null + : DateTime.fromMillisecondsSinceEpoch(startedAtMillis), scheduleFingerprint: scheduleFingerprint, + actionEvents: actionEvents, ); } catch (_) { return null; @@ -89,3 +114,26 @@ class PreparationWithTimeLocalDataSourceImpl await prefs.remove(key); } } + +List _actionEventsFromJson(Object? raw) { + if (raw is! List) return const []; + final events = []; + for (final item in raw) { + if (item is! Map) continue; + final typeName = item['type'] as String?; + final occurredAtMillis = (item['occurredAt'] as num?)?.toInt(); + if (typeName == null || occurredAtMillis == null) continue; + final type = PreparationActionEventType.values + .where((value) => value.name == typeName) + .firstOrNull; + if (type == null) continue; + events.add( + PreparationActionEventEntity( + type: type, + occurredAt: DateTime.fromMillisecondsSinceEpoch(occurredAtMillis), + stepId: item['stepId'] as String?, + ), + ); + } + return events; +} diff --git a/lib/domain/entities/preparation_action_event_entity.dart b/lib/domain/entities/preparation_action_event_entity.dart new file mode 100644 index 00000000..92d3e444 --- /dev/null +++ b/lib/domain/entities/preparation_action_event_entity.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; + +enum PreparationActionEventType { start, skipStep, finish } + +class PreparationActionEventEntity extends Equatable { + const PreparationActionEventEntity({ + required this.type, + required this.occurredAt, + this.stepId, + }); + + factory PreparationActionEventEntity.start({required DateTime occurredAt}) { + return PreparationActionEventEntity( + type: PreparationActionEventType.start, + occurredAt: occurredAt, + ); + } + + factory PreparationActionEventEntity.skipStep({ + required String stepId, + required DateTime occurredAt, + }) { + return PreparationActionEventEntity( + type: PreparationActionEventType.skipStep, + occurredAt: occurredAt, + stepId: stepId, + ); + } + + factory PreparationActionEventEntity.finish({required DateTime occurredAt}) { + return PreparationActionEventEntity( + type: PreparationActionEventType.finish, + occurredAt: occurredAt, + ); + } + + final PreparationActionEventType type; + final DateTime occurredAt; + final String? stepId; + + @override + List get props => [type, occurredAt, stepId]; +} diff --git a/lib/domain/entities/timed_preparation_snapshot_entity.dart b/lib/domain/entities/timed_preparation_snapshot_entity.dart index b1c46289..16ded82b 100644 --- a/lib/domain/entities/timed_preparation_snapshot_entity.dart +++ b/lib/domain/entities/timed_preparation_snapshot_entity.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; class TimedPreparationSnapshotEntity extends Equatable { @@ -6,24 +7,38 @@ class TimedPreparationSnapshotEntity extends Equatable { required this.preparation, required this.savedAt, required this.scheduleFingerprint, + this.startedAt, + this.actionEvents = const [], }); final PreparationWithTimeEntity preparation; final DateTime savedAt; final String scheduleFingerprint; + final DateTime? startedAt; + final List actionEvents; TimedPreparationSnapshotEntity copyWith({ PreparationWithTimeEntity? preparation, DateTime? savedAt, String? scheduleFingerprint, + DateTime? startedAt, + List? actionEvents, }) { return TimedPreparationSnapshotEntity( preparation: preparation ?? this.preparation, savedAt: savedAt ?? this.savedAt, scheduleFingerprint: scheduleFingerprint ?? this.scheduleFingerprint, + startedAt: startedAt ?? this.startedAt, + actionEvents: actionEvents ?? this.actionEvents, ); } @override - List get props => [preparation, savedAt, scheduleFingerprint]; + List get props => [ + preparation, + savedAt, + scheduleFingerprint, + startedAt, + actionEvents, + ]; } diff --git a/lib/domain/use-cases/save_timed_preparation_use_case.dart b/lib/domain/use-cases/save_timed_preparation_use_case.dart index 5f520251..8b0a678a 100644 --- a/lib/domain/use-cases/save_timed_preparation_use_case.dart +++ b/lib/domain/use-cases/save_timed_preparation_use_case.dart @@ -1,4 +1,5 @@ import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; @@ -14,11 +15,15 @@ class SaveTimedPreparationUseCase { ScheduleWithPreparationEntity schedule, PreparationWithTimeEntity preparation, { DateTime? savedAt, + DateTime? startedAt, + List actionEvents = const [], }) { final snapshot = TimedPreparationSnapshotEntity( preparation: preparation, savedAt: savedAt ?? DateTime.now(), scheduleFingerprint: schedule.cacheFingerprint, + startedAt: startedAt, + actionEvents: actionEvents, ); return _timedPreparationRepository.saveTimedPreparationSnapshot( schedule.id, diff --git a/lib/presentation/app/bloc/schedule/schedule_bloc.dart b/lib/presentation/app/bloc/schedule/schedule_bloc.dart index 86f704a9..4a154a74 100644 --- a/lib/presentation/app/bloc/schedule/schedule_bloc.dart +++ b/lib/presentation/app/bloc/schedule/schedule_bloc.dart @@ -7,9 +7,13 @@ import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/logging/app_logger.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:on_time_front/domain/entities/early_start_session_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; +import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart'; import 'package:on_time_front/domain/use-cases/clear_early_start_session_use_case.dart'; import 'package:on_time_front/domain/use-cases/clear_timed_preparation_use_case.dart'; @@ -121,6 +125,9 @@ class ScheduleBloc extends Bloc { on(_onScheduleStarted); on(_onPreparationStarted); on(_onTick); + on( + _onPreparationTimeRefreshRequested, + ); on(_onStepSkipped); on(_onFinished); } @@ -148,6 +155,8 @@ class ScheduleBloc extends Bloc { String? _activeEarlyStartScheduleId; Timer? _preparationTimer; DateTime? _lastSnapshotSavedAt; + DateTime? _activePreparationRunStartedAt; + List _activePreparationActionEvents = const []; bool _suppressNextCatchUpStepNotification = false; final Set _serverStartedScheduleIds = {}; final Map> _notifiedStepIdsByScheduleId = {}; @@ -188,6 +197,7 @@ class ScheduleBloc extends Bloc { _currentScheduleId = null; _activeEarlyStartScheduleId = null; _lastSnapshotSavedAt = null; + _clearActivePreparationRun(); _notifiedStepIdsByScheduleId.clear(); return; } @@ -196,10 +206,12 @@ class ScheduleBloc extends Bloc { if (_currentScheduleId != null && _currentScheduleId != incoming.id) { await _clearPersistedState(_currentScheduleId!); _notifiedStepIdsByScheduleId.remove(_currentScheduleId); + _clearActivePreparationRun(); } _currentScheduleId = incoming.id; - final hasEarlyStartSession = await _hasEarlyStartSession(incoming.id); + final earlyStartSession = await _getEarlyStartSession(incoming.id); + final hasEarlyStartSession = earlyStartSession != null; if (isClosed) return; ScheduleWithPreparationEntity resolvedSchedule; @@ -207,6 +219,7 @@ class ScheduleBloc extends Bloc { // Prevent stale pre-start cache from reviving outdated progress. await _clearTimedPreparationUseCase(incoming.id); resolvedSchedule = incoming; + _clearActivePreparationRun(); } else { resolvedSchedule = await _restoreFromSnapshotIfValid(incoming); } @@ -215,6 +228,20 @@ class ScheduleBloc extends Bloc { if (hasEarlyStartSession) { _activeEarlyStartScheduleId = resolvedSchedule.id; + _activePreparationRunStartedAt ??= earlyStartSession.startedAt; + if (resolvedSchedule.preparation.elapsedTime == Duration.zero && + _activePreparationActionEvents.isEmpty) { + resolvedSchedule = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + resolvedSchedule, + _derivePreparationRun( + resolvedSchedule.preparation, + startedAt: _activePreparationRunStartedAt!, + actionEvents: const [], + now: now, + ), + ); + } await _startScheduleOnServer(resolvedSchedule.id); if (isClosed) return; emit(ScheduleState.started(resolvedSchedule, isEarlyStarted: true)); @@ -263,6 +290,7 @@ class ScheduleBloc extends Bloc { emit(ScheduleState.started(state.schedule!)); _initializeNotificationTracking(state.schedule!); _navigationService.push('/scheduleStart'); + _activePreparationRunStartedAt ??= state.schedule!.preparationStartTime; _startPreparationTimer(); } } @@ -435,13 +463,16 @@ class ScheduleBloc extends Bloc { _scheduleStartTimer?.cancel(); _scheduleStartTimer = null; + final startedAt = _nowProvider(); await _markEarlyStartSessionUseCase( scheduleId: schedule.id, - startedAt: _nowProvider(), + startedAt: startedAt, ); await _startScheduleOnServer(schedule.id); await _cancelScheduleAlarmUseCase?.call(schedule.id); + _activePreparationRunStartedAt = startedAt; + _activePreparationActionEvents = const []; emit(ScheduleState.started(schedule, isEarlyStarted: true)); await _saveTimedPreparationSnapshot(schedule, force: true); _startPreparationTimer(); @@ -461,8 +492,7 @@ class ScheduleBloc extends Bloc { updatedPreparation, ); - final shouldSuppressStepNotification = - _suppressNextCatchUpStepNotification; + final shouldSuppressStepNotification = _suppressNextCatchUpStepNotification; _suppressNextCatchUpStepNotification = false; if (!shouldSuppressStepNotification) { _checkAndNotifyStepChange(state.schedule!, newSchedule); @@ -473,12 +503,57 @@ class ScheduleBloc extends Bloc { await _saveTimedPreparationSnapshot(newSchedule, force: stepChanged); } + Future _onPreparationTimeRefreshRequested( + SchedulePreparationTimeRefreshRequested event, + Emitter emit, + ) async { + if (state.schedule == null) return; + if (state.schedule!.preparation.isAllStepsDone) return; + final startedAt = _activePreparationRunStartedAt; + if (startedAt == null) return; + + final refreshedPreparation = _derivePreparationRun( + state.schedule!.preparation, + startedAt: startedAt, + actionEvents: _activePreparationActionEvents, + now: _nowProvider(), + ); + final refreshedSchedule = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + state.schedule!, + refreshedPreparation, + ); + emit(state.copyWith(schedule: refreshedSchedule)); + await _saveTimedPreparationSnapshot(refreshedSchedule); + } + Future _onStepSkipped( ScheduleStepSkipped event, Emitter emit, ) async { if (state.schedule == null) return; - final updated = state.schedule!.preparation.skipCurrentStep(); + if (state.schedule!.preparation.isAllStepsDone) return; + final now = _nowProvider(); + _activePreparationRunStartedAt ??= state.isEarlyStarted + ? now.subtract(state.schedule!.preparation.elapsedTime) + : state.schedule!.preparationStartTime; + final currentPreparation = _derivePreparationRun( + state.schedule!.preparation, + startedAt: _activePreparationRunStartedAt!, + actionEvents: _activePreparationActionEvents, + now: now, + ); + final currentStep = currentPreparation.currentStep; + if (currentStep == null) return; + final skipEvent = PreparationActionEventEntity.skipStep( + stepId: currentStep.id, + occurredAt: now, + ); + _activePreparationActionEvents = [ + ..._activePreparationActionEvents, + skipEvent, + ]; + final updated = _skipCurrentStepForEvent(currentPreparation, skipEvent); final newSchedule = ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( state.schedule!, @@ -503,6 +578,7 @@ class ScheduleBloc extends Bloc { _currentScheduleId = null; _activeEarlyStartScheduleId = null; _lastSnapshotSavedAt = null; + _clearActivePreparationRun(); _serverStartedScheduleIds.remove(scheduleId); emit(const ScheduleState.notExists()); } catch (error) { @@ -537,6 +613,9 @@ class ScheduleBloc extends Bloc { void _startPreparationTimer() { if (isClosed || state.schedule == null) return; _preparationTimer?.cancel(); + _activePreparationRunStartedAt ??= state.isEarlyStarted + ? _nowProvider().subtract(state.schedule!.preparation.elapsedTime) + : state.schedule!.preparationStartTime; final elapsedTimeAfterLastTick = state.isEarlyStarted ? Duration.zero : _nowProvider().difference(state.schedule!.preparationStartTime) - @@ -549,7 +628,7 @@ class ScheduleBloc extends Bloc { } } _preparationTimer = Timer.periodic(Duration(seconds: 1), (_) { - if (!isClosed) add(ScheduleTick(Duration(seconds: 1))); + if (!isClosed) add(const SchedulePreparationTimeRefreshRequested()); }); } @@ -574,15 +653,23 @@ class ScheduleBloc extends Bloc { if (snapshot == null) { return incoming; } - if (snapshot.scheduleFingerprint != incoming.cacheFingerprint) { + if (snapshot.scheduleFingerprint != incoming.cacheFingerprint && + !_canRestoreAcrossFingerprintMismatch(snapshot, incoming)) { await _clearPersistedState(incoming.id); return incoming; } - final elapsedSinceSave = _nowProvider().difference(snapshot.savedAt); - final restoredPreparation = elapsedSinceSave.isNegative - ? snapshot.preparation - : snapshot.preparation.timeElapsed(elapsedSinceSave); + final startedAt = snapshot.startedAt; + _activePreparationRunStartedAt = startedAt; + _activePreparationActionEvents = snapshot.actionEvents; + final restoredPreparation = startedAt == null + ? _restoreElapsedSnapshot(snapshot) + : _derivePreparationRun( + incoming.preparation, + startedAt: startedAt, + actionEvents: snapshot.actionEvents, + now: _nowProvider(), + ); return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( incoming, @@ -590,9 +677,130 @@ class ScheduleBloc extends Bloc { ); } - Future _hasEarlyStartSession(String scheduleId) async { - final session = await _getEarlyStartSessionUseCase(scheduleId); - return session != null; + PreparationWithTimeEntity _restoreElapsedSnapshot( + TimedPreparationSnapshotEntity snapshot, + ) { + final elapsedSinceSave = _nowProvider().difference(snapshot.savedAt); + return elapsedSinceSave.isNegative + ? snapshot.preparation + : snapshot.preparation.timeElapsed(elapsedSinceSave); + } + + PreparationWithTimeEntity _derivePreparationRun( + PreparationWithTimeEntity source, { + required DateTime startedAt, + required List actionEvents, + required DateTime now, + }) { + var preparation = _resetPreparationProgress(source); + var cursor = startedAt; + final orderedEvents = + actionEvents.where((event) => !event.occurredAt.isAfter(now)).toList() + ..sort((a, b) => a.occurredAt.compareTo(b.occurredAt)); + + for (final event in orderedEvents) { + if (event.occurredAt.isAfter(cursor)) { + preparation = preparation.timeElapsed( + event.occurredAt.difference(cursor), + ); + cursor = event.occurredAt; + } + + switch (event.type) { + case PreparationActionEventType.start: + cursor = event.occurredAt; + case PreparationActionEventType.skipStep: + preparation = _skipCurrentStepForEvent(preparation, event); + cursor = event.occurredAt; + case PreparationActionEventType.finish: + return preparation.timeElapsed(now.difference(cursor)); + } + } + + if (now.isAfter(cursor)) { + preparation = preparation.timeElapsed(now.difference(cursor)); + } + return preparation; + } + + PreparationWithTimeEntity _resetPreparationProgress( + PreparationWithTimeEntity source, + ) { + return PreparationWithTimeEntity( + preparationStepList: [ + for (final step in source.preparationStepList) + PreparationStepWithTimeEntity( + id: step.id, + preparationName: step.preparationName, + preparationTime: step.preparationTime, + nextPreparationId: step.nextPreparationId, + ), + ], + ); + } + + PreparationWithTimeEntity _skipCurrentStepForEvent( + PreparationWithTimeEntity preparation, + PreparationActionEventEntity event, + ) { + final current = preparation.currentStep; + if (current == null) { + return preparation; + } + final eventStepId = event.stepId; + final eventStepStillExists = + eventStepId != null && + preparation.preparationStepList.any((step) => step.id == eventStepId); + if (eventStepStillExists && current.id != eventStepId) { + return preparation; + } + return preparation.copyWith( + preparationStepList: [ + for (final step in preparation.preparationStepList) + step.id == current.id ? step.copyWith(isDone: true) : step, + ], + ); + } + + bool _canRestoreAcrossFingerprintMismatch( + TimedPreparationSnapshotEntity snapshot, + ScheduleWithPreparationEntity incoming, + ) { + return _scheduleTimingFingerprintPrefix(snapshot.scheduleFingerprint) == + _scheduleTimingFingerprintPrefix(incoming.cacheFingerprint) && + _hasSamePreparationShape(snapshot.preparation, incoming.preparation); + } + + String _scheduleTimingFingerprintPrefix(String fingerprint) { + final parts = fingerprint.split('|'); + if (parts.length < 4) { + return fingerprint; + } + return '${parts[0]}|${parts[1]}|${parts[2]}|'; + } + + bool _hasSamePreparationShape( + PreparationWithTimeEntity left, + PreparationWithTimeEntity right, + ) { + final leftSteps = left.preparationStepList; + final rightSteps = right.preparationStepList; + if (leftSteps.length != rightSteps.length) { + return false; + } + for (var index = 0; index < leftSteps.length; index++) { + final leftStep = leftSteps[index]; + final rightStep = rightSteps[index]; + if (leftStep.preparationName != rightStep.preparationName || + leftStep.preparationTime != rightStep.preparationTime) { + return false; + } + } + return true; + } + + Future _getEarlyStartSession(String scheduleId) { + return _getEarlyStartSessionUseCase(scheduleId); } Future _saveTimedPreparationSnapshot( @@ -609,6 +817,8 @@ class ScheduleBloc extends Bloc { schedule, schedule.preparation, savedAt: now, + startedAt: _activePreparationRunStartedAt, + actionEvents: _activePreparationActionEvents, ); _lastSnapshotSavedAt = now; } @@ -618,6 +828,11 @@ class ScheduleBloc extends Bloc { await _clearEarlyStartSessionUseCase(scheduleId); } + void _clearActivePreparationRun() { + _activePreparationRunStartedAt = null; + _activePreparationActionEvents = const []; + } + bool _isAtPreparationStartBoundary( ScheduleWithPreparationEntity schedule, DateTime now, diff --git a/lib/presentation/app/bloc/schedule/schedule_event.dart b/lib/presentation/app/bloc/schedule/schedule_event.dart index 80b5a440..618f60b7 100644 --- a/lib/presentation/app/bloc/schedule/schedule_event.dart +++ b/lib/presentation/app/bloc/schedule/schedule_event.dart @@ -18,8 +18,8 @@ final class ScheduleUpcomingReceived extends ScheduleEvent { final ScheduleWithPreparationEntity? upcomingSchedule; const ScheduleUpcomingReceived( - ScheduleWithPreparationEntity? upcomingScheduleWithPreparation) - : upcomingSchedule = upcomingScheduleWithPreparation; + ScheduleWithPreparationEntity? upcomingScheduleWithPreparation, + ) : upcomingSchedule = upcomingScheduleWithPreparation; @override List get props => [upcomingSchedule]; @@ -38,10 +38,10 @@ final class ScheduleAlarmPromptRequested extends ScheduleEvent { @override List get props => [ - scheduleId, - scheduleFingerprint, - startPreparation, - ]; + scheduleId, + scheduleFingerprint, + startPreparation, + ]; } final class ScheduleStarted extends ScheduleEvent { @@ -67,6 +67,13 @@ final class ScheduleTick extends ScheduleEvent { List get props => [elapsed]; } +final class SchedulePreparationTimeRefreshRequested extends ScheduleEvent { + const SchedulePreparationTimeRefreshRequested(); + + @override + List get props => []; +} + final class ScheduleStepSkipped extends ScheduleEvent { const ScheduleStepSkipped(); } diff --git a/lib/presentation/app/screens/app.dart b/lib/presentation/app/screens/app.dart index 2b5f8fd3..57b22dba 100644 --- a/lib/presentation/app/screens/app.dart +++ b/lib/presentation/app/screens/app.dart @@ -95,6 +95,9 @@ class _AppRouterViewState extends State<_AppRouterView> AppLogger.debug('$_logTag lifecycle state=$state'); if (state != AppLifecycleState.resumed) return; unawaited(HardwareKeyboard.instance.syncKeyboardState().catchError((_) {})); + context.read().add( + const SchedulePreparationTimeRefreshRequested(), + ); unawaited( getIt.get().dispatchPendingLaunchPayload(), ); diff --git a/plans/preparation-run-action-events.md b/plans/preparation-run-action-events.md new file mode 100644 index 00000000..ba001b39 --- /dev/null +++ b/plans/preparation-run-action-events.md @@ -0,0 +1,31 @@ +# Preparation Run Action Events Plan + +## Goal + +Fix preparation timer drift after sleep or app suspension by making local preparation progress derive from a Preparation Run start time and user-performed Preparation Action Events instead of persisted elapsed-time snapshots. + +## Confirmed Decisions + +- A Preparation Action Event records user actions only: start, skip step, and finish. +- Automatic step transitions are derived states, not stored events. +- The Preparation Run start event time is the timing source for preparation progress. +- Skip ends the current step at the skip time and does not treat unused planned duration as elapsed. +- The first implementation is local app state only; backend start and finish calls stay as they are. +- Finish clears the local Preparation Run only after server finish succeeds. +- Deleting a schedule must clear the local Preparation Run. +- Schedule fingerprint mismatch invalidates the stored Preparation Run. + +## TDD Order + +1. RED: restoring an ongoing run derives the current step from start time, skip event, and current time. +2. GREEN: add the smallest local run/action-event model and derivation path needed for that behavior. +3. RED: sleep/wake or delayed tick refresh derives elapsed from actual current time rather than adding one second. +4. GREEN: add a public refresh/resume path through `ScheduleBloc`. +5. RED: schedule deletion clears the local Preparation Run. +6. GREEN: wire delete flow to clear the run. + +## Validation + +- Run targeted `flutter test test/presentation/app/bloc/schedule/schedule_bloc_test.dart`. +- Add narrower data-source/use-case tests only when persistence behavior cannot be verified through `ScheduleBloc`. +- Run `flutter analyze` after the implementation stabilizes. diff --git a/test/data/data_sources/preparation_with_time_local_data_source_test.dart b/test/data/data_sources/preparation_with_time_local_data_source_test.dart index 54c0a5a2..a908309c 100644 --- a/test/data/data_sources/preparation_with_time_local_data_source_test.dart +++ b/test/data/data_sources/preparation_with_time_local_data_source_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/data/data_sources/preparation_with_time_local_data_source.dart'; import 'package:on_time_front/data/repositories/timed_preparation_repository_impl.dart'; +import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; import 'package:on_time_front/domain/entities/timed_preparation_snapshot_entity.dart'; @@ -26,6 +27,27 @@ void main() { expect(loaded.preparation.stepElapsedTimesInSeconds, [600, 120]); }); + test('savePreparation persists preparation run action events', () async { + final startedAt = DateTime(2026, 5, 15, 8); + final skipAt = startedAt.add(const Duration(minutes: 3)); + final snapshot = _snapshot( + startedAt: startedAt, + actionEvents: [ + PreparationActionEventEntity.skipStep( + stepId: 'step-1', + occurredAt: skipAt, + ), + ], + ); + + await dataSource.savePreparation('schedule-1', snapshot); + + final loaded = await dataSource.loadPreparation('schedule-1'); + + expect(loaded!.startedAt, startedAt); + expect(loaded.actionEvents, snapshot.actionEvents); + }); + test( 'loadPreparation returns null for missing or corrupt snapshots', () async { @@ -111,10 +133,14 @@ void main() { TimedPreparationSnapshotEntity _snapshot({ String scheduleFingerprint = 'fingerprint', + DateTime? startedAt, + List actionEvents = const [], }) { return TimedPreparationSnapshotEntity( savedAt: DateTime.fromMillisecondsSinceEpoch(1778774400000), scheduleFingerprint: scheduleFingerprint, + startedAt: startedAt, + actionEvents: actionEvents, preparation: const PreparationWithTimeEntity( preparationStepList: [ PreparationStepWithTimeEntity( diff --git a/test/domain/use-cases/timed_preparation_use_cases_test.dart b/test/domain/use-cases/timed_preparation_use_cases_test.dart index 416e7c27..3bb146d5 100644 --- a/test/domain/use-cases/timed_preparation_use_cases_test.dart +++ b/test/domain/use-cases/timed_preparation_use_cases_test.dart @@ -126,6 +126,8 @@ void main() { original.preparation, original.savedAt, 'fingerprint-1', + null, + const [], ]); }); diff --git a/test/presentation/alarm/screens/preparation_flow_widget_test.dart b/test/presentation/alarm/screens/preparation_flow_widget_test.dart index d5329ca8..908a3ad6 100644 --- a/test/presentation/alarm/screens/preparation_flow_widget_test.dart +++ b/test/presentation/alarm/screens/preparation_flow_widget_test.dart @@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; import 'package:on_time_front/domain/entities/preparation_with_time_entity.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; @@ -61,6 +62,8 @@ class NoopSaveTimedPreparationUseCase implements SaveTimedPreparationUseCase { ScheduleWithPreparationEntity schedule, PreparationWithTimeEntity preparation, { DateTime? savedAt, + DateTime? startedAt, + List actionEvents = const [], }) async {} } diff --git a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart index 7e505861..6e0c2498 100644 --- a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart +++ b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; import 'package:on_time_front/domain/entities/place_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_action_event_entity.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; import 'package:on_time_front/domain/entities/preparation_step_with_time_entity.dart'; @@ -52,15 +53,26 @@ class SpyNavigationService extends NavigationService { } class SpySaveTimedPreparationUseCase implements SaveTimedPreparationUseCase { - final List<(String, PreparationWithTimeEntity, DateTime?)> calls = []; + final List< + ( + String, + PreparationWithTimeEntity, + DateTime?, + DateTime?, + List, + ) + > + calls = []; @override Future call( ScheduleWithPreparationEntity schedule, PreparationWithTimeEntity preparation, { DateTime? savedAt, + DateTime? startedAt, + List actionEvents = const [], }) async { - calls.add((schedule.id, preparation, savedAt)); + calls.add((schedule.id, preparation, savedAt, startedAt, actionEvents)); } } @@ -214,11 +226,15 @@ TimedPreparationSnapshotEntity buildSnapshot({ required PreparationWithTimeEntity preparation, required DateTime savedAt, required String fingerprint, + DateTime? startedAt, + List actionEvents = const [], }) { return TimedPreparationSnapshotEntity( preparation: preparation, savedAt: savedAt, scheduleFingerprint: fingerprint, + startedAt: startedAt, + actionEvents: actionEvents, ); } @@ -584,6 +600,166 @@ void main() { }, ); + test( + 'restoring an ongoing run derives current step from start time skip action and now', + () async { + final startedAt = DateTime(2026, 3, 20, 9, 0); + final schedule = buildSchedule( + id: 'event-restore', + scheduleTime: startedAt.add(const Duration(minutes: 50)), + steps: const [ + PreparationStepWithTimeEntity( + id: 's1', + preparationName: 'wash', + preparationTime: Duration(minutes: 10), + nextPreparationId: 's2', + ), + PreparationStepWithTimeEntity( + id: 's2', + preparationName: 'dress', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + now = startedAt.add(const Duration(minutes: 7)); + getSnapshotUseCase.snapshots['event-restore'] = buildSnapshot( + preparation: schedule.preparation, + savedAt: startedAt.add(const Duration(minutes: 3)), + fingerprint: schedule.cacheFingerprint, + startedAt: startedAt, + actionEvents: [ + PreparationActionEventEntity.skipStep( + stepId: 's1', + occurredAt: startedAt.add(const Duration(minutes: 3)), + ), + ], + ); + + bloc.add(ScheduleUpcomingReceived(schedule)); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + await Future.delayed(Duration.zero); + + final restoredSteps = + bloc.state.schedule!.preparation.preparationStepList; + expect(bloc.state.schedule!.preparation.currentStep?.id, 's2'); + expect(restoredSteps[0].isDone, isTrue); + expect(restoredSteps[0].elapsedTime, const Duration(minutes: 3)); + expect(restoredSteps[1].elapsedTime, const Duration(minutes: 4)); + }, + ); + + test( + 'restoring an ongoing run tolerates frozen preparation ids and preserves skip actions', + () async { + final startedAt = DateTime(2026, 3, 20, 9, 0); + final original = buildSchedule( + id: 'frozen-restore', + scheduleTime: startedAt.add(const Duration(minutes: 50)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'template-s1', + preparationName: 'wash', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'template-s2', + ), + PreparationStepWithTimeEntity( + id: 'template-s2', + preparationName: 'dress', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + final frozen = buildSchedule( + id: 'frozen-restore', + scheduleTime: startedAt.add(const Duration(minutes: 50)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'frozen-s1', + preparationName: 'wash', + preparationTime: Duration(minutes: 10), + nextPreparationId: 'frozen-s2', + ), + PreparationStepWithTimeEntity( + id: 'frozen-s2', + preparationName: 'dress', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + now = startedAt.add(const Duration(minutes: 7)); + getSnapshotUseCase.snapshots['frozen-restore'] = buildSnapshot( + preparation: original.preparation, + savedAt: startedAt.add(const Duration(minutes: 3)), + fingerprint: original.cacheFingerprint, + startedAt: startedAt, + actionEvents: [ + PreparationActionEventEntity.skipStep( + stepId: 'template-s1', + occurredAt: startedAt.add(const Duration(minutes: 3)), + ), + ], + ); + + bloc.add(ScheduleUpcomingReceived(frozen)); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + await Future.delayed(Duration.zero); + + final restoredSteps = + bloc.state.schedule!.preparation.preparationStepList; + expect(clearTimedUseCase.calls, isNot(contains('frozen-restore'))); + expect(restoredSteps[0].id, 'frozen-s1'); + expect(restoredSteps[0].isDone, isTrue); + expect(restoredSteps[0].elapsedTime, const Duration(minutes: 3)); + expect(bloc.state.schedule!.preparation.currentStep?.id, 'frozen-s2'); + expect(restoredSteps[1].elapsedTime, const Duration(minutes: 4)); + }, + ); + + test( + 'refreshing active preparation derives elapsed from actual current time', + () async { + final startedAt = DateTime(2026, 3, 20, 9, 0); + final schedule = buildSchedule( + id: 'refresh-run', + scheduleTime: startedAt.add(const Duration(minutes: 50)), + steps: const [ + PreparationStepWithTimeEntity( + id: 's1', + preparationName: 'wash', + preparationTime: Duration(minutes: 10), + nextPreparationId: 's2', + ), + PreparationStepWithTimeEntity( + id: 's2', + preparationName: 'dress', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + now = startedAt.add(const Duration(minutes: 1)); + bloc.add(ScheduleUpcomingReceived(schedule)); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + await Future.delayed(Duration.zero); + expect( + bloc.state.schedule!.preparation.elapsedTime, + const Duration(minutes: 1), + ); + + now = startedAt.add(const Duration(minutes: 6)); + bloc.add(const SchedulePreparationTimeRefreshRequested()); + await Future.delayed(Duration.zero); + + expect( + bloc.state.schedule!.preparation.elapsedTime, + const Duration(minutes: 6), + ); + }, + ); + test( 'skip current step advances to next and persists timed preparation', () async { @@ -618,6 +794,42 @@ void main() { }, ); + test('skip current step persists a skip action event', () async { + final startedAt = DateTime(2026, 3, 20, 9, 0); + final schedule = buildSchedule( + id: 'skip-event', + scheduleTime: startedAt.add(const Duration(minutes: 50)), + steps: const [ + PreparationStepWithTimeEntity( + id: 's1', + preparationName: 'wash', + preparationTime: Duration(minutes: 10), + nextPreparationId: 's2', + ), + PreparationStepWithTimeEntity( + id: 's2', + preparationName: 'dress', + preparationTime: Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + now = startedAt.add(const Duration(minutes: 1)); + bloc.add(ScheduleUpcomingReceived(schedule)); + await bloc.stream.firstWhere((s) => s.status == ScheduleStatus.ongoing); + await Future.delayed(Duration.zero); + saveUseCase.calls.clear(); + + now = startedAt.add(const Duration(minutes: 3)); + bloc.add(const ScheduleStepSkipped()); + await Future.delayed(Duration.zero); + + expect(saveUseCase.calls.last.$4, startedAt); + expect(saveUseCase.calls.last.$5, [ + PreparationActionEventEntity.skipStep(stepId: 's1', occurredAt: now), + ]); + }); + test('skip on last remaining step marks all steps done', () async { final schedule = buildSchedule( id: 'skip-last', @@ -1094,6 +1306,36 @@ void main() { }, ); + test( + 'resume before official start with only early session derives elapsed from session start', + () async { + final schedule = buildSchedule( + id: 'resume-early-session-only', + scheduleTime: now.add(const Duration(hours: 1)), + steps: const [ + PreparationStepWithTimeEntity( + id: 'a', + preparationName: 'a', + preparationTime: Duration(minutes: 20), + nextPreparationId: null, + ), + ], + ); + markEarlySessionUseCase.sessions['resume-early-session-only'] = now + .subtract(const Duration(minutes: 3)); + + bloc.add(ScheduleUpcomingReceived(schedule)); + await Future.delayed(Duration.zero); + + expect(bloc.state.status, ScheduleStatus.started); + expect(bloc.state.isEarlyStarted, isTrue); + expect( + bloc.state.schedule!.preparation.elapsedTime, + const Duration(minutes: 3), + ); + }, + ); + test( 'remote alarm prompt loads schedule details and arms the prompt', () async {