diff --git a/integration_test/unreadmarker_test.dart b/integration_test/unreadmarker_test.dart index 2db4222dc2..1870bc2655 100644 --- a/integration_test/unreadmarker_test.dart +++ b/integration_test/unreadmarker_test.dart @@ -7,6 +7,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import '../test/api/fake_api.dart'; @@ -38,6 +39,7 @@ void main() { home: GlobalStoreWidget( child: PerAccountStoreWidget( accountId: eg.selfAccount.id, + placeholder: const LoadingPlaceholderPage(), child: const MessageListPage(narrow: AllMessagesNarrow()))))); await tester.pumpAndSettle(); return messages; diff --git a/lib/notifications.dart b/lib/notifications.dart index 9ed1e99f91..d7ee87f34a 100644 --- a/lib/notifications.dart +++ b/lib/notifications.dart @@ -389,10 +389,9 @@ class NotificationDisplayManager { assert(debugLog(' account: $account, narrow: $narrow')); // TODO(nav): Better interact with existing nav stack on notif open - navigator.push(MaterialWidgetRoute( - page: PerAccountStoreWidget(accountId: account.id, - // TODO(#82): Open at specific message, not just conversation - child: MessageListPage(narrow: narrow)))); + navigator.push(MaterialAccountWidgetRoute(accountId: account.id, + // TODO(#82): Open at specific message, not just conversation + page: MessageListPage(narrow: narrow))); return; } } diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index dc546707d6..a153125e49 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -111,23 +111,48 @@ class ZulipApp extends StatelessWidget { // a finger or thumb than the area above. tooltipTheme: const TooltipThemeData(preferBelow: false), ); + return GlobalStoreWidget( - child: MaterialApp( - title: 'Zulip', - localizationsDelegates: ZulipLocalizations.localizationsDelegates, - supportedLocales: ZulipLocalizations.supportedLocales, - theme: theme, - navigatorKey: navigatorKey, - navigatorObservers: navigatorObservers ?? const [], - builder: (BuildContext context, Widget? child) { - if (!ready.value) { - SchedulerBinding.instance.addPostFrameCallback( - (_) => _declareReady()); - } - GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); - return child!; - }, - home: const ChooseAccountPage())); + child: Builder(builder: (context) { + final globalStore = GlobalStoreWidget.of(context); + // TODO(#524) choose initial account as last one used + final initialAccountId = globalStore.accounts.firstOrNull?.id; + return MaterialApp( + title: 'Zulip', + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, + theme: theme, + + navigatorKey: navigatorKey, + navigatorObservers: navigatorObservers ?? const [], + builder: (BuildContext context, Widget? child) { + if (!ready.value) { + SchedulerBinding.instance.addPostFrameCallback( + (_) => _declareReady()); + } + GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); + return child!; + }, + + // We use onGenerateInitialRoutes for the real work of specifying the + // initial nav state. To do that we need [MaterialApp] to decide to + // build a [Navigator]... which means specifying either `home`, `routes`, + // `onGenerateRoute`, or `onUnknownRoute`. Make it `onGenerateRoute`. + // It never actually gets called, though: `onGenerateInitialRoutes` + // handles startup, and then we always push whole routes with methods + // like [Navigator.push], never mere names as with [Navigator.pushNamed]. + onGenerateRoute: (_) => null, + + onGenerateInitialRoutes: (_) { + return [ + MaterialWidgetRoute(page: const ChooseAccountPage()), + if (initialAccountId != null) ...[ + HomePage.buildRoute(accountId: initialAccountId), + InboxPage.buildRoute(accountId: initialAccountId), + ], + ]; + }); + })); } } @@ -211,9 +236,8 @@ class HomePage extends StatelessWidget { const HomePage({super.key}); static Route buildRoute({required int accountId}) { - return MaterialWidgetRoute( - page: PerAccountStoreWidget(accountId: accountId, - child: const HomePage())); + return MaterialAccountWidgetRoute(accountId: accountId, + page: const HomePage()); } @override diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index cbbde8061e..a1e706946f 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -15,8 +15,8 @@ import 'unread_count_badge.dart'; class InboxPage extends StatefulWidget { const InboxPage({super.key}); - static Route buildRoute({required BuildContext context}) { - return MaterialAccountWidgetRoute(context: context, + static Route buildRoute({int? accountId, BuildContext? context}) { + return MaterialAccountWidgetRoute(accountId: accountId, context: context, page: const InboxPage()); } diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 7c3b2a4146..7a3ca5230e 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -208,11 +208,13 @@ class _LightboxPageState extends State<_LightboxPage> { } Route getLightboxRoute({ - required BuildContext context, + int? accountId, + BuildContext? context, required Message message, required Uri src, }) { return AccountPageRouteBuilder( + accountId: accountId, context: context, fullscreenDialog: true, pageBuilder: ( diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 93c4e9c1f5..a957b29cfc 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -26,8 +26,9 @@ import 'text.dart'; class MessageListPage extends StatefulWidget { const MessageListPage({super.key, required this.narrow}); - static Route buildRoute({required BuildContext context, required Narrow narrow}) { - return MaterialAccountWidgetRoute(context: context, + static Route buildRoute({int? accountId, BuildContext? context, + required Narrow narrow}) { + return MaterialAccountWidgetRoute(accountId: accountId, context: context, page: MessageListPage(narrow: narrow)); } diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart index bd2072fb4a..f964b2a0f1 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -14,6 +14,10 @@ abstract class WidgetRoute extends PageRoute { /// A [MaterialPageRoute] that always builds the same widget. /// /// This is useful for making the route more transparent for a test to inspect. +/// +/// See also: +/// * [MaterialAccountWidgetRoute], a subclass which automates providing a +/// per-account store on the new route. class MaterialWidgetRoute extends MaterialPageRoute implements WidgetRoute { MaterialWidgetRoute({ required this.page, @@ -27,6 +31,7 @@ class MaterialWidgetRoute extends MaterialPageRoute implements WidgetRoute final Widget page; } +/// A mixin for providing a given account's per-account store on a page route. mixin AccountPageRouteMixin on PageRoute { int get accountId; @@ -34,30 +39,71 @@ mixin AccountPageRouteMixin on PageRoute { Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { return PerAccountStoreWidget( accountId: accountId, + placeholder: const LoadingPlaceholderPage(), child: super.buildPage(context, animation, secondaryAnimation)); } } +/// A [MaterialPageRoute] providing a per-account store for a given account. +/// +/// See also: +/// * [MaterialAccountWidgetRoute], a subclass which is more transparent +/// for tests. +/// * [AccountPageRouteBuilder], for defining one-off page routes +/// in terms of callbacks. class MaterialAccountPageRoute extends MaterialPageRoute with AccountPageRouteMixin { + /// Construct a [MaterialAccountPageRoute] using either the given account ID, + /// or the ambient one from the given context. + /// + /// The account ID used is [accountId] if specified, + /// else the ambient account ID from [context]. + /// One of those parameters must be specified, and not both. + /// + /// Generally most navigation in the app is within a given account, + /// and should use [context]. Using [accountId] is appropriate for + /// navigating across accounts, or navigating into an account from contexts + /// (like login or the choose-account page) that don't have an ambient account. MaterialAccountPageRoute({ - required BuildContext context, + int? accountId, + BuildContext? context, required super.builder, super.settings, super.maintainState, super.fullscreenDialog, super.allowSnapshotting, - }) : accountId = PerAccountStoreWidget.accountIdOf(context); + }) : assert((accountId != null) ^ (context != null), + "exactly one of accountId or context must be specified"), + accountId = accountId ?? PerAccountStoreWidget.accountIdOf(context!); @override final int accountId; } -/// A [MaterialAccountPageRoute] that always builds the same widget. +/// A [MaterialPageRoute] that provides a per-account store for a given account +/// and always builds the same widget. /// -/// This is useful for making the route more transparent for a test to inspect. +/// This is the [PageRoute] subclass to use for most navigation in the app. +/// +/// Always building the same widget is useful for making the route +/// more transparent for a test to inspect. +/// +/// See also: +/// * [MaterialWidgetRoute], for routes that need no per-account store. class MaterialAccountWidgetRoute extends MaterialAccountPageRoute implements WidgetRoute { + /// Construct a [MaterialAccountWidgetRoute] using either the given account ID, + /// or the ambient one from the given context. + /// + /// The account ID used is [accountId] if specified, + /// else the ambient account ID from [context]. + /// One of those parameters must be specified, and not both. + /// + /// Generally most navigation in the app is within a given account, + /// and should use [context]. Using [accountId] is appropriate for + /// navigating across accounts, or navigating into an account from contexts + /// (like login or the choose-account page) that don't have an ambient account. MaterialAccountWidgetRoute({ - required super.context, + super.accountId, + super.context, required this.page, super.settings, super.maintainState, @@ -69,9 +115,24 @@ class MaterialAccountWidgetRoute extends MaterialAccountPageRoute implemen final Widget page; } +/// A [PageRouteBuilder] providing a per-account store for a given account. +/// +/// This is the [PageRouteBuilder] analogue of [MaterialAccountPageRoute]. class AccountPageRouteBuilder extends PageRouteBuilder with AccountPageRouteMixin { + /// Construct an [AccountPageRouteBuilder] using either the given account ID, + /// or the ambient one from the given context. + /// + /// The account ID used is [accountId] if specified, + /// else the ambient account ID from [context]. + /// One of those parameters must be specified, and not both. + /// + /// Generally most navigation in the app is within a given account, + /// and should use [context]. Using [accountId] is appropriate for + /// navigating across accounts, or navigating into an account from contexts + /// (like login or the choose-account page) that don't have an ambient account. AccountPageRouteBuilder({ - required BuildContext context, + int? accountId, + BuildContext? context, super.settings, required super.pageBuilder, super.transitionsBuilder, @@ -84,8 +145,22 @@ class AccountPageRouteBuilder extends PageRouteBuilder with AccountPageRou super.maintainState, super.fullscreenDialog, super.allowSnapshotting, - }) : accountId = PerAccountStoreWidget.accountIdOf(context); + }) : assert((accountId != null) ^ (context != null), + "exactly one of accountId or context must be specified"), + accountId = accountId ?? PerAccountStoreWidget.accountIdOf(context!); @override final int accountId; } + +class LoadingPlaceholderPage extends StatelessWidget { + const LoadingPlaceholderPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: const LoadingPlaceholder(), + ); + } +} diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 6d125fbff0..055772017e 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -22,8 +22,9 @@ class ProfilePage extends StatelessWidget { final int userId; - static Route buildRoute({required BuildContext context, required int userId}) { - return MaterialAccountWidgetRoute(context: context, + static Route buildRoute({int? accountId, BuildContext? context, + required int userId}) { + return MaterialAccountWidgetRoute(accountId: accountId, context: context, page: ProfilePage(userId: userId)); } diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 1dc95dc78d..dac1d638c5 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -14,8 +14,8 @@ import 'unread_count_badge.dart'; class RecentDmConversationsPage extends StatefulWidget { const RecentDmConversationsPage({super.key}); - static Route buildRoute({required BuildContext context}) { - return MaterialAccountWidgetRoute(context: context, + static Route buildRoute({int? accountId, BuildContext? context}) { + return MaterialAccountWidgetRoute(accountId: accountId, context: context, page: const RecentDmConversationsPage()); } diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart index f69a2c7d6b..1929a225f6 100644 --- a/lib/widgets/store.dart +++ b/lib/widgets/store.dart @@ -12,8 +12,13 @@ import '../model/store.dart'; /// * [PerAccountStoreWidget], for the user's data associated with a /// particular Zulip account. class GlobalStoreWidget extends StatefulWidget { - const GlobalStoreWidget({super.key, required this.child}); + const GlobalStoreWidget({ + super.key, + this.placeholder = const LoadingPlaceholder(), + required this.child, + }); + final Widget placeholder; final Widget child; /// The app's global data store. @@ -65,8 +70,7 @@ class _GlobalStoreWidgetState extends State { @override Widget build(BuildContext context) { final store = this.store; - // TODO: factor out the use of LoadingPage to be configured by the widget, like [widget.child] is - if (store == null) return const LoadingPage(); + if (store == null) return widget.placeholder; return _GlobalStoreInheritedWidget(store: store, child: widget.child); } } @@ -107,10 +111,12 @@ class PerAccountStoreWidget extends StatefulWidget { const PerAccountStoreWidget({ super.key, required this.accountId, + this.placeholder = const LoadingPlaceholder(), required this.child, }); final int accountId; + final Widget placeholder; final Widget child; /// The user's data for the relevant Zulip account for this widget. @@ -220,8 +226,7 @@ class _PerAccountStoreWidgetState extends State { @override Widget build(BuildContext context) { - // TODO: factor out the use of LoadingPage to be configured by the widget, like [widget.child] is - if (store == null) return const LoadingPage(); + if (store == null) return widget.placeholder; return _PerAccountStoreInheritedWidget(store: store!, child: widget.child); } } @@ -242,8 +247,8 @@ class _PerAccountStoreInheritedWidget extends InheritedNotifier store != oldWidget.store; } -class LoadingPage extends StatelessWidget { - const LoadingPage({super.key}); +class LoadingPlaceholder extends StatelessWidget { + const LoadingPlaceholder({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 97c75f8eca..9769311543 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -15,8 +15,8 @@ import 'unread_count_badge.dart'; class SubscriptionListPage extends StatefulWidget { const SubscriptionListPage({super.key}); - static Route buildRoute({required BuildContext context}) { - return MaterialAccountWidgetRoute(context: context, + static Route buildRoute({int? accountId, BuildContext? context}) { + return MaterialAccountWidgetRoute(accountId: accountId, context: context, page: const SubscriptionListPage()); } diff --git a/test/notifications_test.dart b/test/notifications_test.dart index 38d2d7bd98..b3f9bdccb1 100644 --- a/test/notifications_test.dart +++ b/test/notifications_test.dart @@ -12,17 +12,15 @@ import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications.dart'; import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; -import 'package:zulip/widgets/store.dart'; -import 'flutter_checks.dart'; import 'model/binding.dart'; import 'example_data.dart' as eg; import 'test_navigation.dart'; import 'widgets/message_list_checks.dart'; import 'widgets/page_checks.dart'; -import 'widgets/store_checks.dart'; FakeAndroidFlutterLocalNotificationsPlugin get notifAndroid => testBinding.notifications @@ -194,7 +192,24 @@ void main() { group('NotificationDisplayManager open', () { late List> pushedRoutes; - Future prepare(WidgetTester tester, {bool early = false}) async { + void takeStartingRoutes({bool withAccount = true}) { + final expected = [ + (Subject it) => it.isA().page.isA(), + if (withAccount) ...[ + (Subject it) => it.isA() + ..accountId.equals(eg.selfAccount.id) + ..page.isA(), + (Subject it) => it.isA() + ..accountId.equals(eg.selfAccount.id) + ..page.isA(), + ], + ]; + check(pushedRoutes.take(expected.length)).deepEquals(expected); + pushedRoutes.removeRange(0, expected.length); + } + + Future prepare(WidgetTester tester, + {bool early = false, bool withAccount = true}) async { await init(); pushedRoutes = []; final testNavObserver = TestNavigatorObserver() @@ -205,8 +220,8 @@ void main() { return; } await tester.pump(); - check(pushedRoutes).length.equals(1); - pushedRoutes.clear(); + takeStartingRoutes(withAccount: withAccount); + check(pushedRoutes).isEmpty(); } Future openNotification(Account account, Message message) async { @@ -218,12 +233,11 @@ void main() { } void matchesNavigation(Subject route, Account account, Message message) { - route.isA().page - .isA() + route.isA() ..accountId.equals(account.id) - ..child.isA() - .narrow.equals(SendableNarrow.ofMessage(message, - selfUserId: account.userId)); + ..page.isA() + .narrow.equals(SendableNarrow.ofMessage(message, + selfUserId: account.userId)); } Future checkOpenNotification(Account account, Message message) async { @@ -233,26 +247,26 @@ void main() { } testWidgets('stream message', (tester) async { - testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await prepare(tester); await checkOpenNotification(eg.selfAccount, eg.streamMessage()); }); testWidgets('direct message', (tester) async { - testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await prepare(tester); await checkOpenNotification(eg.selfAccount, eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); }); testWidgets('no accounts', (tester) async { - await prepare(tester); + await prepare(tester, withAccount: false); await openNotification(eg.selfAccount, eg.streamMessage()); check(pushedRoutes).isEmpty(); }); testWidgets('mismatching account', (tester) async { - testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await prepare(tester); await openNotification(eg.otherAccount, eg.streamMessage()); check(pushedRoutes).isEmpty(); @@ -270,7 +284,7 @@ void main() { eg.account(id: 1004, realmUrl: realmUrlB, user: user2), ]; for (final account in accounts) { - testBinding.globalStore.insertAccount(account.toCompanion(false)); + testBinding.globalStore.add(account, eg.initialSnapshot()); } await prepare(tester); @@ -281,7 +295,7 @@ void main() { }); testWidgets('wait for app to become ready', (tester) async { - testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await prepare(tester, early: true); final message = eg.streamMessage(); await openNotification(eg.selfAccount, message); @@ -293,11 +307,10 @@ void main() { // Now let the GlobalStore get loaded and the app's main UI get mounted. await tester.pump(); - // The navigator first pushes the home route… - check(pushedRoutes).length.equals(2); - check(pushedRoutes[0]).settings.name.equals("/"); + // The navigator first pushes the starting routes… + takeStartingRoutes(); // … and then the one the notification leads to. - matchesNavigation(check(pushedRoutes[1]), eg.selfAccount, message); + matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); }); testWidgets('at app launch', (tester) async { @@ -311,15 +324,14 @@ void main() { NotificationAppLaunchDetails(true, notificationResponse: response); // Now start the app. - testBinding.globalStore.insertAccount(account.toCompanion(false)); + testBinding.globalStore.add(account, eg.initialSnapshot()); await prepare(tester, early: true); check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet // Once the app is ready, we navigate to the conversation. await tester.pump(); - check(pushedRoutes).length.equals(2); - check(pushedRoutes[0]).settings.name.equals("/"); - matchesNavigation(check(pushedRoutes[1]), account, message); + takeStartingRoutes(); + matchesNavigation(check(pushedRoutes).single, account, message); }); }); } diff --git a/test/test_navigation.dart b/test/test_navigation.dart index 02d7b48f6e..b5065d684c 100644 --- a/test/test_navigation.dart +++ b/test/test_navigation.dart @@ -11,6 +11,7 @@ class TestNavigatorObserver extends NavigatorObserver { void Function(Route route, Route? previousRoute)? onRemoved; void Function(Route? route, Route? previousRoute)? onReplaced; void Function(Route route, Route? previousRoute)? onStartUserGesture; + void Function()? onStopUserGesture; @override void didPush(Route route, Route? previousRoute) { @@ -36,4 +37,9 @@ class TestNavigatorObserver extends NavigatorObserver { void didStartUserGesture(Route route, Route? previousRoute) { onStartUserGesture?.call(route, previousRoute); } + + @override + void didStopUserGesture() { + onStopUserGesture?.call(); + } } diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart new file mode 100644 index 0000000000..020a95986f --- /dev/null +++ b/test/widgets/app_test.dart @@ -0,0 +1,54 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/inbox.dart'; +import 'package:zulip/widgets/page.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../test_navigation.dart'; +import 'page_checks.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('ZulipApp initial navigation', () { + late List> pushedRoutes = []; + + Future>> initialRoutes(WidgetTester tester) async { + pushedRoutes = []; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + await tester.pump(); + return pushedRoutes; + } + + testWidgets('when no accounts, go to choose account', (tester) async { + addTearDown(testBinding.reset); + check(await initialRoutes(tester)).deepEquals([ + (Subject it) => it.isA().page.isA(), + ]); + }); + + testWidgets('when have accounts, go to inbox for first account', (tester) async { + addTearDown(testBinding.reset); + + // We'll need per-account data for the account that a page will be opened + // for, but not for the other account. + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.insertAccount(eg.otherAccount.toCompanion(false)); + + check(await initialRoutes(tester)).deepEquals([ + (Subject it) => it.isA().page.isA(), + (Subject it) => it.isA() + ..accountId.equals(eg.selfAccount.id) + ..page.isA(), + (Subject it) => it.isA() + ..accountId.equals(eg.selfAccount.id) + ..page.isA(), + ]); + }); + }); +} diff --git a/test/widgets/page_checks.dart b/test/widgets/page_checks.dart index 26590df8a5..f6effa2606 100644 --- a/test/widgets/page_checks.dart +++ b/test/widgets/page_checks.dart @@ -5,3 +5,7 @@ import 'package:zulip/widgets/page.dart'; extension WidgetRouteChecks on Subject { Subject get page => has((x) => x.page, 'page'); } + +extension AccountPageRouteMixinChecks on Subject { + Subject get accountId => has((x) => x.accountId, 'accountId'); +}