diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 454ed1fa..5ebad5be 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -215,33 +215,33 @@ SPEC CHECKSUMS: AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33 - firebase_core: 9156a152117c843440b0b990c785aa0259bc5447 - firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269 + firebase_core: 40bc9f4c0ee3a28fbfb1e00e2ddaed744bb86388 + firebase_messaging: 7168ed5c5f52fc396426745be147263cfcc39ecc FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284 FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9 FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_appauth: d4abcf54856e5d8ba82ed7646ffc83245d4aa448 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb - flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 - google_sign_in_ios: 205742c688aea0e64db9da03c33121694a365109 + flutter_appauth: 88fcbc27871cbedac400db9d39b1363e4850179d + flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f + flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d + google_sign_in_ios: 4bb0e529b167cadc6ac785b6ed943c0a0a4cc1c9 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 - kakao_flutter_sdk_common: 682b3606698f87467788598dc2dc09d4e6867fbd + kakao_flutter_sdk_common: a21740b9dd4900f96161f365a2b6ece7a97cbf4f nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 - sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 + sqlite3_flutter_libs: f9114e4bbe1f2e03dd543373c53d23245982ca13 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188 PODFILE CHECKSUM: be4663332fca77601dd6736e3676fe47177084e1 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8786969d..90b1f7e8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -175,6 +175,23 @@ "@preparationTime": { "description": "Label for preparation time" }, + "preparationNameRequired": "Please enter a preparation name.", + "@preparationNameRequired": { + "description": "Error shown when a preparation step name is empty" + }, + "preparationTimeMinimumError": "Set preparation time to at least 1 minute.", + "@preparationTimeMinimumError": { + "description": "Error shown when a preparation step time is zero or negative" + }, + "preparationTimeMaximumError": "Preparation time can be up to {minutes} minutes.", + "@preparationTimeMaximumError": { + "description": "Error shown when a preparation step time exceeds the maximum allowed minutes", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "hours": "hours", "@hours": { "description": "Unit of time" diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index d6dcb735..3be04c0e 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -43,6 +43,17 @@ "appointmentPlace": "약속 장소", "travelTime": "이동시간", "preparationTime": "준비시간", + "preparationNameRequired": "준비 이름을 입력해 주세요.", + "preparationTimeMinimumError": "준비 시간을 1분 이상으로 설정해 주세요.", + "preparationTimeMaximumError": "준비 시간은 최대 {minutes}분까지 설정할 수 있어요.", + "@preparationTimeMaximumError": { + "description": "Error shown when a preparation step time exceeds the maximum allowed minutes", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "hours": "시간", "minutes": "분", "selectTime": "시간을 선택해 주세요", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 02f67e8e..a9510a64 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -362,6 +362,24 @@ abstract class AppLocalizations { /// **'Preparation Time'** String get preparationTime; + /// Error shown when a preparation step name is empty + /// + /// In en, this message translates to: + /// **'Please enter a preparation name.'** + String get preparationNameRequired; + + /// Error shown when a preparation step time is zero or negative + /// + /// In en, this message translates to: + /// **'Set preparation time to at least 1 minute.'** + String get preparationTimeMinimumError; + + /// Error shown when a preparation step time exceeds the maximum allowed minutes + /// + /// In en, this message translates to: + /// **'Preparation time can be up to {minutes} minutes.'** + String preparationTimeMaximumError(int minutes); + /// Unit of time /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 74224485..1d894098 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -150,6 +150,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get preparationTime => 'Preparation Time'; + @override + String get preparationNameRequired => 'Please enter a preparation name.'; + + @override + String get preparationTimeMinimumError => + 'Set preparation time to at least 1 minute.'; + + @override + String preparationTimeMaximumError(int minutes) { + return 'Preparation time can be up to $minutes minutes.'; + } + @override String get hours => 'hours'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index eb4e782b..c5058f89 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -145,6 +145,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get preparationTime => '준비시간'; + @override + String get preparationNameRequired => '준비 이름을 입력해 주세요.'; + + @override + String get preparationTimeMinimumError => '준비 시간을 1분 이상으로 설정해 주세요.'; + + @override + String preparationTimeMaximumError(int minutes) { + return '준비 시간은 최대 $minutes분까지 설정할 수 있어요.'; + } + @override String get hours => '시간'; diff --git a/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart b/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart index 8f2d45d5..2f51c47c 100644 --- a/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart +++ b/lib/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.dart @@ -256,7 +256,8 @@ class _PreparationSection extends StatelessWidget { padding: const EdgeInsets.only(bottom: 15.0), child: Builder( builder: (context) { - final totalDuration = preparationNameState.preparationStepList + final totalDuration = preparationNameState + .visiblePreparationStepList .fold( Duration.zero, (prev, step) => prev + step.preparationTime.value, diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart index 8eae2568..4bd64b8b 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -20,17 +18,38 @@ class PreparationFormBloc PreparationFormBloc() : super(PreparationFormState()) { on(_onPreparationFormEditRequested); on( - _onPreparationFormPreparationStepCreated); + _onPreparationFormPreparationStepCreated, + ); on( - _onPreparationFormPreparationStepRemoved); + _onPreparationFormPreparationStepRemoved, + ); on( - _onPreparationFormPreparationStepNameChanged); + _onPreparationFormPreparationStepNameChanged, + ); + on( + _onPreparationFormPreparationStepNameFocusLost, + ); + on( + _onPreparationFormPreparationStepInteractionEnded, + ); on( - _onPreparationFormPreparationStepTimeChanged); + _onPreparationFormPreparationStepTimeChanged, + ); + on( + _onPreparationFormDraftStepNameChanged, + ); + on( + _onPreparationFormDraftStepTimeChanged, + ); on( - _onPreparationFormPreparationStepOrderChanged); + _onPreparationFormPreparationStepOrderChanged, + ); on( - _onPreparationFormPreparationStepCreationRequested); + _onPreparationFormPreparationStepCreationRequested, + ); + on( + _onPreparationFormValidationRequested, + ); } void _onPreparationFormEditRequested( @@ -39,12 +58,16 @@ class PreparationFormBloc ) { final PreparationFormState preparationFormState = PreparationFormState.fromEntity(event.preparationEntity); - final isValid = _validate(preparationFormState.preparationStepList); - emit(state.copyWith( - status: PreparationFormStatus.initial, - preparationStepList: preparationFormState.preparationStepList, - isValid: isValid, - )); + final isValid = _validate(preparationFormState.visiblePreparationStepList); + emit( + state.copyWith( + status: PreparationFormStatus.initial, + preparationStepList: preparationFormState.preparationStepList, + clearAddingStepId: true, + showValidationErrors: false, + isValid: isValid, + ), + ); } void _onPreparationFormPreparationStepCreated( @@ -62,11 +85,14 @@ class PreparationFormBloc preparationStepList = state.preparationStepList; } final isValid = _validate(preparationStepList); - emit(state.copyWith( - preparationStepList: preparationStepList, - status: PreparationFormStatus.initial, - isValid: isValid, - )); + emit( + state.copyWith( + preparationStepList: preparationStepList, + status: PreparationFormStatus.initial, + clearAddingStepId: true, + isValid: isValid, + ), + ); } } @@ -78,60 +104,233 @@ class PreparationFormBloc return; } - final removedList = - List.from(state.preparationStepList); + final removedList = List.from( + state.preparationStepList, + ); removedList.removeWhere((element) => element.id == event.preparationStepId); final isValid = _validate(removedList); - emit(state.copyWith( - preparationStepList: removedList, - isValid: isValid, - )); + final removedAddingStep = state.addingStepId == event.preparationStepId; + emit( + state.copyWith( + preparationStepList: removedList, + status: removedAddingStep ? PreparationFormStatus.initial : null, + clearAddingStepId: removedAddingStep, + isValid: isValid, + ), + ); } void _onPreparationFormPreparationStepNameChanged( PreparationFormPreparationStepNameChanged event, Emitter emit, ) { - final changedList = - List.from(state.preparationStepList); - changedList[event.index] = changedList[event.index].copyWith( - preparationName: - PreparationNameInputModel.dirty(event.preparationStepName), + final step = state.preparationStepList.elementAtOrNull(event.index); + if (step == null) { + return; + } + + final changedList = List.from( + state.preparationStepList, + ); + changedList[event.index] = step.copyWith( + preparationName: PreparationNameInputModel.dirty( + event.preparationStepName, + ), + ); + + final isValid = _validate(changedList); + final shouldCommitAddingStep = _shouldCommitAddingStep( + changedList[event.index], + ); + emit( + state.copyWith( + preparationStepList: changedList, + status: shouldCommitAddingStep ? PreparationFormStatus.initial : null, + clearAddingStepId: shouldCommitAddingStep, + isValid: isValid, + ), + ); + } + + void _onPreparationFormPreparationStepNameFocusLost( + PreparationFormPreparationStepNameFocusLost event, + Emitter emit, + ) { + final step = state.preparationStepList.elementAtOrNull(event.index); + if (step == null) { + return; + } + + final isBlankAddingStep = + step.id == state.addingStepId && + event.preparationStepName.trim().isEmpty && + step.preparationTime.value == Duration.zero; + if (isBlankAddingStep) { + final changedList = List.from( + state.preparationStepList, + )..removeAt(event.index); + final isValid = _validate(changedList); + emit( + state.copyWith( + preparationStepList: changedList, + status: PreparationFormStatus.initial, + clearAddingStepId: true, + isValid: isValid, + ), + ); + return; + } + + add( + PreparationFormPreparationStepNameChanged( + index: event.index, + preparationStepName: event.preparationStepName, + ), ); + } + + void _onPreparationFormPreparationStepInteractionEnded( + PreparationFormPreparationStepInteractionEnded event, + Emitter emit, + ) { + final step = state.preparationStepList.elementAtOrNull(event.index); + if (step == null || step.id != state.addingStepId) { + return; + } + + final isNameBlank = event.preparationStepName.trim().isEmpty; + final isTimeBlank = step.preparationTime.value == Duration.zero; + if (isNameBlank && isTimeBlank) { + final changedList = List.from( + state.preparationStepList, + )..removeAt(event.index); + final isValid = _validate(changedList); + emit( + state.copyWith( + preparationStepList: changedList, + status: PreparationFormStatus.initial, + clearAddingStepId: true, + isValid: isValid, + ), + ); + return; + } + final changedStep = step.copyWith( + preparationName: PreparationNameInputModel.dirty( + event.preparationStepName, + ), + preparationTime: PreparationTimeInputModel.dirty( + step.preparationTime.value, + ), + ); + final changedList = List.from( + state.preparationStepList, + )..[event.index] = changedStep; final isValid = _validate(changedList); - emit(state.copyWith( - preparationStepList: changedList, - isValid: isValid, - )); + final shouldCommitAddingStep = _shouldCommitAddingStep(changedStep); + emit( + state.copyWith( + preparationStepList: changedList, + status: shouldCommitAddingStep ? PreparationFormStatus.initial : null, + clearAddingStepId: shouldCommitAddingStep, + isValid: isValid, + ), + ); } void _onPreparationFormPreparationStepTimeChanged( PreparationFormPreparationStepTimeChanged event, Emitter emit, ) { - final changedList = - List.from(state.preparationStepList); - changedList[event.index] = changedList[event.index].copyWith( - preparationTime: - PreparationTimeInputModel.dirty(event.preparationStepTime), + final step = state.preparationStepList.elementAtOrNull(event.index); + if (step == null) { + return; + } + + final changedList = List.from( + state.preparationStepList, + ); + changedList[event.index] = step.copyWith( + preparationTime: PreparationTimeInputModel.dirty( + event.preparationStepTime, + ), ); final isValid = _validate(changedList); - emit(state.copyWith( - preparationStepList: changedList, - isValid: isValid, - )); + final shouldCommitAddingStep = _shouldCommitAddingStep( + changedList[event.index], + ); + emit( + state.copyWith( + preparationStepList: changedList, + status: shouldCommitAddingStep ? PreparationFormStatus.initial : null, + clearAddingStepId: shouldCommitAddingStep, + isValid: isValid, + ), + ); + } + + bool _shouldCommitAddingStep(PreparationStepFormState step) { + return step.id == state.addingStepId && + step.preparationName.isValid && + step.preparationTime.isValid; + } + + void _onPreparationFormDraftStepNameChanged( + PreparationFormDraftStepNameChanged event, + Emitter emit, + ) { + final draftIndex = state.preparationStepList.indexWhere( + (step) => step.id == state.addingStepId, + ); + if (draftIndex == -1) { + return; + } + + add( + PreparationFormPreparationStepNameChanged( + index: draftIndex, + preparationStepName: event.preparationStepName, + ), + ); + } + + void _onPreparationFormDraftStepTimeChanged( + PreparationFormDraftStepTimeChanged event, + Emitter emit, + ) { + final draftIndex = state.preparationStepList.indexWhere( + (step) => step.id == state.addingStepId, + ); + if (draftIndex == -1) { + return; + } + + add( + PreparationFormPreparationStepTimeChanged( + index: draftIndex, + preparationStepTime: event.preparationStepTime, + ), + ); } void _onPreparationFormPreparationStepOrderChanged( PreparationFormPreparationStepOrderChanged event, Emitter emit, ) { - final changedList = - List.from(state.preparationStepList); + final changedList = List.from( + state.preparationStepList, + ); int oldIndex = event.oldIndex; int newIndex = event.newIndex; + if (oldIndex < 0 || + oldIndex >= changedList.length || + newIndex < 0 || + newIndex > changedList.length) { + return; + } + if (oldIndex < newIndex) { newIndex -= 1; } @@ -140,25 +339,47 @@ class PreparationFormBloc changedList.insert(newIndex, item); final isValid = _validate(changedList); - emit(state.copyWith( - preparationStepList: changedList, - isValid: isValid, - )); + emit(state.copyWith(preparationStepList: changedList, isValid: isValid)); } bool _validate(List preparationStepList) { - final isValid = preparationStepList.isNotEmpty && - Formz.validate(preparationStepList - .map((e) => [e.preparationName, e.preparationTime]) - .expand((element) => element) - .cast>() - .toList()); + final isValid = + preparationStepList.isNotEmpty && + Formz.validate( + preparationStepList + .map((e) => [e.preparationName, e.preparationTime]) + .expand((element) => element) + .cast>() + .toList(), + ); return isValid; } - FutureOr _onPreparationFormPreparationStepCreationRequested( - PreparationFormPreparationStepCreationRequested event, - Emitter emit) { - emit(state.copyWith(status: PreparationFormStatus.adding)); + void _onPreparationFormPreparationStepCreationRequested( + PreparationFormPreparationStepCreationRequested event, + Emitter emit, + ) { + if (state.status == PreparationFormStatus.adding) { + return; + } + + final addedStep = PreparationStepFormState(); + final changedList = [...state.preparationStepList, addedStep]; + final isValid = _validate(changedList); + emit( + state.copyWith( + status: PreparationFormStatus.adding, + preparationStepList: changedList, + addingStepId: addedStep.id, + isValid: isValid, + ), + ); + } + + void _onPreparationFormValidationRequested( + PreparationFormValidationRequested event, + Emitter emit, + ) { + emit(state.copyWith(showValidationErrors: true)); } } diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_event.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_event.dart index a0854b20..5f7575c0 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_event.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_event.dart @@ -28,8 +28,9 @@ final class PreparationFormPreparationStepCreated extends PreparationFormEvent { final class PreparationFormPreparationStepRemoved extends PreparationFormEvent { final String preparationStepId; - const PreparationFormPreparationStepRemoved( - {required this.preparationStepId}); + const PreparationFormPreparationStepRemoved({ + required this.preparationStepId, + }); @override List get props => [preparationStepId]; @@ -40,8 +41,38 @@ final class PreparationFormPreparationStepNameChanged final int index; final String preparationStepName; - const PreparationFormPreparationStepNameChanged( - {required this.index, required this.preparationStepName}); + const PreparationFormPreparationStepNameChanged({ + required this.index, + required this.preparationStepName, + }); + + @override + List get props => [index, preparationStepName]; +} + +final class PreparationFormPreparationStepNameFocusLost + extends PreparationFormEvent { + final int index; + final String preparationStepName; + + const PreparationFormPreparationStepNameFocusLost({ + required this.index, + required this.preparationStepName, + }); + + @override + List get props => [index, preparationStepName]; +} + +final class PreparationFormPreparationStepInteractionEnded + extends PreparationFormEvent { + final int index; + final String preparationStepName; + + const PreparationFormPreparationStepInteractionEnded({ + required this.index, + required this.preparationStepName, + }); @override List get props => [index, preparationStepName]; @@ -52,20 +83,46 @@ final class PreparationFormPreparationStepTimeChanged final int index; final Duration preparationStepTime; - const PreparationFormPreparationStepTimeChanged( - {required this.index, required this.preparationStepTime}); + const PreparationFormPreparationStepTimeChanged({ + required this.index, + required this.preparationStepTime, + }); @override List get props => [index, preparationStepTime]; } +final class PreparationFormDraftStepNameChanged extends PreparationFormEvent { + final String preparationStepName; + + const PreparationFormDraftStepNameChanged({ + required this.preparationStepName, + }); + + @override + List get props => [preparationStepName]; +} + +final class PreparationFormDraftStepTimeChanged extends PreparationFormEvent { + final Duration preparationStepTime; + + const PreparationFormDraftStepTimeChanged({ + required this.preparationStepTime, + }); + + @override + List get props => [preparationStepTime]; +} + final class PreparationFormPreparationStepOrderChanged extends PreparationFormEvent { final int oldIndex; final int newIndex; - const PreparationFormPreparationStepOrderChanged( - {required this.oldIndex, required this.newIndex}); + const PreparationFormPreparationStepOrderChanged({ + required this.oldIndex, + required this.newIndex, + }); @override List get props => [oldIndex, newIndex]; @@ -75,3 +132,7 @@ final class PreparationFormPreparationStepCreationRequested extends PreparationFormEvent { const PreparationFormPreparationStepCreationRequested(); } + +final class PreparationFormValidationRequested extends PreparationFormEvent { + const PreparationFormValidationRequested(); +} diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_state.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_state.dart index 9dddcfc7..4aca0776 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_state.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_state.dart @@ -2,10 +2,14 @@ part of 'preparation_form_bloc.dart'; enum PreparationFormStatus { initial, success, adding } +enum PreparationFormInvalidField { name, time } + final class PreparationFormState extends Equatable { const PreparationFormState({ this.status = PreparationFormStatus.initial, this.preparationStepList = const [], + this.addingStepId, + this.showValidationErrors = false, this.isValid = false, }); @@ -23,9 +27,11 @@ final class PreparationFormState extends Equatable { PreparationStepFormState( id: currentPreparationStep.id, preparationName: PreparationNameInputModel.pure( - currentPreparationStep.preparationName), + currentPreparationStep.preparationName, + ), preparationTime: PreparationTimeInputModel.pure( - currentPreparationStep.preparationTime), + currentPreparationStep.preparationTime, + ), ), ); break; @@ -39,39 +45,68 @@ final class PreparationFormState extends Equatable { } PreparationEntity toPreparationEntity() { - final steps = preparationStepList - .mapIndexed((index, step) => PreparationStepEntity( - id: step.id, - preparationName: step.preparationName.value, - preparationTime: step.preparationTime.value, - nextPreparationId: index < preparationStepList.length - 1 - ? preparationStepList[index + 1].id - : null, // if not last step, set next step id - )) + final steps = visiblePreparationStepList + .mapIndexed( + (index, step) => PreparationStepEntity( + id: step.id, + preparationName: step.preparationName.value, + preparationTime: step.preparationTime.value, + nextPreparationId: index < visiblePreparationStepList.length - 1 + ? visiblePreparationStepList[index + 1].id + : null, // if not last step, set next step id + ), + ) .toList(); return PreparationEntity(preparationStepList: steps); } final PreparationFormStatus status; final List preparationStepList; + final String? addingStepId; + final bool showValidationErrors; final bool isValid; + List get visiblePreparationStepList => + preparationStepList; + + PreparationStepFormState? get firstInvalidStep => visiblePreparationStepList + .firstWhereOrNull((step) => invalidFieldFor(step) != null); + + PreparationFormInvalidField? invalidFieldFor(PreparationStepFormState step) { + if (!step.preparationName.isValid) { + return PreparationFormInvalidField.name; + } + if (!step.preparationTime.isValid) { + return PreparationFormInvalidField.time; + } + return null; + } + PreparationFormState copyWith({ PreparationFormStatus? status, List? preparationStepList, + String? addingStepId, + bool clearAddingStepId = false, + bool? showValidationErrors, bool? isValid, }) { return PreparationFormState( status: status ?? this.status, preparationStepList: preparationStepList ?? this.preparationStepList, + addingStepId: clearAddingStepId + ? null + : addingStepId ?? this.addingStepId, + showValidationErrors: showValidationErrors ?? this.showValidationErrors, isValid: isValid ?? this.isValid, ); } @override - List get props => [ - status, - preparationStepList, - isValid, - ]; + List get props => [ + status, + preparationStepList, + addingStepId, + showValidationErrors, + isValid, + ]; } diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart index 6a7ca06a..0953229e 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_create_list.dart @@ -2,68 +2,58 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:on_time_front/presentation/onboarding/preparation_name_select/components/create_icon_button.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; -import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_reorderable_list.dart'; -import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart'; class PreparationFormCreateList extends StatelessWidget { - const PreparationFormCreateList( - {super.key, - required this.preparationNameState, - required this.onNameChanged, - required this.onCreationRequested}); + const PreparationFormCreateList({ + super.key, + required this.preparationNameState, + required this.onNameChanged, + required this.onCreationRequested, + this.scrollController, + this.stepKeyFor, + this.nameFocusNodeFor, + }); + final ScrollController? scrollController; final PreparationFormState preparationNameState; + final Key Function(String stepId)? stepKeyFor; + final FocusNode Function(String stepId)? nameFocusNodeFor; final void Function({required int index, required String value}) - onNameChanged; + onNameChanged; final VoidCallback onCreationRequested; @override Widget build(BuildContext context) { return SingleChildScrollView( + controller: scrollController, child: Column( children: [ PreparationFormReorderableList( preparationStepList: preparationNameState.preparationStepList, + addingStepId: preparationNameState.addingStepId, + showValidationErrors: preparationNameState.showValidationErrors, + stepKeyFor: stepKeyFor, + nameFocusNodeFor: nameFocusNodeFor, onNameChanged: (index, value) { onNameChanged(index: index, value: value); }, - onTimeChanged: (index, value) => context - .read() - .add(PreparationFormPreparationStepTimeChanged( - index: index, preparationStepTime: value)), - onReorder: (oldIndex, newIndex) => context - .read() - .add(PreparationFormPreparationStepOrderChanged( - oldIndex: oldIndex, newIndex: newIndex)), - ), - preparationNameState.status == PreparationFormStatus.adding - ? BlocProvider( - create: (context) => PreparationStepFormCubit( - PreparationStepFormState(), - preparationFormBloc: context.read()), - child: BlocBuilder(builder: (context, state) { - return PreparationFormListField( - isAdding: true, - preparationStep: state, - onNameChanged: (value) { - context - .read() - .nameChanged(value); - }, - onNameSaved: () { - context - .read() - .preparationStepSaved(); - }, - ); - }), - ) - : SizedBox.shrink(), - SizedBox( - height: 28.0, + onTimeChanged: (index, value) => + context.read().add( + PreparationFormPreparationStepTimeChanged( + index: index, + preparationStepTime: value, + ), + ), + onReorder: (oldIndex, newIndex) => + context.read().add( + PreparationFormPreparationStepOrderChanged( + oldIndex: oldIndex, + newIndex: newIndex, + ), + ), ), + SizedBox(height: 28.0), Center( child: SizedBox( height: 30, diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart index b6a02fb8..8fc3bd9a 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart @@ -1,5 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:flutter_swipe_action_cell/core/cell.dart'; +import 'package:flutter_swipe_action_cell/core/controller.dart'; +import 'package:on_time_front/core/validation/backend_constraints.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_name_select/input_models/preparation_name_input_model.dart'; +import 'package:on_time_front/presentation/onboarding/preparation_time/input_models/preparation_time_input_model.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart'; import 'package:on_time_front/presentation/shared/components/tile.dart'; @@ -11,17 +17,33 @@ class PreparationFormListField extends StatefulWidget { required this.preparationStep, this.index, this.onNameChanged, + this.onNameFocusLost, this.onPreparationTimeChanged, + this.onPreparationTimeTapped, + this.onInteractionEnded, + this.onRemove, this.onNameSaved, + this.canRemove = true, this.isAdding = false, + this.showValidationErrors = false, + this.focusNode, + this.swipeActionController, }); final PreparationStepFormState preparationStep; final int? index; final ValueChanged? onNameChanged; + final ValueChanged? onNameFocusLost; final ValueChanged? onPreparationTimeChanged; + final VoidCallback? onPreparationTimeTapped; + final ValueChanged? onInteractionEnded; + final VoidCallback? onRemove; final VoidCallback? onNameSaved; + final bool canRemove; final bool isAdding; + final bool showValidationErrors; + final FocusNode? focusNode; + final SwipeActionController? swipeActionController; @override State createState() => @@ -29,7 +51,12 @@ class PreparationFormListField extends StatefulWidget { } class _PreparationFormListFieldState extends State { - final FocusNode focusNode = FocusNode(); + late final FocusNode _internalFocusNode; + final SwipeActionController _internalSwipeActionController = + SwipeActionController(); + late String _nameValue; + bool _hasRequestedInitialFocus = false; + bool _isTimePickerOpen = false; final dragIndicatorSvg = SvgPicture.asset( 'drag_indicator.svg', package: 'assets', @@ -41,28 +68,142 @@ class _PreparationFormListFieldState extends State { @override void dispose() { - focusNode.dispose(); + _effectiveFocusNode.removeListener(_handleFocusChanged); + _internalSwipeActionController.dispose(); + _internalFocusNode.dispose(); super.dispose(); } @override void initState() { super.initState(); - focusNode.addListener(() { - if (!focusNode.hasFocus) { - widget.onNameSaved?.call(); + _internalFocusNode = FocusNode(); + _nameValue = widget.preparationStep.preparationName.value; + _effectiveFocusNode.addListener(_handleFocusChanged); + _requestInitialFocusIfNeeded(); + } + + @override + void didUpdateWidget(covariant PreparationFormListField oldWidget) { + super.didUpdateWidget(oldWidget); + final oldFocusNode = oldWidget.focusNode ?? _internalFocusNode; + final currentFocusNode = widget.focusNode ?? _internalFocusNode; + if (oldFocusNode != currentFocusNode) { + oldFocusNode.removeListener(_handleFocusChanged); + currentFocusNode.addListener(_handleFocusChanged); + } + if (oldWidget.preparationStep.id != widget.preparationStep.id) { + _nameValue = widget.preparationStep.preparationName.value; + } + if (!oldWidget.isAdding && widget.isAdding) { + _hasRequestedInitialFocus = false; + } + _requestInitialFocusIfNeeded(); + } + + FocusNode get _effectiveFocusNode => widget.focusNode ?? _internalFocusNode; + + SwipeActionController get _swipeActionController => + widget.swipeActionController ?? _internalSwipeActionController; + + void _requestInitialFocusIfNeeded() { + if (!widget.isAdding || _hasRequestedInitialFocus) { + return; + } + _hasRequestedInitialFocus = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _effectiveFocusNode.requestFocus(); } }); } - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; + void _handleFocusChanged() { + _swipeActionController.closeAllOpenCell(); + if (!_effectiveFocusNode.hasFocus) { + _handleRowInteractionEnded(); + } + } + + void _handleRowInteractionEnded([String? nameValue]) { + final value = nameValue ?? _nameValue; + if (widget.isAdding && _isTimePickerOpen) { + return; + } if (widget.isAdding) { - focusNode.requestFocus(); + widget.onInteractionEnded?.call(value); + return; } - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), + widget.onNameFocusLost?.call(value); + widget.onNameSaved?.call(); + } + + void _handlePreparationTimeTapped() { + _swipeActionController.closeAllOpenCell(); + if (widget.isAdding) { + _isTimePickerOpen = true; + return; + } + widget.onPreparationTimeTapped?.call(); + } + + void _handlePreparationTimePickerDisposed() { + _isTimePickerOpen = false; + if (!widget.isAdding) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.isAdding) { + _effectiveFocusNode.requestFocus(); + } + }); + } + + String? _nameErrorText(BuildContext context) { + if (!widget.showValidationErrors && + widget.preparationStep.preparationName.isPure) { + return null; + } + + final error = widget.preparationStep.preparationName.validator( + widget.preparationStep.preparationName.value, + ); + return switch (error) { + PreparationNameValidationError.empty => AppLocalizations.of( + context, + )!.preparationNameRequired, + null => null, + }; + } + + String? _timeErrorText(BuildContext context) { + if (!widget.showValidationErrors && + widget.preparationStep.preparationTime.isPure) { + return null; + } + + final error = widget.preparationStep.preparationTime.validator( + widget.preparationStep.preparationTime.value, + ); + final l10n = AppLocalizations.of(context)!; + return switch (error) { + PreparationTimeValidationError.zero || + PreparationTimeValidationError.negative => + l10n.preparationTimeMinimumError, + PreparationTimeValidationError.tooLarge => + l10n.preparationTimeMaximumError(BackendConstraints.maxMinuteValue), + null => null, + }; + } + + Widget _buildTile({ + required BuildContext context, + required String? nameErrorText, + required String? timeErrorText, + }) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return TextFieldTapRegion( child: Tile( key: ValueKey(widget.preparationStep.id), style: TileStyle(padding: EdgeInsets.fromLTRB(21, 19, 21, 19)), @@ -73,8 +214,12 @@ class _PreparationFormListFieldState extends State { child: dragIndicatorSvg, ), trailing: PreparationTimeInput( - time: widget.preparationStep.preparationTime.value, - onPreparationTimeChanged: widget.onPreparationTimeChanged), + time: widget.preparationStep.preparationTime.value, + hasError: timeErrorText != null, + onTap: _handlePreparationTimeTapped, + onDisposed: _handlePreparationTimePickerDisposed, + onPreparationTimeChanged: widget.onPreparationTimeChanged, + ), child: Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0), @@ -83,22 +228,52 @@ class _PreparationFormListFieldState extends State { child: Center( child: TextFormField( scrollPadding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 56), + bottom: MediaQuery.of(context).viewInsets.bottom + 56, + ), initialValue: widget.preparationStep.preparationName.value, - onChanged: widget.onNameChanged, - onFieldSubmitted: (value) => widget.onNameSaved?.call(), + onChanged: (value) { + _nameValue = value; + widget.onNameChanged?.call(value); + }, + onFieldSubmitted: (value) { + _nameValue = value; + _handleRowInteractionEnded(value); + }, + onTap: _swipeActionController.closeAllOpenCell, onTapOutside: (event) { + _swipeActionController.closeAllOpenCell(); FocusManager.instance.primaryFocus?.unfocus(); }, decoration: InputDecoration( isDense: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, + border: nameErrorText == null + ? InputBorder.none + : UnderlineInputBorder( + borderSide: BorderSide( + color: colorScheme.error, + width: 1.5, + ), + ), + enabledBorder: nameErrorText == null + ? InputBorder.none + : UnderlineInputBorder( + borderSide: BorderSide( + color: colorScheme.error, + width: 1.5, + ), + ), + focusedBorder: nameErrorText == null + ? InputBorder.none + : UnderlineInputBorder( + borderSide: BorderSide( + color: colorScheme.error, + width: 2, + ), + ), contentPadding: EdgeInsets.all(3.0), ), style: textTheme.bodyLarge, - focusNode: focusNode, + focusNode: _effectiveFocusNode, ), ), ), @@ -107,4 +282,101 @@ class _PreparationFormListFieldState extends State { ), ); } + + Widget _buildSwipeableTile({ + required BuildContext context, + required String? nameErrorText, + required String? timeErrorText, + }) { + final tile = _buildTile( + context: context, + nameErrorText: nameErrorText, + timeErrorText: timeErrorText, + ); + if (widget.onRemove == null) { + return tile; + } + + return SwipeActionCell( + key: ValueKey('swipe_${widget.preparationStep.id}'), + backgroundColor: Colors.transparent, + controller: _swipeActionController, + trailingActions: [ + SwipeAction( + onTap: (controller) { + if (!widget.canRemove) { + return; + } + widget.onRemove?.call(); + }, + color: Colors.transparent, + content: _SwipeActionContent( + icon: const Icon(Icons.delete, color: Colors.white, size: 24), + color: Theme.of(context).colorScheme.error, + ), + ), + ], + child: tile, + ); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + final nameErrorText = _nameErrorText(context); + final timeErrorText = _timeErrorText(context); + final errorTexts = [?nameErrorText, ?timeErrorText]; + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSwipeableTile( + context: context, + nameErrorText: nameErrorText, + timeErrorText: timeErrorText, + ), + if (errorTexts.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(21, 6, 21, 2), + child: DefaultTextStyle( + style: + textTheme.bodySmall?.copyWith(color: colorScheme.error) ?? + TextStyle(color: colorScheme.error), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final errorText in errorTexts) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text(errorText), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _SwipeActionContent extends StatelessWidget { + const _SwipeActionContent({required this.icon, required this.color}); + + final Widget icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: color, + ), + padding: const EdgeInsets.all(18.0), + child: icon, + ); + } } diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_reorderable_list.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_reorderable_list.dart index 625b6f26..dd0704b8 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_reorderable_list.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_reorderable_list.dart @@ -1,56 +1,58 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_swipe_action_cell/core/cell.dart'; +import 'package:flutter_swipe_action_cell/core/controller.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_form_list_field.dart'; import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_step_form_cubit.dart'; -class _SwipeActionContent extends StatelessWidget { - const _SwipeActionContent({ - required this.icon, - required this.color, - }); - - final Widget icon; - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: color, - ), - padding: const EdgeInsets.all(18.0), - child: icon, - ); - } -} - -class PreparationFormReorderableList extends StatelessWidget { +class PreparationFormReorderableList extends StatefulWidget { const PreparationFormReorderableList({ super.key, required this.preparationStepList, + required this.addingStepId, + required this.showValidationErrors, + required this.stepKeyFor, + required this.nameFocusNodeFor, required this.onNameChanged, required this.onTimeChanged, required this.onReorder, }); final List preparationStepList; + final String? addingStepId; + final bool showValidationErrors; + final Key Function(String stepId)? stepKeyFor; + final FocusNode Function(String stepId)? nameFocusNodeFor; final Function(int index, String value) onNameChanged; final Function(int index, Duration value) onTimeChanged; final Function(int oldIndex, int newIndex) onReorder; + @override + State createState() => + _PreparationFormReorderableListState(); +} + +class _PreparationFormReorderableListState + extends State { + final SwipeActionController _swipeActionController = SwipeActionController(); + + @override + void dispose() { + _swipeActionController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { Widget proxyDecorator( - Widget child, int index, Animation animation) { + Widget child, + int index, + Animation animation, + ) { return AnimatedBuilder( animation: animation, builder: (BuildContext context, Widget? child) { - return SizedBox( - child: child, - ); + return SizedBox(child: child); }, child: child, ); @@ -63,50 +65,57 @@ class PreparationFormReorderableList extends StatelessWidget { shrinkWrap: true, padding: EdgeInsets.zero, physics: NeverScrollableScrollPhysics(), - itemCount: preparationStepList.length, + itemCount: widget.preparationStepList.length, itemBuilder: (context, index) { - final step = preparationStepList[index]; - final theme = Theme.of(context); + final step = widget.preparationStepList[index]; - return SwipeActionCell( - key: ValueKey(step.id), - backgroundColor: Colors.transparent, - trailingActions: [ - SwipeAction( - onTap: (controller) { - if (preparationStepList.length <= 1) return; - context.read().add( - PreparationFormPreparationStepRemoved( - preparationStepId: step.id, - ), - ); - }, - color: Colors.transparent, - content: _SwipeActionContent( - icon: const Icon( - Icons.delete, - color: Colors.white, - size: 24, - ), - color: theme.colorScheme.error, + return PreparationFormListField( + key: + widget.stepKeyFor?.call(step.id) ?? + ValueKey('field_${step.id}'), + index: index, + isAdding: step.id == widget.addingStepId, + canRemove: widget.preparationStepList.length > 1, + showValidationErrors: widget.showValidationErrors, + focusNode: widget.nameFocusNodeFor?.call(step.id), + swipeActionController: _swipeActionController, + preparationStep: step, + onRemove: () { + context.read().add( + PreparationFormPreparationStepRemoved( + preparationStepId: step.id, + ), + ); + }, + onNameChanged: (value) { + widget.onNameChanged(index, value); + }, + onNameFocusLost: (value) { + context.read().add( + PreparationFormPreparationStepNameFocusLost( + index: index, + preparationStepName: value, + ), + ); + }, + onInteractionEnded: (value) { + context.read().add( + PreparationFormPreparationStepInteractionEnded( + index: index, + preparationStepName: value, ), - ), - ], - child: PreparationFormListField( - key: ValueKey('field_${step.id}'), - index: index, - preparationStep: step, - onNameChanged: (value) { - onNameChanged(index, value); - }, - onPreparationTimeChanged: (value) { - onTimeChanged(index, value); - }, - ), + ); + }, + onPreparationTimeTapped: () { + widget.onTimeChanged(index, step.preparationTime.value); + }, + onPreparationTimeChanged: (value) { + widget.onTimeChanged(index, value); + }, ); }, onReorder: (int oldIndex, int newIndex) { - onReorder(oldIndex, newIndex); + widget.onReorder(oldIndex, newIndex); }, ), ); diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart index ed7c457c..56137d66 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/components/preparation_time_input.dart @@ -3,11 +3,20 @@ import 'package:on_time_front/presentation/shared/components/cupertino_picker_mo import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; class PreparationTimeInput extends StatelessWidget { - const PreparationTimeInput( - {super.key, required this.time, required this.onPreparationTimeChanged}); + const PreparationTimeInput({ + super.key, + required this.time, + required this.onPreparationTimeChanged, + this.onTap, + this.onDisposed, + this.hasError = false, + }); final Duration time; final ValueChanged? onPreparationTimeChanged; + final VoidCallback? onTap; + final VoidCallback? onDisposed; + final bool hasError; @override Widget build(BuildContext context) { @@ -21,6 +30,9 @@ class PreparationTimeInput extends StatelessWidget { height: 30, decoration: BoxDecoration( color: AppColors.white, + border: hasError + ? Border.all(color: colorScheme.error, width: 1.5) + : null, borderRadius: BorderRadius.circular(4), ), child: Center( @@ -34,15 +46,17 @@ class PreparationTimeInput extends StatelessWidget { ), ), onTap: () { + onTap?.call(); context.showCupertinoMinutePickerModal( title: '시간을 선택해주세요', initialValue: time, onSaved: (value) { onPreparationTimeChanged?.call(value); }, + onDisposed: onDisposed, ); }, - ) + ), ], ); } diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart index 5f0cc0c3..02bed71f 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/cubit/preparation_edit_draft_cubit.dart @@ -15,4 +15,3 @@ class PreparationEditDraftCubit extends Cubit { void clear() => emit(null); } - diff --git a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart index e6e87652..0d8844cc 100644 --- a/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart +++ b/lib/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/screens/preparation_edit_form.dart @@ -23,14 +23,16 @@ class _PreparationEditFormState extends State { @override Widget build(BuildContext context) { - final draft = getIt.get().state ?? + final draft = + getIt.get().state ?? const PreparationEntity(preparationStepList: []); return Scaffold( body: SafeArea( child: BlocProvider( - create: (context) => getIt.get() - ..add(PreparationFormEditRequested(preparationEntity: draft)), + create: (context) => + getIt.get() + ..add(PreparationFormEditRequested(preparationEntity: draft)), child: BlocBuilder( builder: (context, state) { return Column( @@ -38,9 +40,9 @@ class _PreparationEditFormState extends State { TopBar( onNextPageButtonClicked: state.isValid ? () { - getIt - .get() - .setDraft(state.toPreparationEntity()); + getIt.get().setDraft( + state.toPreparationEntity(), + ); context.pop(); } : null, @@ -51,15 +53,19 @@ class _PreparationEditFormState extends State { Expanded( child: PreparationFormCreateList( preparationNameState: state, - onNameChanged: ( - {required int index, required String value}) { - context.read().add( - PreparationFormPreparationStepNameChanged( - index: index, preparationStepName: value)); - }, + onNameChanged: + ({required int index, required String value}) { + context.read().add( + PreparationFormPreparationStepNameChanged( + index: index, + preparationStepName: value, + ), + ); + }, onCreationRequested: () { context.read().add( - PreparationFormPreparationStepCreationRequested()); + PreparationFormPreparationStepCreationRequested(), + ); }, ), ), diff --git a/test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart b/test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart new file mode 100644 index 00000000..1148cbf9 --- /dev/null +++ b/test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart @@ -0,0 +1,463 @@ +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/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc.dart'; + +void main() { + late PreparationFormBloc bloc; + + final preparation = PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-1', + preparationName: 'Shower', + preparationTime: const Duration(minutes: 10), + nextPreparationId: null, + ), + ], + ); + + setUp(() { + bloc = PreparationFormBloc(); + }); + + tearDown(() async { + await bloc.close(); + }); + + Future waitForState( + bool Function(PreparationFormState state) predicate, + ) { + return bloc.stream.firstWhere(predicate); + } + + test('validates and serializes the newly added preparation step', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.addingStepId != null && + state.preparationStepList.length == 2 && + !state.isValid, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await addingState; + + final validWithAddedStepState = waitForState( + (state) => state.visiblePreparationStepList.length == 2 && state.isValid, + ); + bloc + ..add( + const PreparationFormPreparationStepNameChanged( + index: 1, + preparationStepName: 'Pack bag', + ), + ) + ..add( + const PreparationFormPreparationStepTimeChanged( + index: 1, + preparationStepTime: Duration(minutes: 5), + ), + ); + + final state = await validWithAddedStepState; + final entity = state.toPreparationEntity(); + + expect(state.status, PreparationFormStatus.initial); + expect(state.addingStepId, isNull); + expect(entity.preparationStepList, hasLength(2)); + expect(entity.preparationStepList.last.preparationName, 'Pack bag'); + expect(entity.preparationStepList.last.preparationTime.inMinutes, 5); + }); + + test('can remove a newly added preparation step', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.addingStepId != null && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + final stateWithAddedStep = await addingState; + + final removedState = waitForState( + (state) => + state.status == PreparationFormStatus.initial && + state.addingStepId == null && + state.preparationStepList.length == 1, + ); + bloc.add( + PreparationFormPreparationStepRemoved( + preparationStepId: stateWithAddedStep.addingStepId!, + ), + ); + + final state = await removedState; + + expect(state.preparationStepList.single.id, 'step-1'); + }); + + test('removes a blank newly added step when name focus is lost', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.addingStepId != null && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await addingState; + + final removedState = waitForState( + (state) => + state.status == PreparationFormStatus.initial && + state.addingStepId == null && + state.preparationStepList.length == 1, + ); + bloc.add( + const PreparationFormPreparationStepNameFocusLost( + index: 1, + preparationStepName: '', + ), + ); + + final state = await removedState; + + expect(state.preparationStepList.single.id, 'step-1'); + expect(state.isValid, isTrue); + }); + + test('removes a blank newly added step when row interaction ends', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.addingStepId != null && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await addingState; + + final removedState = waitForState( + (state) => + state.status == PreparationFormStatus.initial && + state.addingStepId == null && + state.preparationStepList.length == 1, + ); + bloc.add( + const PreparationFormPreparationStepInteractionEnded( + index: 1, + preparationStepName: '', + ), + ); + + final state = await removedState; + + expect(state.preparationStepList.single.id, 'step-1'); + expect(state.isValid, isTrue); + }); + + test('marks time dirty when named newly added step leaves row', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.addingStepId != null && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await addingState; + + final nameChangedState = waitForState( + (state) => state.preparationStepList.last.preparationName.value == 'Pack', + ); + bloc.add( + const PreparationFormPreparationStepNameChanged( + index: 1, + preparationStepName: 'Pack', + ), + ); + await nameChangedState; + + final rowEndedState = waitForState( + (state) => + state.preparationStepList.length == 2 && + state.preparationStepList.last.preparationTime.isNotValid && + !state.preparationStepList.last.preparationTime.isPure, + ); + bloc.add( + const PreparationFormPreparationStepInteractionEnded( + index: 1, + preparationStepName: 'Pack', + ), + ); + + final state = await rowEndedState; + + expect(state.addingStepId, isNotNull); + expect(state.preparationStepList.last.preparationName.isValid, isTrue); + expect(state.isValid, isFalse); + }); + + test( + 'keeps name pure when newly added step gets time before leaving', + () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.addingStepId != null && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await addingState; + + final timeChangedState = waitForState( + (state) => + state.preparationStepList.last.preparationTime.value.inMinutes == 5, + ); + bloc.add( + const PreparationFormPreparationStepTimeChanged( + index: 1, + preparationStepTime: Duration(minutes: 5), + ), + ); + + final state = await timeChangedState; + + expect(state.addingStepId, isNotNull); + expect(state.preparationStepList.last.preparationTime.isValid, isTrue); + expect(state.preparationStepList.last.preparationName.isPure, isTrue); + expect(state.preparationStepList.last.preparationName.isNotValid, isTrue); + }, + ); + + test('marks name dirty when timed newly added step leaves row', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.addingStepId != null && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await addingState; + + final timeChangedState = waitForState( + (state) => + state.preparationStepList.last.preparationTime.value.inMinutes == 5, + ); + bloc.add( + const PreparationFormPreparationStepTimeChanged( + index: 1, + preparationStepTime: Duration(minutes: 5), + ), + ); + await timeChangedState; + + final rowEndedState = waitForState( + (state) => + state.preparationStepList.length == 2 && + state.preparationStepList.last.preparationName.isNotValid && + !state.preparationStepList.last.preparationName.isPure, + ); + bloc.add( + const PreparationFormPreparationStepInteractionEnded( + index: 1, + preparationStepName: '', + ), + ); + + final state = await rowEndedState; + + expect(state.addingStepId, isNotNull); + expect(state.preparationStepList.last.preparationTime.isValid, isTrue); + expect(state.isValid, isFalse); + }); + + test( + 'ignores stale time changes after a newly added step is removed', + () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.addingStepId != null && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await addingState; + + final removedState = waitForState( + (state) => + state.status == PreparationFormStatus.initial && + state.addingStepId == null && + state.preparationStepList.length == 1, + ); + bloc.add( + const PreparationFormPreparationStepNameFocusLost( + index: 1, + preparationStepName: '', + ), + ); + final stateAfterRemoval = await removedState; + + bloc.add( + const PreparationFormPreparationStepTimeChanged( + index: 1, + preparationStepTime: Duration(minutes: 5), + ), + ); + await Future.delayed(Duration.zero); + + expect(bloc.state, stateAfterRemoval); + }, + ); + + test('keeps a newly added step with time when name focus is lost', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.addingStepId != null && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await addingState; + + final timeChangedState = waitForState( + (state) => + state.preparationStepList.last.preparationTime.value.inMinutes == 5, + ); + bloc.add( + const PreparationFormPreparationStepTimeChanged( + index: 1, + preparationStepTime: Duration(minutes: 5), + ), + ); + await timeChangedState; + + final focusLostState = waitForState( + (state) => + state.preparationStepList.length == 2 && + state.preparationStepList.last.preparationName.isNotValid, + ); + bloc.add( + const PreparationFormPreparationStepNameFocusLost( + index: 1, + preparationStepName: '', + ), + ); + + final state = await focusLostState; + + expect(state.addingStepId, isNotNull); + expect(state.isValid, isFalse); + }); + + test('can reorder a newly added preparation step', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final addingState = waitForState( + (state) => + state.status == PreparationFormStatus.adding && + state.addingStepId != null && + state.preparationStepList.length == 2, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + final stateWithAddedStep = await addingState; + + final reorderedState = waitForState( + (state) => + state.preparationStepList.first.id == stateWithAddedStep.addingStepId, + ); + bloc.add( + const PreparationFormPreparationStepOrderChanged( + oldIndex: 1, + newIndex: 0, + ), + ); + + final state = await reorderedState; + + expect(state.preparationStepList.last.id, 'step-1'); + }); + + test('shows validation errors after validation is requested', () async { + final editState = waitForState( + (state) => state.preparationStepList.length == 1 && state.isValid, + ); + bloc.add(PreparationFormEditRequested(preparationEntity: preparation)); + await editState; + + final validationState = waitForState( + (state) => state.showValidationErrors && !state.isValid, + ); + bloc + ..add( + const PreparationFormPreparationStepNameChanged( + index: 0, + preparationStepName: '', + ), + ) + ..add(const PreparationFormValidationRequested()); + + final state = await validationState; + + expect(state.firstInvalidStep?.id, 'step-1'); + expect( + state.invalidFieldFor(state.firstInvalidStep!), + PreparationFormInvalidField.name, + ); + }); +}