From 003bbd9364c01a26d4f9e01b885f96a9dce9b00f Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sun, 6 Jul 2025 10:43:48 +0430 Subject: [PATCH 01/12] button: Add ZulipMenuItemButton.subLabel --- lib/widgets/button.dart | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 08af96806b..77e84c9a70 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -290,6 +290,7 @@ class ZulipMenuItemButton extends StatelessWidget { super.key, this.style = ZulipMenuItemButtonStyle.menu, required this.label, + this.subLabel, required this.onPressed, this.icon, this.toggle, @@ -297,6 +298,7 @@ class ZulipMenuItemButton extends StatelessWidget { final ZulipMenuItemButtonStyle style; final String label; + final TextSpan? subLabel; final VoidCallback onPressed; final IconData? icon; @@ -393,13 +395,29 @@ class ZulipMenuItemButton extends StatelessWidget { foregroundColor: _labelColor(designVariables), splashFactory: NoSplash.splashFactory, ).copyWith(backgroundColor: _backgroundColor(designVariables)), + overflowAxis: Axis.vertical, onPressed: onPressed, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), - // TODO sublabel, for [ZulipMenuItemButtonStyle.list] - child: Text(label, - style: const TextStyle(fontSize: 20, height: 24 / 20) - .merge(weightVariableTextStyle(context, wght: _labelWght()))))); + child: Row( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: localizedTextBaseline(context), + children: [ + Flexible(child: Text(label, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 20, height: 24 / 20) + .merge(weightVariableTextStyle(context, wght: _labelWght())))), + if (subLabel != null) + Flexible(child: Text.rich(subLabel!, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 16 / 16, + color: _labelColor(designVariables).withFadedAlpha(0.70), + ).merge(weightVariableTextStyle(context, wght: _labelWght())))), + ], + ))); } } From 3c4a65c99ec918c181cee0053a0bc4f1aab155ae Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 18 Jul 2025 00:58:19 +0430 Subject: [PATCH 02/12] button [nfc]: Make ZulipMenuItemButton.onPressed optional --- lib/widgets/button.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 77e84c9a70..d4f8c98486 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -291,7 +291,7 @@ class ZulipMenuItemButton extends StatelessWidget { this.style = ZulipMenuItemButtonStyle.menu, required this.label, this.subLabel, - required this.onPressed, + this.onPressed, this.icon, this.toggle, }); @@ -299,7 +299,7 @@ class ZulipMenuItemButton extends StatelessWidget { final ZulipMenuItemButtonStyle style; final String label; final TextSpan? subLabel; - final VoidCallback onPressed; + final VoidCallback? onPressed; final IconData? icon; /// A [Toggle] to go before [icon], or in its place if it's null. From d1e38ff34f120ef2cd4dc9101407bc991e29ee88 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sun, 6 Jul 2025 11:32:48 +0430 Subject: [PATCH 03/12] profile: Add button for setting/showing user status in self-user profile In self-user profile page, this also removes the user status information where shown in regular (non-self-user) profile page, as the newly-added button already shows the same information. --- assets/l10n/app_en.arb | 12 +++ lib/generated/l10n/zulip_localizations.dart | 18 ++++ .../l10n/zulip_localizations_ar.dart | 9 ++ .../l10n/zulip_localizations_de.dart | 9 ++ .../l10n/zulip_localizations_en.dart | 9 ++ .../l10n/zulip_localizations_it.dart | 9 ++ .../l10n/zulip_localizations_ja.dart | 9 ++ .../l10n/zulip_localizations_nb.dart | 9 ++ .../l10n/zulip_localizations_pl.dart | 9 ++ .../l10n/zulip_localizations_ru.dart | 9 ++ .../l10n/zulip_localizations_sk.dart | 9 ++ .../l10n/zulip_localizations_sl.dart | 9 ++ .../l10n/zulip_localizations_uk.dart | 9 ++ .../l10n/zulip_localizations_zh.dart | 9 ++ lib/widgets/profile.dart | 88 +++++++++++++------ 15 files changed, 199 insertions(+), 27 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index c24f23dce9..0f6aedeab4 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -809,6 +809,18 @@ "@userRoleUnknown": { "description": "Label for UserRole.unknown" }, + "statusButtonLabelStatusSet": "Status", + "@statusButtonLabelStatusSet": { + "description": "The status button label in self-user profile page when status is set." + }, + "statusButtonLabelStatusUnset": "Set status", + "@statusButtonLabelStatusUnset": { + "description": "The status button label in self-user profile page when status is not set." + }, + "noStatusText": "No status text", + "@noStatusText": { + "description": "The text part of the status button sub-label in self-user profile page when status text is not set." + }, "searchMessagesPageTitle": "Search", "@searchMessagesPageTitle": { "description": "Page title for the 'Search' message view." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 99b52aced1..6b98ba1bfc 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1227,6 +1227,24 @@ abstract class ZulipLocalizations { /// **'Unknown'** String get userRoleUnknown; + /// The status button label in self-user profile page when status is set. + /// + /// In en, this message translates to: + /// **'Status'** + String get statusButtonLabelStatusSet; + + /// The status button label in self-user profile page when status is not set. + /// + /// In en, this message translates to: + /// **'Set status'** + String get statusButtonLabelStatusUnset; + + /// The text part of the status button sub-label in self-user profile page when status text is not set. + /// + /// In en, this message translates to: + /// **'No status text'** + String get noStatusText; + /// Page title for the 'Search' message view. /// /// 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 110b0dbe24..a75418b05e 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -664,6 +664,15 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index f3b1bdad67..1cc36418aa 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -685,6 +685,15 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get userRoleUnknown => 'Unbekannt'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index f99c386087..ccb3b37da2 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -664,6 +664,15 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 2d7d35e23e..0603fee48b 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -679,6 +679,15 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get userRoleUnknown => 'Sconosciuto'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index edf5c759f9..f3d5c4f2fd 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -661,6 +661,15 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get userRoleUnknown => '不明'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 0568bc0ae7..0d79eeac0e 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -664,6 +664,15 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index c96ab24679..f1ca09a23c 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -675,6 +675,15 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get userRoleUnknown => 'Nieznany'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Szukaj'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index be5de60e97..9ebe89be49 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -678,6 +678,15 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get userRoleUnknown => 'Неизвестно'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Поиск'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 33b4465eb6..ce34c3a4e0 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -666,6 +666,15 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get userRoleUnknown => 'Neznáma'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 8d587b9085..8df488f7d2 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -690,6 +690,15 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get userRoleUnknown => 'Neznano'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 6799942531..4b7fef42d3 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -678,6 +678,15 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get userRoleUnknown => 'Невідомо'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 8f88549383..6283718634 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -664,6 +664,15 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get userRoleUnknown => 'Unknown'; + @override + String get statusButtonLabelStatusSet => 'Status'; + + @override + String get statusButtonLabelStatusUnset => 'Set status'; + + @override + String get noStatusText => 'No status text'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 9b65831b29..5431690f9f 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -76,16 +76,17 @@ class ProfilePage extends StatelessWidget { ), // TODO write a test where the user is muted; check this and avatar TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)), - UserStatusEmoji.asWidgetSpan( - userId: userId, - fontSize: nameStyle.fontSize!, - textScaler: MediaQuery.textScalerOf(context), - neverAnimate: false, - ), + if (userId != store.selfUserId) + UserStatusEmoji.asWidgetSpan( + userId: userId, + fontSize: nameStyle.fontSize!, + textScaler: MediaQuery.textScalerOf(context), + neverAnimate: false, + ), ]), textAlign: TextAlign.center, style: nameStyle), - if (userStatus.text != null) + if (userId != store.selfUserId && userStatus.text != null) Text(userStatus.text!, textAlign: TextAlign.center, style: TextStyle(fontSize: 18, height: 22 / 18, @@ -100,9 +101,13 @@ class ProfilePage extends StatelessWidget { // TODO(#196) render active status // TODO(#292) render user local time - if (!store.realmPresenceDisabled && userId == store.selfUserId) ...[ + if (userId == store.selfUserId) ...[ const SizedBox(height: 16), - _InvisibleModeToggle(), + MenuButtonsShape(buttons: [ + _SetStatusButton(), + if (!store.realmPresenceDisabled) + _InvisibleModeToggle(), + ]), const SizedBox(height: 16), ], @@ -132,6 +137,37 @@ class ProfilePage extends StatelessWidget { } } +class _SetStatusButton extends StatelessWidget { + const _SetStatusButton(); + + @override + Widget build(BuildContext context) { + final localizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + final userStatus = store.getUserStatus(store.selfUserId); + + return ZulipMenuItemButton( + style: ZulipMenuItemButtonStyle.list, + label: userStatus == UserStatus.zero + ? localizations.statusButtonLabelStatusUnset + : localizations.statusButtonLabelStatusSet, + subLabel: userStatus == UserStatus.zero ? null : TextSpan(children: [ + UserStatusEmoji.asWidgetSpan( + userId: store.selfUserId, + fontSize: 16, + textScaler: MediaQuery.textScalerOf(context), + position: StatusEmojiPosition.before, + neverAnimate: false, + ), + userStatus.text == null + ? TextSpan(text: localizations.noStatusText, + style: TextStyle(fontStyle: FontStyle.italic)) + : TextSpan(text: userStatus.text), + ]), + ); + } +} + class _InvisibleModeToggle extends StatelessWidget { const _InvisibleModeToggle(); @@ -140,24 +176,22 @@ class _InvisibleModeToggle extends StatelessWidget { final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); - return MenuButtonsShape(buttons: [ - // `value: true` means invisible mode is on, - // i.e., that presenceEnabled is false. - RemoteSettingBuilder( - findValueInStore: (store) => !store.userSettings.presenceEnabled, - sendValueToServer: (value) => updateSettings(store.connection, - newSettings: {UserSettingName.presenceEnabled: !value}), - // TODO(#741) interpret API errors for user - onError: (e, requestedValue) => reportErrorToUserBriefly( - requestedValue - ? zulipLocalizations.turnOnInvisibleModeErrorTitle - : zulipLocalizations.turnOffInvisibleModeErrorTitle), - builder: (value, handleRequestNewValue) => ZulipMenuItemButton( - style: ZulipMenuItemButtonStyle.list, - label: zulipLocalizations.invisibleMode, - onPressed: () => handleRequestNewValue(!value), - toggle: Toggle(value: value, onChanged: handleRequestNewValue))), - ]); + // `value: true` means invisible mode is on, + // i.e., that presenceEnabled is false. + return RemoteSettingBuilder( + findValueInStore: (store) => !store.userSettings.presenceEnabled, + sendValueToServer: (value) => updateSettings(store.connection, + newSettings: {UserSettingName.presenceEnabled: !value}), + // TODO(#741) interpret API errors for user + onError: (e, requestedValue) => reportErrorToUserBriefly( + requestedValue + ? zulipLocalizations.turnOnInvisibleModeErrorTitle + : zulipLocalizations.turnOffInvisibleModeErrorTitle), + builder: (value, handleRequestNewValue) => ZulipMenuItemButton( + style: ZulipMenuItemButtonStyle.list, + label: zulipLocalizations.invisibleMode, + onPressed: () => handleRequestNewValue(!value), + toggle: Toggle(value: value, onChanged: handleRequestNewValue))); } } From b51d7635b19feaafa33bb81b5ed3f974e858f4c6 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 7 Jul 2025 11:51:42 +0430 Subject: [PATCH 04/12] model [nfc]: Add UserStatusChange.copyWith method --- lib/api/model/model.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 1009418d08..fca63412c2 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -223,6 +223,10 @@ class UserStatusChange { return UserStatus(text: text.or(old.text), emoji: emoji.or(old.emoji)); } + UserStatusChange copyWith({Option? text, Option? emoji}) { + return UserStatusChange(text: text ?? this.text, emoji: emoji ?? this.emoji); + } + factory UserStatusChange.fromJson(Map json) { return UserStatusChange( text: _textFromJson(json), emoji: _emojiFromJson(json)); From a86d5aebea807bf43ebf0e398e75a04379c3880b Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 7 Jul 2025 11:53:27 +0430 Subject: [PATCH 05/12] content: Add `emoji` property to UserStatusEmoji widget This is useful when we want to show a status emoji that we already know about, instead of relying on `userId` to get the emoji for the user. For example in the next commits, in setting user status page, where a list of status suggestions are shown. --- lib/widgets/user.dart | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/widgets/user.dart b/lib/widgets/user.dart index 9406580fb2..182a073d30 100644 --- a/lib/widgets/user.dart +++ b/lib/widgets/user.dart @@ -289,6 +289,11 @@ class _PresenceCircleState extends State with PerAccountStoreAwa /// A user status emoji to be displayed in different parts of the app. /// +/// Use [userId] to show status emoji for that user. +/// Use [emoji] to show the specific emoji passed. +/// +/// Only one of [userId] or [emoji] should be passed. +/// /// Use [padding] to control the padding of status emoji from neighboring /// widgets. /// When there is no status emoji to be shown, the padding will be omitted too. @@ -298,13 +303,16 @@ class _PresenceCircleState extends State with PerAccountStoreAwa class UserStatusEmoji extends StatelessWidget { const UserStatusEmoji({ super.key, - required this.userId, + this.userId, + this.emoji, required this.size, this.padding = EdgeInsets.zero, this.neverAnimate = true, - }); + }) : assert((userId == null) != (emoji == null), + 'Only one of the userId or emoji should be provided.'); - final int userId; + final int? userId; + final StatusEmoji? emoji; final double size; final EdgeInsetsGeometry padding; final bool neverAnimate; @@ -317,7 +325,8 @@ class UserStatusEmoji extends StatelessWidget { /// Use [position] to tell the emoji span where it is located relative to /// another span, so that it can adjust the necessary padding from it. static InlineSpan asWidgetSpan({ - required int userId, + int? userId, + StatusEmoji? emoji, required double fontSize, required TextScaler textScaler, StatusEmojiPosition position = StatusEmojiPosition.after, @@ -330,7 +339,7 @@ class UserStatusEmoji extends StatelessWidget { final size = textScaler.scale(fontSize); return WidgetSpan( alignment: PlaceholderAlignment.middle, - child: UserStatusEmoji(userId: userId, size: size, + child: UserStatusEmoji(userId: userId, emoji: emoji, size: size, padding: EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd), neverAnimate: neverAnimate)); } @@ -338,15 +347,15 @@ class UserStatusEmoji extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); - final emoji = store.getUserStatus(userId).emoji; + final effectiveEmoji = emoji ?? store.getUserStatus(userId!).emoji; final placeholder = SizedBox.shrink(); - if (emoji == null) return placeholder; + if (effectiveEmoji == null) return placeholder; final emojiDisplay = store.emojiDisplayFor( - emojiType: emoji.reactionType, - emojiCode: emoji.emojiCode, - emojiName: emoji.emojiName) + emojiType: effectiveEmoji.reactionType, + emojiCode: effectiveEmoji.emojiCode, + emojiName: effectiveEmoji.emojiName) // Web doesn't seem to respect the emojiset user settings for user status. // .resolve(store.userSettings) ; From c25ff7b9d426dffa78267a753c836fe21d8d8a75 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 7 Jul 2025 14:43:45 +0430 Subject: [PATCH 06/12] emoji [nfc]: Make emoji picker return the selected emoji, for reuse Instead of using the selected emoji deep down the widget tree, simply return it where the emoji picker sheet is opened, to use it for different purposes. --- lib/widgets/action_sheet.dart | 12 ++++++++++-- lib/widgets/emoji_reaction.dart | 33 +++++++-------------------------- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6b280df6ee..841fb90bc3 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -738,14 +738,22 @@ class ReactionButtons extends StatelessWidget { : zulipLocalizations.errorReactionAddingFailedTitle); } - void _handleTapMore() { + void _handleTapMore() async { // TODO(design): have emoji picker slide in from right and push // action sheet off to the left // Dismiss current action sheet before opening emoji picker sheet. Navigator.of(pageContext).pop(); - showEmojiPickerSheet(pageContext: pageContext, message: message); + final emoji = await showEmojiPickerSheet(pageContext: pageContext); + if (emoji == null || !pageContext.mounted) return; + unawaited(doAddOrRemoveReaction( + context: pageContext, + doRemoveReaction: false, + messageId: message.id, + emoji: emoji, + errorDialogTitle: + ZulipLocalizations.of(pageContext).errorReactionAddingFailedTitle)); } Widget _buildButton({ diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 3c26361d3a..cca31ccc07 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -398,12 +398,11 @@ Future doAddOrRemoveReaction({ } /// Opens a browsable and searchable emoji picker bottom sheet. -void showEmojiPickerSheet({ +Future showEmojiPickerSheet({ required BuildContext pageContext, - required Message message, -}) { +}) async { final store = PerAccountStoreWidget.of(pageContext); - showModalBottomSheet( + return showModalBottomSheet( context: pageContext, // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect // on my iPhone 13 Pro but is marked as "much slower": @@ -423,20 +422,15 @@ void showEmojiPickerSheet({ // For _EmojiPickerItem, and RealmContentNetworkImage used in ImageEmojiWidget. child: PerAccountStoreWidget( accountId: store.accountId, - child: EmojiPicker(pageContext: pageContext, message: message))); + child: EmojiPicker(pageContext: pageContext))); }); } @visibleForTesting class EmojiPicker extends StatefulWidget { - const EmojiPicker({ - super.key, - required this.pageContext, - required this.message, - }); + const EmojiPicker({super.key, required this.pageContext}); final BuildContext pageContext; - final Message message; @override State createState() => _EmojiPickerState(); @@ -534,8 +528,7 @@ class _EmojiPickerState extends State with PerAccountStoreAwareStat itemCount: _resultsToDisplay.length, itemBuilder: (context, i) => EmojiPickerListEntry( pageContext: widget.pageContext, - emoji: _resultsToDisplay[i].candidate, - message: widget.message)))), + emoji: _resultsToDisplay[i].candidate)))), ]))), ]); } @@ -547,27 +540,15 @@ class EmojiPickerListEntry extends StatelessWidget { super.key, required this.pageContext, required this.emoji, - required this.message, }); final BuildContext pageContext; final EmojiCandidate emoji; - final Message message; static const _emojiSize = 24.0; void _onPressed() { - // Dismiss the enclosing action sheet immediately, - // for swift UI feedback that the user's selection was received. - Navigator.pop(pageContext); - - doAddOrRemoveReaction( - context: pageContext, - doRemoveReaction: false, - messageId: message.id, - emoji: emoji, - errorDialogTitle: - ZulipLocalizations.of(pageContext).errorReactionAddingFailedTitle); + Navigator.pop(pageContext, emoji); } @override From a0d26702747d2740d6163f9810d027f2d719f017 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sat, 19 Jul 2025 00:23:35 +0430 Subject: [PATCH 07/12] api: Add updateStatus --- lib/api/route/users.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/api/route/users.dart b/lib/api/route/users.dart index d07c471e2f..4e47d97576 100644 --- a/lib/api/route/users.dart +++ b/lib/api/route/users.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../../basic.dart'; import '../core.dart'; import '../model/model.dart'; @@ -34,6 +35,21 @@ class GetOwnUserResult { Map toJson() => _$GetOwnUserResultToJson(this); } +/// https://zulip.com/api/update-status +Future updateStatus(ApiConnection connection, { + required UserStatusChange change, +}) { + return connection.post('updateStatus', (_) {}, 'users/me/status', { + if (change.text case OptionSome(:var value)) + 'status_text': RawParameter(value ?? ''), + if (change.emoji case OptionSome(:var value)) ...{ + 'emoji_name': RawParameter(value?.emojiName ?? ''), + 'emoji_code': RawParameter(value?.emojiCode ?? ''), + 'reaction_type': RawParameter(value?.reactionType.toJson() ?? ''), + } + }); +} + /// https://zulip.com/api/update-presence /// /// Passes true for `slim_presence` to avoid getting an ancient data format From 13ebef74864227b5b1321719a3caa37f1778cb2a Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sat, 19 Jul 2025 23:07:20 +0430 Subject: [PATCH 08/12] emoji: Add EmojiStore.getUnicodeEmojiNameByCode method --- lib/model/emoji.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 0b8ae60333..c971b1deba 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -119,6 +119,8 @@ mixin EmojiStore { Iterable allEmojiCandidates(); + String? getUnicodeEmojiNameByCode(String emojiCode); + // TODO cut debugServerEmojiData once we can query for lists of emoji; // have tests make those queries end-to-end Map>? get debugServerEmojiData; @@ -144,6 +146,10 @@ mixin ProxyEmojiStore on EmojiStore { @override Iterable allEmojiCandidates() => emojiStore.allEmojiCandidates(); + @override + String? getUnicodeEmojiNameByCode(String emojiCode) => + emojiStore.getUnicodeEmojiNameByCode(emojiCode); + @override Map>? get debugServerEmojiData => emojiStore.debugServerEmojiData; } @@ -396,6 +402,10 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { return _allEmojiCandidates ??= _generateAllCandidates(); } + @override + String? getUnicodeEmojiNameByCode(String emojiCode) => + _serverEmojiData?[emojiCode]?.first; + void setServerEmojiData(ServerEmojiData data) { _serverEmojiData = data.codeToNames; _popularCandidates = null; From 7aa5805fd522908081019b203a632bb8d3b3960b Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sat, 19 Jul 2025 00:24:39 +0430 Subject: [PATCH 09/12] icons: Add `chevron_down`, from Figma Figma link: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=7729-49342&t=PIXU2xFWUWq88zLE-0 --- assets/icons/ZulipIcons.ttf | Bin 16076 -> 16212 bytes assets/icons/chevron_down.svg | 3 ++ lib/widgets/icons.dart | 83 ++++++++++++++++++---------------- 3 files changed, 46 insertions(+), 40 deletions(-) create mode 100644 assets/icons/chevron_down.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 85f393019a00470c3f77ab60b05ae32b1863547b..cec366907d36cdb1cecab4889822605dde0768fe 100644 GIT binary patch delta 2139 zcmah~OH5p46#nj=VHk$FOo4&6ph#h)2xtMBx%0XV%na{GiPWZ=2INhZmn~8-+A*=w z#$7Y4x?t5*6J0dL*P<~E=|Yp*VB^A-A*3!eF2I;Bj9R~Qxl7gA6F?T3E>j?>~} zW`4{VseDkXk|0Tiwb_y71!YnR@bD0y%jZUBCyKsoX#~OpfT}OdpIf|8^H~&#UgGmN zmv2^T{bmNtV)EM)?_U13LFrGIDO0JF@W6+U@B{u(e98;THDz0Ms6lnZwqd(#ud)Y` z1vj2UJBF|b6$cRDr6Y(CqA<{m7*_$bpcQehZ7kBh#{Pa5_hY>LNK!9EyB4vRy1E`S z2%FRq*pY1#Crsi99Xn8gDrj`+N7!^}kjmhBH#HMDN)3Zy>gb{r{X-~&liCr6)=AmQ z{in#R)2@SoI;oMWKp|cA@F>KCW?uPJ36nO40xHRgkd>o|bnBagg~+<2uP0VtDN+rglnVZlkH> zSjK|nk}6?#NXY%nN!%Ys&ZGTIOPs7v4IK<{GG4_J{~E{49{cGg(_fj8FzRR>r@d4n zL_VD!Y8Z}zSc*ibo24~ z&oz6VaVi;SM$&Da>|1fzQyhZ`|AJ1FXz%YK)11v(_-QI#_VZL~u}7N6wKPq#I=~9Z zg!J%VWT%CVPcg1sEN>G1I6`KFYzcD6d`J~CJd$z+Qtp2#H1k=G%1I+-?nEV5ouoBP zCrxw{VPJbZGBsXKyEHB{+fP$J%JT&EJ!U&I?}@#4M1n!InGD^0dBU!Kn7*GCQ<~B~ z$A$EoqGw9A+TR%r(w# zcQA~01}hcnHN%TBtS(l(4?Ss@bBaC}oD-$AZA*rY=GHiEqU{mAyEl-Cb#?{&deXbj z_fqT|f3{EL&R-ufY#~HBV!=;2YN3sC%!0hJ$1Q{@CoCA0lNOpOr!2HmPFsjmrq1vt zXdzy+&_Ow4fvF%Edj%8yt zg)YkT7Wg3qKZquzLMs+})^E5b)zoeiAbSAZ((Hp=ZxHoUvy01jcmMeta3<&@ zFf%_I%ldCsZ;_yi3ahi(V;|7A@)*N_frND zi#iHB3M}H3MI5DLCywIrqpLn1g?SL;RYH{rY2zrRlAH)xE&Qy5!BD$MB{}z#)ZofQInNDd z)6RQ{)^1ib$}pN~k)(xbvG?*GWS|DmTB#G_Dx72V2iq+cQ7@f$Qt=c^Z}426g!B+| zY{e0V8zrq+Ns!M|OG1+D4sjhtnpOiiOq(dvF;p!onXfc1L&h)@>_P>#a}sl!rjla~ zo@18+bS)hc@&I!Z_f;r-vcKOF7c19Do!2-S=dr|BDQj z>iE7>$#;JsiNgLfn8Q9*AOoktI zydMl|J+<0-o}#|bYC(H`$5Bxz!^JP6{?!-G4y`Spa9+%BIv+LbRFmIdj9ykvlx}{Q zbkc0|+0E>AUgt24l|S0@KNZxqgh`ubE)lwoaXymlU^kt1F*K=1KdY8v$Qd>w#Ch+_ zH@YTC@65LpWDnCi-5D`si6pJsyP`&Ke=rr#^n?cb^2Z97^N9jqj^As*c1`a54G`xP z_*F+%AwW5zkft0}=%gG|h)|9zn3NL=G0I7W1m%=Ml5#pHH==`hSD~A7MxmW@R-ub> zP9aJ;ufTr*EGS6j-&5$Pd|x3*c|jpX$-g8Eu{akWC}b#?6nZE>R0vTnD@cV_6#CZh zyC-x>0Vrm^i}+m|(tgwp{T=Mj^EPLiv i{p@YEyZ(!J$hrQx|7ZjM>%KLw=DTB`u0M>eX#WBQ>lCp7 diff --git a/assets/icons/chevron_down.svg b/assets/icons/chevron_down.svg new file mode 100644 index 0000000000..43d3f6b84f --- /dev/null +++ b/assets/icons/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 1b5c424b0b..bc50c3ad39 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -57,125 +57,128 @@ abstract final class ZulipIcons { /// The Zulip custom icon "check_remove". static const IconData check_remove = IconData(0xf10b, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "chevron_down". + static const IconData chevron_down = IconData(0xf10c, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "edit". - static const IconData edit = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData edit = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye". - static const IconData eye = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData eye = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "eye_off". - static const IconData eye_off = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData eye_off = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "person". - static const IconData person = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData person = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "remove". - static const IconData remove = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData remove = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "search". - static const IconData search = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData search = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf134, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } From cc11ad66ada014fd408da2250b788e0bba27de42 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sat, 19 Jul 2025 00:30:03 +0430 Subject: [PATCH 10/12] user-status: Add page for setting own user status Fixes: #198 --- assets/l10n/app_en.arb | 48 +++ lib/generated/l10n/zulip_localizations.dart | 72 ++++ .../l10n/zulip_localizations_ar.dart | 37 ++ .../l10n/zulip_localizations_de.dart | 37 ++ .../l10n/zulip_localizations_en.dart | 37 ++ .../l10n/zulip_localizations_it.dart | 37 ++ .../l10n/zulip_localizations_ja.dart | 37 ++ .../l10n/zulip_localizations_nb.dart | 37 ++ .../l10n/zulip_localizations_pl.dart | 37 ++ .../l10n/zulip_localizations_ru.dart | 37 ++ .../l10n/zulip_localizations_sk.dart | 37 ++ .../l10n/zulip_localizations_sl.dart | 37 ++ .../l10n/zulip_localizations_uk.dart | 37 ++ .../l10n/zulip_localizations_zh.dart | 37 ++ lib/widgets/profile.dart | 7 + lib/widgets/set_status.dart | 339 ++++++++++++++++++ 16 files changed, 910 insertions(+) create mode 100644 lib/widgets/set_status.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 0f6aedeab4..09d0c6a1b0 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -821,6 +821,54 @@ "@noStatusText": { "description": "The text part of the status button sub-label in self-user profile page when status text is not set." }, + "setStatusPageTitle": "Set status", + "@setStatusPageTitle": { + "description": "Title for the 'Set status' page." + }, + "statusClearButtonLabel": "Clear", + "@statusClearButtonLabel": { + "description": "Label for the button that clears the user status, in 'Set status' page." + }, + "statusSaveButtonLabel": "Save", + "@statusSaveButtonLabel": { + "description": "Label for the button that saves the user status, in 'Set status' page." + }, + "statusTextHint": "Your status", + "@statusTextHint": { + "description": "Hint text for the status text input field in 'Set status' page." + }, + "userStatusBusy": "Busy", + "@userStatusBusy": { + "description": "A suggested user status text, 'Busy'." + }, + "userStatusInAMeeting": "In a meeting", + "@userStatusInAMeeting": { + "description": "A suggested user status text, 'In a meeting'." + }, + "userStatusCommuting": "Commuting", + "@userStatusCommuting": { + "description": "A suggested user status text, 'Commuting'." + }, + "userStatusOutSick": "Out sick", + "@userStatusOutSick": { + "description": "A suggested user status text, 'Out sick'." + }, + "userStatusVacationing": "Vacationing", + "@userStatusVacationing": { + "description": "A suggested user status text, 'Vacationing'." + }, + "userStatusWorkingRemotely": "Working remotely", + "@userStatusWorkingRemotely": { + "description": "A suggested user status text, 'Working remotely'." + }, + "userStatusAtTheOffice": "At the office", + "@userStatusAtTheOffice": { + "description": "A suggested user status text, 'At the office'." + }, + "updateStatusErrorTitle": "Error updating user status. Please try again.", + "@updateStatusErrorTitle": { + "description": "Error title when updating user status failed." + }, "searchMessagesPageTitle": "Search", "@searchMessagesPageTitle": { "description": "Page title for the 'Search' message view." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 6b98ba1bfc..0c6815caac 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1245,6 +1245,78 @@ abstract class ZulipLocalizations { /// **'No status text'** String get noStatusText; + /// Title for the 'Set status' page. + /// + /// In en, this message translates to: + /// **'Set status'** + String get setStatusPageTitle; + + /// Label for the button that clears the user status, in 'Set status' page. + /// + /// In en, this message translates to: + /// **'Clear'** + String get statusClearButtonLabel; + + /// Label for the button that saves the user status, in 'Set status' page. + /// + /// In en, this message translates to: + /// **'Save'** + String get statusSaveButtonLabel; + + /// Hint text for the status text input field in 'Set status' page. + /// + /// In en, this message translates to: + /// **'Your status'** + String get statusTextHint; + + /// A suggested user status text, 'Busy'. + /// + /// In en, this message translates to: + /// **'Busy'** + String get userStatusBusy; + + /// A suggested user status text, 'In a meeting'. + /// + /// In en, this message translates to: + /// **'In a meeting'** + String get userStatusInAMeeting; + + /// A suggested user status text, 'Commuting'. + /// + /// In en, this message translates to: + /// **'Commuting'** + String get userStatusCommuting; + + /// A suggested user status text, 'Out sick'. + /// + /// In en, this message translates to: + /// **'Out sick'** + String get userStatusOutSick; + + /// A suggested user status text, 'Vacationing'. + /// + /// In en, this message translates to: + /// **'Vacationing'** + String get userStatusVacationing; + + /// A suggested user status text, 'Working remotely'. + /// + /// In en, this message translates to: + /// **'Working remotely'** + String get userStatusWorkingRemotely; + + /// A suggested user status text, 'At the office'. + /// + /// In en, this message translates to: + /// **'At the office'** + String get userStatusAtTheOffice; + + /// Error title when updating user status failed. + /// + /// In en, this message translates to: + /// **'Error updating user status. Please try again.'** + String get updateStatusErrorTitle; + /// Page title for the 'Search' message view. /// /// 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 a75418b05e..bab80954d2 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -673,6 +673,43 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 1cc36418aa..fb5926d8ad 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -694,6 +694,43 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index ccb3b37da2..674d9e8547 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -673,6 +673,43 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 0603fee48b..d4e16e0e8c 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -688,6 +688,43 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index f3d5c4f2fd..03134f2f72 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -670,6 +670,43 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 0d79eeac0e..62dd0a3a6b 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -673,6 +673,43 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index f1ca09a23c..af8e857d6d 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -684,6 +684,43 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Szukaj'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 9ebe89be49..53575cf9a9 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -687,6 +687,43 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Поиск'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index ce34c3a4e0..6844f89b1e 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -675,6 +675,43 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 8df488f7d2..d82e44a154 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -699,6 +699,43 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 4b7fef42d3..2e9526a2de 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -687,6 +687,43 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 6283718634..d5aeae4872 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -673,6 +673,43 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get noStatusText => 'No status text'; + @override + String get setStatusPageTitle => 'Set status'; + + @override + String get statusClearButtonLabel => 'Clear'; + + @override + String get statusSaveButtonLabel => 'Save'; + + @override + String get statusTextHint => 'Your status'; + + @override + String get userStatusBusy => 'Busy'; + + @override + String get userStatusInAMeeting => 'In a meeting'; + + @override + String get userStatusCommuting => 'Commuting'; + + @override + String get userStatusOutSick => 'Out sick'; + + @override + String get userStatusVacationing => 'Vacationing'; + + @override + String get userStatusWorkingRemotely => 'Working remotely'; + + @override + String get userStatusAtTheOffice => 'At the office'; + + @override + String get updateStatusErrorTitle => + 'Error updating user status. Please try again.'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 5431690f9f..23f5bf039c 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -11,9 +11,11 @@ import '../model/narrow.dart'; import 'app_bar.dart'; import 'button.dart'; import 'content.dart'; +import 'icons.dart'; import 'message_list.dart'; import 'page.dart'; import 'remote_settings.dart'; +import 'set_status.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; @@ -164,6 +166,11 @@ class _SetStatusButton extends StatelessWidget { style: TextStyle(fontStyle: FontStyle.italic)) : TextSpan(text: userStatus.text), ]), + icon: ZulipIcons.chevron_right, + onPressed: () { + Navigator.push(context, SetStatusPage.buildRoute( + context: context, oldStatus: userStatus)); + }, ); } } diff --git a/lib/widgets/set_status.dart b/lib/widgets/set_status.dart new file mode 100644 index 0000000000..ccb677a499 --- /dev/null +++ b/lib/widgets/set_status.dart @@ -0,0 +1,339 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../api/route/users.dart'; +import '../basic.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../log.dart'; +import 'app_bar.dart'; +import 'color.dart'; +import 'emoji_reaction.dart'; +import 'icons.dart'; +import 'inset_shadow.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; +import 'user.dart'; + +class SetStatusPage extends StatefulWidget { + const SetStatusPage({super.key, required this.oldStatus}); + + final UserStatus oldStatus; + + static AccountRoute buildRoute({ + required BuildContext context, + required UserStatus oldStatus, + }) { + return MaterialAccountWidgetRoute(context: context, + page: SetStatusPage(oldStatus: oldStatus)); + } + + @override + State createState() => _SetStatusPageState(); +} + +class _SetStatusPageState extends State { + late final TextEditingController statusTextController; + late final ValueNotifier statusChange; + + UserStatus get oldStatus => widget.oldStatus; + UserStatus get newStatus => statusChange.value.apply(widget.oldStatus); + + @override + void initState() { + super.initState(); + statusTextController = TextEditingController(text: oldStatus.text) + ..addListener(() { + final trimmedValue = statusTextController.text.trim(); + final text = trimmedValue.isNotEmpty ? trimmedValue : null; + + // Ignore updating [statusChange] for the additional updates with the + // same value from TextField. For example, when a character is deleted. + if (text == newStatus.text) return; + + statusChange.value = statusChange.value.copyWith( + text: toOption(recent: text, old: oldStatus.text), + ); + }); + statusChange = + ValueNotifier(UserStatusChange(text: OptionNone(), emoji: OptionNone())) + ..addListener(() { + final text = statusChange.value.text.or(oldStatus.text) ?? ''; + + // Ignore updating the status text field if it already has the same + // text. It can happen in the following cases: + // 1. Only the emoji is changed. + // 2. The same status is chosen consecutively from the suggested + // statuses list. + // 3. This listener is called as a result of the change in status + // text field. + if (text == statusTextController.text) return; + + statusTextController.text = text; + }); + } + + @override + void dispose() { + statusTextController.dispose(); + statusChange.dispose(); + super.dispose(); + } + + List statusSuggestions(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final localizations = ZulipLocalizations.of(context); + + final values = [ + (localizations.userStatusBusy, '1f6e0'), + (localizations.userStatusInAMeeting, '1f4c5'), + (localizations.userStatusCommuting, '1f68c'), + (localizations.userStatusOutSick, '1f912'), + (localizations.userStatusVacationing, '1f334'), + (localizations.userStatusWorkingRemotely, '1f3e0'), + (localizations.userStatusAtTheOffice, '1f3e2'), + ]; + + return [ + for (final (statusText, emojiCode) in values) + if (store.getUnicodeEmojiNameByCode(emojiCode) case final emojiName?) + UserStatus( + text: statusText, + emoji: StatusEmoji( + emojiName: emojiName, + emojiCode: emojiCode, + reactionType: ReactionType.unicodeEmoji)), + ]; + } + + void handleStatusClear() { + statusChange.value = UserStatusChange( + text: toOption(recent: null, old: oldStatus.text), + emoji: toOption(recent: null, old: oldStatus.emoji), + ); + } + + Future handleStatusSave() async { + final store = PerAccountStoreWidget.of(context); + final localizations = ZulipLocalizations.of(context); + + Navigator.pop(context); + if (newStatus == oldStatus) return; + + try { + await updateStatus(store.connection, change: statusChange.value); + } catch (e) { + reportErrorToUserBriefly(localizations.updateStatusErrorTitle); + } + } + + void chooseStatusEmoji() async { + final emojiCandidate = await showEmojiPickerSheet(pageContext: context); + if (emojiCandidate == null) return; + + final emoji = StatusEmoji( + emojiName: emojiCandidate.emojiName, + emojiCode: emojiCandidate.emojiCode, + reactionType: emojiCandidate.emojiType, + ); + statusChange.value = statusChange.value.copyWith( + emoji: toOption(recent: emoji, old: oldStatus.emoji) + ); + } + + void chooseStatusSuggestion(UserStatus status) { + statusChange.value = UserStatusChange( + text: toOption(recent: status.text, old: oldStatus.text), + emoji: toOption(recent: status.emoji, old: oldStatus.emoji) + ); + } + + Option toOption({required T recent, required T old}) => + recent == old ? OptionNone() : OptionSome(recent); + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final localizations = ZulipLocalizations.of(context); + final suggestions = statusSuggestions(context); + + return Scaffold( + appBar: ZulipAppBar(title: Text(localizations.setStatusPageTitle), + actions: [ + ValueListenableBuilder( + valueListenable: statusChange, + builder: (_, _, _) { + return _ActionButton( + label: localizations.statusClearButtonLabel, + icon: ZulipIcons.remove, + onPressed: newStatus == UserStatus.zero + ? null + : handleStatusClear, + ); + }), + ValueListenableBuilder( + valueListenable: statusChange, + builder: (_, change, _) { + return _ActionButton( + label: localizations.statusSaveButtonLabel, + icon: ZulipIcons.check, + onPressed: switch ((change.text, change.emoji)) { + (OptionNone(), OptionNone()) => null, + _ => handleStatusSave, + }); + }), + ], + ), + body: Column(children: [ + Padding( + padding: const EdgeInsetsDirectional.only( + // In Figma design, this is 16px, but we compensate for that in + // the icon button below. + start: 8, + top: 8, end: 10, + // In Figma design, this is 4px, be we compensate for that in + // [SingleChildScrollView.padding] below. + bottom: 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + onPressed: chooseStatusEmoji, + style: IconButton.styleFrom( + splashFactory: NoSplash.splashFactory, + foregroundColor: designVariables.icon, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.symmetric( + vertical: 8, + // In Figma design, there is no horizontal padding, but we + // provide it in order to create a proper tap target size. + horizontal: 8)), + icon: Row(children: [ + ValueListenableBuilder( + valueListenable: statusChange, + builder: (_, change, _) { + final emoji = change.emoji.or(oldStatus.emoji); + return emoji == null + ? const Icon(ZulipIcons.smile, size: 24) + : UserStatusEmoji(emoji: emoji, size: 24, neverAnimate: false); + }), + SizedBox(width: 4), + Icon(ZulipIcons.chevron_down, size: 16), + ]), + ), + Expanded(child: TextField( + controller: statusTextController, + minLines: 1, + maxLines: 2, + // The limit on the size of the status text is 60 characters. + // See: https://zulip.com/api/update-status#parameter-status_text + maxLength: 60, + cursorColor: designVariables.textInput, + textCapitalization: TextCapitalization.sentences, + style: TextStyle(fontSize: 19, height: 24 / 19), + decoration: InputDecoration( + counterText: '', + hintText: localizations.statusTextHint, + hintStyle: TextStyle(color: designVariables.labelSearchPrompt), + isDense: true, + contentPadding: EdgeInsets.symmetric( + vertical: 8, + // Subtracting 4 pixels to account for the internal + // 4-pixel horizontal padding. + horizontal: 10 - 4, + ), + filled: true, + fillColor: designVariables.bgSearchInput, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + )))), + ]), + ), + Expanded(child: InsetShadowBox( + top: 6, bottom: 6, + color: designVariables.mainBackground, + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), // TODO: necessary? + padding: EdgeInsets.symmetric(vertical: 6), + child: Column(children: [ + for (final status in suggestions) + _StatusSuggestionsListEntry( + status: status, + onTap: () => chooseStatusSuggestion(status)), + ])))), + ]), + ); + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.label, + required this.icon, + required this.onPressed, + }); + + final String label; + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return TextButton( + onPressed: onPressed, + style: IconButton.styleFrom( + splashFactory: NoSplash.splashFactory, + foregroundColor: designVariables.icon, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.symmetric(vertical: 6, horizontal: 8), + ), + child: Row( + spacing: 4, + children: [ + Icon(icon, size: 24), + Text(label, + style: TextStyle( + fontSize: 20, + height: 30 / 20, + ).merge(weightVariableTextStyle(context, wght: 600))), + ])); + } +} + +class _StatusSuggestionsListEntry extends StatelessWidget { + const _StatusSuggestionsListEntry({required this.status, required this.onTap}); + + final UserStatus status; + final GestureTapCallback onTap; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return InkWell( + onTap: onTap, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateColor.resolveWith( + (states) => states.any((e) => e == WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent, + ), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 7, horizontal: 16), + child: Row( + spacing: 8, + children: [ + UserStatusEmoji(emoji: status.emoji!, size: 19), + Flexible(child: Text(status.text!, + style: TextStyle(fontSize: 19, height: 24 / 19), + maxLines: 1, + overflow: TextOverflow.ellipsis)), + ])), + ); + } +} From 862b48a2373ecd019868d40e83f93b9a7367b9c4 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 24 Jul 2025 18:45:51 -0700 Subject: [PATCH 11/12] (squash) user-status: Update a test on profile page --- test/widgets/profile_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index f1aaebd807..9f1e0c70a6 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -29,6 +29,7 @@ import '../stdlib_checks.dart'; import '../test_images.dart'; import '../test_navigation.dart'; import 'checks.dart'; +import 'finders.dart'; import 'test_app.dart'; late PerAccountStore store; @@ -391,7 +392,7 @@ void main() { check(statusEmojiFinder).findsOne(); check(tester.widget(statusEmojiFinder) .neverAnimate).isFalse(); - check(find.text('Busy')).findsOne(); + check(findText(includePlaceholders: false, 'Busy')).findsOne(); }); }); From 9d3abbd6effecf1ed6ff72da5aa228fddafbca3c Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 24 Jul 2025 18:47:28 -0700 Subject: [PATCH 12/12] (squash) user-status: Adjust candidate emoji size to 24 on set-status screen --- lib/widgets/set_status.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/set_status.dart b/lib/widgets/set_status.dart index ccb677a499..038d7a517c 100644 --- a/lib/widgets/set_status.dart +++ b/lib/widgets/set_status.dart @@ -328,7 +328,7 @@ class _StatusSuggestionsListEntry extends StatelessWidget { child: Row( spacing: 8, children: [ - UserStatusEmoji(emoji: status.emoji!, size: 19), + UserStatusEmoji(emoji: status.emoji!, size: 24), Flexible(child: Text(status.text!, style: TextStyle(fontSize: 19, height: 24 / 19), maxLines: 1,