diff --git a/lib/app.dart b/lib/app.dart index 96d79449c..247012c34 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,16 +1,19 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/material.dart'; + +import 'package:dynamic_system_colors/dynamic_system_colors.dart'; +import 'package:i18n_extension/i18n_extension.dart'; +import 'package:in_app_update/in_app_update.dart'; +import 'package:provider/provider.dart'; + import 'package:dpip/core/notify.dart'; import 'package:dpip/core/providers.dart'; import 'package:dpip/models/settings/ui.dart'; import 'package:dpip/router.dart'; +import 'package:dpip/utils/constants.dart'; import 'package:dpip/utils/log.dart'; -import 'package:dynamic_system_colors/dynamic_system_colors.dart'; -import 'package:flutter/material.dart'; -import 'package:i18n_extension/i18n_extension.dart'; -import 'package:in_app_update/in_app_update.dart'; -import 'package:provider/provider.dart'; class DpipApp extends StatefulWidget { const DpipApp({super.key}); @@ -86,12 +89,14 @@ class _DpipAppState extends State with WidgetsBindingObserver { colorScheme: model.themeColor == null ? lightDynamic : null, brightness: Brightness.light, snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + pageTransitionsTheme: kZoomPageTransitionsTheme, ); final darkTheme = ThemeData( colorSchemeSeed: model.themeColor, colorScheme: model.themeColor == null ? darkDynamic : null, brightness: Brightness.dark, snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + pageTransitionsTheme: kZoomPageTransitionsTheme, ); return MaterialApp.router( diff --git a/lib/router.dart b/lib/router.dart index 197c549c9..cb2aea8e0 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -38,8 +38,10 @@ import 'package:dpip/app/welcome/layout.dart'; import 'package:dpip/core/i18n.dart'; import 'package:dpip/core/preference.dart'; import 'package:dpip/route/announcement/announcement.dart'; +import 'package:dpip/utils/constants.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/log.dart'; -import 'package:dpip/widgets/transitions/forward_back.dart'; +import 'package:dpip/widgets/shell_wrapper.dart'; final GlobalKey _rootNavigatorKey = GlobalKey(); final GlobalKey _welcomeNavigatorKey = GlobalKey(); @@ -51,45 +53,28 @@ final router = GoRouter( routes: [ ShellRoute( navigatorKey: _welcomeNavigatorKey, - builder: (context, state, child) => WelcomeLayout(child: child), + builder: (context, state, child) => ShellWrapper(WelcomeLayout(child: child)), routes: [ - GoRoute( - path: WelcomeAboutPage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const WelcomeAboutPage()), - ), + GoRoute(path: WelcomeAboutPage.route, builder: (context, state) => const Material(child: WelcomeAboutPage())), GoRoute( path: WelcomeExpTechPage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const WelcomeExpTechPage()), - ), - GoRoute( - path: WelcomeNoticePage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const WelcomeNoticePage()), + builder: (context, state) => const Material(child: WelcomeExpTechPage()), ), + GoRoute(path: WelcomeNoticePage.route, builder: (context, state) => const Material(child: WelcomeNoticePage())), GoRoute( path: WelcomePermissionPage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const WelcomePermissionPage()), + builder: (context, state) => const Material(child: WelcomePermissionPage()), ), ], ), - GoRoute( - path: HomePage.route, - pageBuilder: (context, state) => const NoTransitionPage( - child: AppLayout( - child: HomePage(), - ), - ), - ), + GoRoute(path: HomePage.route, builder: (context, state) => const AppLayout(child: HomePage())), ShellRoute( navigatorKey: _settingsNavigatorKey, builder: (context, state, child) { final title = switch (state.fullPath) { SettingsLocationPage.route => '所在地'.i18n, - SettingsLocationSelectPage.route => '所在地'.i18n, - final p when p == SettingsLocationSelectCityPage.route() => '所在地'.i18n, + SettingsLocationSelectPage.route => '新增地點'.i18n, + final p when p == SettingsLocationSelectCityPage.route() => '新增地點'.i18n, SettingsThemePage.route => '主題'.i18n, SettingsThemeSelectPage.route => '主題'.i18n, SettingsLocalePage.route => '語言'.i18n, @@ -111,129 +96,92 @@ final router = GoRouter( SettingsDonatePage.route => '贊助我們'.i18n, _ => '設定'.i18n, }; - return SettingsLayout(title: title, child: child); + + return ShellWrapper( + SettingsLayout( + title: title, + child: Theme( + data: context.theme.copyWith(pageTransitionsTheme: kFadeForwardPageTransitionsTheme), + child: child, + ), + ), + ); }, routes: [ - GoRoute( - path: SettingsIndexPage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const SettingsIndexPage()), - ), + GoRoute(path: SettingsIndexPage.route, builder: (context, state) => const Material(child: SettingsIndexPage())), GoRoute( path: SettingsLocationPage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const SettingsLocationPage()), + builder: (context, state) => const Material(child: SettingsLocationPage()), ), GoRoute( path: SettingsLocationSelectPage.route, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsLocationSelectPage()), + builder: (context, state) => const Material(child: SettingsLocationSelectPage()), ), GoRoute( path: SettingsLocationSelectCityPage.route(), - pageBuilder: - (context, state) => ForwardBackTransitionPage( - key: state.pageKey, - child: SettingsLocationSelectCityPage(city: state.pathParameters['city']!), - ), - ), - GoRoute( - path: SettingsThemePage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const SettingsThemePage()), + builder: + (context, state) => Material(child: SettingsLocationSelectCityPage(city: state.pathParameters['city']!)), ), + GoRoute(path: SettingsThemePage.route, builder: (context, state) => const Material(child: SettingsThemePage())), GoRoute( path: SettingsThemeSelectPage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const SettingsThemeSelectPage()), + builder: (context, state) => const Material(child: SettingsThemeSelectPage()), ), GoRoute( path: SettingsLocalePage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const SettingsLocalePage()), + builder: (context, state) => const Material(child: SettingsLocalePage()), ), GoRoute( path: SettingsLocaleSelectPage.route, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsLocaleSelectPage()), - ), - GoRoute( - path: SettingsUnitPage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const SettingsUnitPage()), - ), - GoRoute( - path: SettingsMapPage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const SettingsMapPage()), + builder: (context, state) => const Material(child: SettingsLocaleSelectPage()), ), + GoRoute(path: SettingsUnitPage.route, builder: (context, state) => const Material(child: SettingsUnitPage())), + GoRoute(path: SettingsMapPage.route, builder: (context, state) => const Material(child: SettingsMapPage())), GoRoute( path: SettingsNotifyPage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const SettingsNotifyPage()), + builder: (context, state) => const Material(child: SettingsNotifyPage()), routes: [ GoRoute( path: SettingsNotifyEewPage.name, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsNotifyEewPage()), + builder: (context, state) => const Material(child: SettingsNotifyEewPage()), ), GoRoute( path: SettingsNotifyMonitorPage.name, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsNotifyMonitorPage()), + builder: (context, state) => const Material(child: SettingsNotifyMonitorPage()), ), GoRoute( path: SettingsNotifyReportPage.name, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsNotifyReportPage()), + builder: (context, state) => const Material(child: SettingsNotifyReportPage()), ), GoRoute( path: SettingsNotifyIntensityPage.name, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsNotifyIntensityPage()), + builder: (context, state) => const Material(child: SettingsNotifyIntensityPage()), ), GoRoute( path: SettingsNotifyThunderstormPage.name, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsNotifyThunderstormPage()), + builder: (context, state) => const Material(child: SettingsNotifyThunderstormPage()), ), GoRoute( path: SettingsNotifyAdvisoryPage.name, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsNotifyAdvisoryPage()), + builder: (context, state) => const Material(child: SettingsNotifyAdvisoryPage()), ), GoRoute( path: SettingsNotifyEvacuationPage.name, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsNotifyEvacuationPage()), + builder: (context, state) => const Material(child: SettingsNotifyEvacuationPage()), ), GoRoute( path: SettingsNotifyTsunamiPage.name, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsNotifyTsunamiPage()), + builder: (context, state) => const Material(child: SettingsNotifyTsunamiPage()), ), GoRoute( path: SettingsNotifyAnnouncementPage.name, - pageBuilder: - (context, state) => - ForwardBackTransitionPage(key: state.pageKey, child: const SettingsNotifyAnnouncementPage()), + builder: (context, state) => const Material(child: SettingsNotifyAnnouncementPage()), ), ], ), GoRoute( path: SettingsDonatePage.route, - pageBuilder: - (context, state) => ForwardBackTransitionPage(key: state.pageKey, child: const SettingsDonatePage()), + builder: (context, state) => const Material(child: SettingsDonatePage()), ), ], ), diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 71c3ef33e..ba328d90e 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -1,6 +1,23 @@ import 'package:flutter/material.dart'; + import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:dpip/widgets/transitions/predictive_fade_forward.dart'; + +const kZoomPageTransitionsTheme = PageTransitionsTheme( + builders: { + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), + }, +); + +const kFadeForwardPageTransitionsTheme = PageTransitionsTheme( + builders: { + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.android: PredictiveBackFadeForwardPageTransitionsBuilder(), + }, +); + const kEmphasizedAnimationStyle = AnimationStyle( curve: Easing.emphasizedDecelerate, duration: Durations.medium4, diff --git a/lib/widgets/shell_wrapper.dart b/lib/widgets/shell_wrapper.dart new file mode 100644 index 000000000..c55f2d5f9 --- /dev/null +++ b/lib/widgets/shell_wrapper.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +class ShellWrapper extends StatelessWidget { + final Widget child; + + const ShellWrapper(this.child, {super.key}); + + bool _canPop(BuildContext context) { + final lastMatch = GoRouter.of(context).routerDelegate.currentConfiguration.matches.lastOrNull; + + if (lastMatch is ShellRouteMatch) { + return lastMatch.matches.length == 1; + } + + return true; + } + + @override + Widget build(BuildContext context) => PopScope(canPop: _canPop(context), child: child); +} diff --git a/lib/widgets/transitions/forward_back.dart b/lib/widgets/transitions/forward_back.dart deleted file mode 100644 index 2e1b30969..000000000 --- a/lib/widgets/transitions/forward_back.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -const kBackTransition = Interval(0, 0.5, curve: Easing.emphasizedAccelerate); -const kForwardTransition = Interval(0.5, 1, curve: Easing.emphasizedDecelerate); - -class ForwardBackTransitionPage extends CustomTransitionPage { - ForwardBackTransitionPage({required super.child, super.key}) - : super( - transitionDuration: Durations.long4, - reverseTransitionDuration: Durations.long4, - transitionsBuilder: (context, animation, secondaryAnimation, child) { - final forwardSlide = Tween( - begin: const Offset(0.2, 0.0), - end: Offset.zero, - ).chain(CurveTween(curve: kForwardTransition)); - - final backwardSlide = Tween( - begin: Offset.zero, - end: const Offset(-0.2, 0.0), - ).chain(CurveTween(curve: kBackTransition)); - - final fadeIn = Tween(begin: 0.0, end: 1.0).chain(CurveTween(curve: kForwardTransition)); - final fadeOut = Tween(begin: 1.0, end: 0.0).chain(CurveTween(curve: kBackTransition)); - - return SlideTransition( - position: forwardSlide.animate(animation), - child: SlideTransition( - position: backwardSlide.animate(secondaryAnimation), - child: FadeTransition( - opacity: fadeIn.animate(animation), - child: FadeTransition(opacity: fadeOut.animate(secondaryAnimation), child: Material(child: child)), - ), - ), - ); - }, - ); -} diff --git a/lib/widgets/transitions/predictive_fade_forward.dart b/lib/widgets/transitions/predictive_fade_forward.dart new file mode 100644 index 000000000..2a5cd11e5 --- /dev/null +++ b/lib/widgets/transitions/predictive_fade_forward.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class PredictiveBackFadeForwardPageTransitionsBuilder extends PageTransitionsBuilder { + /// Creates an instance of a [PageTransitionsBuilder] that matches Android U's + /// predictive back transition. + const PredictiveBackFadeForwardPageTransitionsBuilder(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return _PredictiveBackGestureDetector( + route: route, + builder: (BuildContext context) { + // Only do a predictive back transition when the user is performing a + // pop gesture. Otherwise, for things like button presses or other + // programmatic navigation, fall back to FadeForwardsPageTransitionsBuilder. + if (route.popGestureInProgress) { + return _PredictiveBackPageTransition( + animation: animation, + secondaryAnimation: secondaryAnimation, + getIsCurrent: () => route.isCurrent, + child: child, + ); + } + + return const FadeForwardsPageTransitionsBuilder().buildTransitions( + route, + context, + animation, + secondaryAnimation, + child, + ); + }, + ); + } +} + +class _PredictiveBackGestureDetector extends StatefulWidget { + const _PredictiveBackGestureDetector({required this.route, required this.builder}); + + final WidgetBuilder builder; + final PredictiveBackRoute route; + + @override + State<_PredictiveBackGestureDetector> createState() => _PredictiveBackGestureDetectorState(); +} + +class _PredictiveBackGestureDetectorState extends State<_PredictiveBackGestureDetector> with WidgetsBindingObserver { + /// True when the predictive back gesture is enabled. + bool get _isEnabled { + return widget.route.isCurrent && widget.route.popGestureEnabled; + } + + /// The back event when the gesture first started. + PredictiveBackEvent? get startBackEvent => _startBackEvent; + PredictiveBackEvent? _startBackEvent; + set startBackEvent(PredictiveBackEvent? startBackEvent) { + if (_startBackEvent != startBackEvent && mounted) { + setState(() { + _startBackEvent = startBackEvent; + }); + } + } + + /// The most recent back event during the gesture. + PredictiveBackEvent? get currentBackEvent => _currentBackEvent; + PredictiveBackEvent? _currentBackEvent; + set currentBackEvent(PredictiveBackEvent? currentBackEvent) { + if (_currentBackEvent != currentBackEvent && mounted) { + setState(() { + _currentBackEvent = currentBackEvent; + }); + } + } + + // Begin WidgetsBindingObserver. + + @override + bool handleStartBackGesture(PredictiveBackEvent backEvent) { + final bool gestureInProgress = !backEvent.isButtonEvent && _isEnabled; + if (!gestureInProgress) { + return false; + } + + widget.route.handleStartBackGesture(progress: 1 - backEvent.progress); + startBackEvent = currentBackEvent = backEvent; + return true; + } + + @override + void handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) { + widget.route.handleUpdateBackGestureProgress(progress: 1 - backEvent.progress); + currentBackEvent = backEvent; + } + + @override + void handleCancelBackGesture() { + widget.route.handleCancelBackGesture(); + startBackEvent = currentBackEvent = null; + } + + @override + void handleCommitBackGesture() { + widget.route.handleCommitBackGesture(); + startBackEvent = currentBackEvent = null; + } + + // End WidgetsBindingObserver. + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context); + } +} + +/// Android's predictive back page transition. +class _PredictiveBackPageTransition extends StatelessWidget { + const _PredictiveBackPageTransition({ + required this.animation, + required this.secondaryAnimation, + required this.getIsCurrent, + required this.child, + }); + + // These values were eyeballed to match the native predictive back animation + // on a Pixel 2 running Android API 34. + static const double _scaleFullyOpened = 1.0; + static const double _scaleStartTransition = 0.95; + static const double _opacityFullyOpened = 1.0; + static const double _opacityStartTransition = 0.95; + static const double _weightForStartState = 65.0; + static const double _weightForEndState = 35.0; + static const double _screenWidthDivisionFactor = 20.0; + static const double _xShiftAdjustment = 8.0; + + final Animation animation; + final Animation secondaryAnimation; + final ValueGetter getIsCurrent; + final Widget child; + + Widget _secondaryAnimatedBuilder(BuildContext context, Widget? child) { + final double screenWidth = MediaQuery.widthOf(context); + final double xShift = (screenWidth / _screenWidthDivisionFactor) - _xShiftAdjustment; + + final bool isCurrent = getIsCurrent(); + final Tween xShiftTween = isCurrent ? ConstantTween(0) : Tween(begin: xShift, end: 0); + final Animatable scaleTween = + isCurrent + ? ConstantTween(_scaleFullyOpened) + : TweenSequence(>[ + TweenSequenceItem( + tween: Tween(begin: _scaleStartTransition, end: _scaleFullyOpened), + weight: _weightForStartState, + ), + TweenSequenceItem( + tween: Tween(begin: _scaleFullyOpened, end: _scaleFullyOpened), + weight: _weightForEndState, + ), + ]); + final Animatable fadeTween = + isCurrent + ? ConstantTween(_opacityFullyOpened) + : TweenSequence(>[ + TweenSequenceItem( + tween: Tween(begin: _opacityFullyOpened, end: _opacityStartTransition), + weight: _weightForStartState, + ), + TweenSequenceItem( + tween: Tween(begin: _opacityFullyOpened, end: _opacityFullyOpened), + weight: _weightForEndState, + ), + ]); + + return Transform.translate( + offset: Offset(xShiftTween.animate(secondaryAnimation).value, 0), + child: Transform.scale( + scale: scaleTween.animate(secondaryAnimation).value, + child: Opacity(opacity: fadeTween.animate(secondaryAnimation).value, child: child), + ), + ); + } + + Widget _primaryAnimatedBuilder(BuildContext context, Widget? child) { + final double screenWidth = MediaQuery.widthOf(context); + final double xShift = (screenWidth / _screenWidthDivisionFactor) - _xShiftAdjustment; + + final Animatable xShiftTween = TweenSequence(>[ + TweenSequenceItem(tween: Tween(begin: 0.0, end: 0.0), weight: _weightForStartState), + TweenSequenceItem(tween: Tween(begin: xShift, end: 0.0), weight: _weightForEndState), + ]); + final Animatable scaleTween = TweenSequence(>[ + TweenSequenceItem( + tween: Tween(begin: _scaleFullyOpened, end: _scaleFullyOpened), + weight: _weightForStartState, + ), + TweenSequenceItem( + tween: Tween(begin: _scaleStartTransition, end: _scaleFullyOpened), + weight: _weightForEndState, + ), + ]); + final Animatable fadeTween = TweenSequence(>[ + TweenSequenceItem(tween: Tween(begin: 0.0, end: 0.0), weight: _weightForStartState), + TweenSequenceItem( + tween: Tween(begin: _opacityStartTransition, end: _opacityFullyOpened), + weight: _weightForEndState, + ), + ]); + + return Transform.translate( + offset: Offset(xShiftTween.animate(animation).value, 0), + child: Transform.scale( + scale: scaleTween.animate(animation).value, + child: Opacity(opacity: fadeTween.animate(animation).value, child: child), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: secondaryAnimation, + builder: _secondaryAnimatedBuilder, + child: AnimatedBuilder(animation: animation, builder: _primaryAnimatedBuilder, child: child), + ); + } +}