diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 85f393019a..cec366907d 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 c24f23dce9..09d0c6a1b0 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -809,6 +809,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 9668b50f26..6cd6714639 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1229,6 +1229,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 110b0dbe24..bab80954d2 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -664,6 +664,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 f3b1bdad67..fb5926d8ad 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -685,6 +685,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 f99c386087..674d9e8547 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -664,6 +664,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 cbc18b6d35..4ed5e87a08 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -664,6 +664,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 2d7d35e23e..d4e16e0e8c 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -679,6 +679,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 edf5c759f9..03134f2f72 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -661,6 +661,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 0568bc0ae7..62dd0a3a6b 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -664,6 +664,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 c96ab24679..af8e857d6d 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -675,6 +675,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 be5de60e97..53575cf9a9 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -678,6 +678,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 33b4465eb6..6844f89b1e 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -666,6 +666,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 8d587b9085..d82e44a154 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -690,6 +690,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 6799942531..2e9526a2de 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -678,6 +678,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 c44852c041..fb9967691e 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -664,6 +664,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..a493420f02 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,11 @@ class EmojiStoreImpl extends PerAccountStoreBase with EmojiStore { return _allEmojiCandidates ??= _generateAllCandidates(); } + @override + String? getUnicodeEmojiNameByCode(String emojiCode) => + // TODO(log) if null; not supposed to happen + _serverEmojiData?[emojiCode]?.firstOrNull; + void setServerEmojiData(ServerEmojiData data) { _serverEmojiData = data.codeToNames; _popularCandidates = null; 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/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 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 } 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..dc00787b42 --- /dev/null +++ b/lib/widgets/set_status.dart @@ -0,0 +1,346 @@ +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 = statusCodesToText(localizations).entries; + return [ + for (final MapEntry(key: emojiCode, value: 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)), + ])), + ); + } +} + +@visibleForTesting +Map statusCodesToText(ZulipLocalizations localizations) { + return { + '1f6e0': localizations.userStatusBusy, + '1f4c5': localizations.userStatusInAMeeting, + '1f68c': localizations.userStatusCommuting, + '1f912': localizations.userStatusOutSick, + '1f334': localizations.userStatusVacationing, + '1f3e0': localizations.userStatusWorkingRemotely, + '1f3e2': localizations.userStatusAtTheOffice, + }; +} 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/pubspec.lock b/pubspec.lock index e693e77f23..09dd61cd51 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -714,10 +714,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: "direct main" description: @@ -1079,26 +1079,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.26.3" test_api: dependency: "direct dev" description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.12" timing: dependency: transitive description: @@ -1355,5 +1355,5 @@ packages: source: path version: "0.0.1" sdks: - dart: ">=3.9.0-293.0.dev <4.0.0" - flutter: ">=3.33.0-1.0.pre.832" + dart: ">=3.10.0-28.0.dev <4.0.0" + flutter: ">=3.33.0-1.0.pre.1142" diff --git a/pubspec.yaml b/pubspec.yaml index cb108fa57e..f211ea017d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,8 +14,8 @@ environment: # We use a recent version of Flutter from its main channel, and # the corresponding recent version of the Dart SDK. # Feel free to update these regularly; see README.md for instructions. - sdk: '>=3.9.0-293.0.dev <4.0.0' - flutter: '>=3.33.0-1.0.pre.832' # d35bde5363d5e25b71d69a81e8c93b0ee3272609 + sdk: '>=3.10.0-28.0.dev <4.0.0' + flutter: '>=3.33.0-1.0.pre.1142' # e2d591cf88a82b6e2bf40b3a445a3b7c29fcd587 # To update dependencies, see instructions in README.md. dependencies: 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/example_data.dart b/test/example_data.dart index b44108eb21..6858d5b600 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -166,16 +166,9 @@ final ServerEmojiData serverEmojiDataPopular = _immutableServerEmojiData(codeToN }); ServerEmojiData serverEmojiDataPopularPlus(ServerEmojiData data) { - final a = serverEmojiDataPopular; - final b = data; - final result = ServerEmojiData( - codeToNames: {...a.codeToNames, ...b.codeToNames}, - ); - assert( - result.codeToNames.length == a.codeToNames.length + b.codeToNames.length, - 'eg.serverEmojiDataPopularPlus called with data that collides with eg.serverEmojiDataPopular', + return ServerEmojiData( + codeToNames: {...data.codeToNames, ...serverEmojiDataPopular.codeToNames}, ); - return result; } /// Like [serverEmojiDataPopular], but with the legacy '1f642': ['smile'] diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index d96ccae5e4..dacd7d6f05 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -331,6 +331,37 @@ 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 present, emoji code corresponds to empty emoji name list', () { + final store = prepare(unicodeEmoji: { + '1f4c5': [], + }); + check(store.getUnicodeEmojiNameByCode('1f516')).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..ec978bad31 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; @@ -21,11 +22,13 @@ class TestNavigatorObserver extends NavigatorObserver { @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); } @@ -36,6 +39,7 @@ class TestNavigatorObserver extends NavigatorObserver { @override void didReplace({ Route? oldRoute, Route? newRoute }) { + super.didReplace(); onReplaced?.call(newRoute, oldRoute); } 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..859077ed2a --- /dev/null +++ b/test/widgets/set_status_test.dart @@ -0,0 +1,547 @@ +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: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/localizations.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 '../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 Map> suggestedUnicodeEmoji = { + '1f6e0': ['working_on_it'], + '1f4c5': ['calendar'], + '1f68c': ['bus'], + '1f912': ['sick'], + '1f334': ['palm_tree'], + '1f3e0': ['house'], + '1f3e2': ['office'], + }; + final ServerEmojiData suggestedEmojiData = ServerEmojiData(codeToNames: suggestedUnicodeEmoji); + + Future setupPage(WidgetTester tester, { + UserStatusChange change = const UserStatusChange(text: OptionNone(), emoji: OptionNone()), + 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); + 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) { + UserStatusChange(text: OptionNone(), emoji: OptionNone()) + => 'Set status', + _ => 'Status', + })); + await tester.pump(); + await testNavObserver.pumpPastTransition(tester); + check(currentRoute).isNotNull().isA() + .page.isA(); + } + + final clearButtonFinder = find.widgetWithText(TextButton, 'Clear'); + final saveButtonFinder = find.widgetWithText(TextButton, 'Save'); + + void checkButtonEnabled(WidgetTester tester, Finder buttonFinder, + {required bool expected}) { + final button = tester.widget(buttonFinder); + expected + ? check(button.onPressed).isNotNull() + : check(button.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))); + } + + void checkSuggestionsVisible(List emojiCodes) { + final localizations = GlobalLocalizations.zulipLocalizations; + for (final code in emojiCodes) { + check(findSuggestion(code: code, + text: statusCodesToText(localizations)[code]!)).findsOne(); + } + } + + 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(); + checkSuggestionsVisible(suggestedUnicodeEmoji.keys.toList()); + }); + + group('"Clear" & "Save" buttons', () { + group('initial state', () { + testWidgets('no status set -> buttons are disabled', (tester) async { + await setupPage(tester); + + checkButtonEnabled(tester, clearButtonFinder, expected: false); + checkButtonEnabled(tester, saveButtonFinder, expected: 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)))); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + }); + + testWidgets('only text is set -> "Clear" is enabled, "Save" is not', (tester) async { + await setupPage(tester, change: UserStatusChange( + text: OptionSome('Happy'), emoji: OptionNone())); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: 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)))); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + }); + }); + + group('edit status', () { + Future chooseEmojiFromPicker(WidgetTester tester, String code, { + bool emojiSelected = false, + required TestNavigatorObserver navObserver, + required ValueGetter> currentRoute, + }) async { + await tester.tap(findEmojiButton(emojiSelected: emojiSelected)); + check(currentRoute()).isNotNull().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()).isNotNull().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); + + checkButtonEnabled(tester, clearButtonFinder, expected: false); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + // Choose 'slight_smile' from popular emojis. + await chooseEmojiFromPicker(tester, '1f642', + navObserver: testNavObserver, currentRoute: () => currentRoute!); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: true); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('text is added -> buttons are enabled', (tester) async { + await setupPage(tester); + + checkButtonEnabled(tester, clearButtonFinder, expected: false); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + await tester.enterText(findStatusTextField(), 'Happy'); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: true); + }); + + testWidgets('empty spaces are added as text -> buttons stays disabled', (tester) async { + await setupPage(tester); + + checkButtonEnabled(tester, clearButtonFinder, expected: false); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + await tester.enterText(findStatusTextField(), ' '); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: false); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + }); + + testWidgets('a suggestion is selected -> buttons are enabled', (tester) async { + await setupPage(tester, emojiData: suggestedEmojiData); + + checkButtonEnabled(tester, clearButtonFinder, expected: false); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: true); + }); + + testWidgets('emoji & text are added, then removed -> buttons are enabled, then disabled', (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); + + checkButtonEnabled(tester, clearButtonFinder, expected: false); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + // Choose 'slight_smile' from popular emojis. + await chooseEmojiFromPicker(tester, '1f642', + navObserver: testNavObserver, currentRoute: () => currentRoute!); + await tester.enterText(findStatusTextField(), 'Happy'); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: true); + + await tester.tap(clearButtonFinder); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: false); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('a suggestion is selected, then removed -> buttons are enabled, then disabled', (tester) async { + await setupPage(tester, emojiData: suggestedEmojiData); + + checkButtonEnabled(tester, clearButtonFinder, expected: false); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: true); + + await tester.tap(clearButtonFinder); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: false); + checkButtonEnabled(tester, saveButtonFinder, expected: 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: serverEmojiDataPopularPlus(suggestedEmojiData), + navigatorObserver: testNavObserver, + change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + // Choose 'calender' included in suggested emojis. + await chooseEmojiFromPicker(tester, '1f4c5', + emojiSelected: true, + navObserver: testNavObserver, currentRoute: () => currentRoute!); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: 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)))); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + await tester.enterText(findStatusTextField(), 'Happy as a calm'); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: 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)))); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + await tester.enterText(findStatusTextField(), ' Happy '); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: 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)))); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: 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: serverEmojiDataPopularPlus(suggestedEmojiData), + navigatorObserver: testNavObserver, + change: UserStatusChange( + text: OptionSome('Happy'), + emoji: OptionSome(StatusEmoji(emojiName: 'slight_smile', + emojiCode: '1f642', reactionType: ReactionType.unicodeEmoji)))); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + // Choose 'calender' included in suggested emojis. + await chooseEmojiFromPicker(tester, '1f4c5', + emojiSelected: true, + navObserver: testNavObserver, currentRoute: () => currentRoute!); + await tester.enterText(findStatusTextField(), 'Happy as a calm'); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: true); + + // Reset to the initial emoji. + await chooseEmojiFromPicker(tester, '1f642', + emojiSelected: true, + navObserver: testNavObserver, currentRoute: () => currentRoute!); + // Reset to the initial text. + await tester.enterText(findStatusTextField(), 'Happy'); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: 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)))); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: false); + + await tester.tap(findSuggestion(code: '1f6e0', text: 'Busy')); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: true); + + // Reset the suggestion. + await tester.tap(findSuggestion(code: '1f3e0', text: 'Working remotely')); + await tester.pump(); + + checkButtonEnabled(tester, clearButtonFinder, expected: true); + checkButtonEnabled(tester, saveButtonFinder, expected: 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(findStatusTextField()).findsOne(); + check(switch(tester.widget(findStatusTextField())) { + TextField(controller: TextEditingController(text: 'Happy')) => true, + _ => false, + }).isTrue(); + + await tester.tap(clearButtonFinder); + await tester.pump(); + + check(findEmojiButton(emojiSelected: false)).findsOne(); + check(findStatusTextField()).findsOne(); + check(switch(tester.widget(findStatusTextField())) { + TextField(controller: TextEditingController(text: '')) => true, + _ => false, + }).isTrue(); + }); + + group('"Save" button returns to Profile page, saves the status', () { + testWidgets('successful -> status info appears', (tester) async { + // Route? currentRoute; + final testNavObserver = TestNavigatorObserver(); + // testNavObserver.onPopped = (_, previous) => currentRoute = previous; + + 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)); + + checkButtonEnabled(tester, saveButtonFinder, expected: true); + await tester.tap(saveButtonFinder); + await testNavObserver.pumpPastTransition(tester); + // The following doesn't work for no obvious reason. + // check(currentRoute).isNotNull().isA() + // .page.isA(); + + 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(); + check(find.text('\u{1f6e0}')).findsOne(); + check(findText('Busy', includePlaceholders: false)).findsOne(); + }); + + testWidgets("error -> status info doesn't appears", (tester) async { + // Route? currentRoute; + final testNavObserver = TestNavigatorObserver(); + // testNavObserver.onPopped = (_, previous) => currentRoute = previous; + + 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')); + + checkButtonEnabled(tester, saveButtonFinder, expected: true); + await tester.tap(saveButtonFinder); + await testNavObserver.pumpPastTransition(tester); + // The following doesn't work for no obvious reason. + // check(currentRoute).isNotNull().isA() + // .page.isA(); + + check(find.text('\u{1f6e0}')).findsNothing(); + check(findText('Busy', includePlaceholders: false)).findsNothing(); + }); + }); + }); +}