diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 47ed5de895..7ac551bc7f 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1153,6 +1153,35 @@ "@updateStatusErrorTitle": { "description": "Error title when updating user status failed." }, + "statusExpirationLabel": "Automatically clear status", + "@statusExpirationLabel": { + "description": "Label for the dropdown to select when status should be automatically cleared." + }, + "statusExpirationNever": "Never", + "@statusExpirationNever": { + "description": "Option to never automatically clear the status." + }, + "statusExpirationIn30Minutes": "In 30 minutes", + "@statusExpirationIn30Minutes": { + "description": "Option to clear status in 30 minutes." + }, + "statusExpirationIn1Hour": "In 1 hour", + "@statusExpirationIn1Hour": { + "description": "Option to clear status in 1 hour." + }, + "statusExpirationTodayAtTime": "Today at {time}", + "@statusExpirationTodayAtTime": { + "description": "Option to clear status today at a specific time.", + "placeholders": { "time": {"type": "String"} } + }, + "statusExpirationTomorrow": "Tomorrow", + "@statusExpirationTomorrow": { + "description": "Option to clear status tomorrow." + }, + "statusExpirationCustom": "Custom", + "@statusExpirationCustom": { + "description": "Option to set a custom time to clear the status." + }, "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 3e23a0fdd3..5e078dc577 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -270,19 +270,42 @@ class UserStatusChange { final Option text; final Option emoji; - const UserStatusChange({required this.text, required this.emoji}); + /// The scheduled end time for the status (Unix timestamp in seconds). + /// + /// If [OptionSome] with a non-null value, the status will be automatically + /// cleared at that time. If [OptionSome] with null, any existing scheduled + /// end time will be cleared. If [OptionNone], no change to the scheduled + /// end time. + final Option scheduledEndTime; + + const UserStatusChange({ + required this.text, + required this.emoji, + this.scheduledEndTime = const OptionNone(), + }); UserStatus apply(UserStatus old) { 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); + UserStatusChange copyWith({ + Option? text, + Option? emoji, + Option? scheduledEndTime, + }) { + return UserStatusChange( + text: text ?? this.text, + emoji: emoji ?? this.emoji, + scheduledEndTime: scheduledEndTime ?? this.scheduledEndTime, + ); } factory UserStatusChange.fromJson(Map json) { return UserStatusChange( - text: _textFromJson(json), emoji: _emojiFromJson(json)); + text: _textFromJson(json), + emoji: _emojiFromJson(json), + scheduledEndTime: _scheduledEndTimeFromJson(json), + ); } static Option _textFromJson(Map json) { @@ -313,6 +336,14 @@ class UserStatusChange { } } + static Option _scheduledEndTimeFromJson(Map json) { + if (!json.containsKey('scheduled_end_time')) { + return OptionNone(); + } + final value = json['scheduled_end_time'] as int?; + return OptionSome(value); + } + Map toJson() { return { if (text case OptionSome(:var value)) @@ -325,6 +356,8 @@ class UserStatusChange { 'emoji_code': value.emojiCode, 'reaction_type': value.reactionType, }, + if (scheduledEndTime case OptionSome(:var value)) + 'scheduled_end_time': value, }; } } diff --git a/lib/api/route/users.dart b/lib/api/route/users.dart index 4e47d97576..b24e99f7eb 100644 --- a/lib/api/route/users.dart +++ b/lib/api/route/users.dart @@ -46,10 +46,13 @@ Future updateStatus(ApiConnection connection, { 'emoji_name': RawParameter(value?.emojiName ?? ''), 'emoji_code': RawParameter(value?.emojiCode ?? ''), 'reaction_type': RawParameter(value?.reactionType.toJson() ?? ''), - } + }, + if (change.scheduledEndTime case OptionSome(:var value)) + 'scheduled_end_time': value, }); } + /// 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 eee647c141..3976de8043 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1691,6 +1691,48 @@ abstract class ZulipLocalizations { /// **'Error updating user status. Please try again.'** String get updateStatusErrorTitle; + /// Label for the dropdown to select when status should be automatically cleared. + /// + /// In en, this message translates to: + /// **'Automatically clear status'** + String get statusExpirationLabel; + + /// Option to never automatically clear the status. + /// + /// In en, this message translates to: + /// **'Never'** + String get statusExpirationNever; + + /// Option to clear status in 30 minutes. + /// + /// In en, this message translates to: + /// **'In 30 minutes'** + String get statusExpirationIn30Minutes; + + /// Option to clear status in 1 hour. + /// + /// In en, this message translates to: + /// **'In 1 hour'** + String get statusExpirationIn1Hour; + + /// Option to clear status today at a specific time. + /// + /// In en, this message translates to: + /// **'Today at {time}'** + String statusExpirationTodayAtTime(String time); + + /// Option to clear status tomorrow. + /// + /// In en, this message translates to: + /// **'Tomorrow'** + String get statusExpirationTomorrow; + + /// Option to set a custom time to clear the status. + /// + /// In en, this message translates to: + /// **'Custom'** + String get statusExpirationCustom; + /// 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 f39720a694..7ee14a2185 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 50b9a0d6c4..524fe919b5 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -995,6 +995,29 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get updateStatusErrorTitle => 'Fehler beim Update des Benutzerstatus. Bitte versuche es nochmal.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Suche'; diff --git a/lib/generated/l10n/zulip_localizations_el.dart b/lib/generated/l10n/zulip_localizations_el.dart index bf48405724..00f24d07a5 100644 --- a/lib/generated/l10n/zulip_localizations_el.dart +++ b/lib/generated/l10n/zulip_localizations_el.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsEl extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 899fc2b3ab..dbcd5dccfa 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_es.dart b/lib/generated/l10n/zulip_localizations_es.dart index 021fa4399c..59b8a016d9 100644 --- a/lib/generated/l10n/zulip_localizations_es.dart +++ b/lib/generated/l10n/zulip_localizations_es.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsEs extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 9205515b01..807ec310f6 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -986,6 +986,29 @@ class ZulipLocalizationsFr extends ZulipLocalizations { String get updateStatusErrorTitle => 'Erreur lors de la mise à jour du statut de l\'utilisateur. Merci de réessayer.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Recherche'; diff --git a/lib/generated/l10n/zulip_localizations_he.dart b/lib/generated/l10n/zulip_localizations_he.dart index bf7641e733..705237237c 100644 --- a/lib/generated/l10n/zulip_localizations_he.dart +++ b/lib/generated/l10n/zulip_localizations_he.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsHe extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_hu.dart b/lib/generated/l10n/zulip_localizations_hu.dart index 0fbe7aa3b0..5230b56bcc 100644 --- a/lib/generated/l10n/zulip_localizations_hu.dart +++ b/lib/generated/l10n/zulip_localizations_hu.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsHu extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 05580eafb3..de9f9f1fe0 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -981,6 +981,29 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Titolo della pagina per la visualizzazione del messaggio \"Cerca\".'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index ea8da61052..19da508822 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -945,6 +945,29 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get updateStatusErrorTitle => 'ステータスの更新に失敗しました。もう一度お試しください。'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => '検索'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 3f04b9c138..318a852156 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index cab52a50c4..4e99775b59 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -987,6 +987,29 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get updateStatusErrorTitle => 'Błąd aktualizacji stanu. Spróbuj ponownie.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Szukaj'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index fdedf8b02f..3c9f17c118 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -997,6 +997,29 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get updateStatusErrorTitle => 'Ошибка обновления статуса пользователя. Попробуйте ещё раз.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Поиск'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 176cabbe72..572b87d4d2 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -972,6 +972,29 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index beef68a6df..f271391515 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -1006,6 +1006,29 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get updateStatusErrorTitle => 'Napaka pri posodabljanju statusa uporabnika. Poskusite znova.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Iskanje'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index bd92746797..ad530c715a 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -988,6 +988,29 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get updateStatusErrorTitle => 'Помилка оновлення статусу користувача. Спробуйте ще раз.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Пошук'; diff --git a/lib/generated/l10n/zulip_localizations_vi.dart b/lib/generated/l10n/zulip_localizations_vi.dart index 082c8a60bc..477480de7e 100644 --- a/lib/generated/l10n/zulip_localizations_vi.dart +++ b/lib/generated/l10n/zulip_localizations_vi.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsVi extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index ebf6ef5bdf..a95d0075ab 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -970,6 +970,29 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get updateStatusErrorTitle => 'Error updating user status. Please try again.'; + @override + String get statusExpirationLabel => 'Automatically clear status'; + + @override + String get statusExpirationNever => 'Never'; + + @override + String get statusExpirationIn30Minutes => 'In 30 minutes'; + + @override + String get statusExpirationIn1Hour => 'In 1 hour'; + + @override + String statusExpirationTodayAtTime(String time) { + return 'Today at $time'; + } + + @override + String get statusExpirationTomorrow => 'Tomorrow'; + + @override + String get statusExpirationCustom => 'Custom'; + @override String get searchMessagesPageTitle => 'Search'; diff --git a/lib/widgets/set_status.dart b/lib/widgets/set_status.dart index 92ef933a9c..540c6124de 100644 --- a/lib/widgets/set_status.dart +++ b/lib/widgets/set_status.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import '../api/model/model.dart'; import '../api/route/users.dart'; @@ -18,6 +19,17 @@ import 'text.dart'; import 'theme.dart'; import 'user.dart'; +/// Options for automatically clearing status. +enum StatusExpirationOption { + never, + in30Minutes, + in1Hour, + todayAt5PM, + tomorrow, + custom, +} + + class SetStatusPage extends StatefulWidget { const SetStatusPage({super.key, required this.oldStatus}); @@ -39,6 +51,15 @@ class _SetStatusPageState extends State { late final TextEditingController statusTextController; late final ValueNotifier statusChange; + /// Currently selected expiration option. + StatusExpirationOption _selectedExpiration = StatusExpirationOption.never; + + /// Custom expiration time when "Custom" is selected. + DateTime? _customExpirationTime; + + /// Whether the user has manually changed the expiration. + bool _hasUserChangedExpiration = false; + UserStatus get oldStatus => widget.oldStatus; UserStatus get newStatus => statusChange.value.apply(widget.oldStatus); @@ -83,6 +104,107 @@ class _SetStatusPageState extends State { super.dispose(); } + /// Returns the default expiration option for a given preset status emoji code. + StatusExpirationOption _getDefaultExpiration(String emojiCode) { + return switch (emojiCode) { + '1f6e0' => StatusExpirationOption.in1Hour, // Busy + '1f4c5' => StatusExpirationOption.in1Hour, // In a meeting + '1f68c' => StatusExpirationOption.in30Minutes, // Commuting + '1f912' => StatusExpirationOption.tomorrow, // Out sick + '1f334' => StatusExpirationOption.never, // Vacationing + '1f3e0' => StatusExpirationOption.todayAt5PM, // Working remotely + '1f3e2' => StatusExpirationOption.todayAt5PM, // At the office + _ => StatusExpirationOption.never, // Default/Custom + }; + } + + /// Computes the Unix timestamp (in seconds) for the selected expiration option. + int? _computeExpirationTimestamp() { + final now = DateTime.now(); + return switch (_selectedExpiration) { + StatusExpirationOption.never => null, + StatusExpirationOption.in30Minutes => + now.add(const Duration(minutes: 30)).millisecondsSinceEpoch ~/ 1000, + StatusExpirationOption.in1Hour => + now.add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000, + StatusExpirationOption.todayAt5PM => + DateTime(now.year, now.month, now.day, 17, 0).millisecondsSinceEpoch ~/ 1000, + StatusExpirationOption.tomorrow => + DateTime(now.year, now.month, now.day + 1, 0, 0).millisecondsSinceEpoch ~/ 1000, + StatusExpirationOption.custom => _customExpirationTime != null + ? _customExpirationTime!.millisecondsSinceEpoch ~/ 1000 + : null, + }; + } + + /// Returns the display name for an expiration option. + String _getExpirationOptionLabel(StatusExpirationOption option, ZulipLocalizations zulipLocalizations) { + final store = PerAccountStoreWidget.of(context); + final use24Hour = store.userSettings.twentyFourHourTime == TwentyFourHourTimeMode.twentyFourHour; + + return switch (option) { + StatusExpirationOption.never => zulipLocalizations.statusExpirationNever, + StatusExpirationOption.in30Minutes => zulipLocalizations.statusExpirationIn30Minutes, + StatusExpirationOption.in1Hour => zulipLocalizations.statusExpirationIn1Hour, + StatusExpirationOption.todayAt5PM => zulipLocalizations.statusExpirationTodayAtTime( + use24Hour ? '17:00' : '5:00 PM'), + StatusExpirationOption.tomorrow => zulipLocalizations.statusExpirationTomorrow, + StatusExpirationOption.custom => zulipLocalizations.statusExpirationCustom, + }; + } + + /// Formats the expiration time for display. + String? _formatExpirationTime() { + final timestamp = _computeExpirationTimestamp(); + if (timestamp == null) return null; + + final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + final store = PerAccountStoreWidget.of(context); + final use24Hour = store.userSettings.twentyFourHourTime == TwentyFourHourTimeMode.twentyFourHour; + + final dateFormat = DateFormat.MMMd(); + final timeFormat = use24Hour ? DateFormat.Hm() : DateFormat('h:mm a'); + + return '${dateFormat.format(dateTime)} at ${timeFormat.format(dateTime)}'; + } + + /// Opens a date and time picker for custom expiration. + Future _pickCustomTime() async { + final now = DateTime.now(); + + final pickedDate = await showDatePicker( + context: context, + initialDate: now.add(const Duration(hours: 1)), + firstDate: now, + lastDate: now.add(const Duration(days: 365)), + ); + if (pickedDate == null || !mounted) return; + + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(now.add(const Duration(hours: 1))), + ); + if (pickedTime == null || !mounted) return; + + final customDateTime = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + + // Validate the time is in the future + if (customDateTime.isBefore(DateTime.now())) { + return; + } + + setState(() { + _customExpirationTime = customDateTime; + }); + } + + List statusSuggestions(BuildContext context) { final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); @@ -118,10 +240,15 @@ class _SetStatusPageState extends State { final zulipLocalizations = ZulipLocalizations.of(context); Navigator.pop(context); - if (newStatus == oldStatus) return; + if (newStatus == oldStatus && _selectedExpiration == StatusExpirationOption.never) return; + + // Include the expiration timestamp in the status change + final changeWithExpiration = statusChange.value.copyWith( + scheduledEndTime: OptionSome(_computeExpirationTimestamp()), + ); try { - await updateStatus(store.connection, change: statusChange.value); + await updateStatus(store.connection, change: changeWithExpiration); } catch (e) { reportErrorToUserBriefly(zulipLocalizations.updateStatusErrorTitle); } @@ -144,6 +271,13 @@ class _SetStatusPageState extends State { statusChange.value = UserStatusChange( text: asChange(status.text, old: oldStatus.text), emoji: asChange(status.emoji, old: oldStatus.emoji)); + + // Auto-set expiration based on the status emoji if user hasn't manually changed it + if (!_hasUserChangedExpiration && status.emoji != null) { + setState(() { + _selectedExpiration = _getDefaultExpiration(status.emoji!.emojiCode); + }); + } } Option asChange(T new_, {required T old}) => @@ -265,6 +399,78 @@ class _SetStatusPageState extends State { StatusSuggestionsListEntry( status: status, onTap: () => chooseStatusSuggestion(status)), + const SizedBox(height: 16), + // Expiration dropdown section + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + zulipLocalizations.statusExpirationLabel, + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + color: designVariables.labelMenuButton, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: designVariables.bgSearchInput, + borderRadius: BorderRadius.circular(10), + ), + child: DropdownButton( + value: _selectedExpiration, + isExpanded: true, + underline: const SizedBox(), + dropdownColor: designVariables.bgSearchInput, + items: StatusExpirationOption.values.map((option) { + return DropdownMenuItem( + value: option, + child: Text( + _getExpirationOptionLabel(option, zulipLocalizations), + style: TextStyle( + fontSize: 17, + color: designVariables.textInput, + ), + ), + ); + }).toList(), + onChanged: (value) async { + if (value == null) return; + if (value == StatusExpirationOption.custom) { + await _pickCustomTime(); + if (_customExpirationTime != null) { + setState(() { + _selectedExpiration = value; + _hasUserChangedExpiration = true; + }); + } + } else { + setState(() { + _selectedExpiration = value; + _hasUserChangedExpiration = true; + }); + } + }, + ), + ), + if (_selectedExpiration != StatusExpirationOption.never) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _formatExpirationTime() ?? '', + style: TextStyle( + fontSize: 14, + color: designVariables.labelMenuButton, + ), + ), + ), + ], + ), + ), ])))), ])), ); diff --git a/test/widgets/set_status_test.dart b/test/widgets/set_status_test.dart index c1db94096b..c98461cb3d 100644 --- a/test/widgets/set_status_test.dart +++ b/test/widgets/set_status_test.dart @@ -415,12 +415,11 @@ void main() { check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/users/me/status') - ..bodyFields.deepEquals({ - 'status_text': 'Busy', - 'emoji_name': 'working_on_it', - 'emoji_code': '1f6e0', - 'reaction_type': 'unicode_emoji', - }); + ..bodyFields['status_text'].equals('Busy') + ..bodyFields['emoji_name'].equals('working_on_it') + ..bodyFields['emoji_code'].equals('1f6e0') + ..bodyFields['reaction_type'].equals('unicode_emoji') + ..bodyFields.containsKey('scheduled_end_time'); await testNavObserver.pumpPastTransition(tester); check(find.byType(ProfilePage)).findsOne(); }); @@ -441,12 +440,11 @@ void main() { check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/users/me/status') - ..bodyFields.deepEquals({ - 'status_text': 'Busy', - 'emoji_name': 'working_on_it', - 'emoji_code': '1f6e0', - 'reaction_type': 'unicode_emoji', - }); + ..bodyFields['status_text'].equals('Busy') + ..bodyFields['emoji_name'].equals('working_on_it') + ..bodyFields['emoji_code'].equals('1f6e0') + ..bodyFields['reaction_type'].equals('unicode_emoji') + ..bodyFields.containsKey('scheduled_end_time'); await testNavObserver.pumpPastTransition(tester); check(find.byType(ProfilePage)).findsOne();