From d1291dbbe1fac94d95953ca8ce980022027ea90e Mon Sep 17 00:00:00 2001 From: SofiaRey Date: Fri, 20 Dec 2024 15:34:31 -0300 Subject: [PATCH 1/4] feat: add go router package and routing to the app --- flutter_news_example/api/pubspec.lock | 2 +- .../lib/app/routes/routes.dart | 135 +++++++++- flutter_news_example/lib/app/view/app.dart | 15 +- .../lib/article/view/article_page.dart | 17 +- .../lib/article/widgets/article_content.dart | 14 +- .../article/widgets/article_content_item.dart | 10 +- .../lib/feed/widgets/category_feed.dart | 16 +- .../lib/feed/widgets/category_feed_item.dart | 16 +- .../lib/home/view/home_page.dart | 2 + .../lib/home/view/home_view.dart | 13 + .../lib/login/view/login_with_email_page.dart | 2 + .../lib/login/widgets/login_form.dart | 5 +- .../login/widgets/login_with_email_form.dart | 6 +- .../view/magic_link_prompt_page.dart | 8 +- .../lib/network_error/view/network_error.dart | 37 ++- .../view/notification_preferences_page.dart | 1 + .../lib/onboarding/view/onboarding_page.dart | 2 + .../lib/slideshow/view/slideshow_page.dart | 2 + .../view/manage_subscription_page.dart | 2 + .../user_profile/view/user_profile_page.dart | 11 +- .../widgets/user_profile_button.dart | 3 +- flutter_news_example/pubspec.lock | 16 +- flutter_news_example/pubspec.yaml | 1 + .../test/app/routes/routes_test.dart | 48 ---- .../test/app/view/app_test.dart | 14 - .../test/article/view/article_page_test.dart | 31 ++- .../widgets/article_content_item_test.dart | 51 +++- .../article/widgets/article_content_test.dart | 67 ++--- .../feed/widgets/category_feed_item_test.dart | 239 ++++++++++-------- .../test/feed/widgets/category_feed_test.dart | 60 ++--- .../test/home/view/home_page_test.dart | 7 +- .../test/login/widgets/login_form_test.dart | 76 ++++-- .../widgets/login_with_email_form_test.dart | 25 +- .../view/magic_link_prompt_page_test.dart | 29 --- .../view/network_error_test.dart | 41 --- .../view/user_profile_page_test.dart | 38 ++- .../widgets/user_profile_button_test.dart | 12 +- 37 files changed, 598 insertions(+), 476 deletions(-) delete mode 100644 flutter_news_example/test/app/routes/routes_test.dart delete mode 100644 flutter_news_example/test/network_error/view/network_error_test.dart 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..35a3dc996 100644 --- a/flutter_news_example/lib/app/routes/routes.dart +++ b/flutter_news_example/lib/app/routes/routes.dart @@ -1,17 +1,126 @@ 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'; +import 'package:news_blocks/news_blocks.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: (BuildContext context, GoRouterState state) { + return const HomePage(); + }, + routes: [ + GoRoute( + name: NetworkErrorPage.routePath, + path: NetworkErrorPage.routePath, + builder: (BuildContext context, GoRouterState state) { + final onRetry = state.extra as VoidCallback?; + return NetworkError(onRetry: onRetry); + }, + ), + GoRoute( + name: LoginWithEmailPage.routePath, + path: LoginWithEmailPage.routePath, + builder: (BuildContext context, GoRouterState state) { + return const LoginWithEmailPage(); + }, + routes: [ + GoRoute( + name: MagicLinkPromptPage.routePath, + path: MagicLinkPromptPage.routePath, + builder: (BuildContext context, GoRouterState state) { + return MagicLinkPromptPage( + email: state.uri.queryParameters['email']!, + ); + }, + ), + ], + ), + GoRoute( + name: ArticlePage.routeName, + path: ArticlePage.routePath, + builder: (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.' + '${state.uri.queryParameters['interstitialAdBehavior']}', + ) + : null; + + if (id == null) { + throw Exception('Missing required "id" parameter'); + } + + return ArticlePage( + id: id, + isVideoArticle: isVideoArticle, + interstitialAdBehavior: + interstitialAdBehavior ?? InterstitialAdBehavior.onOpen, + ); + }, + routes: [ + GoRoute( + name: SlideshowPage.routePath, + path: SlideshowPage.routePath, + builder: (BuildContext context, GoRouterState state) { + return SlideshowPage( + slideshow: state.extra! as SlideshowBlock, + articleId: state.pathParameters['id']!, + ); + }, + ), + ], + ), + GoRoute( + name: UserProfilePage.routePath, + path: UserProfilePage.routePath, + builder: (BuildContext context, GoRouterState state) { + return const UserProfilePage(); + }, + routes: [ + GoRoute( + name: ManageSubscriptionPage.routePath, + path: ManageSubscriptionPage.routePath, + builder: (BuildContext context, GoRouterState state) { + return const ManageSubscriptionPage(); + }, + ), + GoRoute( + name: NotificationPreferencesPage.routePath, + path: NotificationPreferencesPage.routePath, + builder: (BuildContext context, GoRouterState state) { + return const NotificationPreferencesPage(); + }, + ), + ], + ), + ], + ), + GoRoute( + name: OnboardingPage.routePath, + path: OnboardingPage.routePath, + builder: (BuildContext context, GoRouterState state) { + return const OnboardingPage(); + }, + ), + ], +); diff --git a/flutter_news_example/lib/app/view/app.dart b/flutter_news_example/lib/app/view/app.dart index a616294a6..6a438a17d 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'; @@ -106,18 +105,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/view/article_page.dart b/flutter_news_example/lib/article/view/article_page.dart index 1d56f7617..57e24a3bd 100644 --- a/flutter_news_example/lib/article/view/article_page.dart +++ b/flutter_news_example/lib/article/view/article_page.dart @@ -28,6 +28,9 @@ class ArticlePage extends StatelessWidget { super.key, }); + static const routeName = 'article'; + static const routePath = 'article/:id'; + /// The id of the requested article. final String id; @@ -38,20 +41,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 36bc8824a..444470e6b 100644 --- a/flutter_news_example/lib/article/widgets/article_content.dart +++ b/flutter_news_example/lib/article/widgets/article_content.dart @@ -7,6 +7,7 @@ import 'package:flutter_news_example/article/article.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 { @@ -29,13 +30,12 @@ class ArticleContent extends StatelessWidget { child: BlocListener( listener: (context, state) { if (state.status == ArticleStatus.failure && state.content.isEmpty) { - Navigator.of(context).push( - NetworkError.route( - onRetry: () { - context.read().add(const ArticleRequested()); - Navigator.of(context).pop(); - }, - ), + context.goNamed( + NetworkErrorPage.routePath, + extra: () { + context.read().add(const ArticleRequested()); + Navigator.of(context).pop(); + }, ); } 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 db333e6b6..56cfa00f7 100644 --- a/flutter_news_example/lib/article/widgets/article_content_item.dart +++ b/flutter_news_example/lib/article/widgets/article_content_item.dart @@ -3,6 +3,7 @@ import 'package:flutter_news_example/article/article.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'; @@ -86,11 +87,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 17f56142c..5549a7a5d 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({ @@ -31,15 +32,12 @@ class CategoryFeed extends StatelessWidget { return BlocListener( listener: (context, state) { 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(); - }, - ), + context.goNamed( + NetworkErrorPage.routePath, + extra: () { + context.read().add(FeedRequested(category: category)); + Navigator.of(context).pop(); + }, ); } }, 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 0f24c547d..eed9e0e91 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'; @@ -77,13 +78,20 @@ class CategoryFeedItem extends StatelessWidget { BuildContext context, BlockAction action, ) async { + final router = GoRouter.of(context); 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 19e297c35..30629c613 100644 --- a/flutter_news_example/lib/home/view/home_page.dart +++ b/flutter_news_example/lib/home/view/home_page.dart @@ -8,6 +8,8 @@ import 'package:news_repository/news_repository.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); + static const routePath = '/'; + static Page page() => const MaterialPage(child: HomePage()); @override 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..a3f064072 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 @@ -7,6 +7,8 @@ import 'package:user_repository/user_repository.dart'; class LoginWithEmailPage extends StatelessWidget { const LoginWithEmailPage({super.key}); + static const routePath = 'login-with-email'; + static Route route() => MaterialPageRoute(builder: (_) => const LoginWithEmailPage()); 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..db4d1941f 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 @@ -6,13 +6,9 @@ import 'package:flutter_news_example/magic_link_prompt/magic_link_prompt.dart'; class MagicLinkPromptPage extends StatelessWidget { const MagicLinkPromptPage({required this.email, super.key}); - final String email; + static const routePath = 'magic-link-prompt'; - static Route route({required String email}) { - return MaterialPageRoute( - builder: (_) => MagicLinkPromptPage(email: email), - ); - } + final String email; @override Widget build(BuildContext context) { 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..675b4634d 100644 --- a/flutter_news_example/lib/network_error/view/network_error.dart +++ b/flutter_news_example/lib/network_error/view/network_error.dart @@ -3,29 +3,38 @@ import 'package:flutter/material.dart'; import 'package:flutter_news_example/l10n/l10n.dart'; /// {@template network_error} -/// A network error alert. +/// A network error alert page. /// {@endtemplate} -class NetworkError extends StatelessWidget { +class NetworkErrorPage extends StatelessWidget { /// {@macro network_error} - const NetworkError({super.key, this.onRetry}); + const NetworkErrorPage({super.key, this.onRetry}); /// 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), - ), + static const routePath = 'network-error'; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(leading: const AppBackButton()), + body: Center( + child: NetworkError(onRetry: onRetry), ), ); } +} + +/// {@template network_error} +/// A network error alert. +/// {@endtemplate} +class NetworkError extends StatelessWidget { + /// {@macro network_error} + const NetworkError({super.key, this.onRetry}); + + /// An optional callback which is invoked when the retry button is pressed. + final VoidCallback? onRetry; @override Widget build(BuildContext context) { 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..f9f9ab855 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 @@ -9,6 +9,7 @@ import 'package:notifications_repository/notifications_repository.dart'; class NotificationPreferencesPage extends StatelessWidget { const NotificationPreferencesPage({super.key}); + static const routePath = 'notification-preferences'; static MaterialPageRoute route() { return MaterialPageRoute( builder: (_) => const NotificationPreferencesPage(), diff --git a/flutter_news_example/lib/onboarding/view/onboarding_page.dart b/flutter_news_example/lib/onboarding/view/onboarding_page.dart index 733ba02ad..bee971a34 100644 --- a/flutter_news_example/lib/onboarding/view/onboarding_page.dart +++ b/flutter_news_example/lib/onboarding/view/onboarding_page.dart @@ -8,6 +8,8 @@ import 'package:notifications_repository/notifications_repository.dart'; class OnboardingPage extends StatelessWidget { const OnboardingPage({super.key}); + static const routePath = '/onboarding'; + static Page page() => const MaterialPage(child: OnboardingPage()); @override diff --git a/flutter_news_example/lib/slideshow/view/slideshow_page.dart b/flutter_news_example/lib/slideshow/view/slideshow_page.dart index 02ba024b9..8026d8413 100644 --- a/flutter_news_example/lib/slideshow/view/slideshow_page.dart +++ b/flutter_news_example/lib/slideshow/view/slideshow_page.dart @@ -13,6 +13,8 @@ class SlideshowPage extends StatelessWidget { super.key, }); + static const String routePath = 'slideshow'; + static Route route({ required SlideshowBlock slideshow, required 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..5a5389c77 100644 --- a/flutter_news_example/lib/subscriptions/view/manage_subscription_page.dart +++ b/flutter_news_example/lib/subscriptions/view/manage_subscription_page.dart @@ -5,6 +5,8 @@ import 'package:flutter_news_example/l10n/l10n.dart'; class ManageSubscriptionPage extends StatelessWidget { const ManageSubscriptionPage({super.key}); + static const routePath = 'manage-subscription'; + static MaterialPageRoute route() { return MaterialPageRoute( builder: (_) => const ManageSubscriptionPage(), 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..b4ac85bf5 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,12 +9,15 @@ 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 const routePath = 'profile'; + static MaterialPageRoute route() { return MaterialPageRoute(builder: (_) => const UserProfilePage()); } @@ -120,8 +123,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 +155,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 b96a9c771..8b3dcba93 100644 --- a/flutter_news_example/pubspec.lock +++ b/flutter_news_example/pubspec.lock @@ -342,10 +342,10 @@ packages: dependency: "direct main" description: name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.0.7" facebook_auth_desktop: dependency: transitive description: @@ -693,10 +693,10 @@ packages: dependency: transitive description: name: formz - sha256: a58eb48d84685b7ffafac1d143bf47d585bf54c7db89fe81c175dfd6e53201c7 + sha256: "382c7be452ff76833f9efa0b2333fec3a576393f6d2c7801725bed502f3d40c3" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.8.0" frontend_server_client: dependency: transitive description: @@ -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 80550eb43..870008d62 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.0.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 11d0507a4..0d3bc6340 100644 --- a/flutter_news_example/test/app/view/app_test.dart +++ b/flutter_news_example/test/app/view/app_test.dart @@ -7,7 +7,6 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_news_example/analytics/analytics.dart' as analytics; 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'; import 'package:in_app_purchase_repository/in_app_purchase_repository.dart'; import 'package:mocktail/mocktail.dart'; @@ -103,19 +102,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 { when(() => appBloc.state).thenReturn(AppState.unauthenticated()); await tester.pumpApp( 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..dc9e5a0c2 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,18 @@ class MockFullScreenAdsBloc class MockRewardItem extends Mock implements ads.RewardItem {} +class MockGoRouter extends Mock implements GoRouter {} + void main() { initMockHydratedStorage(); group('ArticlePage', () { + late GoRouter goRouter; late FullScreenAdsBloc fullScreenAdsBloc; late AppBloc appBloc; setUp(() { + goRouter = MockGoRouter(); fullScreenAdsBloc = MockFullScreenAdsBloc(); appBloc = MockAppBloc(); whenListen( @@ -46,17 +51,16 @@ void main() { ); }); - test('has a route', () { - expect(ArticlePage.route(id: 'id'), 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 +69,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 10b07da4a..95dcf2711 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 ' @@ -225,34 +234,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 5e67cef12..5e4a5e4cf 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(); @@ -250,9 +253,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 { final block = PostLargeBlock( @@ -266,23 +290,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 { @@ -298,24 +319,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 { @@ -329,24 +347,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 { @@ -366,11 +381,9 @@ void main() { ); await mockNetworkImages(() async { - await tester.pumpApp( - CustomScrollView( - slivers: [CategoryFeedItem(block: block)], - ), - articleRepository: articleRepository, + await pumpWidget( + tester, + CustomScrollView(slivers: [CategoryFeedItem(block: block)]), ); }); @@ -378,14 +391,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); }); }); @@ -393,6 +405,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 { final block = PostLargeBlock( @@ -407,27 +440,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 { @@ -443,27 +474,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 { @@ -478,27 +507,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 { @@ -517,10 +544,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 @@ -532,21 +558,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.top; @@ -558,13 +587,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 dc573a218..dc797b8eb 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.top); - registerFallbackValue(NetworkError.route()); }); setUp(() { @@ -135,52 +139,36 @@ 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), + when( + () => goRouter.goNamed( + NetworkErrorPage.routePath, + extra: any(named: 'extra'), ), - navigatorObserver: navigatorObserver, - ); - - verify(() => navigatorObserver.didPush(any(), any())); - - expect( - find.ancestor( - of: find.byType(NetworkError), - matching: find.byType(Scaffold), - ), - findsOneWidget, - ); + ).thenAnswer((_) async {}); }); - 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: CategoryFeed(category: category), + ), ), - navigatorObserver: navigatorObserver, ); + } - verify(() => navigatorObserver.didPush(any(), any())); - - await tester.ensureVisible(find.text(networkErrorButtonText)); - - await tester.pump(Duration(seconds: 1)); - await tester.tap(find.textContaining(networkErrorButtonText)); + testWidgets('calls goNamed to Network Error page', (tester) async { + await pumpWidget(tester, CategoryFeed(category: category)); - verify(() => feedBloc.add(any(that: isA()))) - .called(1); - verify(() => navigatorObserver.didPop(any(), any())); + verify( + () => goRouter.goNamed( + NetworkErrorPage.routePath, + extra: any(named: 'extra'), + ), + ).called(1); }); }); 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 33c0a95e4..cbf05183c 100644 --- a/flutter_news_example/test/home/view/home_page_test.dart +++ b/flutter_news_example/test/home/view/home_page_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_news_example/feed/feed.dart'; import 'package:flutter_news_example/home/home.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'; @@ -12,13 +13,17 @@ import '../../helpers/helpers.dart'; class MockNewsRepository extends Mock implements NewsRepository {} +class MockGoRouter extends Mock implements GoRouter {} + void main() { initMockHydratedStorage(); late NewsRepository newsRepository; + late GoRouter router; setUp(() { newsRepository = MockNewsRepository(); + router = MockGoRouter(); when(newsRepository.getCategories).thenAnswer( (_) async => CategoriesResponse( @@ -39,7 +44,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/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..8964bbb43 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 @@ -12,13 +12,6 @@ void main() { const magicLinkPromptCloseIconKey = Key('magicLinkPrompt_closeIcon'); group('MagicLinkPromptPage', () { - test('has a route', () { - expect( - MagicLinkPromptPage.route(email: testEmail), - isA>(), - ); - }); - testWidgets('renders a MagicLinkPromptView', (tester) async { await tester.pumpApp( const MagicLinkPromptPage(email: testEmail), @@ -26,28 +19,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/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/user_profile/view/user_profile_page_test.dart b/flutter_news_example/test/user_profile/view/user_profile_page_test.dart index 8f8744738..fc71e45ea 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,8 +26,11 @@ class MockAnalyticsBloc extends MockBloc class MockAppBloc extends MockBloc implements AppBloc {} +class MockGoRouter extends Mock implements GoRouter {} + void main() { const termsOfServiceItemKey = Key('userProfilePage_termsOfServiceItem'); + late GoRouter goRouter; group('UserProfilePage', () { test('has a route', () { @@ -434,6 +438,9 @@ void main() { }); group('navigates', () { + setUp(() { + goRouter = MockGoRouter(); + }); testWidgets('when tapped on Terms of User & Privacy Policy', (tester) async { await tester.pumpApp( @@ -463,6 +470,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 +485,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 +498,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 +531,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( From 6c51b3e02e888dfffb183f14776efd262fd1c31c Mon Sep 17 00:00:00 2001 From: SofiaRey Date: Mon, 6 Jan 2025 14:53:31 -0300 Subject: [PATCH 2/4] feat: add network error tests --- .../network_error/network_error_test.dart | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 flutter_news_example/test/network_error/network_error_test.dart 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..fb4c05a5b --- /dev/null +++ b/flutter_news_example/test/network_error/network_error_test.dart @@ -0,0 +1,68 @@ +// 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 {} + +void main() { + const tapMeText = 'Tap Me'; + late GoRouter goRouter; + + setUpAll(() { + goRouter = MockGoRouter(); + when(() => goRouter.goNamed(NetworkErrorPage.routePath)).thenAnswer((_) {}); + }); + + group('NetworkError', () { + 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); + }); +} From 5e4412379aabc0cd2981732e8bba6852067d7db1 Mon Sep 17 00:00:00 2001 From: SofiaRey Date: Mon, 6 Jan 2025 15:07:32 -0300 Subject: [PATCH 3/4] fix: flutter analysis fix --- flutter_news_example/lib/app/routes/routes.dart | 1 + flutter_news_example/lib/feed/widgets/category_feed_item.dart | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter_news_example/lib/app/routes/routes.dart b/flutter_news_example/lib/app/routes/routes.dart index 35a3dc996..ac30713fc 100644 --- a/flutter_news_example/lib/app/routes/routes.dart +++ b/flutter_news_example/lib/app/routes/routes.dart @@ -62,6 +62,7 @@ final GoRouter router = GoRouter( (e) => e.toString() == 'InterstitialAdBehavior.' + // ignore: lines_longer_than_80_chars '${state.uri.queryParameters['interstitialAdBehavior']}', ) : null; 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 2934c56d3..b05e1cf19 100644 --- a/flutter_news_example/lib/feed/widgets/category_feed_item.dart +++ b/flutter_news_example/lib/feed/widgets/category_feed_item.dart @@ -88,7 +88,6 @@ class CategoryFeedItem extends StatelessWidget { BuildContext context, BlockAction action, ) async { - final router = GoRouter.of(context); if (action is NavigateToArticleAction) { context.goNamed( ArticlePage.routeName, From 5edd77a022be7f8fe455cd0401afe8259eb869ae Mon Sep 17 00:00:00 2001 From: SofiaRey Date: Wed, 8 Jan 2025 13:47:01 -0300 Subject: [PATCH 4/4] fix: build routes refactor and tests added --- .../lib/app/routes/routes.dart | 74 +++---------------- .../lib/article/bloc/article_bloc.dart | 2 +- .../lib/article/view/article_page.dart | 34 +++++++++ .../lib/article/widgets/article_content.dart | 11 ++- .../lib/feed/widgets/category_feed.dart | 12 +-- .../lib/home/view/home_page.dart | 7 +- .../lib/login/view/login_with_email_page.dart | 8 +- .../view/magic_link_prompt_page.dart | 9 +++ .../lib/network_error/view/network_error.dart | 20 +++-- .../view/notification_preferences_page.dart | 12 +-- .../lib/onboarding/view/onboarding_page.dart | 7 +- .../lib/slideshow/view/slideshow_page.dart | 20 +++-- .../view/manage_subscription_page.dart | 11 +-- .../user_profile/view/user_profile_page.dart | 8 +- .../test/article/view/article_page_test.dart | 18 +++++ .../test/feed/widgets/category_feed_test.dart | 19 +++-- .../test/home/view/home_page_test.dart | 60 ++++++++++++++- .../test/home/view/home_view_test.dart | 37 +++++++++- .../view/login_with_email_page_test.dart | 18 ++++- .../view/magic_link_prompt_page_test.dart | 21 ++++++ .../network_error/network_error_test.dart | 14 ++++ .../notification_preferences_page_test.dart | 25 +++++-- .../onboarding/view/onboarding_page_test.dart | 21 +++++- .../slideshow/view/slideshow_page_test.dart | 28 +++++-- .../view/manage_subscription_page_test.dart | 18 ++++- .../view/user_profile_page_test.dart | 17 ++++- 26 files changed, 385 insertions(+), 146 deletions(-) diff --git a/flutter_news_example/lib/app/routes/routes.dart b/flutter_news_example/lib/app/routes/routes.dart index ac30713fc..47924abca 100644 --- a/flutter_news_example/lib/app/routes/routes.dart +++ b/flutter_news_example/lib/app/routes/routes.dart @@ -1,4 +1,3 @@ -import 'package:flutter/widgets.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'; @@ -10,107 +9,56 @@ 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'; -import 'package:news_blocks/news_blocks.dart'; final GoRouter router = GoRouter( routes: [ GoRoute( path: HomePage.routePath, - builder: (BuildContext context, GoRouterState state) { - return const HomePage(); - }, + builder: HomePage.routeBuilder, routes: [ GoRoute( name: NetworkErrorPage.routePath, path: NetworkErrorPage.routePath, - builder: (BuildContext context, GoRouterState state) { - final onRetry = state.extra as VoidCallback?; - return NetworkError(onRetry: onRetry); - }, + builder: NetworkErrorPage.routeBuilder, ), GoRoute( name: LoginWithEmailPage.routePath, path: LoginWithEmailPage.routePath, - builder: (BuildContext context, GoRouterState state) { - return const LoginWithEmailPage(); - }, + builder: LoginWithEmailPage.routeBuilder, routes: [ GoRoute( name: MagicLinkPromptPage.routePath, path: MagicLinkPromptPage.routePath, - builder: (BuildContext context, GoRouterState state) { - return MagicLinkPromptPage( - email: state.uri.queryParameters['email']!, - ); - }, + builder: MagicLinkPromptPage.routeBuilder, ), ], ), GoRoute( name: ArticlePage.routeName, path: ArticlePage.routePath, - builder: (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, - ); - }, + builder: ArticlePage.routeBuilder, routes: [ GoRoute( name: SlideshowPage.routePath, path: SlideshowPage.routePath, - builder: (BuildContext context, GoRouterState state) { - return SlideshowPage( - slideshow: state.extra! as SlideshowBlock, - articleId: state.pathParameters['id']!, - ); - }, + builder: SlideshowPage.routeBuilder, ), ], ), GoRoute( name: UserProfilePage.routePath, path: UserProfilePage.routePath, - builder: (BuildContext context, GoRouterState state) { - return const UserProfilePage(); - }, + builder: UserProfilePage.routeBuilder, routes: [ GoRoute( name: ManageSubscriptionPage.routePath, path: ManageSubscriptionPage.routePath, - builder: (BuildContext context, GoRouterState state) { - return const ManageSubscriptionPage(); - }, + builder: ManageSubscriptionPage.routeBuilder, ), GoRoute( name: NotificationPreferencesPage.routePath, path: NotificationPreferencesPage.routePath, - builder: (BuildContext context, GoRouterState state) { - return const NotificationPreferencesPage(); - }, + builder: NotificationPreferencesPage.routeBuilder, ), ], ), @@ -119,9 +67,7 @@ final GoRouter router = GoRouter( GoRoute( name: OnboardingPage.routePath, path: OnboardingPage.routePath, - builder: (BuildContext context, GoRouterState state) { - return const OnboardingPage(); - }, + builder: OnboardingPage.routeBuilder, ), ], ); 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 57e24a3bd..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'; @@ -31,6 +32,39 @@ class ArticlePage extends StatelessWidget { 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; diff --git a/flutter_news_example/lib/article/widgets/article_content.dart b/flutter_news_example/lib/article/widgets/article_content.dart index 2b770c477..6d0d2d4b9 100644 --- a/flutter_news_example/lib/article/widgets/article_content.dart +++ b/flutter_news_example/lib/article/widgets/article_content.dart @@ -35,15 +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) { - context.goNamed( + await context.pushNamed( NetworkErrorPage.routePath, - extra: () { - context.read().add(const ArticleRequested()); - Navigator.of(context).pop(); - }, ); + if (context.mounted) { + context.read().add(const ArticleRequested()); + } } else if (state.status == ArticleStatus.shareFailure) { _handleShareFailure(context); } diff --git a/flutter_news_example/lib/feed/widgets/category_feed.dart b/flutter_news_example/lib/feed/widgets/category_feed.dart index e413275df..ef0458f2d 100644 --- a/flutter_news_example/lib/feed/widgets/category_feed.dart +++ b/flutter_news_example/lib/feed/widgets/category_feed.dart @@ -30,15 +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) { - context.goNamed( + await context.pushNamed( NetworkErrorPage.routePath, - extra: () { - context.read().add(FeedRequested(category: category)); - Navigator.of(context).pop(); - }, ); + // 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/home/view/home_page.dart b/flutter_news_example/lib/home/view/home_page.dart index 192c6a09e..815d8cd88 100644 --- a/flutter_news_example/lib/home/view/home_page.dart +++ b/flutter_news_example/lib/home/view/home_page.dart @@ -2,6 +2,7 @@ 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 { @@ -9,7 +10,11 @@ class HomePage extends StatelessWidget { static const routePath = '/'; - static Page page() => const MaterialPage(child: HomePage()); + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + const HomePage(); @override Widget build(BuildContext context) { 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 a3f064072..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,6 +2,7 @@ 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 { @@ -9,8 +10,11 @@ class LoginWithEmailPage extends StatelessWidget { static const routePath = 'login-with-email'; - static Route route() => - MaterialPageRoute(builder: (_) => const LoginWithEmailPage()); + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + const LoginWithEmailPage(); @override Widget build(BuildContext 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 db4d1941f..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,6 +2,7 @@ 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}); @@ -10,6 +11,14 @@ class MagicLinkPromptPage extends StatelessWidget { final String email; + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) { + final email = state.uri.queryParameters['email']!; + return MagicLinkPromptPage(email: email); + } + @override Widget build(BuildContext context) { return Scaffold( 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 675b4634d..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,26 +1,32 @@ 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, this.onRetry}); - - /// An optional callback which is invoked when the retry button is pressed. - final VoidCallback? onRetry; + 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: Center( - child: NetworkError(onRetry: onRetry), + body: const Center( + child: NetworkError(), ), ); } @@ -59,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 f9f9ab855..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,6 +3,7 @@ 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'; @@ -10,11 +11,12 @@ class NotificationPreferencesPage extends StatelessWidget { const NotificationPreferencesPage({super.key}); static const routePath = 'notification-preferences'; - static MaterialPageRoute route() { - return MaterialPageRoute( - builder: (_) => const NotificationPreferencesPage(), - ); - } + + 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 bee971a34..efa8f3457 100644 --- a/flutter_news_example/lib/onboarding/view/onboarding_page.dart +++ b/flutter_news_example/lib/onboarding/view/onboarding_page.dart @@ -3,6 +3,7 @@ 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 { @@ -10,7 +11,11 @@ class OnboardingPage extends StatelessWidget { static const routePath = '/onboarding'; - static Page page() => const MaterialPage(child: OnboardingPage()); + 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 8026d8413..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'; @@ -15,17 +16,14 @@ class SlideshowPage extends StatelessWidget { static const String routePath = 'slideshow'; - static Route route({ - required SlideshowBlock slideshow, - required String articleId, - }) { - return MaterialPageRoute( - builder: (_) => SlideshowPage( - slideshow: slideshow, - articleId: articleId, - ), - ); - } + 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 5a5389c77..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,17 +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 const routePath = 'manage-subscription'; - static MaterialPageRoute route() { - return MaterialPageRoute( - builder: (_) => const ManageSubscriptionPage(), - ); - } + 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 b4ac85bf5..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 @@ -18,9 +18,11 @@ class UserProfilePage extends StatelessWidget { static const routePath = 'profile'; - static MaterialPageRoute route() { - return MaterialPageRoute(builder: (_) => const UserProfilePage()); - } + static Widget routeBuilder( + BuildContext context, + GoRouterState state, + ) => + const UserProfilePage(); @override Widget build(BuildContext context) { 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 dc9e5a0c2..70d27d2c4 100644 --- a/flutter_news_example/test/article/view/article_page_test.dart +++ b/flutter_news_example/test/article/view/article_page_test.dart @@ -32,8 +32,14 @@ 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; @@ -49,6 +55,18 @@ void main() { Stream.value(FullScreenAdsState.initial()), initialState: FullScreenAdsState.initial(), ); + goRouterState = _MockGoRouterState(); + context = _MockBuildContext(); + }); + + 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 { 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 c66fc3d51..12ebade08 100644 --- a/flutter_news_example/test/feed/widgets/category_feed_test.dart +++ b/flutter_news_example/test/feed/widgets/category_feed_test.dart @@ -141,11 +141,12 @@ void main() { ); when( - () => goRouter.goNamed( + () => goRouter.pushNamed( NetworkErrorPage.routePath, - extra: any(named: 'extra'), ), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async { + return null; + }); }); Future pumpWidget(WidgetTester tester, Widget child) async { @@ -154,21 +155,25 @@ void main() { value: feedBloc, child: InheritedGoRouter( goRouter: goRouter, - child: CategoryFeed(category: category), + child: child, ), ), ); } - testWidgets('calls goNamed to Network Error page', (tester) async { + testWidgets('navigates to Network Error page and requests feed again', + (tester) async { await pumpWidget(tester, CategoryFeed(category: category)); verify( - () => goRouter.goNamed( + () => goRouter.pushNamed( NetworkErrorPage.routePath, - extra: any(named: 'extra'), ), ).called(1); + + 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 dd3b55c2e..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,12 +1,15 @@ // 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'; @@ -15,15 +18,56 @@ 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( @@ -31,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 { 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/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 8964bbb43..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,15 +3,36 @@ 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', () { + 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 { await tester.pumpApp( const MagicLinkPromptPage(email: testEmail), diff --git a/flutter_news_example/test/network_error/network_error_test.dart b/flutter_news_example/test/network_error/network_error_test.dart index fb4c05a5b..b02a87c45 100644 --- a/flutter_news_example/test/network_error/network_error_test.dart +++ b/flutter_news_example/test/network_error/network_error_test.dart @@ -10,16 +10,30 @@ 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()); 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 fc71e45ea..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 @@ -28,13 +28,26 @@ 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 {