diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index df2c4ab947..2c274ae957 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/message_square_text.svg b/assets/icons/message_square_text.svg
new file mode 100644
index 0000000000..0e8ede8a0b
--- /dev/null
+++ b/assets/icons/message_square_text.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg
new file mode 100644
index 0000000000..a5b1b7e078
--- /dev/null
+++ b/assets/icons/plus.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 7f37a00dad..41c8cb351b 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -341,6 +341,50 @@
"@composeBoxAttachFromCameraTooltip": {
"description": "Tooltip for compose box icon to attach an image from the camera to the message."
},
+ "composeBoxShowSavedSnippetsTooltip": "Show saved snippets",
+ "@composeBoxShowSavedSnippetsTooltip": {
+ "description": "Tooltip for compose box icon to show a list of saved snippets."
+ },
+ "noSavedSnippets": "No saved snippets",
+ "@noSavedSnippets": {
+ "description": "Text to show on the saved snippets bottom sheet when there are no saved snippets."
+ },
+ "newSavedSnippetButton": "New",
+ "@newSavedSnippetButton": {
+ "description": "Label for adding a new saved snippet."
+ },
+ "newSavedSnippetTitle": "New snippet",
+ "@newSavedSnippetTitle": {
+ "description": "Title for the bottom sheet to add a new saved snippet."
+ },
+ "newSavedSnippetTitleHint": "Title",
+ "@newSavedSnippetTitleHint": {
+ "description": "Hint text for the title input when adding a new saved snippet."
+ },
+ "newSavedSnippetContentHint": "Content",
+ "@newSavedSnippetContentHint": {
+ "description": "Hint text for the content input when adding a new saved snippet."
+ },
+ "errorFailedToCreateSavedSnippet": "Failed to create saved snippet",
+ "@errorFailedToCreateSavedSnippet": {
+ "description": "Error message when the saved snippet failed to be created."
+ },
+ "savedSnippetTitleValidationErrorEmpty": "Title cannot be empty.",
+ "@savedSnippetTitleValidationErrorEmpty": {
+ "description": "Validation error message when the title of the saved snippet is empty."
+ },
+ "savedSnippetTitleValidationErrorTooLong": "Title length shouldn't be greater than 60 characters.",
+ "@savedSnippetTitleValidationErrorTooLong": {
+ "description": "Validation error message when the title of the saved snippet is too long."
+ },
+ "savedSnippetContentValidationErrorEmpty": "Content cannot be empty.",
+ "@savedSnippetContentValidationErrorEmpty": {
+ "description": "Validation error message when the content of the saved snippet is empty."
+ },
+ "savedSnippetContentValidationErrorTooLong": "Content length shouldn't be greater than 10000 characters.",
+ "@savedSnippetContentValidationErrorTooLong": {
+ "description": "Validation error message when the content of the saved snippet is too long."
+ },
"composeBoxGenericContentHint": "Type a message",
"@composeBoxGenericContentHint": {
"description": "Hint text for content input when sending a message."
diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index 9faa3d367e..6677cbc7f4 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -37,6 +37,12 @@ sealed class Event {
case 'update': return RealmUserUpdateEvent.fromJson(json);
default: return UnexpectedEvent.fromJson(json);
}
+ case 'saved_snippets':
+ switch (json['op'] as String) {
+ case 'add': return SavedSnippetsAddEvent.fromJson(json);
+ case 'remove': return SavedSnippetsRemoveEvent.fromJson(json);
+ default: return UnexpectedEvent.fromJson(json);
+ }
case 'stream':
switch (json['op'] as String) {
case 'create': return ChannelCreateEvent.fromJson(json);
@@ -336,6 +342,54 @@ class RealmUserUpdateEvent extends RealmUserEvent {
Map toJson() => _$RealmUserUpdateEventToJson(this);
}
+/// A Zulip event of type `saved_snippets`.
+///
+/// The corresponding API docs are in several places for
+/// different values of `op`; see subclasses.
+sealed class SavedSnippetsEvent extends Event {
+ @override
+ @JsonKey(includeToJson: true)
+ String get type => 'saved_snippets';
+
+ String get op;
+
+ SavedSnippetsEvent({required super.id});
+}
+
+/// A [SavedSnippetsEvent] with op `add`: https://zulip.com/api/get-events#saved_snippets-add
+@JsonSerializable(fieldRename: FieldRename.snake)
+class SavedSnippetsAddEvent extends SavedSnippetsEvent {
+ @override
+ String get op => 'add';
+
+ final SavedSnippet savedSnippet;
+
+ SavedSnippetsAddEvent({required super.id, required this.savedSnippet});
+
+ factory SavedSnippetsAddEvent.fromJson(Map json) =>
+ _$SavedSnippetsAddEventFromJson(json);
+
+ @override
+ Map toJson() => _$SavedSnippetsAddEventToJson(this);
+}
+
+/// A [SavedSnippetsEvent] with op `remove`: https://zulip.com/api/get-events#saved_snippets-remove
+@JsonSerializable(fieldRename: FieldRename.snake)
+class SavedSnippetsRemoveEvent extends SavedSnippetsEvent {
+ @override
+ String get op => 'remove';
+
+ final int savedSnippetId;
+
+ SavedSnippetsRemoveEvent({required super.id, required this.savedSnippetId});
+
+ factory SavedSnippetsRemoveEvent.fromJson(Map json) =>
+ _$SavedSnippetsRemoveEventFromJson(json);
+
+ @override
+ Map toJson() => _$SavedSnippetsRemoveEventToJson(this);
+}
+
/// A Zulip event of type `stream`.
///
/// The corresponding API docs are in several places for
diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart
index d0b0cc7b1b..82a31e66a9 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -203,6 +203,38 @@ Json? _$JsonConverterToJson(
Json? Function(Value value) toJson,
) => value == null ? null : toJson(value);
+SavedSnippetsAddEvent _$SavedSnippetsAddEventFromJson(
+ Map json,
+) => SavedSnippetsAddEvent(
+ id: (json['id'] as num).toInt(),
+ savedSnippet: SavedSnippet.fromJson(
+ json['saved_snippet'] as Map,
+ ),
+);
+
+Map _$SavedSnippetsAddEventToJson(
+ SavedSnippetsAddEvent instance,
+) => {
+ 'id': instance.id,
+ 'type': instance.type,
+ 'saved_snippet': instance.savedSnippet,
+};
+
+SavedSnippetsRemoveEvent _$SavedSnippetsRemoveEventFromJson(
+ Map json,
+) => SavedSnippetsRemoveEvent(
+ id: (json['id'] as num).toInt(),
+ savedSnippetId: (json['saved_snippet_id'] as num).toInt(),
+);
+
+Map _$SavedSnippetsRemoveEventToJson(
+ SavedSnippetsRemoveEvent instance,
+) => {
+ 'id': instance.id,
+ 'type': instance.type,
+ 'saved_snippet_id': instance.savedSnippetId,
+};
+
ChannelCreateEvent _$ChannelCreateEventFromJson(Map json) =>
ChannelCreateEvent(
id: (json['id'] as num).toInt(),
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index 5882122baa..01031d70aa 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -48,6 +48,8 @@ class InitialSnapshot {
final List recentPrivateConversations;
+ final List? savedSnippets; // TODO(server-10)
+
final List subscriptions;
final UnreadMessagesSnapshot unreadMsgs;
@@ -129,6 +131,7 @@ class InitialSnapshot {
required this.serverTypingStartedWaitPeriodMilliseconds,
required this.realmEmoji,
required this.recentPrivateConversations,
+ required this.savedSnippets,
required this.subscriptions,
required this.unreadMsgs,
required this.streams,
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index 79cfbe5557..39c265c04a 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -45,6 +45,10 @@ InitialSnapshot _$InitialSnapshotFromJson(
(json['recent_private_conversations'] as List)
.map((e) => RecentDmConversation.fromJson(e as Map))
.toList(),
+ savedSnippets:
+ (json['saved_snippets'] as List?)
+ ?.map((e) => SavedSnippet.fromJson(e as Map))
+ .toList(),
subscriptions:
(json['subscriptions'] as List)
.map((e) => Subscription.fromJson(e as Map))
@@ -125,6 +129,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) =>
instance.serverTypingStartedWaitPeriodMilliseconds,
'realm_emoji': instance.realmEmoji,
'recent_private_conversations': instance.recentPrivateConversations,
+ 'saved_snippets': instance.savedSnippets,
'subscriptions': instance.subscriptions,
'unread_msgs': instance.unreadMsgs,
'streams': instance.streams,
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index fad8ddc5bc..a0024d78c5 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -310,6 +310,30 @@ enum UserRole{
}
}
+/// An item in `saved_snippets` from the initial snapshot.
+///
+/// For docs, search for "saved_snippets:"
+/// in .
+@JsonSerializable(fieldRename: FieldRename.snake)
+class SavedSnippet {
+ SavedSnippet({
+ required this.id,
+ required this.title,
+ required this.content,
+ required this.dateCreated,
+ });
+
+ final int id;
+ final String title;
+ final String content;
+ final int dateCreated;
+
+ factory SavedSnippet.fromJson(Map json) =>
+ _$SavedSnippetFromJson(json);
+
+ Map toJson() => _$SavedSnippetToJson(this);
+}
+
/// As in `streams` in the initial snapshot.
///
/// Not called `Stream` because dart:async uses that name.
diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart
index 32c8eeb0e7..31e2759962 100644
--- a/lib/api/model/model.g.dart
+++ b/lib/api/model/model.g.dart
@@ -162,6 +162,21 @@ Map _$ProfileFieldUserDataToJson(
'rendered_value': instance.renderedValue,
};
+SavedSnippet _$SavedSnippetFromJson(Map json) => SavedSnippet(
+ id: (json['id'] as num).toInt(),
+ title: json['title'] as String,
+ content: json['content'] as String,
+ dateCreated: (json['date_created'] as num).toInt(),
+);
+
+Map _$SavedSnippetToJson(SavedSnippet instance) =>
+ {
+ 'id': instance.id,
+ 'title': instance.title,
+ 'content': instance.content,
+ 'date_created': instance.dateCreated,
+ };
+
ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream(
streamId: (json['stream_id'] as num).toInt(),
name: json['name'] as String,
diff --git a/lib/api/route/saved_snippets.dart b/lib/api/route/saved_snippets.dart
new file mode 100644
index 0000000000..b6dc0420f1
--- /dev/null
+++ b/lib/api/route/saved_snippets.dart
@@ -0,0 +1,12 @@
+import '../core.dart';
+
+/// https://zulip.com/api/create-saved-snippet
+Future createSavedSnippet(ApiConnection connection, {
+ required String title,
+ required String content,
+}) {
+ return connection.post('createSavedSnippet', (_) {}, 'saved_snippets', {
+ 'title': RawParameter(title),
+ 'content': RawParameter(content),
+ });
+}
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 2f03458e05..3fe4cee68a 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -561,6 +561,72 @@ abstract class ZulipLocalizations {
/// **'Take a photo'**
String get composeBoxAttachFromCameraTooltip;
+ /// Tooltip for compose box icon to show a list of saved snippets.
+ ///
+ /// In en, this message translates to:
+ /// **'Show saved snippets'**
+ String get composeBoxShowSavedSnippetsTooltip;
+
+ /// Text to show on the saved snippets bottom sheet when there are no saved snippets.
+ ///
+ /// In en, this message translates to:
+ /// **'No saved snippets'**
+ String get noSavedSnippets;
+
+ /// Label for adding a new saved snippet.
+ ///
+ /// In en, this message translates to:
+ /// **'New'**
+ String get newSavedSnippetButton;
+
+ /// Title for the bottom sheet to add a new saved snippet.
+ ///
+ /// In en, this message translates to:
+ /// **'New snippet'**
+ String get newSavedSnippetTitle;
+
+ /// Hint text for the title input when adding a new saved snippet.
+ ///
+ /// In en, this message translates to:
+ /// **'Title'**
+ String get newSavedSnippetTitleHint;
+
+ /// Hint text for the content input when adding a new saved snippet.
+ ///
+ /// In en, this message translates to:
+ /// **'Content'**
+ String get newSavedSnippetContentHint;
+
+ /// Error message when the saved snippet failed to be created.
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to create saved snippet'**
+ String get errorFailedToCreateSavedSnippet;
+
+ /// Validation error message when the title of the saved snippet is empty.
+ ///
+ /// In en, this message translates to:
+ /// **'Title cannot be empty.'**
+ String get savedSnippetTitleValidationErrorEmpty;
+
+ /// Validation error message when the title of the saved snippet is too long.
+ ///
+ /// In en, this message translates to:
+ /// **'Title length shouldn\'t be greater than 60 characters.'**
+ String get savedSnippetTitleValidationErrorTooLong;
+
+ /// Validation error message when the content of the saved snippet is empty.
+ ///
+ /// In en, this message translates to:
+ /// **'Content cannot be empty.'**
+ String get savedSnippetContentValidationErrorEmpty;
+
+ /// Validation error message when the content of the saved snippet is too long.
+ ///
+ /// In en, this message translates to:
+ /// **'Content length shouldn\'t be greater than 10000 characters.'**
+ String get savedSnippetContentValidationErrorTooLong;
+
/// Hint text for content input when sending a message.
///
/// 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 fd7905924b..3cb6a143cc 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -270,6 +270,39 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get composeBoxAttachFromCameraTooltip => 'Take a photo';
+ @override
+ String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets';
+
+ @override
+ String get noSavedSnippets => 'No saved snippets';
+
+ @override
+ String get newSavedSnippetButton => 'New';
+
+ @override
+ String get newSavedSnippetTitle => 'New snippet';
+
+ @override
+ String get newSavedSnippetTitleHint => 'Title';
+
+ @override
+ String get newSavedSnippetContentHint => 'Content';
+
+ @override
+ String get errorFailedToCreateSavedSnippet => 'Failed to create saved snippet';
+
+ @override
+ String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.';
+
+ @override
+ String get savedSnippetTitleValidationErrorTooLong => 'Title length shouldn\'t be greater than 60 characters.';
+
+ @override
+ String get savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.';
+
+ @override
+ String get savedSnippetContentValidationErrorTooLong => 'Content length shouldn\'t be greater than 10000 characters.';
+
@override
String get composeBoxGenericContentHint => 'Type a message';
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 1d19cfc7b0..18e787f1f4 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -270,6 +270,39 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get composeBoxAttachFromCameraTooltip => 'Take a photo';
+ @override
+ String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets';
+
+ @override
+ String get noSavedSnippets => 'No saved snippets';
+
+ @override
+ String get newSavedSnippetButton => 'New';
+
+ @override
+ String get newSavedSnippetTitle => 'New snippet';
+
+ @override
+ String get newSavedSnippetTitleHint => 'Title';
+
+ @override
+ String get newSavedSnippetContentHint => 'Content';
+
+ @override
+ String get errorFailedToCreateSavedSnippet => 'Failed to create saved snippet';
+
+ @override
+ String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.';
+
+ @override
+ String get savedSnippetTitleValidationErrorTooLong => 'Title length shouldn\'t be greater than 60 characters.';
+
+ @override
+ String get savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.';
+
+ @override
+ String get savedSnippetContentValidationErrorTooLong => 'Content length shouldn\'t be greater than 10000 characters.';
+
@override
String get composeBoxGenericContentHint => 'Type a message';
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 58a4a1de59..922a53df9e 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -270,6 +270,39 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get composeBoxAttachFromCameraTooltip => 'Take a photo';
+ @override
+ String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets';
+
+ @override
+ String get noSavedSnippets => 'No saved snippets';
+
+ @override
+ String get newSavedSnippetButton => 'New';
+
+ @override
+ String get newSavedSnippetTitle => 'New snippet';
+
+ @override
+ String get newSavedSnippetTitleHint => 'Title';
+
+ @override
+ String get newSavedSnippetContentHint => 'Content';
+
+ @override
+ String get errorFailedToCreateSavedSnippet => 'Failed to create saved snippet';
+
+ @override
+ String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.';
+
+ @override
+ String get savedSnippetTitleValidationErrorTooLong => 'Title length shouldn\'t be greater than 60 characters.';
+
+ @override
+ String get savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.';
+
+ @override
+ String get savedSnippetContentValidationErrorTooLong => 'Content length shouldn\'t be greater than 10000 characters.';
+
@override
String get composeBoxGenericContentHint => 'Type a message';
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index a5bba71bd1..aae8374b5e 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -270,6 +270,39 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get composeBoxAttachFromCameraTooltip => 'Take a photo';
+ @override
+ String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets';
+
+ @override
+ String get noSavedSnippets => 'No saved snippets';
+
+ @override
+ String get newSavedSnippetButton => 'New';
+
+ @override
+ String get newSavedSnippetTitle => 'New snippet';
+
+ @override
+ String get newSavedSnippetTitleHint => 'Title';
+
+ @override
+ String get newSavedSnippetContentHint => 'Content';
+
+ @override
+ String get errorFailedToCreateSavedSnippet => 'Failed to create saved snippet';
+
+ @override
+ String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.';
+
+ @override
+ String get savedSnippetTitleValidationErrorTooLong => 'Title length shouldn\'t be greater than 60 characters.';
+
+ @override
+ String get savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.';
+
+ @override
+ String get savedSnippetContentValidationErrorTooLong => 'Content length shouldn\'t be greater than 10000 characters.';
+
@override
String get composeBoxGenericContentHint => 'Type a message';
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index e78e60dcb2..65b02e574a 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -270,6 +270,39 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get composeBoxAttachFromCameraTooltip => 'Zrób zdjęcie';
+ @override
+ String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets';
+
+ @override
+ String get noSavedSnippets => 'No saved snippets';
+
+ @override
+ String get newSavedSnippetButton => 'New';
+
+ @override
+ String get newSavedSnippetTitle => 'New snippet';
+
+ @override
+ String get newSavedSnippetTitleHint => 'Title';
+
+ @override
+ String get newSavedSnippetContentHint => 'Content';
+
+ @override
+ String get errorFailedToCreateSavedSnippet => 'Failed to create saved snippet';
+
+ @override
+ String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.';
+
+ @override
+ String get savedSnippetTitleValidationErrorTooLong => 'Title length shouldn\'t be greater than 60 characters.';
+
+ @override
+ String get savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.';
+
+ @override
+ String get savedSnippetContentValidationErrorTooLong => 'Content length shouldn\'t be greater than 10000 characters.';
+
@override
String get composeBoxGenericContentHint => 'Wpisz wiadomość';
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index e4bd460f72..813e4d89c9 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -270,6 +270,39 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
@override
String get composeBoxAttachFromCameraTooltip => 'Сделать снимок';
+ @override
+ String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets';
+
+ @override
+ String get noSavedSnippets => 'No saved snippets';
+
+ @override
+ String get newSavedSnippetButton => 'New';
+
+ @override
+ String get newSavedSnippetTitle => 'New snippet';
+
+ @override
+ String get newSavedSnippetTitleHint => 'Title';
+
+ @override
+ String get newSavedSnippetContentHint => 'Content';
+
+ @override
+ String get errorFailedToCreateSavedSnippet => 'Failed to create saved snippet';
+
+ @override
+ String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.';
+
+ @override
+ String get savedSnippetTitleValidationErrorTooLong => 'Title length shouldn\'t be greater than 60 characters.';
+
+ @override
+ String get savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.';
+
+ @override
+ String get savedSnippetContentValidationErrorTooLong => 'Content length shouldn\'t be greater than 10000 characters.';
+
@override
String get composeBoxGenericContentHint => 'Ввести сообщение';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 9ec68077ae..edb246795c 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -270,6 +270,39 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get composeBoxAttachFromCameraTooltip => 'Take a photo';
+ @override
+ String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets';
+
+ @override
+ String get noSavedSnippets => 'No saved snippets';
+
+ @override
+ String get newSavedSnippetButton => 'New';
+
+ @override
+ String get newSavedSnippetTitle => 'New snippet';
+
+ @override
+ String get newSavedSnippetTitleHint => 'Title';
+
+ @override
+ String get newSavedSnippetContentHint => 'Content';
+
+ @override
+ String get errorFailedToCreateSavedSnippet => 'Failed to create saved snippet';
+
+ @override
+ String get savedSnippetTitleValidationErrorEmpty => 'Title cannot be empty.';
+
+ @override
+ String get savedSnippetTitleValidationErrorTooLong => 'Title length shouldn\'t be greater than 60 characters.';
+
+ @override
+ String get savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.';
+
+ @override
+ String get savedSnippetContentValidationErrorTooLong => 'Content length shouldn\'t be greater than 10000 characters.';
+
@override
String get composeBoxGenericContentHint => 'Type a message';
diff --git a/lib/model/saved_snippet.dart b/lib/model/saved_snippet.dart
new file mode 100644
index 0000000000..693d73f86e
--- /dev/null
+++ b/lib/model/saved_snippet.dart
@@ -0,0 +1,27 @@
+import '../api/model/events.dart';
+import '../api/model/model.dart';
+
+mixin SavedSnippetStore {
+ Iterable get savedSnippets;
+}
+
+class SavedSnippetStoreImpl with SavedSnippetStore {
+ SavedSnippetStoreImpl({required Iterable savedSnippets})
+ : _savedSnippets = Map.fromIterable(
+ savedSnippets, key: (x) => (x as SavedSnippet).id);
+
+ @override
+ Iterable get savedSnippets => _savedSnippets.values;
+
+ final Map _savedSnippets;
+
+ void handleSavedSnippetsEvent(SavedSnippetsEvent event) {
+ switch (event) {
+ case SavedSnippetsAddEvent(:final savedSnippet):
+ _savedSnippets[savedSnippet.id] = savedSnippet;
+
+ case SavedSnippetsRemoveEvent(:final savedSnippetId):
+ _savedSnippets.remove(savedSnippetId);
+ }
+ }
+}
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 05a9faabf3..432db9f993 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -29,6 +29,7 @@ import 'message_list.dart';
import 'recent_dm_conversations.dart';
import 'recent_senders.dart';
import 'channel.dart';
+import 'saved_snippet.dart';
import 'typing_status.dart';
import 'unreads.dart';
import 'user.dart';
@@ -295,7 +296,7 @@ class AccountNotFoundException implements Exception {}
/// This class does not attempt to poll an event queue
/// to keep the data up to date. For that behavior, see
/// [UpdateMachine].
-class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, ChannelStore, MessageStore {
+class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, SavedSnippetStore, ChannelStore, MessageStore {
/// Construct a store for the user's data, starting from the given snapshot.
///
/// The global store must already have been updated with
@@ -337,6 +338,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, Channel
emoji: EmojiStoreImpl(
realmUrl: realmUrl, allRealmEmoji: initialSnapshot.realmEmoji),
accountId: accountId,
+ savedSnippets: SavedSnippetStoreImpl(
+ savedSnippets: initialSnapshot.savedSnippets ?? []),
userSettings: initialSnapshot.userSettings,
typingNotifier: TypingNotifier(
connection: connection,
@@ -379,6 +382,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, Channel
required this.emailAddressVisibility,
required EmojiStoreImpl emoji,
required this.accountId,
+ required SavedSnippetStoreImpl savedSnippets,
required this.userSettings,
required this.typingNotifier,
required UserStoreImpl users,
@@ -395,6 +399,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, Channel
_realmEmptyTopicDisplayName = realmEmptyTopicDisplayName,
_emoji = emoji,
_users = users,
+ _savedSnippets = savedSnippets,
_channels = channels,
_messages = messages;
@@ -499,6 +504,10 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, Channel
/// Will throw if called after [dispose] has been called.
Account get account => _globalStore.getAccount(accountId)!;
+ @override
+ Iterable get savedSnippets => _savedSnippets.savedSnippets;
+ final SavedSnippetStoreImpl _savedSnippets; // TODO(server-10)
+
final UserSettings? userSettings; // TODO(server-5)
final TypingNotifier typingNotifier;
@@ -726,6 +735,11 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, Channel
autocompleteViewManager.handleRealmUserUpdateEvent(event);
notifyListeners();
+ case SavedSnippetsEvent():
+ assert(debugLog('server event: saved_snippet/${event.op}'));
+ _savedSnippets.handleSavedSnippetsEvent(event);
+ notifyListeners();
+
case ChannelEvent():
assert(debugLog("server event: stream/${event.op}"));
_channels.handleChannelEvent(event);
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index 1d3cbad495..98ccfa05a5 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -35,10 +35,6 @@ void _showActionSheet(
}) {
showModalBottomSheet(
context: context,
- // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
- // on my iPhone 13 Pro but is marked as "much slower":
- // https://api.flutter.dev/flutter/dart-ui/Clip.html
- clipBehavior: Clip.antiAlias,
useSafeArea: true,
isScrollControlled: true,
builder: (BuildContext _) {
diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart
index 7f47046d11..122ec0c8f5 100644
--- a/lib/widgets/compose_box.dart
+++ b/lib/widgets/compose_box.dart
@@ -8,6 +8,7 @@ import 'package:mime/mime.dart';
import '../api/exception.dart';
import '../api/model/model.dart';
import '../api/route/messages.dart';
+import '../api/route/saved_snippets.dart';
import '../generated/l10n/zulip_localizations.dart';
import '../model/binding.dart';
import '../model/compose.dart';
@@ -18,6 +19,7 @@ import 'color.dart';
import 'dialog.dart';
import 'icons.dart';
import 'inset_shadow.dart';
+import 'saved_snippet.dart';
import 'store.dart';
import 'text.dart';
import 'theme.dart';
@@ -376,6 +378,164 @@ class ComposeContentController extends ComposeController
}
}
+enum SavedSnippetTitleValidationError {
+ empty,
+ tooLong;
+
+ String message(ZulipLocalizations zulipLocalizations) {
+ return switch (this) {
+ SavedSnippetTitleValidationError.empty => zulipLocalizations.savedSnippetTitleValidationErrorEmpty,
+ SavedSnippetTitleValidationError.tooLong => zulipLocalizations.savedSnippetTitleValidationErrorTooLong,
+ };
+ }
+}
+
+class SavedSnippetTitleComposeController extends ComposeController {
+ SavedSnippetTitleComposeController() {
+ _update();
+ }
+
+ @override int get maxLengthUnicodeCodePoints => kMaxTopicLengthCodePoints;
+
+ @override
+ String _computeTextNormalized() {
+ return text.trim();
+ }
+
+ @override
+ List _computeValidationErrors() {
+ return [
+ if (textNormalized.isEmpty)
+ SavedSnippetTitleValidationError.empty,
+
+ if (
+ _lengthUnicodeCodePointsIfLong != null
+ && _lengthUnicodeCodePointsIfLong! > maxLengthUnicodeCodePoints
+ )
+ SavedSnippetTitleValidationError.tooLong,
+ ];
+ }
+}
+
+enum SavedSnippetContentValidationError {
+ empty,
+ tooLong;
+
+ String message(ZulipLocalizations zulipLocalizations) {
+ return switch (this) {
+ SavedSnippetContentValidationError.empty => zulipLocalizations.savedSnippetContentValidationErrorEmpty,
+ SavedSnippetContentValidationError.tooLong => zulipLocalizations.savedSnippetContentValidationErrorTooLong,
+ };
+ }
+}
+
+class SavedSnippetContentComposeController extends ComposeController {
+ SavedSnippetContentComposeController() {
+ _update();
+ }
+
+ @override int get maxLengthUnicodeCodePoints => kMaxMessageLengthCodePoints;
+
+ @override
+ String _computeTextNormalized() {
+ return text.trim();
+ }
+
+ @override
+ List _computeValidationErrors() {
+ return [
+ if (textNormalized.isEmpty)
+ SavedSnippetContentValidationError.empty,
+
+ if (
+ _lengthUnicodeCodePointsIfLong != null
+ && _lengthUnicodeCodePointsIfLong! > maxLengthUnicodeCodePoints
+ )
+ SavedSnippetContentValidationError.tooLong,
+ ];
+ }
+}
+
+class _ContentTextField extends StatelessWidget {
+ const _ContentTextField({
+ required this.controller,
+ required this.focusNode,
+ required this.hintText,
+ });
+
+ final TextEditingController controller;
+ final FocusNode focusNode;
+ final String hintText;
+
+ static double maxHeight(BuildContext context) {
+ final clampingTextScaler = MediaQuery.textScalerOf(context)
+ .clamp(maxScaleFactor: 1.5);
+ final scaledLineHeight = clampingTextScaler.scale(_fontSize) * _lineHeightRatio;
+
+ // Reserve space to fully show the first 7th lines and just partially
+ // clip the 8th line, where the height matches the spec at
+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
+ // > Maximum size of the compose box is suggested to be 178px. Which
+ // > has 7 fully visible lines of text
+ //
+ // The partial line hints that the content input is scrollable.
+ //
+ // Using the ambient TextScale means this works for different values of the
+ // system text-size setting. We clamp to a max scale factor to limit
+ // how tall the content input can get; that's to save room for the message
+ // list. The user can still scroll the input to see everything.
+ return _verticalPadding + 7.727 * scaledLineHeight;
+ }
+
+ static const _verticalPadding = 8.0;
+ static const _fontSize = 17.0;
+ static const _lineHeight = 22.0;
+ static const _lineHeightRatio = _lineHeight / _fontSize;
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+ return ConstrainedBox(
+ constraints: BoxConstraints(maxHeight: maxHeight(context)),
+ // This [ClipRect] replaces the [TextField] clipping we disable below.
+ child: ClipRect(
+ child: InsetShadowBox(
+ top: _verticalPadding, bottom: _verticalPadding,
+ color: designVariables.composeBoxBg,
+ child: TextField(
+ controller: controller,
+ focusNode: focusNode,
+ // Let the content show through the `contentPadding` so that
+ // our [InsetShadowBox] can fade it smoothly there.
+ clipBehavior: Clip.none,
+ style: TextStyle(
+ fontSize: _fontSize,
+ height: _lineHeightRatio,
+ color: designVariables.textInput),
+ // From the spec at
+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
+ // > Compose box has the height to fit 2 lines. This is [done] to
+ // > have a bigger hit area for the user to start the input. […]
+ minLines: 2,
+ maxLines: null,
+ textCapitalization: TextCapitalization.sentences,
+ decoration: InputDecoration(
+ // This padding ensures that the user can always scroll long
+ // content entirely out of the top or bottom shadow if desired.
+ // With this and the `minLines: 2` above, an empty content input
+ // gets 60px vertical distance (with no text-size scaling)
+ // between the top of the top shadow and the bottom of the
+ // bottom shadow. That's a bit more than the 54px given in the
+ // Figma, and we can revisit if needed, but it's tricky to get
+ // that 54px distance while also making the scrolling work like
+ // this and offering two lines of touchable area.
+ contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding),
+ hintText: hintText,
+ hintStyle: TextStyle(
+ color: designVariables.textInput.withFadedAlpha(0.5)))))));
+ }
+}
+
class _ContentInput extends StatefulWidget {
const _ContentInput({
required this.narrow,
@@ -466,77 +626,16 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
}
}
- static double maxHeight(BuildContext context) {
- final clampingTextScaler = MediaQuery.textScalerOf(context)
- .clamp(maxScaleFactor: 1.5);
- final scaledLineHeight = clampingTextScaler.scale(_fontSize) * _lineHeightRatio;
-
- // Reserve space to fully show the first 7th lines and just partially
- // clip the 8th line, where the height matches the spec at
- // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
- // > Maximum size of the compose box is suggested to be 178px. Which
- // > has 7 fully visible lines of text
- //
- // The partial line hints that the content input is scrollable.
- //
- // Using the ambient TextScale means this works for different values of the
- // system text-size setting. We clamp to a max scale factor to limit
- // how tall the content input can get; that's to save room for the message
- // list. The user can still scroll the input to see everything.
- return _verticalPadding + 7.727 * scaledLineHeight;
- }
-
- static const _verticalPadding = 8.0;
- static const _fontSize = 17.0;
- static const _lineHeight = 22.0;
- static const _lineHeightRatio = _lineHeight / _fontSize;
-
@override
Widget build(BuildContext context) {
- final designVariables = DesignVariables.of(context);
-
return ComposeAutocomplete(
narrow: widget.narrow,
controller: widget.controller.content,
focusNode: widget.controller.contentFocusNode,
- fieldViewBuilder: (context) => ConstrainedBox(
- constraints: BoxConstraints(maxHeight: maxHeight(context)),
- // This [ClipRect] replaces the [TextField] clipping we disable below.
- child: ClipRect(
- child: InsetShadowBox(
- top: _verticalPadding, bottom: _verticalPadding,
- color: designVariables.composeBoxBg,
- child: TextField(
- controller: widget.controller.content,
- focusNode: widget.controller.contentFocusNode,
- // Let the content show through the `contentPadding` so that
- // our [InsetShadowBox] can fade it smoothly there.
- clipBehavior: Clip.none,
- style: TextStyle(
- fontSize: _fontSize,
- height: _lineHeightRatio,
- color: designVariables.textInput),
- // From the spec at
- // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
- // > Compose box has the height to fit 2 lines. This is [done] to
- // > have a bigger hit area for the user to start the input. […]
- minLines: 2,
- maxLines: null,
- textCapitalization: TextCapitalization.sentences,
- decoration: InputDecoration(
- // This padding ensures that the user can always scroll long
- // content entirely out of the top or bottom shadow if desired.
- // With this and the `minLines: 2` above, an empty content input
- // gets 60px vertical distance (with no text-size scaling)
- // between the top of the top shadow and the bottom of the
- // bottom shadow. That's a bit more than the 54px given in the
- // Figma, and we can revisit if needed, but it's tricky to get
- // that 54px distance while also making the scrolling work like
- // this and offering two lines of touchable area.
- contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding),
- hintText: widget.hintText,
- hintStyle: TextStyle(
- color: designVariables.textInput.withFadedAlpha(0.5))))))));
+ fieldViewBuilder: (context) => _ContentTextField(
+ controller: widget.controller.content,
+ focusNode: widget.controller.contentFocusNode,
+ hintText: widget.hintText));
}
}
@@ -599,15 +698,19 @@ class _StreamContentInputState extends State<_StreamContentInput> {
}
}
-class _TopicInput extends StatelessWidget {
- const _TopicInput({required this.streamId, required this.controller});
+class _TitleTextField extends StatelessWidget {
+ const _TitleTextField({
+ required this.controller,
+ required this.focusNode,
+ required this.hintText,
+ });
- final int streamId;
- final StreamComposeBoxController controller;
+ final TextEditingController controller;
+ final FocusNode focusNode;
+ final String hintText;
@override
Widget build(BuildContext context) {
- final zulipLocalizations = ZulipLocalizations.of(context);
final designVariables = DesignVariables.of(context);
TextStyle topicTextStyle = TextStyle(
fontSize: 20,
@@ -615,25 +718,43 @@ class _TopicInput extends StatelessWidget {
color: designVariables.textInput.withFadedAlpha(0.9),
).merge(weightVariableTextStyle(context, wght: 600));
+ return Container(
+ padding: const EdgeInsets.only(top: 10, bottom: 9),
+ decoration: BoxDecoration(border: Border(bottom: BorderSide(
+ width: 1,
+ color: designVariables.foreground.withFadedAlpha(0.2)))),
+ child: TextField(
+ controller: controller,
+ focusNode: focusNode,
+ textInputAction: TextInputAction.next,
+ style: topicTextStyle,
+ decoration: InputDecoration(
+ hintText: hintText,
+ hintStyle: topicTextStyle.copyWith(
+ color: designVariables.textInput.withFadedAlpha(0.5)))));
+ }
+}
+
+class _TopicInput extends StatelessWidget {
+ const _TopicInput({required this.streamId, required this.controller});
+
+ final int streamId;
+ final StreamComposeBoxController controller;
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+
return TopicAutocomplete(
streamId: streamId,
controller: controller.topic,
focusNode: controller.topicFocusNode,
contentFocusNode: controller.contentFocusNode,
- fieldViewBuilder: (context) => Container(
- padding: const EdgeInsets.only(top: 10, bottom: 9),
- decoration: BoxDecoration(border: Border(bottom: BorderSide(
- width: 1,
- color: designVariables.foreground.withFadedAlpha(0.2)))),
- child: TextField(
+ fieldViewBuilder: (context) =>
+ _TitleTextField(
controller: controller.topic,
focusNode: controller.topicFocusNode,
- textInputAction: TextInputAction.next,
- style: topicTextStyle,
- decoration: InputDecoration(
- hintText: zulipLocalizations.composeBoxTopicHintText,
- hintStyle: topicTextStyle.copyWith(
- color: designVariables.textInput.withFadedAlpha(0.5))))));
+ hintText: zulipLocalizations.composeBoxTopicHintText));
}
}
@@ -770,14 +891,32 @@ Future _uploadFiles({
}
}
-abstract class _AttachUploadsButton extends StatelessWidget {
- const _AttachUploadsButton({required this.controller});
+abstract class _ComposeButton extends StatelessWidget {
+ const _ComposeButton({required this.controller});
final ComposeBoxController controller;
IconData get icon;
String tooltip(ZulipLocalizations zulipLocalizations);
+ void handlePress(BuildContext context);
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ return SizedBox(
+ width: _composeButtonSize,
+ child: IconButton(
+ icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)),
+ tooltip: tooltip(zulipLocalizations),
+ onPressed: () => handlePress(context)));
+ }
+}
+
+abstract class _AttachUploadsButton extends _ComposeButton {
+ const _AttachUploadsButton({required super.controller});
+
/// Request files from the user, in the way specific to this upload type.
///
/// Subclasses should manage the interaction completely, e.g., by catching and
@@ -787,7 +926,8 @@ abstract class _AttachUploadsButton extends StatelessWidget {
/// return an empty [Iterable] after showing user feedback as appropriate.
Future> getFiles(BuildContext context);
- void _handlePress(BuildContext context) async {
+ @override
+ void handlePress(BuildContext context) async {
final files = await getFiles(context);
if (files.isEmpty) {
return; // Nothing to do (getFiles handles user feedback)
@@ -805,18 +945,6 @@ abstract class _AttachUploadsButton extends StatelessWidget {
contentFocusNode: controller.contentFocusNode,
files: files);
}
-
- @override
- Widget build(BuildContext context) {
- final designVariables = DesignVariables.of(context);
- final zulipLocalizations = ZulipLocalizations.of(context);
- return SizedBox(
- width: _composeButtonSize,
- child: IconButton(
- icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)),
- tooltip: tooltip(zulipLocalizations),
- onPressed: () => _handlePress(context)));
- }
}
Future> _getFilePickerFiles(BuildContext context, FileType type) async {
@@ -978,6 +1106,47 @@ class _AttachFromCameraButton extends _AttachUploadsButton {
}
}
+class _ComposeButtonRow extends StatelessWidget {
+ const _ComposeButtonRow({required this.controller, required this.sendButton});
+
+ final ComposeBoxController controller;
+ final Widget sendButton;
+
+ @override
+ Widget build(BuildContext context) {
+ final store = PerAccountStoreWidget.of(context);
+ final composeButtons = [
+ _AttachFileButton(controller: controller),
+ _AttachMediaButton(controller: controller),
+ _AttachFromCameraButton(controller: controller),
+ if (store.zulipFeatureLevel >= 297) // TODO(server-10) remove
+ _ShowSavedSnippetsButton(controller: controller),
+ ];
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Row(children: composeButtons),
+ sendButton,
+ ]);
+ }
+}
+
+class _ShowSavedSnippetsButton extends _ComposeButton {
+ const _ShowSavedSnippetsButton({required super.controller});
+
+ @override
+ void handlePress(BuildContext context) {
+ showSavedSnippetPickerSheet(context: context, controller: controller);
+ }
+
+ @override
+ IconData get icon => ZulipIcons.message_square_text;
+
+ @override
+ String tooltip(ZulipLocalizations zulipLocalizations)
+ => zulipLocalizations.composeBoxShowSavedSnippetsTooltip;
+}
+
class _SendButton extends StatefulWidget {
const _SendButton({required this.controller, required this.getDestination});
@@ -1114,6 +1283,98 @@ class _SendButtonState extends State<_SendButton> {
}
}
+class _SavedSnipppetSaveButton extends StatefulWidget {
+ const _SavedSnipppetSaveButton({required this.controller});
+
+ final SavedSnippetComposeBoxController controller;
+
+ @override
+ State<_SavedSnipppetSaveButton> createState() => _SavedSnipppetSaveButtonState();
+}
+
+class _SavedSnipppetSaveButtonState extends State<_SavedSnipppetSaveButton> {
+ @override
+ void initState() {
+ super.initState();
+ widget.controller.title.hasValidationErrors.addListener(_hasErrorsChanged);
+ widget.controller.content.hasValidationErrors.addListener(_hasErrorsChanged);
+ }
+
+ @override
+ void didUpdateWidget(covariant _SavedSnipppetSaveButton oldWidget) {
+ super.didUpdateWidget(oldWidget);
+
+ final controller = widget.controller;
+ final oldController = oldWidget.controller;
+ if (controller == oldController) return;
+
+ oldController.title.hasValidationErrors.removeListener(_hasErrorsChanged);
+ controller.title.hasValidationErrors.addListener(_hasErrorsChanged);
+ oldController.content.hasValidationErrors.removeListener(_hasErrorsChanged);
+ controller.content.hasValidationErrors.addListener(_hasErrorsChanged);
+ }
+
+ @override
+ void dispose() {
+ widget.controller.title.hasValidationErrors.removeListener(_hasErrorsChanged);
+ widget.controller.content.hasValidationErrors.removeListener(_hasErrorsChanged);
+ super.dispose();
+ }
+
+ void _hasErrorsChanged() {
+ setState(() {
+ // The actual state lives in widget.controller.
+ });
+ }
+
+ void _save() async {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+
+ if (widget.controller.title.hasValidationErrors.value
+ || widget.controller.content.hasValidationErrors.value) {
+ final validationErrorMessages = [
+ for (final error in widget.controller.title.validationErrors)
+ error.message(zulipLocalizations),
+ for (final error in widget.controller.content.validationErrors)
+ error.message(zulipLocalizations),
+ ];
+ showErrorDialog(context: context,
+ title: zulipLocalizations.errorFailedToCreateSavedSnippet,
+ message: validationErrorMessages.join('\n\n'));
+ return;
+ }
+
+ final store = PerAccountStoreWidget.of(context);
+ try {
+ await createSavedSnippet(store.connection,
+ title: widget.controller.title.textNormalized,
+ content: widget.controller.content.textNormalized);
+ if (!mounted) return;
+ Navigator.pop(context);
+ } on ApiRequestException catch (e) {
+ if (!mounted) return;
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final message = switch (e) {
+ ZulipApiException() => zulipLocalizations.errorServerMessage(e.message),
+ _ => e.message,
+ };
+ showErrorDialog(context: context,
+ title: zulipLocalizations.errorFailedToCreateSavedSnippet,
+ message: message);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+ return IconButton(onPressed: _save,
+ icon: Icon(ZulipIcons.check, color:
+ widget.controller.title.hasValidationErrors.value
+ || widget.controller.content.hasValidationErrors.value
+ ? designVariables.icon.withFadedAlpha(0.5) : designVariables.icon));
+ }
+}
+
class _ComposeBoxContainer extends StatelessWidget {
const _ComposeBoxContainer({
required this.body,
@@ -1177,14 +1438,9 @@ class _ComposeBoxContainer extends StatelessWidget {
/// The text inputs, compose-button row, and send button for the compose box.
abstract class _ComposeBoxBody extends StatelessWidget {
- /// The narrow on view in the message list.
- Narrow get narrow;
-
- ComposeBoxController get controller;
-
- Widget? buildTopicInput();
- Widget buildContentInput();
- Widget buildSendButton();
+ Widget? buildTopicInput(BuildContext context);
+ Widget buildContentInput(BuildContext context);
+ Widget buildComposeButtonRow(BuildContext context);
@override
Widget build(BuildContext context) {
@@ -1210,13 +1466,7 @@ abstract class _ComposeBoxBody extends StatelessWidget {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(4)))));
- final composeButtons = [
- _AttachFileButton(controller: controller),
- _AttachMediaButton(controller: controller),
- _AttachFromCameraButton(controller: controller),
- ];
-
- final topicInput = buildTopicInput();
+ final topicInput = buildTopicInput(context);
return Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1224,18 +1474,13 @@ abstract class _ComposeBoxBody extends StatelessWidget {
data: inputThemeData,
child: Column(children: [
if (topicInput != null) topicInput,
- buildContentInput(),
+ buildContentInput(context),
]))),
SizedBox(
height: _composeButtonSize,
child: IconButtonTheme(
data: iconButtonThemeData,
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Row(children: composeButtons),
- buildSendButton(),
- ]))),
+ child: buildComposeButtonRow(context))),
]);
}
}
@@ -1247,49 +1492,73 @@ abstract class _ComposeBoxBody extends StatelessWidget {
class _StreamComposeBoxBody extends _ComposeBoxBody {
_StreamComposeBoxBody({required this.narrow, required this.controller});
- @override
final ChannelNarrow narrow;
- @override
final StreamComposeBoxController controller;
- @override Widget buildTopicInput() => _TopicInput(
+ @override Widget buildTopicInput(_) => _TopicInput(
streamId: narrow.streamId,
controller: controller,
);
- @override Widget buildContentInput() => _StreamContentInput(
+ @override Widget buildContentInput(_) => _StreamContentInput(
narrow: narrow,
controller: controller,
);
- @override Widget buildSendButton() => _SendButton(
+ @override Widget buildComposeButtonRow(_) => _ComposeButtonRow(
controller: controller,
- getDestination: () => StreamDestination(
- narrow.streamId, TopicName(controller.topic.textNormalized)),
- );
+ sendButton: _SendButton(
+ controller: controller,
+ getDestination: () => StreamDestination(
+ narrow.streamId, TopicName(controller.topic.textNormalized))));
}
class _FixedDestinationComposeBoxBody extends _ComposeBoxBody {
_FixedDestinationComposeBoxBody({required this.narrow, required this.controller});
- @override
final SendableNarrow narrow;
- @override
final FixedDestinationComposeBoxController controller;
- @override Widget? buildTopicInput() => null;
+ @override Widget? buildTopicInput(_) => null;
- @override Widget buildContentInput() => _FixedDestinationContentInput(
+ @override Widget buildContentInput(_) => _FixedDestinationContentInput(
narrow: narrow,
controller: controller,
);
- @override Widget buildSendButton() => _SendButton(
+ @override Widget buildComposeButtonRow(_) => _ComposeButtonRow(
controller: controller,
- getDestination: () => narrow.destination,
- );
+ sendButton: _SendButton(
+ controller: controller,
+ getDestination: () => narrow.destination));
+}
+
+class _SavedSnippetComposeBoxBody extends _ComposeBoxBody {
+ _SavedSnippetComposeBoxBody({required this.controller});
+
+ final SavedSnippetComposeBoxController controller;
+
+ @override Widget buildTopicInput(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ return _TitleTextField(
+ controller: controller.title,
+ focusNode: controller.titleFocusNode,
+ hintText: zulipLocalizations.newSavedSnippetTitleHint);
+ }
+
+ @override Widget buildContentInput(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ return _ContentTextField(
+ controller: controller.content,
+ focusNode: controller.contentFocusNode,
+ hintText: zulipLocalizations.newSavedSnippetContentHint);
+ }
+
+ @override Widget buildComposeButtonRow(_) => Align(
+ alignment: Alignment.centerRight,
+ child: _SavedSnipppetSaveButton(controller: controller));
}
sealed class ComposeBoxController {
@@ -1320,6 +1589,22 @@ class StreamComposeBoxController extends ComposeBoxController {
class FixedDestinationComposeBoxController extends ComposeBoxController {}
+final class SavedSnippetComposeBoxController {
+ SavedSnippetComposeBoxController();
+
+ final title = SavedSnippetTitleComposeController();
+ final titleFocusNode = FocusNode();
+ final content = SavedSnippetContentComposeController();
+ final contentFocusNode = FocusNode();
+
+ void dispose() {
+ title.dispose();
+ titleFocusNode.dispose();
+ content.dispose();
+ contentFocusNode.dispose();
+ }
+}
+
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.label});
@@ -1476,3 +1761,33 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM
return _ComposeBoxContainer(body: body, errorBanner: null);
}
}
+
+class SavedSnippetComposeBox extends StatefulWidget {
+ const SavedSnippetComposeBox({super.key});
+
+ @override
+ State createState() => _SavedSnippetComposeBoxState();
+}
+
+class _SavedSnippetComposeBoxState extends State {
+ // TODO: preserve the controller independent from this widget
+ late SavedSnippetComposeBoxController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = SavedSnippetComposeBoxController();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return _ComposeBoxContainer(
+ body: _SavedSnippetComposeBoxBody(controller: _controller));
+ }
+}
diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart
index 0f6d490a97..084a9dbaae 100644
--- a/lib/widgets/emoji_reaction.dart
+++ b/lib/widgets/emoji_reaction.dart
@@ -413,10 +413,6 @@ void showEmojiPickerSheet({
final store = PerAccountStoreWidget.of(pageContext);
showModalBottomSheet(
context: pageContext,
- // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
- // on my iPhone 13 Pro but is marked as "much slower":
- // https://api.flutter.dev/flutter/dart-ui/Clip.html
- clipBehavior: Clip.antiAlias,
// The bottom inset is left for [builder] to handle;
// see [EmojiPicker] and its [CustomScrollView] for how we do that.
useSafeArea: true,
diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart
index f444f0a6a5..d91386b869 100644
--- a/lib/widgets/home.dart
+++ b/lib/widgets/home.dart
@@ -292,10 +292,6 @@ void _showMainMenu(BuildContext context, {
final accountId = PerAccountStoreWidget.accountIdOf(context);
showModalBottomSheet(
context: context,
- // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
- // on my iPhone 13 Pro but is marked as "much slower":
- // https://api.flutter.dev/flutter/dart-ui/Clip.html
- clipBehavior: Clip.antiAlias,
useSafeArea: true,
isScrollControlled: true,
// TODO: Fix the issue that the color does not respond when the theme
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index ff9b2f7794..b75c51b843 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -108,44 +108,50 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "message_feed".
static const IconData message_feed = IconData(0xf11c, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "message_square_text".
+ static const IconData message_square_text = IconData(0xf11d, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "mute".
- static const IconData mute = IconData(0xf11d, fontFamily: "Zulip Icons");
+ static const IconData mute = IconData(0xf11e, fontFamily: "Zulip Icons");
+
+ /// The Zulip custom icon "plus".
+ static const IconData plus = IconData(0xf11f, fontFamily: "Zulip Icons");
/// The Zulip custom icon "read_receipts".
- static const IconData read_receipts = IconData(0xf11e, fontFamily: "Zulip Icons");
+ static const IconData read_receipts = IconData(0xf120, fontFamily: "Zulip Icons");
/// The Zulip custom icon "send".
- static const IconData send = IconData(0xf11f, fontFamily: "Zulip Icons");
+ static const IconData send = IconData(0xf121, fontFamily: "Zulip Icons");
/// The Zulip custom icon "settings".
- static const IconData settings = IconData(0xf120, fontFamily: "Zulip Icons");
+ static const IconData settings = IconData(0xf122, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share".
- static const IconData share = IconData(0xf121, fontFamily: "Zulip Icons");
+ static const IconData share = IconData(0xf123, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share_ios".
- static const IconData share_ios = IconData(0xf122, fontFamily: "Zulip Icons");
+ static const IconData share_ios = IconData(0xf124, fontFamily: "Zulip Icons");
/// The Zulip custom icon "smile".
- static const IconData smile = IconData(0xf123, fontFamily: "Zulip Icons");
+ static const IconData smile = IconData(0xf125, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star".
- static const IconData star = IconData(0xf124, fontFamily: "Zulip Icons");
+ static const IconData star = IconData(0xf126, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star_filled".
- static const IconData star_filled = IconData(0xf125, fontFamily: "Zulip Icons");
+ static const IconData star_filled = IconData(0xf127, fontFamily: "Zulip Icons");
/// The Zulip custom icon "three_person".
- static const IconData three_person = IconData(0xf126, fontFamily: "Zulip Icons");
+ static const IconData three_person = IconData(0xf128, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf127, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf129, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf128, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf12a, fontFamily: "Zulip Icons");
/// The Zulip custom icon "user".
- static const IconData user = IconData(0xf129, fontFamily: "Zulip Icons");
+ static const IconData user = IconData(0xf12b, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/lib/widgets/saved_snippet.dart b/lib/widgets/saved_snippet.dart
new file mode 100644
index 0000000000..056440a817
--- /dev/null
+++ b/lib/widgets/saved_snippet.dart
@@ -0,0 +1,244 @@
+import 'dart:async';
+
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+
+import '../api/model/model.dart';
+import '../generated/l10n/zulip_localizations.dart';
+import 'compose_box.dart';
+import 'icons.dart';
+import 'inset_shadow.dart';
+import 'store.dart';
+import 'text.dart';
+import 'theme.dart';
+
+void showSavedSnippetPickerSheet({
+ required BuildContext context,
+ required ComposeBoxController controller,
+}) async {
+ final store = PerAccountStoreWidget.of(context);
+ assert(store.zulipFeatureLevel >= 297); // TODO(server-10) remove
+ unawaited(showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ useSafeArea: true,
+ builder: (BuildContext context) {
+ return PerAccountStoreWidget(
+ accountId: store.accountId,
+ child: _SavedSnippetPicker(controller: controller));
+ }));
+}
+
+class _SavedSnippetPicker extends StatelessWidget {
+ const _SavedSnippetPicker({required this.controller});
+
+ final ComposeBoxController controller;
+
+ void _handleSelect(BuildContext context, String content) {
+ if (!content.endsWith('\n')) {
+ content = '$content\n';
+ }
+ controller.content.insertPadded(content);
+ Navigator.pop(context);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final store = PerAccountStoreWidget.of(context);
+ // Usually a user shouldn't have that many saved snippets, so it is
+ // tolerable to re-sort during builds.
+ final savedSnippets = store.savedSnippets.sortedBy((x) => x.title); // TODO(#1399)
+ return Padding(
+ padding: const EdgeInsets.only(top: 4.0),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const _SavedSnippetPickerHeader(),
+ Flexible(
+ child: InsetShadowBox(
+ top: 8,
+ color: designVariables.bgContextMenu,
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.only(top: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ for (final savedSnippet in savedSnippets)
+ _SavedSnippetItem(
+ savedSnippet: savedSnippet,
+ onPressed:
+ () => _handleSelect(context, savedSnippet.content)),
+ if (store.savedSnippets.isEmpty)
+ // TODO(design)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: Text(zulipLocalizations.noSavedSnippets,
+ textAlign: TextAlign.center)),
+ ])))),
+ ]));
+ }
+}
+
+class _SavedSnippetPickerHeader extends StatelessWidget {
+ const _SavedSnippetPickerHeader();
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final designVariables = DesignVariables.of(context);
+ final textStyle = TextStyle(
+ fontSize: 20,
+ height: 30 / 20,
+ color: designVariables.icon,
+ );
+ final textButtonStyle = TextButton.styleFrom(
+ shape: const ContinuousRectangleBorder(),
+ padding: EdgeInsets.zero,
+ minimumSize: const Size.square(42),
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ splashFactory: NoSplash.splashFactory);
+
+ return Container(
+ constraints: const BoxConstraints(minHeight: 42),
+ child: Row(
+ children: [
+ const SizedBox(width: 8),
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ style: textButtonStyle.copyWith(
+ padding: const WidgetStatePropertyAll(
+ EdgeInsets.symmetric(horizontal: 8))),
+ child: Text(zulipLocalizations.dialogClose,
+ style: textStyle.merge(weightVariableTextStyle(context, wght: 400)))),
+ Expanded(child: SizedBox.shrink()),
+ TextButton(
+ onPressed: () => showNewSavedSnippetComposeBox(context: context),
+ style: textButtonStyle.copyWith(
+ padding: const WidgetStatePropertyAll(
+ EdgeInsetsDirectional.fromSTEB(3, 0, 10, 0))),
+ child: Row(
+ spacing: 4,
+ children: [
+ const Icon(ZulipIcons.plus, size: 24),
+ Text(zulipLocalizations.newSavedSnippetButton,
+ style: textStyle.merge(
+ weightVariableTextStyle(context, wght: 600))),
+ ])),
+ ]));
+ }
+}
+
+class _SavedSnippetItem extends StatelessWidget {
+ const _SavedSnippetItem({
+ required this.savedSnippet,
+ required this.onPressed,
+ });
+
+ final SavedSnippet savedSnippet;
+ final void Function() onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+
+ // TODO(#xxx): support editing saved snippets
+ return InkWell(
+ onTap: onPressed,
+ borderRadius: BorderRadius.circular(10),
+ splashFactory: NoSplash.splashFactory,
+ overlayColor: WidgetStateProperty.fromMap({
+ WidgetState.pressed: designVariables.pressedTint,
+ WidgetState.hovered: designVariables.pressedTint,
+ WidgetState.any: Colors.transparent,
+ }),
+ child: Padding(
+ // The end padding is 14px to account for the lack of edit button,
+ // whose visible part would be 14px away from the end of the text. See:
+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=7965-76050&t=IxXomdPIZ5bXvJKA-0
+ padding: EdgeInsetsDirectional.fromSTEB(16, 8, 14, 8),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ spacing: 4,
+ children: [
+ Text(savedSnippet.title,
+ style: TextStyle(
+ fontSize: 18,
+ height: 22 / 18,
+ color: designVariables.textMessage,
+ ).merge(weightVariableTextStyle(context, wght: 600))),
+ Text(savedSnippet.content,
+ style: TextStyle(
+ fontSize: 17,
+ height: 18 / 17,
+ color: designVariables.textMessage
+ ).merge(weightVariableTextStyle(context, wght: 400))),
+ ])));
+ }
+}
+
+class _NewSavedSnippetHeader extends StatelessWidget {
+ const _NewSavedSnippetHeader();
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ return Container(
+ padding: const EdgeInsets.only(top: 4.0),
+ constraints: BoxConstraints(minHeight: 44, maxHeight: 60),
+ color: designVariables.bgContextMenu,
+ // TODO(upstream) give more information when height is unconstrained;
+ // document that.
+ child: NavigationToolbar(
+ middle: Text(zulipLocalizations.newSavedSnippetTitle,
+ style: TextStyle(
+ color: designVariables.title,
+ fontSize: 20,
+ height: 30 / 20,
+ ).merge(weightVariableTextStyle(context, wght: 600))),
+ trailing: TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ style: TextButton.styleFrom(
+ shape: const ContinuousRectangleBorder(),
+ padding: EdgeInsetsDirectional.fromSTEB(8, 0, 16, 0)),
+ child: Text(zulipLocalizations.dialogCancel,
+ style: TextStyle(
+ color: designVariables.icon,
+ fontSize: 20,
+ height: 30 / 20,
+ ).merge(weightVariableTextStyle(context, wght: 400))))));
+ }
+}
+
+void showNewSavedSnippetComposeBox({
+ required BuildContext context,
+}) {
+ final store = PerAccountStoreWidget.of(context);
+ showModalBottomSheet(context: context,
+ isScrollControlled: true,
+ useSafeArea: true,
+ builder: (context) {
+ return PerAccountStoreWidget(
+ accountId: store.accountId,
+ child: Padding(
+ padding: EdgeInsets.only(
+ // When there is bottom viewInset, part of the bottom sheet would
+ // be completely obstructed by certain system UI, typically the
+ // keyboard. For the compose box on message-list page, this is
+ // handled by [Scaffold]; modal bottom sheet doesn't have that.
+ // TODO(upstream) https://github.com/flutter/flutter/issues/71418
+ bottom: MediaQuery.viewInsetsOf(context).bottom),
+ child: MediaQuery.removeViewInsets(
+ context: context,
+ removeBottom: true,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const _NewSavedSnippetHeader(),
+ const SavedSnippetComposeBox(),
+ ]))));
+ });
+}
diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart
index cc2c51fe20..969435b224 100644
--- a/lib/widgets/theme.dart
+++ b/lib/widgets/theme.dart
@@ -105,6 +105,9 @@ ThemeData zulipThemeData(BuildContext context) {
scaffoldBackgroundColor: designVariables.mainBackground,
tooltipTheme: const TooltipThemeData(preferBelow: false),
bottomSheetTheme: BottomSheetThemeData(
+ // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect
+ // on my iPhone 13 Pro but is marked as "much slower":
+ // https://api.flutter.dev/flutter/dart-ui/Clip.html
clipBehavior: Clip.antiAlias,
backgroundColor: designVariables.bgContextMenu,
modalBarrierColor: designVariables.modalBarrierColor,
@@ -154,6 +157,7 @@ class DesignVariables extends ThemeExtension {
labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(),
labelMenuButton: const Color(0xff222222),
mainBackground: const Color(0xfff0f0f0),
+ pressedTint: Colors.black.withValues(alpha: 0.04),
textInput: const Color(0xff000000),
title: const Color(0xff1a1a1a),
bgSearchInput: const Color(0xffe3e3e3),
@@ -204,6 +208,7 @@ class DesignVariables extends ThemeExtension {
labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(),
labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85),
mainBackground: const Color(0xff1d1d1d),
+ pressedTint: Colors.white.withValues(alpha: 0.04),
textInput: const Color(0xffffffff).withValues(alpha: 0.9),
title: const Color(0xffffffff).withValues(alpha: 0.9),
bgSearchInput: const Color(0xff313131),
@@ -262,6 +267,7 @@ class DesignVariables extends ThemeExtension {
required this.labelEdited,
required this.labelMenuButton,
required this.mainBackground,
+ required this.pressedTint,
required this.textInput,
required this.title,
required this.bgSearchInput,
@@ -321,6 +327,7 @@ class DesignVariables extends ThemeExtension {
final Color labelEdited;
final Color labelMenuButton;
final Color mainBackground;
+ final Color pressedTint;
final Color textInput;
final Color title;
final Color bgSearchInput;
@@ -375,6 +382,7 @@ class DesignVariables extends ThemeExtension {
Color? labelEdited,
Color? labelMenuButton,
Color? mainBackground,
+ Color? pressedTint,
Color? textInput,
Color? title,
Color? bgSearchInput,
@@ -424,6 +432,7 @@ class DesignVariables extends ThemeExtension {
labelEdited: labelEdited ?? this.labelEdited,
labelMenuButton: labelMenuButton ?? this.labelMenuButton,
mainBackground: mainBackground ?? this.mainBackground,
+ pressedTint: pressedTint ?? this.pressedTint,
textInput: textInput ?? this.textInput,
title: title ?? this.title,
bgSearchInput: bgSearchInput ?? this.bgSearchInput,
@@ -480,6 +489,7 @@ class DesignVariables extends ThemeExtension {
labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!,
labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!,
mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!,
+ pressedTint: Color.lerp(pressedTint, other.pressedTint, t)!,
textInput: Color.lerp(textInput, other.textInput, t)!,
title: Color.lerp(title, other.title, t)!,
bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!,
diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart
index 8b39b1ad57..8f9e4b800f 100644
--- a/test/api/model/model_checks.dart
+++ b/test/api/model/model_checks.dart
@@ -21,6 +21,10 @@ extension UserChecks on Subject {
Subject get isSystemBot => has((x) => x.isSystemBot, 'isSystemBot');
}
+extension SavedSnippetChecks on Subject {
+ Subject get id => has((x) => x.id, 'id');
+}
+
extension ZulipStreamChecks on Subject {
}
diff --git a/test/example_data.dart b/test/example_data.dart
index 03cabbda97..43b4670d13 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -233,6 +233,28 @@ final User thirdUser = user(fullName: 'Third User');
final User fourthUser = user(fullName: 'Fourth User');
+////////////////////////////////////////////////////////////////
+// Data attached to the self-account on the realm
+//
+
+int _nextSavedSnippetId() => _lastSavedSnippetId++;
+int _lastSavedSnippetId = 1;
+
+SavedSnippet savedSnippet({
+ int? id,
+ String? title,
+ String? content,
+ int? dateCreated,
+}) {
+ _checkPositive(id, 'saved snippet ID');
+ return SavedSnippet(
+ id: id ?? _nextSavedSnippetId(),
+ title: title ?? 'A saved snippet',
+ content: content ?? 'foo bar baz',
+ dateCreated: dateCreated ?? 1741390853,
+ );
+}
+
////////////////////////////////////////////////////////////////
// Streams and subscriptions.
//
@@ -910,6 +932,7 @@ InitialSnapshot initialSnapshot({
int? serverTypingStartedWaitPeriodMilliseconds,
Map? realmEmoji,
List? recentPrivateConversations,
+ List? savedSnippets,
List? subscriptions,
UnreadMessagesSnapshot? unreadMsgs,
List? streams,
@@ -943,6 +966,7 @@ InitialSnapshot initialSnapshot({
serverTypingStartedWaitPeriodMilliseconds ?? 10000,
realmEmoji: realmEmoji ?? {},
recentPrivateConversations: recentPrivateConversations ?? [],
+ savedSnippets: savedSnippets ?? [],
subscriptions: subscriptions ?? [], // TODO add subscriptions to default
unreadMsgs: unreadMsgs ?? _unreadMsgs(),
streams: streams ?? [], // TODO add streams to default
diff --git a/test/model/saved_snippet.dart b/test/model/saved_snippet.dart
new file mode 100644
index 0000000000..af229048f5
--- /dev/null
+++ b/test/model/saved_snippet.dart
@@ -0,0 +1,26 @@
+import 'package:checks/checks.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/model/events.dart';
+import 'package:zulip/api/model/model.dart';
+
+import '../api/model/model_checks.dart';
+import '../example_data.dart' as eg;
+import 'store_checks.dart';
+
+void main() {
+ test('handleSavedSnippetEvent', () {
+ final store = eg.store(initialSnapshot: eg.initialSnapshot(
+ savedSnippets: [eg.savedSnippet(id: 101)]));
+
+ store.handleEvent(SavedSnippetsAddEvent(
+ id: 1, savedSnippet: eg.savedSnippet(id: 102)));
+ check(store).savedSnippets.deepEquals(>[
+ (it) => it.isA().id.equals(101),
+ (it) => it.isA().id.equals(102),
+ ]);
+
+ store.handleEvent(SavedSnippetsRemoveEvent(
+ id: 2, savedSnippetId: 101));
+ check(store).savedSnippets.single.id.equals(102);
+ });
+}
diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart
index 5b05935572..2fba1a8579 100644
--- a/test/model/store_checks.dart
+++ b/test/model/store_checks.dart
@@ -45,6 +45,7 @@ extension PerAccountStoreChecks on Subject {
Subject get accountId => has((x) => x.accountId, 'accountId');
Subject get account => has((x) => x.account, 'account');
Subject get selfUserId => has((x) => x.selfUserId, 'selfUserId');
+ Subject> get savedSnippets => has((x) => x.savedSnippets, 'savedSnippets');
Subject get userSettings => has((x) => x.userSettings, 'userSettings');
Subject