From ae5cf633750383b448e4a5b7df8a64842c41749d Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Mon, 11 May 2026 19:34:39 +0900 Subject: [PATCH] fix: smooth default preparation edit transition --- .../preparation_spare_time_edit_screen.dart | 240 ++++++++++++------ ...eparation_spare_time_edit_screen_test.dart | 156 ++++++++++++ 2 files changed, 312 insertions(+), 84 deletions(-) create mode 100644 test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart 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 0bb63d8d..8f2d45d5 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 @@ -14,14 +14,21 @@ class PreparationSpareTimeEditScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) { - final spareTime = - context.read().state.user.spareTimeOrNull ?? - Duration.zero; - return getIt.get() - ..add(FormEditRequested(spareTime: spareTime)); - }, + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) { + final spareTime = + context.read().state.user.spareTimeOrNull ?? + Duration.zero; + return getIt.get() + ..add(FormEditRequested(spareTime: spareTime)); + }, + ), + BlocProvider( + create: (context) => getIt.get(), + ), + ], child: const _PreparationSpareTimeEditView(), ); } @@ -32,95 +39,153 @@ class _PreparationSpareTimeEditView extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder< + return BlocListener< DefaultPreparationSpareTimeFormBloc, DefaultPreparationSpareTimeFormState >( - builder: (context, state) { - if (state.status == DefaultPreparationSpareTimeStatus.success) { - return BlocProvider( - create: (context) => getIt.get() - ..add( - PreparationFormEditRequested( - preparationEntity: state.preparation!, - ), - ), - child: BlocBuilder( + listenWhen: (previous, current) => + current.status == DefaultPreparationSpareTimeStatus.success && + current.preparation != null && + previous.preparation != current.preparation, + listener: (context, state) { + context.read().add( + PreparationFormEditRequested(preparationEntity: state.preparation!), + ); + }, + child: Scaffold( + appBar: AppBar( + elevation: 0, + shadowColor: Colors.transparent, + scrolledUnderElevation: 0, + backgroundColor: Colors.transparent, + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios_rounded, + color: Theme.of(context).colorScheme.outlineVariant, + ), + onPressed: () => context.pop(), + ), + title: Text( + AppLocalizations.of(context)!.editDefaultPreparation, + style: Theme.of(context).textTheme.titleLarge, + ), + actions: [ + BlocBuilder< + DefaultPreparationSpareTimeFormBloc, + DefaultPreparationSpareTimeFormState + >( + buildWhen: (previous, current) => + previous.status != current.status || + previous.spareTime != current.spareTime, builder: (context, state2) { - return Scaffold( - appBar: AppBar( - elevation: 0, - shadowColor: Colors.transparent, - scrolledUnderElevation: 0, - backgroundColor: Colors.transparent, - leading: IconButton( - icon: Icon( - Icons.arrow_back_ios_rounded, - color: Theme.of(context).colorScheme.outlineVariant, - ), - onPressed: () => context.pop(), - ), - title: Text( - AppLocalizations.of(context)!.editDefaultPreparation, - style: Theme.of(context).textTheme.titleLarge, - ), - actions: [ - TextButton( - onPressed: state2.isValid - ? () { - context - .read() - .add( - FormSubmitted( - note: '', - preparation: state2 - .toPreparationEntity(), - ), - ); - context.pop(); - } - : null, - child: Text(AppLocalizations.of(context)!.ok), - ), - ], - bottom: const PreferredSize( - preferredSize: Size.fromHeight(33), - child: SizedBox(height: 33), - ), - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - children: [ - SizedBox( - width: double.infinity, - child: _SpareTimeSection( - spareTime: state.spareTime!, - ), - ), - SizedBox(height: 42.0), - Expanded( - child: _PreparationSection( - preparationNameState: state2, - ), - ), - ], - ), - ), - ), + return BlocBuilder( + buildWhen: (previous, current) => + previous.isValid != current.isValid, + builder: (context, preparationState) { + return TextButton( + onPressed: + state2.isReadyForEditing && preparationState.isValid + ? () { + context + .read() + .add( + FormSubmitted( + note: '', + preparation: preparationState + .toPreparationEntity(), + ), + ); + context.pop(); + } + : null, + child: Text(AppLocalizations.of(context)!.ok), + ); + }, ); }, ), - ); - } + ], + bottom: const PreferredSize( + preferredSize: Size.fromHeight(33), + child: SizedBox(height: 33), + ), + ), + body: const SafeArea(child: _PreparationSpareTimeEditBody()), + ), + ); + } +} - return const Center(child: CircularProgressIndicator()); +class _PreparationSpareTimeEditBody extends StatelessWidget { + const _PreparationSpareTimeEditBody(); + + @override + Widget build(BuildContext context) { + return BlocBuilder< + DefaultPreparationSpareTimeFormBloc, + DefaultPreparationSpareTimeFormState + >( + buildWhen: (previous, current) => + previous.status != current.status || + previous.spareTime != current.spareTime || + previous.preparation != current.preparation, + builder: (context, state) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: state.isReadyForEditing + ? _PreparationSpareTimeEditContent( + key: const ValueKey('preparation_spare_time_form'), + spareTime: state.spareTime!, + ) + : const _PreparationSpareTimeEditLoading( + key: ValueKey('preparation_spare_time_loading'), + ), + ); }, ); } } +class _PreparationSpareTimeEditLoading extends StatelessWidget { + const _PreparationSpareTimeEditLoading({super.key}); + + @override + Widget build(BuildContext context) { + return const Center(child: CircularProgressIndicator()); + } +} + +class _PreparationSpareTimeEditContent extends StatelessWidget { + const _PreparationSpareTimeEditContent({super.key, required this.spareTime}); + + final Duration spareTime; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: _SpareTimeSection(spareTime: spareTime), + ), + SizedBox(height: 42.0), + Expanded( + child: BlocBuilder( + builder: (context, state) { + return _PreparationSection(preparationNameState: state); + }, + ), + ), + ], + ), + ); + } +} + class _SpareTimeSection extends StatelessWidget { const _SpareTimeSection({required this.spareTime}); @@ -159,6 +224,13 @@ class _SpareTimeSection extends StatelessWidget { } } +extension on DefaultPreparationSpareTimeFormState { + bool get isReadyForEditing => + status == DefaultPreparationSpareTimeStatus.success && + spareTime != null && + preparation != null; +} + class _PreparationSection extends StatelessWidget { const _PreparationSection({required this.preparationNameState}); diff --git a/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart b/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart new file mode 100644 index 00000000..4d0734ab --- /dev/null +++ b/test/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:on_time_front/core/di/di_setup.dart'; +import 'package:on_time_front/domain/entities/preparation_entity.dart'; +import 'package:on_time_front/domain/entities/preparation_step_entity.dart'; +import 'package:on_time_front/domain/entities/user_entity.dart'; +import 'package:on_time_front/domain/use-cases/get_default_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/load_user_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_default_preparation_use_case.dart'; +import 'package:on_time_front/domain/use-cases/update_spare_time_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; +import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/bloc/default_preparation_spare_time_form_bloc.dart'; +import 'package:on_time_front/presentation/my_page/preparation_spare_time_edit/preparation_spare_time_edit_screen.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/shared/theme/theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakePreparationStore preparationStore; + + setUp(() async { + await getIt.reset(); + preparationStore = _FakePreparationStore( + const PreparationEntity( + preparationStepList: [ + PreparationStepEntity( + id: 'step-1', + preparationName: 'Shower', + preparationTime: Duration(minutes: 5), + ), + ], + ), + ); + + getIt + ..registerFactory( + () => DefaultPreparationSpareTimeFormBloc( + _FakeGetDefaultPreparationUseCase(preparationStore), + _FakeUpdateDefaultPreparationUseCase(preparationStore), + _FakeUpdateSpareTimeUseCase(preparationStore), + _FakeLoadUserUseCase(), + ), + ) + ..registerFactory(PreparationFormBloc.new); + }); + + tearDown(() async { + await getIt.reset(); + }); + + testWidgets('keeps preparation edits mounted when spare time changes', ( + tester, + ) async { + await _pumpScreen(tester); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + await tester.enterText(find.byType(TextFormField).first, 'Coffee'); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.add).first); + await tester.pump(); + + expect(find.text('Coffee'), findsOneWidget); + expect(find.text('15분'), findsOneWidget); + }); +} + +Future _pumpScreen(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: const Locale('ko'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BlocProvider.value( + value: _StubAuthBloc(), + child: const PreparationSpareTimeEditScreen(), + ), + ), + ); +} + +class _FakePreparationStore { + _FakePreparationStore(this.defaultPreparation); + + PreparationEntity defaultPreparation; + PreparationEntity? updatedPreparation; + Duration? updatedSpareTime; + int loadUserCount = 0; +} + +class _FakeGetDefaultPreparationUseCase extends Mock + implements GetDefaultPreparationUseCase { + _FakeGetDefaultPreparationUseCase(this.store); + + final _FakePreparationStore store; + + @override + Future call() async => store.defaultPreparation; +} + +class _FakeUpdateDefaultPreparationUseCase extends Mock + implements UpdateDefaultPreparationUseCase { + _FakeUpdateDefaultPreparationUseCase(this.store); + + final _FakePreparationStore store; + + @override + Future call(PreparationEntity preparationEntity) async { + store.updatedPreparation = preparationEntity; + } +} + +class _FakeUpdateSpareTimeUseCase extends Mock + implements UpdateSpareTimeUseCase { + _FakeUpdateSpareTimeUseCase(this.store); + + final _FakePreparationStore store; + + @override + Future call(Duration newSpareTime) async { + store.updatedSpareTime = newSpareTime; + } +} + +class _FakeLoadUserUseCase extends Mock implements LoadUserUseCase { + @override + Future call() async {} +} + +class _StubAuthBloc extends Mock implements AuthBloc { + @override + AuthState get state => AuthState( + user: const UserEntity( + id: 'user-1', + email: 'user@example.com', + name: 'User', + spareTime: Duration(minutes: 10), + note: '', + score: 0, + isOnboardingCompleted: true, + ), + ); + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; +}