diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index aa01eda043..b435438119 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -409,13 +409,9 @@ "@composeBoxGenericContentHint": { "description": "Hint text for content input when sending a message." }, - "newDmSheetBackButtonLabel": "Back", - "@newDmSheetBackButtonLabel": { - "description": "Label for the back button in the new DM sheet, allowing the user to return to the previous screen." - }, - "newDmSheetNextButtonLabel": "Next", - "@newDmSheetNextButtonLabel": { - "description": "Label for the front button in the new DM sheet, if applicable, for navigation or action." + "newDmSheetComposeButtonLabel": "Compose", + "@newDmSheetComposeButtonLabel": { + "description": "Label for the compose button in the new DM sheet that starts composing a message to the selected users." }, "newDmSheetScreenTitle": "New DM", "@newDmSheetScreenTitle": { @@ -431,7 +427,7 @@ }, "newDmSheetSearchHintSomeSelected": "Add another user…", "@newDmSheetSearchHintSomeSelected": { - "description": "Hint text for the search bar when at least one user is selected" + "description": "Hint text for the search bar when at least one user is selected." }, "newDmSheetNoUsersFound": "No users found", "@newDmSheetNoUsersFound": { diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 306596044b..fcc72d98e4 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -691,17 +691,11 @@ abstract class ZulipLocalizations { /// **'Type a message'** String get composeBoxGenericContentHint; - /// Label for the back button in the new DM sheet, allowing the user to return to the previous screen. + /// Label for the compose button in the new DM sheet that starts composing a message to the selected users. /// /// In en, this message translates to: - /// **'Back'** - String get newDmSheetBackButtonLabel; - - /// Label for the front button in the new DM sheet, if applicable, for navigation or action. - /// - /// In en, this message translates to: - /// **'Next'** - String get newDmSheetNextButtonLabel; + /// **'Compose'** + String get newDmSheetComposeButtonLabel; /// Title displayed at the top of the new DM screen. /// @@ -721,7 +715,7 @@ abstract class ZulipLocalizations { /// **'Add one or more users'** String get newDmSheetSearchHintEmpty; - /// Hint text for the search bar when at least one user is selected + /// Hint text for the search bar when at least one user is selected. /// /// In en, this message translates to: /// **'Add another user…'** diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 92b2ce681c..a3542d4627 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 54faf4fde4..fd0a1a4c28 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index dacb23923a..11ff100012 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 9d7e3ce291..72cae69b3e 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 92f9e33706..5d2fb39840 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 2657910637..dc74fd95b6 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -351,10 +351,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Wpisz wiadomość'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index bd4ee4423c..01995faa08 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -352,10 +352,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Ввести сообщение'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 93103de344..584bcf8733 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 9f49e2df4d..829c150728 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -353,10 +353,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Ввести повідомлення'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 8b3760c36d..e85420b675 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -344,10 +344,7 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get composeBoxGenericContentHint => 'Type a message'; @override - String get newDmSheetBackButtonLabel => 'Back'; - - @override - String get newDmSheetNextButtonLabel => 'Next'; + String get newDmSheetComposeButtonLabel => 'Compose'; @override String get newDmSheetScreenTitle => 'New DM'; diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 034199521d..cd3fa7d7d2 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -556,7 +556,6 @@ class MentionAutocompleteView extends AutocompleteView( + context: context, + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (BuildContext context) => Padding( + // By default, when software keyboard is opened, the ListView + // expands behind the software keyboard — resulting in some + // list entries being covered by the keyboard. Add explicit + // bottom padding the size of the keyboard, which fixes this. + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SafeArea( + child: PerAccountStoreWidget( + accountId: store.accountId, + child: NewDmPicker(pageContext: context))))); +} + +@visibleForTesting +class NewDmPicker extends StatefulWidget { + const NewDmPicker({ + super.key, + required this.pageContext, + }); + + final BuildContext pageContext; + + @override + State createState() => _NewDmPickerState(); +} + +class _NewDmPickerState extends State { + late TextEditingController searchController; + Set selectedUserIds = {}; + List filteredUsers = []; + List sortedUsers = []; + + @override + void initState() { + super.initState(); + searchController = TextEditingController()..addListener(_handleSearchUpdate); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final store = PerAccountStoreWidget.of(context); + _initSortedUsers(store); + } + + @override + void dispose() { + searchController.dispose(); + super.dispose(); + } + + void _initSortedUsers(PerAccountStore store) { + sortedUsers = List.from(store.allUsers); + sortedUsers.sort((a, b) => MentionAutocompleteView.compareByDms(a, b, store: store)); + _updateFilteredUsers(store); + } + + void _handleSearchUpdate() { + final store = PerAccountStoreWidget.of(context); + _updateFilteredUsers(store); + } + + // Function to sort users based on recency of DM's + // TODO: switch to using an `AutocompleteView` for users + void _updateFilteredUsers(PerAccountStore store) { + final excludeSelfUser = selectedUserIds.isNotEmpty + && !selectedUserIds.contains(store.selfUserId); + final searchTextLower = searchController.text.toLowerCase(); + + final result = []; + for (final user in sortedUsers) { + if (excludeSelfUser && user.userId == store.selfUserId) continue; + if (user.fullName.toLowerCase().contains(searchTextLower)) { + result.add(user); + } + } + + setState(() { + filteredUsers = result; + }); + } + + void _handleUserTap(int userId) { + final store = PerAccountStoreWidget.of(context); + selectedUserIds.contains(userId) + ? selectedUserIds.remove(userId) + : selectedUserIds.add(userId); + if (userId != store.selfUserId) { + selectedUserIds.remove(store.selfUserId); + } + + searchController.clear(); + _updateFilteredUsers(store); + } + + @override + Widget build(BuildContext context) { + return Column(children: [ + _NewDmHeader(selectedUserIds: selectedUserIds), + _NewDmSearchBar( + searchController: searchController, + selectedUserIds: selectedUserIds), + Expanded( + child: _NewDmUserList( + filteredUsers: filteredUsers, + selectedUserIds: selectedUserIds, + onUserTapped: (userId) => _handleUserTap(userId))), + ]); + } +} + +class _NewDmHeader extends StatelessWidget { + const _NewDmHeader({required this.selectedUserIds}); + + final Set selectedUserIds; + + Widget _buildCancelButton(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return GestureDetector( + onTap: Navigator.of(context).pop, + child: Text(zulipLocalizations.dialogCancel, style: TextStyle( + color: designVariables.icon, + fontSize: 20, + height: 30 / 20))); + } + + Widget _buildComposeButton(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final nextButtonColor = selectedUserIds.isEmpty + ? designVariables.icon.withFadedAlpha(0.5) + : designVariables.icon; + + return GestureDetector( + onTap: selectedUserIds.isEmpty ? null : () { + final store = PerAccountStoreWidget.of(context); + final narrow = DmNarrow.withUsers( + selectedUserIds.toList(), + selfUserId: store.selfUserId); + Navigator.pushReplacement(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + child: Text(zulipLocalizations.newDmSheetComposeButtonLabel, + style: TextStyle( + color: nextButtonColor, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600)))); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB(12, 10, 8, 6), + child: Row(children: [ + _buildCancelButton(context), + SizedBox(width: 8), + Expanded(child: Text(zulipLocalizations.newDmSheetScreenTitle, + style: TextStyle( + color: designVariables.title, + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600)), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center)), + SizedBox(width: 8), + _buildComposeButton(context), + ])); + } +} + +class _NewDmSearchBar extends StatelessWidget { + const _NewDmSearchBar({ + required this.searchController, + required this.selectedUserIds, + }); + + final TextEditingController searchController; + final Set selectedUserIds; + + Widget _buildSearchField(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final hintText = selectedUserIds.isEmpty + ? zulipLocalizations.newDmSheetSearchHintEmpty + : zulipLocalizations.newDmSheetSearchHintSomeSelected; + + return TextField( + controller: searchController, + autofocus: true, + cursorColor: designVariables.foreground, + style: TextStyle( + color: designVariables.textMessage, + fontSize: 17, + height: 22 / 17), + scrollPadding: EdgeInsets.zero, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + hintText: hintText, + hintStyle: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + height: 22 / 17))); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 44, + maxHeight: 124), + child: DecoratedBox( + decoration: BoxDecoration(color: designVariables.bgSearchInput), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: SingleChildScrollView( + reverse: true, + child: Row(children: [ + Expanded(child: Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final userId in selectedUserIds) + _SelectedUserChip(userId: userId), + // The IntrinsicWidth lets the text field participate in the Wrap + // when its content fits on the same line with a user chip, + // by preventing it from expanding to fill the available width. See: + // https://github.com/zulip/zulip-flutter/pull/1322#discussion_r2094112488 + IntrinsicWidth(child: _buildSearchField(context)), + ])), + ]))))); + } +} + +class _SelectedUserChip extends StatelessWidget { + const _SelectedUserChip({required this.userId}); + + final int userId; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + + final fullName = store.userDisplayName(userId); + + return DecoratedBox( + decoration: BoxDecoration( + color: designVariables.bgMenuButtonSelected, + borderRadius: BorderRadius.circular(3)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Avatar(userId: userId, size: 22, borderRadius: 3), + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(5, 3, 4, 3), + child: Text(fullName, + style: TextStyle( + fontSize: 16, + height: 16 / 16, + color: designVariables.labelMenuButton))), + ])); + } +} + +class _NewDmUserList extends StatelessWidget { + const _NewDmUserList({ + required this.filteredUsers, + required this.selectedUserIds, + required this.onUserTapped, + }); + + final List filteredUsers; + final Set selectedUserIds; + final void Function(int userId) onUserTapped; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + if (filteredUsers.isEmpty) { + // TODO(design): Missing in Figma. + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + zulipLocalizations.newDmSheetNoUsersFound, + style: TextStyle( + color: designVariables.labelMenuButton, + fontSize: 16)))); + } + + return ListView.builder( + padding: EdgeInsets.all(8), + itemCount: filteredUsers.length, + itemBuilder: (context, index) { + final user = filteredUsers[index]; + final isSelected = selectedUserIds.contains(user.userId); + + return InkWell( + splashFactory: NoSplash.splashFactory, + onTap: () => onUserTapped(user.userId), + child: DecoratedBox( + decoration: !isSelected + ? const BoxDecoration() + : BoxDecoration(color: designVariables.bgMenuButtonSelected, + borderRadius: BorderRadius.circular(10)), + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 6, 12, 6), + child: Row(children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Icon( + Icons.circle_outlined, + color: designVariables.radioBorder, + size: 24)), + Avatar(userId: user.userId, size: 32, borderRadius: 3), + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(8, 6.5, 0, 6.5), + child: Text(store.userDisplayName(user.userId), + style: TextStyle( + fontSize: 17, + height: 19 / 17, + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 500))))), + ])))); + }); + } +} diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 982dde4f08..b3fc3059c6 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; +import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; import 'content.dart'; import 'icons.dart'; import 'message_list.dart'; +import 'new_dm_sheet.dart'; import 'store.dart'; +import 'text.dart'; import 'theme.dart'; import 'unread_count_badge.dart'; @@ -49,18 +52,25 @@ class _RecentDmConversationsPageBodyState extends State createState() => _NewDmButtonState(); +} + +class _NewDmButtonState extends State<_NewDmButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + final fabBgColor = _pressed + ? designVariables.fabBgPressed + : designVariables.fabBg; + final fabLabelColor = _pressed + ? designVariables.fabLabelPressed + : designVariables.fabLabel; + + return InkWell( + onTap: () => showNewDmSheet(context), + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + borderRadius: BorderRadius.circular(28), + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 20, 12), + decoration: BoxDecoration( + color: fabBgColor, + borderRadius: BorderRadius.circular(28), + boxShadow: [BoxShadow( + color: designVariables.fabShadow, + blurRadius: _pressed ? 12 : 16, + spreadRadius: 0, + offset: _pressed + ? const Offset(0, 2) + : const Offset(0, 4)), + ]), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 24, color: fabLabelColor), + const SizedBox(width: 8), + Text( + zulipLocalizations.newDmFabButtonLabel, + style: TextStyle( + fontSize: 20, + height: 24 / 20, + color: fabLabelColor, + ).merge(weightVariableTextStyle(context, wght: 500))), + ]))); + } +} diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 492f82c88a..85d3ad7877 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -158,13 +158,20 @@ class DesignVariables extends ThemeExtension { contextMenuItemMeta: const Color(0xff626573), contextMenuItemText: const Color(0xff381da7), editorButtonPressedBg: Colors.black.withValues(alpha: 0.06), + fabBg: const Color(0xff6e69f3), + fabBgPressed: const Color(0xff6159e1), + fabLabel: const Color(0xfff1f3fe), + fabLabelPressed: const Color(0xffeceefc), + fabShadow: const Color(0xff2b0e8a).withValues(alpha: 0.4), 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), + labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), mainBackground: const Color(0xfff0f0f0), + radioBorder: Color(0xffbbbdc8), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), bgSearchInput: const Color(0xffe3e3e3), @@ -219,13 +226,20 @@ class DesignVariables extends ThemeExtension { contextMenuItemMeta: const Color(0xff9194a3), contextMenuItemText: const Color(0xff9398fd), editorButtonPressedBg: Colors.white.withValues(alpha: 0.06), + fabBg: const Color(0xff4f42c9), + fabBgPressed: const Color(0xff4331b8), + fabLabel: const Color(0xffeceefc), + fabLabelPressed: const Color(0xffeceefc), + fabShadow: const Color(0xff18171c), 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), + labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), mainBackground: const Color(0xff1d1d1d), + radioBorder: Color(0xff626573), textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff).withValues(alpha: 0.9), bgSearchInput: const Color(0xff313131), @@ -289,12 +303,19 @@ class DesignVariables extends ThemeExtension { required this.contextMenuItemText, required this.editorButtonPressedBg, required this.foreground, + required this.fabBg, + required this.fabBgPressed, + required this.fabLabel, + required this.fabLabelPressed, + required this.fabShadow, required this.icon, required this.iconSelected, required this.labelCounterUnread, required this.labelEdited, required this.labelMenuButton, + required this.labelSearchPrompt, required this.mainBackground, + required this.radioBorder, required this.textInput, required this.title, required this.bgSearchInput, @@ -358,13 +379,20 @@ class DesignVariables extends ThemeExtension { final Color contextMenuItemMeta; final Color contextMenuItemText; final Color editorButtonPressedBg; + final Color fabBg; + final Color fabBgPressed; + final Color fabLabel; + final Color fabLabelPressed; + final Color fabShadow; final Color foreground; final Color icon; final Color iconSelected; final Color labelCounterUnread; final Color labelEdited; final Color labelMenuButton; + final Color labelSearchPrompt; final Color mainBackground; + final Color radioBorder; final Color textInput; final Color title; final Color bgSearchInput; @@ -423,13 +451,20 @@ class DesignVariables extends ThemeExtension { Color? contextMenuItemMeta, Color? contextMenuItemText, Color? editorButtonPressedBg, + Color? fabBg, + Color? fabBgPressed, + Color? fabLabel, + Color? fabLabelPressed, + Color? fabShadow, Color? foreground, Color? icon, Color? iconSelected, Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, + Color? labelSearchPrompt, Color? mainBackground, + Color? radioBorder, Color? textInput, Color? title, Color? bgSearchInput, @@ -484,12 +519,19 @@ class DesignVariables extends ThemeExtension { contextMenuItemText: contextMenuItemText ?? this.contextMenuItemText, editorButtonPressedBg: editorButtonPressedBg ?? this.editorButtonPressedBg, foreground: foreground ?? this.foreground, + fabBg: fabBg ?? this.fabBg, + fabBgPressed: fabBgPressed ?? this.fabBgPressed, + fabLabel: fabLabel ?? this.fabLabel, + fabLabelPressed: fabLabelPressed ?? this.fabLabelPressed, + fabShadow: fabShadow ?? this.fabShadow, icon: icon ?? this.icon, iconSelected: iconSelected ?? this.iconSelected, labelCounterUnread: labelCounterUnread ?? this.labelCounterUnread, labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, + labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, mainBackground: mainBackground ?? this.mainBackground, + radioBorder: radioBorder ?? this.radioBorder, textInput: textInput ?? this.textInput, title: title ?? this.title, bgSearchInput: bgSearchInput ?? this.bgSearchInput, @@ -551,12 +593,19 @@ class DesignVariables extends ThemeExtension { contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemText, t)!, editorButtonPressedBg: Color.lerp(editorButtonPressedBg, other.editorButtonPressedBg, t)!, foreground: Color.lerp(foreground, other.foreground, t)!, + fabBg: Color.lerp(fabBg, other.fabBg, t)!, + fabBgPressed: Color.lerp(fabBgPressed, other.fabBgPressed, t)!, + fabLabel: Color.lerp(fabLabel, other.fabLabel, t)!, + fabLabelPressed: Color.lerp(fabLabelPressed, other.fabLabelPressed, t)!, + fabShadow: Color.lerp(fabShadow, other.fabShadow, 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)!, + labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, + radioBorder: Color.lerp(radioBorder, other.radioBorder, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart new file mode 100644 index 0000000000..ff84d476d9 --- /dev/null +++ b/test/widgets/new_dm_sheet_test.dart @@ -0,0 +1,307 @@ +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/model.dart'; +import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/new_dm_sheet.dart'; +import 'package:zulip/widgets/store.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import 'test_app.dart'; + +Future setupSheet(WidgetTester tester, { + required List users, +}) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUsers(users); + + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: const HomePage())); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(ZulipIcons.user)); + await tester.pumpAndSettle(); + + final fab = find.ancestor( + of: find.text("New DM"), + matching: find.byType(InkWell)); + await tester.tap(fab); + await tester.pumpAndSettle(); +} + +void main() { + TestZulipBinding.ensureInitialized(); + + group('NewDmSheet', () { + testWidgets('shows header with correct buttons', (tester) async { + await setupSheet(tester, users: []); + + check(find.descendant( + of: find.byType(NewDmPicker), + matching: find.text('New DM'))).findsOne(); + check(find.text('Cancel')).findsOne(); + check(find.text('Compose')).findsOne(); + + final composeButton = tester.widget( + find.widgetWithText(GestureDetector, 'Compose')); + check(composeButton.onTap).isNull(); + }); + + testWidgets('search field has autofocus when sheet opens', (tester) async { + await setupSheet(tester, users: []); + + final textField = tester.widget(find.byType(TextField)); + check(textField.autofocus).isTrue(); + }); + + group('user filtering', () { + final testUsers = [ + eg.user(fullName: 'Alice Anderson'), + eg.user(fullName: 'Bob Brown'), + eg.user(fullName: 'Charlie Carter'), + ]; + + testWidgets('shows all users initially', (tester) async { + await setupSheet(tester, users: testUsers); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Bob Brown')).findsOne(); + check(find.text('Charlie Carter')).findsOne(); + }); + + testWidgets('shows filtered users based on search', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'Alice'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Charlie Carter')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + }); + + testWidgets('search is case-insensitive', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'alice'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + + await tester.enterText(find.byType(TextField), 'ALICE'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + }); + + testWidgets('partial name and last name search handling', (tester) async { + await setupSheet(tester, users: testUsers); + + await tester.enterText(find.byType(TextField), 'Ali'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Bob Brown')).findsNothing(); + check(find.text('Charlie Carter')).findsNothing(); + + await tester.enterText(find.byType(TextField), 'Anderson'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Charlie Carter')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + + await tester.enterText(find.byType(TextField), 'son'); + await tester.pump(); + check(find.text('Alice Anderson')).findsOne(); + check(find.text('Charlie Carter')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + }); + + testWidgets('shows empty state when no users match', (tester) async { + await setupSheet(tester, users: testUsers); + await tester.enterText(find.byType(TextField), 'Zebra'); + await tester.pump(); + check(find.text('No users found')).findsOne(); + check(find.text('Alice Anderson')).findsNothing(); + check(find.text('Bob Brown')).findsNothing(); + check(find.text('Charlie Carter')).findsNothing(); + }); + + testWidgets('search text clears when user is selected', (tester) async { + final user = eg.user(fullName: 'Test User'); + await setupSheet(tester, users: [user]); + + await tester.enterText(find.byType(TextField), 'Test'); + await tester.pump(); + final textField = tester.widget(find.byType(TextField)); + check(textField.controller!.text).equals('Test'); + + final userTileFinder = find.ancestor( + of: find.text(user.fullName), + matching: find.byType(InkWell)); + await tester.tap(userTileFinder); + await tester.pump(); + check(textField.controller!.text).isEmpty(); + }); + }); + + group('user selection', () { + bool isUserSelected(WidgetTester tester, String userName) { + final userTextFinder = find.text(userName); + final userItemFinder = find.ancestor( + of: userTextFinder, + matching: find.byType(InkWell)); + + final decoratedBox = tester.widget(find.descendant( + of: userItemFinder, + matching: find.byType(DecoratedBox))); + + final decoration = decoratedBox.decoration as BoxDecoration?; + return decoration?.color != null; + } + + testWidgets('selecting and deselecting a user', (tester) async { + final user = eg.user(fullName: 'Test User'); + final userTileFinder = find.ancestor( + of: find.text(user.fullName), + matching: find.byType(InkWell)); + await setupSheet(tester, users: [eg.selfUser, user]); + + var composeButton = tester.widget( + find.widgetWithText(GestureDetector, 'Compose')); + check(isUserSelected(tester, user.fullName)).isFalse(); + check(isUserSelected(tester, eg.selfUser.fullName)).isFalse(); + check(composeButton.onTap).isNull(); + + await tester.tap(userTileFinder); + await tester.pump(); + check(isUserSelected(tester, user.fullName)).isTrue(); + composeButton = tester.widget( + find.widgetWithText(GestureDetector, 'Compose')); + check(composeButton.onTap).isNotNull(); + + await tester.tap(userTileFinder); + await tester.pump(); + check(isUserSelected(tester, user.fullName)).isFalse(); + composeButton = tester.widget( + find.widgetWithText(GestureDetector, 'Compose')); + check(composeButton.onTap).isNull(); + }); + + testWidgets('other user selection deselects self user', (tester) async { + final otherUser = eg.user(fullName: 'Other User'); + final otherUserTileFinder = find.ancestor( + of: find.text(otherUser.fullName), + matching: find.byType(InkWell)); + final selfUserTileFinder = find.ancestor( + of: find.text(eg.selfUser.fullName), + matching: find.byType(InkWell)); + await setupSheet(tester, users: [eg.selfUser, otherUser]); + + await tester.tap(selfUserTileFinder); + await tester.pump(); + check(isUserSelected(tester, eg.selfUser.fullName)).isTrue(); + check(find.text(eg.selfUser.fullName)).findsExactly(2); + + await tester.tap(otherUserTileFinder); + await tester.pump(); + check(isUserSelected(tester, otherUser.fullName)).isTrue(); + check(find.text(eg.selfUser.fullName)).findsNothing(); + }); + + testWidgets('other user selection hides self user', (tester) async { + final otherUser = eg.user(fullName: 'Other User'); + final otherUserTileFinder = find.ancestor( + of: find.text(otherUser.fullName), + matching: find.byType(InkWell)); + await setupSheet(tester, users: [eg.selfUser, otherUser]); + + check(find.text(eg.selfUser.fullName)).findsOne(); + + await tester.tap(otherUserTileFinder); + await tester.pump(); + check(find.text(eg.selfUser.fullName)).findsNothing(); + }); + + testWidgets('can select multiple users', (tester) async { + final user1 = eg.user(fullName: 'Test User 1'); + final user2 = eg.user(fullName: 'Test User 2'); + await setupSheet(tester, users: [user1, user2]); + + final userTile1 = find.ancestor( + of: find.text(user1.fullName), + matching: find.byType(InkWell)); + final userTile2 = find.ancestor( + of: find.text(user2.fullName), + matching: find.byType(InkWell)); + + await tester.tap(userTile1); + await tester.pump(); + await tester.tap(userTile2); + await tester.pump(); + check(isUserSelected(tester, user1.fullName)).isTrue(); + check(isUserSelected(tester, user2.fullName)).isTrue(); + }); + }); + + group('navigation to DM Narrow', () { + Future runAndCheck(WidgetTester tester, { + required List users, + required String expectedAppBarTitle, + }) async { + await setupSheet(tester, users: users); + + final context = tester.element(find.byType(NewDmPicker)); + final store = PerAccountStoreWidget.of(context); + final connection = store.connection as FakeApiConnection; + + connection.prepare( + json: eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); + for (final user in users) { + final userTile = find.ancestor( + of: find.text(user.fullName), + matching: find.byType(InkWell)); + await tester.tap(userTile); + await tester.pump(); + } + await tester.tap(find.widgetWithText(GestureDetector, 'Compose')); + await tester.pumpAndSettle(); + check(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text(expectedAppBarTitle))).findsOne(); + + check(find.byType(ComposeBox)).findsOne(); + } + + testWidgets('navigates to self DM on Next', (tester) async { + await runAndCheck( + tester, + users: [eg.selfUser], + expectedAppBarTitle: 'DMs with yourself'); + }); + + testWidgets('navigates to 1:1 DM on Next', (tester) async { + final user = eg.user(fullName: 'Test User'); + await runAndCheck( + tester, + users: [user], + expectedAppBarTitle: 'DMs with Test User'); + }); + + testWidgets('navigates to group DM on Next', (tester) async { + final users = [ + eg.user(fullName: 'User 1'), + eg.user(fullName: 'User 2'), + eg.user(fullName: 'User 3'), + ]; + await runAndCheck( + tester, + users: users, + expectedAppBarTitle: 'DMs with User 1, User 2, User 3'); + }); + }); + }); +} diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 44322ccea1..52193344ab 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.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/api/model/model.dart'; @@ -9,6 +10,7 @@ import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/new_dm_sheet.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/recent_dm_conversations.dart'; @@ -106,6 +108,24 @@ void main() { await tester.pumpAndSettle(); check(tester.any(oldestConversationFinder)).isTrue(); // onscreen }); + + testWidgets('opens new DM sheet on New DM button tap', (tester) async { + await setupPage(tester, users: [], dmMessages: []); + final newDmButton = find.ancestor( + of: find.text("New DM"), + matching: find.byType(InkWell)); + check(newDmButton).findsOne(); + + await tester.tap(newDmButton); + await tester.pumpAndSettle(); + check(find.descendant( + of: find.byType(NewDmPicker), + matching: find.text('New DM'))).findsOne(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + check(find.byType(NewDmPicker)).findsNothing(); + }); }); group('RecentDmConversationsItem', () {