From 7e7411d671b20b2fa7a4af8be69bb8a46c38ef76 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 13 May 2026 01:09:26 +0900 Subject: [PATCH 1/8] fix: improve preparation validation feedback --- lib/l10n/app_en.arb | 17 ++ lib/l10n/app_ko.arb | 11 + lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_en.dart | 12 + lib/l10n/app_localizations_ko.dart | 11 + .../bloc/preparation_form_bloc.dart | 201 ++++++++++++----- .../bloc/preparation_form_event.dart | 49 +++- .../bloc/preparation_form_state.dart | 70 ++++-- .../preparation_form_create_list.dart | 96 ++++---- .../preparation_form_list_field.dart | 212 ++++++++++++++---- .../preparation_form_reorderable_list.dart | 40 ++-- .../components/preparation_time_input.dart | 14 +- .../cubit/preparation_edit_draft_cubit.dart | 1 - .../screens/preparation_edit_form.dart | 32 +-- .../bloc/preparation_form_bloc_test.dart | 100 +++++++++ 15 files changed, 681 insertions(+), 203 deletions(-) create mode 100644 test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart 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/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..5679bf1c 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,32 @@ class PreparationFormBloc PreparationFormBloc() : super(PreparationFormState()) { on(_onPreparationFormEditRequested); on( - _onPreparationFormPreparationStepCreated); + _onPreparationFormPreparationStepCreated, + ); on( - _onPreparationFormPreparationStepRemoved); + _onPreparationFormPreparationStepRemoved, + ); on( - _onPreparationFormPreparationStepNameChanged); + _onPreparationFormPreparationStepNameChanged, + ); on( - _onPreparationFormPreparationStepTimeChanged); + _onPreparationFormPreparationStepTimeChanged, + ); + on( + _onPreparationFormDraftStepNameChanged, + ); + on( + _onPreparationFormDraftStepTimeChanged, + ); on( - _onPreparationFormPreparationStepOrderChanged); + _onPreparationFormPreparationStepOrderChanged, + ); on( - _onPreparationFormPreparationStepCreationRequested); + _onPreparationFormPreparationStepCreationRequested, + ); + on( + _onPreparationFormValidationRequested, + ); } void _onPreparationFormEditRequested( @@ -39,12 +52,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, + draftStep: null, + showValidationErrors: false, + isValid: isValid, + ), + ); } void _onPreparationFormPreparationStepCreated( @@ -62,11 +79,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, + draftStep: null, + isValid: isValid, + ), + ); } } @@ -78,58 +98,92 @@ 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 isValid = _validate([ + ...removedList, + if (state.draftStep != null) state.draftStep!, + ]); + emit(state.copyWith(preparationStepList: removedList, isValid: isValid)); } void _onPreparationFormPreparationStepNameChanged( PreparationFormPreparationStepNameChanged event, Emitter emit, ) { - final changedList = - List.from(state.preparationStepList); + final changedList = List.from( + state.preparationStepList, + ); changedList[event.index] = changedList[event.index].copyWith( - preparationName: - PreparationNameInputModel.dirty(event.preparationStepName), + preparationName: PreparationNameInputModel.dirty( + event.preparationStepName, + ), ); - final isValid = _validate(changedList); - emit(state.copyWith( - preparationStepList: changedList, - isValid: isValid, - )); + final isValid = _validate([ + ...changedList, + if (state.draftStep != null) state.draftStep!, + ]); + emit(state.copyWith(preparationStepList: changedList, isValid: isValid)); } void _onPreparationFormPreparationStepTimeChanged( PreparationFormPreparationStepTimeChanged event, Emitter emit, ) { - final changedList = - List.from(state.preparationStepList); + final changedList = List.from( + state.preparationStepList, + ); changedList[event.index] = changedList[event.index].copyWith( - preparationTime: - PreparationTimeInputModel.dirty(event.preparationStepTime), - ); - final isValid = _validate(changedList); - emit(state.copyWith( - preparationStepList: changedList, - isValid: isValid, - )); + preparationTime: PreparationTimeInputModel.dirty( + event.preparationStepTime, + ), + ); + final isValid = _validate([ + ...changedList, + if (state.draftStep != null) state.draftStep!, + ]); + emit(state.copyWith(preparationStepList: changedList, isValid: isValid)); + } + + void _onPreparationFormDraftStepNameChanged( + PreparationFormDraftStepNameChanged event, + Emitter emit, + ) { + final draftStep = state.draftStep ?? PreparationStepFormState(); + final changedDraft = draftStep.copyWith( + preparationName: PreparationNameInputModel.dirty( + event.preparationStepName, + ), + ); + final isValid = _validate([...state.preparationStepList, changedDraft]); + emit(state.copyWith(draftStep: changedDraft, isValid: isValid)); + } + + void _onPreparationFormDraftStepTimeChanged( + PreparationFormDraftStepTimeChanged event, + Emitter emit, + ) { + final draftStep = state.draftStep ?? PreparationStepFormState(); + final changedDraft = draftStep.copyWith( + preparationTime: PreparationTimeInputModel.dirty( + event.preparationStepTime, + ), + ); + final isValid = _validate([...state.preparationStepList, changedDraft]); + emit(state.copyWith(draftStep: changedDraft, isValid: isValid)); } 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 < newIndex) { @@ -139,26 +193,49 @@ class PreparationFormBloc final item = changedList.removeAt(oldIndex); changedList.insert(newIndex, item); - final isValid = _validate(changedList); - emit(state.copyWith( - preparationStepList: changedList, - isValid: isValid, - )); + final isValid = _validate([ + ...changedList, + if (state.draftStep != null) state.draftStep!, + ]); + 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 draftStep = PreparationStepFormState(); + final isValid = _validate([...state.preparationStepList, draftStep]); + emit( + state.copyWith( + status: PreparationFormStatus.adding, + draftStep: draftStep, + 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..5df4aea8 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,10 @@ 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]; @@ -52,20 +55,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 +104,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..cc75314f 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,16 @@ part of 'preparation_form_bloc.dart'; enum PreparationFormStatus { initial, success, adding } +enum PreparationFormInvalidField { name, time } + +const _draftStepNoChange = Object(); + final class PreparationFormState extends Equatable { const PreparationFormState({ this.status = PreparationFormStatus.initial, this.preparationStepList = const [], + this.draftStep, + this.showValidationErrors = false, this.isValid = false, }); @@ -23,9 +29,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 +47,69 @@ 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 PreparationStepFormState? draftStep; + final bool showValidationErrors; final bool isValid; + List get visiblePreparationStepList => [ + ...preparationStepList, + if (draftStep != null) draftStep!, + ]; + + 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, + Object? draftStep = _draftStepNoChange, + bool? showValidationErrors, bool? isValid, }) { return PreparationFormState( status: status ?? this.status, preparationStepList: preparationStepList ?? this.preparationStepList, + draftStep: identical(draftStep, _draftStepNoChange) + ? this.draftStep + : draftStep as PreparationStepFormState?, + showValidationErrors: showValidationErrors ?? this.showValidationErrors, isValid: isValid ?? this.isValid, ); } @override - List get props => [ - status, - preparationStepList, - isValid, - ]; + List get props => [ + status, + preparationStepList, + draftStep, + 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..14a042a7 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 @@ -4,66 +4,86 @@ import 'package:on_time_front/presentation/onboarding/preparation_name_select/co 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, + 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)), + 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(); - }, + ? PreparationFormListField( + key: + stepKeyFor?.call(preparationNameState.draftStep!.id) ?? + ValueKey( + 'draft_${preparationNameState.draftStep!.id}', + ), + isAdding: true, + showValidationErrors: + preparationNameState.showValidationErrors, + focusNode: nameFocusNodeFor?.call( + preparationNameState.draftStep!.id, + ), + preparationStep: preparationNameState.draftStep!, + onNameChanged: (value) { + context.read().add( + PreparationFormDraftStepNameChanged( + preparationStepName: value, + ), ); - }), + }, + onPreparationTimeChanged: (value) { + context.read().add( + PreparationFormDraftStepTimeChanged( + preparationStepTime: value, + ), + ); + }, ) : SizedBox.shrink(), - SizedBox( - height: 28.0, - ), + 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..48dc3de3 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,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.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'; @@ -14,6 +18,8 @@ class PreparationFormListField extends StatefulWidget { this.onPreparationTimeChanged, this.onNameSaved, this.isAdding = false, + this.showValidationErrors = false, + this.focusNode, }); final PreparationStepFormState preparationStep; @@ -22,6 +28,8 @@ class PreparationFormListField extends StatefulWidget { final ValueChanged? onPreparationTimeChanged; final VoidCallback? onNameSaved; final bool isAdding; + final bool showValidationErrors; + final FocusNode? focusNode; @override State createState() => @@ -29,7 +37,8 @@ class PreparationFormListField extends StatefulWidget { } class _PreparationFormListFieldState extends State { - final FocusNode focusNode = FocusNode(); + late final FocusNode _internalFocusNode; + bool _hasRequestedInitialFocus = false; final dragIndicatorSvg = SvgPicture.asset( 'drag_indicator.svg', package: 'assets', @@ -41,69 +50,184 @@ class _PreparationFormListFieldState extends State { @override void dispose() { - focusNode.dispose(); + _effectiveFocusNode.removeListener(_handleFocusChanged); + _internalFocusNode.dispose(); super.dispose(); } @override void initState() { super.initState(); - focusNode.addListener(() { - if (!focusNode.hasFocus) { - widget.onNameSaved?.call(); + _internalFocusNode = FocusNode(); + _effectiveFocusNode.addListener(_handleFocusChanged); + _requestInitialFocusIfNeeded(); + } + + @override + void didUpdateWidget(covariant PreparationFormListField oldWidget) { + super.didUpdateWidget(oldWidget); + if (!oldWidget.isAdding && widget.isAdding) { + _hasRequestedInitialFocus = false; + } + _requestInitialFocusIfNeeded(); + } + + FocusNode get _effectiveFocusNode => widget.focusNode ?? _internalFocusNode; + + void _requestInitialFocusIfNeeded() { + if (!widget.isAdding || _hasRequestedInitialFocus) { + return; + } + _hasRequestedInitialFocus = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _effectiveFocusNode.requestFocus(); } }); } + void _handleFocusChanged() { + if (!_effectiveFocusNode.hasFocus) { + widget.onNameSaved?.call(); + } + } + + 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, + }; + } + @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - if (widget.isAdding) { - focusNode.requestFocus(); - } + 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: Tile( - key: ValueKey(widget.preparationStep.id), - style: TileStyle(padding: EdgeInsets.fromLTRB(21, 19, 21, 19)), - leading: widget.index == null - ? dragIndicatorSvg - : ReorderableDragStartListener( - index: widget.index!, - child: dragIndicatorSvg, - ), - trailing: PreparationTimeInput( - time: widget.preparationStep.preparationTime.value, - onPreparationTimeChanged: widget.onPreparationTimeChanged), - child: Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Container( - constraints: BoxConstraints(minHeight: 30), - child: Center( - child: TextFormField( - scrollPadding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 56), - initialValue: widget.preparationStep.preparationName.value, - onChanged: widget.onNameChanged, - onFieldSubmitted: (value) => widget.onNameSaved?.call(), - onTapOutside: (event) { - FocusManager.instance.primaryFocus?.unfocus(); - }, - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: EdgeInsets.all(3.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Tile( + key: ValueKey(widget.preparationStep.id), + style: TileStyle(padding: EdgeInsets.fromLTRB(21, 19, 21, 19)), + leading: widget.index == null + ? dragIndicatorSvg + : ReorderableDragStartListener( + index: widget.index!, + child: dragIndicatorSvg, + ), + trailing: PreparationTimeInput( + time: widget.preparationStep.preparationTime.value, + hasError: timeErrorText != null, + onPreparationTimeChanged: widget.onPreparationTimeChanged, + ), + child: Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Container( + constraints: BoxConstraints(minHeight: 30), + child: Center( + child: TextFormField( + scrollPadding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 56, + ), + initialValue: + widget.preparationStep.preparationName.value, + onChanged: widget.onNameChanged, + onFieldSubmitted: (value) => widget.onNameSaved?.call(), + onTapOutside: (event) { + FocusManager.instance.primaryFocus?.unfocus(); + }, + decoration: InputDecoration( + isDense: true, + 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: _effectiveFocusNode, + ), ), - style: textTheme.bodyLarge, - focusNode: focusNode, ), ), ), ), - ), + 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), + ), + ], + ), + ), + ), + ], ), ); } 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..e8699577 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 @@ -6,10 +6,7 @@ import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_pr 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, - }); + const _SwipeActionContent({required this.icon, required this.color}); final Widget icon; final Color color; @@ -31,12 +28,18 @@ class PreparationFormReorderableList extends StatelessWidget { const PreparationFormReorderableList({ super.key, required this.preparationStepList, + required this.showValidationErrors, + required this.stepKeyFor, + required this.nameFocusNodeFor, required this.onNameChanged, required this.onTimeChanged, required this.onReorder, }); final List preparationStepList; + 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; @@ -44,13 +47,14 @@ class PreparationFormReorderableList extends StatelessWidget { @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, ); @@ -76,25 +80,25 @@ class PreparationFormReorderableList extends StatelessWidget { onTap: (controller) { if (preparationStepList.length <= 1) return; context.read().add( - PreparationFormPreparationStepRemoved( - preparationStepId: step.id, - ), - ); + PreparationFormPreparationStepRemoved( + preparationStepId: step.id, + ), + ); }, color: Colors.transparent, content: _SwipeActionContent( - icon: const Icon( - Icons.delete, - color: Colors.white, - size: 24, - ), + icon: const Icon(Icons.delete, color: Colors.white, size: 24), color: theme.colorScheme.error, ), ), ], child: PreparationFormListField( - key: ValueKey('field_${step.id}'), + key: + stepKeyFor?.call(step.id) ?? + ValueKey('field_${step.id}'), index: index, + showValidationErrors: showValidationErrors, + focusNode: nameFocusNodeFor?.call(step.id), preparationStep: step, onNameChanged: (value) { onNameChanged(index, value); 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..ddb6d6f8 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,16 @@ 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.hasError = false, + }); final Duration time; final ValueChanged? onPreparationTimeChanged; + final bool hasError; @override Widget build(BuildContext context) { @@ -21,6 +26,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( @@ -42,7 +50,7 @@ class PreparationTimeInput extends StatelessWidget { }, ); }, - ) + ), ], ); } 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..e4cb48a3 --- /dev/null +++ b/test/presentation/schedule_create/schedule_spare_and_preparing_time/preparation_form/bloc/preparation_form_bloc_test.dart @@ -0,0 +1,100 @@ +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 visible draft 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.draftStep != null && + !state.isValid, + ); + bloc.add(const PreparationFormPreparationStepCreationRequested()); + await addingState; + + final validWithDraftState = waitForState( + (state) => state.visiblePreparationStepList.length == 2 && state.isValid, + ); + bloc + ..add( + const PreparationFormDraftStepNameChanged( + preparationStepName: 'Pack bag', + ), + ) + ..add( + const PreparationFormDraftStepTimeChanged( + preparationStepTime: Duration(minutes: 5), + ), + ); + + final state = await validWithDraftState; + final entity = state.toPreparationEntity(); + + expect(entity.preparationStepList, hasLength(2)); + expect(entity.preparationStepList.last.preparationName, 'Pack bag'); + expect(entity.preparationStepList.last.preparationTime.inMinutes, 5); + }); + + 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, + ); + }); +} From 2a94aaf2cba31cfa4d70a1e8e05ae06499c0c695 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 13 May 2026 01:18:21 +0900 Subject: [PATCH 2/8] fix: validate preparation fields on touch --- .../preparation_form_create_list.dart | 17 ++++++++++++++ .../preparation_form_list_field.dart | 22 +++++++++++++++++-- .../preparation_form_reorderable_list.dart | 6 +++++ .../components/preparation_time_input.dart | 3 +++ 4 files changed, 46 insertions(+), 2 deletions(-) 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 14a042a7..f48eb8bf 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 @@ -74,6 +74,23 @@ class PreparationFormCreateList extends StatelessWidget { ), ); }, + onNameFocusLost: (value) { + context.read().add( + PreparationFormDraftStepNameChanged( + preparationStepName: value, + ), + ); + }, + onPreparationTimeTapped: () { + context.read().add( + PreparationFormDraftStepTimeChanged( + preparationStepTime: preparationNameState + .draftStep! + .preparationTime + .value, + ), + ); + }, onPreparationTimeChanged: (value) { context.read().add( PreparationFormDraftStepTimeChanged( 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 48dc3de3..ed143f1c 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 @@ -15,7 +15,9 @@ class PreparationFormListField extends StatefulWidget { required this.preparationStep, this.index, this.onNameChanged, + this.onNameFocusLost, this.onPreparationTimeChanged, + this.onPreparationTimeTapped, this.onNameSaved, this.isAdding = false, this.showValidationErrors = false, @@ -25,7 +27,9 @@ class PreparationFormListField extends StatefulWidget { final PreparationStepFormState preparationStep; final int? index; final ValueChanged? onNameChanged; + final ValueChanged? onNameFocusLost; final ValueChanged? onPreparationTimeChanged; + final VoidCallback? onPreparationTimeTapped; final VoidCallback? onNameSaved; final bool isAdding; final bool showValidationErrors; @@ -38,6 +42,7 @@ class PreparationFormListField extends StatefulWidget { class _PreparationFormListFieldState extends State { late final FocusNode _internalFocusNode; + late String _nameValue; bool _hasRequestedInitialFocus = false; final dragIndicatorSvg = SvgPicture.asset( 'drag_indicator.svg', @@ -59,6 +64,7 @@ class _PreparationFormListFieldState extends State { void initState() { super.initState(); _internalFocusNode = FocusNode(); + _nameValue = widget.preparationStep.preparationName.value; _effectiveFocusNode.addListener(_handleFocusChanged); _requestInitialFocusIfNeeded(); } @@ -66,6 +72,9 @@ class _PreparationFormListFieldState extends State { @override void didUpdateWidget(covariant PreparationFormListField oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.preparationStep.id != widget.preparationStep.id) { + _nameValue = widget.preparationStep.preparationName.value; + } if (!oldWidget.isAdding && widget.isAdding) { _hasRequestedInitialFocus = false; } @@ -88,6 +97,7 @@ class _PreparationFormListFieldState extends State { void _handleFocusChanged() { if (!_effectiveFocusNode.hasFocus) { + widget.onNameFocusLost?.call(_nameValue); widget.onNameSaved?.call(); } } @@ -153,6 +163,7 @@ class _PreparationFormListFieldState extends State { trailing: PreparationTimeInput( time: widget.preparationStep.preparationTime.value, hasError: timeErrorText != null, + onTap: widget.onPreparationTimeTapped, onPreparationTimeChanged: widget.onPreparationTimeChanged, ), child: Expanded( @@ -167,8 +178,15 @@ class _PreparationFormListFieldState extends State { ), 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; + widget.onNameFocusLost?.call(value); + widget.onNameSaved?.call(); + }, onTapOutside: (event) { FocusManager.instance.primaryFocus?.unfocus(); }, 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 e8699577..f657b13d 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 @@ -103,6 +103,12 @@ class PreparationFormReorderableList extends StatelessWidget { onNameChanged: (value) { onNameChanged(index, value); }, + onNameFocusLost: (value) { + onNameChanged(index, value); + }, + onPreparationTimeTapped: () { + onTimeChanged(index, step.preparationTime.value); + }, onPreparationTimeChanged: (value) { onTimeChanged(index, value); }, 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 ddb6d6f8..3775be0f 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 @@ -7,11 +7,13 @@ class PreparationTimeInput extends StatelessWidget { super.key, required this.time, required this.onPreparationTimeChanged, + this.onTap, this.hasError = false, }); final Duration time; final ValueChanged? onPreparationTimeChanged; + final VoidCallback? onTap; final bool hasError; @override @@ -42,6 +44,7 @@ class PreparationTimeInput extends StatelessWidget { ), ), onTap: () { + onTap?.call(); context.showCupertinoMinutePickerModal( title: '시간을 선택해주세요', initialValue: time, From 6facc59ea8d528b8afe4ae9e2d11a1be2c4d436d Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 13 May 2026 02:04:14 +0900 Subject: [PATCH 3/8] fix: include draft preparation in default edit total --- .../preparation_spare_time_edit_screen.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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, From 818bb100b0bdd29159c7831a3c0bf1f269e63594 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 13 May 2026 02:18:22 +0900 Subject: [PATCH 4/8] fix: allow new preparation row reorder and delete --- .../bloc/preparation_form_bloc.dart | 106 ++++++++++++------ .../bloc/preparation_form_state.dart | 23 ++-- .../preparation_form_create_list.dart | 49 +------- .../preparation_form_reorderable_list.dart | 3 + .../bloc/preparation_form_bloc_test.dart | 82 +++++++++++++- 5 files changed, 160 insertions(+), 103 deletions(-) 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 5679bf1c..d021fd9a 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 @@ -57,7 +57,7 @@ class PreparationFormBloc state.copyWith( status: PreparationFormStatus.initial, preparationStepList: preparationFormState.preparationStepList, - draftStep: null, + clearAddingStepId: true, showValidationErrors: false, isValid: isValid, ), @@ -83,7 +83,7 @@ class PreparationFormBloc state.copyWith( preparationStepList: preparationStepList, status: PreparationFormStatus.initial, - draftStep: null, + clearAddingStepId: true, isValid: isValid, ), ); @@ -103,11 +103,16 @@ class PreparationFormBloc ); removedList.removeWhere((element) => element.id == event.preparationStepId); - final isValid = _validate([ - ...removedList, - if (state.draftStep != null) state.draftStep!, - ]); - emit(state.copyWith(preparationStepList: removedList, isValid: isValid)); + final isValid = _validate(removedList); + final removedAddingStep = state.addingStepId == event.preparationStepId; + emit( + state.copyWith( + preparationStepList: removedList, + status: removedAddingStep ? PreparationFormStatus.initial : null, + clearAddingStepId: removedAddingStep, + isValid: isValid, + ), + ); } void _onPreparationFormPreparationStepNameChanged( @@ -123,11 +128,18 @@ class PreparationFormBloc ), ); - final isValid = _validate([ - ...changedList, - if (state.draftStep != null) state.draftStep!, - ]); - emit(state.copyWith(preparationStepList: changedList, isValid: isValid)); + 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 _onPreparationFormPreparationStepTimeChanged( @@ -142,39 +154,62 @@ class PreparationFormBloc event.preparationStepTime, ), ); - final isValid = _validate([ - ...changedList, - if (state.draftStep != null) state.draftStep!, - ]); - emit(state.copyWith(preparationStepList: changedList, isValid: isValid)); + final isValid = _validate(changedList); + 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 draftStep = state.draftStep ?? PreparationStepFormState(); - final changedDraft = draftStep.copyWith( - preparationName: PreparationNameInputModel.dirty( - event.preparationStepName, + final draftIndex = state.preparationStepList.indexWhere( + (step) => step.id == state.addingStepId, + ); + if (draftIndex == -1) { + return; + } + + add( + PreparationFormPreparationStepNameChanged( + index: draftIndex, + preparationStepName: event.preparationStepName, ), ); - final isValid = _validate([...state.preparationStepList, changedDraft]); - emit(state.copyWith(draftStep: changedDraft, isValid: isValid)); } void _onPreparationFormDraftStepTimeChanged( PreparationFormDraftStepTimeChanged event, Emitter emit, ) { - final draftStep = state.draftStep ?? PreparationStepFormState(); - final changedDraft = draftStep.copyWith( - preparationTime: PreparationTimeInputModel.dirty( - event.preparationStepTime, + final draftIndex = state.preparationStepList.indexWhere( + (step) => step.id == state.addingStepId, + ); + if (draftIndex == -1) { + return; + } + + add( + PreparationFormPreparationStepTimeChanged( + index: draftIndex, + preparationStepTime: event.preparationStepTime, ), ); - final isValid = _validate([...state.preparationStepList, changedDraft]); - emit(state.copyWith(draftStep: changedDraft, isValid: isValid)); } void _onPreparationFormPreparationStepOrderChanged( @@ -193,10 +228,7 @@ class PreparationFormBloc final item = changedList.removeAt(oldIndex); changedList.insert(newIndex, item); - final isValid = _validate([ - ...changedList, - if (state.draftStep != null) state.draftStep!, - ]); + final isValid = _validate(changedList); emit(state.copyWith(preparationStepList: changedList, isValid: isValid)); } @@ -221,12 +253,14 @@ class PreparationFormBloc return; } - final draftStep = PreparationStepFormState(); - final isValid = _validate([...state.preparationStepList, draftStep]); + final addedStep = PreparationStepFormState(); + final changedList = [...state.preparationStepList, addedStep]; + final isValid = _validate(changedList); emit( state.copyWith( status: PreparationFormStatus.adding, - draftStep: draftStep, + preparationStepList: changedList, + addingStepId: addedStep.id, isValid: isValid, ), ); 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 cc75314f..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 @@ -4,13 +4,11 @@ enum PreparationFormStatus { initial, success, adding } enum PreparationFormInvalidField { name, time } -const _draftStepNoChange = Object(); - final class PreparationFormState extends Equatable { const PreparationFormState({ this.status = PreparationFormStatus.initial, this.preparationStepList = const [], - this.draftStep, + this.addingStepId, this.showValidationErrors = false, this.isValid = false, }); @@ -64,14 +62,12 @@ final class PreparationFormState extends Equatable { final PreparationFormStatus status; final List preparationStepList; - final PreparationStepFormState? draftStep; + final String? addingStepId; final bool showValidationErrors; final bool isValid; - List get visiblePreparationStepList => [ - ...preparationStepList, - if (draftStep != null) draftStep!, - ]; + List get visiblePreparationStepList => + preparationStepList; PreparationStepFormState? get firstInvalidStep => visiblePreparationStepList .firstWhereOrNull((step) => invalidFieldFor(step) != null); @@ -89,16 +85,17 @@ final class PreparationFormState extends Equatable { PreparationFormState copyWith({ PreparationFormStatus? status, List? preparationStepList, - Object? draftStep = _draftStepNoChange, + String? addingStepId, + bool clearAddingStepId = false, bool? showValidationErrors, bool? isValid, }) { return PreparationFormState( status: status ?? this.status, preparationStepList: preparationStepList ?? this.preparationStepList, - draftStep: identical(draftStep, _draftStepNoChange) - ? this.draftStep - : draftStep as PreparationStepFormState?, + addingStepId: clearAddingStepId + ? null + : addingStepId ?? this.addingStepId, showValidationErrors: showValidationErrors ?? this.showValidationErrors, isValid: isValid ?? this.isValid, ); @@ -108,7 +105,7 @@ final class PreparationFormState extends Equatable { List get props => [ status, preparationStepList, - draftStep, + 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 f48eb8bf..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,7 +2,6 @@ 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'; class PreparationFormCreateList extends StatelessWidget { @@ -32,6 +31,7 @@ class PreparationFormCreateList extends StatelessWidget { children: [ PreparationFormReorderableList( preparationStepList: preparationNameState.preparationStepList, + addingStepId: preparationNameState.addingStepId, showValidationErrors: preparationNameState.showValidationErrors, stepKeyFor: stepKeyFor, nameFocusNodeFor: nameFocusNodeFor, @@ -53,53 +53,6 @@ class PreparationFormCreateList extends StatelessWidget { ), ), ), - preparationNameState.status == PreparationFormStatus.adding - ? PreparationFormListField( - key: - stepKeyFor?.call(preparationNameState.draftStep!.id) ?? - ValueKey( - 'draft_${preparationNameState.draftStep!.id}', - ), - isAdding: true, - showValidationErrors: - preparationNameState.showValidationErrors, - focusNode: nameFocusNodeFor?.call( - preparationNameState.draftStep!.id, - ), - preparationStep: preparationNameState.draftStep!, - onNameChanged: (value) { - context.read().add( - PreparationFormDraftStepNameChanged( - preparationStepName: value, - ), - ); - }, - onNameFocusLost: (value) { - context.read().add( - PreparationFormDraftStepNameChanged( - preparationStepName: value, - ), - ); - }, - onPreparationTimeTapped: () { - context.read().add( - PreparationFormDraftStepTimeChanged( - preparationStepTime: preparationNameState - .draftStep! - .preparationTime - .value, - ), - ); - }, - onPreparationTimeChanged: (value) { - context.read().add( - PreparationFormDraftStepTimeChanged( - preparationStepTime: value, - ), - ); - }, - ) - : SizedBox.shrink(), SizedBox(height: 28.0), Center( child: SizedBox( 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 f657b13d..daede337 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 @@ -28,6 +28,7 @@ class PreparationFormReorderableList extends StatelessWidget { const PreparationFormReorderableList({ super.key, required this.preparationStepList, + required this.addingStepId, required this.showValidationErrors, required this.stepKeyFor, required this.nameFocusNodeFor, @@ -37,6 +38,7 @@ class PreparationFormReorderableList extends StatelessWidget { }); final List preparationStepList; + final String? addingStepId; final bool showValidationErrors; final Key Function(String stepId)? stepKeyFor; final FocusNode Function(String stepId)? nameFocusNodeFor; @@ -97,6 +99,7 @@ class PreparationFormReorderableList extends StatelessWidget { stepKeyFor?.call(step.id) ?? ValueKey('field_${step.id}'), index: index, + isAdding: step.id == addingStepId, showValidationErrors: showValidationErrors, focusNode: nameFocusNodeFor?.call(step.id), preparationStep: step, 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 index e4cb48a3..9625be81 100644 --- 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 @@ -31,7 +31,7 @@ void main() { return bloc.stream.firstWhere(predicate); } - test('validates and serializes the visible draft preparation step', () async { + test('validates and serializes the newly added preparation step', () async { final editState = waitForState( (state) => state.preparationStepList.length == 1 && state.isValid, ); @@ -41,35 +41,105 @@ void main() { final addingState = waitForState( (state) => state.status == PreparationFormStatus.adding && - state.draftStep != null && + state.addingStepId != null && + state.preparationStepList.length == 2 && !state.isValid, ); bloc.add(const PreparationFormPreparationStepCreationRequested()); await addingState; - final validWithDraftState = waitForState( + final validWithAddedStepState = waitForState( (state) => state.visiblePreparationStepList.length == 2 && state.isValid, ); bloc ..add( - const PreparationFormDraftStepNameChanged( + const PreparationFormPreparationStepNameChanged( + index: 1, preparationStepName: 'Pack bag', ), ) ..add( - const PreparationFormDraftStepTimeChanged( + const PreparationFormPreparationStepTimeChanged( + index: 1, preparationStepTime: Duration(minutes: 5), ), ); - final state = await validWithDraftState; + 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('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, From f98b2a37917437f65f4ebda1f564f50cc35c6a1d Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 13 May 2026 03:31:56 +0900 Subject: [PATCH 5/8] fix: align preparation delete action with tile --- .../preparation_form_list_field.dart | 221 ++++++++++++------ .../preparation_form_reorderable_list.dart | 90 +++---- 2 files changed, 175 insertions(+), 136 deletions(-) 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 ed143f1c..0ad17ccc 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,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:flutter_swipe_action_cell/core/cell.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'; @@ -18,7 +19,9 @@ class PreparationFormListField extends StatefulWidget { this.onNameFocusLost, this.onPreparationTimeChanged, this.onPreparationTimeTapped, + this.onRemove, this.onNameSaved, + this.canRemove = true, this.isAdding = false, this.showValidationErrors = false, this.focusNode, @@ -30,7 +33,9 @@ class PreparationFormListField extends StatefulWidget { final ValueChanged? onNameFocusLost; final ValueChanged? onPreparationTimeChanged; final VoidCallback? onPreparationTimeTapped; + final VoidCallback? onRemove; final VoidCallback? onNameSaved; + final bool canRemove; final bool isAdding; final bool showValidationErrors; final FocusNode? focusNode; @@ -139,6 +144,125 @@ class _PreparationFormListFieldState extends State { }; } + Widget _buildTile({ + required BuildContext context, + required String? nameErrorText, + required String? timeErrorText, + }) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return Tile( + key: ValueKey(widget.preparationStep.id), + style: TileStyle(padding: EdgeInsets.fromLTRB(21, 19, 21, 19)), + leading: widget.index == null + ? dragIndicatorSvg + : ReorderableDragStartListener( + index: widget.index!, + child: dragIndicatorSvg, + ), + trailing: PreparationTimeInput( + time: widget.preparationStep.preparationTime.value, + hasError: timeErrorText != null, + onTap: widget.onPreparationTimeTapped, + onPreparationTimeChanged: widget.onPreparationTimeChanged, + ), + child: Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Container( + constraints: BoxConstraints(minHeight: 30), + child: Center( + child: TextFormField( + scrollPadding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 56, + ), + initialValue: widget.preparationStep.preparationName.value, + onChanged: (value) { + _nameValue = value; + widget.onNameChanged?.call(value); + }, + onFieldSubmitted: (value) { + _nameValue = value; + widget.onNameFocusLost?.call(value); + widget.onNameSaved?.call(); + }, + onTapOutside: (event) { + FocusManager.instance.primaryFocus?.unfocus(); + }, + decoration: InputDecoration( + isDense: true, + 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: _effectiveFocusNode, + ), + ), + ), + ), + ), + ); + } + + 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, + 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; @@ -151,80 +275,10 @@ class _PreparationFormListFieldState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Tile( - key: ValueKey(widget.preparationStep.id), - style: TileStyle(padding: EdgeInsets.fromLTRB(21, 19, 21, 19)), - leading: widget.index == null - ? dragIndicatorSvg - : ReorderableDragStartListener( - index: widget.index!, - child: dragIndicatorSvg, - ), - trailing: PreparationTimeInput( - time: widget.preparationStep.preparationTime.value, - hasError: timeErrorText != null, - onTap: widget.onPreparationTimeTapped, - onPreparationTimeChanged: widget.onPreparationTimeChanged, - ), - child: Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Container( - constraints: BoxConstraints(minHeight: 30), - child: Center( - child: TextFormField( - scrollPadding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 56, - ), - initialValue: - widget.preparationStep.preparationName.value, - onChanged: (value) { - _nameValue = value; - widget.onNameChanged?.call(value); - }, - onFieldSubmitted: (value) { - _nameValue = value; - widget.onNameFocusLost?.call(value); - widget.onNameSaved?.call(); - }, - onTapOutside: (event) { - FocusManager.instance.primaryFocus?.unfocus(); - }, - decoration: InputDecoration( - isDense: true, - 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: _effectiveFocusNode, - ), - ), - ), - ), - ), + _buildSwipeableTile( + context: context, + nameErrorText: nameErrorText, + timeErrorText: timeErrorText, ), if (errorTexts.isNotEmpty) Padding( @@ -250,3 +304,22 @@ class _PreparationFormListFieldState extends State { ); } } + +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 daede337..12761141 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,29 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_swipe_action_cell/core/cell.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 { const PreparationFormReorderableList({ super.key, @@ -72,50 +52,36 @@ class PreparationFormReorderableList extends StatelessWidget { itemCount: preparationStepList.length, itemBuilder: (context, index) { final step = preparationStepList[index]; - final theme = Theme.of(context); - 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: + stepKeyFor?.call(step.id) ?? + ValueKey('field_${step.id}'), + index: index, + isAdding: step.id == addingStepId, + canRemove: preparationStepList.length > 1, + showValidationErrors: showValidationErrors, + focusNode: nameFocusNodeFor?.call(step.id), + preparationStep: step, + onRemove: () { + context.read().add( + PreparationFormPreparationStepRemoved( + preparationStepId: step.id, ), - ), - ], - child: PreparationFormListField( - key: - stepKeyFor?.call(step.id) ?? - ValueKey('field_${step.id}'), - index: index, - isAdding: step.id == addingStepId, - showValidationErrors: showValidationErrors, - focusNode: nameFocusNodeFor?.call(step.id), - preparationStep: step, - onNameChanged: (value) { - onNameChanged(index, value); - }, - onNameFocusLost: (value) { - onNameChanged(index, value); - }, - onPreparationTimeTapped: () { - onTimeChanged(index, step.preparationTime.value); - }, - onPreparationTimeChanged: (value) { - onTimeChanged(index, value); - }, - ), + ); + }, + onNameChanged: (value) { + onNameChanged(index, value); + }, + onNameFocusLost: (value) { + onNameChanged(index, value); + }, + onPreparationTimeTapped: () { + onTimeChanged(index, step.preparationTime.value); + }, + onPreparationTimeChanged: (value) { + onTimeChanged(index, value); + }, ); }, onReorder: (int oldIndex, int newIndex) { From 8d5e2a164bdfb9a2b09bfd9cf0fc1b5b42d2d0e7 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 13 May 2026 03:37:41 +0900 Subject: [PATCH 6/8] fix: close preparation swipe action on focus loss --- .../components/preparation_form_list_field.dart | 6 ++++++ 1 file changed, 6 insertions(+) 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 0ad17ccc..1a2316b1 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,6 +1,7 @@ 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'; @@ -47,6 +48,7 @@ class PreparationFormListField extends StatefulWidget { class _PreparationFormListFieldState extends State { late final FocusNode _internalFocusNode; + final SwipeActionController _swipeActionController = SwipeActionController(); late String _nameValue; bool _hasRequestedInitialFocus = false; final dragIndicatorSvg = SvgPicture.asset( @@ -61,6 +63,7 @@ class _PreparationFormListFieldState extends State { @override void dispose() { _effectiveFocusNode.removeListener(_handleFocusChanged); + _swipeActionController.dispose(); _internalFocusNode.dispose(); super.dispose(); } @@ -102,6 +105,7 @@ class _PreparationFormListFieldState extends State { void _handleFocusChanged() { if (!_effectiveFocusNode.hasFocus) { + _swipeActionController.closeAllOpenCell(); widget.onNameFocusLost?.call(_nameValue); widget.onNameSaved?.call(); } @@ -187,6 +191,7 @@ class _PreparationFormListFieldState extends State { widget.onNameSaved?.call(); }, onTapOutside: (event) { + _swipeActionController.closeAllOpenCell(); FocusManager.instance.primaryFocus?.unfocus(); }, decoration: InputDecoration( @@ -244,6 +249,7 @@ class _PreparationFormListFieldState extends State { return SwipeActionCell( key: ValueKey('swipe_${widget.preparationStep.id}'), backgroundColor: Colors.transparent, + controller: _swipeActionController, trailingActions: [ SwipeAction( onTap: (controller) { From 5e0f88bf83ee276b17e87c1f3f4502427c177879 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 13 May 2026 03:45:39 +0900 Subject: [PATCH 7/8] fix: remove blank new preparation on blur --- .../bloc/preparation_form_bloc.dart | 40 ++++++ .../bloc/preparation_form_event.dart | 14 ++ .../preparation_form_list_field.dart | 132 +++++++++--------- .../preparation_form_reorderable_list.dart | 7 +- .../bloc/preparation_form_bloc_test.dart | 81 +++++++++++ 5 files changed, 208 insertions(+), 66 deletions(-) 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 d021fd9a..9f6f1f26 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 @@ -26,6 +26,9 @@ class PreparationFormBloc on( _onPreparationFormPreparationStepNameChanged, ); + on( + _onPreparationFormPreparationStepNameFocusLost, + ); on( _onPreparationFormPreparationStepTimeChanged, ); @@ -142,6 +145,43 @@ class PreparationFormBloc ); } + 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 _onPreparationFormPreparationStepTimeChanged( PreparationFormPreparationStepTimeChanged event, Emitter emit, 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 5df4aea8..b5669585 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 @@ -50,6 +50,20 @@ final class PreparationFormPreparationStepNameChanged 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 PreparationFormPreparationStepTimeChanged extends PreparationFormEvent { final int index; 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 1a2316b1..03d6aab4 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 @@ -155,75 +155,77 @@ class _PreparationFormListFieldState extends State { }) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - return Tile( - key: ValueKey(widget.preparationStep.id), - style: TileStyle(padding: EdgeInsets.fromLTRB(21, 19, 21, 19)), - leading: widget.index == null - ? dragIndicatorSvg - : ReorderableDragStartListener( - index: widget.index!, - child: dragIndicatorSvg, - ), - trailing: PreparationTimeInput( - time: widget.preparationStep.preparationTime.value, - hasError: timeErrorText != null, - onTap: widget.onPreparationTimeTapped, - onPreparationTimeChanged: widget.onPreparationTimeChanged, - ), - child: Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Container( - constraints: BoxConstraints(minHeight: 30), - child: Center( - child: TextFormField( - scrollPadding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 56, - ), - initialValue: widget.preparationStep.preparationName.value, - onChanged: (value) { - _nameValue = value; - widget.onNameChanged?.call(value); - }, - onFieldSubmitted: (value) { - _nameValue = value; - widget.onNameFocusLost?.call(value); - widget.onNameSaved?.call(); - }, - onTapOutside: (event) { - _swipeActionController.closeAllOpenCell(); - FocusManager.instance.primaryFocus?.unfocus(); - }, - decoration: InputDecoration( - isDense: true, - border: nameErrorText == null - ? InputBorder.none - : UnderlineInputBorder( - borderSide: BorderSide( - color: colorScheme.error, - width: 1.5, + return TextFieldTapRegion( + child: Tile( + key: ValueKey(widget.preparationStep.id), + style: TileStyle(padding: EdgeInsets.fromLTRB(21, 19, 21, 19)), + leading: widget.index == null + ? dragIndicatorSvg + : ReorderableDragStartListener( + index: widget.index!, + child: dragIndicatorSvg, + ), + trailing: PreparationTimeInput( + time: widget.preparationStep.preparationTime.value, + hasError: timeErrorText != null, + onTap: widget.onPreparationTimeTapped, + onPreparationTimeChanged: widget.onPreparationTimeChanged, + ), + child: Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Container( + constraints: BoxConstraints(minHeight: 30), + child: Center( + child: TextFormField( + scrollPadding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 56, + ), + initialValue: widget.preparationStep.preparationName.value, + onChanged: (value) { + _nameValue = value; + widget.onNameChanged?.call(value); + }, + onFieldSubmitted: (value) { + _nameValue = value; + widget.onNameFocusLost?.call(value); + widget.onNameSaved?.call(); + }, + onTapOutside: (event) { + _swipeActionController.closeAllOpenCell(); + FocusManager.instance.primaryFocus?.unfocus(); + }, + decoration: InputDecoration( + isDense: true, + 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, + 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, + focusedBorder: nameErrorText == null + ? InputBorder.none + : UnderlineInputBorder( + borderSide: BorderSide( + color: colorScheme.error, + width: 2, + ), ), - ), - contentPadding: EdgeInsets.all(3.0), + contentPadding: EdgeInsets.all(3.0), + ), + style: textTheme.bodyLarge, + focusNode: _effectiveFocusNode, ), - style: textTheme.bodyLarge, - focusNode: _effectiveFocusNode, ), ), ), 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 12761141..487ba530 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 @@ -74,7 +74,12 @@ class PreparationFormReorderableList extends StatelessWidget { onNameChanged(index, value); }, onNameFocusLost: (value) { - onNameChanged(index, value); + context.read().add( + PreparationFormPreparationStepNameFocusLost( + index: index, + preparationStepName: value, + ), + ); }, onPreparationTimeTapped: () { onTimeChanged(index, step.preparationTime.value); 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 index 9625be81..50938bfc 100644 --- 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 @@ -108,6 +108,87 @@ void main() { 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('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, From 52243e6a702d0bd97d88553966dc22d8f58d1bb9 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 13 May 2026 13:49:44 +0900 Subject: [PATCH 8/8] Implement row-level validation for newly added preparation rows --- ios/Podfile.lock | 28 +-- .../bloc/preparation_form_bloc.dart | 74 +++++- .../bloc/preparation_form_event.dart | 14 ++ .../preparation_form_list_field.dart | 65 +++++- .../preparation_form_reorderable_list.dart | 49 +++- .../components/preparation_time_input.dart | 3 + .../bloc/preparation_form_bloc_test.dart | 212 ++++++++++++++++++ 7 files changed, 409 insertions(+), 36 deletions(-) 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/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 9f6f1f26..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 @@ -29,6 +29,9 @@ class PreparationFormBloc on( _onPreparationFormPreparationStepNameFocusLost, ); + on( + _onPreparationFormPreparationStepInteractionEnded, + ); on( _onPreparationFormPreparationStepTimeChanged, ); @@ -122,10 +125,15 @@ class PreparationFormBloc PreparationFormPreparationStepNameChanged event, Emitter emit, ) { + final step = state.preparationStepList.elementAtOrNull(event.index); + if (step == null) { + return; + } + final changedList = List.from( state.preparationStepList, ); - changedList[event.index] = changedList[event.index].copyWith( + changedList[event.index] = step.copyWith( preparationName: PreparationNameInputModel.dirty( event.preparationStepName, ), @@ -182,14 +190,69 @@ class PreparationFormBloc ); } + 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); + 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 step = state.preparationStepList.elementAtOrNull(event.index); + if (step == null) { + return; + } + final changedList = List.from( state.preparationStepList, ); - changedList[event.index] = changedList[event.index].copyWith( + changedList[event.index] = step.copyWith( preparationTime: PreparationTimeInputModel.dirty( event.preparationStepTime, ), @@ -261,6 +324,13 @@ class PreparationFormBloc ); 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; } 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 b5669585..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 @@ -64,6 +64,20 @@ final class PreparationFormPreparationStepNameFocusLost 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]; +} + final class PreparationFormPreparationStepTimeChanged extends PreparationFormEvent { final int index; 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 03d6aab4..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 @@ -20,12 +20,14 @@ class PreparationFormListField extends StatefulWidget { 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; @@ -34,12 +36,14 @@ class PreparationFormListField extends StatefulWidget { 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() => @@ -48,9 +52,11 @@ class PreparationFormListField extends StatefulWidget { class _PreparationFormListFieldState extends State { late final FocusNode _internalFocusNode; - final SwipeActionController _swipeActionController = SwipeActionController(); + final SwipeActionController _internalSwipeActionController = + SwipeActionController(); late String _nameValue; bool _hasRequestedInitialFocus = false; + bool _isTimePickerOpen = false; final dragIndicatorSvg = SvgPicture.asset( 'drag_indicator.svg', package: 'assets', @@ -63,7 +69,7 @@ class _PreparationFormListFieldState extends State { @override void dispose() { _effectiveFocusNode.removeListener(_handleFocusChanged); - _swipeActionController.dispose(); + _internalSwipeActionController.dispose(); _internalFocusNode.dispose(); super.dispose(); } @@ -80,6 +86,12 @@ class _PreparationFormListFieldState extends State { @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; } @@ -91,6 +103,9 @@ class _PreparationFormListFieldState extends State { FocusNode get _effectiveFocusNode => widget.focusNode ?? _internalFocusNode; + SwipeActionController get _swipeActionController => + widget.swipeActionController ?? _internalSwipeActionController; + void _requestInitialFocusIfNeeded() { if (!widget.isAdding || _hasRequestedInitialFocus) { return; @@ -104,11 +119,44 @@ class _PreparationFormListFieldState extends State { } void _handleFocusChanged() { + _swipeActionController.closeAllOpenCell(); if (!_effectiveFocusNode.hasFocus) { - _swipeActionController.closeAllOpenCell(); - widget.onNameFocusLost?.call(_nameValue); - widget.onNameSaved?.call(); + _handleRowInteractionEnded(); + } + } + + void _handleRowInteractionEnded([String? nameValue]) { + final value = nameValue ?? _nameValue; + if (widget.isAdding && _isTimePickerOpen) { + return; + } + if (widget.isAdding) { + widget.onInteractionEnded?.call(value); + return; + } + 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) { @@ -168,7 +216,8 @@ class _PreparationFormListFieldState extends State { trailing: PreparationTimeInput( time: widget.preparationStep.preparationTime.value, hasError: timeErrorText != null, - onTap: widget.onPreparationTimeTapped, + onTap: _handlePreparationTimeTapped, + onDisposed: _handlePreparationTimePickerDisposed, onPreparationTimeChanged: widget.onPreparationTimeChanged, ), child: Expanded( @@ -188,9 +237,9 @@ class _PreparationFormListFieldState extends State { }, onFieldSubmitted: (value) { _nameValue = value; - widget.onNameFocusLost?.call(value); - widget.onNameSaved?.call(); + _handleRowInteractionEnded(value); }, + onTap: _swipeActionController.closeAllOpenCell, onTapOutside: (event) { _swipeActionController.closeAllOpenCell(); FocusManager.instance.primaryFocus?.unfocus(); 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 487ba530..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,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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 PreparationFormReorderableList extends StatelessWidget { +class PreparationFormReorderableList extends StatefulWidget { const PreparationFormReorderableList({ super.key, required this.preparationStepList, @@ -26,6 +27,21 @@ class PreparationFormReorderableList extends StatelessWidget { 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( @@ -49,19 +65,20 @@ 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 step = widget.preparationStepList[index]; return PreparationFormListField( key: - stepKeyFor?.call(step.id) ?? + widget.stepKeyFor?.call(step.id) ?? ValueKey('field_${step.id}'), index: index, - isAdding: step.id == addingStepId, - canRemove: preparationStepList.length > 1, - showValidationErrors: showValidationErrors, - focusNode: nameFocusNodeFor?.call(step.id), + 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( @@ -71,7 +88,7 @@ class PreparationFormReorderableList extends StatelessWidget { ); }, onNameChanged: (value) { - onNameChanged(index, value); + widget.onNameChanged(index, value); }, onNameFocusLost: (value) { context.read().add( @@ -81,16 +98,24 @@ class PreparationFormReorderableList extends StatelessWidget { ), ); }, + onInteractionEnded: (value) { + context.read().add( + PreparationFormPreparationStepInteractionEnded( + index: index, + preparationStepName: value, + ), + ); + }, onPreparationTimeTapped: () { - onTimeChanged(index, step.preparationTime.value); + widget.onTimeChanged(index, step.preparationTime.value); }, onPreparationTimeChanged: (value) { - onTimeChanged(index, 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 3775be0f..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 @@ -8,12 +8,14 @@ class PreparationTimeInput extends StatelessWidget { 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 @@ -51,6 +53,7 @@ class PreparationTimeInput extends StatelessWidget { onSaved: (value) { onPreparationTimeChanged?.call(value); }, + onDisposed: onDisposed, ); }, ), 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 index 50938bfc..1148cbf9 100644 --- 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 @@ -143,6 +143,218 @@ void main() { 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,