From b1dc47b0f2f66b46ef3d899e37ac6cfab0ecbae7 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Sun, 28 Jun 2026 14:55:42 +0900 Subject: [PATCH] refactor: split schedule form orchestration --- CONTEXT.md | 27 +- ...ate_schedule_form_submission_use_case.dart | 32 + .../load_schedule_form_draft_use_case.dart | 136 +++++ .../use-cases/schedule_analytics_tracker.dart | 9 + .../use-cases/schedule_form_submission.dart | 18 + .../track_schedule_analytics_use_case.dart | 41 ++ ...ate_schedule_form_submission_use_case.dart | 26 + .../bloc/schedule_form_bloc.dart | 182 ++---- .../bloc/schedule_form_event.dart | 21 +- .../screens/schedule_create_screen.dart | 14 +- .../screens/schedule_edit_screen.dart | 3 +- ...chedule_form_submission_use_case_test.dart | 99 +++ ...oad_schedule_form_draft_use_case_test.dart | 81 +++ ...rack_schedule_analytics_use_case_test.dart | 71 +++ ...chedule_form_submission_use_case_test.dart | 80 +++ .../bloc/schedule_form_bloc_test.dart | 573 +++++------------- .../bloc/schedule_form_state_event_test.dart | 11 +- .../schedule_multi_page_form_test.dart | 215 +++---- 18 files changed, 957 insertions(+), 682 deletions(-) create mode 100644 lib/domain/use-cases/create_schedule_form_submission_use_case.dart create mode 100644 lib/domain/use-cases/load_schedule_form_draft_use_case.dart create mode 100644 lib/domain/use-cases/schedule_analytics_tracker.dart create mode 100644 lib/domain/use-cases/schedule_form_submission.dart create mode 100644 lib/domain/use-cases/track_schedule_analytics_use_case.dart create mode 100644 lib/domain/use-cases/update_schedule_form_submission_use_case.dart create mode 100644 test/domain/use-cases/create_schedule_form_submission_use_case_test.dart create mode 100644 test/domain/use-cases/load_schedule_form_draft_use_case_test.dart create mode 100644 test/domain/use-cases/track_schedule_analytics_use_case_test.dart create mode 100644 test/domain/use-cases/update_schedule_form_submission_use_case_test.dart diff --git a/CONTEXT.md b/CONTEXT.md index 39095a5d..39301339 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -53,6 +53,26 @@ _Avoid_: Provider login completed, credential received An allowlisted non-content value attached to a Product Usage Event. _Avoid_: Event payload, arbitrary metadata, raw detail +**Schedule**: +A planned commitment with a place, appointment time, travel time, optional buffer time, and preparation. +_Avoid_: Event, appointment record + +**Preparation**: +An ordered set of steps and durations used to get ready for a Schedule. +_Avoid_: Routine, prep checklist + +**Default Preparation**: +The user's fallback Preparation for new Schedules. +_Avoid_: Base preparation, global preparation + +**Custom Preparation**: +A Schedule-specific Preparation that differs from the user's fallback or selected template. +_Avoid_: Changed preparation, edited default + +**Preparation Mode**: +The category describing whether a Schedule uses Default Preparation, a preparation template, or Custom Preparation. +_Avoid_: Preparation type, preparation source + **Schedule Notification**: A user-facing notification that starts preparation for a scheduled commitment at the intended moment. _Avoid_: Schedule alarm, alarm, push @@ -107,7 +127,11 @@ _Avoid_: Notification, native alarm ## Relationships -- A **Product Usage Event** may describe a schedule, preparation, notification, alarm, onboarding, or account action without storing the user's raw schedule names, notes, place names, credentials, tokens, or free text. +- A **Product Usage Event** may describe a **Schedule**, **Preparation**, notification, alarm, onboarding, or account action without storing the user's raw schedule names, notes, place names, credentials, tokens, or free text. +- A **Schedule** has one effective **Preparation** for calculating preparation timing. +- A **Default Preparation** may seed a new **Schedule** before the user chooses a different preparation. +- A **Custom Preparation** belongs to one **Schedule**. +- **Preparation Mode** may appear as an **Analytics Event Parameter** without exposing preparation step names. - First-release **Product Usage Events** are **Workflow Milestone Events**, not every tap or raw navigation step. - First-release **Workflow Milestone Events** cover analytics preference, onboarding, authentication, schedule, notification permission, alarm, and schedule-finish outcomes. - **Provider Authentication Completed** precedes **OnTime Session Established** during Apple or Google sign-in. @@ -165,6 +189,7 @@ _Avoid_: Notification, native alarm - "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. - "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. - "Pending" was ambiguous for notification status; resolved: the canonical state is **No Scheduled Notification** when notifications are enabled but no upcoming Schedule Notification is armed. - "Allowed" was ambiguous for permission requests; resolved: a request action is not the same as granted **Exact Timing Permission**. diff --git a/lib/domain/use-cases/create_schedule_form_submission_use_case.dart b/lib/domain/use-cases/create_schedule_form_submission_use_case.dart new file mode 100644 index 00000000..667076a7 --- /dev/null +++ b/lib/domain/use-cases/create_schedule_form_submission_use_case.dart @@ -0,0 +1,32 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/use-cases/create_custom_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_analytics_tracker.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; + +@Injectable() +class CreateScheduleFormSubmissionUseCase { + final CreateScheduleWithPlaceUseCase _createScheduleWithPlaceUseCase; + final CreateCustomPreparationUseCase _createCustomPreparationUseCase; + final ScheduleAnalyticsTracker _scheduleAnalyticsTracker; + + CreateScheduleFormSubmissionUseCase( + this._createScheduleWithPlaceUseCase, + this._createCustomPreparationUseCase, + this._scheduleAnalyticsTracker, + ); + + Future call(ScheduleFormSubmission submission) async { + await _createScheduleWithPlaceUseCase(submission.schedule); + if (submission.preparationChanged) { + await _createCustomPreparationUseCase( + submission.preparation, + submission.schedule.id, + ); + } + await _scheduleAnalyticsTracker.trackScheduleCreated( + schedule: submission.schedule, + preparation: submission.preparation, + ); + } +} diff --git a/lib/domain/use-cases/load_schedule_form_draft_use_case.dart b/lib/domain/use-cases/load_schedule_form_draft_use_case.dart new file mode 100644 index 00000000..2480052d --- /dev/null +++ b/lib/domain/use-cases/load_schedule_form_draft_use_case.dart @@ -0,0 +1,136 @@ +import 'package:equatable/equatable.dart'; +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_default_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_schedule_by_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_preparation_by_schedule_id_use_case.dart'; +import 'package:uuid/uuid.dart'; + +class ScheduleFormDraft extends Equatable { + final String id; + final String? placeId; + final String? placeName; + final String? scheduleName; + final DateTime? scheduleTime; + final Duration? moveTime; + final bool preparationChanged; + final Duration? scheduleSpareTime; + final String? scheduleNote; + final PreparationEntity preparation; + + const ScheduleFormDraft({ + required this.id, + required this.placeId, + required this.placeName, + required this.scheduleName, + required this.scheduleTime, + required this.moveTime, + required this.preparationChanged, + required this.scheduleSpareTime, + required this.scheduleNote, + required this.preparation, + }); + + @override + List get props => [ + id, + placeId, + placeName, + scheduleName, + scheduleTime, + moveTime, + preparationChanged, + scheduleSpareTime, + scheduleNote, + preparation, + ]; +} + +@Injectable() +class LoadScheduleFormDraftUseCase { + final LoadPreparationByScheduleIdUseCase _loadPreparationByScheduleIdUseCase; + final GetPreparationByScheduleIdUseCase _getPreparationByScheduleIdUseCase; + final GetDefaultPreparationUseCase _getDefaultPreparationUseCase; + final GetScheduleByIdUseCase _getScheduleByIdUseCase; + final DateTime Function() _now; + final String Function() _newId; + + LoadScheduleFormDraftUseCase( + this._loadPreparationByScheduleIdUseCase, + this._getPreparationByScheduleIdUseCase, + this._getDefaultPreparationUseCase, + this._getScheduleByIdUseCase, + ) : _now = DateTime.now, + _newId = const Uuid().v7; + + LoadScheduleFormDraftUseCase.withOverrides( + this._loadPreparationByScheduleIdUseCase, + this._getPreparationByScheduleIdUseCase, + this._getDefaultPreparationUseCase, + this._getScheduleByIdUseCase, { + required DateTime Function() now, + required String Function() newId, + }) : _now = now, + _newId = newId; + + Future create({ + DateTime? initialDate, + Duration? currentUserSpareTime, + }) async { + final defaultPreparation = await _getDefaultPreparationUseCase(); + + return ScheduleFormDraft( + id: _newId(), + placeId: _newId(), + placeName: null, + scheduleName: null, + scheduleTime: initialDate == null + ? null + : _initialScheduleTime(initialDate, _now()), + moveTime: null, + preparationChanged: false, + scheduleSpareTime: currentUserSpareTime, + scheduleNote: null, + preparation: defaultPreparation, + ); + } + + Future edit(String scheduleId) async { + await _loadPreparationByScheduleIdUseCase(scheduleId); + final preparation = await _getPreparationByScheduleIdUseCase(scheduleId); + final schedule = await _getScheduleByIdUseCase(scheduleId); + + return ScheduleFormDraft( + id: schedule.id, + placeId: schedule.place.id, + placeName: schedule.place.placeName, + scheduleName: schedule.scheduleName, + scheduleTime: schedule.scheduleTime, + moveTime: schedule.moveTime, + preparationChanged: schedule.isChanged, + scheduleSpareTime: schedule.scheduleSpareTime, + scheduleNote: schedule.scheduleNote, + preparation: preparation, + ); + } + + DateTime _initialScheduleTime(DateTime initialDate, DateTime now) { + final selectedDate = DateTime( + initialDate.year, + initialDate.month, + initialDate.day, + ); + final today = DateTime(now.year, now.month, now.day); + final initialTime = selectedDate == today + ? now.add(const Duration(minutes: 1)) + : now; + return DateTime( + initialDate.year, + initialDate.month, + initialDate.day, + initialTime.hour, + initialTime.minute, + ); + } +} diff --git a/lib/domain/use-cases/schedule_analytics_tracker.dart b/lib/domain/use-cases/schedule_analytics_tracker.dart new file mode 100644 index 00000000..10abb063 --- /dev/null +++ b/lib/domain/use-cases/schedule_analytics_tracker.dart @@ -0,0 +1,9 @@ +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; + +abstract interface class ScheduleAnalyticsTracker { + Future trackScheduleCreated({ + required ScheduleEntity schedule, + required PreparationEntity preparation, + }); +} diff --git a/lib/domain/use-cases/schedule_form_submission.dart b/lib/domain/use-cases/schedule_form_submission.dart new file mode 100644 index 00000000..a02ada94 --- /dev/null +++ b/lib/domain/use-cases/schedule_form_submission.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; + +class ScheduleFormSubmission extends Equatable { + final ScheduleEntity schedule; + final PreparationEntity preparation; + final bool preparationChanged; + + const ScheduleFormSubmission({ + required this.schedule, + required this.preparation, + required this.preparationChanged, + }); + + @override + List get props => [schedule, preparation, preparationChanged]; +} diff --git a/lib/domain/use-cases/track_schedule_analytics_use_case.dart b/lib/domain/use-cases/track_schedule_analytics_use_case.dart new file mode 100644 index 00000000..196dfbec --- /dev/null +++ b/lib/domain/use-cases/track_schedule_analytics_use_case.dart @@ -0,0 +1,41 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/product_usage_event.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/use-cases/schedule_analytics_tracker.dart'; +import 'package:on_time_front/domain/use-cases/track_product_usage_event_use_case.dart'; + +@Injectable(as: ScheduleAnalyticsTracker) +class TrackScheduleAnalyticsUseCase implements ScheduleAnalyticsTracker { + final ProductUsageEventTracker _productUsageEventTracker; + final DateTime Function() _now; + + TrackScheduleAnalyticsUseCase(this._productUsageEventTracker) + : _now = DateTime.now; + + TrackScheduleAnalyticsUseCase.withClock( + this._productUsageEventTracker, { + required DateTime Function() now, + }) : _now = now; + + @override + Future trackScheduleCreated({ + required ScheduleEntity schedule, + required PreparationEntity preparation, + }) async { + await _productUsageEventTracker.track( + ProductUsageEvent( + name: 'schedule_created', + workflow: 'schedule', + result: 'success', + parameters: { + 'preparation_mode': schedule.preparationMode?.name ?? 'default', + 'preparation_step_count': preparation.preparationStepList.length, + 'minutes_until_schedule': schedule.scheduleTime + .difference(_now()) + .inMinutes, + }, + ), + ); + } +} diff --git a/lib/domain/use-cases/update_schedule_form_submission_use_case.dart b/lib/domain/use-cases/update_schedule_form_submission_use_case.dart new file mode 100644 index 00000000..939f552f --- /dev/null +++ b/lib/domain/use-cases/update_schedule_form_submission_use_case.dart @@ -0,0 +1,26 @@ +import 'package:injectable/injectable.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; +import 'package:on_time_front/domain/use-cases/update_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_schedule_use_case.dart'; + +@Injectable() +class UpdateScheduleFormSubmissionUseCase { + final UpdateScheduleUseCase _updateScheduleUseCase; + final UpdatePreparationByScheduleIdUseCase + _updatePreparationByScheduleIdUseCase; + + UpdateScheduleFormSubmissionUseCase( + this._updateScheduleUseCase, + this._updatePreparationByScheduleIdUseCase, + ); + + Future call(ScheduleFormSubmission submission) async { + await _updateScheduleUseCase(submission.schedule); + if (submission.preparationChanged) { + await _updatePreparationByScheduleIdUseCase( + submission.preparation, + submission.schedule.id, + ); + } + } +} diff --git a/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart b/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart index a0932ed8..aeb8c0c2 100644 --- a/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart +++ b/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart @@ -5,17 +5,10 @@ import 'package:on_time_front/core/dio/api_error_message.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/schedule_entity.dart'; -import 'package:on_time_front/domain/use-cases/create_custom_preparation_use_case.dart'; -import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_default_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/load_preparation_by_schedule_id_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_schedule_by_id_use_case.dart'; -import 'package:on_time_front/domain/entities/product_usage_event.dart'; -import 'package:on_time_front/domain/use-cases/track_product_usage_event_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_preparation_by_schedule_id_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_schedule_use_case.dart'; -import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/domain/use-cases/create_schedule_form_submission_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedule_form_draft_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; +import 'package:on_time_front/domain/use-cases/update_schedule_form_submission_use_case.dart'; import 'package:uuid/uuid.dart'; part 'schedule_form_event.dart'; @@ -24,16 +17,9 @@ part 'schedule_form_state.dart'; @Injectable() class ScheduleFormBloc extends Bloc { ScheduleFormBloc( - this._loadPreparationByScheduleIdUseCase, - this._getPreparationByScheduleIdUseCase, - this._getDefaultPreparationUseCase, - this._getScheduleByIdUseCase, - this._createScheduleWithPlaceUseCase, - this._createCustomPreparationUseCase, - this._updateScheduleUseCase, - this._updatePreparationByScheduleIdUseCase, - this._productUsageEventTracker, - @factoryParam this._authBloc, + this._loadScheduleFormDraftUseCase, + this._createScheduleFormSubmissionUseCase, + this._updateScheduleFormSubmissionUseCase, ) : super(ScheduleFormState()) { on(_onEditRequested); on(_onCreateRequested); @@ -48,17 +34,11 @@ class ScheduleFormBloc extends Bloc { on(_onValidated); } - final LoadPreparationByScheduleIdUseCase _loadPreparationByScheduleIdUseCase; - final GetPreparationByScheduleIdUseCase _getPreparationByScheduleIdUseCase; - final GetDefaultPreparationUseCase _getDefaultPreparationUseCase; - final GetScheduleByIdUseCase _getScheduleByIdUseCase; - final CreateScheduleWithPlaceUseCase _createScheduleWithPlaceUseCase; - final CreateCustomPreparationUseCase _createCustomPreparationUseCase; - final UpdateScheduleUseCase _updateScheduleUseCase; - final UpdatePreparationByScheduleIdUseCase - _updatePreparationByScheduleIdUseCase; - final ProductUsageEventTracker _productUsageEventTracker; - final AuthBloc _authBloc; + final LoadScheduleFormDraftUseCase _loadScheduleFormDraftUseCase; + final CreateScheduleFormSubmissionUseCase + _createScheduleFormSubmissionUseCase; + final UpdateScheduleFormSubmissionUseCase + _updateScheduleFormSubmissionUseCase; Future _onEditRequested( ScheduleFormEditRequested event, @@ -72,33 +52,8 @@ class ScheduleFormBloc extends Bloc { ), ); - await _loadPreparationByScheduleIdUseCase(event.scheduleId); - final PreparationEntity preparationEntity = - await _getPreparationByScheduleIdUseCase(event.scheduleId); - - final ScheduleEntity scheduleEntity = await _getScheduleByIdUseCase( - event.scheduleId, - ); - - emit( - state.copyWith( - status: ScheduleFormStatus.success, - submissionStatus: ScheduleFormSubmissionStatus.idle, - submissionError: null, - id: scheduleEntity.id, - placeId: scheduleEntity.place.id, - placeName: scheduleEntity.place.placeName, - scheduleName: scheduleEntity.scheduleName, - scheduleTime: scheduleEntity.scheduleTime, - moveTime: scheduleEntity.moveTime, - isChanged: scheduleEntity.isChanged - ? IsPreparationChanged.changed - : IsPreparationChanged.unchanged, - scheduleSpareTime: scheduleEntity.scheduleSpareTime, - scheduleNote: scheduleEntity.scheduleNote, - preparation: preparationEntity, - ), - ); + final draft = await _loadScheduleFormDraftUseCase.edit(event.scheduleId); + _emitLoadedDraft(draft, emit); } void _onCreateRequested( @@ -113,33 +68,11 @@ class ScheduleFormBloc extends Bloc { ), ); - final PreparationEntity defaultPreparationStepList = - await _getDefaultPreparationUseCase(); - - // Get spareTime from user model - final userSpareTime = _authBloc.state.user.spareTimeOrNull; - final now = DateTime.now(); - final initialScheduleTime = event.initialDate == null - ? null - : _initialScheduleTime(event.initialDate!, now); - - emit( - state.copyWith( - status: ScheduleFormStatus.success, - submissionStatus: ScheduleFormSubmissionStatus.idle, - submissionError: null, - id: Uuid().v7(), - placeId: Uuid().v7(), - placeName: null, - scheduleName: null, - scheduleTime: initialScheduleTime, - moveTime: null, - isChanged: null, - scheduleSpareTime: userSpareTime, - scheduleNote: null, - preparation: defaultPreparationStepList, - ), + final draft = await _loadScheduleFormDraftUseCase.create( + initialDate: event.initialDate, + currentUserSpareTime: event.currentUserSpareTime, ); + _emitLoadedDraft(draft, emit); } void _onScheduleNameChanged( @@ -221,13 +154,9 @@ class ScheduleFormBloc extends Bloc { try { final ScheduleEntity scheduleEntity = state.createEntity(state); - await _updateScheduleUseCase(scheduleEntity); - if (state.isChanged != IsPreparationChanged.unchanged) { - await _updatePreparationByScheduleIdUseCase( - state.preparation!, - scheduleEntity.id, - ); - } + await _updateScheduleFormSubmissionUseCase( + _submissionFor(scheduleEntity), + ); emit( state.copyWith( submissionStatus: ScheduleFormSubmissionStatus.success, @@ -261,14 +190,9 @@ class ScheduleFormBloc extends Bloc { try { final ScheduleEntity scheduleEntity = state.createEntity(state); - await _createScheduleWithPlaceUseCase(scheduleEntity); - if (state.isChanged != IsPreparationChanged.unchanged) { - await _createCustomPreparationUseCase( - state.preparation!, - scheduleEntity.id, - ); - } - await _trackScheduleCreated(scheduleEntity); + await _createScheduleFormSubmissionUseCase( + _submissionFor(scheduleEntity), + ); emit( state.copyWith( submissionStatus: ScheduleFormSubmissionStatus.success, @@ -292,40 +216,36 @@ class ScheduleFormBloc extends Bloc { emit(state.copyWith(isValid: event.isValid)); } - DateTime _initialScheduleTime(DateTime initialDate, DateTime now) { - final selectedDate = DateTime( - initialDate.year, - initialDate.month, - initialDate.day, - ); - final today = DateTime(now.year, now.month, now.day); - final initialTime = selectedDate == today - ? now.add(const Duration(minutes: 1)) - : now; - return DateTime( - initialDate.year, - initialDate.month, - initialDate.day, - initialTime.hour, - initialTime.minute, + void _emitLoadedDraft( + ScheduleFormDraft draft, + Emitter emit, + ) { + emit( + state.copyWith( + status: ScheduleFormStatus.success, + submissionStatus: ScheduleFormSubmissionStatus.idle, + submissionError: null, + id: draft.id, + placeId: draft.placeId, + placeName: draft.placeName, + scheduleName: draft.scheduleName, + scheduleTime: draft.scheduleTime, + moveTime: draft.moveTime, + isChanged: draft.preparationChanged + ? IsPreparationChanged.changed + : IsPreparationChanged.unchanged, + scheduleSpareTime: draft.scheduleSpareTime, + scheduleNote: draft.scheduleNote, + preparation: draft.preparation, + ), ); } - Future _trackScheduleCreated(ScheduleEntity scheduleEntity) async { - await _productUsageEventTracker.track( - ProductUsageEvent( - name: 'schedule_created', - workflow: 'schedule', - result: 'success', - parameters: { - 'preparation_mode': scheduleEntity.preparationMode?.name ?? 'default', - 'preparation_step_count': - state.preparation?.preparationStepList.length ?? 0, - 'minutes_until_schedule': scheduleEntity.scheduleTime - .difference(DateTime.now()) - .inMinutes, - }, - ), + ScheduleFormSubmission _submissionFor(ScheduleEntity scheduleEntity) { + return ScheduleFormSubmission( + schedule: scheduleEntity, + preparation: state.preparation!, + preparationChanged: state.isChanged != IsPreparationChanged.unchanged, ); } } diff --git a/lib/presentation/schedule_create/bloc/schedule_form_event.dart b/lib/presentation/schedule_create/bloc/schedule_form_event.dart index faa6e6b0..a505cacf 100644 --- a/lib/presentation/schedule_create/bloc/schedule_form_event.dart +++ b/lib/presentation/schedule_create/bloc/schedule_form_event.dart @@ -18,13 +18,18 @@ final class ScheduleFormEditRequested extends ScheduleFormEvent { final class ScheduleFormCreateRequested extends ScheduleFormEvent { final DateTime? initialDate; + final Duration? currentUserSpareTime; - const ScheduleFormCreateRequested({this.initialDate}); + const ScheduleFormCreateRequested({ + this.initialDate, + this.currentUserSpareTime, + }); @override List get props => [ - initialDate ?? DateTime.fromMillisecondsSinceEpoch(0), - ]; + initialDate ?? DateTime.fromMillisecondsSinceEpoch(0), + currentUserSpareTime ?? Duration.zero, + ]; } final class ScheduleFormScheduleNameChanged extends ScheduleFormEvent { @@ -51,11 +56,11 @@ final class ScheduleFormScheduleDateTimeChanged extends ScheduleFormEvent { @override List get props => [ - scheduleDate, - scheduleTime, - maxAvailableTime ?? const Duration(days: -999999), - previousScheduleName ?? '', - ]; + scheduleDate, + scheduleTime, + maxAvailableTime ?? const Duration(days: -999999), + previousScheduleName ?? '', + ]; } final class ScheduleFormPlaceNameChanged extends ScheduleFormEvent { diff --git a/lib/presentation/schedule_create/screens/schedule_create_screen.dart b/lib/presentation/schedule_create/screens/schedule_create_screen.dart index 4a30f964..23f7e8b0 100644 --- a/lib/presentation/schedule_create/screens/schedule_create_screen.dart +++ b/lib/presentation/schedule_create/screens/schedule_create_screen.dart @@ -15,9 +15,17 @@ class ScheduleCreateScreen extends StatelessWidget { Widget build(BuildContext context) { return KeyboardBackedBottomSheet( child: BlocProvider( - create: (context) => - getIt.get(param1: context.read()) - ..add(ScheduleFormCreateRequested(initialDate: initialDate)), + create: (context) => getIt.get() + ..add( + ScheduleFormCreateRequested( + initialDate: initialDate, + currentUserSpareTime: context + .read() + .state + .user + .spareTimeOrNull, + ), + ), child: BlocBuilder( builder: (context, state) { return ScheduleMultiPageForm( diff --git a/lib/presentation/schedule_create/screens/schedule_edit_screen.dart b/lib/presentation/schedule_create/screens/schedule_edit_screen.dart index bdd48c37..ff28636f 100644 --- a/lib/presentation/schedule_create/screens/schedule_edit_screen.dart +++ b/lib/presentation/schedule_create/screens/schedule_edit_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:on_time_front/core/di/di_setup.dart'; -import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; import 'package:on_time_front/presentation/schedule_create/components/keyboard_backed_bottom_sheet.dart'; import 'package:on_time_front/presentation/schedule_create/components/schedule_multi_page_form.dart'; @@ -16,7 +15,7 @@ class ScheduleEditScreen extends StatelessWidget { return KeyboardBackedBottomSheet( child: BlocProvider( create: (context) => - getIt.get(param1: context.read()) + getIt.get() ..add(ScheduleFormEditRequested(scheduleId: scheduleId)), child: BlocBuilder( builder: (context, state) { diff --git a/test/domain/use-cases/create_schedule_form_submission_use_case_test.dart b/test/domain/use-cases/create_schedule_form_submission_use_case_test.dart new file mode 100644 index 00000000..9972e0fb --- /dev/null +++ b/test/domain/use-cases/create_schedule_form_submission_use_case_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.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/schedule_entity.dart'; +import 'package:on_time_front/domain/use-cases/create_custom_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/create_schedule_form_submission_use_case.dart'; +import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_analytics_tracker.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; + +class SpyCreateScheduleWithPlaceUseCase + implements CreateScheduleWithPlaceUseCase { + final createdSchedules = []; + + @override + Future call(ScheduleEntity schedule) async { + createdSchedules.add(schedule); + } +} + +class SpyCreateCustomPreparationUseCase + implements CreateCustomPreparationUseCase { + final createdPreparations = <({PreparationEntity preparation, String id})>[]; + + @override + Future call( + PreparationEntity preparationEntity, + String scheduleId, + ) async { + createdPreparations.add((preparation: preparationEntity, id: scheduleId)); + } +} + +class SpyScheduleAnalyticsTracker implements ScheduleAnalyticsTracker { + final createdSchedules = + <({ScheduleEntity schedule, PreparationEntity preparation})>[]; + + @override + Future trackScheduleCreated({ + required ScheduleEntity schedule, + required PreparationEntity preparation, + }) async { + createdSchedules.add((schedule: schedule, preparation: preparation)); + } +} + +void main() { + test( + 'changed preparation creates schedule, saves custom preparation, and tracks create analytics', + () async { + final createScheduleUseCase = SpyCreateScheduleWithPlaceUseCase(); + final createCustomPreparationUseCase = + SpyCreateCustomPreparationUseCase(); + final analyticsTracker = SpyScheduleAnalyticsTracker(); + final useCase = CreateScheduleFormSubmissionUseCase( + createScheduleUseCase, + createCustomPreparationUseCase, + analyticsTracker, + ); + final schedule = ScheduleEntity( + id: 'schedule-1', + place: PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: DateTime(2027, 3, 20, 9), + moveTime: const Duration(minutes: 30), + isChanged: true, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', + ); + final preparation = PreparationEntity( + preparationStepList: const [ + PreparationStepEntity( + id: 'prep-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + ), + ], + ); + + await useCase( + ScheduleFormSubmission( + schedule: schedule, + preparation: preparation, + preparationChanged: true, + ), + ); + + expect(createScheduleUseCase.createdSchedules, [schedule]); + expect(createCustomPreparationUseCase.createdPreparations, [ + (preparation: preparation, id: 'schedule-1'), + ]); + expect(analyticsTracker.createdSchedules, [ + (schedule: schedule, preparation: preparation), + ]); + }, + ); +} diff --git a/test/domain/use-cases/load_schedule_form_draft_use_case_test.dart b/test/domain/use-cases/load_schedule_form_draft_use_case_test.dart new file mode 100644 index 00000000..6379644a --- /dev/null +++ b/test/domain/use-cases/load_schedule_form_draft_use_case_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.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/use-cases/get_default_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_schedule_by_id_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/load_schedule_form_draft_use_case.dart'; + +class StubLoadPreparationByScheduleIdUseCase + implements LoadPreparationByScheduleIdUseCase { + @override + Future call(String scheduleId) async {} +} + +class StubGetPreparationByScheduleIdUseCase + implements GetPreparationByScheduleIdUseCase { + @override + Future call(String scheduleId) { + throw UnimplementedError(); + } +} + +class StubGetDefaultPreparationUseCase implements GetDefaultPreparationUseCase { + StubGetDefaultPreparationUseCase(this.preparation); + + final PreparationEntity preparation; + + @override + Future call() async => preparation; +} + +class StubGetScheduleByIdUseCase implements GetScheduleByIdUseCase { + @override + call(String id) { + throw UnimplementedError(); + } +} + +void main() { + test( + 'create draft uses default preparation, user spare time, generated ids, and seeded date', + () async { + final defaultPreparation = PreparationEntity( + preparationStepList: const [ + PreparationStepEntity( + id: 'prep-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + ), + ], + ); + final generatedIds = ['schedule-id', 'place-id'].iterator; + String nextId() { + generatedIds.moveNext(); + return generatedIds.current; + } + + final useCase = LoadScheduleFormDraftUseCase.withOverrides( + StubLoadPreparationByScheduleIdUseCase(), + StubGetPreparationByScheduleIdUseCase(), + StubGetDefaultPreparationUseCase(defaultPreparation), + StubGetScheduleByIdUseCase(), + now: () => DateTime(2027, 4, 5, 8, 30), + newId: nextId, + ); + + final draft = await useCase.create( + initialDate: DateTime(2027, 4, 5), + currentUserSpareTime: const Duration(minutes: 5), + ); + + expect(draft.id, 'schedule-id'); + expect(draft.placeId, 'place-id'); + expect(draft.scheduleTime, DateTime(2027, 4, 5, 8, 31)); + expect(draft.scheduleSpareTime, const Duration(minutes: 5)); + expect(draft.preparation, defaultPreparation); + expect(draft.preparationChanged, isFalse); + }, + ); +} diff --git a/test/domain/use-cases/track_schedule_analytics_use_case_test.dart b/test/domain/use-cases/track_schedule_analytics_use_case_test.dart new file mode 100644 index 00000000..eceb4baf --- /dev/null +++ b/test/domain/use-cases/track_schedule_analytics_use_case_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.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/product_usage_event.dart'; +import 'package:on_time_front/domain/entities/schedule_entity.dart'; +import 'package:on_time_front/domain/use-cases/track_product_usage_event_use_case.dart'; +import 'package:on_time_front/domain/use-cases/track_schedule_analytics_use_case.dart'; + +class SpyProductUsageEventTracker implements ProductUsageEventTracker { + final events = []; + + @override + Future track(ProductUsageEvent event) async { + events.add(event); + } +} + +void main() { + test( + 'schedule create analytics uses allowed schedule_created parameters', + () async { + final productUsageEventTracker = SpyProductUsageEventTracker(); + final tracker = TrackScheduleAnalyticsUseCase.withClock( + productUsageEventTracker, + now: () => DateTime(2027, 3, 20, 8), + ); + final schedule = ScheduleEntity( + id: 'schedule-1', + place: PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: DateTime(2027, 3, 20, 9), + moveTime: const Duration(minutes: 30), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: 'bring laptop', + ); + final preparation = PreparationEntity( + preparationStepList: const [ + PreparationStepEntity( + id: 'prep-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + ), + PreparationStepEntity( + id: 'prep-2', + preparationName: 'Pack', + preparationTime: Duration(minutes: 5), + ), + ], + ); + + await tracker.trackScheduleCreated( + schedule: schedule, + preparation: preparation, + ); + + expect(productUsageEventTracker.events, hasLength(1)); + final event = productUsageEventTracker.events.single; + expect(event.name, 'schedule_created'); + expect(event.workflow, 'schedule'); + expect(event.result, 'success'); + expect(event.parameters, { + 'preparation_mode': 'default', + 'preparation_step_count': 2, + 'minutes_until_schedule': 60, + }); + }, + ); +} diff --git a/test/domain/use-cases/update_schedule_form_submission_use_case_test.dart b/test/domain/use-cases/update_schedule_form_submission_use_case_test.dart new file mode 100644 index 00000000..5ed75823 --- /dev/null +++ b/test/domain/use-cases/update_schedule_form_submission_use_case_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.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/schedule_entity.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; +import 'package:on_time_front/domain/use-cases/update_preparation_by_schedule_id_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_schedule_form_submission_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_schedule_use_case.dart'; + +class SpyUpdateScheduleUseCase implements UpdateScheduleUseCase { + final updatedSchedules = []; + + @override + Future call( + ScheduleEntity schedule, { + bool includePreparationSource = false, + }) async { + updatedSchedules.add(schedule); + } +} + +class SpyUpdatePreparationByScheduleIdUseCase + implements UpdatePreparationByScheduleIdUseCase { + final updatedPreparations = <({PreparationEntity preparation, String id})>[]; + + @override + Future call( + PreparationEntity preparationEntity, + String scheduleId, + ) async { + updatedPreparations.add((preparation: preparationEntity, id: scheduleId)); + } +} + +void main() { + test( + 'unchanged preparation updates the schedule without saving preparation', + () async { + final updateScheduleUseCase = SpyUpdateScheduleUseCase(); + final updatePreparationUseCase = + SpyUpdatePreparationByScheduleIdUseCase(); + final useCase = UpdateScheduleFormSubmissionUseCase( + updateScheduleUseCase, + updatePreparationUseCase, + ); + final schedule = ScheduleEntity( + id: 'schedule-1', + place: PlaceEntity(id: 'place-1', placeName: 'Office'), + scheduleName: 'Meeting', + scheduleTime: DateTime(2027, 3, 20, 9), + moveTime: const Duration(minutes: 30), + isChanged: false, + isStarted: false, + scheduleSpareTime: const Duration(minutes: 10), + scheduleNote: '', + ); + final preparation = PreparationEntity( + preparationStepList: const [ + PreparationStepEntity( + id: 'prep-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 10), + ), + ], + ); + + await useCase( + ScheduleFormSubmission( + schedule: schedule, + preparation: preparation, + preparationChanged: false, + ), + ); + + expect(updateScheduleUseCase.updatedSchedules, [schedule]); + expect(updatePreparationUseCase.updatedPreparations, isEmpty); + }, + ); +} diff --git a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart index 0771631a..e6c4d1f0 100644 --- a/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart +++ b/test/presentation/schedule_create/bloc/schedule_form_bloc_test.dart @@ -1,140 +1,74 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.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/product_usage_event.dart'; import 'package:on_time_front/domain/entities/schedule_entity.dart'; -import 'package:on_time_front/domain/entities/user_entity.dart'; -import 'package:on_time_front/domain/use-cases/track_product_usage_event_use_case.dart'; -import 'package:on_time_front/domain/use-cases/create_custom_preparation_use_case.dart'; -import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_default_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_schedule_by_id_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/update_preparation_by_schedule_id_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_schedule_use_case.dart'; -import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/domain/use-cases/create_schedule_form_submission_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedule_form_draft_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; +import 'package:on_time_front/domain/use-cases/update_schedule_form_submission_use_case.dart'; import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; -class StubAuthBloc extends Mock implements AuthBloc { - StubAuthBloc(this._state); +const _unset = Object(); - final AuthState _state; - - @override - AuthState get state => _state; -} - -class StubLoadPreparationByScheduleIdUseCase - implements LoadPreparationByScheduleIdUseCase { - StubLoadPreparationByScheduleIdUseCase(this.handler); - - final Future Function(String scheduleId) handler; - - @override - Future call(String scheduleId) => handler(scheduleId); -} - -class StubGetPreparationByScheduleIdUseCase - implements GetPreparationByScheduleIdUseCase { - StubGetPreparationByScheduleIdUseCase(this.handler); - - final Future Function(String scheduleId) handler; - - @override - Future call(String scheduleId) => handler(scheduleId); -} - -class StubGetDefaultPreparationUseCase implements GetDefaultPreparationUseCase { - StubGetDefaultPreparationUseCase(this.handler); - - final Future Function() handler; - - @override - Future call() => handler(); -} - -class StubGetScheduleByIdUseCase implements GetScheduleByIdUseCase { - StubGetScheduleByIdUseCase(this.handler); - - final Future Function(String id) handler; - - @override - Future call(String id) => handler(id); -} - -class StubCreateScheduleWithPlaceUseCase - implements CreateScheduleWithPlaceUseCase { - StubCreateScheduleWithPlaceUseCase(this.handler); - - final Future Function(ScheduleEntity schedule) handler; - - @override - Future call(ScheduleEntity schedule) => handler(schedule); -} - -class StubCreateCustomPreparationUseCase - implements CreateCustomPreparationUseCase { - StubCreateCustomPreparationUseCase(this.handler); +class StubLoadScheduleFormDraftUseCase implements LoadScheduleFormDraftUseCase { + StubLoadScheduleFormDraftUseCase({ + required this.createHandler, + required this.editHandler, + }); - final Future Function(PreparationEntity preparation, String scheduleId) - handler; + Future Function({ + DateTime? initialDate, + Duration? currentUserSpareTime, + }) + createHandler; + Future Function(String scheduleId) editHandler; @override - Future call(PreparationEntity preparationEntity, String scheduleId) { - return handler(preparationEntity, scheduleId); + Future create({ + DateTime? initialDate, + Duration? currentUserSpareTime, + }) { + return createHandler( + initialDate: initialDate, + currentUserSpareTime: currentUserSpareTime, + ); } -} - -class StubUpdateScheduleUseCase implements UpdateScheduleUseCase { - StubUpdateScheduleUseCase(this.handler); - - final Future Function(ScheduleEntity schedule) handler; @override - Future call( - ScheduleEntity schedule, { - bool includePreparationSource = false, - }) => handler(schedule); + Future edit(String scheduleId) => editHandler(scheduleId); } -class StubUpdatePreparationByScheduleIdUseCase - implements UpdatePreparationByScheduleIdUseCase { - StubUpdatePreparationByScheduleIdUseCase(this.handler); - - final Future Function(PreparationEntity preparation, String scheduleId) - handler; +class SpyCreateScheduleFormSubmissionUseCase + implements CreateScheduleFormSubmissionUseCase { + Future Function(ScheduleFormSubmission submission)? handler; + final submissions = []; @override - Future call(PreparationEntity preparationEntity, String scheduleId) { - return handler(preparationEntity, scheduleId); + Future call(ScheduleFormSubmission submission) async { + submissions.add(submission); + await handler?.call(submission); } } -class StubProductUsageEventTracker implements ProductUsageEventTracker { - final events = []; +class SpyUpdateScheduleFormSubmissionUseCase + implements UpdateScheduleFormSubmissionUseCase { + Future Function(ScheduleFormSubmission submission)? handler; + final submissions = []; @override - Future track(ProductUsageEvent event) async { - events.add(event); + Future call(ScheduleFormSubmission submission) async { + submissions.add(submission); + await handler?.call(submission); } } void main() { - late StubLoadPreparationByScheduleIdUseCase - loadPreparationByScheduleIdUseCase; - late StubGetPreparationByScheduleIdUseCase getPreparationByScheduleIdUseCase; - late StubGetDefaultPreparationUseCase getDefaultPreparationUseCase; - late StubGetScheduleByIdUseCase getScheduleByIdUseCase; - late StubCreateScheduleWithPlaceUseCase createScheduleWithPlaceUseCase; - late StubCreateCustomPreparationUseCase createCustomPreparationUseCase; - late StubUpdateScheduleUseCase updateScheduleUseCase; - late StubUpdatePreparationByScheduleIdUseCase - updatePreparationByScheduleIdUseCase; - late StubProductUsageEventTracker productUsageEventTracker; - late StubAuthBloc authBloc; + late StubLoadScheduleFormDraftUseCase loadScheduleFormDraftUseCase; + late SpyCreateScheduleFormSubmissionUseCase + createScheduleFormSubmissionUseCase; + late SpyUpdateScheduleFormSubmissionUseCase + updateScheduleFormSubmissionUseCase; final preparation = PreparationEntity( preparationStepList: const [ @@ -158,105 +92,129 @@ void main() { scheduleNote: 'bring laptop', ); + ScheduleFormDraft draftFromSchedule({ + bool preparationChanged = false, + Duration? spareTime = const Duration(minutes: 10), + Object? scheduleTime = _unset, + }) { + return ScheduleFormDraft( + id: schedule.id, + placeId: schedule.place.id, + placeName: schedule.place.placeName, + scheduleName: schedule.scheduleName, + scheduleTime: identical(scheduleTime, _unset) + ? schedule.scheduleTime + : scheduleTime as DateTime?, + moveTime: schedule.moveTime, + preparationChanged: preparationChanged, + scheduleSpareTime: spareTime, + scheduleNote: schedule.scheduleNote, + preparation: preparation, + ); + } + ScheduleFormBloc buildBloc() { return ScheduleFormBloc( - loadPreparationByScheduleIdUseCase, - getPreparationByScheduleIdUseCase, - getDefaultPreparationUseCase, - getScheduleByIdUseCase, - createScheduleWithPlaceUseCase, - createCustomPreparationUseCase, - updateScheduleUseCase, - updatePreparationByScheduleIdUseCase, - productUsageEventTracker, - authBloc, + loadScheduleFormDraftUseCase, + createScheduleFormSubmissionUseCase, + updateScheduleFormSubmissionUseCase, ); } - setUp(() { - loadPreparationByScheduleIdUseCase = StubLoadPreparationByScheduleIdUseCase( - (_) async {}, - ); - getPreparationByScheduleIdUseCase = StubGetPreparationByScheduleIdUseCase( - (_) async => preparation, - ); - getDefaultPreparationUseCase = StubGetDefaultPreparationUseCase( - () async => preparation, - ); - getScheduleByIdUseCase = StubGetScheduleByIdUseCase((_) async => schedule); - createScheduleWithPlaceUseCase = StubCreateScheduleWithPlaceUseCase( - (_) async {}, + Future primeEditState(ScheduleFormBloc bloc) async { + final editReady = bloc.stream.firstWhere( + (state) => state.status == ScheduleFormStatus.success, ); - createCustomPreparationUseCase = StubCreateCustomPreparationUseCase( - (_, __) async {}, + bloc.add(const ScheduleFormEditRequested(scheduleId: 'schedule-1')); + await editReady; + } + + Future primeCreateState(ScheduleFormBloc bloc) async { + final createReady = bloc.stream.firstWhere( + (state) => state.status == ScheduleFormStatus.success, ); - updateScheduleUseCase = StubUpdateScheduleUseCase((_) async {}); - updatePreparationByScheduleIdUseCase = - StubUpdatePreparationByScheduleIdUseCase((_, __) async {}); - productUsageEventTracker = StubProductUsageEventTracker(); - - authBloc = StubAuthBloc( - AuthState( - user: UserEntity( - id: 'user-1', - email: 'user@test.com', - name: 'tester', - spareTime: const Duration(minutes: 5), - note: '', - score: 1, - isOnboardingCompleted: true, - ), + bloc.add( + const ScheduleFormCreateRequested( + currentUserSpareTime: Duration(minutes: 5), ), ); + await createReady; + } + + setUp(() { + loadScheduleFormDraftUseCase = StubLoadScheduleFormDraftUseCase( + createHandler: + ({DateTime? initialDate, Duration? currentUserSpareTime}) async { + return draftFromSchedule( + spareTime: currentUserSpareTime, + scheduleTime: initialDate, + ); + }, + editHandler: (_) async => draftFromSchedule(), + ); + createScheduleFormSubmissionUseCase = + SpyCreateScheduleFormSubmissionUseCase(); + updateScheduleFormSubmissionUseCase = + SpyUpdateScheduleFormSubmissionUseCase(); }); - test('ScheduleFormUpdated emits submitting then success', () async { + test('ScheduleFormCreateRequested maps loaded draft into state', () async { + DateTime? requestedInitialDate; + Duration? requestedSpareTime; + loadScheduleFormDraftUseCase.createHandler = + ({DateTime? initialDate, Duration? currentUserSpareTime}) async { + requestedInitialDate = initialDate; + requestedSpareTime = currentUserSpareTime; + return draftFromSchedule( + spareTime: currentUserSpareTime, + scheduleTime: initialDate, + ); + }; final bloc = buildBloc(); addTearDown(bloc.close); - final submissionStatuses = []; - final subscription = bloc.stream - .map((state) => state.submissionStatus) - .listen(submissionStatuses.add); - addTearDown(subscription.cancel); - - final editReady = bloc.stream.firstWhere( + final createReady = bloc.stream.firstWhere( (state) => state.status == ScheduleFormStatus.success, ); - bloc.add(const ScheduleFormEditRequested(scheduleId: 'schedule-1')); - await editReady; - - final submitDone = bloc.stream.firstWhere( - (state) => state.submissionStatus == ScheduleFormSubmissionStatus.success, + bloc.add( + ScheduleFormCreateRequested( + initialDate: DateTime(2027, 4, 5), + currentUserSpareTime: const Duration(minutes: 5), + ), ); - bloc.add(const ScheduleFormUpdated()); - await submitDone; + final state = await createReady; - expect( - submissionStatuses, - containsAllInOrder([ - ScheduleFormSubmissionStatus.submitting, - ScheduleFormSubmissionStatus.success, - ]), - ); + expect(requestedInitialDate, DateTime(2027, 4, 5)); + expect(requestedSpareTime, const Duration(minutes: 5)); + expect(state.scheduleTime, DateTime(2027, 4, 5)); + expect(state.scheduleSpareTime, const Duration(minutes: 5)); + expect(state.preparation, preparation); }); test( - 'ScheduleFormUpdated sends edited schedule fields to update use case', + 'ScheduleFormEditRequested maps changed preparation flag into state', () async { - ScheduleEntity? updatedSchedule; - updateScheduleUseCase = StubUpdateScheduleUseCase((schedule) async { - updatedSchedule = schedule; - }); + loadScheduleFormDraftUseCase.editHandler = (_) async => + draftFromSchedule(preparationChanged: true); + final bloc = buildBloc(); + addTearDown(bloc.close); + await primeEditState(bloc); + + expect(bloc.state.id, 'schedule-1'); + expect(bloc.state.placeName, 'Office'); + expect(bloc.state.isChanged, IsPreparationChanged.changed); + expect(bloc.state.preparation, preparation); + }, + ); + + test( + 'ScheduleFormUpdated sends edited schedule fields to submission use case', + () async { final bloc = buildBloc(); addTearDown(bloc.close); - final editReady = bloc.stream.firstWhere( - (state) => state.status == ScheduleFormStatus.success, - ); - bloc.add(const ScheduleFormEditRequested(scheduleId: 'schedule-1')); - await editReady; + await primeEditState(bloc); bloc.add( const ScheduleFormScheduleNameChanged(scheduleName: 'Edited Meeting'), @@ -284,22 +242,24 @@ void main() { bloc.add(const ScheduleFormUpdated()); await submitDone; - expect(updatedSchedule, isNotNull); - expect(updatedSchedule!.id, 'schedule-1'); - expect(updatedSchedule!.place.id, 'place-1'); - expect(updatedSchedule!.place.placeName, 'New Office'); - expect(updatedSchedule!.scheduleName, 'Edited Meeting'); - expect(updatedSchedule!.scheduleTime, DateTime(2027, 3, 21, 10, 30)); - expect(updatedSchedule!.moveTime, const Duration(minutes: 45)); - expect(updatedSchedule!.scheduleSpareTime, const Duration(minutes: 25)); + final submission = updateScheduleFormSubmissionUseCase.submissions.single; + expect(submission.schedule.id, 'schedule-1'); + expect(submission.schedule.place.id, 'place-1'); + expect(submission.schedule.place.placeName, 'New Office'); + expect(submission.schedule.scheduleName, 'Edited Meeting'); + expect(submission.schedule.scheduleTime, DateTime(2027, 3, 21, 10, 30)); + expect(submission.schedule.moveTime, const Duration(minutes: 45)); + expect( + submission.schedule.scheduleSpareTime, + const Duration(minutes: 25), + ); + expect(submission.preparationChanged, isFalse); }, ); test('ScheduleFormUpdated emits submitting then failure on error', () async { - updateScheduleUseCase = StubUpdateScheduleUseCase( - (_) => Future.error(Exception('update')), - ); - + updateScheduleFormSubmissionUseCase.handler = (_) => + Future.error(Exception('update')); final bloc = buildBloc(); addTearDown(bloc.close); @@ -309,11 +269,7 @@ void main() { .listen(submissionStatuses.add); addTearDown(subscription.cancel); - final editReady = bloc.stream.firstWhere( - (state) => state.status == ScheduleFormStatus.success, - ); - bloc.add(const ScheduleFormEditRequested(scheduleId: 'schedule-1')); - await editReady; + await primeEditState(bloc); final submitFailed = bloc.stream.firstWhere( (state) => state.submissionStatus == ScheduleFormSubmissionStatus.failure, @@ -330,171 +286,13 @@ void main() { ); }); - test('ScheduleFormCreated emits submitting then success', () async { - final bloc = buildBloc(); - addTearDown(bloc.close); - - final submissionStatuses = []; - final subscription = bloc.stream - .map((state) => state.submissionStatus) - .listen(submissionStatuses.add); - addTearDown(subscription.cancel); - - final createReady = bloc.stream.firstWhere( - (state) => state.status == ScheduleFormStatus.success, - ); - bloc.add(const ScheduleFormCreateRequested()); - await createReady; - - bloc.add(const ScheduleFormScheduleNameChanged(scheduleName: 'Meeting')); - bloc.add( - ScheduleFormScheduleDateTimeChanged( - scheduleDate: DateTime(2027, 3, 20), - scheduleTime: DateTime(2027, 3, 20, 9, 0), - ), - ); - bloc.add(const ScheduleFormPlaceNameChanged(placeName: 'Office')); - bloc.add( - const ScheduleFormMoveTimeChanged(moveTime: Duration(minutes: 30)), - ); - bloc.add( - const ScheduleFormScheduleSpareTimeChanged( - scheduleSpareTime: Duration(minutes: 10), - ), - ); - - final submitDone = bloc.stream.firstWhere( - (state) => state.submissionStatus == ScheduleFormSubmissionStatus.success, - ); - bloc.add(const ScheduleFormCreated()); - await submitDone; - - expect( - submissionStatuses, - containsAllInOrder([ - ScheduleFormSubmissionStatus.submitting, - ScheduleFormSubmissionStatus.success, - ]), - ); - }); - - test('ScheduleFormCreated tracks schedule_created after success', () async { - final bloc = buildBloc(); - addTearDown(bloc.close); - - final createReady = bloc.stream.firstWhere( - (state) => state.status == ScheduleFormStatus.success, - ); - bloc.add(const ScheduleFormCreateRequested()); - await createReady; - - bloc - ..add(const ScheduleFormScheduleNameChanged(scheduleName: 'Meeting')) - ..add( - ScheduleFormScheduleDateTimeChanged( - scheduleDate: DateTime(2027, 3, 20), - scheduleTime: DateTime(2027, 3, 20, 9), - ), - ) - ..add(const ScheduleFormPlaceNameChanged(placeName: 'Office')) - ..add(const ScheduleFormMoveTimeChanged(moveTime: Duration(minutes: 30))) - ..add( - const ScheduleFormScheduleSpareTimeChanged( - scheduleSpareTime: Duration(minutes: 10), - ), - ); - - final submitDone = bloc.stream.firstWhere( - (state) => state.submissionStatus == ScheduleFormSubmissionStatus.success, - ); - bloc.add(const ScheduleFormCreated()); - await submitDone; - - expect(productUsageEventTracker.events, hasLength(1)); - expect(productUsageEventTracker.events.single.name, 'schedule_created'); - expect(productUsageEventTracker.events.single.workflow, 'schedule'); - expect(productUsageEventTracker.events.single.result, 'success'); - expect( - productUsageEventTracker.events.single.parameters, - containsPair('preparation_step_count', 1), - ); - }); - - test('ScheduleFormCreateRequested seeds a provided future date', () async { - final bloc = buildBloc(); - addTearDown(bloc.close); - - final createReady = bloc.stream.firstWhere( - (state) => state.status == ScheduleFormStatus.success, - ); - bloc.add(ScheduleFormCreateRequested(initialDate: DateTime(2027, 4, 5))); - final state = await createReady; - - expect(state.scheduleTime, isNotNull); - expect(state.scheduleTime!.year, 2027); - expect(state.scheduleTime!.month, 4); - expect(state.scheduleTime!.day, 5); - expect(state.scheduleSpareTime, const Duration(minutes: 5)); - }); - - test('ScheduleFormPreparationChanged marks unchanged preparations', () async { - final bloc = buildBloc(); - addTearDown(bloc.close); - - final editReady = bloc.stream.firstWhere( - (state) => state.status == ScheduleFormStatus.success, - ); - bloc.add(const ScheduleFormEditRequested(scheduleId: 'schedule-1')); - await editReady; - - bloc.add(ScheduleFormPreparationChanged(preparation: preparation)); - await pumpEventQueue(); - - expect(bloc.state.isChanged, IsPreparationChanged.unchanged); - }); - - test('ScheduleFormUpdated skips preparation update when unchanged', () async { - var preparationUpdateCount = 0; - updatePreparationByScheduleIdUseCase = - StubUpdatePreparationByScheduleIdUseCase((_, __) async { - preparationUpdateCount += 1; - }); - final bloc = buildBloc(); - addTearDown(bloc.close); - - final editReady = bloc.stream.firstWhere( - (state) => state.status == ScheduleFormStatus.success, - ); - bloc.add(const ScheduleFormEditRequested(scheduleId: 'schedule-1')); - await editReady; - - final submitDone = bloc.stream.firstWhere( - (state) => state.submissionStatus == ScheduleFormSubmissionStatus.success, - ); - bloc.add(const ScheduleFormUpdated()); - await submitDone; - - expect(preparationUpdateCount, 0); - }); - test( - 'ScheduleFormCreated persists custom preparation when changed', + 'ScheduleFormCreated sends changed preparation to create submission', () async { - var customPreparationCount = 0; - createCustomPreparationUseCase = StubCreateCustomPreparationUseCase(( - _, - __, - ) async { - customPreparationCount += 1; - }); final bloc = buildBloc(); addTearDown(bloc.close); - final createReady = bloc.stream.firstWhere( - (state) => state.status == ScheduleFormStatus.success, - ); - bloc.add(const ScheduleFormCreateRequested()); - await createReady; + await primeCreateState(bloc); final changedPreparation = PreparationEntity( preparationStepList: const [ @@ -531,59 +329,22 @@ void main() { bloc.add(const ScheduleFormCreated()); await submitDone; - expect(customPreparationCount, 1); + final submission = createScheduleFormSubmissionUseCase.submissions.single; + expect(submission.schedule.id, 'schedule-1'); + expect(submission.preparation, changedPreparation); + expect(submission.preparationChanged, isTrue); }, ); - test('ScheduleFormCreated emits submitting then failure on error', () async { - createScheduleWithPlaceUseCase = StubCreateScheduleWithPlaceUseCase( - (_) => Future.error(Exception()), - ); - + test('ScheduleFormPreparationChanged marks unchanged preparations', () async { final bloc = buildBloc(); addTearDown(bloc.close); - final submissionStatuses = []; - final subscription = bloc.stream - .map((state) => state.submissionStatus) - .listen(submissionStatuses.add); - addTearDown(subscription.cancel); - - final createReady = bloc.stream.firstWhere( - (state) => state.status == ScheduleFormStatus.success, - ); - bloc.add(const ScheduleFormCreateRequested()); - await createReady; - - bloc.add(const ScheduleFormScheduleNameChanged(scheduleName: 'Meeting')); - bloc.add( - ScheduleFormScheduleDateTimeChanged( - scheduleDate: DateTime(2027, 3, 20), - scheduleTime: DateTime(2027, 3, 20, 9, 0), - ), - ); - bloc.add(const ScheduleFormPlaceNameChanged(placeName: 'Office')); - bloc.add( - const ScheduleFormMoveTimeChanged(moveTime: Duration(minutes: 30)), - ); - bloc.add( - const ScheduleFormScheduleSpareTimeChanged( - scheduleSpareTime: Duration(minutes: 10), - ), - ); + await primeEditState(bloc); - final submitFailed = bloc.stream.firstWhere( - (state) => state.submissionStatus == ScheduleFormSubmissionStatus.failure, - ); - bloc.add(const ScheduleFormCreated()); - await submitFailed; + bloc.add(ScheduleFormPreparationChanged(preparation: preparation)); + await pumpEventQueue(); - expect( - submissionStatuses, - containsAllInOrder([ - ScheduleFormSubmissionStatus.submitting, - ScheduleFormSubmissionStatus.failure, - ]), - ); + expect(bloc.state.isChanged, IsPreparationChanged.unchanged); }); } diff --git a/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart b/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart index 56c3bfee..28908068 100644 --- a/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart +++ b/test/presentation/schedule_create/bloc/schedule_form_state_event_test.dart @@ -10,8 +10,15 @@ void main() { final preparation = _preparation(); expect(const ScheduleFormEditRequested(scheduleId: 's-1').props, ['s-1']); - expect(ScheduleFormCreateRequested(initialDate: date).props, [date]); - expect(const ScheduleFormCreateRequested().props.single, isA()); + expect( + ScheduleFormCreateRequested( + initialDate: date, + currentUserSpareTime: const Duration(minutes: 5), + ).props, + [date, const Duration(minutes: 5)], + ); + expect(const ScheduleFormCreateRequested().props.first, isA()); + expect(const ScheduleFormCreateRequested().props.last, Duration.zero); expect( const ScheduleFormScheduleNameChanged(scheduleName: 'Meeting').props, ['Meeting'], diff --git a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart index f55e1fe9..90d0e88e 100644 --- a/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart +++ b/test/presentation/schedule_create/components/schedule_multi_page_form_test.dart @@ -10,21 +10,15 @@ 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_with_time_entity.dart'; -import 'package:on_time_front/domain/entities/product_usage_event.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/user_entity.dart'; -import 'package:on_time_front/domain/use-cases/create_custom_preparation_use_case.dart'; -import 'package:on_time_front/domain/use-cases/create_schedule_with_place_use_case.dart'; +import 'package:on_time_front/domain/use-cases/create_schedule_form_submission_use_case.dart'; import 'package:on_time_front/domain/use-cases/get_adjacent_schedules_with_preparation_use_case.dart'; -import 'package:on_time_front/domain/use-cases/get_default_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_schedule_by_id_use_case.dart'; import 'package:on_time_front/domain/use-cases/load_adjacent_schedule_with_preparation_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/track_product_usage_event_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_preparation_by_schedule_id_use_case.dart'; -import 'package:on_time_front/domain/use-cases/update_schedule_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_schedule_form_draft_use_case.dart'; +import 'package:on_time_front/domain/use-cases/schedule_form_submission.dart'; +import 'package:on_time_front/domain/use-cases/update_schedule_form_submission_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/schedule_create/bloc/schedule_form_bloc.dart'; @@ -34,6 +28,8 @@ import 'package:on_time_front/presentation/schedule_create/schedule_date_time/cu import 'package:on_time_front/presentation/schedule_create/screens/schedule_create_screen.dart'; import 'package:on_time_front/presentation/schedule_create/screens/schedule_edit_screen.dart'; +const _unset = Object(); + class StubAuthBloc extends Mock implements AuthBloc { StubAuthBloc(this._state); @@ -49,94 +45,51 @@ class StubAuthBloc extends Mock implements AuthBloc { bool get isClosed => false; } -class NoopProductUsageEventTracker implements ProductUsageEventTracker { - @override - Future track(ProductUsageEvent event) async {} -} - -class StubLoadPreparationByScheduleIdUseCase - implements LoadPreparationByScheduleIdUseCase { - StubLoadPreparationByScheduleIdUseCase(this.handler); - - final Future Function(String scheduleId) handler; - - @override - Future call(String scheduleId) => handler(scheduleId); -} - -class StubGetPreparationByScheduleIdUseCase - implements GetPreparationByScheduleIdUseCase { - StubGetPreparationByScheduleIdUseCase(this.handler); - - final Future Function(String scheduleId) handler; - - @override - Future call(String scheduleId) => handler(scheduleId); -} - -class StubGetDefaultPreparationUseCase implements GetDefaultPreparationUseCase { - StubGetDefaultPreparationUseCase(this.handler); - - final Future Function() handler; - - @override - Future call() => handler(); -} - -class StubGetScheduleByIdUseCase implements GetScheduleByIdUseCase { - StubGetScheduleByIdUseCase(this.handler); +class StubLoadScheduleFormDraftUseCase implements LoadScheduleFormDraftUseCase { + StubLoadScheduleFormDraftUseCase({ + required this.createHandler, + required this.editHandler, + }); - final Future Function(String id) handler; + Future Function({ + DateTime? initialDate, + Duration? currentUserSpareTime, + }) + createHandler; + Future Function(String scheduleId) editHandler; @override - Future call(String id) => handler(id); -} - -class StubCreateScheduleWithPlaceUseCase - implements CreateScheduleWithPlaceUseCase { - StubCreateScheduleWithPlaceUseCase(this.handler); - - final Future Function(ScheduleEntity schedule) handler; + Future create({ + DateTime? initialDate, + Duration? currentUserSpareTime, + }) { + return createHandler( + initialDate: initialDate, + currentUserSpareTime: currentUserSpareTime, + ); + } @override - Future call(ScheduleEntity schedule) => handler(schedule); + Future edit(String scheduleId) => editHandler(scheduleId); } -class StubCreateCustomPreparationUseCase - implements CreateCustomPreparationUseCase { - StubCreateCustomPreparationUseCase(this.handler); - - final Future Function(PreparationEntity preparation, String scheduleId) - handler; +class SpyCreateScheduleFormSubmissionUseCase + implements CreateScheduleFormSubmissionUseCase { + Future Function(ScheduleFormSubmission submission)? handler; @override - Future call(PreparationEntity preparationEntity, String scheduleId) { - return handler(preparationEntity, scheduleId); + Future call(ScheduleFormSubmission submission) async { + await handler?.call(submission); } } -class StubUpdateScheduleUseCase implements UpdateScheduleUseCase { - StubUpdateScheduleUseCase(this.handler); - - Future Function(ScheduleEntity schedule) handler; - - @override - Future call( - ScheduleEntity schedule, { - bool includePreparationSource = false, - }) => handler(schedule); -} - -class StubUpdatePreparationByScheduleIdUseCase - implements UpdatePreparationByScheduleIdUseCase { - StubUpdatePreparationByScheduleIdUseCase(this.handler); - - final Future Function(PreparationEntity preparation, String scheduleId) - handler; +class SpyUpdateScheduleFormSubmissionUseCase + implements UpdateScheduleFormSubmissionUseCase { + Future Function(ScheduleFormSubmission submission)? handler; @override - Future call(PreparationEntity preparationEntity, String scheduleId) { - return handler(preparationEntity, scheduleId); + Future call(ScheduleFormSubmission submission) async { + await handler?.call(submission); } } @@ -185,16 +138,11 @@ class StubGetAdjacentSchedulesWithPreparationUseCase } void main() { - late StubLoadPreparationByScheduleIdUseCase - loadPreparationByScheduleIdUseCase; - late StubGetPreparationByScheduleIdUseCase getPreparationByScheduleIdUseCase; - late StubGetDefaultPreparationUseCase getDefaultPreparationUseCase; - late StubGetScheduleByIdUseCase getScheduleByIdUseCase; - late StubCreateScheduleWithPlaceUseCase createScheduleWithPlaceUseCase; - late StubCreateCustomPreparationUseCase createCustomPreparationUseCase; - late StubUpdateScheduleUseCase updateScheduleUseCase; - late StubUpdatePreparationByScheduleIdUseCase - updatePreparationByScheduleIdUseCase; + late StubLoadScheduleFormDraftUseCase loadScheduleFormDraftUseCase; + late SpyCreateScheduleFormSubmissionUseCase + createScheduleFormSubmissionUseCase; + late SpyUpdateScheduleFormSubmissionUseCase + updateScheduleFormSubmissionUseCase; late StubAuthBloc authBloc; late StubLoadAdjacentScheduleWithPreparationUseCase @@ -226,16 +174,30 @@ void main() { ScheduleFormBloc buildBloc() { return ScheduleFormBloc( - loadPreparationByScheduleIdUseCase, - getPreparationByScheduleIdUseCase, - getDefaultPreparationUseCase, - getScheduleByIdUseCase, - createScheduleWithPlaceUseCase, - createCustomPreparationUseCase, - updateScheduleUseCase, - updatePreparationByScheduleIdUseCase, - NoopProductUsageEventTracker(), - authBloc, + loadScheduleFormDraftUseCase, + createScheduleFormSubmissionUseCase, + updateScheduleFormSubmissionUseCase, + ); + } + + ScheduleFormDraft draftFromSchedule({ + bool preparationChanged = false, + Duration? spareTime = const Duration(minutes: 10), + Object? scheduleTime = _unset, + }) { + return ScheduleFormDraft( + id: schedule.id, + placeId: schedule.place.id, + placeName: schedule.place.placeName, + scheduleName: schedule.scheduleName, + scheduleTime: identical(scheduleTime, _unset) + ? schedule.scheduleTime + : scheduleTime as DateTime?, + moveTime: schedule.moveTime, + preparationChanged: preparationChanged, + scheduleSpareTime: spareTime, + scheduleNote: schedule.scheduleNote, + preparation: preparation, ); } @@ -294,9 +256,7 @@ void main() { WidgetTester tester, { required Widget screen, }) async { - getIt.registerFactoryParam( - (_, __) => buildBloc(), - ); + getIt.registerFactory(() => buildBloc()); await tester.pumpWidget( MaterialApp( @@ -331,25 +291,20 @@ void main() { } setUp(() { - loadPreparationByScheduleIdUseCase = StubLoadPreparationByScheduleIdUseCase( - (_) async {}, - ); - getPreparationByScheduleIdUseCase = StubGetPreparationByScheduleIdUseCase( - (_) async => preparation, - ); - getDefaultPreparationUseCase = StubGetDefaultPreparationUseCase( - () async => preparation, - ); - getScheduleByIdUseCase = StubGetScheduleByIdUseCase((_) async => schedule); - createScheduleWithPlaceUseCase = StubCreateScheduleWithPlaceUseCase( - (_) async {}, - ); - createCustomPreparationUseCase = StubCreateCustomPreparationUseCase( - (_, __) async {}, + loadScheduleFormDraftUseCase = StubLoadScheduleFormDraftUseCase( + createHandler: + ({DateTime? initialDate, Duration? currentUserSpareTime}) async { + return draftFromSchedule( + spareTime: currentUserSpareTime, + scheduleTime: initialDate, + ); + }, + editHandler: (_) async => draftFromSchedule(), ); - updateScheduleUseCase = StubUpdateScheduleUseCase((_) async {}); - updatePreparationByScheduleIdUseCase = - StubUpdatePreparationByScheduleIdUseCase((_, __) async {}); + createScheduleFormSubmissionUseCase = + SpyCreateScheduleFormSubmissionUseCase(); + updateScheduleFormSubmissionUseCase = + SpyUpdateScheduleFormSubmissionUseCase(); loadAdjacentScheduleWithPreparationUseCase = StubLoadAdjacentScheduleWithPreparationUseCase( @@ -402,7 +357,8 @@ void main() { 'final submit does not close sheet immediately while submitting', (tester) async { final submitCompleter = Completer(); - updateScheduleUseCase.handler = (_) => submitCompleter.future; + updateScheduleFormSubmissionUseCase.handler = (_) => + submitCompleter.future; final bloc = buildBloc(); addTearDown(bloc.close); @@ -459,7 +415,8 @@ void main() { testWidgets('sheet stays open and shows error when submit fails', ( tester, ) async { - updateScheduleUseCase.handler = (_) => Future.error(Exception('update')); + updateScheduleFormSubmissionUseCase.handler = (_) => + Future.error(Exception('update')); final bloc = buildBloc(); addTearDown(bloc.close); @@ -478,8 +435,8 @@ void main() { tester, ) async { ScheduleEntity? updatedSchedule; - updateScheduleUseCase.handler = (schedule) async { - updatedSchedule = schedule; + updateScheduleFormSubmissionUseCase.handler = (submission) async { + updatedSchedule = submission.schedule; }; final bloc = buildBloc();