diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 612d8a04fa..d38427fc5e 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ 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/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 2520b6bb81..0f762fc1cd 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -825,6 +825,66 @@ "@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." + }, + "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/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)); 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 diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index f1bc2e1e8f..0432baa8f7 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1253,6 +1253,96 @@ 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; + + /// 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 3784045f0b..00a08887a2 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -676,6 +676,52 @@ 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 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 16637d166c..a769a6e862 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -697,6 +697,52 @@ 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 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 8ab4638c78..f85fff71dc 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -676,6 +676,52 @@ 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 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_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 7cb32f40e1..46c3aa2885 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -676,6 +676,52 @@ class ZulipLocalizationsFr 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 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 8716612d8b..dc0db573a4 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -691,6 +691,52 @@ 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 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 234cfb22c4..4fe6d68cd4 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -673,6 +673,52 @@ 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 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 46771b771f..2ec302486c 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -676,6 +676,52 @@ 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 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 7714cd26bc..a084d59ae3 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -687,6 +687,52 @@ 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 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 f7e35040a1..198d1b594f 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -690,6 +690,52 @@ 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 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 52f0d3794b..d7b1580c21 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -678,6 +678,52 @@ 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 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 91c7a3070a..4102e374fe 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -702,6 +702,52 @@ 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 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 289ebe6de6..8b682b7a92 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -690,6 +690,52 @@ 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 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 5be4fdf1fb..3a3ce62aab 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -676,6 +676,52 @@ 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 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/model/emoji.dart b/lib/model/emoji.dart index 0b8ae60333..c15ab8e2da 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; // TODO(log) if null + void setServerEmojiData(ServerEmojiData data) { _serverEmojiData = data.codeToNames; _popularCandidates = null; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 11d7bc0fca..73676b598e 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -789,14 +789,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/button.dart b/lib/widgets/button.dart index 08af96806b..d4f8c98486 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -290,14 +290,16 @@ class ZulipMenuItemButton extends StatelessWidget { super.key, this.style = ZulipMenuItemButtonStyle.menu, required this.label, - required this.onPressed, + this.subLabel, + this.onPressed, this.icon, this.toggle, }); final ZulipMenuItemButtonStyle style; final String label; - final VoidCallback onPressed; + final TextSpan? subLabel; + final VoidCallback? onPressed; final IconData? icon; /// A [Toggle] to go before [icon], or in its place if it's null. @@ -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())))), + ], + ))); } } 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 diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 2392e054c5..1ba94dc389 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -57,128 +57,131 @@ 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 "link". - static const IconData link = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData link = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "person". - static const IconData person = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData person = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "plus". - static const IconData plus = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData plus = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "remove". - static const IconData remove = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData remove = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "search". - static const IconData search = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData search = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf12a, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf12b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf12b, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf12c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf12c, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf12d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf12d, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf12e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf12e, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf12f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf12f, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf130, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf130, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf131, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf131, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf132, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topics". - static const IconData topics = IconData(0xf132, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf133, fontFamily: "Zulip Icons"); /// The Zulip custom icon "two_person". - static const IconData two_person = IconData(0xf133, fontFamily: "Zulip Icons"); + static const IconData two_person = IconData(0xf134, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf134, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf135, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 9b65831b29..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'; @@ -76,16 +78,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 +103,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 +139,42 @@ 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), + ]), + icon: ZulipIcons.chevron_right, + onPressed: () { + Navigator.push(context, SetStatusPage.buildRoute( + context: context, oldStatus: userStatus)); + }, + ); + } +} + class _InvisibleModeToggle extends StatelessWidget { const _InvisibleModeToggle(); @@ -140,24 +183,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))); } } diff --git a/lib/widgets/set_status.dart b/lib/widgets/set_status.dart new file mode 100644 index 0000000000..9d28eaca27 --- /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 there is a change in + // selection or in composing range. + if (text == newStatus.text) return; + + statusChange.value = statusChange.value.copyWith( + text: asChange(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 = [ + ('1f6e0', localizations.userStatusBusy), + ('1f4c5', localizations.userStatusInAMeeting), + ('1f68c', localizations.userStatusCommuting), + ('1f912', localizations.userStatusOutSick), + ('1f334', localizations.userStatusVacationing), + ('1f3e0', localizations.userStatusWorkingRemotely), + ('1f3e2', localizations.userStatusAtTheOffice), + ]; + return [ + for (final (emojiCode, statusText) 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: asChange(null, old: oldStatus.text), + emoji: asChange(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: asChange(emoji, old: oldStatus.emoji)); + } + + void chooseStatusSuggestion(UserStatus status) { + statusChange.value = UserStatusChange( + text: asChange(status.text, old: oldStatus.text), + emoji: asChange(status.emoji, old: oldStatus.emoji)); + } + + Option asChange(T new_, {required T old}) => + new_ == old ? OptionNone() : OptionSome(new_); + + @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(spacing: 4, 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); + }), + 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( + // TODO: display a counter as suggested in CZO discussion: + // https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/Set.20user.20status/near/2224549 + 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( + 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))), + ])); + } +} + +@visibleForTesting +class StatusSuggestionsListEntry extends StatelessWidget { + const StatusSuggestionsListEntry({ + super.key, + 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: 24), + Flexible(child: Text(status.text!, + style: TextStyle(fontSize: 19, height: 24 / 19), + maxLines: 1, + overflow: TextOverflow.ellipsis)), + ])), + ); + } +} 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) ; diff --git a/test/api/route/users_test.dart b/test/api/route/users_test.dart index b83c801a2a..16975bbc23 100644 --- a/test/api/route/users_test.dart +++ b/test/api/route/users_test.dart @@ -3,11 +3,31 @@ import 'package:http/http.dart' as http; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/users.dart'; +import 'package:zulip/basic.dart'; import '../../stdlib_checks.dart'; import '../fake_api.dart'; void main() { + test('smoke updateStatus', () { + return FakeApiConnection.with_((connection) async { + connection.prepare(json: {}); + await updateStatus(connection, change: UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + check(connection.takeRequests()).single.isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/status') + ..bodyFields.deepEquals({ + 'status_text': 'Busy', + 'emoji_name': 'working_on_it', + 'emoji_code': '1f6e0', + 'reaction_type': 'unicode_emoji', + }); + }); + }); + test('smoke updatePresence', () { return FakeApiConnection.with_((connection) async { final response = UpdatePresenceResult( diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index d96ccae5e4..5a89722d6e 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -331,6 +331,30 @@ void main() { }); }); + group('getUnicodeEmojiNameByCode', () { + test('happy path', () { + final store = prepare(unicodeEmoji: { + '1f4c5': ['calendar'], + '1f34a': ['orange', 'tangerine', 'mandarin'], + }); + check(store.getUnicodeEmojiNameByCode('1f4c5')).equals('calendar'); + check(store.getUnicodeEmojiNameByCode('1f34a')).equals('orange'); + }); + + test('server emoji data present, emoji code not present', () { + final store = prepare(unicodeEmoji: { + '1f4c5': ['calendar'], + }); + check(store.getUnicodeEmojiNameByCode('1f34a')).isNull(); + }); + + test('server emoji data is not present', () { + final store = prepare(addServerDataForPopular: false); + check(store.debugServerEmojiData).isNull(); + check(store.getUnicodeEmojiNameByCode('1f516')).isNull(); + }); + }); + group('EmojiAutocompleteView', () { Condition isUnicodeResult({String? emojiCode, List? names}) { return (it) => it.isA().candidate.which( diff --git a/test/test_navigation.dart b/test/test_navigation.dart index 35da8af6b0..15c6a345d6 100644 --- a/test/test_navigation.dart +++ b/test/test_navigation.dart @@ -1,11 +1,12 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; // Inspired by test code in the Flutter tree: // https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/observer_tester.dart // https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/navigator_test.dart /// A trivial observer for testing the navigator. -class TestNavigatorObserver extends NavigatorObserver { +class TestNavigatorObserver extends TransitionDurationObserver{ void Function(Route topRoute, Route? previousTopRoute)? onChangedTop; void Function(Route route, Route? previousRoute)? onPushed; void Function(Route route, Route? previousRoute)? onPopped; @@ -16,36 +17,43 @@ class TestNavigatorObserver extends NavigatorObserver { @override void didChangeTop(Route topRoute, Route? previousTopRoute) { + super.didChangeTop(topRoute, previousTopRoute); onChangedTop?.call(topRoute, previousTopRoute); } @override void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); onPushed?.call(route, previousRoute); } @override void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); onPopped?.call(route, previousRoute); } @override void didRemove(Route route, Route? previousRoute) { + super.didRemove(route, previousRoute); onRemoved?.call(route, previousRoute); } @override void didReplace({ Route? oldRoute, Route? newRoute }) { + super.didReplace(oldRoute: oldRoute, newRoute: newRoute); onReplaced?.call(newRoute, oldRoute); } @override void didStartUserGesture(Route route, Route? previousRoute) { + super.didStartUserGesture(route, previousRoute); onStartUserGesture?.call(route, previousRoute); } @override void didStopUserGesture() { + super.didStopUserGesture(); onStopUserGesture?.call(); } } diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index f1aaebd807..a7b74356ab 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -10,6 +10,7 @@ import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/basic.dart'; +import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/button.dart'; @@ -29,6 +30,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; @@ -362,14 +364,25 @@ void main() { }); group('user status', () { - testWidgets('non-self profile, status set: status info appears', (tester) async { - await setupPage(tester, users: [eg.otherUser], pageUserId: eg.otherUser.userId); + final localizations = GlobalLocalizations.zulipLocalizations; + + Finder findStatusButton({required bool statusSet}) { + return find.widgetWithText(ZulipMenuItemButton, + statusSet + ? localizations.statusButtonLabelStatusSet + : localizations.statusButtonLabelStatusUnset); + } + + testWidgets('non-self profile, status set: no status button, status info appears', (tester) async { + await setupPage(tester, pageUserId: eg.otherUser.userId, users: [eg.otherUser]); await store.changeUserStatus(eg.otherUser.userId, UserStatusChange( text: OptionSome('Busy'), emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); await tester.pump(); + check(findStatusButton(statusSet: true)).findsNothing(); + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), matching: find.byType(UserStatusEmoji)); check(statusEmojiFinder).findsOne(); @@ -378,20 +391,54 @@ void main() { check(find.text('Busy')).findsOne(); }); - testWidgets('self-profile, status set: status info appears', (tester) async { - await setupPage(tester, users: [eg.selfUser], pageUserId: eg.selfUser.userId); - await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( - text: OptionSome('Busy'), - emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', - emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); - await tester.pump(); + group('self-profile', () { + testWidgets('no status set: status button appears', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId, users: [eg.selfUser]); + check(findStatusButton(statusSet: false)).findsOne(); + }); - final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), - matching: find.byType(UserStatusEmoji)); - check(statusEmojiFinder).findsOne(); - check(tester.widget(statusEmojiFinder) - .neverAnimate).isFalse(); - check(find.text('Busy')).findsOne(); + testWidgets('status set: status button appears with status info inside it', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId, users: [eg.selfUser]); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + final statusButtonFinder = findStatusButton(statusSet: true); + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + final statusTextFinder = findText(includePlaceholders: false, 'Busy'); + + check(statusButtonFinder).findsOne(); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(statusTextFinder).findsOne(); + + check(find.descendant(of: statusButtonFinder, + matching: statusEmojiFinder)).findsOne(); + check(find.descendant(of: statusButtonFinder, + matching: statusTextFinder)).findsOne(); + }); + + testWidgets('not status text set: status button appears with a placeholder text inside it', (tester) async { + await setupPage(tester, pageUserId: eg.selfUser.userId, users: [eg.selfUser]); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionNone(), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + final statusButtonFinder = findStatusButton(statusSet: true); + final textPlaceholderFinder = findText( + includePlaceholders: false, localizations.noStatusText); + + check(statusButtonFinder).findsOne(); + check(textPlaceholderFinder).findsOne(); + check(find.descendant(of: statusButtonFinder, + matching: textPlaceholderFinder)).findsOne(); + }); }); }); diff --git a/test/widgets/set_status_test.dart b/test/widgets/set_status_test.dart new file mode 100644 index 0000000000..c1db94096b --- /dev/null +++ b/test/widgets/set_status_test.dart @@ -0,0 +1,463 @@ +import 'dart:io'; + +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:http/http.dart' as http; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/basic.dart'; +import 'package:zulip/model/emoji.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/button.dart'; +import 'package:zulip/widgets/emoji_reaction.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/profile.dart'; +import 'package:zulip/widgets/set_status.dart'; +import 'package:zulip/widgets/user.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../example_data.dart'; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../stdlib_checks.dart'; +import '../test_images.dart'; +import '../test_navigation.dart'; + +import 'checks.dart'; +import 'finders.dart'; +import 'test_app.dart'; + +void main() { + late PerAccountStore store; + + TestZulipBinding.ensureInitialized(); + + final ServerEmojiData suggestedEmojiData = ServerEmojiData(codeToNames: { + '1f6e0': ['working_on_it'], + '1f4c5': ['calendar'], + '1f68c': ['bus'], + '1f912': ['sick'], + '1f334': ['palm_tree'], + '1f3e0': ['house'], + '1f3e2': ['office'], + }); + + Future setupPage(WidgetTester tester, { + UserStatusChange? change, + ServerEmojiData? emojiData, + NavigatorObserver? navigatorObserver, + }) async { + addTearDown(testBinding.reset); + + Route? currentRoute; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, _) => currentRoute = route; + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUser(eg.selfUser); + if (change != null) { + await store.changeUserStatus(eg.selfUser.userId, change); + } + if (emojiData != null) { + store.setServerEmojiData(emojiData); + } + + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + navigatorObservers: [testNavObserver, ?navigatorObserver], + child: ProfilePage(userId: eg.selfUser.userId))); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ZulipMenuItemButton, + switch (change) { + null || UserStatusChange(text: OptionNone(), emoji: OptionNone()) + => 'Set status', + _ => 'Status', + })); + await tester.pump(); + await testNavObserver.pumpPastTransition(tester); + check(currentRoute).isA().page.isA(); + } + + final clearButtonFinder = find.widgetWithText(TextButton, 'Clear'); + final saveButtonFinder = find.widgetWithText(TextButton, 'Save'); + + void checkButtonsEnabled(WidgetTester tester, + {required bool expectClear, required bool expectSave}) { + final clearButton = tester.widget(clearButtonFinder); + final saveButton = tester.widget(saveButtonFinder); + + expectClear + ? check(clearButton.onPressed).isNotNull() + : check(clearButton.onPressed).isNull(); + expectSave + ? check(saveButton.onPressed).isNotNull() + : check(saveButton.onPressed).isNull(); + } + + Finder findEmojiButton({bool emojiSelected = false}) { + return find.ancestor(of: emojiSelected + ? find.byType(UserStatusEmoji) : find.byIcon(ZulipIcons.smile), + matching: find.ancestor(of: find.byIcon(ZulipIcons.chevron_down), + matching: find.byType(IconButton))); + } + + Finder findStatusTextField() { + return find.byWidgetPredicate((widget) => switch(widget) { + TextField(decoration: InputDecoration(hintText: 'Your status')) => true, + _ => false + }); + } + + Finder findSuggestion({required String code, required String text}) { + final emojiFinder = find.ancestor( + of: find.text(tryParseEmojiCodeToUnicode(code)!), + matching: find.byType(UserStatusEmoji)); + return find.ancestor(of: emojiFinder, + matching: find.ancestor(of: find.text(text), + matching: find.byType(StatusSuggestionsListEntry))); + } + + testWidgets('set status page renders', (tester) async { + await setupPage(tester, emojiData: suggestedEmojiData); + + check(find.text('Set status')).findsOne(); + check(clearButtonFinder).findsOne(); + check(saveButtonFinder).findsOne(); + check(findEmojiButton()).findsOne(); + check(findStatusTextField()).findsOne(); + + check(findSuggestion(code: '1f6e0', text: 'Busy')).findsOne(); + check(findSuggestion(code: '1f4c5', text: 'In a meeting')).findsOne(); + check(findSuggestion(code: '1f68c', text: 'Commuting')).findsOne(); + check(findSuggestion(code: '1f3e0', text: 'Working remotely')).findsOne(); + }); + + group('"Clear" & "Save" buttons', () { + group('initial state', () { + testWidgets('no status set -> buttons are disabled', (tester) async { + await setupPage(tester); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + }); + + testWidgets('text & emoji are set -> "Clear" is enabled, "Save" is not', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + }); + + testWidgets('only text is set -> "Clear" is enabled, "Save" is not', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), emoji: OptionNone())); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + }); + + testWidgets('only emoji is set -> "Clear" is enabled, "Save" is not', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionNone(), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + }); + }); + + group('edit status', () { + Future chooseUnicodeEmojiFromPicker(WidgetTester tester, String code, { + bool emojiSelected = false, + required TestNavigatorObserver navObserver, + required ValueGetter> currentRoute, + }) async { + await tester.tap(findEmojiButton(emojiSelected: emojiSelected)); + check(currentRoute()).isA>(); + await navObserver.pumpPastTransition(tester); + // We use `find.descendant` to not match for an emoji in status + // suggestions in the underlying page. + await tester.tap(find.descendant(of: find.byType(EmojiPicker), + matching: find.text(tryParseEmojiCodeToUnicode(code)!))); + await tester.pump(); + await navObserver.pumpPastTransition(tester); + check(currentRoute()).isA() + .page.isA(); + } + + group('no status set, buttons are disabled', () { + testWidgets('emoji is added -> buttons are enabled', (tester) async { + prepareBoringImageHttpClient(); + + Route? currentRoute; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, _) => currentRoute = route; + testNavObserver.onPopped = (_, previous) => currentRoute = previous; + + await setupPage(tester, + emojiData: serverEmojiDataPopular, + navigatorObserver: testNavObserver); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + + // Choose 'slight_smile' from popular emojis. + await chooseUnicodeEmojiFromPicker(tester, '1f642', + navObserver: testNavObserver, currentRoute: () => currentRoute!); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('text is added -> buttons are enabled', (tester) async { + await setupPage(tester); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + + await tester.enterText(findStatusTextField(), 'Happy'); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + }); + + testWidgets('empty spaces are added as text -> buttons stays disabled', (tester) async { + await setupPage(tester); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + + await tester.enterText(findStatusTextField(), ' '); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + }); + + testWidgets('a suggestion is selected -> buttons are enabled', (tester) async { + await setupPage(tester, emojiData: suggestedEmojiData); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + }); + + testWidgets('a suggestion is selected, then removed -> buttons are enabled, then disabled', (tester) async { + await setupPage(tester, emojiData: suggestedEmojiData); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + + await tester.tap(clearButtonFinder); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: false, expectSave: false); + }); + }); + + group('status set, "Clear" is enabled, "Save" is not', () { + testWidgets('emoji is changed -> buttons are enabled', (tester) async { + prepareBoringImageHttpClient(); + + Route? currentRoute; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, _) => currentRoute = route; + testNavObserver.onPopped = (_, previous) => currentRoute = previous; + + await setupPage(tester, + emojiData: ServerEmojiData(codeToNames: { + ...serverEmojiDataPopular.codeToNames, + ...suggestedEmojiData.codeToNames, + }), + navigatorObserver: testNavObserver, + change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + // Choose 'calender' included in suggested emojis. + await chooseUnicodeEmojiFromPicker(tester, '1f4c5', + emojiSelected: true, + navObserver: testNavObserver, currentRoute: () => currentRoute!); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('text is changed -> buttons are enabled', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + await tester.enterText(findStatusTextField(), 'Happy as a calm'); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + }); + + testWidgets('empty spaces are added around the text -> buttons stays the same', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + await tester.enterText(findStatusTextField(), ' Happy '); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + }); + + testWidgets('a suggestion is selected -> buttons are enabled', (tester) async { + await setupPage(tester, + emojiData: suggestedEmojiData, + change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + }); + + testWidgets('emoji & text are changed, then reset -> buttons are enabled, then "Clear" is enabled, "Save" is not', (tester) async { + prepareBoringImageHttpClient(); + + Route? currentRoute; + final testNavObserver = TestNavigatorObserver(); + testNavObserver.onPushed = (route, _) => currentRoute = route; + testNavObserver.onPopped = (_, previous) => currentRoute = previous; + + await setupPage(tester, + emojiData: ServerEmojiData(codeToNames: { + ...serverEmojiDataPopular.codeToNames, + ...suggestedEmojiData.codeToNames, + }), + navigatorObserver: testNavObserver, + change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + // Choose 'calender' included in suggested emojis. + await chooseUnicodeEmojiFromPicker(tester, '1f4c5', + emojiSelected: true, + navObserver: testNavObserver, currentRoute: () => currentRoute!); + await tester.enterText(findStatusTextField(), 'Happy as a calm'); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + + // Reset to the initial emoji. + await chooseUnicodeEmojiFromPicker(tester, '1f642', + emojiSelected: true, + navObserver: testNavObserver, currentRoute: () => currentRoute!); + // Reset to the initial text. + await tester.enterText(findStatusTextField(), 'Happy'); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('a new suggestion is selected, then reset -> buttons are enabled, then "Clear" is enabled, "Save" is not', (tester) async { + await setupPage(tester, + emojiData: suggestedEmojiData, + // One of the emoji suggestions. + change: UserStatusChange( + text: OptionSome('Working remotely'), + emoji: OptionSome(StatusEmoji(emojiName: 'house', + emojiCode: '1f3e0', reactionType: ReactionType.unicodeEmoji)))); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: true); + + // Reset the suggestion. + await tester.tap(findSuggestion(code: '1f3e0', text: 'Working remotely')); + await tester.pump(); + checkButtonsEnabled(tester, expectClear: true, expectSave: false); + }); + }); + }); + + testWidgets('"Clear" button removes both emoji and text', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + + check(findEmojiButton(emojiSelected: true)).findsOne(); + check(tester.widget(findStatusTextField()).controller!.text) + .equals('Happy'); + + await tester.tap(clearButtonFinder); + await tester.pump(); + + check(findEmojiButton(emojiSelected: false)).findsOne(); + check(tester.widget(findStatusTextField()).controller!.text) + .equals(''); + }); + + group('"Save" button returns to Profile page, saves the status', () { + testWidgets('successful -> status info appears', (tester) async { + final testNavObserver = TestNavigatorObserver(); + + await setupPage(tester, + emojiData: suggestedEmojiData, navigatorObserver: testNavObserver); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + + final connection = store.connection as FakeApiConnection; + connection.prepare(json: {}, delay: Duration(milliseconds: 100)); + + await tester.tap(saveButtonFinder); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/status') + ..bodyFields.deepEquals({ + 'status_text': 'Busy', + 'emoji_name': 'working_on_it', + 'emoji_code': '1f6e0', + 'reaction_type': 'unicode_emoji', + }); + await testNavObserver.pumpPastTransition(tester); + check(find.byType(ProfilePage)).findsOne(); + }); + + testWidgets("error -> status info doesn't appears", (tester) async { + final testNavObserver = TestNavigatorObserver(); + + await setupPage(tester, + emojiData: suggestedEmojiData, navigatorObserver: testNavObserver); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + + final connection = store.connection as FakeApiConnection; + connection.prepare(httpException: SocketException('failed')); + + await tester.tap(saveButtonFinder); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/status') + ..bodyFields.deepEquals({ + 'status_text': 'Busy', + 'emoji_name': 'working_on_it', + 'emoji_code': '1f6e0', + 'reaction_type': 'unicode_emoji', + }); + await testNavObserver.pumpPastTransition(tester); + check(find.byType(ProfilePage)).findsOne(); + + check(find.text('\u{1f6e0}')).findsNothing(); + check(findText('Busy', includePlaceholders: false)).findsNothing(); + }); + }); + }); + + testWidgets('Status text field has a 60-char limit', (tester) async { + await setupPage(tester); + check(tester.widget(findStatusTextField()).maxLength).equals(60); + }); +}