From 7f16314b0423067da0ff731868881b9ac2977ee7 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 26 Nov 2024 17:26:48 -0500 Subject: [PATCH] nav: Add bottom tabs and main menu This is an initial implementation because some features were considered out-of-scope as of now for #1035. Compared to the Figma, we swapped the order of _ChannelsButton and _DirectMessagesButton in the menu so they match their order on the navigation bar. See: https://chat.zulip.org/#narrow/channel/48-mobile/topic/Buttons.20on.20the.20bottom.20tabs.20and.20main.20menu We also added _CombinedFeedButton, using the same icon we have for "Combined feed" on the web app's topleft sidebar, added a "Switch account" button, and renamed "Streams" to "Channels". Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 16 + lib/generated/l10n/zulip_localizations.dart | 24 + .../l10n/zulip_localizations_ar.dart | 12 + .../l10n/zulip_localizations_en.dart | 12 + .../l10n/zulip_localizations_ja.dart | 12 + lib/widgets/home.dart | 534 +++++++++++++++--- lib/widgets/theme.dart | 35 ++ test/widgets/home_test.dart | 157 +++++ 8 files changed, 731 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 cf816f50c43..6646d82a64d 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -19,6 +19,10 @@ "@chooseAccountPageTitle": { "description": "Title for ChooseAccountPage" }, + "switchAccountButton": "Switch account", + "@switchAccountButton": { + "description": "Main menu button for the choose-account page" + }, "chooseAccountPageLogOutButton": "Log out", "@chooseAccountPageLogOutButton": { "description": "Label for the 'Log out' button for an account on the choose-account page" @@ -529,6 +533,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 +553,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 972b1e1ad26..9ca604d07a5 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -127,6 +127,12 @@ abstract class ZulipLocalizations { /// **'Choose account'** String get chooseAccountPageTitle; + /// Main menu button for the choose-account page + /// + /// In en, this message translates to: + /// **'Switch account'** + String get switchAccountButton; + /// Label for the 'Log out' button for an account on the choose-account page /// /// In en, this message translates to: @@ -799,6 +805,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 +835,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 95ff1d0aea8..4e5134b8904 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -23,6 +23,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get chooseAccountPageTitle => 'Choose account'; + @override + String get switchAccountButton => 'Switch account'; + @override String get chooseAccountPageLogOutButton => 'Log out'; @@ -419,6 +422,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get inboxButton => 'Inbox'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @@ -431,6 +437,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 d440ed2b10d..02c4639afa2 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -23,6 +23,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get chooseAccountPageTitle => 'Choose account'; + @override + String get switchAccountButton => 'Switch account'; + @override String get chooseAccountPageLogOutButton => 'Log out'; @@ -419,6 +422,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get inboxButton => 'Inbox'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @@ -431,6 +437,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 42128ba024e..9d72530d8a8 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -23,6 +23,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get chooseAccountPageTitle => 'アカウントを選択'; + @override + String get switchAccountButton => 'Switch account'; + @override String get chooseAccountPageLogOutButton => 'Log out'; @@ -419,6 +422,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get userRoleUnknown => '不明'; + @override + String get inboxButton => 'Inbox'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; @@ -431,6 +437,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..645c785412e 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -2,96 +2,488 @@ 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, tabNotifier: _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) + // TODO(a11y): 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, width: 0)), + color: designVariables.bgBotBar), + child: SafeArea( + // [Center] is necessary to limit the width of the row of icons, + // because it allows the child to be smaller than itself. + child: Center( + // This shrinks [Center] to be as tall as its child, instead of + // growing as tall as [Scaffold] allows, filling the entire screen. + heightFactor: 1, + child: ConstrainedBox( + // TODO(design): determine a suitable max width for bottom nav bar + 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({ + 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 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, + backgroundColor: designVariables.bgBotBar, + builder: (BuildContext _) { + return PerAccountStoreWidget( + accountId: accountId, + child: SafeArea( + minimum: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgBotBar, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + child: Column(children: menuItems)))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AnimatedScaleOnTap( + scaleEnd: 0.95, + duration: const Duration(milliseconds: 100), + 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), + // 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) async { + tabNotifier.value = navigationTarget; + Navigator.of(context).pop(); + } +} + +class _InboxButton extends _NavigationBarMenuButton { + const _InboxButton({required super.tabNotifier}); + + @override + IconData get icon => ZulipIcons.inbox; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.inboxButton; + } + + @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.channelsButton; + } + + @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.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.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/theme.dart b/lib/widgets/theme.dart index 13bb12b3916..2e038678435 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -115,10 +115,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), @@ -128,6 +132,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), @@ -156,10 +161,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), @@ -169,6 +178,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), @@ -204,10 +214,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, @@ -217,6 +231,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, @@ -253,10 +268,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; @@ -266,6 +285,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; @@ -297,10 +317,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, @@ -310,6 +334,7 @@ class DesignVariables extends ThemeExtension { Color? editorButtonPressedBg, Color? foreground, Color? icon, + Color? iconSelected, Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, @@ -336,10 +361,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, @@ -349,6 +378,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, @@ -382,10 +412,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)!, @@ -395,6 +429,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..1d0b3123ca7 --- /dev/null +++ b/test/widgets/home_test.dart @@ -0,0 +1,157 @@ +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(); + 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('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(); + }); + }); +}