diff --git a/CONTEXT.md b/CONTEXT.md index 66b4e897..530e5f75 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -93,6 +93,22 @@ _Avoid_: Event, appointment record An ordered set of steps and durations used to get ready for a Schedule. _Avoid_: Routine, prep checklist +**Preparation Step**: +One named action in a Preparation with its own expected duration. +_Avoid_: Task, todo, subroutine + +**Preparation Start Moment**: +The intended time for the user to begin Preparation so the Schedule can still be met on time. +_Avoid_: Alarm time, notification time, wake-up time + +**Schedule Preparation Session**: +The active period when a user is working through Preparation for one Schedule. +_Avoid_: Schedule run, preparation runtime, alarm session + +**Early Start Session**: +A Schedule Preparation Session that the user starts before the Preparation Start Moment. +_Avoid_: Manual start, premature alarm, early alarm + **Preparation Duration**: The sum of a Schedule's Preparation step durations, excluding move time and Schedule Spare Time. _Avoid_: Total duration, travel time, buffer time @@ -216,6 +232,12 @@ _Avoid_: Loaded range, stream range, cached range - A **Schedule** has a **Preparation** whose **Preparation Duration** contributes to preparation-start timing. - **Preparation Duration**, move time, and **Schedule Spare Time** are distinct schedule timing inputs. - User-facing copy should call a scheduled notification a **Schedule Notification**, not an **Alarm**, unless it opens an OnTime screen without the user first tapping a notification. +- A **Schedule** has one active **Preparation** selection. +- A **Preparation** contains one or more ordered **Preparation Steps**. +- A **Preparation Start Moment** is derived from the **Schedule**, **Preparation**, movement time, and spare time. +- A **Schedule Preparation Session** belongs to exactly one **Schedule**. +- An **Early Start Session** is a **Schedule Preparation Session** started before the **Preparation Start Moment**. +- A **Schedule Notification** may prompt the user to begin a **Schedule Preparation Session** at the **Preparation Start Moment**. - The profile setting for upcoming schedule preparation delivery should be called **Schedule Notification Setting**. - On iOS, user-facing copy may say **Alarm** only when OnTime can deliver an **iOS AlarmKit Alarm**. - iOS permission prompts should use alarm language only when requesting an **iOS AlarmKit Alarm**; otherwise they should use notification language. @@ -259,6 +281,9 @@ _Avoid_: Loaded range, stream range, cached range > **Dev:** "Should the analytics event include the schedule note so we can understand why users are late?" > **Domain expert:** "No. A **Product Usage Event** can say a schedule was finished late, but it must not include the user's raw note." +> +> **Dev:** "If the user taps Start before the scheduled notification, is that a separate schedule run?" +> **Domain expert:** "No - it is an **Early Start Session**, which is still the **Schedule Preparation Session** for that **Schedule**." > **Dev:** "Can a **Schedule Notification** replace the **Schedule** if the user taps it?" > **Domain expert:** "No. The **Schedule Notification** only prompts preparation for the **Schedule**; the **Schedule** remains the planned commitment." @@ -275,6 +300,7 @@ _Avoid_: Loaded range, stream range, cached range - "Third party" was ambiguous for analytics; resolved: the canonical term is **Analytics Provider**. - "Event taxonomy" was broad; resolved: first-release analytics tracks **Workflow Milestone Events** only. - "Event payload" was too open-ended; resolved: events use allowlisted **Analytics Event Parameters** only. +- "Schedule preparation session" was implicit in code but not in the glossary; resolved: the canonical term is **Schedule Preparation Session**, with **Early Start Session** for sessions started before the **Preparation Start Moment**. - "Login completed" was ambiguous for Apple and Google sign-in; resolved: external account prompt completion is **Provider Authentication Completed**, while usable OnTime sign-in is **OnTime Session Established**. - "Preparation" was ambiguous between the user's fallback steps and a schedule-specific edited set; resolved: use **Default Preparation** for the fallback and **Custom Preparation** for the schedule-specific version. - "Alarm permission" was ambiguous between **Exact Timing Permission** and notification permission; resolved: notification permission may enable a **Fallback Notification**, but does not mean **Exact Timing Permission** is granted. diff --git a/lib/domain/use-cases/schedule_preparation_session_use_case.dart b/lib/domain/use-cases/schedule_preparation_session_use_case.dart new file mode 100644 index 00000000..f1a07c8c --- /dev/null +++ b/lib/domain/use-cases/schedule_preparation_session_use_case.dart @@ -0,0 +1,337 @@ +import 'dart:async'; + +import 'package:injectable/injectable.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_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/repositories/early_start_session_repository.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_repository.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; + +enum SchedulePreparationPromptStatus { ready, rejected, unavailable } + +typedef RestoredSessionCallback = + void Function({ + required DateTime? startedAt, + required List actionEvents, + }); + +class SchedulePreparationPromptResult { + const SchedulePreparationPromptResult._({ + required this.status, + this.schedule, + }); + + const SchedulePreparationPromptResult.ready( + ScheduleWithPreparationEntity schedule, + ) : this._(status: SchedulePreparationPromptStatus.ready, schedule: schedule); + + const SchedulePreparationPromptResult.rejected() + : this._(status: SchedulePreparationPromptStatus.rejected); + + const SchedulePreparationPromptResult.unavailable() + : this._(status: SchedulePreparationPromptStatus.unavailable); + + final SchedulePreparationPromptStatus status; + final ScheduleWithPreparationEntity? schedule; +} + +@Singleton() +class SchedulePreparationSessionUseCase { + SchedulePreparationSessionUseCase( + this._scheduleRepository, + this._preparationRepository, + this._timedPreparationRepository, + this._earlyStartSessionRepository, + this._cancelScheduleAlarmUseCase, + this._reconcileAlarmsUseCase, + ); + + final ScheduleRepository _scheduleRepository; + final PreparationRepository _preparationRepository; + final TimedPreparationRepository _timedPreparationRepository; + final EarlyStartSessionRepository _earlyStartSessionRepository; + final CancelScheduleAlarmUseCase _cancelScheduleAlarmUseCase; + final ReconcileAlarmsUseCase _reconcileAlarmsUseCase; + final Set _startedScheduleIds = {}; + + Future startEarlySession( + ScheduleWithPreparationEntity schedule, { + required DateTime startedAt, + }) async { + await _earlyStartSessionRepository.markStarted( + scheduleId: schedule.id, + startedAt: startedAt, + ); + await startSchedulePreparation(schedule.id); + await _cancelScheduleAlarmUseCase(schedule.id); + await saveTimedPreparationSnapshot( + schedule, + savedAt: startedAt, + startedAt: startedAt, + actionEvents: const [], + ); + } + + Future startSchedulePreparation(String scheduleId) async { + if (!_startedScheduleIds.add(scheduleId)) return; + await _scheduleRepository.startSchedule(scheduleId); + } + + Future hasEarlyStartSession(String scheduleId) async { + return await _earlyStartSessionRepository.getSession(scheduleId) != null; + } + + Future getEarlyStartSession(String scheduleId) { + return _earlyStartSessionRepository.getSession(scheduleId); + } + + Future saveTimedPreparationSnapshot( + ScheduleWithPreparationEntity schedule, { + DateTime? savedAt, + DateTime? startedAt, + List actionEvents = const [], + }) { + final snapshot = TimedPreparationSnapshotEntity( + preparation: schedule.preparation, + savedAt: savedAt ?? DateTime.now(), + scheduleFingerprint: schedule.cacheFingerprint, + startedAt: startedAt, + actionEvents: actionEvents, + ); + return _timedPreparationRepository.saveTimedPreparationSnapshot( + schedule.id, + snapshot, + ); + } + + Future restoreTimedPreparationIfValid( + ScheduleWithPreparationEntity schedule, { + required DateTime now, + RestoredSessionCallback? onRestoredSession, + }) async { + final snapshot = await _timedPreparationRepository + .getTimedPreparationSnapshot(schedule.id); + if (snapshot == null) return schedule; + if (snapshot.scheduleFingerprint != schedule.cacheFingerprint && + !_canRestoreAcrossFingerprintMismatch(snapshot, schedule)) { + await clearPersistedState(schedule.id); + return schedule; + } + + final startedAt = snapshot.startedAt; + onRestoredSession?.call( + startedAt: startedAt, + actionEvents: snapshot.actionEvents, + ); + final restoredPreparation = startedAt == null + ? _restoreElapsedSnapshot(snapshot, now) + : _derivePreparationRun( + schedule.preparation, + startedAt: startedAt, + actionEvents: snapshot.actionEvents, + now: now, + ); + + return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + restoredPreparation, + ); + } + + Future clearPersistedState(String scheduleId) async { + await _timedPreparationRepository.clearTimedPreparation(scheduleId); + await _earlyStartSessionRepository.clear(scheduleId); + _startedScheduleIds.remove(scheduleId); + } + + Future finishSchedulePreparation( + String scheduleId, { + required int latenessTime, + }) async { + await startSchedulePreparation(scheduleId); + await _scheduleRepository.finishSchedule(scheduleId, latenessTime); + await _cancelScheduleAlarmUseCase(scheduleId); + await clearPersistedState(scheduleId); + unawaited(_reconcileAlarmsUseCase()); + } + + Future resolvePromptedSchedule({ + required String scheduleId, + required bool startPreparation, + String? scheduleFingerprint, + }) async { + try { + final schedule = await _scheduleRepository.getScheduleById(scheduleId); + if (_isEnded(schedule.doneStatus)) { + await _cancelScheduleAlarmUseCase(scheduleId); + return const SchedulePreparationPromptResult.rejected(); + } + + final preparationFuture = _preparationRepository.preparationStream + .map((preparations) => preparations[scheduleId]) + .where((preparation) => preparation != null) + .cast() + .first; + await _preparationRepository.getPreparationByScheduleId(scheduleId); + final preparation = await preparationFuture; + final scheduleWithPreparation = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + PreparationWithTimeEntity.fromPreparation(preparation), + ); + + if (scheduleFingerprint != null && + scheduleFingerprint != scheduleWithPreparation.cacheFingerprint && + !startPreparation) { + await _cancelScheduleAlarmUseCase(scheduleId); + return const SchedulePreparationPromptResult.rejected(); + } + + return SchedulePreparationPromptResult.ready(scheduleWithPreparation); + } catch (_) { + if (startPreparation) { + return const SchedulePreparationPromptResult.unavailable(); + } + await _cancelScheduleAlarmUseCase(scheduleId); + return const SchedulePreparationPromptResult.rejected(); + } + } + + bool _isEnded(ScheduleDoneStatus doneStatus) { + return doneStatus == ScheduleDoneStatus.normalEnd || + doneStatus == ScheduleDoneStatus.lateEnd || + doneStatus == ScheduleDoneStatus.abnormalEnd; + } + + PreparationWithTimeEntity _restoreElapsedSnapshot( + TimedPreparationSnapshotEntity snapshot, + DateTime now, + ) { + final elapsedSinceSave = now.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 schedule, + ) { + return _scheduleTimingFingerprintPrefix(snapshot.scheduleFingerprint) == + _scheduleTimingFingerprintPrefix(schedule.cacheFingerprint) && + _hasSamePreparationShape(snapshot.preparation, schedule.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; + } +} diff --git a/lib/presentation/app/bloc/schedule/schedule_bloc.dart b/lib/presentation/app/bloc/schedule/schedule_bloc.dart index 4a154a74..235ff003 100644 --- a/lib/presentation/app/bloc/schedule/schedule_bloc.dart +++ b/lib/presentation/app/bloc/schedule/schedule_bloc.dart @@ -13,20 +13,8 @@ import 'package:on_time_front/domain/entities/preparation_step_with_time_entity. 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'; -import 'package:on_time_front/domain/use-cases/get_preparation_by_schedule_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_early_start_session_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_schedule_by_id_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_timed_preparation_snapshot_use_case.dart'; -import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; -import 'package:on_time_front/domain/use-cases/mark_early_start_session_use_case.dart'; -import 'package:on_time_front/domain/use-cases/save_timed_preparation_use_case.dart'; -import 'package:on_time_front/domain/use-cases/finish_schedule_use_case.dart'; -import 'package:on_time_front/domain/use-cases/start_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; part 'schedule_event.dart'; part 'schedule_state.dart'; @@ -59,30 +47,10 @@ class ScheduleBloc extends Bloc { ScheduleBloc( this._getNearestUpcomingScheduleUseCase, this._navigationService, - this._saveTimedPreparationUseCase, - this._getTimedPreparationSnapshotUseCase, - this._clearTimedPreparationUseCase, - this._startScheduleUseCase, - this._finishScheduleUseCase, { - required MarkEarlyStartSessionUseCase markEarlyStartSessionUseCase, - required GetEarlyStartSessionUseCase getEarlyStartSessionUseCase, - required ClearEarlyStartSessionUseCase clearEarlyStartSessionUseCase, - required CancelScheduleAlarmUseCase cancelScheduleAlarmUseCase, - required GetScheduleByIdUseCase getScheduleByIdUseCase, - required LoadPreparationByScheduleIdUseCase - loadPreparationByScheduleIdUseCase, - required GetPreparationByScheduleIdUseCase - getPreparationByScheduleIdUseCase, - }) : _nowProvider = DateTime.now, - _markEarlyStartSessionUseCase = markEarlyStartSessionUseCase, - _getEarlyStartSessionUseCase = getEarlyStartSessionUseCase, - _clearEarlyStartSessionUseCase = clearEarlyStartSessionUseCase, - _cancelScheduleAlarmUseCase = cancelScheduleAlarmUseCase, - _getScheduleByIdUseCase = getScheduleByIdUseCase, - _loadPreparationByScheduleIdUseCase = loadPreparationByScheduleIdUseCase, - _getPreparationByScheduleIdUseCase = getPreparationByScheduleIdUseCase, - _notifyPreparationStep = _defaultNotifyPreparationStep, - super(const ScheduleState.initial()) { + this._schedulePreparationSessionUseCase, + ) : _nowProvider = DateTime.now, + _notifyPreparationStep = _defaultNotifyPreparationStep, + super(const ScheduleState.initial()) { _registerHandlers(); } @@ -90,28 +58,10 @@ class ScheduleBloc extends Bloc { ScheduleBloc.test( this._getNearestUpcomingScheduleUseCase, this._navigationService, - this._saveTimedPreparationUseCase, - this._getTimedPreparationSnapshotUseCase, - this._clearTimedPreparationUseCase, - this._startScheduleUseCase, - this._finishScheduleUseCase, { - required MarkEarlyStartSessionUseCase markEarlyStartSessionUseCase, - required GetEarlyStartSessionUseCase getEarlyStartSessionUseCase, - required ClearEarlyStartSessionUseCase clearEarlyStartSessionUseCase, - CancelScheduleAlarmUseCase? cancelScheduleAlarmUseCase, - GetScheduleByIdUseCase? getScheduleByIdUseCase, - LoadPreparationByScheduleIdUseCase? loadPreparationByScheduleIdUseCase, - GetPreparationByScheduleIdUseCase? getPreparationByScheduleIdUseCase, + this._schedulePreparationSessionUseCase, { NowProvider? nowProvider, NotifyPreparationStep? notifyPreparationStep, }) : _nowProvider = nowProvider ?? DateTime.now, - _markEarlyStartSessionUseCase = markEarlyStartSessionUseCase, - _getEarlyStartSessionUseCase = getEarlyStartSessionUseCase, - _clearEarlyStartSessionUseCase = clearEarlyStartSessionUseCase, - _cancelScheduleAlarmUseCase = cancelScheduleAlarmUseCase, - _getScheduleByIdUseCase = getScheduleByIdUseCase, - _loadPreparationByScheduleIdUseCase = loadPreparationByScheduleIdUseCase, - _getPreparationByScheduleIdUseCase = getPreparationByScheduleIdUseCase, _notifyPreparationStep = notifyPreparationStep ?? _defaultNotifyPreparationStep, super(const ScheduleState.initial()) { @@ -134,18 +84,7 @@ class ScheduleBloc extends Bloc { final GetNearestUpcomingScheduleUseCase _getNearestUpcomingScheduleUseCase; final NavigationService _navigationService; - final SaveTimedPreparationUseCase _saveTimedPreparationUseCase; - final GetTimedPreparationSnapshotUseCase _getTimedPreparationSnapshotUseCase; - final ClearTimedPreparationUseCase _clearTimedPreparationUseCase; - final StartScheduleUseCase _startScheduleUseCase; - final FinishScheduleUseCase _finishScheduleUseCase; - final MarkEarlyStartSessionUseCase _markEarlyStartSessionUseCase; - final GetEarlyStartSessionUseCase _getEarlyStartSessionUseCase; - final ClearEarlyStartSessionUseCase _clearEarlyStartSessionUseCase; - final CancelScheduleAlarmUseCase? _cancelScheduleAlarmUseCase; - final GetScheduleByIdUseCase? _getScheduleByIdUseCase; - final LoadPreparationByScheduleIdUseCase? _loadPreparationByScheduleIdUseCase; - final GetPreparationByScheduleIdUseCase? _getPreparationByScheduleIdUseCase; + final SchedulePreparationSessionUseCase _schedulePreparationSessionUseCase; final NowProvider _nowProvider; final NotifyPreparationStep _notifyPreparationStep; StreamSubscription? @@ -158,7 +97,6 @@ class ScheduleBloc extends Bloc { DateTime? _activePreparationRunStartedAt; List _activePreparationActionEvents = const []; bool _suppressNextCatchUpStepNotification = false; - final Set _serverStartedScheduleIds = {}; final Map> _notifiedStepIdsByScheduleId = {}; Future _onSubscriptionRequested( @@ -217,7 +155,7 @@ class ScheduleBloc extends Bloc { ScheduleWithPreparationEntity resolvedSchedule; if (!hasEarlyStartSession && incoming.preparationStartTime.isAfter(now)) { // Prevent stale pre-start cache from reviving outdated progress. - await _clearTimedPreparationUseCase(incoming.id); + await _schedulePreparationSessionUseCase.clearPersistedState(incoming.id); resolvedSchedule = incoming; _clearActivePreparationRun(); } else { @@ -315,85 +253,30 @@ class ScheduleBloc extends Bloc { return; } - if (_getScheduleByIdUseCase == null || - _loadPreparationByScheduleIdUseCase == null || - _getPreparationByScheduleIdUseCase == null) { - AppLogger.debug( - 'alarm prompt validation unavailable: missing schedule use cases ' - 'scheduleId=${event.scheduleId}', - ); - return; - } - final getScheduleByIdUseCase = _getScheduleByIdUseCase; - final loadPreparationByScheduleIdUseCase = - _loadPreparationByScheduleIdUseCase; - final getPreparationByScheduleIdUseCase = - _getPreparationByScheduleIdUseCase; - - try { - final schedule = await getScheduleByIdUseCase(event.scheduleId); - if (_isEnded(schedule.doneStatus)) { + final promptResult = await _schedulePreparationSessionUseCase + .resolvePromptedSchedule( + scheduleId: event.scheduleId, + startPreparation: event.startPreparation, + scheduleFingerprint: event.scheduleFingerprint, + ); + switch (promptResult.status) { + case SchedulePreparationPromptStatus.ready: + await _activateAlarmPromptSchedule( + promptResult.schedule!, + event, + emit, + source: 'remote', + ); + case SchedulePreparationPromptStatus.rejected: AppLogger.debug( - 'alarm prompt rejected ended schedule: scheduleId=${event.scheduleId}', + 'alarm prompt rejected: scheduleId=${event.scheduleId}', ); - await _cancelScheduleAlarmUseCase?.call(event.scheduleId); _navigationService.go('/home'); - return; - } - - await loadPreparationByScheduleIdUseCase(event.scheduleId); - final preparation = await getPreparationByScheduleIdUseCase( - event.scheduleId, - ); - final scheduleWithPreparation = - ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( - schedule, - PreparationWithTimeEntity.fromPreparation(preparation), - ); - - if (event.scheduleFingerprint != null && - event.scheduleFingerprint != - scheduleWithPreparation.cacheFingerprint) { - if (event.startPreparation) { - AppLogger.debug( - 'alarm prompt continuing start despite fingerprint mismatch: ' - 'scheduleId=${event.scheduleId} ' - 'expected=${event.scheduleFingerprint} ' - 'actual=${scheduleWithPreparation.cacheFingerprint}', - ); - } else { - AppLogger.debug( - 'alarm prompt rejected fingerprint mismatch: ' - 'scheduleId=${event.scheduleId} ' - 'expected=${event.scheduleFingerprint} ' - 'actual=${scheduleWithPreparation.cacheFingerprint}', - ); - await _cancelScheduleAlarmUseCase?.call(event.scheduleId); - _navigationService.go('/home'); - return; - } - } - - await _activateAlarmPromptSchedule( - scheduleWithPreparation, - event, - emit, - source: 'remote', - ); - } catch (error) { - AppLogger.debug( - 'alarm prompt validation failed ' - 'scheduleId=${event.scheduleId} errorType=${error.runtimeType}', - ); - if (event.startPreparation) { + case SchedulePreparationPromptStatus.unavailable: AppLogger.debug( - 'alarm prompt start kept on current route after validation failure: ' - 'scheduleId=${event.scheduleId}', + 'alarm prompt validation unavailable: scheduleId=${event.scheduleId}', ); return; - } - await _cancelScheduleAlarmUseCase?.call(event.scheduleId); - _navigationService.go('/home'); } } @@ -464,17 +347,15 @@ class ScheduleBloc extends Bloc { _scheduleStartTimer = null; final startedAt = _nowProvider(); - await _markEarlyStartSessionUseCase( - scheduleId: schedule.id, + await _schedulePreparationSessionUseCase.startEarlySession( + schedule, 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); + _lastSnapshotSavedAt = startedAt; _startPreparationTimer(); } @@ -570,16 +451,17 @@ class ScheduleBloc extends Bloc { if (state.schedule == null) return; final scheduleId = state.schedule!.id; try { - await _finishScheduleUseCase(scheduleId, event.latenessTime); + await _schedulePreparationSessionUseCase.finishSchedulePreparation( + scheduleId, + latenessTime: event.latenessTime, + ); // After finishing, clear timers and set state to notExists _stopPreparationTimer(); _scheduleStartTimer?.cancel(); - await _clearPersistedState(scheduleId); _currentScheduleId = null; _activeEarlyStartScheduleId = null; _lastSnapshotSavedAt = null; _clearActivePreparationRun(); - _serverStartedScheduleIds.remove(scheduleId); emit(const ScheduleState.notExists()); } catch (error) { AppLogger.debug('error finishing schedule: $error'); @@ -587,9 +469,9 @@ class ScheduleBloc extends Bloc { } Future _startScheduleOnServer(String scheduleId) async { - if (_serverStartedScheduleIds.contains(scheduleId)) return; - await _startScheduleUseCase(scheduleId); - _serverStartedScheduleIds.add(scheduleId); + await _schedulePreparationSessionUseCase.startSchedulePreparation( + scheduleId, + ); } void _startScheduleTimer(ScheduleWithPreparationEntity schedule) { @@ -649,43 +531,16 @@ class ScheduleBloc extends Bloc { Future _restoreFromSnapshotIfValid( ScheduleWithPreparationEntity incoming, ) async { - final snapshot = await _getTimedPreparationSnapshotUseCase(incoming.id); - if (snapshot == null) { - return incoming; - } - if (snapshot.scheduleFingerprint != incoming.cacheFingerprint && - !_canRestoreAcrossFingerprintMismatch(snapshot, incoming)) { - await _clearPersistedState(incoming.id); - return incoming; - } - - 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( + return _schedulePreparationSessionUseCase.restoreTimedPreparationIfValid( incoming, - restoredPreparation, + now: _nowProvider(), + onRestoredSession: ({required startedAt, required actionEvents}) { + _activePreparationRunStartedAt = startedAt; + _activePreparationActionEvents = actionEvents; + }, ); } - 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, @@ -762,45 +617,8 @@ class ScheduleBloc extends Bloc { ); } - 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); + return _schedulePreparationSessionUseCase.getEarlyStartSession(scheduleId); } Future _saveTimedPreparationSnapshot( @@ -813,9 +631,8 @@ class ScheduleBloc extends Bloc { now.difference(_lastSnapshotSavedAt!) < const Duration(seconds: 5)) { return; } - await _saveTimedPreparationUseCase( + await _schedulePreparationSessionUseCase.saveTimedPreparationSnapshot( schedule, - schedule.preparation, savedAt: now, startedAt: _activePreparationRunStartedAt, actionEvents: _activePreparationActionEvents, @@ -824,8 +641,7 @@ class ScheduleBloc extends Bloc { } Future _clearPersistedState(String scheduleId) async { - await _clearTimedPreparationUseCase(scheduleId); - await _clearEarlyStartSessionUseCase(scheduleId); + await _schedulePreparationSessionUseCase.clearPersistedState(scheduleId); } void _clearActivePreparationRun() { diff --git a/plans/issue-535-use-case-classification.md b/plans/issue-535-use-case-classification.md new file mode 100644 index 00000000..6cb8cfe3 --- /dev/null +++ b/plans/issue-535-use-case-classification.md @@ -0,0 +1,84 @@ +# Issue 535 Use Case Classification + +Issue #535 asks for a deletion-test audit of `lib/domain/use-cases/`. This +plan classifies every current use case as of this branch and records the +remaining consolidation work after the schedule preparation session slice. + +## Classification Key + +- **Keep**: hides workflow policy, orchestration, reconciliation, date/range + semantics, cleanup, or approved event semantics. +- **Deepen**: useful workflow boundary, but should absorb more adjacent shallow + operations before being considered done. +- **Consolidate**: shallow pass-through or one step in a larger workflow; keep + only until callers move to the deeper workflow. +- **Delete**: remove after callers and tests no longer need the wrapper. + +## Current Use Cases + +| Use case | Classification | Notes | +| --- | --- | --- | +| `cancel_all_alarms_use_case.dart` | Keep | Multi-provider cleanup plus device unregister policy. | +| `cancel_schedule_alarm_use_case.dart` | Keep | Idempotent native/fallback cancellation and registry cleanup. | +| `clear_early_start_session_use_case.dart` | Delete | Folded into `SchedulePreparationSessionUseCase.clearPersistedState`. | +| `clear_timed_preparation_use_case.dart` | Delete | Folded into `SchedulePreparationSessionUseCase.clearPersistedState`. | +| `create_custom_preparation_use_case.dart` | Consolidate | Part of preparation/template authoring workflow. | +| `create_preparation_template_use_case.dart` | Consolidate | Part of preparation template CRUD workflow. | +| `create_schedule_with_place_use_case.dart` | Keep | Schedule mutation plus alarm reconciliation. | +| `delete_preparation_template_use_case.dart` | Consolidate | Part of preparation template CRUD workflow. | +| `delete_schedule_use_case.dart` | Keep | Schedule deletion, scheduled delivery cleanup, reconciliation. | +| `delete_user_use_case.dart` | Keep | Account deletion workflow boundary. | +| `finish_schedule_use_case.dart` | Delete | Folded into `SchedulePreparationSessionUseCase.finishSchedulePreparation` for `ScheduleBloc`; remove after other callers migrate. | +| `get_adjacent_schedules_with_preparation_use_case.dart` | Keep | Date-adjacent schedule/preparation composition. | +| `get_default_preparation_use_case.dart` | Consolidate | Part of default preparation workflow. | +| `get_early_start_session_use_case.dart` | Delete | Folded into `SchedulePreparationSessionUseCase.hasEarlyStartSession`. | +| `get_nearest_upcoming_schedule_use_case.dart` | Keep | Upcoming schedule selection and preparation loading semantics. | +| `get_preparation_by_schedule_id_use_case.dart` | Consolidate | Cache read step in schedule/preparation loading workflows. | +| `get_preparation_template_use_case.dart` | Consolidate | Part of preparation template workflow. | +| `get_preparation_templates_use_case.dart` | Consolidate | Part of preparation template workflow. | +| `get_schedule_by_id_use_case.dart` | Consolidate | Retrieval step in prompt/form workflows. | +| `get_schedules_by_date_use_case.dart` | Consolidate | Lower-level schedule calendar read step. | +| `get_timed_preparation_snapshot_use_case.dart` | Delete | Folded into `SchedulePreparationSessionUseCase.restoreTimedPreparationIfValid`. | +| `load_adjacent_schedule_with_preparation_use_case.dart` | Keep | Coordinates date-range loading with preparation prefetch. | +| `load_analytics_preference_use_case.dart` | Keep | Account/install preference loading boundary. | +| `load_preparation_by_schedule_id_use_case.dart` | Consolidate | Cache refresh step in schedule/preparation loading workflows. | +| `load_schedules_by_date_use_case.dart` | Consolidate | Lower-level schedule calendar loading step. | +| `load_schedules_for_month_use_case.dart` | Consolidate | Calendar workflow candidate with date-range semantics. | +| `load_schedules_for_week_use_case.dart` | Consolidate | Calendar workflow candidate with date-range semantics. | +| `load_user_use_case.dart` | Consolidate | Shallow user repository stream/read wrapper. | +| `mark_early_start_session_use_case.dart` | Delete | Folded into `SchedulePreparationSessionUseCase.startEarlySession`. | +| `onboard_use_case.dart` | Keep | Onboarding completion workflow. | +| `reconcile_alarms_use_case.dart` | Keep | Deep schedule delivery reconciliation workflow. | +| `save_timed_preparation_use_case.dart` | Delete | Folded into `SchedulePreparationSessionUseCase.saveTimedPreparationSnapshot`. | +| `schedule_preparation_session_use_case.dart` | Deepen | New workflow boundary for active preparation session behavior. | +| `sign_out_use_case.dart` | Keep | Sign-out cleanup ordering. | +| `start_schedule_use_case.dart` | Delete | Folded into `SchedulePreparationSessionUseCase.startSchedulePreparation`. | +| `stream_preparations_use_case.dart` | Consolidate | Lower-level preparation cache stream wrapper. | +| `stream_user_use_case.dart` | Consolidate | Shallow user repository stream wrapper. | +| `track_product_usage_event_use_case.dart` | Keep | Approved analytics event boundary. | +| `update_analytics_preference_use_case.dart` | Keep | Preference mutation plus persistence boundary. | +| `update_default_preparation_use_case.dart` | Consolidate | Part of default preparation workflow. | +| `update_preparation_by_schedule_id_use_case.dart` | Consolidate | Part of schedule-specific preparation workflow. | +| `update_preparation_template_use_case.dart` | Consolidate | Part of preparation template workflow. | +| `update_schedule_use_case.dart` | Keep | Schedule mutation plus alarm reconciliation. | +| `update_spare_time_use_case.dart` | Consolidate | Part of default preparation workflow. | + +## Slice Completed In This Branch + +- Introduce `SchedulePreparationSessionUseCase` for early starts, session start + dedupe, timed-preparation snapshot save/restore/clear, alarm prompt + validation, and session finish cleanup. +- Reduce `ScheduleBloc` constructor dependencies from separate session, + snapshot, prompt-loading, start, finish, and alarm cleanup use cases to one + workflow dependency. +- Keep UI-owned timer, navigation, and step-notification behavior inside + `ScheduleBloc`. + +## Remaining Checklist + +- Migrate any non-`ScheduleBloc` callers off the deleted-category wrappers. +- Delete the obsolete shallow wrappers once caller migration is complete. +- Consolidate preparation/template CRUD into one workflow-level use case. +- Consolidate calendar date/week/month loading into one calendar workflow + boundary. +- Revisit user stream/load wrappers after auth/account workflows are clarified. diff --git a/test/domain/use-cases/schedule_preparation_session_use_case_test.dart b/test/domain/use-cases/schedule_preparation_session_use_case_test.dart new file mode 100644 index 00000000..cc3e84df --- /dev/null +++ b/test/domain/use-cases/schedule_preparation_session_use_case_test.dart @@ -0,0 +1,462 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/domain/entities/early_start_session_entity.dart'; +import 'package:on_time_front/domain/entities/place_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'; +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/repositories/early_start_session_repository.dart'; +import 'package:on_time_front/domain/repositories/preparation_repository.dart'; +import 'package:on_time_front/domain/repositories/schedule_repository.dart'; +import 'package:on_time_front/domain/repositories/timed_preparation_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; + +void main() { + test( + 'early start begins the schedule preparation session once and saves progress', + () async { + final scheduleRepository = _FakeScheduleRepository(); + final timedPreparationRepository = _FakeTimedPreparationRepository(); + final earlyStartSessionRepository = _FakeEarlyStartSessionRepository(); + final cancelScheduleAlarmUseCase = _FakeCancelScheduleAlarmUseCase(); + final useCase = SchedulePreparationSessionUseCase( + scheduleRepository, + _FakePreparationRepository(), + timedPreparationRepository, + earlyStartSessionRepository, + cancelScheduleAlarmUseCase, + _FakeReconcileAlarmsUseCase(), + ); + final schedule = _scheduleWithPreparation('schedule-1'); + final startedAt = DateTime.utc(2026, 6, 28, 8); + + await useCase.startEarlySession(schedule, startedAt: startedAt); + await useCase.startSchedulePreparation(schedule.id); + + expect( + earlyStartSessionRepository.sessions[schedule.id], + EarlyStartSessionEntity(scheduleId: schedule.id, startedAt: startedAt), + ); + expect(scheduleRepository.startedScheduleIds, [schedule.id]); + expect(cancelScheduleAlarmUseCase.cancelledScheduleIds, [schedule.id]); + + final snapshot = timedPreparationRepository.snapshots[schedule.id]!; + expect(snapshot.preparation, schedule.preparation); + expect(snapshot.savedAt, startedAt); + expect(snapshot.scheduleFingerprint, schedule.cacheFingerprint); + }, + ); + + test( + 'matching timed preparation snapshot restores elapsed session progress', + () async { + final timedPreparationRepository = _FakeTimedPreparationRepository(); + final useCase = SchedulePreparationSessionUseCase( + _FakeScheduleRepository(), + _FakePreparationRepository(), + timedPreparationRepository, + _FakeEarlyStartSessionRepository(), + _FakeCancelScheduleAlarmUseCase(), + _FakeReconcileAlarmsUseCase(), + ); + final schedule = _scheduleWithPreparation('schedule-1'); + final now = DateTime.utc(2026, 6, 28, 8, 10); + final savedPreparation = schedule.preparation.timeElapsed( + const Duration(minutes: 4), + ); + timedPreparationRepository.snapshots[schedule.id] = + TimedPreparationSnapshotEntity( + preparation: savedPreparation, + savedAt: now.subtract(const Duration(minutes: 2)), + scheduleFingerprint: schedule.cacheFingerprint, + ); + + final restored = await useCase.restoreTimedPreparationIfValid( + schedule, + now: now, + ); + + expect( + restored.preparation.currentStep!.elapsedTime, + const Duration(minutes: 6), + ); + expect(timedPreparationRepository.clearedScheduleIds, isEmpty); + }, + ); + + test( + 'stale timed preparation snapshot clears persisted session state', + () async { + final timedPreparationRepository = _FakeTimedPreparationRepository(); + final earlyStartSessionRepository = _FakeEarlyStartSessionRepository(); + final useCase = SchedulePreparationSessionUseCase( + _FakeScheduleRepository(), + _FakePreparationRepository(), + timedPreparationRepository, + earlyStartSessionRepository, + _FakeCancelScheduleAlarmUseCase(), + _FakeReconcileAlarmsUseCase(), + ); + final schedule = _scheduleWithPreparation('schedule-1'); + final startedAt = DateTime.utc(2026, 6, 28, 7, 50); + earlyStartSessionRepository.sessions[schedule.id] = + EarlyStartSessionEntity( + scheduleId: schedule.id, + startedAt: startedAt, + ); + timedPreparationRepository.snapshots[schedule.id] = + TimedPreparationSnapshotEntity( + preparation: schedule.preparation.timeElapsed( + const Duration(minutes: 4), + ), + savedAt: startedAt, + scheduleFingerprint: 'old-fingerprint', + ); + + final restored = await useCase.restoreTimedPreparationIfValid( + schedule, + now: DateTime.utc(2026, 6, 28, 8), + ); + + expect(restored, schedule); + expect(timedPreparationRepository.clearedScheduleIds, [schedule.id]); + expect(earlyStartSessionRepository.clearedScheduleIds, [schedule.id]); + }, + ); + + test( + 'ended prompted schedule is rejected and scheduled delivery is cancelled', + () async { + final scheduleRepository = _FakeScheduleRepository(); + final preparationRepository = _FakePreparationRepository(); + final cancelScheduleAlarmUseCase = _FakeCancelScheduleAlarmUseCase(); + final useCase = SchedulePreparationSessionUseCase( + scheduleRepository, + preparationRepository, + _FakeTimedPreparationRepository(), + _FakeEarlyStartSessionRepository(), + cancelScheduleAlarmUseCase, + _FakeReconcileAlarmsUseCase(), + ); + scheduleRepository.schedulesById['schedule-1'] = _scheduleEntity( + 'schedule-1', + doneStatus: ScheduleDoneStatus.normalEnd, + ); + + final result = await useCase.resolvePromptedSchedule( + scheduleId: 'schedule-1', + startPreparation: false, + ); + + expect(result.status, SchedulePreparationPromptStatus.rejected); + expect(result.schedule, isNull); + expect(cancelScheduleAlarmUseCase.cancelledScheduleIds, ['schedule-1']); + expect(preparationRepository.loadedScheduleIds, isEmpty); + }, + ); + + test( + 'valid prompted schedule loads preparation and returns ready schedule', + () async { + final scheduleRepository = _FakeScheduleRepository(); + final preparationRepository = _FakePreparationRepository(); + final cancelScheduleAlarmUseCase = _FakeCancelScheduleAlarmUseCase(); + final useCase = SchedulePreparationSessionUseCase( + scheduleRepository, + preparationRepository, + _FakeTimedPreparationRepository(), + _FakeEarlyStartSessionRepository(), + cancelScheduleAlarmUseCase, + _FakeReconcileAlarmsUseCase(), + ); + final schedule = _scheduleEntity('schedule-1'); + final preparation = _preparation('prep-1'); + final expectedSchedule = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + PreparationWithTimeEntity.fromPreparation(preparation), + ); + scheduleRepository.schedulesById[schedule.id] = schedule; + preparationRepository.preparationsById[schedule.id] = preparation; + + final result = await useCase.resolvePromptedSchedule( + scheduleId: schedule.id, + startPreparation: false, + scheduleFingerprint: expectedSchedule.cacheFingerprint, + ); + + expect(result.status, SchedulePreparationPromptStatus.ready); + expect(result.schedule, expectedSchedule); + expect(preparationRepository.loadedScheduleIds, [schedule.id]); + expect(cancelScheduleAlarmUseCase.cancelledScheduleIds, isEmpty); + }, + ); + + test('prompt validation failure rejects only non-start prompts', () async { + final scheduleRepository = _FakeScheduleRepository() + ..throwingIds.addAll({'ready-prompt', 'start-prompt'}); + final cancelScheduleAlarmUseCase = _FakeCancelScheduleAlarmUseCase(); + final useCase = SchedulePreparationSessionUseCase( + scheduleRepository, + _FakePreparationRepository(), + _FakeTimedPreparationRepository(), + _FakeEarlyStartSessionRepository(), + cancelScheduleAlarmUseCase, + _FakeReconcileAlarmsUseCase(), + ); + + final readyPrompt = await useCase.resolvePromptedSchedule( + scheduleId: 'ready-prompt', + startPreparation: false, + ); + final startPrompt = await useCase.resolvePromptedSchedule( + scheduleId: 'start-prompt', + startPreparation: true, + ); + + expect(readyPrompt.status, SchedulePreparationPromptStatus.rejected); + expect(startPrompt.status, SchedulePreparationPromptStatus.unavailable); + expect(cancelScheduleAlarmUseCase.cancelledScheduleIds, ['ready-prompt']); + }); + + test( + 'finish completes the preparation session and clears scheduled delivery', + () async { + final scheduleRepository = _FakeScheduleRepository(); + final timedPreparationRepository = _FakeTimedPreparationRepository(); + final earlyStartSessionRepository = _FakeEarlyStartSessionRepository(); + final cancelScheduleAlarmUseCase = _FakeCancelScheduleAlarmUseCase(); + final reconcileAlarmsUseCase = _FakeReconcileAlarmsUseCase(); + final useCase = SchedulePreparationSessionUseCase( + scheduleRepository, + _FakePreparationRepository(), + timedPreparationRepository, + earlyStartSessionRepository, + cancelScheduleAlarmUseCase, + reconcileAlarmsUseCase, + ); + final schedule = _scheduleWithPreparation('schedule-1'); + timedPreparationRepository.snapshots[schedule.id] = + TimedPreparationSnapshotEntity( + preparation: schedule.preparation, + savedAt: DateTime.utc(2026, 6, 28, 8), + scheduleFingerprint: schedule.cacheFingerprint, + ); + earlyStartSessionRepository.sessions[schedule.id] = + EarlyStartSessionEntity( + scheduleId: schedule.id, + startedAt: DateTime.utc(2026, 6, 28, 7, 55), + ); + + await useCase.finishSchedulePreparation(schedule.id, latenessTime: 7); + await pumpEventQueue(); + + expect(scheduleRepository.startedScheduleIds, [schedule.id]); + expect(scheduleRepository.finishedSchedules, [(schedule.id, 7)]); + expect(cancelScheduleAlarmUseCase.cancelledScheduleIds, [schedule.id]); + expect(timedPreparationRepository.clearedScheduleIds, [schedule.id]); + expect(earlyStartSessionRepository.clearedScheduleIds, [schedule.id]); + expect(reconcileAlarmsUseCase.callCount, 1); + }, + ); +} + +ScheduleWithPreparationEntity _scheduleWithPreparation(String id) { + final schedule = _scheduleEntity(id); + return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + const PreparationWithTimeEntity( + preparationStepList: [ + PreparationStepWithTimeEntity( + id: 'prep-1', + preparationName: 'Pack', + preparationTime: Duration(minutes: 15), + nextPreparationId: null, + ), + ], + ), + ); +} + +ScheduleEntity _scheduleEntity( + String id, { + ScheduleDoneStatus doneStatus = ScheduleDoneStatus.notEnded, +}) { + return ScheduleWithPreparationEntity( + id: id, + place: const PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: DateTime.utc(2026, 6, 28, 9), + moveTime: const Duration(minutes: 10), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 5), + scheduleNote: '', + doneStatus: doneStatus, + preparation: const PreparationWithTimeEntity(preparationStepList: []), + ); +} + +PreparationEntity _preparation(String id) { + return PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: id, + preparationName: 'Pack', + preparationTime: const Duration(minutes: 15), + ), + ], + ); +} + +class _FakeScheduleRepository implements ScheduleRepository { + final startedScheduleIds = []; + final finishedSchedules = <(String, int)>[]; + final schedulesById = {}; + final throwingIds = {}; + + @override + Stream> get scheduleStream => const Stream.empty(); + + @override + Stream> watchSchedulesByDate( + DateTime startDate, + DateTime endDate, + ) => const Stream.empty(); + + @override + Future startSchedule(String scheduleId) async { + startedScheduleIds.add(scheduleId); + } + + @override + Future finishSchedule(String scheduleId, int latenessTime) async { + finishedSchedules.add((scheduleId, latenessTime)); + } + + @override + Future getScheduleById(String id) async { + if (throwingIds.contains(id)) { + throw Exception('schedule unavailable'); + } + return schedulesById[id] ?? _scheduleWithPreparation(id); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakePreparationRepository implements PreparationRepository { + final controller = + StreamController>.broadcast(); + final loadedScheduleIds = []; + final preparationsById = {}; + + @override + Stream> get preparationStream => + controller.stream; + + @override + Future getPreparationByScheduleId(String scheduleId) async { + loadedScheduleIds.add(scheduleId); + final preparation = preparationsById[scheduleId]; + if (preparation != null) { + controller.add({scheduleId: preparation}); + } + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeTimedPreparationRepository implements TimedPreparationRepository { + final snapshots = {}; + final clearedScheduleIds = []; + + @override + Future saveTimedPreparationSnapshot( + String scheduleId, + TimedPreparationSnapshotEntity snapshot, + ) async { + snapshots[scheduleId] = snapshot; + } + + @override + Future getTimedPreparationSnapshot( + String scheduleId, + ) async { + return snapshots[scheduleId]; + } + + @override + Future clearTimedPreparation(String scheduleId) async { + clearedScheduleIds.add(scheduleId); + snapshots.remove(scheduleId); + } +} + +class _FakeEarlyStartSessionRepository implements EarlyStartSessionRepository { + final sessions = {}; + final clearedScheduleIds = []; + + @override + Future markStarted({ + required String scheduleId, + required DateTime startedAt, + }) async { + sessions[scheduleId] = EarlyStartSessionEntity( + scheduleId: scheduleId, + startedAt: startedAt, + ); + } + + @override + Future getSession(String scheduleId) async { + return sessions[scheduleId]; + } + + @override + Future clear(String scheduleId) async { + clearedScheduleIds.add(scheduleId); + sessions.remove(scheduleId); + } +} + +class _FakeCancelScheduleAlarmUseCase implements CancelScheduleAlarmUseCase { + final cancelledScheduleIds = []; + + @override + Future call(String scheduleId) async { + cancelledScheduleIds.add(scheduleId); + } +} + +class _FakeReconcileAlarmsUseCase implements ReconcileAlarmsUseCase { + int callCount = 0; + + @override + Future call() async { + callCount += 1; + final now = DateTime.utc(2026, 6, 28, 8); + return AlarmReconciliationResult( + status: AlarmReconciliationStatus.armed, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const [], + skippedScheduleCount: 0, + failures: const [], + scheduleWindowStart: now, + scheduleWindowEnd: now, + alarmCoverageStart: now, + alarmCoverageEnd: now, + ); + } +} diff --git a/test/presentation/alarm/screens/preparation_flow_widget_test.dart b/test/presentation/alarm/screens/preparation_flow_widget_test.dart index 908a3ad6..7f7e08a6 100644 --- a/test/presentation/alarm/screens/preparation_flow_widget_test.dart +++ b/test/presentation/alarm/screens/preparation_flow_widget_test.dart @@ -21,6 +21,7 @@ import 'package:on_time_front/domain/use-cases/get_nearest_upcoming_schedule_use import 'package:on_time_front/domain/use-cases/get_early_start_session_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_timed_preparation_snapshot_use_case.dart'; import 'package:on_time_front/domain/use-cases/mark_early_start_session_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; import 'package:on_time_front/domain/use-cases/save_timed_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/start_schedule_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; @@ -167,6 +168,150 @@ EarlyStartUseCaseBundle createEarlyStartUseCaseBundle() { ); } +class TestSchedulePreparationSessionUseCase + implements SchedulePreparationSessionUseCase { + TestSchedulePreparationSessionUseCase({ + required this.saveTimedPreparationUseCase, + required this.getTimedPreparationSnapshotUseCase, + required this.clearTimedPreparationUseCase, + required this.startScheduleUseCase, + required this.finishScheduleUseCase, + required this.markEarlyStartSessionUseCase, + required this.getEarlyStartSessionUseCase, + required this.clearEarlyStartSessionUseCase, + }); + + final SaveTimedPreparationUseCase saveTimedPreparationUseCase; + final GetTimedPreparationSnapshotUseCase getTimedPreparationSnapshotUseCase; + final ClearTimedPreparationUseCase clearTimedPreparationUseCase; + final StartScheduleUseCase startScheduleUseCase; + final FinishScheduleUseCase finishScheduleUseCase; + final MarkEarlyStartSessionUseCase markEarlyStartSessionUseCase; + final GetEarlyStartSessionUseCase getEarlyStartSessionUseCase; + final ClearEarlyStartSessionUseCase clearEarlyStartSessionUseCase; + final _startedScheduleIds = {}; + + @override + Future startEarlySession( + ScheduleWithPreparationEntity schedule, { + required DateTime startedAt, + }) async { + await markEarlyStartSessionUseCase( + scheduleId: schedule.id, + startedAt: startedAt, + ); + await startSchedulePreparation(schedule.id); + await saveTimedPreparationSnapshot( + schedule, + savedAt: startedAt, + startedAt: startedAt, + ); + } + + @override + Future startSchedulePreparation(String scheduleId) async { + if (!_startedScheduleIds.add(scheduleId)) return; + await startScheduleUseCase(scheduleId); + } + + @override + Future hasEarlyStartSession(String scheduleId) async { + return await getEarlyStartSessionUseCase(scheduleId) != null; + } + + @override + Future getEarlyStartSession(String scheduleId) { + return getEarlyStartSessionUseCase(scheduleId); + } + + @override + Future saveTimedPreparationSnapshot( + ScheduleWithPreparationEntity schedule, { + DateTime? savedAt, + DateTime? startedAt, + List actionEvents = const [], + }) { + return saveTimedPreparationUseCase( + schedule, + schedule.preparation, + savedAt: savedAt, + startedAt: startedAt, + actionEvents: actionEvents, + ); + } + + @override + Future restoreTimedPreparationIfValid( + ScheduleWithPreparationEntity schedule, { + required DateTime now, + RestoredSessionCallback? onRestoredSession, + }) async { + final snapshot = await getTimedPreparationSnapshotUseCase(schedule.id); + if (snapshot == null) return schedule; + if (snapshot.scheduleFingerprint != schedule.cacheFingerprint) { + await clearPersistedState(schedule.id); + return schedule; + } + onRestoredSession?.call( + startedAt: snapshot.startedAt, + actionEvents: snapshot.actionEvents, + ); + final elapsedSinceSave = now.difference(snapshot.savedAt); + final restoredPreparation = elapsedSinceSave.isNegative + ? snapshot.preparation + : snapshot.preparation.timeElapsed(elapsedSinceSave); + return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + restoredPreparation, + ); + } + + @override + Future clearPersistedState(String scheduleId) async { + await clearTimedPreparationUseCase(scheduleId); + await clearEarlyStartSessionUseCase(scheduleId); + _startedScheduleIds.remove(scheduleId); + } + + @override + Future finishSchedulePreparation( + String scheduleId, { + required int latenessTime, + }) async { + await finishScheduleUseCase(scheduleId, latenessTime); + await clearPersistedState(scheduleId); + } + + @override + Future resolvePromptedSchedule({ + required String scheduleId, + required bool startPreparation, + String? scheduleFingerprint, + }) async { + return const SchedulePreparationPromptResult.unavailable(); + } +} + +TestSchedulePreparationSessionUseCase createSessionUseCase({ + required SaveTimedPreparationUseCase saveUseCase, + required GetTimedPreparationSnapshotUseCase getSnapshotUseCase, + required ClearTimedPreparationUseCase clearTimedUseCase, + required StartScheduleUseCase startUseCase, + required FinishScheduleUseCase finishUseCase, + required EarlyStartUseCaseBundle earlyBundle, +}) { + return TestSchedulePreparationSessionUseCase( + saveTimedPreparationUseCase: saveUseCase, + getTimedPreparationSnapshotUseCase: getSnapshotUseCase, + clearTimedPreparationUseCase: clearTimedUseCase, + startScheduleUseCase: startUseCase, + finishScheduleUseCase: finishUseCase, + markEarlyStartSessionUseCase: earlyBundle.markUseCase, + getEarlyStartSessionUseCase: earlyBundle.getUseCase, + clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + ); +} + ScheduleWithPreparationEntity buildSchedule({ required String id, required DateTime scheduleTime, @@ -362,14 +507,16 @@ void main() { bloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => controller.stream), navigationService, - NoopSaveTimedPreparationUseCase(), - getSnapshotUseCase, - clearTimedUseCase, - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: markEarlyStartUseCase, - getEarlyStartSessionUseCase: getEarlyStartUseCase, - clearEarlyStartSessionUseCase: clearEarlyStartUseCase, + TestSchedulePreparationSessionUseCase( + saveTimedPreparationUseCase: NoopSaveTimedPreparationUseCase(), + getTimedPreparationSnapshotUseCase: getSnapshotUseCase, + clearTimedPreparationUseCase: clearTimedUseCase, + startScheduleUseCase: startUseCase, + finishScheduleUseCase: finishUseCase, + markEarlyStartSessionUseCase: markEarlyStartUseCase, + getEarlyStartSessionUseCase: getEarlyStartUseCase, + clearEarlyStartSessionUseCase: clearEarlyStartUseCase, + ), nowProvider: () => now, ); }); @@ -928,14 +1075,14 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, ); addTearDown(alarmBloc.close); @@ -1084,14 +1231,14 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, ); addTearDown(alarmBloc.close); @@ -1152,14 +1299,14 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, ); addTearDown(alarmBloc.close); @@ -1221,14 +1368,14 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, ); addTearDown(alarmBloc.close); @@ -1285,14 +1432,14 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, ); addTearDown(alarmBloc.close); @@ -1392,14 +1539,14 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, ); addTearDown(alarmBloc.close); @@ -1464,14 +1611,14 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, ); addTearDown(alarmBloc.close); @@ -1585,14 +1732,14 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => Stream.value(schedule)), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, ); addTearDown(alarmBloc.close); @@ -1633,14 +1780,14 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => const Stream.empty()), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, )..emit(const ScheduleState.notExists()); addTearDown(alarmBloc.close); @@ -1679,14 +1826,14 @@ void main() { final alarmBloc = ScheduleBloc.test( StubGetNearestUpcomingScheduleUseCase(() => Stream.value(null)), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, )..emit(const ScheduleState.notExists()); addTearDown(alarmBloc.close); @@ -1740,14 +1887,14 @@ void main() { () => Stream.value(staleEndedSchedule), ), navigationService, - NoopSaveTimedPreparationUseCase(), - StubGetTimedPreparationSnapshotUseCase({}), - NoopClearTimedPreparationUseCase(), - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: earlyBundle.markUseCase, - getEarlyStartSessionUseCase: earlyBundle.getUseCase, - clearEarlyStartSessionUseCase: earlyBundle.clearUseCase, + createSessionUseCase( + saveUseCase: NoopSaveTimedPreparationUseCase(), + getSnapshotUseCase: StubGetTimedPreparationSnapshotUseCase({}), + clearTimedUseCase: NoopClearTimedPreparationUseCase(), + startUseCase: startUseCase, + finishUseCase: finishUseCase, + earlyBundle: earlyBundle, + ), nowProvider: () => now, ); addTearDown(alarmBloc.close); diff --git a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart index 6e0c2498..8578f5d7 100644 --- a/test/presentation/app/bloc/schedule/schedule_bloc_test.dart +++ b/test/presentation/app/bloc/schedule/schedule_bloc_test.dart @@ -23,6 +23,7 @@ import 'package:on_time_front/domain/use-cases/get_schedule_by_id_use_case.dart' import 'package:on_time_front/domain/use-cases/get_timed_preparation_snapshot_use_case.dart'; import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/mark_early_start_session_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_preparation_session_use_case.dart'; import 'package:on_time_front/domain/use-cases/save_timed_preparation_use_case.dart'; import 'package:on_time_front/domain/use-cases/start_schedule_use_case.dart'; import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; @@ -201,6 +202,310 @@ class SpyClearEarlyStartSessionUseCase } } +class FakeSchedulePreparationSessionUseCase + implements SchedulePreparationSessionUseCase { + FakeSchedulePreparationSessionUseCase({ + required this.saveTimedPreparationUseCase, + required this.getTimedPreparationSnapshotUseCase, + required this.clearTimedPreparationUseCase, + required this.startScheduleUseCase, + required this.finishScheduleUseCase, + required this.markEarlyStartSessionUseCase, + required this.getEarlyStartSessionUseCase, + required this.clearEarlyStartSessionUseCase, + required this.cancelScheduleAlarmUseCase, + required this.getScheduleByIdUseCase, + required this.loadPreparationByScheduleIdUseCase, + required this.getPreparationByScheduleIdUseCase, + }); + + final SpySaveTimedPreparationUseCase saveTimedPreparationUseCase; + final StubGetTimedPreparationSnapshotUseCase + getTimedPreparationSnapshotUseCase; + final SpyClearTimedPreparationUseCase clearTimedPreparationUseCase; + final SpyStartScheduleUseCase startScheduleUseCase; + final SpyFinishScheduleUseCase finishScheduleUseCase; + final SpyMarkEarlyStartSessionUseCase markEarlyStartSessionUseCase; + final StubGetEarlyStartSessionUseCase getEarlyStartSessionUseCase; + final SpyClearEarlyStartSessionUseCase clearEarlyStartSessionUseCase; + final SpyCancelScheduleAlarmUseCase cancelScheduleAlarmUseCase; + final StubGetScheduleByIdUseCase getScheduleByIdUseCase; + final SpyLoadPreparationByScheduleIdUseCase + loadPreparationByScheduleIdUseCase; + final StubGetPreparationByScheduleIdUseCase getPreparationByScheduleIdUseCase; + final unavailablePromptIds = {}; + final _startedScheduleIds = {}; + + @override + Future startEarlySession( + ScheduleWithPreparationEntity schedule, { + required DateTime startedAt, + }) async { + await markEarlyStartSessionUseCase( + scheduleId: schedule.id, + startedAt: startedAt, + ); + await startSchedulePreparation(schedule.id); + await cancelScheduleAlarmUseCase(schedule.id); + await saveTimedPreparationSnapshot( + schedule, + savedAt: startedAt, + startedAt: startedAt, + actionEvents: const [], + ); + } + + @override + Future startSchedulePreparation(String scheduleId) async { + if (!_startedScheduleIds.add(scheduleId)) return; + await startScheduleUseCase(scheduleId); + } + + @override + Future hasEarlyStartSession(String scheduleId) async { + return await getEarlyStartSessionUseCase(scheduleId) != null; + } + + @override + Future getEarlyStartSession(String scheduleId) { + return getEarlyStartSessionUseCase(scheduleId); + } + + @override + Future saveTimedPreparationSnapshot( + ScheduleWithPreparationEntity schedule, { + DateTime? savedAt, + DateTime? startedAt, + List actionEvents = const [], + }) { + return saveTimedPreparationUseCase( + schedule, + schedule.preparation, + savedAt: savedAt, + startedAt: startedAt, + actionEvents: actionEvents, + ); + } + + @override + Future restoreTimedPreparationIfValid( + ScheduleWithPreparationEntity schedule, { + required DateTime now, + RestoredSessionCallback? onRestoredSession, + }) async { + final snapshot = await getTimedPreparationSnapshotUseCase(schedule.id); + if (snapshot == null) return schedule; + if (snapshot.scheduleFingerprint != schedule.cacheFingerprint && + !_canRestoreAcrossFingerprintMismatch(snapshot, schedule)) { + await clearPersistedState(schedule.id); + return schedule; + } + + final startedAt = snapshot.startedAt; + onRestoredSession?.call( + startedAt: startedAt, + actionEvents: snapshot.actionEvents, + ); + final restoredPreparation = startedAt == null + ? _restoreElapsedSnapshot(snapshot, now) + : _derivePreparationRun( + schedule.preparation, + startedAt: startedAt, + actionEvents: snapshot.actionEvents, + now: now, + ); + return ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + restoredPreparation, + ); + } + + @override + Future clearPersistedState(String scheduleId) async { + await clearTimedPreparationUseCase(scheduleId); + await clearEarlyStartSessionUseCase(scheduleId); + _startedScheduleIds.remove(scheduleId); + } + + @override + Future finishSchedulePreparation( + String scheduleId, { + required int latenessTime, + }) async { + await finishScheduleUseCase(scheduleId, latenessTime); + await clearPersistedState(scheduleId); + } + + @override + Future resolvePromptedSchedule({ + required String scheduleId, + required bool startPreparation, + String? scheduleFingerprint, + }) async { + if (unavailablePromptIds.contains(scheduleId)) { + return const SchedulePreparationPromptResult.unavailable(); + } + + try { + final schedule = await getScheduleByIdUseCase(scheduleId); + if (_isEnded(schedule.doneStatus)) { + await cancelScheduleAlarmUseCase(scheduleId); + return const SchedulePreparationPromptResult.rejected(); + } + + await loadPreparationByScheduleIdUseCase(scheduleId); + final preparation = await getPreparationByScheduleIdUseCase(scheduleId); + final scheduleWithPreparation = + ScheduleWithPreparationEntity.fromScheduleAndPreparationEntity( + schedule, + PreparationWithTimeEntity.fromPreparation(preparation), + ); + if (scheduleFingerprint != null && + scheduleFingerprint != scheduleWithPreparation.cacheFingerprint && + !startPreparation) { + await cancelScheduleAlarmUseCase(scheduleId); + return const SchedulePreparationPromptResult.rejected(); + } + return SchedulePreparationPromptResult.ready(scheduleWithPreparation); + } catch (_) { + if (startPreparation) { + return const SchedulePreparationPromptResult.unavailable(); + } + await cancelScheduleAlarmUseCase(scheduleId); + return const SchedulePreparationPromptResult.rejected(); + } + } + + bool _isEnded(ScheduleDoneStatus doneStatus) { + return doneStatus == ScheduleDoneStatus.normalEnd || + doneStatus == ScheduleDoneStatus.lateEnd || + doneStatus == ScheduleDoneStatus.abnormalEnd; + } + + PreparationWithTimeEntity _restoreElapsedSnapshot( + TimedPreparationSnapshotEntity snapshot, + DateTime now, + ) { + final elapsedSinceSave = now.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 schedule, + ) { + return _scheduleTimingFingerprintPrefix(snapshot.scheduleFingerprint) == + _scheduleTimingFingerprintPrefix(schedule.cacheFingerprint) && + _hasSamePreparationShape(snapshot.preparation, schedule.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; + } +} + ScheduleWithPreparationEntity buildSchedule({ required String id, required DateTime scheduleTime, @@ -256,6 +561,7 @@ void main() { loadPreparationByScheduleIdUseCase; late StubGetPreparationByScheduleIdUseCase getPreparationByScheduleIdUseCase; + late FakeSchedulePreparationSessionUseCase sessionUseCase; late DateTime now; late List notifiedStepIds; @@ -282,16 +588,12 @@ void main() { SpyLoadPreparationByScheduleIdUseCase(); getPreparationByScheduleIdUseCase = StubGetPreparationByScheduleIdUseCase(); - now = DateTime(2026, 3, 20, 9, 0, 0); - notifiedStepIds = []; - bloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => controller.stream), - navigationService, - saveUseCase, - getSnapshotUseCase, - clearTimedUseCase, - startUseCase, - finishUseCase, + sessionUseCase = FakeSchedulePreparationSessionUseCase( + saveTimedPreparationUseCase: saveUseCase, + getTimedPreparationSnapshotUseCase: getSnapshotUseCase, + clearTimedPreparationUseCase: clearTimedUseCase, + startScheduleUseCase: startUseCase, + finishScheduleUseCase: finishUseCase, markEarlyStartSessionUseCase: markEarlySessionUseCase, getEarlyStartSessionUseCase: getEarlySessionUseCase, clearEarlyStartSessionUseCase: clearEarlySessionUseCase, @@ -299,6 +601,13 @@ void main() { getScheduleByIdUseCase: getScheduleByIdUseCase, loadPreparationByScheduleIdUseCase: loadPreparationByScheduleIdUseCase, getPreparationByScheduleIdUseCase: getPreparationByScheduleIdUseCase, + ); + now = DateTime(2026, 3, 20, 9, 0, 0); + notifiedStepIds = []; + bloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => controller.stream), + navigationService, + sessionUseCase, nowProvider: () => now, notifyPreparationStep: ({ @@ -324,19 +633,27 @@ void main() { }); test( - 'alarm prompt is ignored when validation use cases are unavailable', + 'alarm prompt is ignored when validation workflow is unavailable', () async { - final minimalBloc = ScheduleBloc.test( - StubGetNearestUpcomingScheduleUseCase(() => const Stream.empty()), - navigationService, - saveUseCase, - getSnapshotUseCase, - clearTimedUseCase, - startUseCase, - finishUseCase, + final unavailableSessionUseCase = FakeSchedulePreparationSessionUseCase( + saveTimedPreparationUseCase: saveUseCase, + getTimedPreparationSnapshotUseCase: getSnapshotUseCase, + clearTimedPreparationUseCase: clearTimedUseCase, + startScheduleUseCase: startUseCase, + finishScheduleUseCase: finishUseCase, markEarlyStartSessionUseCase: markEarlySessionUseCase, getEarlyStartSessionUseCase: getEarlySessionUseCase, clearEarlyStartSessionUseCase: clearEarlySessionUseCase, + cancelScheduleAlarmUseCase: cancelScheduleAlarmUseCase, + getScheduleByIdUseCase: getScheduleByIdUseCase, + loadPreparationByScheduleIdUseCase: + loadPreparationByScheduleIdUseCase, + getPreparationByScheduleIdUseCase: getPreparationByScheduleIdUseCase, + )..unavailablePromptIds.add('missing-validation'); + final minimalBloc = ScheduleBloc.test( + StubGetNearestUpcomingScheduleUseCase(() => const Stream.empty()), + navigationService, + unavailableSessionUseCase, nowProvider: () => now, ); addTearDown(minimalBloc.close); @@ -357,19 +674,7 @@ void main() { final constructedBloc = ScheduleBloc( StubGetNearestUpcomingScheduleUseCase(() => const Stream.empty()), navigationService, - saveUseCase, - getSnapshotUseCase, - clearTimedUseCase, - startUseCase, - finishUseCase, - markEarlyStartSessionUseCase: markEarlySessionUseCase, - getEarlyStartSessionUseCase: getEarlySessionUseCase, - clearEarlyStartSessionUseCase: clearEarlySessionUseCase, - cancelScheduleAlarmUseCase: cancelScheduleAlarmUseCase, - getScheduleByIdUseCase: getScheduleByIdUseCase, - loadPreparationByScheduleIdUseCase: - loadPreparationByScheduleIdUseCase, - getPreparationByScheduleIdUseCase: getPreparationByScheduleIdUseCase, + sessionUseCase, ); addTearDown(constructedBloc.close);