diff --git a/veggieseasons/lib/main.dart b/veggieseasons/lib/main.dart index a54f282d5..07943c438 100644 --- a/veggieseasons/lib/main.dart +++ b/veggieseasons/lib/main.dart @@ -7,13 +7,21 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show DeviceOrientation, SystemChrome; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:veggieseasons/data/app_state.dart'; import 'package:veggieseasons/data/preferences.dart'; import 'package:veggieseasons/screens/home.dart'; import 'package:veggieseasons/styles.dart'; +import 'package:veggieseasons/widgets/fade_transition_page.dart'; import 'package:window_size/window_size.dart'; +import 'screens/details.dart'; +import 'screens/favorites.dart'; +import 'screens/list.dart'; +import 'screens/search.dart'; +import 'screens/settings.dart'; + void main() { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setPreferredOrientations([ @@ -48,6 +56,9 @@ void setupWindow() { } } +final _rootNavigatorKey = GlobalKey(); +final _shellNavigatorKey = GlobalKey(); + class VeggieApp extends StatefulWidget { const VeggieApp({super.key}); @@ -83,14 +94,138 @@ class _VeggieAppState extends State with RestorationMixin { create: (_) => Preferences()..load(), ), ], - child: CupertinoApp( + child: CupertinoApp.router( theme: Styles.veggieThemeData, debugShowCheckedModeBanner: false, - home: const HomeScreen(restorationId: 'home'), restorationScopeId: 'app', + routerConfig: GoRouter( + navigatorKey: _rootNavigatorKey, + restorationScopeId: 'router', + initialLocation: '/list', + redirect: (context, state) { + if (state.path == '/') { + return '/list'; + } + return null; + }, + debugLogDiagnostics: true, + routes: [ + ShellRoute( + navigatorKey: _shellNavigatorKey, + pageBuilder: (context, state, child) { + return CupertinoPage( + restorationId: 'router.shell', + child: HomeScreen( + restorationId: 'home', + child: child, + onTap: (index) { + if (index == 0) { + context.go('/list'); + } else if (index == 1) { + context.go('/favorites'); + } else if (index == 2) { + context.go('/search'); + } else { + context.go('/settings'); + } + }, + ), + ); + }, + routes: [ + GoRoute( + path: '/list', + pageBuilder: (context, state) { + return FadeTransitionPage( + key: state.pageKey, + restorationId: 'route.list', + child: const ListScreen(restorationId: 'list'), + ); + }, + routes: [ + _buildDetailsRoute(), + ], + ), + GoRoute( + path: '/favorites', + pageBuilder: (context, state) { + return FadeTransitionPage( + key: state.pageKey, + restorationId: 'route.favorites', + child: const FavoritesScreen(restorationId: 'favorites'), + ); + }, + routes: [ + _buildDetailsRoute(), + ], + ), + GoRoute( + path: '/search', + pageBuilder: (context, state) { + return FadeTransitionPage( + key: state.pageKey, + restorationId: 'route.search', + child: const SearchScreen(restorationId: 'search'), + ); + }, + routes: [ + _buildDetailsRoute(), + ], + ), + GoRoute( + path: '/settings', + pageBuilder: (context, state) { + return FadeTransitionPage( + key: state.pageKey, + restorationId: 'route.settings', + child: const SettingsScreen(restorationId: 'settings'), + ); + }, + routes: [ + GoRoute( + parentNavigatorKey: _rootNavigatorKey, + path: 'categories', + pageBuilder: (context, state) { + return VeggieCategorySettingsScreen.pageBuilder( + context); + }, + ), + GoRoute( + parentNavigatorKey: _rootNavigatorKey, + path: 'calories', + pageBuilder: (context, state) { + return CalorieSettingsScreen.pageBuilder(context); + }, + ), + ], + ), + ], + ), + ], + ), ), ); } + + // GoRouter does not support relative routes, + // see https://github.com/flutter/flutter/issues/108177 + GoRoute _buildDetailsRoute() { + return GoRoute( + parentNavigatorKey: _rootNavigatorKey, + path: 'details/:id', + pageBuilder: (context, state) { + final veggieId = int.parse(state.params['id']!); + return CupertinoPage( + restorationId: 'route.details', + fullscreenDialog: true, + child: DetailsScreen( + id: veggieId, + restorationId: 'details', + ), + ); + }, + ); + } } class _RestorableAppState extends RestorableListenable { diff --git a/veggieseasons/lib/screens/details.dart b/veggieseasons/lib/screens/details.dart index 8cc96f497..e672fee8c 100644 --- a/veggieseasons/lib/screens/details.dart +++ b/veggieseasons/lib/screens/details.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/cupertino.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:veggieseasons/data/app_state.dart'; import 'package:veggieseasons/data/preferences.dart'; @@ -240,19 +241,6 @@ class DetailsScreen extends StatefulWidget { const DetailsScreen({this.id, this.restorationId, super.key}); - static String show(NavigatorState navigator, int veggieId) { - return navigator.restorablePush(_routeBuilder, arguments: veggieId); - } - - static Route _routeBuilder(BuildContext context, Object? arguments) { - final veggieId = arguments as int?; - return CupertinoPageRoute( - builder: (context) => - DetailsScreen(id: veggieId, restorationId: 'details'), - fullscreenDialog: true, - ); - } - @override State createState() => _DetailsScreenState(); } @@ -295,7 +283,7 @@ class _DetailsScreenState extends State with RestorationMixin { left: 16, child: SafeArea( child: CloseButton(() { - Navigator.of(context).pop(); + context.pop(); }), ), ), diff --git a/veggieseasons/lib/screens/home.dart b/veggieseasons/lib/screens/home.dart index fd6a89f5b..901294bfd 100644 --- a/veggieseasons/lib/screens/home.dart +++ b/veggieseasons/lib/screens/home.dart @@ -3,52 +3,62 @@ // found in the LICENSE file. import 'package:flutter/cupertino.dart'; -import 'package:veggieseasons/screens/favorites.dart'; -import 'package:veggieseasons/screens/list.dart'; -import 'package:veggieseasons/screens/search.dart'; -import 'package:veggieseasons/screens/settings.dart'; +import 'package:go_router/go_router.dart'; class HomeScreen extends StatelessWidget { - const HomeScreen({super.key, this.restorationId}); + const HomeScreen({ + super.key, + this.restorationId, + required this.child, + required this.onTap, + }); final String? restorationId; + final Widget child; + final void Function(int) onTap; @override Widget build(BuildContext context) { + final index = _getSelectedIndex(GoRouter.of(context).location); return RestorationScope( restorationId: restorationId, - child: CupertinoTabScaffold( - restorationId: 'scaffold', - tabBar: CupertinoTabBar(items: const [ - BottomNavigationBarItem( - icon: Icon(CupertinoIcons.home), - label: 'Home', - ), - BottomNavigationBarItem( - icon: Icon(CupertinoIcons.book), - label: 'My Garden', - ), - BottomNavigationBarItem( - icon: Icon(CupertinoIcons.search), - label: 'Search', - ), - BottomNavigationBarItem( - icon: Icon(CupertinoIcons.settings), - label: 'Settings', - ), - ]), - tabBuilder: (context, index) { - if (index == 0) { - return const ListScreen(restorationId: 'list'); - } else if (index == 1) { - return const FavoritesScreen(restorationId: 'favorites'); - } else if (index == 2) { - return const SearchScreen(restorationId: 'search'); - } else { - return const SettingsScreen(restorationId: 'settings'); - } - }, + child: CupertinoPageScaffold( + child: Column( + children: [ + Expanded(child: child), + CupertinoTabBar( + currentIndex: index, + items: const [ + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.home), + label: 'Home', + ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.book), + label: 'My Garden', + ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.search), + label: 'Search', + ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.settings), + label: 'Settings', + ), + ], + onTap: onTap, + ), + ], + ), ), ); } + + int _getSelectedIndex(String location) { + if (location.startsWith('/list')) return 0; + if (location.startsWith('/favorites')) return 1; + if (location.startsWith('/search')) return 2; + if (location.startsWith('/settings')) return 3; + return 0; + } } diff --git a/veggieseasons/lib/screens/settings.dart b/veggieseasons/lib/screens/settings.dart index 53d3ccb38..a095695da 100644 --- a/veggieseasons/lib/screens/settings.dart +++ b/veggieseasons/lib/screens/settings.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/cupertino.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:veggieseasons/data/preferences.dart'; import 'package:veggieseasons/data/veggie.dart'; @@ -15,14 +16,10 @@ class VeggieCategorySettingsScreen extends StatelessWidget { final String? restorationId; - static String show(NavigatorState navigator) { - return navigator.restorablePush(_routeBuilder); - } - - static Route _routeBuilder(BuildContext context, Object? argument) { - return CupertinoPageRoute( - builder: (context) => - const VeggieCategorySettingsScreen(restorationId: 'category'), + static Page pageBuilder(BuildContext context) { + return const CupertinoPage( + restorationId: 'router.categories', + child: VeggieCategorySettingsScreen(restorationId: 'category'), title: 'Preferred Categories', ); } @@ -99,14 +96,10 @@ class CalorieSettingsScreen extends StatelessWidget { static const min = 2600; static const step = 200; - static String show(NavigatorState navigator) { - return navigator.restorablePush(_routeBuilder); - } - - static Route _routeBuilder(BuildContext context, Object? argument) { - return CupertinoPageRoute( - builder: (context) => - const CalorieSettingsScreen(restorationId: 'calorie'), + static Page pageBuilder(BuildContext context) { + return const CupertinoPage( + restorationId: 'router.calorie', + child: CalorieSettingsScreen(restorationId: 'calorie'), title: 'Calorie Target', ); } @@ -198,7 +191,7 @@ class _SettingsScreenState extends State { }, ), onPress: () { - CalorieSettingsScreen.show(Navigator.of(context)); + context.go('/settings/calories'); }, ); } @@ -213,7 +206,7 @@ class _SettingsScreenState extends State { ), content: const SettingsNavigationIndicator(), onPress: () { - VeggieCategorySettingsScreen.show(Navigator.of(context)); + context.go('/settings/categories'); }, ); } @@ -242,13 +235,13 @@ class _SettingsScreenState extends State { onPressed: () async { await prefs.restoreDefaults(); if (!mounted) return; - Navigator.pop(context); + context.pop(); }, ), CupertinoDialogAction( isDefaultAction: true, child: const Text('No'), - onPressed: () => Navigator.pop(context), + onPressed: () => context.pop(), ) ], ), diff --git a/veggieseasons/lib/widgets/fade_transition_page.dart b/veggieseasons/lib/widgets/fade_transition_page.dart new file mode 100644 index 000000000..066ca5d89 --- /dev/null +++ b/veggieseasons/lib/widgets/fade_transition_page.dart @@ -0,0 +1,66 @@ +// Copyright 2021, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; + +class FadeTransitionPage extends Page { + final Widget child; + final Duration duration; + + const FadeTransitionPage({ + super.key, + required this.child, + this.duration = const Duration(milliseconds: 300), + super.restorationId, + }); + + @override + Route createRoute(BuildContext context) => + PageBasedFadeTransitionRoute(this); +} + +class PageBasedFadeTransitionRoute extends PageRoute { + PageBasedFadeTransitionRoute(FadeTransitionPage page) + : super(settings: page); + + FadeTransitionPage get _page => settings as FadeTransitionPage; + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => _page.duration; + + @override + Duration get reverseTransitionDuration => _page.duration; + + @override + bool get maintainState => true; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return _page.child; + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final tween = CurveTween(curve: Curves.easeInOut); + return FadeTransition( + opacity: animation.drive(tween), + child: _page.child, + ); + } +} diff --git a/veggieseasons/lib/widgets/veggie_card.dart b/veggieseasons/lib/widgets/veggie_card.dart index f7b2eb952..4faa7c2cc 100644 --- a/veggieseasons/lib/widgets/veggie_card.dart +++ b/veggieseasons/lib/widgets/veggie_card.dart @@ -5,8 +5,8 @@ import 'dart:ui'; import 'package:flutter/cupertino.dart'; +import 'package:go_router/go_router.dart'; import 'package:veggieseasons/data/veggie.dart'; -import 'package:veggieseasons/screens/details.dart'; import 'package:veggieseasons/styles.dart'; class FrostyBackground extends StatelessWidget { @@ -139,7 +139,12 @@ class VeggieCard extends StatelessWidget { @override Widget build(BuildContext context) { return PressableCard( - onPressed: () => DetailsScreen.show(Navigator.of(context), veggie.id), + onPressed: () { + // GoRouter does not support relative routes, + // so navigate to the absolute route. + // see https://github.com/flutter/flutter/issues/108177 + context.go('/list/details/${veggie.id}'); + }, child: Stack( children: [ Semantics( diff --git a/veggieseasons/lib/widgets/veggie_headline.dart b/veggieseasons/lib/widgets/veggie_headline.dart index d9e05ec50..c0c00c300 100644 --- a/veggieseasons/lib/widgets/veggie_headline.dart +++ b/veggieseasons/lib/widgets/veggie_headline.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. import 'package:flutter/cupertino.dart'; +import 'package:go_router/go_router.dart'; import 'package:veggieseasons/data/veggie.dart'; -import 'package:veggieseasons/screens/details.dart'; import 'package:veggieseasons/styles.dart'; class ZoomClipAssetImage extends StatelessWidget { @@ -72,7 +72,13 @@ class VeggieHeadline extends StatelessWidget { final themeData = CupertinoTheme.of(context); return GestureDetector( - onTap: () => DetailsScreen.show(Navigator.of(context), veggie.id), + onTap: () { + // GoRouter does not support relative routes, + // so navigate to the absolute route, which can be either + // `/favorites/details/${veggie.id}` or `/search/details/${veggie.id}` + // see https://github.com/flutter/flutter/issues/108177 + context.go('${GoRouter.of(context).location}/details/${veggie.id}'); + }, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/veggieseasons/macos/Flutter/GeneratedPluginRegistrant.swift b/veggieseasons/macos/Flutter/GeneratedPluginRegistrant.swift index f71750892..6c0c3d478 100644 --- a/veggieseasons/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/veggieseasons/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,7 +5,7 @@ import FlutterMacOS import Foundation -import shared_preferences_foundation +import shared_preferences_macos import window_size func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { diff --git a/veggieseasons/pubspec.yaml b/veggieseasons/pubspec.yaml index 8a03227f8..6910554df 100644 --- a/veggieseasons/pubspec.yaml +++ b/veggieseasons/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: git: url: https://github.com/google/flutter-desktop-embedding path: plugins/window_size + go_router: ^6.0.0 dev_dependencies: analysis_defaults: