From 67584cda04cdd251e8346424e592c730e33bd697 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Sat, 27 Jun 2026 23:28:46 +0900 Subject: [PATCH] feat: normalize route transitions --- .../shared/router/app_route_transition.dart | 154 ++++++++++++++++++ lib/presentation/shared/router/go_router.dart | 146 +++++++++++------ .../router/app_route_transition_test.dart | 110 +++++++++++++ 3 files changed, 357 insertions(+), 53 deletions(-) create mode 100644 lib/presentation/shared/router/app_route_transition.dart create mode 100644 test/presentation/shared/router/app_route_transition_test.dart diff --git a/lib/presentation/shared/router/app_route_transition.dart b/lib/presentation/shared/router/app_route_transition.dart new file mode 100644 index 00000000..ef015e1a --- /dev/null +++ b/lib/presentation/shared/router/app_route_transition.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +enum AppRouteTransition { + fade, + standard, + bottomNavFromLeft, + bottomNavFromRight, + scheduleFlow, +} + +CustomTransitionPage buildAppRoutePage({ + required LocalKey key, + required Widget child, + AppRouteTransition transition = AppRouteTransition.standard, +}) { + final spec = _AppRouteTransitionSpec.fromTransition(transition); + + return CustomTransitionPage( + key: key, + transitionDuration: spec.duration, + reverseTransitionDuration: spec.reverseDuration, + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return _AppRouteTransitionView( + animation: animation, + transition: transition, + child: child, + ); + }, + ); +} + +class _AppRouteTransitionSpec { + const _AppRouteTransitionSpec({ + required this.duration, + required this.reverseDuration, + }); + + final Duration duration; + final Duration reverseDuration; + + static _AppRouteTransitionSpec fromTransition(AppRouteTransition transition) { + switch (transition) { + case AppRouteTransition.fade: + return const _AppRouteTransitionSpec( + duration: Duration(milliseconds: 180), + reverseDuration: Duration(milliseconds: 140), + ); + case AppRouteTransition.standard: + return const _AppRouteTransitionSpec( + duration: Duration(milliseconds: 260), + reverseDuration: Duration(milliseconds: 220), + ); + case AppRouteTransition.bottomNavFromLeft: + case AppRouteTransition.bottomNavFromRight: + return const _AppRouteTransitionSpec( + duration: Duration(milliseconds: 220), + reverseDuration: Duration(milliseconds: 180), + ); + case AppRouteTransition.scheduleFlow: + return const _AppRouteTransitionSpec( + duration: Duration(milliseconds: 200), + reverseDuration: Duration(milliseconds: 160), + ); + } + } +} + +class _AppRouteTransitionView extends StatelessWidget { + const _AppRouteTransitionView({ + required this.animation, + required this.transition, + required this.child, + }); + + final Animation animation; + final AppRouteTransition transition; + final Widget child; + + @override + Widget build(BuildContext context) { + switch (transition) { + case AppRouteTransition.fade: + return FadeTransition( + opacity: _curvedOpacityAnimation(animation, begin: 0), + child: child, + ); + case AppRouteTransition.standard: + return FadeTransition( + opacity: _curvedOpacityAnimation(animation, begin: 0.08), + child: SlideTransition( + position: _curvedOffsetAnimation( + animation, + begin: const Offset(0, 0.032), + ), + child: child, + ), + ); + case AppRouteTransition.bottomNavFromLeft: + return FadeTransition( + opacity: _curvedOpacityAnimation(animation, begin: 0.2), + child: SlideTransition( + position: _curvedOffsetAnimation( + animation, + begin: const Offset(-0.16, 0), + ), + child: child, + ), + ); + case AppRouteTransition.bottomNavFromRight: + return FadeTransition( + opacity: _curvedOpacityAnimation(animation, begin: 0.2), + child: SlideTransition( + position: _curvedOffsetAnimation( + animation, + begin: const Offset(0.16, 0), + ), + child: child, + ), + ); + case AppRouteTransition.scheduleFlow: + return FadeTransition( + opacity: _curvedOpacityAnimation(animation, begin: 0), + child: ScaleTransition( + scale: Tween(begin: 0.985, end: 1).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + ); + } + } + + Animation _curvedOpacityAnimation( + Animation animation, { + required double begin, + }) { + return Tween( + begin: begin, + end: 1, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)); + } + + Animation _curvedOffsetAnimation( + Animation animation, { + required Offset begin, + }) { + return Tween( + begin: begin, + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)); + } +} diff --git a/lib/presentation/shared/router/go_router.dart b/lib/presentation/shared/router/go_router.dart index 93162eba..a654cc86 100644 --- a/lib/presentation/shared/router/go_router.dart +++ b/lib/presentation/shared/router/go_router.dart @@ -25,6 +25,7 @@ import 'package:on_time_front/presentation/schedule_create/schedule_spare_and_pr import 'package:on_time_front/presentation/schedule_create/screens/schedule_create_screen.dart'; import 'package:on_time_front/presentation/schedule_create/screens/schedule_edit_screen.dart'; import 'package:on_time_front/presentation/shared/components/bottom_nav_bar_scaffold.dart'; +import 'package:on_time_front/presentation/shared/router/app_route_transition.dart'; import 'package:on_time_front/presentation/shared/router/route_arguments.dart'; import 'package:on_time_front/presentation/shared/utils/stream_to_listenable.dart'; import 'package:on_time_front/presentation/startup/screens/startup_screen.dart'; @@ -87,25 +88,43 @@ GoRouter goRouterConfig( routes: [ GoRoute( path: '/startup', - builder: (context, state) => const StartupScreen(), + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + transition: AppRouteTransition.fade, + child: const StartupScreen(), + ), ), GoRoute( path: '/allowNotification', - builder: (context, state) { - return NotificationAllowScreen(); - }, + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + transition: AppRouteTransition.fade, + child: NotificationAllowScreen(), + ), ), GoRoute( path: '/allowAlarm', - builder: (context, state) => const AlarmAllowScreen(), + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + transition: AppRouteTransition.fade, + child: const AlarmAllowScreen(), + ), ), GoRoute( path: '/onboarding', - builder: (context, state) => OnboardingScreen(), + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + transition: AppRouteTransition.fade, + child: OnboardingScreen(), + ), routes: [ GoRoute( path: '/start', - builder: (context, state) => OnboardingStartScreen(), + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + transition: AppRouteTransition.fade, + child: OnboardingStartScreen(), + ), ), ], ), @@ -114,17 +133,17 @@ GoRouter goRouterConfig( routes: [ GoRoute( path: '/home', - pageBuilder: (context, state) => _buildBottomNavSlidePage( + pageBuilder: (context, state) => _buildAppRoutePage( state: state, - beginOffset: const Offset(-1, 0), + transition: AppRouteTransition.bottomNavFromLeft, child: HomeScreenTmp(), ), ), GoRoute( path: '/myPage', - pageBuilder: (context, state) => _buildBottomNavSlidePage( + pageBuilder: (context, state) => _buildAppRoutePage( state: state, - beginOffset: const Offset(1, 0), + transition: AppRouteTransition.bottomNavFromRight, child: MyPageScreen(), ), ), @@ -132,40 +151,68 @@ GoRouter goRouterConfig( ), GoRoute( path: '/defaultPreparationSpareTimeEdit', - builder: (context, state) => PreparationSpareTimeEditScreen(), + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + child: PreparationSpareTimeEditScreen(), + ), + ), + GoRoute( + path: '/signIn', + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + transition: AppRouteTransition.fade, + child: SignInMainScreen(), + ), ), - GoRoute(path: '/signIn', builder: (context, state) => SignInMainScreen()), GoRoute( path: '/calendar', - builder: (context, state) => - CalendarScreen(initialDate: calendarInitialDateFromState(state)), + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + child: CalendarScreen( + initialDate: calendarInitialDateFromState(state), + ), + ), ), GoRoute( path: '/scheduleCreate', - builder: (context, state) => ScheduleCreateScreen(), + pageBuilder: (context, state) => + _buildAppRoutePage(state: state, child: ScheduleCreateScreen()), ), GoRoute( path: '/scheduleEdit/:scheduleId', - builder: (context, state) => - ScheduleEditScreen(scheduleId: state.pathParameters['scheduleId']!), + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + child: ScheduleEditScreen( + scheduleId: state.pathParameters['scheduleId']!, + ), + ), ), GoRoute( path: '/preparationEdit', - builder: (context, state) => const PreparationEditForm(), + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + child: const PreparationEditForm(), + ), ), GoRoute( path: '/scheduleStart', name: 'scheduleStart', - builder: (context, state) { + pageBuilder: (context, state) { final extra = scheduleStartRouteExtraFromState(state); - return _ScheduleStartRouteGate(extra: extra); + return _buildAppRoutePage( + state: state, + transition: AppRouteTransition.scheduleFlow, + child: _ScheduleStartRouteGate(extra: extra), + ); }, ), GoRoute( path: '/alarmScreen', - builder: (context, state) { - return AlarmScreen(); - }, + pageBuilder: (context, state) => _buildAppRoutePage( + state: state, + transition: AppRouteTransition.scheduleFlow, + child: AlarmScreen(), + ), ), GoRoute( path: '/earlyLate', @@ -174,51 +221,44 @@ GoRouter goRouterConfig( ? '/home' : null; }, - builder: (context, state) { + pageBuilder: (context, state) { final arguments = earlyLateRouteArgumentsFromState(state); if (arguments == null) { - return const LoadingScreen(); + return _buildAppRoutePage( + state: state, + transition: AppRouteTransition.scheduleFlow, + child: const LoadingScreen(), + ); } - return EarlyLateScreen( - earlyLateTime: arguments.earlyLateTime, - isLate: arguments.isLate, + return _buildAppRoutePage( + state: state, + transition: AppRouteTransition.scheduleFlow, + child: EarlyLateScreen( + earlyLateTime: arguments.earlyLateTime, + isLate: arguments.isLate, + ), ); }, ), - GoRoute(path: '/moving', builder: (context, state) => MovingScreen()), + GoRoute( + path: '/moving', + pageBuilder: (context, state) => + _buildAppRoutePage(state: state, child: MovingScreen()), + ), ], ); } -CustomTransitionPage _buildBottomNavSlidePage({ +CustomTransitionPage _buildAppRoutePage({ required GoRouterState state, - required Offset beginOffset, + AppRouteTransition transition = AppRouteTransition.standard, required Widget child, }) { - final slideTween = Tween( - begin: beginOffset, - end: Offset.zero, - ).chain(CurveTween(curve: Curves.easeOutCubic)); - final secondarySlideTween = Tween( - begin: Offset.zero, - end: beginOffset, - ).chain(CurveTween(curve: Curves.easeOutCubic)); - - return CustomTransitionPage( + return buildAppRoutePage( key: state.pageKey, - transitionDuration: const Duration(milliseconds: 280), - reverseTransitionDuration: const Duration(milliseconds: 280), + transition: transition, child: child, - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return SlideTransition( - position: secondaryAnimation.drive(secondarySlideTween), - child: SlideTransition( - position: animation.drive(slideTween), - child: child, - ), - ); - }, ); } diff --git a/test/presentation/shared/router/app_route_transition_test.dart b/test/presentation/shared/router/app_route_transition_test.dart new file mode 100644 index 00000000..a769f2e1 --- /dev/null +++ b/test/presentation/shared/router/app_route_transition_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/presentation/shared/router/app_route_transition.dart'; + +void main() { + testWidgets('standard app route fades and glides into place', (tester) async { + final page = buildAppRoutePage( + key: const ValueKey('standard-route'), + child: const SizedBox(key: Key('route_child')), + transition: AppRouteTransition.standard, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) => page.transitionsBuilder( + context, + const AlwaysStoppedAnimation(0.5), + const AlwaysStoppedAnimation(0), + page.child, + ), + ), + ), + ); + + final fade = tester.widget(find.byType(FadeTransition)); + final slide = tester.widget(find.byType(SlideTransition)); + + expect(fade.opacity.value, greaterThan(0)); + expect(fade.opacity.value, lessThan(1)); + expect(slide.position.value.dy, greaterThan(0)); + expect(slide.position.value.dy, lessThan(0.04)); + expect(slide.position.value.dx, 0); + }); + + testWidgets('standard app route exits back toward its entry edge', ( + tester, + ) async { + final page = buildAppRoutePage( + key: const ValueKey('standard-route'), + child: const SizedBox(key: Key('route_child')), + transition: AppRouteTransition.standard, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) => page.transitionsBuilder( + context, + const AlwaysStoppedAnimation(0), + const AlwaysStoppedAnimation(0), + page.child, + ), + ), + ), + ); + + final fade = tester.widget(find.byType(FadeTransition)); + final slide = tester.widget(find.byType(SlideTransition)); + + expect(fade.opacity.value, lessThan(0.1)); + expect(slide.position.value.dy, greaterThan(0)); + expect(slide.position.value.dx, 0); + }); + + testWidgets('bottom navigation routes exit back toward their entry edge', ( + tester, + ) async { + Future popOffsetFor(AppRouteTransition transition) async { + final page = buildAppRoutePage( + key: ValueKey('route-$transition'), + child: const SizedBox(key: Key('route_child')), + transition: transition, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) => page.transitionsBuilder( + context, + const AlwaysStoppedAnimation(0), + const AlwaysStoppedAnimation(0), + page.child, + ), + ), + ), + ); + + return tester + .widget(find.byType(SlideTransition)) + .position + .value; + } + + final homePopOffset = await popOffsetFor( + AppRouteTransition.bottomNavFromLeft, + ); + final myPagePopOffset = await popOffsetFor( + AppRouteTransition.bottomNavFromRight, + ); + + expect(homePopOffset.dx, lessThan(0)); + expect(homePopOffset.dy, 0); + expect(myPagePopOffset.dx, greaterThan(0)); + expect(myPagePopOffset.dy, 0); + }); +}