From 2a54d0794cd7b05f38c1413e3dcd7d596c7e7269 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 26 Nov 2024 17:26:48 -0500 Subject: [PATCH] nav: Add bottom tabs and main menu This is an initial implementation because some features were considered out-of-scope as of now for #1035. Compared to the Figma, we swapped the order of _ChannelsButton and _DirectMessagesButton in the menu so they match their order on the navigation bar. See: https://chat.zulip.org/#narrow/channel/48-mobile/topic/Buttons.20on.20the.20bottom.20tabs.20and.20main.20menu We also added _CombinedFeedButton, using the same icon we have for "Combined feed" on the web app's topleft sidebar, added a "Switch account" button, and renamed "Streams" to "Channels". For now, we do not aim to fully implement the app bar redesign; we keep a simple one in this implementation. We maintain a 16px padding for the title text on the app bar for both ChooseAccountPage (when it is at the root navigation level) and HomePage (which is expected to be at the root level all the time). We allow user to navigate to the choose-account page from the loading screen after a certain timeout. This is helpful when loading the account data takes a while, and that the user, for example, wants to try another account. See: https://github.com/zulip/zulip-flutter/pull/1076#discussion_r1868262551 Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 25 + lib/generated/l10n/zulip_localizations.dart | 36 + .../l10n/zulip_localizations_ar.dart | 20 + .../l10n/zulip_localizations_en.dart | 20 + .../l10n/zulip_localizations_ja.dart | 20 + lib/widgets/app.dart | 19 +- lib/widgets/home.dart | 630 ++++++++++++++++-- lib/widgets/login.dart | 5 +- lib/widgets/page.dart | 2 +- lib/widgets/theme.dart | 42 ++ test/flutter_checks.dart | 1 + test/notifications/display_test.dart | 7 +- test/widgets/app_test.dart | 48 +- test/widgets/home_test.dart | 410 ++++++++++++ test/widgets/login_test.dart | 31 +- 15 files changed, 1227 insertions(+), 89 deletions(-) create mode 100644 test/widgets/home_test.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 1b8e6ff8b8..db8993ab09 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -19,6 +19,19 @@ "@chooseAccountPageTitle": { "description": "Title for the page to choose between Zulip accounts." }, + "switchAccountButton": "Switch account", + "@switchAccountButton": { + "description": "Label for main-menu button leading to the choose-account page." + }, + "tryAnotherAccountMessage": "Your account at {url} is taking a while to load.", + "@tryAnotherAccountMessage": { + "description": "Message that appears on the loading screen after waiting for some time.", + "url": {"type": "String", "example": "http://chat.example.com/"} + }, + "tryAnotherAccountButton": "Try another account", + "@tryAnotherAccountButton": { + "description": "Label for loading screen button prompting user to try another account." + }, "chooseAccountPageLogOutButton": "Log out", "@chooseAccountPageLogOutButton": { "description": "Label for the 'Log out' button for an account on the choose-account page" @@ -529,6 +542,10 @@ "@userRoleUnknown": { "description": "Label for UserRole.unknown" }, + "inboxPageTitle": "Inbox", + "@inboxPageTitle": { + "description": "Title for the page with unreads." + }, "recentDmConversationsPageTitle": "Direct messages", "@recentDmConversationsPageTitle": { "description": "Title for the page with a list of DM conversations." @@ -545,6 +562,14 @@ "@starredMessagesPageTitle": { "description": "Page title for the 'Starred messages' message view." }, + "channelsPageTitle": "Channels", + "@channelsPageTitle": { + "description": "Title for the page with a list of subscribed channels." + }, + "mainMenuMyProfile": "My profile", + "@mainMenuMyProfile": { + "description": "Label for main-menu button leading to the user's own profile." + }, "channelFeedButtonTooltip": "Channel feed", "@channelFeedButtonTooltip": { "description": "Tooltip for button to navigate to a given channel's feed" diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index b058af8f2b..e9fdd6184a 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -127,6 +127,24 @@ abstract class ZulipLocalizations { /// **'Choose account'** String get chooseAccountPageTitle; + /// Label for main-menu button leading to the choose-account page. + /// + /// In en, this message translates to: + /// **'Switch account'** + String get switchAccountButton; + + /// Message that appears on the loading screen after waiting for some time. + /// + /// In en, this message translates to: + /// **'Your account at {url} is taking a while to load.'** + String tryAnotherAccountMessage(Object url); + + /// Label for loading screen button prompting user to try another account. + /// + /// In en, this message translates to: + /// **'Try another account'** + String get tryAnotherAccountButton; + /// Label for the 'Log out' button for an account on the choose-account page /// /// In en, this message translates to: @@ -799,6 +817,12 @@ abstract class ZulipLocalizations { /// **'Unknown'** String get userRoleUnknown; + /// Title for the page with unreads. + /// + /// In en, this message translates to: + /// **'Inbox'** + String get inboxPageTitle; + /// Title for the page with a list of DM conversations. /// /// In en, this message translates to: @@ -823,6 +847,18 @@ abstract class ZulipLocalizations { /// **'Starred messages'** String get starredMessagesPageTitle; + /// Title for the page with a list of subscribed channels. + /// + /// In en, this message translates to: + /// **'Channels'** + String get channelsPageTitle; + + /// Label for main-menu button leading to the user's own profile. + /// + /// In en, this message translates to: + /// **'My profile'** + String get mainMenuMyProfile; + /// Tooltip for button to navigate to a given channel's feed /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 95ff1d0aea..92f901666f 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -23,6 +23,17 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get chooseAccountPageTitle => 'Choose account'; + @override + String get switchAccountButton => 'Switch account'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Your account at $url is taking a while to load.'; + } + + @override + String get tryAnotherAccountButton => 'Try another account'; + @override String get chooseAccountPageLogOutButton => 'Log out'; @@ -419,6 +430,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get inboxPageTitle => 'Inbox'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @@ -431,6 +445,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get starredMessagesPageTitle => 'Starred messages'; + @override + String get channelsPageTitle => 'Channels'; + + @override + String get mainMenuMyProfile => 'My profile'; + @override String get channelFeedButtonTooltip => 'Channel feed'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index d440ed2b10..b65e5af796 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -23,6 +23,17 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get chooseAccountPageTitle => 'Choose account'; + @override + String get switchAccountButton => 'Switch account'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Your account at $url is taking a while to load.'; + } + + @override + String get tryAnotherAccountButton => 'Try another account'; + @override String get chooseAccountPageLogOutButton => 'Log out'; @@ -419,6 +430,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get inboxPageTitle => 'Inbox'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @@ -431,6 +445,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get starredMessagesPageTitle => 'Starred messages'; + @override + String get channelsPageTitle => 'Channels'; + + @override + String get mainMenuMyProfile => 'My profile'; + @override String get channelFeedButtonTooltip => 'Channel feed'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 42128ba024..899404cdda 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -23,6 +23,17 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get chooseAccountPageTitle => 'アカウントを選択'; + @override + String get switchAccountButton => 'Switch account'; + + @override + String tryAnotherAccountMessage(Object url) { + return 'Your account at $url is taking a while to load.'; + } + + @override + String get tryAnotherAccountButton => 'Try another account'; + @override String get chooseAccountPageLogOutButton => 'Log out'; @@ -419,6 +430,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get userRoleUnknown => '不明'; + @override + String get inboxPageTitle => 'Inbox'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @@ -431,6 +445,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get starredMessagesPageTitle => 'Starred messages'; + @override + String get channelsPageTitle => 'Channels'; + + @override + String get mainMenuMyProfile => 'My profile'; + @override String get channelFeedButtonTooltip => 'Channel feed'; diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 7d7d1246e2..2b0a1376fe 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -13,7 +13,6 @@ import 'about_zulip.dart'; import 'actions.dart'; import 'dialog.dart'; import 'home.dart'; -import 'inbox.dart'; import 'login.dart'; import 'page.dart'; import 'store.dart'; @@ -209,11 +208,10 @@ class _ZulipAppState extends State with WidgetsBindingObserver { onGenerateInitialRoutes: (_) { return [ - MaterialWidgetRoute(page: const ChooseAccountPage()), - if (initialAccountId != null) ...[ + if (initialAccountId == null) + MaterialWidgetRoute(page: const ChooseAccountPage()) + else HomePage.buildRoute(accountId: initialAccountId), - InboxPage.buildRoute(accountId: initialAccountId), - ], ]; }); })); @@ -271,8 +269,7 @@ class ChooseAccountPage extends StatelessWidget { // The default trailing padding with M3 is 24px. Decrease by 12 because // IconButton (the "…" button) comes with 12px padding on all sides. contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 12), - onTap: () => Navigator.push(context, - HomePage.buildRoute(accountId: accountId)))); + onTap: () => HomePage.navigate(context, accountId: accountId))); } @override @@ -281,13 +278,19 @@ class ChooseAccountPage extends StatelessWidget { final zulipLocalizations = ZulipLocalizations.of(context); assert(!PerAccountStoreWidget.debugExistsOf(context)); final globalStore = GlobalStoreWidget.of(context); + + // Borrowed from [AppBar.build]. + // See documentation on [ModalRoute.impliesAppBarDismissal]: + // > Whether an [AppBar] in the route should automatically add a back button or + // > close button. + final hasBackButton = ModalRoute.of(context)?.impliesAppBarDismissal ?? false; return MenuButtonTheme( data: MenuButtonThemeData(style: MenuItemButton.styleFrom( backgroundColor: colorScheme.secondaryContainer, foregroundColor: colorScheme.onSecondaryContainer)), child: Scaffold( appBar: AppBar( - titleSpacing: 16, + titleSpacing: hasBackButton ? null : 16, title: Text(zulipLocalizations.chooseAccountPageTitle), actions: const [ChooseAccountPageOverflowButton()]), body: SafeArea( diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index 2c454cc90c..07863f0a81 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -1,91 +1,597 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; +import 'action_sheet.dart'; +import 'app.dart'; import 'app_bar.dart'; +import 'color.dart'; +import 'content.dart'; +import 'icons.dart'; import 'inbox.dart'; +import 'inset_shadow.dart'; import 'message_list.dart'; import 'page.dart'; +import 'profile.dart'; import 'recent_dm_conversations.dart'; import 'store.dart'; import 'subscription_list.dart'; import 'text.dart'; +import 'theme.dart'; + +enum HomePageTab { + inbox, + channels, + directMessages, +} -class HomePage extends StatelessWidget { +class HomePage extends StatefulWidget { const HomePage({super.key}); static Route buildRoute({required int accountId}) { return MaterialAccountWidgetRoute(accountId: accountId, - page: const HomePage()); + loadingPlaceholderPage: _LoadingPlaceholderPage(accountId: accountId), + page: const HomePage()); + } + + /// Navigate to [HomePage], ensuring that its route is at the root level. + static void navigate(BuildContext context, {required int accountId}) { + final navigator = Navigator.of(context); + navigator.popUntil((route) => route.isFirst); + unawaited(navigator.pushReplacement( + HomePage.buildRoute(accountId: accountId))); } @override - Widget build(BuildContext context) { - final store = PerAccountStoreWidget.of(context); + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + late final ValueNotifier _tab = ValueNotifier(HomePageTab.inbox); + + @override + void initState() { + super.initState(); + _tab.addListener(_tabChanged); + } + + @override + void dispose() { + _tab.dispose(); + super.dispose(); + } + + void _tabChanged() { + setState(() { + // The actual state lives in [_tab]. + }); + } + + String get _currentTabTitle { final zulipLocalizations = ZulipLocalizations.of(context); + switch(_tab.value) { + case HomePageTab.inbox: + return zulipLocalizations.inboxPageTitle; + case HomePageTab.channels: + return zulipLocalizations.channelsPageTitle; + case HomePageTab.directMessages: + return zulipLocalizations.recentDmConversationsPageTitle; + } + } - InlineSpan bold(String text) => TextSpan( - style: const TextStyle().merge(weightVariableTextStyle(context, wght: 700)), - text: text); + @override + Widget build(BuildContext context) { + const pageBodies = [ + (HomePageTab.inbox, InboxPageBody()), + (HomePageTab.channels, SubscriptionListPageBody()), + // TODO(#1094): Users + (HomePageTab.directMessages, RecentDmConversationsPageBody()), + ]; - int? testStreamId; - if (store.connection.realmUrl.origin == 'https://chat.zulip.org') { - testStreamId = 7; // i.e. `#test here`; TODO cut this scaffolding hack + _NavigationBarButton button(HomePageTab tab, IconData icon) { + return _NavigationBarButton(icon: icon, + selected: _tab.value == tab, + onPressed: () { + _tab.value = tab; + }); } + // TODO(a11y): add tooltips for these buttons + final navigationBarButtons = { + button(HomePageTab.inbox, ZulipIcons.inbox), + button(HomePageTab.channels, ZulipIcons.hash_italic), + // TODO(#1094): Users + button(HomePageTab.directMessages, ZulipIcons.user), + _NavigationBarButton( icon: ZulipIcons.menu, + selected: false, + onPressed: () => _showMainMenu(context, tabNotifier: _tab)), + }; + + final designVariables = DesignVariables.of(context); return Scaffold( - appBar: ZulipAppBar(title: const Text("Home")), + appBar: ZulipAppBar(titleSpacing: 16, + title: Text(_currentTabTitle)), + body: Stack( + children: [ + for (final (tab, body) in pageBodies) + // TODO(#535): Decide if we find it helpful to use something like + // [SemanticsProperties.namesRoute] to structure this UI better + // for screen-reader software. + Offstage(offstage: tab != _tab.value, child: body), + ]), + bottomNavigationBar: DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: designVariables.borderBar)), + color: designVariables.bgBotBar), + child: SafeArea( + child: SizedBox(height: 48, + child: Center( + child: ConstrainedBox( + // TODO(design): determine a suitable max width for bottom nav bar + constraints: const BoxConstraints(maxWidth: 600), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final navigationBarButton in navigationBarButtons) + Expanded(child: navigationBarButton), + ]))))))); + } +} + +const kTryAnotherAccountWaitPeriod = Duration(seconds: 5); + +class _LoadingPlaceholderPage extends StatefulWidget { + const _LoadingPlaceholderPage({required this.accountId}); + + final int accountId; + + @override + State<_LoadingPlaceholderPage> createState() => _LoadingPlaceholderPageState(); +} + +class _LoadingPlaceholderPageState extends State<_LoadingPlaceholderPage> { + Timer? tryAnotherAccountTimer; + bool showTryAnotherAccount = false; + + late Uri realmUrl; + + @override + void initState() { + super.initState(); + tryAnotherAccountTimer = Timer(kTryAnotherAccountWaitPeriod, () { + setState(() { + showTryAnotherAccount = true; + }); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + realmUrl = GlobalStoreWidget.of(context).getAccount(widget.accountId)!.realmUrl; + } + + @override + void dispose() { + tryAnotherAccountTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + + return Scaffold( + appBar: AppBar(), body: Center( - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - DefaultTextStyle.merge( - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 18), - child: Column(children: [ - Text.rich(TextSpan( - text: 'Connected to: ', - children: [bold(store.realmUrl.toString())])), - ])), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: const CombinedFeedNarrow())), - child: Text(zulipLocalizations.combinedFeedPageTitle)), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: const MentionsNarrow())), - child: Text(zulipLocalizations.mentionsPageTitle)), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: const StarredMessagesNarrow())), - child: Text(zulipLocalizations.starredMessagesPageTitle)), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => Navigator.push(context, - InboxPage.buildRoute(context: context)), - child: const Text("Inbox")), // TODO(i18n) - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => Navigator.push(context, - SubscriptionListPage.buildRoute(context: context)), - child: const Text("Subscribed channels")), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => Navigator.push(context, - RecentDmConversationsPage.buildRoute(context: context)), - child: Text(zulipLocalizations.recentDmConversationsPageTitle)), - if (testStreamId != null) ...[ - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: ChannelNarrow(testStreamId!))), - child: const Text("#test here")), // scaffolding hack, see above - ], - ]))); + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + IgnorePointer( + ignoring: !showTryAnotherAccount, + child: Opacity(opacity: showTryAnotherAccount ? 1 : 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + children: [ + const SizedBox(height: 16), + Text(zulipLocalizations.tryAnotherAccountMessage(realmUrl.toString())), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () => Navigator.push(context, + MaterialWidgetRoute(page: const ChooseAccountPage())), + child: Text(zulipLocalizations.tryAnotherAccountButton)), + ])))), + ]))); + } +} + +class _NavigationBarButton extends StatelessWidget { + const _NavigationBarButton({ + required this.icon, + required this.selected, + required this.onPressed, + }); + + final IconData icon; + final bool selected; + final void Function() onPressed; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + final iconColor = WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.iconSelected, + ~WidgetState.pressed: selected ? designVariables.iconSelected + : designVariables.icon, + }); + + return AnimatedScaleOnTap( + scaleEnd: 0.875, + duration: const Duration(milliseconds: 100), + child: IconButton( + icon: Icon(icon, size: 24), + onPressed: onPressed, + style: IconButton.styleFrom( + // TODO(#417): Disable splash effects for all buttons globally. + splashFactory: NoSplash.splashFactory, + highlightColor: designVariables.navigationButtonBg, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))), + ).copyWith(foregroundColor: iconColor))); + } +} + +void _showMainMenu(BuildContext context, { + required ValueNotifier tabNotifier, +}) { + final menuItems = [ + // TODO(#252): Search + // const SizedBox(height: 8), + _InboxButton(tabNotifier: tabNotifier), + // TODO: Recent conversations + const _MentionsButton(), + const _StarredMessagesButton(), + const _CombinedFeedButton(), + // TODO: Drafts + _ChannelsButton(tabNotifier: tabNotifier), + _DirectMessagesButton(tabNotifier: tabNotifier), + // TODO(#1094): Users + const _MyProfileButton(), + const _SwitchAccountButton(), + // TODO(#198): Set my status + // const SizedBox(height: 8), + // TODO(#97): Settings + // TODO(#661): Notifications + // const SizedBox(height: 8), + // TODO(#1095): VersionInfo + ]; + + final designVariables = DesignVariables.of(context); + final accountId = PerAccountStoreWidget.accountIdOf(context); + showModalBottomSheet( + context: context, + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect + // on my iPhone 13 Pro but is marked as "much slower": + // https://api.flutter.dev/flutter/dart-ui/Clip.html + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + // TODO: Fix the issue that the color does not respond when the theme + // changes, because `designVariables` was retrieved from a gesture handler, + // not a build method. + backgroundColor: designVariables.bgBotBar, + builder: (BuildContext _) { + return PerAccountStoreWidget( + accountId: accountId, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgBotBar, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + child: Column(children: menuItems)))), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: AnimatedScaleOnTap( + scaleEnd: 0.95, + duration: Duration(milliseconds: 100), + child: ActionSheetCancelButton())), + ]))); + }); +} + +abstract class _MenuButton extends StatelessWidget { + const _MenuButton(); + + String label(ZulipLocalizations zulipLocalizations); + + bool get selected => false; + + /// An icon to display before [label]. + /// + /// Must be non-null unless [buildLeading] is overridden. + IconData? get icon; + + Widget buildLeading(BuildContext context) { + assert(icon != null); + final designVariables = DesignVariables.of(context); + return Icon(icon, size: 24, color: selected ? designVariables.iconSelected + : designVariables.icon); + } + + void onPressed(BuildContext context); + + void _handlePress(BuildContext context) { + // Dismiss the enclosing action sheet immediately, + // for swift UI feedback that the user's selection was received. + Navigator.of(context).pop(); + + onPressed(context); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final borderSideSelected = BorderSide(width: 1, + strokeAlign: BorderSide.strokeAlignOutside, + color: designVariables.borderMenuButtonSelected); + final buttonStyle = TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 8), + foregroundColor: designVariables.labelMenuButton, + // This has a default behavior of affecting the background color of the + // button for states including "hovered", "focused" and "pressed". + // Make this transparent so that we can have full control of these colors. + overlayColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ).copyWith( + backgroundColor: WidgetStateColor.fromMap({ + WidgetState.hovered: designVariables.bgMenuButtonActive.withFadedAlpha(0.5), + WidgetState.focused: designVariables.bgMenuButtonActive, + WidgetState.pressed: designVariables.bgMenuButtonActive, + WidgetState.any: + selected ? designVariables.bgMenuButtonSelected : Colors.transparent, + }), + side: WidgetStateBorderSide.fromMap({ + WidgetState.pressed: null, + ~WidgetState.pressed: selected ? borderSideSelected : null, + })); + + return AnimatedScaleOnTap( + duration: const Duration(milliseconds: 100), + scaleEnd: 0.95, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: TextButton( + onPressed: () => _handlePress(context), + style: buttonStyle, + child: Row(spacing: 8, children: [ + buildLeading(context), + Expanded(child: Text(label(zulipLocalizations), + // TODO(design): determine if we prefer to wrap + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 19, height: 26 / 19) + .merge(weightVariableTextStyle(context, wght: selected ? 600 : 400)))), + ])))); + } +} + +/// A menu button controlling the selected [HomePageTab] on the bottom nav bar. +abstract class _NavigationBarMenuButton extends _MenuButton { + const _NavigationBarMenuButton({required this.tabNotifier}); + + final ValueNotifier tabNotifier; + + HomePageTab get navigationTarget; + + @override + bool get selected => tabNotifier.value == navigationTarget; + + @override + void onPressed(BuildContext context) { + tabNotifier.value = navigationTarget; + } +} + +class _InboxButton extends _NavigationBarMenuButton { + const _InboxButton({required super.tabNotifier}); + + @override + IconData get icon => ZulipIcons.inbox; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.inboxPageTitle; + } + + @override + HomePageTab get navigationTarget => HomePageTab.inbox; +} + +class _MentionsButton extends _MenuButton { + const _MentionsButton(); + + @override + IconData get icon => ZulipIcons.at_sign; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.mentionsPageTitle; + } + + @override + void onPressed(BuildContext context) { + Navigator.of(context).push(MessageListPage.buildRoute( + context: context, narrow: const MentionsNarrow())); + } +} + +class _StarredMessagesButton extends _MenuButton { + const _StarredMessagesButton(); + + @override + IconData get icon => ZulipIcons.star; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.starredMessagesPageTitle; + } + + @override + void onPressed(BuildContext context) { + Navigator.of(context).push(MessageListPage.buildRoute( + context: context, narrow: const StarredMessagesNarrow())); + } +} + +class _CombinedFeedButton extends _MenuButton { + const _CombinedFeedButton(); + + @override + IconData get icon => ZulipIcons.message_feed; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.combinedFeedPageTitle; + } + + @override + void onPressed(BuildContext context) { + Navigator.of(context).push(MessageListPage.buildRoute( + context: context, narrow: const CombinedFeedNarrow())); + } +} + +class _ChannelsButton extends _NavigationBarMenuButton { + const _ChannelsButton({required super.tabNotifier}); + + @override + IconData get icon => ZulipIcons.hash_italic; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.channelsPageTitle; + } + + @override + HomePageTab get navigationTarget => HomePageTab.channels; +} + +class _DirectMessagesButton extends _NavigationBarMenuButton { + const _DirectMessagesButton({required super.tabNotifier}); + + @override + IconData get icon => ZulipIcons.user; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.recentDmConversationsPageTitle; + } + + @override + HomePageTab get navigationTarget => HomePageTab.directMessages; +} + +class _MyProfileButton extends _MenuButton { + const _MyProfileButton(); + + @override + IconData? get icon => null; + + @override + Widget buildLeading(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + return Avatar(userId: store.selfUserId, size: 24, borderRadius: 4); + } + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.mainMenuMyProfile; + } + + @override + void onPressed(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + Navigator.of(context).push( + ProfilePage.buildRoute(context: context, userId: store.selfUserId)); + } +} + +class _SwitchAccountButton extends _MenuButton { + const _SwitchAccountButton(); + + @override + // TODO(design): choose an icon + IconData? get icon => null; + + @override + Widget buildLeading(BuildContext context) => const SizedBox.shrink(); + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.switchAccountButton; + } + + @override + void onPressed(BuildContext context) { + Navigator.of(context).push(MaterialWidgetRoute(page: const ChooseAccountPage())); + } +} + +/// Apply [Transform.scale] to the child widget when tapped, and reset its scale +/// when released, while animating the transitions. +class AnimatedScaleOnTap extends StatefulWidget { + const AnimatedScaleOnTap({ + super.key, + required this.scaleEnd, + required this.duration, + required this.child, + }); + + /// The terminal scale to animate to. + final double scaleEnd; + + /// The duration over which to animate the scale change. + final Duration duration; + + final Widget child; + + @override + State createState() => _AnimatedScaleOnTapState(); +} + +class _AnimatedScaleOnTapState extends State { + double _scale = 1; + + void _changeScale(double scale) { + setState(() { + _scale = scale; + }); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTapDown: (_) => _changeScale(widget.scaleEnd), + onTapUp: (_) => _changeScale(1), + onTapCancel: () => _changeScale(1), + child: AnimatedScale( + scale: _scale, + duration: widget.duration, + curve: Curves.easeOut, + child: widget.child)); } } diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index 088650e4f2..ce1cf09438 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -395,10 +395,7 @@ class _LoginPageState extends State { return; } - unawaited(Navigator.of(context).pushAndRemoveUntil( - HomePage.buildRoute(accountId: accountId), - (route) => (route is! _LoginSequenceRoute)), - ); + HomePage.navigate(context, accountId: accountId); } Future _getUserId(String email, String apiKey) async { diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart index d6afddcee6..bebb37c22c 100644 --- a/lib/widgets/page.dart +++ b/lib/widgets/page.dart @@ -161,7 +161,7 @@ class AccountPageRouteBuilder extends PageRouteBuilder wit final int accountId; @override - Widget? loadingPlaceholderPage; + final Widget? loadingPlaceholderPage; } class LoadingPlaceholderPage extends StatelessWidget { diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 67fcf1adbe..96b492208a 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -119,10 +119,14 @@ class DesignVariables extends ThemeExtension { this._( background: const Color(0xffffffff), bannerBgIntDanger: const Color(0xfff2e4e4), + bgBotBar: const Color(0xfff6f6f6), bgContextMenu: const Color(0xfff2f2f2), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15), + bgMenuButtonActive: Colors.black.withValues(alpha: 0.05), + bgMenuButtonSelected: Colors.white, bgTopBar: const Color(0xfff5f5f5), borderBar: Colors.black.withValues(alpha: 0.2), + borderMenuButtonSelected: Colors.black.withValues(alpha: 0.2), btnLabelAttLowIntDanger: const Color(0xffc0070a), btnLabelAttMediumIntDanger: const Color(0xffac0508), composeBoxBg: const Color(0xffffffff), @@ -132,6 +136,7 @@ class DesignVariables extends ThemeExtension { editorButtonPressedBg: Colors.black.withValues(alpha: 0.06), foreground: const Color(0xff000000), icon: const Color(0xff6159e1), + iconSelected: const Color(0xff222222), labelCounterUnread: const Color(0xff222222), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), @@ -149,6 +154,7 @@ class DesignVariables extends ThemeExtension { loginOrDividerText: const Color(0xff575757), modalBarrierColor: const Color(0xff000000).withValues(alpha: 0.3), mutedUnreadBadge: const HSLColor.fromAHSL(0.5, 0, 0, 0.8).toColor(), + navigationButtonBg: Colors.black.withValues(alpha: 0.05), sectionCollapseIcon: const Color(0x7f1e2e48), star: const HSLColor.fromAHSL(0.5, 47, 1, 0.41).toColor(), subscriptionListHeaderLine: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor(), @@ -160,10 +166,14 @@ class DesignVariables extends ThemeExtension { this._( background: const Color(0xff000000), bannerBgIntDanger: const Color(0xff461616), + bgBotBar: const Color(0xff222222), bgContextMenu: const Color(0xff262626), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37), + bgMenuButtonActive: Colors.black.withValues(alpha: 0.2), + bgMenuButtonSelected: Colors.black.withValues(alpha: 0.25), bgTopBar: const Color(0xff242424), borderBar: Colors.black.withValues(alpha: 0.5), + borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1), btnLabelAttLowIntDanger: const Color(0xffff8b7c), btnLabelAttMediumIntDanger: const Color(0xffff8b7c), composeBoxBg: const Color(0xff0f0f0f), @@ -173,6 +183,7 @@ class DesignVariables extends ThemeExtension { editorButtonPressedBg: Colors.white.withValues(alpha: 0.06), foreground: const Color(0xffffffff), icon: const Color(0xff7977fe), + iconSelected: Colors.white.withValues(alpha: 0.8), labelCounterUnread: const Color(0xffffffff).withValues(alpha: 0.7), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), @@ -194,6 +205,7 @@ class DesignVariables extends ThemeExtension { modalBarrierColor: const Color(0xff000000).withValues(alpha: 0.5), // TODO(design-dark) need proper dark-theme color (this is ad hoc) mutedUnreadBadge: const HSLColor.fromAHSL(0.5, 0, 0, 0.6).toColor(), + navigationButtonBg: Colors.white.withValues(alpha: 0.05), // TODO(design-dark) need proper dark-theme color (this is ad hoc) sectionCollapseIcon: const Color(0x7fb6c8e2), // TODO(design-dark) unchanged in dark theme? @@ -208,10 +220,14 @@ class DesignVariables extends ThemeExtension { DesignVariables._({ required this.background, required this.bannerBgIntDanger, + required this.bgBotBar, required this.bgContextMenu, required this.bgCounterUnread, + required this.bgMenuButtonActive, + required this.bgMenuButtonSelected, required this.bgTopBar, required this.borderBar, + required this.borderMenuButtonSelected, required this.btnLabelAttLowIntDanger, required this.btnLabelAttMediumIntDanger, required this.composeBoxBg, @@ -221,6 +237,7 @@ class DesignVariables extends ThemeExtension { required this.editorButtonPressedBg, required this.foreground, required this.icon, + required this.iconSelected, required this.labelCounterUnread, required this.labelEdited, required this.labelMenuButton, @@ -238,6 +255,7 @@ class DesignVariables extends ThemeExtension { required this.loginOrDividerText, required this.modalBarrierColor, required this.mutedUnreadBadge, + required this.navigationButtonBg, required this.sectionCollapseIcon, required this.star, required this.subscriptionListHeaderLine, @@ -257,10 +275,14 @@ class DesignVariables extends ThemeExtension { final Color background; final Color bannerBgIntDanger; + final Color bgBotBar; final Color bgContextMenu; final Color bgCounterUnread; + final Color bgMenuButtonActive; + final Color bgMenuButtonSelected; final Color bgTopBar; final Color borderBar; + final Color borderMenuButtonSelected; final Color btnLabelAttLowIntDanger; final Color btnLabelAttMediumIntDanger; final Color composeBoxBg; @@ -270,6 +292,7 @@ class DesignVariables extends ThemeExtension { final Color editorButtonPressedBg; final Color foreground; final Color icon; + final Color iconSelected; final Color labelCounterUnread; final Color labelEdited; final Color labelMenuButton; @@ -291,6 +314,7 @@ class DesignVariables extends ThemeExtension { final Color loginOrDividerText; // TODO(design-dark) need proper dark-theme color (this is ad hoc) final Color modalBarrierColor; final Color mutedUnreadBadge; + final Color navigationButtonBg; final Color sectionCollapseIcon; final Color star; final Color subscriptionListHeaderLine; @@ -301,10 +325,14 @@ class DesignVariables extends ThemeExtension { DesignVariables copyWith({ Color? background, Color? bannerBgIntDanger, + Color? bgBotBar, Color? bgContextMenu, Color? bgCounterUnread, + Color? bgMenuButtonActive, + Color? bgMenuButtonSelected, Color? bgTopBar, Color? borderBar, + Color? borderMenuButtonSelected, Color? btnLabelAttLowIntDanger, Color? btnLabelAttMediumIntDanger, Color? composeBoxBg, @@ -314,6 +342,7 @@ class DesignVariables extends ThemeExtension { Color? editorButtonPressedBg, Color? foreground, Color? icon, + Color? iconSelected, Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, @@ -331,6 +360,7 @@ class DesignVariables extends ThemeExtension { Color? loginOrDividerText, Color? modalBarrierColor, Color? mutedUnreadBadge, + Color? navigationButtonBg, Color? sectionCollapseIcon, Color? star, Color? subscriptionListHeaderLine, @@ -340,10 +370,14 @@ class DesignVariables extends ThemeExtension { return DesignVariables._( background: background ?? this.background, bannerBgIntDanger: bannerBgIntDanger ?? this.bannerBgIntDanger, + bgBotBar: bgBotBar ?? this.bgBotBar, bgContextMenu: bgContextMenu ?? this.bgContextMenu, bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread, + bgMenuButtonActive: bgMenuButtonActive ?? this.bgMenuButtonActive, + bgMenuButtonSelected: bgMenuButtonSelected ?? this.bgMenuButtonSelected, bgTopBar: bgTopBar ?? this.bgTopBar, borderBar: borderBar ?? this.borderBar, + borderMenuButtonSelected: borderMenuButtonSelected ?? this.borderMenuButtonSelected, btnLabelAttLowIntDanger: btnLabelAttLowIntDanger ?? this.btnLabelAttLowIntDanger, btnLabelAttMediumIntDanger: btnLabelAttMediumIntDanger ?? this.btnLabelAttMediumIntDanger, composeBoxBg: composeBoxBg ?? this.composeBoxBg, @@ -353,6 +387,7 @@ class DesignVariables extends ThemeExtension { editorButtonPressedBg: editorButtonPressedBg ?? this.editorButtonPressedBg, foreground: foreground ?? this.foreground, icon: icon ?? this.icon, + iconSelected: iconSelected ?? this.iconSelected, labelCounterUnread: labelCounterUnread ?? this.labelCounterUnread, labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, @@ -370,6 +405,7 @@ class DesignVariables extends ThemeExtension { loginOrDividerText: loginOrDividerText ?? this.loginOrDividerText, modalBarrierColor: modalBarrierColor ?? this.modalBarrierColor, mutedUnreadBadge: mutedUnreadBadge ?? this.mutedUnreadBadge, + navigationButtonBg: navigationButtonBg ?? this.navigationButtonBg, sectionCollapseIcon: sectionCollapseIcon ?? this.sectionCollapseIcon, star: star ?? this.star, subscriptionListHeaderLine: subscriptionListHeaderLine ?? this.subscriptionListHeaderLine, @@ -386,10 +422,14 @@ class DesignVariables extends ThemeExtension { return DesignVariables._( background: Color.lerp(background, other.background, t)!, bannerBgIntDanger: Color.lerp(bannerBgIntDanger, other.bannerBgIntDanger, t)!, + bgBotBar: Color.lerp(bgBotBar, other.bgBotBar, t)!, bgContextMenu: Color.lerp(bgContextMenu, other.bgContextMenu, t)!, bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!, + bgMenuButtonActive: Color.lerp(bgMenuButtonActive, other.bgMenuButtonActive, t)!, + bgMenuButtonSelected: Color.lerp(bgMenuButtonSelected, other.bgMenuButtonSelected, t)!, bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!, borderBar: Color.lerp(borderBar, other.borderBar, t)!, + borderMenuButtonSelected: Color.lerp(borderMenuButtonSelected, other.borderMenuButtonSelected, t)!, btnLabelAttLowIntDanger: Color.lerp(btnLabelAttLowIntDanger, other.btnLabelAttLowIntDanger, t)!, btnLabelAttMediumIntDanger: Color.lerp(btnLabelAttMediumIntDanger, other.btnLabelAttMediumIntDanger, t)!, composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!, @@ -399,6 +439,7 @@ class DesignVariables extends ThemeExtension { editorButtonPressedBg: Color.lerp(editorButtonPressedBg, other.editorButtonPressedBg, t)!, foreground: Color.lerp(foreground, other.foreground, t)!, icon: Color.lerp(icon, other.icon, t)!, + iconSelected: Color.lerp(iconSelected, other.iconSelected, t)!, labelCounterUnread: Color.lerp(labelCounterUnread, other.labelCounterUnread, t)!, labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, @@ -416,6 +457,7 @@ class DesignVariables extends ThemeExtension { loginOrDividerText: Color.lerp(loginOrDividerText, other.loginOrDividerText, t)!, modalBarrierColor: Color.lerp(modalBarrierColor, other.modalBarrierColor, t)!, mutedUnreadBadge: Color.lerp(mutedUnreadBadge, other.mutedUnreadBadge, t)!, + navigationButtonBg: Color.lerp(navigationButtonBg, other.navigationButtonBg, t)!, sectionCollapseIcon: Color.lerp(sectionCollapseIcon, other.sectionCollapseIcon, t)!, star: Color.lerp(star, other.star, t)!, subscriptionListHeaderLine: Color.lerp(subscriptionListHeaderLine, other.subscriptionListHeaderLine, t)!, diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 9d81e8ea20..d2b4b6f38d 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -44,6 +44,7 @@ extension IconChecks on Subject { } extension RouteChecks on Subject> { + Subject get isFirst => has((r) => r.isFirst, 'isFirst'); Subject get settings => has((r) => r.settings, 'settings'); } diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 202beba5e5..6924190a9a 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -22,7 +22,6 @@ import 'package:zulip/notifications/receive.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/home.dart'; -import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/theme.dart'; @@ -930,15 +929,13 @@ void main() { void takeStartingRoutes({bool withAccount = true}) { final expected = >[ - (it) => it.isA().page.isA(), if (withAccount) ...[ (it) => it.isA() ..accountId.equals(eg.selfAccount.id) ..page.isA(), - (it) => it.isA() - ..accountId.equals(eg.selfAccount.id) - ..page.isA(), ], + if (!withAccount) + (it) => it.isA().page.isA(), ]; check(pushedRoutes.take(expected.length)).deepEquals(expected); pushedRoutes.removeRange(0, expected.length); diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index 366724d09a..2b5d29b682 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -5,7 +7,6 @@ import 'package:zulip/log.dart'; import 'package:zulip/model/database.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/home.dart'; -import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/page.dart'; import '../example_data.dart' as eg; @@ -41,7 +42,7 @@ void main() { ]); }); - testWidgets('when have accounts, go to inbox for first account', (tester) async { + testWidgets('when have accounts, go to home page for first account', (tester) async { // 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()); @@ -49,13 +50,9 @@ void main() { await prepare(tester); check(pushedRoutes).deepEquals(>[ - (it) => it.isA().page.isA(), (it) => it.isA() ..accountId.equals(eg.selfAccount.id) ..page.isA(), - (it) => it.isA() - ..accountId.equals(eg.selfAccount.id) - ..page.isA(), ]); }); }); @@ -162,6 +159,45 @@ void main() { ..bottom.isLessThan(2 / 3 * screenHeight); }); + testWidgets('choosing an account clears the navigator stack', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot()); + + final pushedRoutes = >[]; + final poppedRoutes = >[]; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); + testNavObserver.onPopped = (route, prevRoute) => poppedRoutes.add(route); + testNavObserver.onReplaced = (route, prevRoute) { + poppedRoutes.add(prevRoute!); + pushedRoutes.add(route!); + }; + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + await tester.pump(); + + final navigator = await ZulipApp.navigator; + unawaited(navigator.push( + MaterialWidgetRoute(page: const ChooseAccountPage()))); + await tester.pumpAndSettle(); + + check(poppedRoutes).isEmpty(); + check(pushedRoutes).deepEquals(>[ + (it) => it.isA() + ..accountId.equals(eg.selfAccount.id) + ..page.isA(), + (it) => it.isA().page.isA() + ]); + pushedRoutes.clear(); + + await tester.tap(find.text(eg.otherAccount.email)); + await tester.pump(); + check(poppedRoutes).length.equals(2); + check(pushedRoutes).single.isA() + ..accountId.equals(eg.otherAccount.id) + ..page.isA(); + }); + group('log out', () { Future<(Widget, Widget)> prepare(WidgetTester tester, {required Account account}) async { await setupChooseAccountPage(tester, accounts: [account]); diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart new file mode 100644 index 0000000000..0093a95040 --- /dev/null +++ b/test/widgets/home_test.dart @@ -0,0 +1,410 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/inbox.dart'; +import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/subscription_list.dart'; +import 'package:zulip/widgets/theme.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../flutter_checks.dart'; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import 'page_checks.dart'; +import 'test_app.dart'; + +void main () { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + + Future prepare(WidgetTester tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + await store.addUsers([eg.selfUser, eg.otherUser]); + final stream = eg.stream(); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + await store.handleEvent(MessageEvent( + id: 0, message: eg.streamMessage(stream: stream, sender: eg.otherUser))); + + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: const HomePage())); + await tester.pumpAndSettle(); + } + + group('bottom nav navigation', () { + testWidgets('preserve states when switching between views', (tester) async { + await prepare(tester); + + check(find.byIcon(ZulipIcons.arrow_down)).findsExactly(2); + check(find.byIcon(ZulipIcons.arrow_right)).findsNothing(); + + // Collapsing the header updates inbox's internal state. + await tester.tap(find.byIcon(ZulipIcons.arrow_down).first); + await tester.pump(); + check(find.byIcon(ZulipIcons.arrow_down)).findsNothing(); + check(find.byIcon(ZulipIcons.arrow_right)).findsExactly(2); + + // Switch to channels view. + await tester.tap(find.byIcon(ZulipIcons.hash_italic)); + await tester.pump(); + check(find.byIcon(ZulipIcons.arrow_down)).findsNothing(); + check(find.byIcon(ZulipIcons.arrow_right)).findsNothing(); + + // The header should remain collapsed when we return to the inbox. + await tester.tap(find.byIcon(ZulipIcons.inbox)); + await tester.pump(); + check(find.byIcon(ZulipIcons.arrow_down)).findsNothing(); + check(find.byIcon(ZulipIcons.arrow_right)).findsExactly(2); + }); + + testWidgets('update app bar title when switching between views', (tester) async { + await prepare(tester); + + check(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text('Inbox'))).findsOne(); + + await tester.tap(find.byIcon(ZulipIcons.hash_italic)); + await tester.pump(); + check(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text('Channels'))).findsOne(); + + await tester.tap(find.byIcon(ZulipIcons.user)); + await tester.pump(); + check(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text('Direct messages'))).findsOne(); + }); + }); + + group('menu', () { + final designVariables = DesignVariables.light(); + + final inboxMenuIconFinder = find.descendant( + of: find.byType(SingleChildScrollView), + matching: find.byIcon(ZulipIcons.inbox)); + final channelsMenuIconFinder = find.descendant( + of: find.byType(SingleChildScrollView), + matching: find.byIcon(ZulipIcons.hash_italic)); + final combinedFeedMenuIconFinder = find.descendant( + of: find.byType(SingleChildScrollView), + matching: find.byIcon(ZulipIcons.message_feed)); + + Future tapOpenMenu(WidgetTester tester) async { + await tester.tap(find.byIcon(ZulipIcons.menu)); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 250)); // wait for animation + check(find.byType(SingleChildScrollView)).findsOne(); + } + + void checkIconSelected(WidgetTester tester, Finder finder) { + check(tester.widget(finder)).isA().color.isNotNull() + .isSameColorAs(designVariables.iconSelected); + } + + void checkIconNotSelected(WidgetTester tester, Finder finder) { + check(tester.widget(finder)).isA().color.isNotNull() + .isSameColorAs(designVariables.icon); + } + + testWidgets('navigation states reflect on navigation bar menu buttons', (tester) async { + await prepare(tester); + + await tapOpenMenu(tester); + checkIconSelected(tester, inboxMenuIconFinder); + checkIconNotSelected(tester, channelsMenuIconFinder); + await tester.tap(find.text('Cancel')); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 250)); // wait for animation + + await tester.tap(find.byIcon(ZulipIcons.hash_italic)); + await tester.pump(); + + await tapOpenMenu(tester); + checkIconNotSelected(tester, inboxMenuIconFinder); + checkIconSelected(tester, channelsMenuIconFinder); + }); + + testWidgets('navigation bar menu buttons control navigation states', (tester) async { + await prepare(tester); + + await tapOpenMenu(tester); + checkIconSelected(tester, inboxMenuIconFinder); + checkIconNotSelected(tester, channelsMenuIconFinder); + check(find.byType(InboxPageBody)).findsOne(); + check(find.byType(SubscriptionListPageBody)).findsNothing(); + + await tester.tap(channelsMenuIconFinder); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 250)); // wait for animation + check(find.byType(SingleChildScrollView)).findsNothing(); + check(find.byType(InboxPageBody)).findsNothing(); + check(find.byType(SubscriptionListPageBody)).findsOne(); + + await tapOpenMenu(tester); + checkIconNotSelected(tester, inboxMenuIconFinder); + checkIconSelected(tester, channelsMenuIconFinder); + }); + + testWidgets('navigation bar menu buttons dismiss the menu', (tester) async { + await prepare(tester); + await tapOpenMenu(tester); + + await tester.tap(channelsMenuIconFinder); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 250)); // wait for animation + check(find.byType(SingleChildScrollView)).findsNothing(); + }); + + testWidgets('cancel button dismisses the menu', (tester) async { + await prepare(tester); + await tapOpenMenu(tester); + + await tester.tap(find.text('Cancel')); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 250)); // wait for animation + check(find.byType(SingleChildScrollView)).findsNothing(); + }); + + testWidgets('menu buttons dismiss the menu', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + + await tester.pumpWidget(const ZulipApp()); + await tester.pumpAndSettle(); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final connection = store.connection as FakeApiConnection; + + await tapOpenMenu(tester); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [eg.streamMessage()]).toJson()); + await tester.tap(combinedFeedMenuIconFinder); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 250)); // wait for animation + + // When we go back to the home page, the menu sheet should have gone. + (await ZulipApp.navigator).pop(); + await tester.pumpAndSettle(); + check(find.byType(SingleChildScrollView)).findsNothing(); + }); + + testWidgets('_MyProfileButton', (tester) async { + await prepare(tester); + await tapOpenMenu(tester); + + await tester.tap(find.text('My profile')); + await tester.pumpAndSettle(); + check(find.byType(ProfilePage)).findsOne(); + check(find.text(eg.selfUser.fullName)).findsAny(); + }); + }); + + group('_LoadingPlaceholderPage', () { + const loadPerAccountDuration = Duration(seconds: 30); + assert(loadPerAccountDuration > kTryAnotherAccountWaitPeriod); + + void checkOnLoadingPage() { + check(find.byType(CircularProgressIndicator).hitTestable()).findsOne(); + check(find.byType(ChooseAccountPage)).findsNothing(); + check(find.byType(HomePage)).findsNothing(); + } + + void checkOnChooseAccountPage() { + // Ignore the possible loading page in the background. + check(find.byType(CircularProgressIndicator).hitTestable()).findsNothing(); + check(find.byType(ChooseAccountPage)).findsOne(); + check(find.byType(HomePage)).findsNothing(); + } + + ModalRoute? getRouteOf(WidgetTester tester, Finder elementFinder) => + ModalRoute.of(tester.element(elementFinder)); + + void checkOnHomePage(WidgetTester tester, {required Account expectedAccount}) { + check(find.byType(CircularProgressIndicator)).findsNothing(); + check(find.byType(ChooseAccountPage)).findsNothing(); + check(find.byType(HomePage).hitTestable()).findsOne(); + check(getRouteOf(tester, find.byType(HomePage))) + .isA().accountId.equals(expectedAccount.id); + } + + Future prepare(WidgetTester tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot()); + await tester.pumpWidget(const ZulipApp()); + await tester.pump(Duration.zero); // wait for the loading page + checkOnLoadingPage(); + } + + Future tapChooseAccount(WidgetTester tester) async { + await tester.tap(find.text('Try another account')); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 250)); // wait for animation + checkOnChooseAccountPage(); + } + + Future chooseAccountWithEmail(WidgetTester tester, String email) async { + await tester.tap(find.text(email)); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 350)); // wait for push & pop animations + checkOnLoadingPage(); + } + + testWidgets('smoke', (tester) async { + addTearDown(testBinding.reset); + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; + await prepare(tester); + await tester.pump(loadPerAccountDuration); + checkOnHomePage(tester, expectedAccount: eg.selfAccount); + }); + + testWidgets('"Try another account" button appears after timeout', (tester) async { + addTearDown(testBinding.reset); + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; + await prepare(tester); + checkOnLoadingPage(); + check(find.text('Try another account').hitTestable()).findsNothing(); + + await tester.pump(kTryAnotherAccountWaitPeriod); + checkOnLoadingPage(); + check(find.text('Try another account').hitTestable()).findsOne(); + + await tester.pump(loadPerAccountDuration); + checkOnHomePage(tester, expectedAccount: eg.selfAccount); + }); + + testWidgets('while loading, go back from ChooseAccountPage', (tester) async { + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; + await prepare(tester); + await tester.pump(kTryAnotherAccountWaitPeriod); + await tapChooseAccount(tester); + + await tester.tap(find.byType(BackButton)); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + checkOnLoadingPage(); + + await tester.pump(loadPerAccountDuration); + checkOnHomePage(tester, expectedAccount: eg.selfAccount); + }); + + testWidgets('while loading, choose a different account', (tester) async { + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; + await prepare(tester); + await tester.pump(kTryAnotherAccountWaitPeriod); + await tapChooseAccount(tester); + + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration * 2; + await chooseAccountWithEmail(tester, eg.otherAccount.email); + + // The second loadPerAccount is still pending. + await tester.pump(loadPerAccountDuration); + checkOnLoadingPage(); + + // The second loadPerAccount finished. + await tester.pump(loadPerAccountDuration); + checkOnHomePage(tester, expectedAccount: eg.otherAccount); + }); + + testWidgets('while loading, choosing an account disallow going back', (tester) async { + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; + await prepare(tester); + await tester.pump(kTryAnotherAccountWaitPeriod); + await tapChooseAccount(tester); + + // While still loading, choose a different account. + await chooseAccountWithEmail(tester, eg.otherAccount.email); + + // User cannot go back because the navigator stack + // was cleared after choosing an account. + check(getRouteOf(tester, find.byType(CircularProgressIndicator))) + .isNotNull().isFirst.isTrue(); + + await tester.pump(loadPerAccountDuration); // wait for loadPerAccount + checkOnHomePage(tester, expectedAccount: eg.otherAccount); + }); + + testWidgets('while loading, go to nested levels of ChooseAccountPage', (tester) async { + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; + final thirdAccount = eg.account(user: eg.thirdUser); + await testBinding.globalStore.add(thirdAccount, eg.initialSnapshot()); + await prepare(tester); + + // While still loading the first account, choose a different account. + await tester.pump(kTryAnotherAccountWaitPeriod); + await tapChooseAccount(tester); + await chooseAccountWithEmail(tester, eg.otherAccount.email); + // User cannot go back because the navigator stack + // was cleared after choosing an account. + check(getRouteOf(tester, find.byType(CircularProgressIndicator))) + .isA() + ..isFirst.isTrue() + ..accountId.equals(eg.otherAccount.id); + + // While still loading the second account, choose a different account. + await tester.pump(kTryAnotherAccountWaitPeriod); + await tapChooseAccount(tester); + await chooseAccountWithEmail(tester, thirdAccount.email); + // User cannot go back because the navigator stack + // was cleared after choosing an account. + check(getRouteOf(tester, find.byType(CircularProgressIndicator))) + .isA() + ..isFirst.isTrue() + ..accountId.equals(thirdAccount.id); + + await tester.pump(loadPerAccountDuration); // wait for loadPerAccount + checkOnHomePage(tester, expectedAccount: thirdAccount); + }); + + testWidgets('after finishing loading, go back from ChooseAccountPage', (tester) async { + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; + await prepare(tester); + await tester.pump(kTryAnotherAccountWaitPeriod); + await tapChooseAccount(tester); + + // Stall while on ChoooseAccountPage so that the account finished loading. + await tester.pump(loadPerAccountDuration); + checkOnChooseAccountPage(); + + await tester.tap(find.byType(BackButton)); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 350)); // wait for pop animation + checkOnHomePage(tester, expectedAccount: eg.selfAccount); + }); + + testWidgets('after finishing loading, choose the loaded account', (tester) async { + testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration; + await prepare(tester); + await tester.pump(kTryAnotherAccountWaitPeriod); + await tapChooseAccount(tester); + + // Stall while on ChoooseAccountPage so that the account finished loading. + await tester.pump(loadPerAccountDuration); + checkOnChooseAccountPage(); + + // Choosing the already loaded account should result in no loading page. + await tester.tap(find.text(eg.selfAccount.email)); + await tester.pump(Duration.zero); // tap the button + await tester.pump(const Duration(milliseconds: 350)); // wait for push & pop animations + // No additional wait for loadPerAccount. + checkOnHomePage(tester, expectedAccount: eg.selfAccount); + }); + }); +} diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart index 676cef9ff5..e25913c0c5 100644 --- a/test/widgets/login_test.dart +++ b/test/widgets/login_test.dart @@ -70,6 +70,7 @@ void main() { group('LoginPage', () { late FakeApiConnection connection; late List> pushedRoutes; + late List> poppedRoutes; void takeStartingRoutes() { final expected = >[ @@ -89,8 +90,14 @@ void main() { zulipFeatureLevel: serverSettings.zulipFeatureLevel); pushedRoutes = []; - final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + poppedRoutes = []; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route); + testNavObserver.onPopped = (route, prevRoute) => poppedRoutes.add(route); + testNavObserver.onReplaced = (route, prevRoute) { + poppedRoutes.add(prevRoute!); + pushedRoutes.add(route!); + }; await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); await tester.pump(); final navigator = await ZulipApp.navigator; @@ -144,6 +151,25 @@ void main() { id: testBinding.globalStore.accounts.single.id)); }); + testWidgets('logging into a different account', (tester) async { + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final serverSettings = eg.serverSettings(); + await prepare(tester, serverSettings); + check(poppedRoutes).isEmpty(); + check(pushedRoutes).deepEquals(>[ + (it) => it.isA().page.isA(), + (it) => it.isA().page.isA(), + ]); + pushedRoutes.clear(); + + await login(tester, eg.otherAccount); + final newAccount = testBinding.globalStore.accounts.singleWhere( + (account) => account != eg.selfAccount); + check(newAccount).equals(eg.otherAccount.copyWith(id: newAccount.id)); + check(poppedRoutes).length.equals(2); + check(pushedRoutes).single.isA().page.isA(); + }); + testWidgets('trims whitespace on username', (tester) async { final serverSettings = eg.serverSettings(); await prepare(tester, serverSettings); @@ -192,7 +218,6 @@ void main() { }); // TODO test validators on the TextFormField widgets - // TODO test navigation, i.e. the call to pushAndRemoveUntil // TODO test _getUserId case // TODO test handling failure in fetchApiKey request // TODO test _inProgress logic