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