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(); + }); +}