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