diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 1b8e6ff8b8..d8b8503f83 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": "You 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://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..fe2909eeeb 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: + /// **'You 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..e67fb29c48 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 'You 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..961ab6c0a9 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 'You 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..7e29101c70 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 'You 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..77d2c22961 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), - ], ]; }); })); @@ -229,7 +227,6 @@ class ChooseAccountPage extends StatelessWidget { required Widget title, Widget? subtitle, }) { - final colorScheme = ColorScheme.of(context); final designVariables = DesignVariables.of(context); final zulipLocalizations = ZulipLocalizations.of(context); final materialLocalizations = MaterialLocalizations.of(context); @@ -238,8 +235,6 @@ class ChooseAccountPage extends StatelessWidget { child: ListTile( title: title, subtitle: subtitle, - tileColor: colorScheme.secondaryContainer, - textColor: colorScheme.onSecondaryContainer, trailing: MenuAnchor( menuChildren: [ MenuItemButton( @@ -271,8 +266,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 +275,18 @@ 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..b05195ff96 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -1,91 +1,596 @@ +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: 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 92b76a4fbc..322ee0cbb9 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