From 6cc9d476ef94c922f791928cf024efb7dd8f5187 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 15 Nov 2024 13:01:52 -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". Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 12 + lib/generated/l10n/zulip_localizations.dart | 18 + .../l10n/zulip_localizations_ar.dart | 9 + .../l10n/zulip_localizations_en.dart | 9 + .../l10n/zulip_localizations_ja.dart | 9 + lib/widgets/home.dart | 535 +++++++++++++++--- lib/widgets/theme.dart | 35 ++ test/widgets/home_test.dart | 155 +++++ 8 files changed, 711 insertions(+), 71 deletions(-) create mode 100644 test/widgets/home_test.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 5e5af5e0813..a4c1c25c222 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -529,6 +529,10 @@ "@userRoleUnknown": { "description": "Label for UserRole.unknown" }, + "inboxButton": "Inbox", + "@inboxButton": { + "description": "Label for the menu button switching to inbox." + }, "recentDmConversationsPageTitle": "Direct messages", "@recentDmConversationsPageTitle": { "description": "Title for the page of recent DM conversations" @@ -545,6 +549,14 @@ "@starredMessagesPageTitle": { "description": "Title for the page of starred messages." }, + "channelsButton": "Channels", + "@channelsButton": { + "description": "Label for the menu button switching to channels." + }, + "profilePageTitle": "My profile", + "@profilePageTitle": { + "description": "Title for the page of the logged in user's 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 5c830f2174e..a41aca83e26 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -799,6 +799,12 @@ abstract class ZulipLocalizations { /// **'Unknown'** String get userRoleUnknown; + /// Label for the menu button switching to inbox. + /// + /// In en, this message translates to: + /// **'Inbox'** + String get inboxButton; + /// Title for the page of recent DM conversations /// /// In en, this message translates to: @@ -823,6 +829,18 @@ abstract class ZulipLocalizations { /// **'Starred messages'** String get starredMessagesPageTitle; + /// Label for the menu button switching to channels. + /// + /// In en, this message translates to: + /// **'Channels'** + String get channelsButton; + + /// Title for the page of the logged in user's profile. + /// + /// In en, this message translates to: + /// **'My profile'** + String get profilePageTitle; + /// 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 f749862c8ef..8338f44738c 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -419,6 +419,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get inboxButton => 'Inbox'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @@ -431,6 +434,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get starredMessagesPageTitle => 'Starred messages'; + @override + String get channelsButton => 'Channels'; + + @override + String get profilePageTitle => '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 4033238fbd7..308a0de8129 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -419,6 +419,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get inboxButton => 'Inbox'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @@ -431,6 +434,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get starredMessagesPageTitle => 'Starred messages'; + @override + String get channelsButton => 'Channels'; + + @override + String get profilePageTitle => '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 075120e5b21..9a950b4b5fe 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -419,6 +419,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get userRoleUnknown => '不明'; + @override + String get inboxButton => 'Inbox'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @@ -431,6 +434,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get starredMessagesPageTitle => 'Starred messages'; + @override + String get channelsButton => 'Channels'; + + @override + String get profilePageTitle => 'My profile'; + @override String get channelFeedButtonTooltip => 'Channel feed'; diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index fd115370de3..e7eb24ddd43 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -2,96 +2,489 @@ import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; -import 'app_bar.dart'; +import 'action_sheet.dart'; +import 'app.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'; -class HomePage extends StatelessWidget { - const HomePage({super.key}); +enum HomePageTab { + inbox, + channels, + directMessages, +} + +typedef NavigationButtonBuilder = Widget Function(HomePageTab tab); + +class HomePage extends StatefulWidget { + const HomePage({super.key, required this.initialTab}); - static Route buildRoute({required int accountId}) { + static Route buildRoute({required int accountId, HomePageTab? initialTab}) { return MaterialAccountWidgetRoute(accountId: accountId, - page: const HomePage()); + page: HomePage(initialTab: initialTab ?? HomePageTab.inbox)); } + final HomePageTab initialTab; + @override - Widget build(BuildContext context) { - final store = PerAccountStoreWidget.of(context); - final zulipLocalizations = ZulipLocalizations.of(context); + State createState() => _HomePageState(); +} - final colorScheme = ColorScheme.of(context); +class _HomePageState extends State { + late final ValueNotifier _tab = ValueNotifier(widget.initialTab); - InlineSpan bold(String text) => TextSpan( - style: const TextStyle().merge(weightVariableTextStyle(context, wght: 700)), - text: text); + @override + void initState() { + super.initState(); + _tab.addListener(_tabChanged); + } - int? testStreamId; - if (store.connection.realmUrl.origin == 'https://chat.zulip.org') { - testStreamId = 7; // i.e. `#test here`; TODO cut this scaffolding hack + @override + void dispose() { + _tab.dispose(); + super.dispose(); + } + + void _tabChanged() { + setState(() { + // The actual state lives in [_tab]. + }); + } + + @override + Widget build(BuildContext context) { + const pageBodies = [ + (HomePageTab.inbox, InboxPageBody()), + (HomePageTab.channels, SubscriptionListPageBody()), + // TODO(#1094): Users + (HomePageTab.directMessages, RecentDmConversationsPageBody()), + ]; + + NavigationButton button(HomePageTab tab, IconData icon) { + return NavigationButton(icon: icon, + selected: _tab.value == tab, + onPressed: () { + _tab.value = tab; + }); } + // TODO(a11y): add tooltips for the buttons + final navigationButtons = { + button(HomePageTab.inbox, ZulipIcons.inbox), + button(HomePageTab.channels, ZulipIcons.hash_italic), + // TODO(#1094): Users + button(HomePageTab.directMessages, ZulipIcons.user), + NavigationButton(icon: ZulipIcons.menu, selected: false, + onPressed: () => _showMenu(context, tab: _tab)), + }; + + final designVariables = DesignVariables.of(context); return Scaffold( - appBar: ZulipAppBar(title: const Text("Home")), - body: ElevatedButtonTheme( - data: ElevatedButtonThemeData(style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll(colorScheme.secondaryContainer), - foregroundColor: WidgetStatePropertyAll(colorScheme.onSecondaryContainer))), - child: 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 - ], + body: Stack( + children: [ + for (final (tab, body) in pageBodies) + Offstage(offstage: tab != _tab.value, child: body), + ]), + bottomNavigationBar: SafeArea( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: designVariables.borderBar, width: 0)), + color: designVariables.bgBotBar), + child: Align( + // This is necessary to limit the width of the row of icons, + // because [Align] allows the child to be smaller than itself. + heightFactor: 1, + child: ConstrainedBox( + // TODO(design): determine a suitable max width + constraints: const BoxConstraints(maxWidth: 600).tighten(height: 48), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final navigationButton in navigationButtons) + Expanded(child: navigationButton), + ])))))); + } +} + +class NavigationButton extends StatelessWidget { + const NavigationButton({ + super.key, + 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.iconSelected.withValues(alpha: 0.05), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))), + ).copyWith(foregroundColor: iconColor))); + } +} + +void _showMenu(BuildContext context, { + required ValueNotifier tab, +}) { + final menuItems = [ + // TODO(#252): Search + // const SizedBox(height: 8), + _InboxButton(tab: tab), + // TODO: Recent conversations + const _MentionsButton(), + const _StarredMessagesButton(), + const _CombinedFeedButton(), + // TODO: Drafts + _ChannelsButton(tab: tab), + _DirectMessagesButton(tab: tab), + // 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, + backgroundColor: designVariables.bgBotBar, + builder: (BuildContext _) { + return PerAccountStoreWidget( + accountId: accountId, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded(child: InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgBotBar, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: menuItems)))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AnimatedScaleOnTap( + scaleEnd: 0.95, + duration: const Duration(milliseconds: 100), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: const 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); + + @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, + splashFactory: NoSplash.splashFactory, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ).copyWith( + backgroundColor: WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.bgMenuButtonActive, + ~WidgetState.pressed: 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: () => onPressed(context), + style: buttonStyle, + child: Row(spacing: 8, children: [ + buildLeading(context), + Expanded(child: Text(label(zulipLocalizations), + textAlign: TextAlign.start, + // 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)))), ])))); } } + +abstract class _NavigationBarMenuButton extends _MenuButton { + const _NavigationBarMenuButton({required this.tab}); + + final ValueNotifier tab; + + HomePageTab get target; + + @override + bool get selected => tab.value == target; + + @override + void onPressed(BuildContext context) async { + tab.value = target; + Navigator.of(context).pop(); + } +} + +class _InboxButton extends _NavigationBarMenuButton { + const _InboxButton({required super.tab}); + + @override + IconData get icon => ZulipIcons.inbox; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.inboxButton; + } + + @override + HomePageTab get target => 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.tab}); + + @override + IconData get icon => ZulipIcons.hash_italic; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.channelsButton; + } + + @override + HomePageTab get target => HomePageTab.channels; +} + +class _DirectMessagesButton extends _NavigationBarMenuButton { + const _DirectMessagesButton({required super.tab}); + + @override + IconData get icon => ZulipIcons.user; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.recentDmConversationsPageTitle; + } + + @override + HomePageTab get target => 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.profilePageTitle; + } + + @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 => ZulipIcons.globe; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.switchAccountPageTitle; + } + + @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/theme.dart b/lib/widgets/theme.dart index a115ee5a283..0c48fae64ee 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -114,10 +114,14 @@ class DesignVariables extends ThemeExtension { DesignVariables.light() : this._( background: const Color(0xffffffff), + 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), composeBoxBg: const Color(0xffffffff), contextMenuCancelText: const Color(0xff222222), contextMenuItemBg: const Color(0xff6159e1), @@ -125,6 +129,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), @@ -155,10 +160,14 @@ class DesignVariables extends ThemeExtension { DesignVariables.dark() : this._( background: const Color(0xff000000), + 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), composeBoxBg: const Color(0xff0f0f0f), contextMenuCancelText: const Color(0xffffffff).withValues(alpha: 0.75), contextMenuItemBg: const Color(0xff7977fe), @@ -166,6 +175,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), @@ -203,10 +213,14 @@ class DesignVariables extends ThemeExtension { DesignVariables._({ required this.background, + 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.composeBoxBg, required this.contextMenuCancelText, required this.contextMenuItemBg, @@ -214,6 +228,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, @@ -252,10 +267,14 @@ class DesignVariables extends ThemeExtension { } final Color background; + 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 composeBoxBg; final Color contextMenuCancelText; final Color contextMenuItemBg; @@ -263,6 +282,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; @@ -296,10 +316,14 @@ class DesignVariables extends ThemeExtension { @override DesignVariables copyWith({ Color? background, + Color? bgBotBar, Color? bgContextMenu, Color? bgCounterUnread, + Color? bgMenuButtonActive, + Color? bgMenuButtonSelected, Color? bgTopBar, Color? borderBar, + Color? borderMenuButtonSelected, Color? composeBoxBg, Color? contextMenuCancelText, Color? contextMenuItemBg, @@ -307,6 +331,7 @@ class DesignVariables extends ThemeExtension { Color? editorButtonPressedBg, Color? foreground, Color? icon, + Color? iconSelected, Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, @@ -335,10 +360,14 @@ class DesignVariables extends ThemeExtension { }) { return DesignVariables._( background: background ?? this.background, + 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, composeBoxBg: composeBoxBg ?? this.composeBoxBg, contextMenuCancelText: contextMenuCancelText ?? this.contextMenuCancelText, contextMenuItemBg: contextMenuItemBg ?? this.contextMenuItemBg, @@ -346,6 +375,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, @@ -381,10 +411,14 @@ class DesignVariables extends ThemeExtension { } return DesignVariables._( background: Color.lerp(background, other.background, 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)!, composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!, contextMenuCancelText: Color.lerp(contextMenuCancelText, other.contextMenuCancelText, t)!, contextMenuItemBg: Color.lerp(contextMenuItemBg, other.contextMenuItemBg, t)!, @@ -392,6 +426,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)!, diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart new file mode 100644 index 00000000000..a7c52d77d44 --- /dev/null +++ b/test/widgets/home_test.dart @@ -0,0 +1,155 @@ +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/home.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/inbox.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/subscription_list.dart'; +import 'package:zulip/widgets/theme.dart'; + +import '../example_data.dart' as eg; +import '../flutter_checks.dart'; +import '../model/binding.dart'; +import '../model/test_store.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(initialTab: HomePageTab.inbox))); + await tester.pumpAndSettle(); + } + + group('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); + }); + }); + + 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)); + + 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 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 menu buttons control navigation states', (tester) async { + await prepare(tester); + + await tapOpenMenu(tester); + checkIconSelected(tester, inboxMenuIconFinder); + checkIconNotSelected(tester, channelsMenuIconFinder); + check(find.byType(InboxPageBody)).findsOne(); + + 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(SubscriptionListPageBody)).findsOne(); + + await tapOpenMenu(tester); + checkIconNotSelected(tester, inboxMenuIconFinder); + checkIconSelected(tester, channelsMenuIconFinder); + }); + + 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('_MyProfileButton', (tester) async { + await prepare(tester); + await tapOpenMenu(tester); + + await tester.tap(find.text('My profile')); + await tester.pump(Duration.zero); + check(find.byType(ProfilePage)).findsOne(); + check(find.text(eg.selfUser.fullName)).findsAny(); + }); + }); +}