From 9aba14cc8aba3df27945fc3c46e897bb9b91d2e9 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 3 Mar 2025 15:58:37 -0500 Subject: [PATCH 01/15] theme [nfc]: Follow clipBehavior from zulipTheme The theme already applies to all of our bottom sheets, and all of them uses Clip.antiAlias already. Signed-off-by: Zixuan James Li --- lib/widgets/action_sheet.dart | 4 ---- lib/widgets/emoji_reaction.dart | 4 ---- lib/widgets/home.dart | 4 ---- lib/widgets/theme.dart | 3 +++ 4 files changed, 3 insertions(+), 12 deletions(-) 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/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/theme.dart b/lib/widgets/theme.dart index cc2c51fe20..c7cd80fe9b 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, From 027fd9f260b10155b0588e2d0d4d0666a4e99bb1 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 30 Jan 2025 15:08:30 -0500 Subject: [PATCH 02/15] compose [nfc]: Extract _ComposeButton Not all buttons are about uploading files, while most of the buttons on the compose box share the same design. Extract that part for later use. Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 37 +++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 7f47046d11..9954f4e659 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -770,14 +770,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 +805,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 +824,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 { From 6dbeb3bf3f0e8be173e9956113649d50441ecaee Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 6 Mar 2025 14:16:22 -0500 Subject: [PATCH 03/15] compose [nfc]: Extract _ContentTextField and _TitleTextField Both widgets are agnostic to any specific compose controller, so that we can reuse the styling elsewhere. Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 205 +++++++++++++++++++++-------------- 1 file changed, 123 insertions(+), 82 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 9954f4e659..0a69a06889 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -376,6 +376,86 @@ class ComposeContentController extends ComposeController } } +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 +546,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 +618,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 +638,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)); } } From a9ec651f62c418a14e0618a04344ddb5d1ac25a1 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 6 Mar 2025 14:30:34 -0500 Subject: [PATCH 04/15] compose [nfc]: Extract _ComposeButtonRow Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 57 +++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 0a69a06889..a06ffabc2b 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1026,6 +1026,28 @@ 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 composeButtons = [ + _AttachFileButton(controller: controller), + _AttachMediaButton(controller: controller), + _AttachFromCameraButton(controller: controller), + ]; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: composeButtons), + sendButton, + ]); + } +} + class _SendButton extends StatefulWidget { const _SendButton({required this.controller, required this.getDestination}); @@ -1228,11 +1250,9 @@ 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 buildComposeButtonRow(); @override Widget build(BuildContext context) { @@ -1258,12 +1278,6 @@ 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(); return Column(children: [ Padding( @@ -1278,12 +1292,7 @@ abstract class _ComposeBoxBody extends StatelessWidget { height: _composeButtonSize, child: IconButtonTheme( data: iconButtonThemeData, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row(children: composeButtons), - buildSendButton(), - ]))), + child: buildComposeButtonRow())), ]); } } @@ -1298,7 +1307,6 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { @override final ChannelNarrow narrow; - @override final StreamComposeBoxController controller; @override Widget buildTopicInput() => _TopicInput( @@ -1311,11 +1319,12 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { 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 { @@ -1324,7 +1333,6 @@ class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { @override final SendableNarrow narrow; - @override final FixedDestinationComposeBoxController controller; @override Widget? buildTopicInput() => null; @@ -1334,10 +1342,11 @@ class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { controller: controller, ); - @override Widget buildSendButton() => _SendButton( + @override Widget buildComposeButtonRow() => _ComposeButtonRow( controller: controller, - getDestination: () => narrow.destination, - ); + sendButton: _SendButton( + controller: controller, + getDestination: () => narrow.destination)); } sealed class ComposeBoxController { From 8abecbf7f437fce6b04b4597d355fe78dac31c1a Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 6 Mar 2025 14:33:53 -0500 Subject: [PATCH 05/15] compose [nfc]: Remove narrow from _ComposeBoxBody Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index a06ffabc2b..baea6a885b 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1247,9 +1247,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; - Widget? buildTopicInput(); Widget buildContentInput(); Widget buildComposeButtonRow(); @@ -1304,7 +1301,6 @@ abstract class _ComposeBoxBody extends StatelessWidget { class _StreamComposeBoxBody extends _ComposeBoxBody { _StreamComposeBoxBody({required this.narrow, required this.controller}); - @override final ChannelNarrow narrow; final StreamComposeBoxController controller; @@ -1330,7 +1326,6 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { _FixedDestinationComposeBoxBody({required this.narrow, required this.controller}); - @override final SendableNarrow narrow; final FixedDestinationComposeBoxController controller; From ffa650ca3f10207009a5f3a25e3ed967f5393b52 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 7 Mar 2025 17:44:07 -0500 Subject: [PATCH 06/15] compose [nfc]: Make BuildContext available to build methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This makes inline implementations for these build… methods possible, without defining a separate StatelessWidget class. Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index baea6a885b..951f28f029 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1247,9 +1247,9 @@ class _ComposeBoxContainer extends StatelessWidget { /// The text inputs, compose-button row, and send button for the compose box. abstract class _ComposeBoxBody extends StatelessWidget { - Widget? buildTopicInput(); - Widget buildContentInput(); - Widget buildComposeButtonRow(); + Widget? buildTopicInput(BuildContext context); + Widget buildContentInput(BuildContext context); + Widget buildComposeButtonRow(BuildContext context); @override Widget build(BuildContext context) { @@ -1275,7 +1275,7 @@ abstract class _ComposeBoxBody extends StatelessWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(4))))); - final topicInput = buildTopicInput(); + final topicInput = buildTopicInput(context); return Column(children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1283,13 +1283,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: buildComposeButtonRow())), + child: buildComposeButtonRow(context))), ]); } } @@ -1305,17 +1305,17 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { 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 buildComposeButtonRow() => _ComposeButtonRow( + @override Widget buildComposeButtonRow(_) => _ComposeButtonRow( controller: controller, sendButton: _SendButton( controller: controller, @@ -1330,14 +1330,14 @@ class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { 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 buildComposeButtonRow() => _ComposeButtonRow( + @override Widget buildComposeButtonRow(_) => _ComposeButtonRow( controller: controller, sendButton: _SendButton( controller: controller, From 7f398cb09664135658a056e3e8a1fd5c5311b92c Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 3 Mar 2025 15:19:49 -0500 Subject: [PATCH 07/15] compose test [nfc]: Support setting zulipFeatureLevel This will later become useful for tetsing saved snippets, a feature only available after server 10. Signed-off-by: Zixuan James Li --- test/widgets/compose_box_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 52d2d1c851..4b195481fc 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -47,6 +47,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 +55,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); From 0767d0fef100b6cc4491bd2f7ab04f3d01f730a1 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 30 Jan 2025 14:59:36 -0500 Subject: [PATCH 08/15] icons: Add icons for "saved snippets" The icons taken are from the Figma design: plus: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4912-31325&m=dev message-square-text: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=7965-53132&m=dev Signed-off-by: Zixuan James Li --- assets/icons/ZulipIcons.ttf | Bin 13840 -> 14256 bytes assets/icons/message_square_text.svg | 3 +++ assets/icons/plus.svg | 3 +++ lib/widgets/icons.dart | 32 ++++++++++++++++----------- 4 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 assets/icons/message_square_text.svg create mode 100644 assets/icons/plus.svg diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index df2c4ab94724dc1d2f78fde5515534a5c5dad87a..2c274ae9572e9f8fe00655c5c6803cb20c28215b 100644 GIT binary patch delta 2129 zcmb7FU1(cn7=FK#pZ+XqlQc4fFKPM9XM3x6f$79;$x8pw%*>qUFGFvGvx8E%P zMP&a3d)oZs`MKfrJ3b=E0#VPkg>vcS+23z}0oz}I^a3^-g>?+;8$kC$rMkZH{PrXG zzK9cd!&!U2#VeoFHw7x7}5@{&+;=S{9sZ#b`FK9$byepaIrPb=b5C1L` zrHh>ykh$`R#9`ZK~XwPFH@Bia!~|NlwuU8B3HHurDL6iUZaEHC5oL4 zf?LtTKI*}4JIdDJ!FCx$^}>o%aVb8mHOj$jl>G3~z(JIhnylQ7%9+*?gp8xZJczc* zQgU$RVminJ3d2ax^l|Vo^qjtpn*Q70;j01uy-0S1PS6}J)3cKPFmmir&xesJFvZwm#1<;2Mn*_SI%90{rz=e2+X$px~=mNNL zN=AlcgBr5zPDK+rYEsE3#0njqM`;Msfg+gb`pEDZH-LpO^h|CTS}uvFU=Y34x#G1G zh48@)%bBrbAo+NCh;@2_PSZL)4aRiD%OXlY)wu6027yPW&H_)vD4h0VTy_oFj%T-? zlF55Y2Hgq7`V_RR*=O=Hf@8BHGDqYQeg;V5y*{vB>LYs=J?2f55Y$yQW;Iop2GW=z>WGA`O0jel&l*nRl(-(7Uit1x6Uu8RAJ ze+%!;U{Ap_O{eJmy^wL$9!$hxl|zOE@~6Pd01kf;#pY2T^I1eySu`?&(}{teVdH^) zyrGXmh-){5y|f<@a)%Ons(%2U$-EXV6eHR6$WUx_*!ZsDO~Ylk)f~u9mID9cZxom| z5r-_92t&@8$U)AUI0SjpL;@1mn*`svb0$)d^CoaN6Yg7y0mxG|{sA(;t0o2^7fmD~ zD<<-gr%m8qr6m(l$Ym1+$TKF2kSiu4kgF!LkW~|TT-dZ`f=>|_y2KFVITJC+bra0c zc@x8%?=@`+|GhpU28M_3fqe#rjMw%I9lh)Y9$L=dl>fToGWz{33ew#dM z0WY6=RMqo%(xX}?;B;fgfxQj48u!%QCOE*ACR?-5scfjRuMs+^svp3qTUBZE%dU`k zQGO7XNy{X@Y7%-6_Yvr_d;Q6P+w13U&=ML7C?R-l%k+Y<+^NJ8;)=I<>j~}V-Jo#c yAL&-|ZL1g)hmESf-O*MludbHn%hRi8)=Deo>1uhsYOSnQ%hu(^wN+z1b@^Y)OEKmE delta 1729 zcmb7^OKe+36o$|BD=#N;(j>GEP2Hw}CU)%W>)5`rpy4a z&c~}4SL$MkNSnMVez6~!eQx^M`DdacmMh}F{?u&JIeK&UOOe2DSiQ1Pt<)af|Dq=n zIxZ6ITUyyTG57nCT_WLt$k4^*#me#XcW!)w?QM`MooZ#hX1#1>QSN3PJyoeL_I_G&L{gVUe7V}{*|j@={CrC! z{Wa_9OVNQ@DeQT9^eNLt32#UvwH$ualSpdE{y{UG~ej1d{fE`eW0`&-gl z|Cd8jhqfgkO**iv1KaQp$N)4Q0wi@phd>V6N$gyRg=9CQ8NCgaGBS&P4}NhhC$vRW zIw>Yjz5;qhyo6;v&eV|(Ld4;e(iBv{s62pq4s(Z*AeF=1MQ_W4^d-(thTs8dmA%+H zav!v9Jd|CAs6AMzREkP3b6fIwO-mm>Ha&T{ON4msELe%-i6rd2VoO-!N~0$XsA<`-4?x}e#3h@)LsuOp8mt?pWif? zH%K5W1|!H<4f4nZgK;DeqDB(AXu!M0MQtFBTr%K&lVyV(@?>58fed)fU;?>fkU~}s z3P>JI4I8;?5J%PwCXuHNO2{(?G2~eTUU(kA28v@$&KZ=E=M9R;*9}II>jtVr8wOLG z-?eO6-mSLckzc~^hd=8$)^WEp-}zM}5n1WVcD>bYcYpE!$L4bQ^S;er2M=0TPY*?S Y)X!wz_WsP=3kIs^))u`zx%Vvj8?;^T(f|Me 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/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 } From 88a0440c9a2ed3fd4cbe1b9c313234fceba37a85 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 30 Jan 2025 14:39:21 -0500 Subject: [PATCH 09/15] api: Add savedSnippets to initial snapshot Signed-off-by: Zixuan James Li --- lib/api/model/initial_snapshot.dart | 3 +++ lib/api/model/initial_snapshot.g.dart | 5 +++++ lib/api/model/model.dart | 24 ++++++++++++++++++++++++ lib/api/model/model.g.dart | 15 +++++++++++++++ test/example_data.dart | 2 ++ 5 files changed, 49 insertions(+) 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/test/example_data.dart b/test/example_data.dart index 03cabbda97..6280e0d01b 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -910,6 +910,7 @@ InitialSnapshot initialSnapshot({ int? serverTypingStartedWaitPeriodMilliseconds, Map? realmEmoji, List? recentPrivateConversations, + List? savedSnippets, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, List? streams, @@ -943,6 +944,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 From d36bcff226be4e75f8ca56807ec3f70ce97ae415 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 11 Mar 2025 14:31:34 -0400 Subject: [PATCH 10/15] api: Add save_snippet events and handle live-updates Signed-off-by: Zixuan James Li --- lib/api/model/events.dart | 54 ++++++++++++++++++++++++++++++++ lib/api/model/events.g.dart | 32 +++++++++++++++++++ lib/model/saved_snippet.dart | 27 ++++++++++++++++ lib/model/store.dart | 16 +++++++++- test/api/model/model_checks.dart | 4 +++ test/example_data.dart | 22 +++++++++++++ test/model/saved_snippet.dart | 26 +++++++++++++++ test/model/store_checks.dart | 1 + 8 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 lib/model/saved_snippet.dart create mode 100644 test/model/saved_snippet.dart 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/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/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 6280e0d01b..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. // 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'); From 943f3ac3aa8898ec2dac0b1f4dec2fce33218249 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 6 Mar 2025 14:23:52 -0500 Subject: [PATCH 11/15] api: Add createSavedSnippet route Signed-off-by: Zixuan James Li --- lib/api/route/saved_snippets.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 lib/api/route/saved_snippets.dart 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), + }); +} From d10140aad6c08647ef351f7d4fcc58bbed471eb8 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 14 Mar 2025 18:56:25 -0400 Subject: [PATCH 12/15] compose [nfc]: Make maxLengthUnicodeCodePoints nullable. This will allow controller implementations that do not support validation errors akin to "*TooLong" to set this to null. --- lib/widgets/compose_box.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 951f28f029..bfe95e1fd9 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -81,7 +81,7 @@ const double _composeButtonSize = 44; /// /// Subclasses must ensure that [_update] is called in all exposed constructors. abstract class ComposeController extends TextEditingController { - int get maxLengthUnicodeCodePoints; + int? get maxLengthUnicodeCodePoints; String get textNormalized => _textNormalized; late String _textNormalized; @@ -102,7 +102,8 @@ abstract class ComposeController extends TextEditingController { @visibleForTesting int? get debugLengthUnicodeCodePointsIfLong => _lengthUnicodeCodePointsIfLong; int? _computeLengthUnicodeCodePointsIfLong() => - _textNormalized.length > maxLengthUnicodeCodePoints + maxLengthUnicodeCodePoints != null + && _textNormalized.length > maxLengthUnicodeCodePoints! ? _textNormalized.runes.length : null; @@ -152,7 +153,7 @@ class ComposeTopicController extends ComposeController { bool get mandatory => store.realmMandatoryTopics; // TODO(#307) use `max_topic_length` instead of hardcoded limit - @override final maxLengthUnicodeCodePoints = kMaxTopicLengthCodePoints; + @override final int maxLengthUnicodeCodePoints = kMaxTopicLengthCodePoints; @override String _computeTextNormalized() { @@ -213,7 +214,7 @@ class ComposeContentController extends ComposeController } // TODO(#1237) use `max_message_length` instead of hardcoded limit - @override final maxLengthUnicodeCodePoints = kMaxMessageLengthCodePoints; + @override final int maxLengthUnicodeCodePoints = kMaxMessageLengthCodePoints; int _nextQuoteAndReplyTag = 0; int _nextUploadTag = 0; From 1ceaef292b70cb0b191771d67bf6741ec8f8a459 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 30 Jan 2025 16:02:08 -0500 Subject: [PATCH 13/15] wip; compose: Support save snippets TODO: look for a better way to arrange the new compose box We sort the saved snippets by title to be consistent with the web implementation. A subtle nuance of having a compose box in a modal bottom sheet is that the message list page behind will shift when the keyboard is expanded, even though its compose box is not visible anyway. An UX improvement would be preserving the inputs on the saved snippet compose box after the user has navigated away. Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 36 +++ lib/generated/l10n/zulip_localizations.dart | 54 ++++ .../l10n/zulip_localizations_ar.dart | 27 ++ .../l10n/zulip_localizations_en.dart | 27 ++ .../l10n/zulip_localizations_ja.dart | 27 ++ .../l10n/zulip_localizations_nb.dart | 27 ++ .../l10n/zulip_localizations_pl.dart | 27 ++ .../l10n/zulip_localizations_ru.dart | 27 ++ .../l10n/zulip_localizations_sk.dart | 27 ++ lib/widgets/compose_box.dart | 247 ++++++++++++++++++ lib/widgets/saved_snippet.dart | 244 +++++++++++++++++ lib/widgets/theme.dart | 7 + test/widgets/compose_box_test.dart | 105 ++++++++ test/widgets/saved_snippet_test.dart | 120 +++++++++ 14 files changed, 1002 insertions(+) create mode 100644 lib/widgets/saved_snippet.dart create mode 100644 test/widgets/saved_snippet_test.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 7f37a00dad..294be81327 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -341,6 +341,42 @@ "@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." + }, + "savedSnippetContentValidationErrorEmpty": "Content cannot be empty.", + "@savedSnippetContentValidationErrorEmpty": { + "description": "Validation error message when the content of the saved snippet is empty." + }, "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 2f03458e05..552f5f8175 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -561,6 +561,60 @@ 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 content of the saved snippet is empty. + /// + /// In en, this message translates to: + /// **'Content cannot be empty.'** + String get savedSnippetContentValidationErrorEmpty; + /// 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..78e1d9ce4c 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -270,6 +270,33 @@ 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 savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.'; + @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..bcecfd17c2 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -270,6 +270,33 @@ 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 savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.'; + @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..a9fafd4f59 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -270,6 +270,33 @@ 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 savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.'; + @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..913bed41e5 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -270,6 +270,33 @@ 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 savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.'; + @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..6a78bcc3be 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -270,6 +270,33 @@ 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 savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.'; + @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..dfdcc6bf3f 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -270,6 +270,33 @@ 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 savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.'; + @override String get composeBoxGenericContentHint => 'Ввести сообщение'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 9ec68077ae..ed777c1847 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -270,6 +270,33 @@ 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 savedSnippetContentValidationErrorEmpty => 'Content cannot be empty.'; + @override String get composeBoxGenericContentHint => 'Type a message'; diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index bfe95e1fd9..3bb3353b6b 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'; @@ -377,6 +379,68 @@ class ComposeContentController extends ComposeController } } +enum SavedSnippetTitleValidationError { + empty; + + String message(ZulipLocalizations zulipLocalizations) { + return switch (this) { + SavedSnippetTitleValidationError.empty => zulipLocalizations.savedSnippetTitleValidationErrorEmpty, + }; + } +} + +class SavedSnippetTitleComposeController extends ComposeController { + SavedSnippetTitleComposeController() { + _update(); + } + + @override final maxLengthUnicodeCodePoints = null; + + @override + String _computeTextNormalized() { + return text.trim(); + } + + @override + List _computeValidationErrors() { + return [ + if (textNormalized.isEmpty) + SavedSnippetTitleValidationError.empty, + ]; + } +} + +enum SavedSnippetContentValidationError { + empty; + + String message(ZulipLocalizations zulipLocalizations) { + return switch (this) { + SavedSnippetContentValidationError.empty => zulipLocalizations.savedSnippetContentValidationErrorEmpty, + }; + } +} + +class SavedSnippetContentComposeController extends ComposeController { + SavedSnippetContentComposeController() { + _update(); + } + + @override final maxLengthUnicodeCodePoints = null; + + @override + String _computeTextNormalized() { + return text.trim(); + } + + @override + List _computeValidationErrors() { + return [ + if (textNormalized.isEmpty) + SavedSnippetContentValidationError.empty, + ]; + } +} + class _ContentTextField extends StatelessWidget { const _ContentTextField({ required this.controller, @@ -1035,10 +1099,13 @@ class _ComposeButtonRow extends StatelessWidget { @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, @@ -1049,6 +1116,22 @@ class _ComposeButtonRow extends StatelessWidget { } } +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}); @@ -1185,6 +1268,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, @@ -1345,6 +1520,32 @@ class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { 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 { final content = ComposeContentController(); final contentFocusNode = FocusNode(); @@ -1373,6 +1574,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}); @@ -1529,3 +1746,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/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 c7cd80fe9b..969435b224 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -157,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), @@ -207,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), @@ -265,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, @@ -324,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; @@ -378,6 +382,7 @@ class DesignVariables extends ThemeExtension { Color? labelEdited, Color? labelMenuButton, Color? mainBackground, + Color? pressedTint, Color? textInput, Color? title, Color? bgSearchInput, @@ -427,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, @@ -483,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/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 4b195481fc..59f10189fa 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'; @@ -843,6 +844,110 @@ 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('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(); + }); +} From 299c53e9669f198051a0e2388b2cae6a0c363e32 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 14 Mar 2025 19:01:38 -0400 Subject: [PATCH 14/15] revert? "compose [nfc]: Make maxLengthUnicodeCodePoints nullable." This reverts commit d10140aad6c08647ef351f7d4fcc58bbed471eb8. --- lib/widgets/compose_box.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 3bb3353b6b..2a1b0006ce 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -83,7 +83,7 @@ const double _composeButtonSize = 44; /// /// Subclasses must ensure that [_update] is called in all exposed constructors. abstract class ComposeController extends TextEditingController { - int? get maxLengthUnicodeCodePoints; + int get maxLengthUnicodeCodePoints; String get textNormalized => _textNormalized; late String _textNormalized; @@ -104,8 +104,7 @@ abstract class ComposeController extends TextEditingController { @visibleForTesting int? get debugLengthUnicodeCodePointsIfLong => _lengthUnicodeCodePointsIfLong; int? _computeLengthUnicodeCodePointsIfLong() => - maxLengthUnicodeCodePoints != null - && _textNormalized.length > maxLengthUnicodeCodePoints! + _textNormalized.length > maxLengthUnicodeCodePoints ? _textNormalized.runes.length : null; @@ -155,7 +154,7 @@ class ComposeTopicController extends ComposeController { bool get mandatory => store.realmMandatoryTopics; // TODO(#307) use `max_topic_length` instead of hardcoded limit - @override final int maxLengthUnicodeCodePoints = kMaxTopicLengthCodePoints; + @override final maxLengthUnicodeCodePoints = kMaxTopicLengthCodePoints; @override String _computeTextNormalized() { @@ -216,7 +215,7 @@ class ComposeContentController extends ComposeController } // TODO(#1237) use `max_message_length` instead of hardcoded limit - @override final int maxLengthUnicodeCodePoints = kMaxMessageLengthCodePoints; + @override final maxLengthUnicodeCodePoints = kMaxMessageLengthCodePoints; int _nextQuoteAndReplyTag = 0; int _nextUploadTag = 0; From 18dae84f6a8ff302f275a02a087de51b69b11408 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 14 Mar 2025 19:03:30 -0400 Subject: [PATCH 15/15] squash into main commit? saved_snippet: Check for title/content length --- assets/l10n/app_en.arb | 8 ++++++ lib/generated/l10n/zulip_localizations.dart | 12 +++++++++ .../l10n/zulip_localizations_ar.dart | 6 +++++ .../l10n/zulip_localizations_en.dart | 6 +++++ .../l10n/zulip_localizations_ja.dart | 6 +++++ .../l10n/zulip_localizations_nb.dart | 6 +++++ .../l10n/zulip_localizations_pl.dart | 6 +++++ .../l10n/zulip_localizations_ru.dart | 6 +++++ .../l10n/zulip_localizations_sk.dart | 6 +++++ lib/widgets/compose_box.dart | 24 ++++++++++++++--- test/widgets/compose_box_test.dart | 26 +++++++++++++++++++ 11 files changed, 108 insertions(+), 4 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 294be81327..41c8cb351b 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -373,10 +373,18 @@ "@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 552f5f8175..3fe4cee68a 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -609,12 +609,24 @@ abstract class ZulipLocalizations { /// **'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 78e1d9ce4c..3cb6a143cc 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -294,9 +294,15 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @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 bcecfd17c2..18e787f1f4 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -294,9 +294,15 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @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 a9fafd4f59..922a53df9e 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -294,9 +294,15 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @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 913bed41e5..aae8374b5e 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -294,9 +294,15 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @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 6a78bcc3be..65b02e574a 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -294,9 +294,15 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @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 dfdcc6bf3f..813e4d89c9 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -294,9 +294,15 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @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 ed777c1847..edb246795c 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -294,9 +294,15 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @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/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 2a1b0006ce..122ec0c8f5 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -379,11 +379,13 @@ class ComposeContentController extends ComposeController } enum SavedSnippetTitleValidationError { - empty; + empty, + tooLong; String message(ZulipLocalizations zulipLocalizations) { return switch (this) { SavedSnippetTitleValidationError.empty => zulipLocalizations.savedSnippetTitleValidationErrorEmpty, + SavedSnippetTitleValidationError.tooLong => zulipLocalizations.savedSnippetTitleValidationErrorTooLong, }; } } @@ -393,7 +395,7 @@ class SavedSnippetTitleComposeController extends ComposeController kMaxTopicLengthCodePoints; @override String _computeTextNormalized() { @@ -405,16 +407,24 @@ class SavedSnippetTitleComposeController extends ComposeController maxLengthUnicodeCodePoints + ) + SavedSnippetTitleValidationError.tooLong, ]; } } enum SavedSnippetContentValidationError { - empty; + empty, + tooLong; String message(ZulipLocalizations zulipLocalizations) { return switch (this) { SavedSnippetContentValidationError.empty => zulipLocalizations.savedSnippetContentValidationErrorEmpty, + SavedSnippetContentValidationError.tooLong => zulipLocalizations.savedSnippetContentValidationErrorTooLong, }; } } @@ -424,7 +434,7 @@ class SavedSnippetContentComposeController extends ComposeController kMaxMessageLengthCodePoints; @override String _computeTextNormalized() { @@ -436,6 +446,12 @@ class SavedSnippetContentComposeController extends ComposeController maxLengthUnicodeCodePoints + ) + SavedSnippetContentValidationError.tooLong, ]; } } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 59f10189fa..09d8ab833b 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -932,6 +932,32 @@ void main() { 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');