diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 84e19a9cfa..44770c5270 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 d11bf43eda..6ad1d7b2d9 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -393,6 +393,54 @@
"@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."
+ },
+ "savedSnippetsTitle": "Saved snippets",
+ "@savedSnippetsTitle": {
+ "description": "Title for the bottom sheet to display 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."
+ },
+ "errorFailedToCreateSavedSnippetTitle": "Failed to create saved snippet",
+ "@errorFailedToCreateSavedSnippetTitle": {
+ "description": "Error title 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/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index ecb0eee16a..ade939d8d7 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -655,6 +655,78 @@ 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;
+
+ /// Title for the bottom sheet to display saved snippets.
+ ///
+ /// In en, this message translates to:
+ /// **'Saved snippets'**
+ String get savedSnippetsTitle;
+
+ /// 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 title when the saved snippet failed to be created.
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to create saved snippet'**
+ String get errorFailedToCreateSavedSnippetTitle;
+
+ /// 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 98dd9a7af6..d187cb6670 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -330,6 +330,46 @@ 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 savedSnippetsTitle => '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 errorFailedToCreateSavedSnippetTitle =>
+ '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_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index 08d09bb3c4..c23e7f9891 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -330,6 +330,46 @@ class ZulipLocalizationsDe 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 savedSnippetsTitle => '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 errorFailedToCreateSavedSnippetTitle =>
+ '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 105162429b..d1fe7d063a 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -330,6 +330,46 @@ 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 savedSnippetsTitle => '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 errorFailedToCreateSavedSnippetTitle =>
+ '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 74a2d4bedb..a731a3832f 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -330,6 +330,46 @@ 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 savedSnippetsTitle => '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 errorFailedToCreateSavedSnippetTitle =>
+ '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 02913278b8..40e43abe62 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -330,6 +330,46 @@ 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 savedSnippetsTitle => '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 errorFailedToCreateSavedSnippetTitle =>
+ '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 26b4b7e306..7c74ac575c 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -337,6 +337,46 @@ 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 savedSnippetsTitle => '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 errorFailedToCreateSavedSnippetTitle =>
+ '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 5d8899290d..5c22842002 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -338,6 +338,46 @@ 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 savedSnippetsTitle => '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 errorFailedToCreateSavedSnippetTitle =>
+ '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 3ff534eca5..e2b4cbcab1 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -330,6 +330,46 @@ 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 savedSnippetsTitle => '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 errorFailedToCreateSavedSnippetTitle =>
+ '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_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 94fee8825a..938b1d1980 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -339,6 +339,46 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
@override
String get composeBoxAttachFromCameraTooltip => 'Зробити фото';
+ @override
+ String get composeBoxShowSavedSnippetsTooltip => 'Show saved snippets';
+
+ @override
+ String get noSavedSnippets => 'No saved snippets';
+
+ @override
+ String get savedSnippetsTitle => '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 errorFailedToCreateSavedSnippetTitle =>
+ '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/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index 04e535e65a..979f84202c 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -36,10 +36,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 d629e69029..1978ba7865 100644
--- a/lib/widgets/compose_box.dart
+++ b/lib/widgets/compose_box.dart
@@ -10,6 +10,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';
@@ -22,6 +23,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';
@@ -228,6 +230,19 @@ enum ContentValidationError {
return zulipLocalizations.contentValidationErrorUploadInProgress;
}
}
+
+ /// Convert this into message suitable to use in [SavedSnippetComposeBox].
+ String messageForSavedSnippet(ZulipLocalizations zulipLocalizations) {
+ switch (this) {
+ case ContentValidationError.empty:
+ return zulipLocalizations.savedSnippetContentValidationErrorEmpty;
+ case ContentValidationError.tooLong:
+ return zulipLocalizations.savedSnippetContentValidationErrorTooLong;
+ case ContentValidationError.quoteAndReplyInProgress:
+ case ContentValidationError.uploadInProgress:
+ return message(zulipLocalizations);
+ }
+ }
}
class ComposeContentController extends ComposeController {
@@ -402,6 +417,46 @@ class ComposeContentController extends ComposeController
}
}
+enum SavedSnippetTitleValidationError {
+ empty,
+ tooLong;
+
+ String message(ZulipLocalizations zulipLocalizations) {
+ return switch (this) {
+ SavedSnippetTitleValidationError.empty => zulipLocalizations.savedSnippetTitleValidationErrorEmpty,
+ SavedSnippetTitleValidationError.tooLong => zulipLocalizations.savedSnippetTitleValidationErrorTooLong,
+ };
+ }
+}
+
+class ComposeSavedSnippetTitleController extends ComposeController {
+ ComposeSavedSnippetTitleController() {
+ _update();
+ }
+
+ // TODO find the right value for this
+ @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,
+ ];
+ }
+}
+
class _TypingNotifier extends StatefulWidget {
const _TypingNotifier({
required this.destination,
@@ -501,8 +556,12 @@ class _ContentInput extends StatelessWidget {
this.hintText,
this.enabled = true,
});
+ /// The narrow used for autocomplete.
+ ///
+ /// If `null`, autocomplete is disabled.
+ // TODO support autocomplete without a narrow
+ final Narrow? narrow;
- final Narrow narrow;
final ComposeBoxController controller;
final String? hintText;
final bool enabled;
@@ -536,49 +595,55 @@ class _ContentInput extends StatelessWidget {
Widget build(BuildContext context) {
final designVariables = DesignVariables.of(context);
+ final inputWidget = 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(
+ enabled: enabled,
+ controller: controller.content,
+ focusNode: 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: hintText,
+ hintStyle: TextStyle(
+ color: designVariables.textInput.withFadedAlpha(0.5)))))));
+
+ if (narrow == null) {
+ return inputWidget;
+ }
+
return ComposeAutocomplete(
- narrow: narrow,
+ narrow: narrow!,
controller: controller.content,
focusNode: 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(
- enabled: enabled,
- controller: controller.content,
- focusNode: 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: hintText,
- hintStyle: TextStyle(
- color: designVariables.textInput.withFadedAlpha(0.5))))))));
+ fieldViewBuilder: (context) => inputWidget);
}
}
@@ -830,6 +895,38 @@ class _TopicInputState extends State<_TopicInput> {
}
}
+class _SavedSnippetTitleInput extends StatelessWidget {
+ const _SavedSnippetTitleInput({required this.controller});
+
+ final SavedSnippetComposeBoxController controller;
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final designVariables = DesignVariables.of(context);
+ final titleTextStyle = TextStyle(
+ fontSize: 20,
+ height: 22 / 20,
+ 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.title,
+ focusNode: controller.titleFocusNode,
+ textInputAction: TextInputAction.next,
+ style: titleTextStyle,
+ decoration: InputDecoration(
+ hintText: zulipLocalizations.newSavedSnippetTitleHint,
+ hintStyle: titleTextStyle.copyWith(
+ color: designVariables.textInput.withFadedAlpha(0.5)))));
+ }
+}
+
class _FixedDestinationContentInput extends StatelessWidget {
const _FixedDestinationContentInput({
required this.narrow,
@@ -904,6 +1001,22 @@ class _EditMessageContentInput extends StatelessWidget {
}
}
+class _SavedSnippetContentInput extends StatelessWidget {
+ const _SavedSnippetContentInput({required this.controller});
+
+ final SavedSnippetComposeBoxController controller;
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ return _ContentInput(
+ narrow: null,
+ controller: controller,
+ hintText: zulipLocalizations.newSavedSnippetContentHint);
+ }
+}
+
+
/// Data on a file to be uploaded, from any source.
///
/// A convenience class to represent data from the generic file picker,
@@ -990,8 +1103,8 @@ Future _uploadFiles({
}
}
-abstract class _AttachUploadsButton extends StatelessWidget {
- const _AttachUploadsButton({required this.controller, required this.enabled});
+abstract class _ComposeButton extends StatelessWidget {
+ const _ComposeButton({required this.controller, required this.enabled});
final ComposeBoxController controller;
final bool enabled;
@@ -999,6 +1112,24 @@ abstract class _AttachUploadsButton extends StatelessWidget {
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: enabled ? () => handlePress(context) : null));
+ }
+}
+
+abstract class _AttachUploadsButton extends _ComposeButton {
+ const _AttachUploadsButton({required super.controller, required super.enabled});
+
/// Request files from the user, in the way specific to this upload type.
///
/// Subclasses should manage the interaction completely, e.g., by catching and
@@ -1008,7 +1139,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)
@@ -1026,18 +1158,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: enabled ? () => _handlePress(context) : null));
- }
}
Future> _getFilePickerFiles(BuildContext context, FileType type) async {
@@ -1199,6 +1319,23 @@ class _AttachFromCameraButton extends _AttachUploadsButton {
}
}
+class _ShowSavedSnippetsButton extends _ComposeButton {
+ const _ShowSavedSnippetsButton({required super.controller, required super.enabled})
+ : assert(controller is! SavedSnippetComposeBoxController);
+
+ @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});
@@ -1335,6 +1472,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 {
+ if (widget.controller.title.hasValidationErrors.value
+ || widget.controller.content.hasValidationErrors.value) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final validationErrorMessages = [
+ for (final error in widget.controller.title.validationErrors)
+ error.message(zulipLocalizations),
+ for (final error in widget.controller.content.validationErrors)
+ error.messageForSavedSnippet(zulipLocalizations),
+ ];
+ showErrorDialog(context: context,
+ title: zulipLocalizations.errorFailedToCreateSavedSnippetTitle,
+ message: validationErrorMessages.join('\n\n'));
+ return;
+ }
+
+ final store = PerAccountStoreWidget.of(context);
+ try {
+ // TODO(#1502) allow saving edits to an existing saved snippet as well
+ 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.errorFailedToCreateSavedSnippetTitle,
+ 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,
@@ -1398,9 +1627,6 @@ 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();
@@ -1433,10 +1659,14 @@ abstract class _ComposeBoxBody extends StatelessWidget {
borderRadius: BorderRadius.all(Radius.circular(4)))));
final composeButtonsEnabled = getComposeButtonsEnabled(context);
+ final store = PerAccountStoreWidget.of(context);
final composeButtons = [
_AttachFileButton(controller: controller, enabled: composeButtonsEnabled),
_AttachMediaButton(controller: controller, enabled: composeButtonsEnabled),
_AttachFromCameraButton(controller: controller, enabled: composeButtonsEnabled),
+ if (store.zulipFeatureLevel >= 297 // TODO(server-10) simplify
+ && controller is! SavedSnippetComposeBoxController)
+ _ShowSavedSnippetsButton(controller: controller, enabled: composeButtonsEnabled),
];
final topicInput = buildTopicInput();
@@ -1471,7 +1701,6 @@ abstract class _ComposeBoxBody extends StatelessWidget {
class _StreamComposeBoxBody extends _ComposeBoxBody {
_StreamComposeBoxBody({required this.narrow, required this.controller});
- @override
final ChannelNarrow narrow;
@override
@@ -1499,7 +1728,6 @@ class _StreamComposeBoxBody extends _ComposeBoxBody {
class _FixedDestinationComposeBoxBody extends _ComposeBoxBody {
_FixedDestinationComposeBoxBody({required this.narrow, required this.controller});
- @override
final SendableNarrow narrow;
@override
@@ -1524,7 +1752,6 @@ class _FixedDestinationComposeBoxBody extends _ComposeBoxBody {
class _EditMessageComposeBoxBody extends _ComposeBoxBody {
_EditMessageComposeBoxBody({required this.narrow, required this.controller});
- @override
final Narrow narrow;
@override
@@ -1542,6 +1769,24 @@ class _EditMessageComposeBoxBody extends _ComposeBoxBody {
@override Widget? buildSendButton() => null;
}
+class _SavedSnippetComposeBoxBody extends _ComposeBoxBody {
+ _SavedSnippetComposeBoxBody({required this.controller});
+
+ @override
+ final SavedSnippetComposeBoxController controller;
+
+ @override Widget buildTopicInput() => _SavedSnippetTitleInput(
+ controller: controller);
+
+ @override Widget buildContentInput() => _SavedSnippetContentInput(
+ controller: controller);
+
+ @override bool getComposeButtonsEnabled(BuildContext context) => true;
+
+ @override Widget? buildSendButton() => _SavedSnipppetSaveButton(
+ controller: controller);
+}
+
sealed class ComposeBoxController {
final content = ComposeContentController();
final contentFocusNode = FocusNode();
@@ -1642,6 +1887,20 @@ class EditMessageComposeBoxController extends ComposeBoxController {
String? originalRawContent;
}
+class SavedSnippetComposeBoxController extends ComposeBoxController {
+ SavedSnippetComposeBoxController();
+
+ final title = ComposeSavedSnippetTitleController();
+ final titleFocusNode = FocusNode();
+
+ @override
+ void dispose() {
+ super.dispose();
+ title.dispose();
+ titleFocusNode.dispose();
+ }
+}
+
abstract class _Banner extends StatelessWidget {
const _Banner();
@@ -1981,6 +2240,9 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM
case FixedDestinationComposeBoxController():
case EditMessageComposeBoxController():
// no reference to the store that needs updating
+ break;
+ case SavedSnippetComposeBoxController():
+ throw StateError('unexpected controller type');
}
}
@@ -2059,6 +2321,8 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM
body = _EditMessageComposeBoxBody(controller: controller, narrow: narrow);
banner = _EditMessageBanner(composeBoxState: this);
}
+ case SavedSnippetComposeBoxController():
+ throw StateError('unexpected controller type');
}
// TODO(#720) dismissable message-send error, maybe something like:
@@ -2104,3 +2368,33 @@ class ComposeBoxInheritedWidget extends InheritedWidget {
return widget!;
}
}
+
+class SavedSnippetComposeBox extends StatefulWidget {
+ const SavedSnippetComposeBox({super.key});
+
+ @override
+ State createState() => _SavedSnippetComposeBoxState();
+}
+
+class _SavedSnippetComposeBoxState extends State {
+ // TODO: preserve the controller independent from this state
+ 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 ab5ad446db..4910aaba17 100644
--- a/lib/widgets/home.dart
+++ b/lib/widgets/home.dart
@@ -293,10 +293,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 bab7b152de..41e0f369df 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -111,44 +111,50 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "message_feed".
static const IconData message_feed = IconData(0xf11d, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "message_square_text".
+ static const IconData message_square_text = IconData(0xf11e, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "mute".
- static const IconData mute = IconData(0xf11e, fontFamily: "Zulip Icons");
+ static const IconData mute = IconData(0xf11f, fontFamily: "Zulip Icons");
+
+ /// The Zulip custom icon "plus".
+ static const IconData plus = IconData(0xf120, fontFamily: "Zulip Icons");
/// The Zulip custom icon "read_receipts".
- static const IconData read_receipts = IconData(0xf11f, fontFamily: "Zulip Icons");
+ static const IconData read_receipts = IconData(0xf121, fontFamily: "Zulip Icons");
/// The Zulip custom icon "send".
- static const IconData send = IconData(0xf120, fontFamily: "Zulip Icons");
+ static const IconData send = IconData(0xf122, fontFamily: "Zulip Icons");
/// The Zulip custom icon "settings".
- static const IconData settings = IconData(0xf121, fontFamily: "Zulip Icons");
+ static const IconData settings = IconData(0xf123, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share".
- static const IconData share = IconData(0xf122, fontFamily: "Zulip Icons");
+ static const IconData share = IconData(0xf124, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share_ios".
- static const IconData share_ios = IconData(0xf123, fontFamily: "Zulip Icons");
+ static const IconData share_ios = IconData(0xf125, fontFamily: "Zulip Icons");
/// The Zulip custom icon "smile".
- static const IconData smile = IconData(0xf124, fontFamily: "Zulip Icons");
+ static const IconData smile = IconData(0xf126, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star".
- static const IconData star = IconData(0xf125, fontFamily: "Zulip Icons");
+ static const IconData star = IconData(0xf127, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star_filled".
- static const IconData star_filled = IconData(0xf126, fontFamily: "Zulip Icons");
+ static const IconData star_filled = IconData(0xf128, fontFamily: "Zulip Icons");
/// The Zulip custom icon "three_person".
- static const IconData three_person = IconData(0xf127, fontFamily: "Zulip Icons");
+ static const IconData three_person = IconData(0xf129, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf128, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf12a, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf129, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf12b, fontFamily: "Zulip Icons");
/// The Zulip custom icon "user".
- static const IconData user = IconData(0xf12a, fontFamily: "Zulip Icons");
+ static const IconData user = IconData(0xf12c, 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..653c1d4396
--- /dev/null
+++ b/lib/widgets/saved_snippet.dart
@@ -0,0 +1,265 @@
+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.values.sortedBy((x) => x.title); // TODO(#1399)
+ return 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 overlayColor = WidgetStateColor.fromMap({
+ // TODO(design) check if these are the right colors
+ WidgetState.hovered: designVariables.pressedTint,
+ WidgetState.pressed: designVariables.pressedTint,
+ WidgetState.any: Colors.transparent,
+ });
+
+ return Material(
+ color: designVariables.bgContextMenu,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ InkWell(
+ splashFactory: NoSplash.splashFactory,
+ overlayColor: overlayColor,
+ onTap: () => Navigator.of(context).pop(),
+ child: Padding(
+ padding: EdgeInsetsDirectional.fromSTEB(16, 10, 8, 6),
+ child: Text(zulipLocalizations.dialogClose,
+ style: textStyle.merge(
+ weightVariableTextStyle(context, wght: 400))))),
+
+ // TODO(#1501) support search box
+ Expanded(child: Padding(
+ padding: EdgeInsets.only(top: 10, bottom: 6),
+ child: Text(zulipLocalizations.savedSnippetsTitle,
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ color: designVariables.title,
+ fontSize: 20,
+ height: 30 / 20,
+ ).merge(weightVariableTextStyle(context, wght: 600))))),
+
+ InkWell(
+ splashFactory: NoSplash.splashFactory,
+ overlayColor: overlayColor,
+ onTap: () => showNewSavedSnippetComposeBox(context: context),
+ child: Padding(
+ padding: EdgeInsetsDirectional.fromSTEB(3, 10, 10, 6),
+ child: Row(
+ spacing: 4,
+ children: [
+ Icon(ZulipIcons.plus, size: 24, color: designVariables.icon),
+ 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 Material(
+ color: designVariables.bgContextMenu,
+ child: Stack(
+ children: [
+ Center(child: Padding(
+ padding: EdgeInsets.only(top: 10, bottom: 6),
+ child: Text(zulipLocalizations.newSavedSnippetTitle,
+ style: TextStyle(
+ color: designVariables.title,
+ fontSize: 20,
+ height: 30 / 20,
+ ).merge(weightVariableTextStyle(context, wght: 600))))),
+ PositionedDirectional(
+ end: 0,
+ child: InkWell(
+ splashFactory: NoSplash.splashFactory,
+ overlayColor: WidgetStateProperty.fromMap({
+ // TODO(design) check if these are the right colors
+ WidgetState.pressed: designVariables.pressedTint,
+ WidgetState.hovered: designVariables.pressedTint,
+ WidgetState.any: Colors.transparent,
+ }),
+ onTap: () => Navigator.of(context).pop(),
+ child: Padding(
+ padding: EdgeInsetsDirectional.fromSTEB(8, 10, 16, 6),
+ 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 276e308b2b..cd045237c0 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,
@@ -164,6 +167,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),
@@ -224,6 +228,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),
@@ -292,6 +297,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,
@@ -361,6 +367,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;
@@ -425,6 +432,7 @@ class DesignVariables extends ThemeExtension {
Color? labelEdited,
Color? labelMenuButton,
Color? mainBackground,
+ Color? pressedTint,
Color? textInput,
Color? title,
Color? bgSearchInput,
@@ -484,6 +492,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,
@@ -550,6 +559,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/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart
index 679f4de190..98652623b4 100644
--- a/test/widgets/compose_box_test.dart
+++ b/test/widgets/compose_box_test.dart
@@ -14,6 +14,7 @@ import 'package:zulip/api/model/events.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/route/channels.dart';
import 'package:zulip/api/route/messages.dart';
+import 'package:zulip/api/route/saved_snippets.dart';
import 'package:zulip/model/localizations.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/model/store.dart';
@@ -35,6 +36,7 @@ import '../model/store_checks.dart';
import '../model/test_store.dart';
import '../model/typing_status_test.dart';
import '../stdlib_checks.dart';
+import '../test_navigation.dart';
import 'compose_box_checks.dart';
import 'dialog_checks.dart';
import 'test_app.dart';
@@ -1086,6 +1088,162 @@ void main() {
skip: Platform.isWindows);
});
+ testWidgets('_ShowSavedSnippetsButton', (tester) async {
+ final channel = eg.stream();
+ await prepareComposeBox(tester, narrow: ChannelNarrow(channel.streamId),
+ streams: [channel]);
+
+ check(find.byIcon(ZulipIcons.message_square_text)).findsOne();
+ });
+
+ testWidgets('legacy: _ShowSavedSnippetsButton', (tester) async {
+ final channel = eg.stream();
+ await prepareComposeBox(tester, narrow: ChannelNarrow(channel.streamId),
+ streams: [channel],
+ zulipFeatureLevel: 296);
+
+ check(find.byIcon(ZulipIcons.message_square_text)).findsNothing();
+ });
+
+ // Tests for the bottom sheet that _ShowSavedSnippetsButton leads to
+ // are in test/widgets/saved_snippet_test.dart.
+
+ group('SavedSnippetComposeBox', () {
+ final newSavedSnippetInputFinder = find.descendant(
+ of: find.byType(SavedSnippetComposeBox), matching: find.byType(TextField));
+
+ late List> poppedRoutes;
+
+ Future prepareSavedSnippetComposeBox(WidgetTester tester, {
+ required String title,
+ required String content,
+ }) async {
+ addTearDown(testBinding.reset);
+ await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+ store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+ connection = store.connection as FakeApiConnection;
+
+ poppedRoutes = [];
+ final navigatorObserver = TestNavigatorObserver()
+ ..onPopped = (route, prevRoute) => poppedRoutes.add(route);
+ await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
+ navigatorObservers: [navigatorObserver],
+ child: const SavedSnippetComposeBox()));
+ await tester.pump();
+ await tester.enterText(newSavedSnippetInputFinder.first, title);
+ await tester.enterText(newSavedSnippetInputFinder.last, content);
+ }
+
+ testWidgets('should not offer _ShowSavedSnippetsButton', (tester) async {
+ await prepareSavedSnippetComposeBox(tester,
+ title: 'title foo', content: 'content bar');
+ check(find.byIcon(ZulipIcons.message_square_text)).findsNothing();
+ });
+
+ testWidgets('add new saved snippet', (tester) async {
+ await prepareSavedSnippetComposeBox(tester,
+ title: 'title foo', content: 'content bar');
+
+ connection.prepare(json:
+ CreateSavedSnippetResult(savedSnippetId: 123).toJson());
+ await tester.tap(find.byIcon(ZulipIcons.check));
+ await tester.pump(Duration.zero);
+ check(poppedRoutes).single;
+ check(connection.takeRequests()).single.isA()
+ ..bodyFields['title'].equals('title foo')
+ ..bodyFields['content'].equals('content bar');
+ checkNoErrorDialog(tester);
+
+ await store.handleEvent(SavedSnippetsAddEvent(id: 100,
+ savedSnippet: eg.savedSnippet(
+ id: 123, title: 'title foo', content: 'content bar')));
+ await tester.pump();
+ check(find.text('title foo')).findsOne();
+ check(find.text('content bar')).findsOne();
+ });
+
+ testWidgets('handle unexpected API exception', (tester) async {
+ await prepareSavedSnippetComposeBox(tester,
+ title: 'title foo', content: 'content bar');
+
+ connection.prepare(apiException: eg.apiExceptionUnauthorized());
+ await tester.tap(find.byIcon(ZulipIcons.check));
+ await tester.pump(Duration.zero);
+ check(poppedRoutes).isEmpty();
+ checkErrorDialog(tester,
+ expectedTitle: 'Failed to create saved snippet',
+ expectedMessage: 'The server said:\n\nInvalid API key');
+ });
+
+ group('client validation errors', () {
+ testWidgets('empty title', (tester) async {
+ await prepareSavedSnippetComposeBox(tester,
+ title: '', content: 'content bar');
+
+ await tester.tap(find.byIcon(ZulipIcons.check));
+ await tester.pump(Duration.zero);
+ check(poppedRoutes).isEmpty();
+ check(connection.takeRequests()).isEmpty();
+ checkErrorDialog(tester,
+ expectedTitle: 'Failed to create saved snippet',
+ expectedMessage: 'Title cannot be empty.');
+ });
+
+ testWidgets('empty content', (tester) async {
+ await prepareSavedSnippetComposeBox(tester,
+ title: 'title foo', content: '');
+
+ await tester.tap(find.byIcon(ZulipIcons.check));
+ await tester.pump(Duration.zero);
+ check(poppedRoutes).isEmpty();
+ check(connection.takeRequests()).isEmpty();
+ checkErrorDialog(tester,
+ expectedTitle: 'Failed to create saved snippet',
+ expectedMessage: 'Content cannot be empty.');
+ });
+
+ testWidgets('title is too long', (tester) async {
+ await prepareSavedSnippetComposeBox(tester,
+ title: 'a' * 61, content: 'content bar');
+
+ await tester.tap(find.byIcon(ZulipIcons.check));
+ await tester.pump(Duration.zero);
+ check(poppedRoutes).isEmpty();
+ check(connection.takeRequests()).isEmpty();
+ checkErrorDialog(tester,
+ expectedTitle: 'Failed to create saved snippet',
+ expectedMessage: "Title length shouldn't be greater than 60 characters.");
+ });
+
+ testWidgets('content is too long', (tester) async {
+ await prepareSavedSnippetComposeBox(tester,
+ title: 'title foo', content: 'a' * 10001);
+
+ await tester.tap(find.byIcon(ZulipIcons.check));
+ await tester.pump(Duration.zero);
+ check(poppedRoutes).isEmpty();
+ check(connection.takeRequests()).isEmpty();
+ checkErrorDialog(tester,
+ expectedTitle: 'Failed to create saved snippet',
+ expectedMessage: "Content length shouldn't be greater than 10000 characters.");
+ });
+
+ testWidgets('disable send button if there are validation errors', (tester) async {
+ await prepareSavedSnippetComposeBox(tester,
+ title: '', content: 'content bar');
+ final iconElement = tester.element(find.byIcon(ZulipIcons.check));
+ final designVariables = DesignVariables.of(iconElement);
+ check(iconElement.widget).isA().color.isNotNull()
+ .isSameColorAs(designVariables.icon.withFadedAlpha(0.5));
+
+ await tester.enterText(newSavedSnippetInputFinder.first, 'title foo');
+ await tester.pump();
+ check(iconElement.widget).isA().color.isNotNull()
+ .isSameColorAs(designVariables.icon);
+ });
+ });
+ });
+
group('error banner', () {
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
diff --git a/test/widgets/saved_snippet_test.dart b/test/widgets/saved_snippet_test.dart
new file mode 100644
index 0000000000..b3d0b3424f
--- /dev/null
+++ b/test/widgets/saved_snippet_test.dart
@@ -0,0 +1,117 @@
+import 'package:checks/checks.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_checks/flutter_checks.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/model/model.dart';
+import 'package:zulip/model/typing_status.dart';
+import 'package:zulip/widgets/compose_box.dart';
+import 'package:zulip/widgets/icons.dart';
+
+import '../flutter_checks.dart';
+import '../model/binding.dart';
+import '../model/test_store.dart';
+import 'test_app.dart';
+
+import '../example_data.dart' as eg;
+
+void main() {
+ TestZulipBinding.ensureInitialized();
+
+ Future prepare(WidgetTester tester, {
+ required List savedSnippets,
+ }) async {
+ addTearDown(testBinding.reset);
+ final account = eg.account(
+ user: eg.selfUser, zulipFeatureLevel: eg.futureZulipFeatureLevel);
+ await testBinding.globalStore.add(account, eg.initialSnapshot(
+ savedSnippets: savedSnippets,
+ zulipFeatureLevel: eg.futureZulipFeatureLevel,
+ ));
+ final store = await testBinding.globalStore.perAccount(account.id);
+ final channel = eg.stream();
+ await store.addStream(channel);
+ await store.addSubscription(eg.subscription(channel));
+ await store.addUser(eg.selfUser);
+
+ await tester.pumpWidget(TestZulipApp(
+ accountId: account.id,
+ child: ComposeBox(narrow: eg.topicNarrow(channel.streamId, 'test'))));
+ await tester.pumpAndSettle();
+ }
+
+ Future tapShowSavedSnippets(WidgetTester tester) async {
+ await tester.tap(find.byIcon(ZulipIcons.message_square_text));
+ await tester.pump();
+ await tester.pump(Duration(milliseconds: 250)); // bottom-sheet animation
+ }
+
+ testWidgets('show placeholder when empty', (tester) async {
+ await prepare(tester, savedSnippets: []);
+
+ await tapShowSavedSnippets(tester);
+ check(find.text('No saved snippets')).findsOne();
+ });
+
+ testWidgets('sort saved snippets by title', (tester) async {
+ const content = 'saved snippet content';
+ await prepare(tester, savedSnippets: [
+ eg.savedSnippet(title: 'zzz', content: content),
+ eg.savedSnippet(title: '1abc', content: content),
+ eg.savedSnippet(title: '1b', content: content),
+ ]);
+ Finder findTitleAt(int index) => find.descendant(
+ of: find.ancestor(of: find.text(content).at(index),
+ matching: find.byType(Column)),
+ matching: find.byType(Text)).first;
+
+ await tapShowSavedSnippets(tester);
+ check(
+ List.generate(3, (i) => tester.widget(findTitleAt(i))),
+ ).deepEquals(>[
+ (it) => it.isA().data.equals('1abc'),
+ (it) => it.isA().data.equals('1b'),
+ (it) => it.isA().data.equals('zzz'),
+ ]);
+ });
+
+ testWidgets('insert into content input', (tester) async {
+ addTearDown(TypingNotifier.debugReset);
+ TypingNotifier.debugEnable = false;
+ await prepare(tester, savedSnippets: [
+ eg.savedSnippet(
+ title: 'saved snippet title',
+ content: 'saved snippet content'),
+ ]);
+
+ await tapShowSavedSnippets(tester);
+ check(find.text('saved snippet title')).findsOne();
+ check(find.text('saved snippet content')).findsOne();
+
+ await tester.tap(find.text('saved snippet content'));
+ await tester.pump();
+ await tester.pump(Duration(milliseconds: 250)); // bottom-sheet animation
+ check(find.descendant(
+ of: find.byType(ComposeBox),
+ matching: find.textContaining('saved snippet content')),
+ ).findsOne();
+ });
+
+ testWidgets('insert into non-empty content input', (tester) async {
+ addTearDown(TypingNotifier.debugReset);
+ TypingNotifier.debugEnable = false;
+ await prepare(tester, savedSnippets: [
+ eg.savedSnippet(content: 'saved snippet content'),
+ ]);
+ await tester.enterText(find.byType(TextField), 'some existing content');
+
+ await tapShowSavedSnippets(tester);
+ await tester.tap(find.text('saved snippet content'));
+ await tester.pump();
+ await tester.pump(Duration(milliseconds: 250)); // bottom-sheet animation
+ check(find.descendant(
+ of: find.byType(ComposeBox),
+ matching: find.textContaining('some existing content\n\n'
+ 'saved snippet content')),
+ ).findsOne();
+ });
+}