diff --git a/flutter_news_example/api/pubspec.lock b/flutter_news_example/api/pubspec.lock index d1aa092c4..113e1b1e1 100644 --- a/flutter_news_example/api/pubspec.lock +++ b/flutter_news_example/api/pubspec.lock @@ -614,4 +614,4 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" + dart: ">=3.5.0 <4.0.0" diff --git a/flutter_news_example/lib/app/routes/routes.dart b/flutter_news_example/lib/app/routes/routes.dart index ffc1eb365..47924abca 100644 --- a/flutter_news_example/lib/app/routes/routes.dart +++ b/flutter_news_example/lib/app/routes/routes.dart @@ -1,17 +1,73 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_news_example/app/app.dart'; +import 'package:flutter_news_example/article/article.dart'; import 'package:flutter_news_example/home/home.dart'; +import 'package:flutter_news_example/login/login.dart'; +import 'package:flutter_news_example/magic_link_prompt/view/magic_link_prompt_page.dart'; +import 'package:flutter_news_example/network_error/network_error.dart'; +import 'package:flutter_news_example/notification_preferences/notification_preferences.dart'; import 'package:flutter_news_example/onboarding/onboarding.dart'; +import 'package:flutter_news_example/slideshow/slideshow.dart'; +import 'package:flutter_news_example/subscriptions/view/manage_subscription_page.dart'; +import 'package:flutter_news_example/user_profile/user_profile.dart'; +import 'package:go_router/go_router.dart'; -List> onGenerateAppViewPages( - AppStatus state, - List> pages, -) { - switch (state) { - case AppStatus.onboardingRequired: - return [OnboardingPage.page()]; - case AppStatus.unauthenticated: - case AppStatus.authenticated: - return [HomePage.page()]; - } -} +final GoRouter router = GoRouter( + routes: [ + GoRoute( + path: HomePage.routePath, + builder: HomePage.routeBuilder, + routes: [ + GoRoute( + name: NetworkErrorPage.routePath, + path: NetworkErrorPage.routePath, + builder: NetworkErrorPage.routeBuilder, + ), + GoRoute( + name: LoginWithEmailPage.routePath, + path: LoginWithEmailPage.routePath, + builder: LoginWithEmailPage.routeBuilder, + routes: [ + GoRoute( + name: MagicLinkPromptPage.routePath, + path: MagicLinkPromptPage.routePath, + builder: MagicLinkPromptPage.routeBuilder, + ), + ], + ), + GoRoute( + name: ArticlePage.routeName, + path: ArticlePage.routePath, + builder: ArticlePage.routeBuilder, + routes: [ + GoRoute( + name: SlideshowPage.routePath, + path: SlideshowPage.routePath, + builder: SlideshowPage.routeBuilder, + ), + ], + ), + GoRoute( + name: UserProfilePage.routePath, + path: UserProfilePage.routePath, + builder: UserProfilePage.routeBuilder, + routes: [ + GoRoute( + name: ManageSubscriptionPage.routePath, + path: ManageSubscriptionPage.routePath, + builder: ManageSubscriptionPage.routeBuilder, + ), + GoRoute( + name: NotificationPreferencesPage.routePath, + path: NotificationPreferencesPage.routePath, + builder: NotificationPreferencesPage.routeBuilder, + ), + ], + ), + ], + ), + GoRoute( + name: OnboardingPage.routePath, + path: OnboardingPage.routePath, + builder: OnboardingPage.routeBuilder, + ), + ], +); diff --git a/flutter_news_example/lib/app/view/app.dart b/flutter_news_example/lib/app/view/app.dart index ae4a17ef4..bf670db71 100644 --- a/flutter_news_example/lib/app/view/app.dart +++ b/flutter_news_example/lib/app/view/app.dart @@ -2,7 +2,6 @@ import 'package:ads_consent_client/ads_consent_client.dart'; import 'package:analytics_repository/analytics_repository.dart'; import 'package:app_ui/app_ui.dart'; import 'package:article_repository/article_repository.dart'; -import 'package:flow_builder/flow_builder.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_example/ads/ads.dart'; @@ -112,18 +111,18 @@ class AppView extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( + return MaterialApp.router( + routerConfig: router, themeMode: ThemeMode.light, theme: const AppTheme().themeData, darkTheme: const AppDarkTheme().themeData, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: AuthenticatedUserListener( - child: FlowBuilder( - state: context.select((AppBloc bloc) => bloc.state.status), - onGeneratePages: onGenerateAppViewPages, - ), - ), + builder: (context, router) { + return AuthenticatedUserListener( + child: router ?? const SizedBox(), + ); + }, ); } } diff --git a/flutter_news_example/lib/article/bloc/article_bloc.dart b/flutter_news_example/lib/article/bloc/article_bloc.dart index a7b02dee0..cc66a71c0 100644 --- a/flutter_news_example/lib/article/bloc/article_bloc.dart +++ b/flutter_news_example/lib/article/bloc/article_bloc.dart @@ -10,9 +10,9 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:news_blocks/news_blocks.dart'; import 'package:share_launcher/share_launcher.dart'; +part 'article_bloc.g.dart'; part 'article_event.dart'; part 'article_state.dart'; -part 'article_bloc.g.dart'; class ArticleBloc extends HydratedBloc { ArticleBloc({ diff --git a/flutter_news_example/lib/article/view/article_page.dart b/flutter_news_example/lib/article/view/article_page.dart index 1d56f7617..9bb0a97ca 100644 --- a/flutter_news_example/lib/article/view/article_page.dart +++ b/flutter_news_example/lib/article/view/article_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_news_example/app/app.dart'; import 'package:flutter_news_example/article/article.dart'; import 'package:flutter_news_example/l10n/l10n.dart'; import 'package:flutter_news_example/subscriptions/subscriptions.dart'; +import 'package:go_router/go_router.dart'; import 'package:news_blocks_ui/news_blocks_ui.dart'; import 'package:share_launcher/share_launcher.dart'; @@ -28,6 +29,42 @@ class ArticlePage extends StatelessWidget { super.key, }); + static const routeName = 'article'; + static const routePath = 'article/:id'; + + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) { + final id = state.pathParameters['id']; + + final isVideoArticle = bool.tryParse( + state.uri.queryParameters['isVideoArticle'] ?? 'false', + ) ?? + false; + final interstitialAdBehavior = + state.uri.queryParameters['interstitialAdBehavior'] != null + ? InterstitialAdBehavior.values.firstWhere( + (e) => + e.toString() == + 'InterstitialAdBehavior.' + // ignore: lines_longer_than_80_chars + '${state.uri.queryParameters['interstitialAdBehavior']}', + ) + : null; + + if (id == null) { + throw Exception('Missing required "id" parameter'); + } + + return ArticlePage( + id: id, + isVideoArticle: isVideoArticle, + interstitialAdBehavior: + interstitialAdBehavior ?? InterstitialAdBehavior.onOpen, + ); + } + /// The id of the requested article. final String id; @@ -38,20 +75,6 @@ class ArticlePage extends StatelessWidget { /// Default to [InterstitialAdBehavior.onOpen] final InterstitialAdBehavior interstitialAdBehavior; - static Route route({ - required String id, - bool isVideoArticle = false, - InterstitialAdBehavior interstitialAdBehavior = - InterstitialAdBehavior.onOpen, - }) => - MaterialPageRoute( - builder: (_) => ArticlePage( - id: id, - isVideoArticle: isVideoArticle, - interstitialAdBehavior: interstitialAdBehavior, - ), - ); - @override Widget build(BuildContext context) { return BlocProvider( diff --git a/flutter_news_example/lib/article/widgets/article_content.dart b/flutter_news_example/lib/article/widgets/article_content.dart index 12a40ecbd..6d0d2d4b9 100644 --- a/flutter_news_example/lib/article/widgets/article_content.dart +++ b/flutter_news_example/lib/article/widgets/article_content.dart @@ -8,6 +8,7 @@ import 'package:flutter_news_example/categories/categories.dart'; import 'package:flutter_news_example/l10n/l10n.dart'; import 'package:flutter_news_example/network_error/network_error.dart'; import 'package:flutter_news_example_api/client.dart'; +import 'package:go_router/go_router.dart'; import 'package:visibility_detector/visibility_detector.dart'; class ArticleContent extends StatelessWidget { @@ -34,16 +35,14 @@ class ArticleContent extends StatelessWidget { return ArticleContentSeenListener( child: BlocListener( - listener: (context, state) { + listener: (context, state) async { if (state.status == ArticleStatus.failure && state.content.isEmpty) { - Navigator.of(context).push( - NetworkError.route( - onRetry: () { - context.read().add(const ArticleRequested()); - Navigator.of(context).pop(); - }, - ), + await context.pushNamed( + NetworkErrorPage.routePath, ); + if (context.mounted) { + context.read().add(const ArticleRequested()); + } } else if (state.status == ArticleStatus.shareFailure) { _handleShareFailure(context); } diff --git a/flutter_news_example/lib/article/widgets/article_content_item.dart b/flutter_news_example/lib/article/widgets/article_content_item.dart index ea218f26c..74c1b3dbd 100644 --- a/flutter_news_example/lib/article/widgets/article_content_item.dart +++ b/flutter_news_example/lib/article/widgets/article_content_item.dart @@ -5,6 +5,7 @@ import 'package:flutter_news_example/categories/categories.dart'; import 'package:flutter_news_example/l10n/l10n.dart'; import 'package:flutter_news_example/newsletter/newsletter.dart'; import 'package:flutter_news_example/slideshow/slideshow.dart'; +import 'package:go_router/go_router.dart'; import 'package:news_blocks/news_blocks.dart'; import 'package:news_blocks_ui/news_blocks_ui.dart'; @@ -100,11 +101,10 @@ class ArticleContentItem extends StatelessWidget { BlockAction action, ) async { if (action is NavigateToSlideshowAction) { - await Navigator.of(context).push( - SlideshowPage.route( - slideshow: action.slideshow, - articleId: action.articleId, - ), + context.goNamed( + SlideshowPage.routePath, + pathParameters: {'id': action.articleId}, + extra: action.slideshow, ); } } diff --git a/flutter_news_example/lib/feed/widgets/category_feed.dart b/flutter_news_example/lib/feed/widgets/category_feed.dart index 2b9abac41..ef0458f2d 100644 --- a/flutter_news_example/lib/feed/widgets/category_feed.dart +++ b/flutter_news_example/lib/feed/widgets/category_feed.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_example/feed/feed.dart'; import 'package:flutter_news_example/network_error/network_error.dart'; import 'package:flutter_news_example_api/client.dart'; +import 'package:go_router/go_router.dart'; class CategoryFeed extends StatelessWidget { const CategoryFeed({ @@ -29,18 +30,15 @@ class CategoryFeed extends StatelessWidget { .select((FeedBloc bloc) => bloc.state.status == FeedStatus.failure); return BlocListener( - listener: (context, state) { + listener: (context, state) async { if (state.status == FeedStatus.failure && state.feed.isEmpty) { - Navigator.of(context).push( - NetworkError.route( - onRetry: () { - context - .read() - .add(FeedRefreshRequested(category: category)); - Navigator.of(context).pop(); - }, - ), + await context.pushNamed( + NetworkErrorPage.routePath, ); + // TODO: check if this implementation works (tests) + if (context.mounted) { + context.read().add(FeedRequested(category: category)); + } } }, child: RefreshIndicator( diff --git a/flutter_news_example/lib/feed/widgets/category_feed_item.dart b/flutter_news_example/lib/feed/widgets/category_feed_item.dart index 42e10ef5d..b05e1cf19 100644 --- a/flutter_news_example/lib/feed/widgets/category_feed_item.dart +++ b/flutter_news_example/lib/feed/widgets/category_feed_item.dart @@ -5,6 +5,7 @@ import 'package:flutter_news_example/article/article.dart'; import 'package:flutter_news_example/categories/categories.dart'; import 'package:flutter_news_example/l10n/l10n.dart'; import 'package:flutter_news_example/newsletter/newsletter.dart'; +import 'package:go_router/go_router.dart'; import 'package:news_blocks/news_blocks.dart'; import 'package:news_blocks_ui/news_blocks_ui.dart'; @@ -88,12 +89,18 @@ class CategoryFeedItem extends StatelessWidget { BlockAction action, ) async { if (action is NavigateToArticleAction) { - await Navigator.of(context).push( - ArticlePage.route(id: action.articleId), + context.goNamed( + ArticlePage.routeName, + pathParameters: {'id': action.articleId}, ); } else if (action is NavigateToVideoArticleAction) { - await Navigator.of(context).push( - ArticlePage.route(id: action.articleId, isVideoArticle: true), + context.goNamed( + ArticlePage.routeName, + pathParameters: {'id': action.articleId}, + queryParameters: { + 'articleId': action.articleId, + 'isVideoArticle': true, + }, ); } else if (action is NavigateToFeedCategoryAction) { context diff --git a/flutter_news_example/lib/home/view/home_page.dart b/flutter_news_example/lib/home/view/home_page.dart index f283b9e33..815d8cd88 100644 --- a/flutter_news_example/lib/home/view/home_page.dart +++ b/flutter_news_example/lib/home/view/home_page.dart @@ -2,12 +2,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_example/feed/feed.dart'; import 'package:flutter_news_example/home/home.dart'; +import 'package:go_router/go_router.dart'; import 'package:news_repository/news_repository.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); - static Page page() => const MaterialPage(child: HomePage()); + static const routePath = '/'; + + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + const HomePage(); @override Widget build(BuildContext context) { diff --git a/flutter_news_example/lib/home/view/home_view.dart b/flutter_news_example/lib/home/view/home_view.dart index 88fd3f588..8b7b746e6 100644 --- a/flutter_news_example/lib/home/view/home_view.dart +++ b/flutter_news_example/lib/home/view/home_view.dart @@ -6,8 +6,10 @@ import 'package:flutter_news_example/feed/feed.dart'; import 'package:flutter_news_example/home/home.dart'; import 'package:flutter_news_example/login/login.dart'; import 'package:flutter_news_example/navigation/navigation.dart'; +import 'package:flutter_news_example/onboarding/view/onboarding_page.dart'; import 'package:flutter_news_example/search/search.dart'; import 'package:flutter_news_example/user_profile/user_profile.dart'; +import 'package:go_router/go_router.dart'; class HomeView extends StatelessWidget { const HomeView({super.key}); @@ -31,6 +33,17 @@ class HomeView extends StatelessWidget { } }, ), + BlocListener( + listener: (context, state) { + switch (state.status) { + case AppStatus.onboardingRequired: + context.goNamed(OnboardingPage.routePath); + case AppStatus.unauthenticated: + case AppStatus.authenticated: + return; + } + }, + ), BlocListener( listener: (context, state) { FocusManager.instance.primaryFocus?.unfocus(); diff --git a/flutter_news_example/lib/login/view/login_with_email_page.dart b/flutter_news_example/lib/login/view/login_with_email_page.dart index 69f358f56..76f2e2f23 100644 --- a/flutter_news_example/lib/login/view/login_with_email_page.dart +++ b/flutter_news_example/lib/login/view/login_with_email_page.dart @@ -2,13 +2,19 @@ import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_example/login/login.dart'; +import 'package:go_router/go_router.dart'; import 'package:user_repository/user_repository.dart'; class LoginWithEmailPage extends StatelessWidget { const LoginWithEmailPage({super.key}); - static Route route() => - MaterialPageRoute(builder: (_) => const LoginWithEmailPage()); + static const routePath = 'login-with-email'; + + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + const LoginWithEmailPage(); @override Widget build(BuildContext context) { diff --git a/flutter_news_example/lib/login/widgets/login_form.dart b/flutter_news_example/lib/login/widgets/login_form.dart index 9bc04c651..8f8a5db04 100644 --- a/flutter_news_example/lib/login/widgets/login_form.dart +++ b/flutter_news_example/lib/login/widgets/login_form.dart @@ -5,6 +5,7 @@ import 'package:flutter_news_example/app/app.dart'; import 'package:flutter_news_example/l10n/l10n.dart'; import 'package:flutter_news_example/login/login.dart'; import 'package:form_inputs/form_inputs.dart'; +import 'package:go_router/go_router.dart'; class LoginForm extends StatelessWidget { const LoginForm({super.key}); @@ -201,9 +202,7 @@ class _ContinueWithEmailLoginButton extends StatelessWidget { Widget build(BuildContext context) { return AppButton.outlinedTransparentDarkAqua( key: const Key('loginForm_emailLogin_appButton'), - onPressed: () => Navigator.of(context).push( - LoginWithEmailPage.route(), - ), + onPressed: () => context.goNamed(LoginWithEmailPage.routePath), textStyle: Theme.of(context).textTheme.titleMedium, child: Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/flutter_news_example/lib/login/widgets/login_with_email_form.dart b/flutter_news_example/lib/login/widgets/login_with_email_form.dart index 7a14d2223..5a2ed3f31 100644 --- a/flutter_news_example/lib/login/widgets/login_with_email_form.dart +++ b/flutter_news_example/lib/login/widgets/login_with_email_form.dart @@ -7,6 +7,7 @@ import 'package:flutter_news_example/login/login.dart'; import 'package:flutter_news_example/magic_link_prompt/magic_link_prompt.dart'; import 'package:flutter_news_example/terms_of_service/terms_of_service.dart'; import 'package:form_inputs/form_inputs.dart'; +import 'package:go_router/go_router.dart'; class LoginWithEmailForm extends StatelessWidget { const LoginWithEmailForm({super.key}); @@ -17,8 +18,9 @@ class LoginWithEmailForm extends StatelessWidget { return BlocListener( listener: (context, state) { if (state.status.isSuccess) { - Navigator.of(context).push( - MagicLinkPromptPage.route(email: email), + context.goNamed( + MagicLinkPromptPage.routePath, + queryParameters: {'email': email}, ); } else if (state.status.isFailure) { ScaffoldMessenger.of(context) diff --git a/flutter_news_example/lib/magic_link_prompt/view/magic_link_prompt_page.dart b/flutter_news_example/lib/magic_link_prompt/view/magic_link_prompt_page.dart index 12b47e76b..1d64746a7 100644 --- a/flutter_news_example/lib/magic_link_prompt/view/magic_link_prompt_page.dart +++ b/flutter_news_example/lib/magic_link_prompt/view/magic_link_prompt_page.dart @@ -2,16 +2,21 @@ import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_news_example/login/login.dart'; import 'package:flutter_news_example/magic_link_prompt/magic_link_prompt.dart'; +import 'package:go_router/go_router.dart'; class MagicLinkPromptPage extends StatelessWidget { const MagicLinkPromptPage({required this.email, super.key}); + static const routePath = 'magic-link-prompt'; + final String email; - static Route route({required String email}) { - return MaterialPageRoute( - builder: (_) => MagicLinkPromptPage(email: email), - ); + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) { + final email = state.uri.queryParameters['email']!; + return MagicLinkPromptPage(email: email); } @override diff --git a/flutter_news_example/lib/network_error/view/network_error.dart b/flutter_news_example/lib/network_error/view/network_error.dart index 35d6243dc..1ee668da7 100644 --- a/flutter_news_example/lib/network_error/view/network_error.dart +++ b/flutter_news_example/lib/network_error/view/network_error.dart @@ -1,6 +1,36 @@ import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_news_example/l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; + +/// {@template network_error} +/// A network error alert page. +/// {@endtemplate} +class NetworkErrorPage extends StatelessWidget { + /// {@macro network_error} + const NetworkErrorPage({ + super.key, + }); + + static const routePath = 'network-error'; + + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + const NetworkErrorPage(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(leading: const AppBackButton()), + body: const Center( + child: NetworkError(), + ), + ); + } +} /// {@template network_error} /// A network error alert. @@ -12,21 +42,6 @@ class NetworkError extends StatelessWidget { /// An optional callback which is invoked when the retry button is pressed. final VoidCallback? onRetry; - /// Route constructor to display the widget inside a [Scaffold]. - static Route route({VoidCallback? onRetry}) { - return PageRouteBuilder( - pageBuilder: (_, __, ___) => Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( - leading: const AppBackButton(), - ), - body: Center( - child: NetworkError(onRetry: onRetry), - ), - ), - ); - } - @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -50,7 +65,7 @@ class NetworkError extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxxlg), child: AppButton.darkAqua( - onPressed: onRetry, + onPressed: onRetry ?? context.pop, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/flutter_news_example/lib/notification_preferences/view/notification_preferences_page.dart b/flutter_news_example/lib/notification_preferences/view/notification_preferences_page.dart index 57e739e03..6d309d8e7 100644 --- a/flutter_news_example/lib/notification_preferences/view/notification_preferences_page.dart +++ b/flutter_news_example/lib/notification_preferences/view/notification_preferences_page.dart @@ -3,17 +3,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_example/l10n/l10n.dart'; import 'package:flutter_news_example/notification_preferences/notification_preferences.dart'; +import 'package:go_router/go_router.dart'; import 'package:news_repository/news_repository.dart'; import 'package:notifications_repository/notifications_repository.dart'; class NotificationPreferencesPage extends StatelessWidget { const NotificationPreferencesPage({super.key}); - static MaterialPageRoute route() { - return MaterialPageRoute( - builder: (_) => const NotificationPreferencesPage(), - ); - } + static const routePath = 'notification-preferences'; + + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + const NotificationPreferencesPage(); @override Widget build(BuildContext context) { diff --git a/flutter_news_example/lib/onboarding/view/onboarding_page.dart b/flutter_news_example/lib/onboarding/view/onboarding_page.dart index 733ba02ad..efa8f3457 100644 --- a/flutter_news_example/lib/onboarding/view/onboarding_page.dart +++ b/flutter_news_example/lib/onboarding/view/onboarding_page.dart @@ -3,12 +3,19 @@ import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_example/onboarding/onboarding.dart'; +import 'package:go_router/go_router.dart'; import 'package:notifications_repository/notifications_repository.dart'; class OnboardingPage extends StatelessWidget { const OnboardingPage({super.key}); - static Page page() => const MaterialPage(child: OnboardingPage()); + static const routePath = '/onboarding'; + + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + const OnboardingPage(); @override Widget build(BuildContext context) { diff --git a/flutter_news_example/lib/slideshow/view/slideshow_page.dart b/flutter_news_example/lib/slideshow/view/slideshow_page.dart index 02ba024b9..d0fa00581 100644 --- a/flutter_news_example/lib/slideshow/view/slideshow_page.dart +++ b/flutter_news_example/lib/slideshow/view/slideshow_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_example/article/article.dart'; import 'package:flutter_news_example/slideshow/slideshow.dart'; +import 'package:go_router/go_router.dart'; import 'package:news_blocks/news_blocks.dart'; import 'package:share_launcher/share_launcher.dart'; @@ -13,17 +14,16 @@ class SlideshowPage extends StatelessWidget { super.key, }); - static Route route({ - required SlideshowBlock slideshow, - required String articleId, - }) { - return MaterialPageRoute( - builder: (_) => SlideshowPage( - slideshow: slideshow, - articleId: articleId, - ), - ); - } + static const String routePath = 'slideshow'; + + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + SlideshowPage( + slideshow: state.extra! as SlideshowBlock, + articleId: state.pathParameters['id']!, + ); final SlideshowBlock slideshow; final String articleId; diff --git a/flutter_news_example/lib/subscriptions/view/manage_subscription_page.dart b/flutter_news_example/lib/subscriptions/view/manage_subscription_page.dart index b855bb529..3be6bc68d 100644 --- a/flutter_news_example/lib/subscriptions/view/manage_subscription_page.dart +++ b/flutter_news_example/lib/subscriptions/view/manage_subscription_page.dart @@ -1,15 +1,18 @@ import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_news_example/l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; class ManageSubscriptionPage extends StatelessWidget { const ManageSubscriptionPage({super.key}); - static MaterialPageRoute route() { - return MaterialPageRoute( - builder: (_) => const ManageSubscriptionPage(), - ); - } + static const routePath = 'manage-subscription'; + + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + const ManageSubscriptionPage(); @override Widget build(BuildContext context) { diff --git a/flutter_news_example/lib/user_profile/view/user_profile_page.dart b/flutter_news_example/lib/user_profile/view/user_profile_page.dart index 54db67f4a..1883058a4 100644 --- a/flutter_news_example/lib/user_profile/view/user_profile_page.dart +++ b/flutter_news_example/lib/user_profile/view/user_profile_page.dart @@ -9,15 +9,20 @@ import 'package:flutter_news_example/notification_preferences/notification_prefe import 'package:flutter_news_example/subscriptions/subscriptions.dart'; import 'package:flutter_news_example/terms_of_service/terms_of_service.dart'; import 'package:flutter_news_example/user_profile/user_profile.dart'; +import 'package:go_router/go_router.dart'; import 'package:notifications_repository/notifications_repository.dart'; import 'package:user_repository/user_repository.dart'; class UserProfilePage extends StatelessWidget { const UserProfilePage({super.key}); - static MaterialPageRoute route() { - return MaterialPageRoute(builder: (_) => const UserProfilePage()); - } + static const routePath = 'profile'; + + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + const UserProfilePage(); @override Widget build(BuildContext context) { @@ -120,8 +125,8 @@ class _UserProfileViewState extends State key: const Key('userProfilePage_subscriptionItem'), title: l10n.manageSubscriptionTile, trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - ManageSubscriptionPage.route(), + onTap: () => context.goNamed( + ManageSubscriptionPage.routePath, ), ) else @@ -152,8 +157,8 @@ class _UserProfileViewState extends State ), title: l10n.notificationPreferencesTitle, trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - NotificationPreferencesPage.route(), + onTap: () => context.goNamed( + NotificationPreferencesPage.routePath, ), ), const _UserProfileDivider(), diff --git a/flutter_news_example/lib/user_profile/widgets/user_profile_button.dart b/flutter_news_example/lib/user_profile/widgets/user_profile_button.dart index 68e4e0265..f8c2c044d 100644 --- a/flutter_news_example/lib/user_profile/widgets/user_profile_button.dart +++ b/flutter_news_example/lib/user_profile/widgets/user_profile_button.dart @@ -5,6 +5,7 @@ import 'package:flutter_news_example/app/app.dart'; import 'package:flutter_news_example/l10n/l10n.dart'; import 'package:flutter_news_example/login/login.dart'; import 'package:flutter_news_example/user_profile/user_profile.dart'; +import 'package:go_router/go_router.dart'; /// A user profile button which displays a [LoginButton] /// for the unauthenticated user or an [OpenProfileButton] @@ -58,7 +59,7 @@ class OpenProfileButton extends StatelessWidget { horizontal: AppSpacing.lg, vertical: AppSpacing.sm, ), - onPressed: () => Navigator.of(context).push(UserProfilePage.route()), + onPressed: () => context.goNamed(UserProfilePage.routePath), tooltip: context.l10n.openProfileTooltip, ); } diff --git a/flutter_news_example/pubspec.lock b/flutter_news_example/pubspec.lock index 298826633..d36193780 100644 --- a/flutter_news_example/pubspec.lock +++ b/flutter_news_example/pubspec.lock @@ -713,6 +713,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "2fd11229f59e23e967b0775df8d5948a519cd7e1e8b6e849729e010587b46539" + url: "https://pub.dev" + source: hosted + version: "14.6.2" google_identity_services_web: dependency: transitive description: diff --git a/flutter_news_example/pubspec.yaml b/flutter_news_example/pubspec.yaml index 82c40e944..ef976e6b2 100644 --- a/flutter_news_example/pubspec.yaml +++ b/flutter_news_example/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: font_awesome_flutter: ^10.1.0 form_inputs: path: packages/form_inputs + go_router: ^14.6.2 google_mobile_ads: ^5.2.0 hydrated_bloc: ^9.0.0 in_app_purchase_repository: diff --git a/flutter_news_example/test/app/routes/routes_test.dart b/flutter_news_example/test/app/routes/routes_test.dart deleted file mode 100644 index c0611b6b0..000000000 --- a/flutter_news_example/test/app/routes/routes_test.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_news_example/app/app.dart'; -import 'package:flutter_news_example/home/home.dart'; -import 'package:flutter_news_example/onboarding/onboarding.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('onGenerateAppViewPages', () { - test('returns [OnboardingPage] when onboardingRequired', () { - expect( - onGenerateAppViewPages(AppStatus.onboardingRequired, []), - [ - isA>().having( - (p) => p.child, - 'child', - isA(), - ), - ], - ); - }); - - test('returns [HomePage] when authenticated', () { - expect( - onGenerateAppViewPages(AppStatus.authenticated, []), - [ - isA>().having( - (p) => p.child, - 'child', - isA(), - ), - ], - ); - }); - - test('returns [HomePage] when unauthenticated', () { - expect( - onGenerateAppViewPages(AppStatus.unauthenticated, []), - [ - isA>().having( - (p) => p.child, - 'child', - isA(), - ), - ], - ); - }); - }); -} diff --git a/flutter_news_example/test/app/view/app_test.dart b/flutter_news_example/test/app/view/app_test.dart index 41b75e4db..2df3cf6a8 100644 --- a/flutter_news_example/test/app/view/app_test.dart +++ b/flutter_news_example/test/app/view/app_test.dart @@ -8,7 +8,6 @@ import 'package:flutter_news_example/analytics/analytics.dart' as analytics; import 'package:flutter_news_example/app/app.dart'; import 'package:flutter_news_example/categories/categories.dart'; import 'package:flutter_news_example/home/home.dart'; -import 'package:flutter_news_example/onboarding/onboarding.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:in_app_purchase_repository/in_app_purchase_repository.dart'; import 'package:mocktail/mocktail.dart'; @@ -107,19 +106,6 @@ void main() { userRepository = MockUserRepository(); }); - testWidgets('navigates to OnboardingPage when onboardingRequired', - (tester) async { - final user = MockUser(); - when(() => appBloc.state).thenReturn(AppState.onboardingRequired(user)); - await tester.pumpApp( - const AppView(), - appBloc: appBloc, - userRepository: userRepository, - ); - await tester.pumpAndSettle(); - expect(find.byType(OnboardingPage), findsOneWidget); - }); - testWidgets('navigates to HomePage when unauthenticated', (tester) async { final categoriesBloc = MockCategoriesBloc(); when(() => appBloc.state).thenReturn(AppState.unauthenticated()); diff --git a/flutter_news_example/test/article/view/article_page_test.dart b/flutter_news_example/test/article/view/article_page_test.dart index 77b467a23..70d27d2c4 100644 --- a/flutter_news_example/test/article/view/article_page_test.dart +++ b/flutter_news_example/test/article/view/article_page_test.dart @@ -10,6 +10,7 @@ import 'package:flutter_news_example/app/app.dart'; import 'package:flutter_news_example/article/article.dart'; import 'package:flutter_news_example/subscriptions/subscriptions.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart' as ads; import 'package:in_app_purchase_repository/in_app_purchase_repository.dart'; import 'package:mockingjay/mockingjay.dart'; @@ -29,14 +30,24 @@ class MockFullScreenAdsBloc class MockRewardItem extends Mock implements ads.RewardItem {} +class MockGoRouter extends Mock implements GoRouter {} + +class _MockGoRouterState extends Mock implements GoRouterState {} + +class _MockBuildContext extends Mock implements BuildContext {} + void main() { initMockHydratedStorage(); + late GoRouterState goRouterState; + late BuildContext context; group('ArticlePage', () { + late GoRouter goRouter; late FullScreenAdsBloc fullScreenAdsBloc; late AppBloc appBloc; setUp(() { + goRouter = MockGoRouter(); fullScreenAdsBloc = MockFullScreenAdsBloc(); appBloc = MockAppBloc(); whenListen( @@ -44,19 +55,30 @@ void main() { Stream.value(FullScreenAdsState.initial()), initialState: FullScreenAdsState.initial(), ); + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); }); - test('has a route', () { - expect(ArticlePage.route(id: 'id'), isA>()); + testWidgets('routeBuilder builds a ArticlePage', (tester) async { + when(() => goRouterState.pathParameters).thenReturn({'id': 'id'}); + when(() => goRouterState.uri) + .thenReturn(Uri(queryParameters: {'isVideoArticle': 'true'})); + + final page = ArticlePage.routeBuilder(context, goRouterState); + + expect(page, isA()); }); testWidgets('renders ArticleView', (tester) async { await tester.pumpApp( fullScreenAdsBloc: fullScreenAdsBloc, - ArticlePage( - id: 'id', - isVideoArticle: false, - interstitialAdBehavior: InterstitialAdBehavior.onOpen, + InheritedGoRouter( + goRouter: goRouter, + child: ArticlePage( + id: 'id', + isVideoArticle: false, + interstitialAdBehavior: InterstitialAdBehavior.onOpen, + ), ), ); expect(find.byType(ArticleView), findsOneWidget); @@ -65,10 +87,13 @@ void main() { testWidgets('provides ArticleBloc', (tester) async { await tester.pumpApp( fullScreenAdsBloc: fullScreenAdsBloc, - ArticlePage( - id: 'id', - isVideoArticle: false, - interstitialAdBehavior: InterstitialAdBehavior.onOpen, + InheritedGoRouter( + goRouter: goRouter, + child: ArticlePage( + id: 'id', + isVideoArticle: false, + interstitialAdBehavior: InterstitialAdBehavior.onOpen, + ), ), ); final BuildContext viewContext = tester.element(find.byType(ArticleView)); diff --git a/flutter_news_example/test/article/widgets/article_content_item_test.dart b/flutter_news_example/test/article/widgets/article_content_item_test.dart index 3dd285b73..139b64ddf 100644 --- a/flutter_news_example/test/article/widgets/article_content_item_test.dart +++ b/flutter_news_example/test/article/widgets/article_content_item_test.dart @@ -5,6 +5,8 @@ import 'package:flutter_news_example/article/article.dart'; import 'package:flutter_news_example/newsletter/newsletter.dart'; import 'package:flutter_news_example/slideshow/slideshow.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:news_blocks/news_blocks.dart'; import 'package:news_blocks_ui/news_blocks_ui.dart'; import 'package:video_player_platform_interface/video_player_platform_interface.dart'; @@ -13,7 +15,10 @@ import 'package:visibility_detector/visibility_detector.dart'; import '../../helpers/helpers.dart'; import '../helpers/helpers.dart'; +class MockGoRouter extends Mock implements GoRouter {} + void main() { + late GoRouter goRouter; initMockHydratedStorage(); void setUpVideoPlayerPlatform() { @@ -21,6 +26,10 @@ void main() { VideoPlayerPlatform.instance = fakeVideoPlayerPlatform; } + setUp(() { + goRouter = MockGoRouter(); + }); + group('ArticleContentItem', () { testWidgets( 'renders DividerHorizontal ' @@ -226,34 +235,52 @@ void main() { }); testWidgets( - 'renders SlideshowIntroduction ' + 'calls goRouter.goNamed to SlideshowPage ' 'for SlideshowIntroductionBlock', (tester) async { + const articleId = 'articleId'; + final slideshow = SlideshowBlock( + slides: [], + title: 'title', + ); + final block = SlideshowIntroductionBlock( title: 'title', coverImageUrl: 'coverImageUrl', action: NavigateToSlideshowAction( - slideshow: SlideshowBlock( - slides: [], - title: 'title', - ), - articleId: 'articleId', + slideshow: slideshow, + articleId: articleId, ), ); + + when( + () => goRouter.goNamed( + SlideshowPage.routePath, + pathParameters: {'id': articleId}, + extra: slideshow, + ), + ).thenAnswer((_) {}); + await tester.pumpApp( ListView( children: [ - ArticleContentItem(block: block), + InheritedGoRouter( + goRouter: goRouter, + child: ArticleContentItem(block: block), + ), ], ), ); await tester.ensureVisible(find.byType(SlideshowIntroduction)); await tester.tap(find.byType(SlideshowIntroduction)); - await tester.pumpAndSettle(); - expect( - find.byType(SlideshowPage), - findsOneWidget, - ); + + verify( + () => goRouter.goNamed( + SlideshowPage.routePath, + pathParameters: {'id': articleId}, + extra: slideshow, + ), + ).called(1); }); }); diff --git a/flutter_news_example/test/article/widgets/article_content_test.dart b/flutter_news_example/test/article/widgets/article_content_test.dart index 9a8d8131c..b50a39a8a 100644 --- a/flutter_news_example/test/article/widgets/article_content_test.dart +++ b/flutter_news_example/test/article/widgets/article_content_test.dart @@ -10,6 +10,7 @@ import 'package:flutter_news_example/analytics/analytics.dart'; import 'package:flutter_news_example/article/article.dart'; import 'package:flutter_news_example/network_error/network_error.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:news_blocks/news_blocks.dart'; import 'package:visibility_detector/visibility_detector.dart'; @@ -23,6 +24,8 @@ class MockNavigatorObserver extends Mock implements NavigatorObserver {} const networkErrorButtonText = 'Try Again'; +class MockGoRouter extends Mock implements GoRouter {} + void main() { late ArticleBloc articleBloc; @@ -134,11 +137,17 @@ void main() { }); group('when ArticleStatus is failure and content is absent', () { - setUpAll(() { - registerFallbackValue(NetworkError.route()); - }); + late GoRouter goRouter; setUp(() { + goRouter = MockGoRouter(); + when( + () => goRouter.goNamed( + NetworkErrorPage.routePath, + extra: any(named: 'extra'), + ), + ).thenAnswer((_) {}); + whenListen( articleBloc, Stream.fromIterable([ @@ -148,56 +157,24 @@ void main() { ); }); - testWidgets('pushes NetworkErrorAlert on Scaffold', (tester) async { - final navigatorObserver = MockNavigatorObserver(); - + Future pumpWidget(WidgetTester tester, Widget child) async { await tester.pumpApp( BlocProvider.value( value: articleBloc, - child: ArticleContent(), - ), - navigatorObserver: navigatorObserver, - ); - - verify(() => navigatorObserver.didPush(any(), any())); - - expect( - find.ancestor( - of: find.byType(NetworkError), - matching: find.byType(Scaffold), + child: InheritedGoRouter(goRouter: goRouter, child: child), ), - findsOneWidget, ); - }); + } - testWidgets('NetworkErrorAlert requests article on press', - (tester) async { - final navigatorObserver = MockNavigatorObserver(); + testWidgets('pushes NetworkErrorAlert on Scaffold', (tester) async { + await pumpWidget(tester, ArticleContent()); - await tester.pumpApp( - BlocProvider.value( - value: articleBloc, - child: ArticleContent(), + verify( + () => goRouter.goNamed( + NetworkErrorPage.routePath, + extra: any(named: 'extra'), ), - navigatorObserver: navigatorObserver, - ); - - verify(() => navigatorObserver.didPush(any(), any())); - - expect( - find.text(networkErrorButtonText), - findsOneWidget, - ); - - await tester.ensureVisible(find.textContaining(networkErrorButtonText)); - verify(() => articleBloc.add(ArticleRequested())).called(1); - - await tester.pump(); - await tester.tap(find.textContaining(networkErrorButtonText).last); - await tester.pump(); - - verify(() => articleBloc.add(ArticleRequested())).called(1); - verify(() => navigatorObserver.didPop(any(), any())); + ).called(1); }); }); diff --git a/flutter_news_example/test/feed/widgets/category_feed_item_test.dart b/flutter_news_example/test/feed/widgets/category_feed_item_test.dart index da20b7e19..60378481c 100644 --- a/flutter_news_example/test/feed/widgets/category_feed_item_test.dart +++ b/flutter_news_example/test/feed/widgets/category_feed_item_test.dart @@ -10,6 +10,7 @@ import 'package:flutter_news_example/categories/categories.dart'; import 'package:flutter_news_example/feed/feed.dart'; import 'package:flutter_news_example/newsletter/newsletter.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:mocktail_image_network/mocktail_image_network.dart'; import 'package:news_blocks/news_blocks.dart'; @@ -23,6 +24,8 @@ class MockArticleRepository extends Mock implements ArticleRepository {} class MockCategoriesBloc extends MockBloc implements CategoriesBloc {} +class MockGoRouter extends Mock implements GoRouter {} + void main() { initMockHydratedStorage(); @@ -254,9 +257,30 @@ void main() { }); group( - 'navigates to ArticlePage ' + 'calls GoRouter.goNamed to navigate to ArticlePage ' 'on NavigateToArticleAction', () { const articleId = 'articleId'; + late GoRouter goRouter; + + setUpAll(() { + goRouter = MockGoRouter(); + when( + () => goRouter.goNamed( + ArticlePage.routeName, + pathParameters: {'id': articleId}, + ), + ).thenAnswer((_) {}); + }); + + Future pumpWidget(WidgetTester tester, Widget widget) { + return tester.pumpApp( + InheritedGoRouter( + goRouter: goRouter, + child: widget, + ), + articleRepository: articleRepository, + ); + } testWidgets('from PostLarge', (tester) async { const category = Category(id: 'technology', name: 'Technology'); @@ -271,23 +295,20 @@ void main() { action: NavigateToArticleAction(articleId: articleId), ); - await tester.pumpApp( - CustomScrollView( - slivers: [CategoryFeedItem(block: block)], - ), - articleRepository: articleRepository, + await pumpWidget( + tester, + CustomScrollView(slivers: [CategoryFeedItem(block: block)]), ); await tester.ensureVisible(find.byType(PostLarge)); await tester.tap(find.byType(PostLarge)); - await tester.pumpAndSettle(); - expect( - find.byWidgetPredicate( - (widget) => widget is ArticlePage && widget.id == articleId, + verify( + () => goRouter.goNamed( + ArticlePage.routeName, + pathParameters: {'id': articleId}, ), - findsOneWidget, - ); + ).called(1); }); testWidgets('from PostMedium', (tester) async { @@ -304,24 +325,21 @@ void main() { ); await mockNetworkImages(() async { - await tester.pumpApp( - CustomScrollView( - slivers: [CategoryFeedItem(block: block)], - ), - articleRepository: articleRepository, + await pumpWidget( + tester, + CustomScrollView(slivers: [CategoryFeedItem(block: block)]), ); }); await tester.ensureVisible(find.byType(PostMedium)); await tester.tap(find.byType(PostMedium)); - await tester.pumpAndSettle(); - expect( - find.byWidgetPredicate( - (widget) => widget is ArticlePage && widget.id == articleId, + verify( + () => goRouter.goNamed( + ArticlePage.routeName, + pathParameters: {'id': articleId}, ), - findsOneWidget, - ); + ).called(1); }); testWidgets('from PostSmall', (tester) async { @@ -336,24 +354,21 @@ void main() { action: NavigateToArticleAction(articleId: articleId), ); await mockNetworkImages(() async { - await tester.pumpApp( - CustomScrollView( - slivers: [CategoryFeedItem(block: block)], - ), - articleRepository: articleRepository, + await pumpWidget( + tester, + CustomScrollView(slivers: [CategoryFeedItem(block: block)]), ); }); await tester.ensureVisible(find.byType(PostSmallContent)); await tester.tap(find.byType(PostSmallContent)); - await tester.pumpAndSettle(); - expect( - find.byWidgetPredicate( - (widget) => widget is ArticlePage && widget.id == articleId, + verify( + () => goRouter.goNamed( + ArticlePage.routeName, + pathParameters: {'id': articleId}, ), - findsOneWidget, - ); + ).called(1); }); testWidgets('from PostGrid', (tester) async { @@ -374,11 +389,9 @@ void main() { ); await mockNetworkImages(() async { - await tester.pumpApp( - CustomScrollView( - slivers: [CategoryFeedItem(block: block)], - ), - articleRepository: articleRepository, + await pumpWidget( + tester, + CustomScrollView(slivers: [CategoryFeedItem(block: block)]), ); }); @@ -386,14 +399,13 @@ void main() { // is displayed as a large post. await tester.ensureVisible(find.byType(PostLarge)); await tester.tap(find.byType(PostLarge)); - await tester.pumpAndSettle(); - expect( - find.byWidgetPredicate( - (widget) => widget is ArticlePage && widget.id == articleId, + verify( + () => goRouter.goNamed( + ArticlePage.routeName, + pathParameters: {'id': articleId}, ), - findsOneWidget, - ); + ).called(1); }); }); @@ -401,6 +413,27 @@ void main() { 'navigates to video ArticlePage ' 'on NavigateToVideoArticleAction', () { const articleId = 'articleId'; + late GoRouter goRouter; + + setUpAll(() { + goRouter = MockGoRouter(); + when( + () => goRouter.goNamed( + ArticlePage.routeName, + pathParameters: {'id': articleId}, + ), + ).thenAnswer((_) {}); + }); + + Future pumpWidget(WidgetTester tester, Widget widget) { + return tester.pumpApp( + InheritedGoRouter( + goRouter: goRouter, + child: widget, + ), + articleRepository: articleRepository, + ); + } testWidgets('from PostLarge', (tester) async { const category = Category(id: 'technology', name: 'Technology'); @@ -416,27 +449,25 @@ void main() { ); await mockNetworkImages(() async { - await tester.pumpApp( - CustomScrollView( - slivers: [CategoryFeedItem(block: block)], - ), - articleRepository: articleRepository, + await pumpWidget( + tester, + CustomScrollView(slivers: [CategoryFeedItem(block: block)]), ); }); await tester.ensureVisible(find.byType(PostLarge)); await tester.tap(find.byType(PostLarge)); - await tester.pumpAndSettle(); - - expect( - find.byWidgetPredicate( - (widget) => - widget is ArticlePage && - widget.id == articleId && - widget.isVideoArticle == true, + + verify( + () => goRouter.goNamed( + ArticlePage.routeName, + pathParameters: {'id': articleId}, + queryParameters: { + 'articleId': articleId, + 'isVideoArticle': true, + }, ), - findsOneWidget, - ); + ).called(1); }); testWidgets('from PostMedium', (tester) async { @@ -453,27 +484,25 @@ void main() { ); await mockNetworkImages(() async { - await tester.pumpApp( - CustomScrollView( - slivers: [CategoryFeedItem(block: block)], - ), - articleRepository: articleRepository, + await pumpWidget( + tester, + CustomScrollView(slivers: [CategoryFeedItem(block: block)]), ); }); await tester.ensureVisible(find.byType(PostMedium)); await tester.tap(find.byType(PostMedium)); - await tester.pumpAndSettle(); - - expect( - find.byWidgetPredicate( - (widget) => - widget is ArticlePage && - widget.id == articleId && - widget.isVideoArticle == true, + + verify( + () => goRouter.goNamed( + ArticlePage.routeName, + pathParameters: {'id': articleId}, + queryParameters: { + 'articleId': articleId, + 'isVideoArticle': true, + }, ), - findsOneWidget, - ); + ).called(1); }); testWidgets('from PostSmall', (tester) async { @@ -489,27 +518,25 @@ void main() { ); await mockNetworkImages(() async { - await tester.pumpApp( - CustomScrollView( - slivers: [CategoryFeedItem(block: block)], - ), - articleRepository: articleRepository, + await pumpWidget( + tester, + CustomScrollView(slivers: [CategoryFeedItem(block: block)]), ); }); await tester.ensureVisible(find.byType(PostSmallContent)); await tester.tap(find.byType(PostSmallContent)); - await tester.pumpAndSettle(); - - expect( - find.byWidgetPredicate( - (widget) => - widget is ArticlePage && - widget.id == articleId && - widget.isVideoArticle == true, + + verify( + () => goRouter.goNamed( + ArticlePage.routeName, + pathParameters: {'id': articleId}, + queryParameters: { + 'articleId': articleId, + 'isVideoArticle': true, + }, ), - findsOneWidget, - ); + ).called(1); }); testWidgets('from PostGrid', (tester) async { @@ -529,10 +556,9 @@ void main() { ], ); - await tester.pumpApp( - CustomScrollView( - slivers: [CategoryFeedItem(block: block)], - ), + await pumpWidget( + tester, + CustomScrollView(slivers: [CategoryFeedItem(block: block)]), ); // We're tapping on a PostLarge as the first post of the PostGrid @@ -544,21 +570,24 @@ void main() { await tester.pump(); await tester.pump(kThemeAnimationDuration); - expect( - find.byWidgetPredicate( - (widget) => - widget is ArticlePage && - widget.id == articleId && - widget.isVideoArticle == true, + verify( + () => goRouter.goNamed( + ArticlePage.routeName, + pathParameters: {'id': articleId}, + queryParameters: { + 'articleId': articleId, + 'isVideoArticle': true, + }, ), - findsOneWidget, - ); + ).called(1); }); }); testWidgets( 'adds CategorySelected to CategoriesBloc ' 'on NavigateToFeedCategoryAction', (tester) async { + final goRouter = MockGoRouter(); + final categoriesBloc = MockCategoriesBloc(); const category = Category(id: 'top', name: 'Top'); @@ -570,13 +599,15 @@ void main() { await tester.pumpApp( BlocProvider.value( value: categoriesBloc, - child: CustomScrollView(slivers: [CategoryFeedItem(block: block)]), + child: InheritedGoRouter( + goRouter: goRouter, + child: CustomScrollView(slivers: [CategoryFeedItem(block: block)]), + ), ), ); await tester.ensureVisible(find.byType(IconButton)); await tester.tap(find.byType(IconButton)); - await tester.pumpAndSettle(); verify(() => categoriesBloc.add(CategorySelected(category: category))) .called(1); diff --git a/flutter_news_example/test/feed/widgets/category_feed_test.dart b/flutter_news_example/test/feed/widgets/category_feed_test.dart index df9ffae4a..12ebade08 100644 --- a/flutter_news_example/test/feed/widgets/category_feed_test.dart +++ b/flutter_news_example/test/feed/widgets/category_feed_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_example/feed/feed.dart'; import 'package:flutter_news_example/network_error/network_error.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:news_blocks/news_blocks.dart'; @@ -16,6 +17,8 @@ class MockFeedBloc extends MockBloc implements FeedBloc {} class MockNavigatorObserver extends Mock implements NavigatorObserver {} +class MockGoRouter extends Mock implements GoRouter {} + const networkErrorButtonText = 'Try Again'; void main() { @@ -122,9 +125,10 @@ void main() { }); group('when FeedStatus is failure and feed is unpopulated', () { + late GoRouter goRouter; setUpAll(() { + goRouter = MockGoRouter(); registerFallbackValue(category); - registerFallbackValue(NetworkError.route()); }); setUp(() { @@ -135,52 +139,41 @@ void main() { FeedState(feed: {}, status: FeedStatus.failure), ]), ); - }); - - testWidgets('pushes NetworkErrorAlert on Scaffold', (tester) async { - final navigatorObserver = MockNavigatorObserver(); - - await tester.pumpApp( - BlocProvider.value( - value: feedBloc, - child: CategoryFeed(category: category), - ), - navigatorObserver: navigatorObserver, - ); - verify(() => navigatorObserver.didPush(any(), any())); - - expect( - find.ancestor( - of: find.byType(NetworkError), - matching: find.byType(Scaffold), + when( + () => goRouter.pushNamed( + NetworkErrorPage.routePath, ), - findsOneWidget, - ); + ).thenAnswer((_) async { + return null; + }); }); - testWidgets('requests feed refresh on NetworkErrorAlert press', - (tester) async { - final navigatorObserver = MockNavigatorObserver(); - + Future pumpWidget(WidgetTester tester, Widget child) async { await tester.pumpApp( BlocProvider.value( value: feedBloc, - child: CategoryFeed(category: category), + child: InheritedGoRouter( + goRouter: goRouter, + child: child, + ), ), - navigatorObserver: navigatorObserver, ); + } - verify(() => navigatorObserver.didPush(any(), any())); - - await tester.ensureVisible(find.text(networkErrorButtonText)); + testWidgets('navigates to Network Error page and requests feed again', + (tester) async { + await pumpWidget(tester, CategoryFeed(category: category)); - await tester.pump(Duration(seconds: 1)); - await tester.tap(find.textContaining(networkErrorButtonText)); + verify( + () => goRouter.pushNamed( + NetworkErrorPage.routePath, + ), + ).called(1); - verify(() => feedBloc.add(any(that: isA()))) - .called(1); - verify(() => navigatorObserver.didPop(any(), any())); + verify( + () => feedBloc.add(FeedRequested(category: category)), + ).called(2); }); }); diff --git a/flutter_news_example/test/home/view/home_page_test.dart b/flutter_news_example/test/home/view/home_page_test.dart index 1a3767349..f72e92507 100644 --- a/flutter_news_example/test/home/view/home_page_test.dart +++ b/flutter_news_example/test/home/view/home_page_test.dart @@ -1,24 +1,73 @@ // ignore_for_file: prefer_const_constructors // ignore_for_file: prefer_const_literals_to_create_immutables -import 'package:flutter/material.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_news_example/feed/feed.dart'; import 'package:flutter_news_example/home/home.dart'; +import 'package:flutter_news_example/network_error/network_error.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:news_blocks/news_blocks.dart'; import 'package:news_repository/news_repository.dart'; import '../../helpers/helpers.dart'; class MockNewsRepository extends Mock implements NewsRepository {} +class MockGoRouter extends Mock implements GoRouter {} + +class _MockGoRouterState extends Mock implements GoRouterState {} + +class _MockBuildContext extends Mock implements BuildContext {} + +class MockFeedBloc extends MockBloc implements FeedBloc {} + void main() { initMockHydratedStorage(); late NewsRepository newsRepository; + late FeedBloc feedBloc; + + final entertainmentCategory = Category( + id: 'entertainment', + name: 'Entertainment', + ); + final healthCategory = Category(id: 'health', name: 'Health'); + + final feed = >{ + entertainmentCategory.id: [ + SectionHeaderBlock(title: 'Top'), + DividerHorizontalBlock(), + SpacerBlock(spacing: Spacing.medium), + ], + healthCategory.id: [ + SectionHeaderBlock(title: 'Technology'), + DividerHorizontalBlock(), + SpacerBlock(spacing: Spacing.medium), + ], + }; + + initMockHydratedStorage(); + late GoRouter router; + late GoRouterState goRouterState; + late BuildContext context; setUp(() { + feedBloc = MockFeedBloc(); + + when(() => feedBloc.state).thenReturn( + FeedState( + feed: feed, + status: FeedStatus.populated, + ), + ); + newsRepository = MockNewsRepository(); + router = MockGoRouter(); + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); final healthCategory = Category(id: 'health', name: 'Health'); when(newsRepository.getCategories).thenAnswer( @@ -26,10 +75,20 @@ void main() { categories: [healthCategory], ), ); + + when( + () => router.pushNamed( + NetworkErrorPage.routePath, + ), + ).thenAnswer((_) async { + return null; + }); }); - test('has a page', () { - expect(HomePage.page(), isA>()); + testWidgets('routeBuilder builds a HomePage', (tester) async { + final page = HomePage.routeBuilder(context, goRouterState); + + expect(page, isA()); }); testWidgets('renders a HomeView', (tester) async { @@ -40,7 +99,7 @@ void main() { testWidgets('renders FeedView', (tester) async { await tester.pumpApp( - const HomePage(), + InheritedGoRouter(goRouter: router, child: const HomePage()), newsRepository: newsRepository, ); diff --git a/flutter_news_example/test/home/view/home_view_test.dart b/flutter_news_example/test/home/view/home_view_test.dart index 11b5b0d26..656d1d9f8 100644 --- a/flutter_news_example/test/home/view/home_view_test.dart +++ b/flutter_news_example/test/home/view/home_view_test.dart @@ -13,9 +13,11 @@ import 'package:flutter_news_example/feed/feed.dart'; import 'package:flutter_news_example/home/home.dart'; import 'package:flutter_news_example/login/login.dart'; import 'package:flutter_news_example/navigation/navigation.dart'; +import 'package:flutter_news_example/onboarding/onboarding.dart'; import 'package:flutter_news_example/search/search.dart'; import 'package:flutter_news_example/user_profile/user_profile.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:news_blocks/news_blocks.dart'; import 'package:news_repository/news_repository.dart'; @@ -33,6 +35,8 @@ class MockNewsRepository extends Mock implements NewsRepository {} class MockAppBloc extends Mock implements AppBloc {} +class MockGoRouter extends Mock implements GoRouter {} + void main() { initMockHydratedStorage(); @@ -41,6 +45,7 @@ void main() { late CategoriesBloc categoriesBloc; late FeedBloc feedBloc; late AppBloc appBloc; + late GoRouter goRouter; final entertainmentCategory = Category( id: 'entertainment', @@ -69,6 +74,7 @@ void main() { feedBloc = MockFeedBloc(); cubit = MockHomeCubit(); appBloc = MockAppBloc(); + goRouter = MockGoRouter(); when(() => appBloc.state).thenReturn( AppState( @@ -186,6 +192,32 @@ void main() { expect(find.byType(LoginModal), findsOneWidget); }); + + testWidgets('navigates to Onboarding page if onboarding is required', + (tester) async { + whenListen( + appBloc, + Stream.fromIterable([ + AppState( + showLoginOverlay: false, + status: AppStatus.onboardingRequired, + ), + ]), + ); + when(() => goRouter.goNamed(OnboardingPage.routePath)).thenAnswer((_) {}); + + await pumpHomeView( + tester: tester, + cubit: cubit, + categoriesBloc: categoriesBloc, + feedBloc: feedBloc, + newsRepository: newsRepository, + appBloc: appBloc, + goRouter: goRouter, + ); + + verify(() => goRouter.goNamed(OnboardingPage.routePath)).called(1); + }); }); group('BottomNavigationBar', () { @@ -285,6 +317,7 @@ Future pumpHomeView({ required FeedBloc feedBloc, required NewsRepository newsRepository, AppBloc? appBloc, + GoRouter? goRouter, }) async { await tester.pumpApp( MultiBlocProvider( @@ -299,7 +332,9 @@ Future pumpHomeView({ value: cubit, ), ], - child: HomeView(), + child: goRouter != null + ? InheritedGoRouter(goRouter: goRouter, child: HomeView()) + : HomeView(), ), newsRepository: newsRepository, appBloc: appBloc, diff --git a/flutter_news_example/test/login/view/login_with_email_page_test.dart b/flutter_news_example/test/login/view/login_with_email_page_test.dart index 578c5f429..1e708d56d 100644 --- a/flutter_news_example/test/login/view/login_with_email_page_test.dart +++ b/flutter_news_example/test/login/view/login_with_email_page_test.dart @@ -2,16 +2,30 @@ import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_news_example/login/login.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mockingjay/mockingjay.dart'; import '../../helpers/helpers.dart'; +class _MockGoRouterState extends Mock implements GoRouterState {} + +class _MockBuildContext extends Mock implements BuildContext {} + void main() { const closeIcon = Key('loginWithEmailPage_closeIcon'); + late GoRouterState goRouterState; + late BuildContext context; + + setUp(() { + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); + }); group('LoginWithEmailPage', () { - test('has a route', () { - expect(LoginWithEmailPage.route(), isA>()); + testWidgets('routeBuilder builds a LoginWithEmailPage', (tester) async { + final page = LoginWithEmailPage.routeBuilder(context, goRouterState); + + expect(page, isA()); }); testWidgets('renders LoginWithEmailForm', (tester) async { diff --git a/flutter_news_example/test/login/widgets/login_form_test.dart b/flutter_news_example/test/login/widgets/login_form_test.dart index 2c31700aa..431123d0a 100644 --- a/flutter_news_example/test/login/widgets/login_form_test.dart +++ b/flutter_news_example/test/login/widgets/login_form_test.dart @@ -10,6 +10,7 @@ import 'package:flutter_news_example/app/app.dart'; import 'package:flutter_news_example/login/login.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:form_inputs/form_inputs.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:user_repository/user_repository.dart'; @@ -22,6 +23,8 @@ class MockLoginBloc extends MockBloc class MockAppBloc extends MockBloc implements AppBloc {} +class MockGoRouter extends Mock implements GoRouter {} + void main() { const loginButtonKey = Key('loginForm_emailLogin_appButton'); const signInWithGoogleButtonKey = Key('loginForm_googleLogin_appButton'); @@ -151,23 +154,51 @@ void main() { }); group('navigates', () { + late GoRouter goRouter; + + setUpAll(() { + goRouter = MockGoRouter(); + when(() => goRouter.goNamed(LoginWithEmailPage.routePath)) + .thenAnswer((_) {}); + }); + testWidgets('to LoginWithEmailPage when Continue with email is pressed', (tester) async { await tester.pumpApp( - BlocProvider.value(value: loginBloc, child: const LoginForm()), + InheritedGoRouter( + goRouter: goRouter, + child: BlocProvider.value( + value: loginBloc, + child: const LoginForm(), + ), + ), ); await tester.ensureVisible(find.byKey(loginButtonKey)); await tester.tap(find.byKey(loginButtonKey)); - await tester.pumpAndSettle(); - expect(find.byType(LoginWithEmailPage), findsOneWidget); + + verify(() => goRouter.goNamed(LoginWithEmailPage.routePath)).called(1); }); }); group('closes modal', () { const buttonText = 'button'; + late GoRouter goRouter; - testWidgets('when the close icon is pressed', (tester) async { + setUpAll(() { + goRouter = MockGoRouter(); + when(() => goRouter.goNamed(LoginWithEmailPage.routePath)) + .thenAnswer((_) {}); + }); + + Future pumpWidget(WidgetTester tester, Widget widget) async { await tester.pumpApp( + InheritedGoRouter(goRouter: goRouter, child: widget), + ); + } + + testWidgets('when the close icon is pressed', (tester) async { + await pumpWidget( + tester, BlocProvider.value( value: loginBloc, child: Builder( @@ -202,7 +233,11 @@ void main() { initialState: const AppState.unauthenticated(), ); - await tester.pumpApp( + when(() => goRouter.goNamed(LoginWithEmailPage.routePath)) + .thenAnswer((_) {}); + + await pumpWidget( + tester, Builder( builder: (context) { return AppButton.black( @@ -211,7 +246,10 @@ void main() { context: context, builder: (context) => BlocProvider.value( value: appBloc, - child: LoginModal(), + child: InheritedGoRouter( + goRouter: goRouter, + child: LoginModal(), + ), ), routeSettings: const RouteSettings(name: LoginModal.name), ), @@ -224,15 +262,7 @@ void main() { await tester.ensureVisible(find.byKey(loginButtonKey)); await tester.tap(find.byKey(loginButtonKey)); - await tester.pumpAndSettle(); - expect(find.byType(LoginWithEmailPage), findsOneWidget); - - appStateController.add(AppState.authenticated(user)); - await tester.pump(); - await tester.pumpAndSettle(); - - expect(find.byType(LoginWithEmailPage), findsNothing); - expect(find.byType(LoginForm), findsNothing); + verify(() => goRouter.goNamed(LoginWithEmailPage.routePath)).called(1); }); testWidgets('when user is authenticated and onboarding is required', @@ -245,7 +275,8 @@ void main() { initialState: const AppState.unauthenticated(), ); - await tester.pumpApp( + await pumpWidget( + tester, Builder( builder: (context) { return AppButton.black( @@ -254,7 +285,10 @@ void main() { context: context, builder: (context) => BlocProvider.value( value: appBloc, - child: LoginModal(), + child: InheritedGoRouter( + goRouter: goRouter, + child: LoginModal(), + ), ), routeSettings: const RouteSettings(name: LoginModal.name), ), @@ -267,15 +301,9 @@ void main() { await tester.ensureVisible(find.byKey(loginButtonKey)); await tester.tap(find.byKey(loginButtonKey)); - await tester.pumpAndSettle(); - expect(find.byType(LoginWithEmailPage), findsOneWidget); - appStateController.add(AppState.onboardingRequired(user)); - await tester.pump(); - await tester.pumpAndSettle(); - expect(find.byType(LoginWithEmailPage), findsNothing); - expect(find.byType(LoginForm), findsNothing); + verify(() => goRouter.goNamed(LoginWithEmailPage.routePath)).called(1); }); }); }); diff --git a/flutter_news_example/test/login/widgets/login_with_email_form_test.dart b/flutter_news_example/test/login/widgets/login_with_email_form_test.dart index 7fd3c6adf..dd3e06177 100644 --- a/flutter_news_example/test/login/widgets/login_with_email_form_test.dart +++ b/flutter_news_example/test/login/widgets/login_with_email_form_test.dart @@ -10,6 +10,7 @@ import 'package:flutter_news_example/magic_link_prompt/magic_link_prompt.dart'; import 'package:flutter_news_example/terms_of_service/terms_of_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:form_inputs/form_inputs.dart'; +import 'package:go_router/go_router.dart'; import 'package:mockingjay/mockingjay.dart'; import 'package:user_repository/user_repository.dart'; @@ -22,6 +23,8 @@ class MockLoginBloc extends MockBloc class MockEmail extends Mock implements Email {} +class MockGoRouter extends Mock implements GoRouter {} + void main() { const nextButtonKey = Key('loginWithEmailForm_nextButton'); const emailInputKey = Key('loginWithEmailForm_emailInput_textField'); @@ -36,10 +39,12 @@ void main() { const invalidTestEmail = 'test@g'; late LoginBloc loginBloc; + late GoRouter goRouter; group('LoginWithEmailForm', () { setUp(() { loginBloc = MockLoginBloc(); + goRouter = MockGoRouter(); when(() => loginBloc.state).thenReturn(const LoginState()); }); @@ -232,14 +237,28 @@ void main() { initialState: const LoginState(), ); + when( + () => goRouter.goNamed( + MagicLinkPromptPage.routePath, + queryParameters: {'email': ''}, + ), + ).thenAnswer((_) {}); + await tester.pumpApp( BlocProvider.value( value: loginBloc, - child: const LoginWithEmailForm(), + child: InheritedGoRouter( + goRouter: goRouter, + child: const LoginWithEmailForm(), + ), ), ); - await tester.pump(); - expect(find.byType(MagicLinkPromptPage), findsOneWidget); + verify( + () => goRouter.goNamed( + MagicLinkPromptPage.routePath, + queryParameters: {'email': ''}, + ), + ).called(1); }); }); diff --git a/flutter_news_example/test/magic_link_prompt/view/magic_link_prompt_page_test.dart b/flutter_news_example/test/magic_link_prompt/view/magic_link_prompt_page_test.dart index c6d9ba080..f4677ef36 100644 --- a/flutter_news_example/test/magic_link_prompt/view/magic_link_prompt_page_test.dart +++ b/flutter_news_example/test/magic_link_prompt/view/magic_link_prompt_page_test.dart @@ -3,20 +3,34 @@ import 'package:flutter/material.dart'; import 'package:flutter_news_example/magic_link_prompt/magic_link_prompt.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mockingjay/mockingjay.dart'; import '../../helpers/helpers.dart'; +class _MockGoRouterState extends Mock implements GoRouterState {} + +class _MockBuildContext extends Mock implements BuildContext {} + void main() { const testEmail = 'testEmail@gmail.com'; const magicLinkPromptCloseIconKey = Key('magicLinkPrompt_closeIcon'); + late GoRouterState goRouterState; + late BuildContext context; + + setUp(() { + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); + }); group('MagicLinkPromptPage', () { - test('has a route', () { - expect( - MagicLinkPromptPage.route(email: testEmail), - isA>(), - ); + testWidgets('routeBuilder builds a MagicLinkPromptPage', (tester) async { + when(() => goRouterState.uri) + .thenReturn(Uri(queryParameters: {'email': 'email'})); + + final page = MagicLinkPromptPage.routeBuilder(context, goRouterState); + + expect(page, isA()); }); testWidgets('renders a MagicLinkPromptView', (tester) async { @@ -26,28 +40,6 @@ void main() { expect(find.byType(MagicLinkPromptView), findsOneWidget); }); - testWidgets('router returns a valid navigation route', (tester) async { - await tester.pumpApp( - Scaffold( - body: Builder( - builder: (context) { - return ElevatedButton( - onPressed: () { - Navigator.of(context) - .push(MagicLinkPromptPage.route(email: testEmail)); - }, - child: const Text('Tap me'), - ); - }, - ), - ), - ); - await tester.tap(find.text('Tap me')); - await tester.pumpAndSettle(); - - expect(find.byType(MagicLinkPromptPage), findsOneWidget); - }); - group('navigates', () { testWidgets('back when pressed on close icon', (tester) async { final navigator = MockNavigator(); diff --git a/flutter_news_example/test/network_error/network_error_test.dart b/flutter_news_example/test/network_error/network_error_test.dart new file mode 100644 index 000000000..b02a87c45 --- /dev/null +++ b/flutter_news_example/test/network_error/network_error_test.dart @@ -0,0 +1,82 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_news_example/network_error/network_error.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../helpers/helpers.dart'; + +class MockGoRouter extends Mock implements GoRouter {} + +class _MockGoRouterState extends Mock implements GoRouterState {} + +class _MockBuildContext extends Mock implements BuildContext {} + +void main() { + const tapMeText = 'Tap Me'; + late GoRouter goRouter; + late GoRouterState goRouterState; + late BuildContext context; + + setUpAll(() { + goRouter = MockGoRouter(); + when(() => goRouter.goNamed(NetworkErrorPage.routePath)).thenAnswer((_) {}); + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); + }); + + group('NetworkError', () { + testWidgets('builds a NetworkErrorPage', (tester) async { + final page = NetworkErrorPage.routeBuilder(context, goRouterState); + + expect(page, isA()); + }); + + testWidgets('renders correctly', (tester) async { + await tester.pumpApp(NetworkError()); + + expect(find.byType(NetworkError), findsOneWidget); + }); + + testWidgets('navigates to network error page routePath', (tester) async { + await tester.pumpApp( + InheritedGoRouter( + goRouter: goRouter, + child: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + context.goNamed(NetworkErrorPage.routePath); + }, + child: const Text(tapMeText), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text(tapMeText)); + + verify(() => goRouter.goNamed(NetworkErrorPage.routePath)).called(1); + }); + }); + + testWidgets('calls onRetry function when button pressed', (tester) async { + var retryPressed = false; + await tester.pumpApp( + NetworkError( + onRetry: () { + retryPressed = true; + }, + ), + ); + + await tester.tap(find.byType(ElevatedButton)); + + expect(retryPressed, isTrue); + }); +} diff --git a/flutter_news_example/test/network_error/view/network_error_test.dart b/flutter_news_example/test/network_error/view/network_error_test.dart deleted file mode 100644 index 1c22ea639..000000000 --- a/flutter_news_example/test/network_error/view/network_error_test.dart +++ /dev/null @@ -1,41 +0,0 @@ -// ignore_for_file: prefer_const_constructors - -import 'package:flutter/material.dart'; -import 'package:flutter_news_example/network_error/network_error.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../helpers/helpers.dart'; - -void main() { - const tapMeText = 'Tap Me'; - - group('NetworkError', () { - testWidgets('renders correctly', (tester) async { - await tester.pumpApp(NetworkError()); - - expect(find.byType(NetworkError), findsOneWidget); - }); - - testWidgets('router returns a valid navigation route', (tester) async { - await tester.pumpApp( - Scaffold( - body: Builder( - builder: (context) { - return ElevatedButton( - onPressed: () { - Navigator.of(context).push(NetworkError.route()); - }, - child: const Text(tapMeText), - ); - }, - ), - ), - ); - - await tester.tap(find.text(tapMeText)); - await tester.pumpAndSettle(); - - expect(find.byType(NetworkError), findsOneWidget); - }); - }); -} diff --git a/flutter_news_example/test/notification_preferences/view/notification_preferences_page_test.dart b/flutter_news_example/test/notification_preferences/view/notification_preferences_page_test.dart index c564e04f2..59cadaab1 100644 --- a/flutter_news_example/test/notification_preferences/view/notification_preferences_page_test.dart +++ b/flutter_news_example/test/notification_preferences/view/notification_preferences_page_test.dart @@ -2,11 +2,12 @@ import 'package:app_ui/app_ui.dart'; import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_example/categories/categories.dart'; import 'package:flutter_news_example/notification_preferences/notification_preferences.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:news_repository/news_repository.dart'; import 'package:notifications_repository/notifications_repository.dart'; @@ -21,11 +22,17 @@ class MockNotificationPreferencesRepository extends Mock class MockCategoriesBloc extends Mock implements CategoriesBloc {} +class _MockGoRouterState extends Mock implements GoRouterState {} + +class _MockBuildContext extends Mock implements BuildContext {} + void main() { final NotificationPreferencesBloc bloc = MockNotificationPreferencesBloc(); final NotificationsRepository repository = MockNotificationPreferencesRepository(); final CategoriesBloc categoryBloc = MockCategoriesBloc(); + late GoRouterState goRouterState; + late BuildContext context; final entertainmentCategory = Category( id: 'entertainment', @@ -33,17 +40,23 @@ void main() { ); final healthCategory = Category(id: 'health', name: 'Health'); + setUp(() { + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); + }); + group('NotificationPreferencesPage', () { final populatedState = CategoriesState( status: CategoriesStatus.populated, categories: [entertainmentCategory, healthCategory], ); - test('has a route', () { - expect( - NotificationPreferencesPage.route(), - isA>(), - ); + testWidgets('routeBuilder builds a NotificationPreferencesPage', + (tester) async { + final page = + NotificationPreferencesPage.routeBuilder(context, goRouterState); + + expect(page, isA()); }); testWidgets('renders NotificationPreferencesView', (tester) async { diff --git a/flutter_news_example/test/onboarding/view/onboarding_page_test.dart b/flutter_news_example/test/onboarding/view/onboarding_page_test.dart index 122bf057b..91fc457a1 100644 --- a/flutter_news_example/test/onboarding/view/onboarding_page_test.dart +++ b/flutter_news_example/test/onboarding/view/onboarding_page_test.dart @@ -1,19 +1,34 @@ // ignore_for_file: prefer_const_constructors import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_news_example/app/app.dart'; import 'package:flutter_news_example/onboarding/onboarding.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; import '../../helpers/helpers.dart'; class MockAppBloc extends MockBloc implements AppBloc {} +class _MockGoRouterState extends Mock implements GoRouterState {} + +class _MockBuildContext extends Mock implements BuildContext {} + void main() { + late GoRouterState goRouterState; + late BuildContext context; + + setUp(() { + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); + }); group('OnboardingPage', () { - test('has a page', () { - expect(OnboardingPage.page(), isA>()); + testWidgets('routeBuilder builds a OnboardingPage', (tester) async { + final page = OnboardingPage.routeBuilder(context, goRouterState); + + expect(page, isA()); }); testWidgets('renders OnboardingView', (tester) async { diff --git a/flutter_news_example/test/slideshow/view/slideshow_page_test.dart b/flutter_news_example/test/slideshow/view/slideshow_page_test.dart index 4ba64e578..0dc713b79 100644 --- a/flutter_news_example/test/slideshow/view/slideshow_page_test.dart +++ b/flutter_news_example/test/slideshow/view/slideshow_page_test.dart @@ -4,14 +4,26 @@ import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_news_example/slideshow/slideshow.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mockingjay/mockingjay.dart'; import 'package:mocktail_image_network/mocktail_image_network.dart'; import 'package:news_blocks/news_blocks.dart'; import '../../helpers/helpers.dart'; +class _MockGoRouterState extends Mock implements GoRouterState {} + +class _MockBuildContext extends Mock implements BuildContext {} + void main() { initMockHydratedStorage(); + late GoRouterState goRouterState; + late BuildContext context; + + setUp(() { + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); + }); group('SlideshowPage', () { const articleId = 'articleId'; @@ -26,14 +38,14 @@ void main() { ); final slideshow = SlideshowBlock(title: 'title', slides: slides); - test('has a route', () { - expect( - SlideshowPage.route( - slideshow: slideshow, - articleId: articleId, - ), - isA>(), - ); + testWidgets('routeBuilder builds a SlideshowPage', (tester) async { + when(() => goRouterState.pathParameters).thenReturn({'id': 'id'}); + when(() => goRouterState.extra) + .thenReturn(const SlideshowBlock(title: 'title', slides: [])); + + final page = SlideshowPage.routeBuilder(context, goRouterState); + + expect(page, isA()); }); testWidgets('renders a SlideshowView', (tester) async { diff --git a/flutter_news_example/test/subscriptions/view/manage_subscription_page_test.dart b/flutter_news_example/test/subscriptions/view/manage_subscription_page_test.dart index ce6cdea3d..8110007e0 100644 --- a/flutter_news_example/test/subscriptions/view/manage_subscription_page_test.dart +++ b/flutter_news_example/test/subscriptions/view/manage_subscription_page_test.dart @@ -5,16 +5,30 @@ import 'package:flutter/material.dart'; import 'package:flutter_news_example/app/app.dart'; import 'package:flutter_news_example/subscriptions/subscriptions.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mockingjay/mockingjay.dart'; import '../../helpers/helpers.dart'; class MockAppBloc extends MockBloc implements AppBloc {} +class _MockGoRouterState extends Mock implements GoRouterState {} + +class _MockBuildContext extends Mock implements BuildContext {} + void main() { + late GoRouterState goRouterState; + late BuildContext context; + + setUp(() { + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); + }); group('ManageSubscriptionPage', () { - test('has a route', () { - expect(ManageSubscriptionPage.route(), isA>()); + testWidgets('routeBuilder builds a ManageSubscriptionPage', (tester) async { + final page = ManageSubscriptionPage.routeBuilder(context, goRouterState); + + expect(page, isA()); }); testWidgets('renders ManageSubscriptionView', (tester) async { diff --git a/flutter_news_example/test/user_profile/view/user_profile_page_test.dart b/flutter_news_example/test/user_profile/view/user_profile_page_test.dart index 8f8744738..bb3957271 100644 --- a/flutter_news_example/test/user_profile/view/user_profile_page_test.dart +++ b/flutter_news_example/test/user_profile/view/user_profile_page_test.dart @@ -11,6 +11,7 @@ import 'package:flutter_news_example/subscriptions/subscriptions.dart'; import 'package:flutter_news_example/terms_of_service/terms_of_service.dart'; import 'package:flutter_news_example/user_profile/user_profile.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:in_app_purchase_repository/in_app_purchase_repository.dart'; import 'package:mockingjay/mockingjay.dart'; import 'package:user_repository/user_repository.dart'; @@ -25,12 +26,28 @@ class MockAnalyticsBloc extends MockBloc class MockAppBloc extends MockBloc implements AppBloc {} +class MockGoRouter extends Mock implements GoRouter {} + +class _MockGoRouterState extends Mock implements GoRouterState {} + +class _MockBuildContext extends Mock implements BuildContext {} + void main() { const termsOfServiceItemKey = Key('userProfilePage_termsOfServiceItem'); + late GoRouter goRouter; + late GoRouterState goRouterState; + late BuildContext context; + + setUp(() { + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); + }); group('UserProfilePage', () { - test('has a route', () { - expect(UserProfilePage.route(), isA>()); + testWidgets('routeBuilder builds a UserProfilePage', (tester) async { + final page = UserProfilePage.routeBuilder(context, goRouterState); + + expect(page, isA()); }); testWidgets('renders UserProfileView', (tester) async { @@ -434,6 +451,9 @@ void main() { }); group('navigates', () { + setUp(() { + goRouter = MockGoRouter(); + }); testWidgets('when tapped on Terms of User & Privacy Policy', (tester) async { await tester.pumpApp( @@ -463,6 +483,9 @@ void main() { 'to ManageSubscriptionPage ' 'when isUserSubscribed is true and ' 'tapped on Manage Subscription', (tester) async { + when(() => goRouter.goNamed(ManageSubscriptionPage.routePath)) + .thenAnswer((_) {}); + final subscribedUser = User( id: '1', email: 'email', @@ -475,9 +498,12 @@ void main() { await tester.pumpApp( appBloc: appBloc, - BlocProvider.value( - value: userProfileBloc, - child: UserProfileView(), + InheritedGoRouter( + goRouter: goRouter, + child: BlocProvider.value( + value: userProfileBloc, + child: UserProfileView(), + ), ), ); @@ -485,18 +511,23 @@ void main() { find.byKey(Key('userProfilePage_subscriptionItem')); await tester.ensureVisible(subscriptionItem); await tester.tap(subscriptionItem); - await tester.pumpAndSettle(); - expect(find.byType(ManageSubscriptionPage), findsOneWidget); + verify(() => goRouter.goNamed(ManageSubscriptionPage.routePath)) + .called(1); }); testWidgets( 'to NotificationPreferencesPage ' 'when tapped on NotificationPreferences', (tester) async { + when(() => goRouter.goNamed(NotificationPreferencesPage.routePath)) + .thenAnswer((_) {}); await tester.pumpApp( - BlocProvider.value( - value: userProfileBloc, - child: UserProfileView(), + InheritedGoRouter( + goRouter: goRouter, + child: BlocProvider.value( + value: userProfileBloc, + child: UserProfileView(), + ), ), ); @@ -513,9 +544,9 @@ void main() { await tester.ensureVisible(subscriptionItem); await tester.tap(subscriptionItem); - await tester.pumpAndSettle(); - expect(find.byType(NotificationPreferencesPage), findsOneWidget); + verify(() => goRouter.goNamed(NotificationPreferencesPage.routePath)) + .called(1); }); }); diff --git a/flutter_news_example/test/user_profile/widgets/user_profile_button_test.dart b/flutter_news_example/test/user_profile/widgets/user_profile_button_test.dart index b42ab2e1e..696b4a5e3 100644 --- a/flutter_news_example/test/user_profile/widgets/user_profile_button_test.dart +++ b/flutter_news_example/test/user_profile/widgets/user_profile_button_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_news_example/login/login.dart'; import 'package:flutter_news_example/user_profile/user_profile.dart'; import 'package:flutter_news_example_api/client.dart' hide User; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:user_repository/user_repository.dart'; @@ -20,13 +21,17 @@ class MockNavigatorObserver extends Mock implements NavigatorObserver {} class MockRoute extends Mock implements Route {} +class MockGoRouter extends Mock implements GoRouter {} + void main() { group('UserProfileButton', () { late AppBloc appBloc; late User user; + late GoRouter goRouter; setUp(() { appBloc = MockAppBloc(); + goRouter = MockGoRouter(); user = User(id: 'id', subscriptionPlan: SubscriptionPlan.none); }); @@ -80,14 +85,17 @@ void main() { ); await tester.pumpApp( - UserProfileButton(), + InheritedGoRouter( + goRouter: goRouter, + child: UserProfileButton(), + ), appBloc: appBloc, ); await tester.tap(find.byType(OpenProfileButton)); await tester.pumpAndSettle(); - expect(find.byType(UserProfilePage), findsOneWidget); + verify(() => goRouter.goNamed(UserProfilePage.routePath)).called(1); }); testWidgets(