From 6d5619317426b7b7c8e045cdd8dff9df4e9b1441 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 19 Mar 2026 10:14:04 +0100 Subject: [PATCH 1/9] Add commands attachment option --- .../src/autocomplete/stream_autocomplete.dart | 13 +++ .../stream_command_autocomplete_options.dart | 108 ++++++------------ .../stream_mention_autocomplete_options.dart | 38 +++++- .../attachment_picker/options/options.dart | 1 + .../options/stream_command_picker.dart | 103 +++++++++++++++++ .../stream_attachment_picker.dart | 12 +- .../message_input/stream_message_input.dart | 42 ++++--- 7 files changed, 223 insertions(+), 94 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart index 9cbcc3d662..4d35263ee2 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart @@ -551,6 +551,19 @@ const _kDefaultStreamAutocompleteOptionsShape = RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ); +/// Defines the visual style of autocomplete options overlay. +enum AutocompleteOptionsStyle { + /// Flat overlay with no elevation or margin. + /// + /// Used for overlays that appear directly above the composer (default). + fixed, + + /// Floating card with elevation and rounded corners. + /// + /// Used for overlays that appear in open space away from the composer. + floating, +} + /// A helper widget used to show the options of a [StreamAutocomplete]. class StreamAutocompleteOptions extends StatelessWidget { /// Creates a [StreamAutocompleteOptions] widget. diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart index ce8c22c145..684784eadd 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart @@ -12,6 +12,7 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { required this.query, required this.channel, this.onCommandSelected, + this.style = AutocompleteOptionsStyle.fixed, super.key, }); @@ -24,6 +25,11 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { /// Callback called when a command is selected. final ValueSetter? onCommandSelected; + /// The visual style of the autocomplete options overlay. + /// + /// Defaults to [AutocompleteOptionsStyle.fixed]. + final AutocompleteOptionsStyle style; + @override Widget build(BuildContext context) { final commands = channel.config?.commands.where((it) { @@ -38,8 +44,27 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { final colorTheme = streamChatTheme.colorTheme; final textTheme = streamChatTheme.textTheme; + final (elevation, margin, shape) = switch (style) { + AutocompleteOptionsStyle.fixed => ( + 0.0, + EdgeInsets.zero, + const RoundedRectangleBorder() as ShapeBorder, + ), + AutocompleteOptionsStyle.floating => ( + 4.0, + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ) + as ShapeBorder, + ), + }; + return StreamAutocompleteOptions( options: commands, + elevation: elevation, + margin: margin, + shape: shape, headerBuilder: (context) { return ListTile( dense: true, @@ -93,87 +118,24 @@ class _CommandIcon extends StatelessWidget { @override Widget build(BuildContext context) { - final _streamChatTheme = StreamChatTheme.of(context); + final colorTheme = StreamChatTheme.of(context).colorTheme; switch (command.name) { case 'giphy': - return const CircleAvatar( - radius: 12, - child: StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.giphy, - ), - ); + return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.giphy); + case 'imgur': + return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.imgur); case 'ban': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.peopleRemove, - size: 16, - color: Colors.white, - ), - ); + return Icon(context.streamIcons.peopleRemove, size: 20, color: colorTheme.textHighEmphasis); case 'flag': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.flag2, - size: 14, - color: Colors.white, - ), - ); - case 'imgur': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: const ClipOval( - child: StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.imgur, - ), - ), - ); + return Icon(context.streamIcons.flag2, size: 20, color: colorTheme.textHighEmphasis); case 'mute': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.mute, - size: 16, - color: Colors.white, - ), - ); + return Icon(context.streamIcons.mute, size: 20, color: colorTheme.textHighEmphasis); case 'unban': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.peopleAdd, - size: 16, - color: Colors.white, - ), - ); + return Icon(context.streamIcons.peopleAdd, size: 20, color: colorTheme.textHighEmphasis); case 'unmute': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.volumeFull, - size: 16, - color: Colors.white, - ), - ); + return Icon(context.streamIcons.volumeFull, size: 20, color: colorTheme.textHighEmphasis); default: - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.thunder, - size: 16, - color: Colors.white, - ), - ); + return Icon(context.streamIcons.thunder, size: 20, color: colorTheme.textHighEmphasis); } } } diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart index e528ab4832..cf99836f81 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart @@ -83,13 +83,43 @@ class _StreamMentionAutocompleteOptionsState extends State( options: users, + elevation: 0, + margin: EdgeInsets.zero, + shape: const RoundedRectangleBorder(), optionBuilder: (context, user) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - return Material( - color: colorTheme.barsBg, + final mentionsTileBuilder = widget.mentionsTileBuilder; + if (mentionsTileBuilder != null) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + return Material( + color: colorTheme.barsBg, + child: InkWell( + onTap: widget.onMentionUserTap == null ? null : () => widget.onMentionUserTap!(user), + child: mentionsTileBuilder(context, user), + ), + ); + } + + final textTheme = StreamChatTheme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), child: InkWell( + borderRadius: BorderRadius.circular(12), onTap: widget.onMentionUserTap == null ? null : () => widget.onMentionUserTap!(user), - child: widget.mentionsTileBuilder?.call(context, user) ?? StreamUserMentionTile(user), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + spacing: 12, + children: [ + StreamUserAvatar(size: .sm, user: user), + Text( + user.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyBold, + ), + ], + ), + ), ), ); }, diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart index 2e34169c56..f7c187f3ee 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart @@ -1,3 +1,4 @@ +export 'stream_command_picker.dart'; export 'stream_file_picker.dart'; export 'stream_gallery_picker.dart'; export 'stream_image_picker.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart new file mode 100644 index 0000000000..0dfa95051b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Widget shown in the attachment picker for browsing and selecting commands. +class StreamCommandPicker extends StatelessWidget { + /// Creates a [StreamCommandPicker] widget. + const StreamCommandPicker({ + super.key, + this.onCommandSelected, + }); + + /// Callback called when a command is selected. + final ValueSetter? onCommandSelected; + + @override + Widget build(BuildContext context) { + final channel = StreamChannel.of(context).channel; + final commands = channel.config?.commands ?? const []; + + final textTheme = StreamChatTheme.of(context).textTheme; + final colorTheme = StreamChatTheme.of(context).colorTheme; + final spacing = context.streamSpacing; + + return OptionDrawer( + margin: EdgeInsets.zero, + title: Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md), + child: Text( + context.translations.instantCommandsLabel, + style: textTheme.headlineBold, + ), + ), + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: commands.length, + itemBuilder: (context, index) { + final command = commands[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onCommandSelected == null ? null : () => onCommandSelected!(command), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), + child: Row( + spacing: spacing.sm, + children: [ + _CommandPickerIcon(command: command), + Text( + command.name.sentenceCase, + style: textTheme.bodyBold.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + Expanded( + child: Text( + command.args, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +class _CommandPickerIcon extends StatelessWidget { + const _CommandPickerIcon({required this.command}); + + final Command command; + + @override + Widget build(BuildContext context) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + switch (command.name) { + case 'giphy': + return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.giphy); + case 'imgur': + return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.imgur); + case 'ban': + return Icon(context.streamIcons.peopleRemove, size: 20, color: colorTheme.textHighEmphasis); + case 'flag': + return Icon(context.streamIcons.flag2, size: 20, color: colorTheme.textHighEmphasis); + case 'mute': + return Icon(context.streamIcons.mute, size: 20, color: colorTheme.textHighEmphasis); + case 'unban': + return Icon(context.streamIcons.peopleAdd, size: 20, color: colorTheme.textHighEmphasis); + case 'unmute': + return Icon(context.streamIcons.volumeFull, size: 20, color: colorTheme.textHighEmphasis); + default: + return Icon(context.streamIcons.thunder, size: 20, color: colorTheme.textHighEmphasis); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart index aec6681760..57e80fd36e 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart @@ -162,7 +162,8 @@ class _TabbedAttachmentPickerOptions extends StatelessWidget { ...options.map( (option) { final supported = option.supportedTypes; - final isEnabled = enabledTypes.any(supported.contains); + // An option with no supportedTypes is always enabled. + final isEnabled = supported.isEmpty || enabledTypes.any(supported.contains); final isSelected = option == currentOption; final onPressed = switch (isEnabled) { @@ -391,6 +392,7 @@ Widget tabbedAttachmentPickerBuilder({ AttachmentPickerOptionsBuilder? optionsBuilder, ValueSetter? onError, ValueSetter? onPollCreated, + ValueSetter? onCommandSelected, }) { final defaultOptions = [ TabbedAttachmentPickerOption( @@ -486,6 +488,14 @@ Widget tabbedAttachmentPickerBuilder({ ); }, ), + TabbedAttachmentPickerOption( + key: 'command-picker', + icon: context.streamIcons.runShortcut, + supportedTypes: const [], + optionViewBuilder: (context, controller) => StreamCommandPicker( + onCommandSelected: onCommandSelected, + ), + ), ]; final allOptions = switch (optionsBuilder) { diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart index d23df1b207..6dde417574 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart @@ -811,23 +811,33 @@ class StreamMessageInputState extends State with Restoration final useSystemPicker = widget.useSystemAttachmentPicker || (messageInputTheme.useSystemAttachmentPicker ?? false) || isWebOrDesktop; - final builder = switch (useSystemPicker) { - true => systemAttachmentPickerBuilder, - false => tabbedAttachmentPickerBuilder, - }; + final child = useSystemPicker + ? systemAttachmentPickerBuilder( + context: context, + controller: _pickerController!, + allowedTypes: allowedTypes, + pollConfig: widget.pollConfig, + optionsBuilder: widget.attachmentPickerOptionsBuilder, + onError: _onPickerError, + onPollCreated: _onPollCreated, + ) + : tabbedAttachmentPickerBuilder( + context: context, + controller: _pickerController!, + allowedTypes: allowedTypes, + pollConfig: widget.pollConfig, + optionsBuilder: widget.attachmentPickerOptionsBuilder, + onError: _onPickerError, + onPollCreated: _onPollCreated, + onCommandSelected: _onCommandSelectedFromPicker, + ); - return SizedBox( - height: 333, - child: builder.call( - context: context, - controller: _pickerController!, - allowedTypes: allowedTypes, - pollConfig: widget.pollConfig, - optionsBuilder: widget.attachmentPickerOptionsBuilder, - onError: _onPickerError, - onPollCreated: _onPollCreated, - ), - ); + return SizedBox(height: 333, child: child); + } + + void _onCommandSelectedFromPicker(Command command) { + _hidePicker(); + _effectiveController.command = command.name; } Widget? _buildTopMessageArea(BuildContext context) { From cb680ac495aa74be84ad063ebaedeb9c2f6a2b29 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 19 Mar 2026 10:18:04 +0100 Subject: [PATCH 2/9] merge command icons --- .../stream_command_autocomplete_options.dart | 32 ++--------------- .../options/stream_command_icon.dart | 36 +++++++++++++++++++ .../options/stream_command_picker.dart | 32 ++--------------- 3 files changed, 40 insertions(+), 60 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart index 684784eadd..2438776d8b 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_command_icon.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -86,7 +87,7 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { return ListTile( dense: true, horizontalTitleGap: 8, - leading: _CommandIcon(command: command), + leading: StreamCommandIcon(command: command), title: Row( children: [ Text( @@ -110,32 +111,3 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { ); } } - -class _CommandIcon extends StatelessWidget { - const _CommandIcon({required this.command}); - - final Command command; - - @override - Widget build(BuildContext context) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - switch (command.name) { - case 'giphy': - return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.giphy); - case 'imgur': - return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.imgur); - case 'ban': - return Icon(context.streamIcons.peopleRemove, size: 20, color: colorTheme.textHighEmphasis); - case 'flag': - return Icon(context.streamIcons.flag2, size: 20, color: colorTheme.textHighEmphasis); - case 'mute': - return Icon(context.streamIcons.mute, size: 20, color: colorTheme.textHighEmphasis); - case 'unban': - return Icon(context.streamIcons.peopleAdd, size: 20, color: colorTheme.textHighEmphasis); - case 'unmute': - return Icon(context.streamIcons.volumeFull, size: 20, color: colorTheme.textHighEmphasis); - default: - return Icon(context.streamIcons.thunder, size: 20, color: colorTheme.textHighEmphasis); - } - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart new file mode 100644 index 0000000000..c8fa5cd46e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// An icon widget for a chat command. +/// +/// Displays a 20px icon matching the given [command] name. +class StreamCommandIcon extends StatelessWidget { + /// Creates a [StreamCommandIcon]. + const StreamCommandIcon({super.key, required this.command}); + + /// The command whose icon is displayed. + final Command command; + + @override + Widget build(BuildContext context) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + switch (command.name) { + case 'giphy': + return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.giphy); + case 'imgur': + return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.imgur); + case 'ban': + return Icon(context.streamIcons.peopleRemove, size: 20, color: colorTheme.textHighEmphasis); + case 'flag': + return Icon(context.streamIcons.flag2, size: 20, color: colorTheme.textHighEmphasis); + case 'mute': + return Icon(context.streamIcons.mute, size: 20, color: colorTheme.textHighEmphasis); + case 'unban': + return Icon(context.streamIcons.peopleAdd, size: 20, color: colorTheme.textHighEmphasis); + case 'unmute': + return Icon(context.streamIcons.volumeFull, size: 20, color: colorTheme.textHighEmphasis); + default: + return Icon(context.streamIcons.thunder, size: 20, color: colorTheme.textHighEmphasis); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart index 0dfa95051b..2bd610b1b2 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_command_icon.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Widget shown in the attachment picker for browsing and selecting commands. @@ -45,7 +46,7 @@ class StreamCommandPicker extends StatelessWidget { child: Row( spacing: spacing.sm, children: [ - _CommandPickerIcon(command: command), + StreamCommandIcon(command: command), Text( command.name.sentenceCase, style: textTheme.bodyBold.copyWith( @@ -72,32 +73,3 @@ class StreamCommandPicker extends StatelessWidget { ); } } - -class _CommandPickerIcon extends StatelessWidget { - const _CommandPickerIcon({required this.command}); - - final Command command; - - @override - Widget build(BuildContext context) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - switch (command.name) { - case 'giphy': - return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.giphy); - case 'imgur': - return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.imgur); - case 'ban': - return Icon(context.streamIcons.peopleRemove, size: 20, color: colorTheme.textHighEmphasis); - case 'flag': - return Icon(context.streamIcons.flag2, size: 20, color: colorTheme.textHighEmphasis); - case 'mute': - return Icon(context.streamIcons.mute, size: 20, color: colorTheme.textHighEmphasis); - case 'unban': - return Icon(context.streamIcons.peopleAdd, size: 20, color: colorTheme.textHighEmphasis); - case 'unmute': - return Icon(context.streamIcons.volumeFull, size: 20, color: colorTheme.textHighEmphasis); - default: - return Icon(context.streamIcons.thunder, size: 20, color: colorTheme.textHighEmphasis); - } - } -} From e6ed43794eb0a7beb31fda2da228e6b53182c1d9 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 19 Mar 2026 10:37:21 +0100 Subject: [PATCH 3/9] improve layout command autocomplete --- .../src/autocomplete/stream_autocomplete.dart | 5 +- .../stream_command_autocomplete_options.dart | 71 +++++++++++++------ 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart index 4d35263ee2..7fa1d486d9 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart @@ -630,10 +630,7 @@ class StreamAutocompleteOptions extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (headerBuilder != null) ...[ - headerBuilder!(context), - Divider(height: 0, color: colorTheme.borders), - ], + if (headerBuilder != null) headerBuilder!(context), LimitedBox( maxHeight: maxHeight ?? height * 0.5, child: ListView.builder( diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart index 2438776d8b..a24f34fc1e 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart @@ -41,15 +41,14 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { if (commands == null || commands.isEmpty) return const Empty(); - final streamChatTheme = StreamChatTheme.of(context); - final colorTheme = streamChatTheme.colorTheme; - final textTheme = streamChatTheme.textTheme; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; final (elevation, margin, shape) = switch (style) { AutocompleteOptionsStyle.fixed => ( 0.0, EdgeInsets.zero, - const RoundedRectangleBorder() as ShapeBorder, + _TopBorderShape(BorderSide(color: colorScheme.borderDefault)) as ShapeBorder, ), AutocompleteOptionsStyle.floating => ( 4.0, @@ -67,18 +66,18 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { margin: margin, shape: shape, headerBuilder: (context) { - return ListTile( - dense: true, - horizontalTitleGap: 0, - leading: Icon( - context.streamIcons.thunder, - color: colorTheme.accentPrimary, - size: 28, + return Padding( + padding: EdgeInsets.only( + left: context.streamSpacing.sm, + right: context.streamSpacing.sm, + top: context.streamSpacing.md, + bottom: context.streamSpacing.xs, ), - title: Text( - context.translations.instantCommandsLabel, - style: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, + child: Align( + alignment: .centerStart, + child: Text( + context.translations.instantCommandsLabel, + style: textTheme.headingXs.copyWith(color: colorScheme.textTertiary), ), ), ); @@ -86,21 +85,21 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { optionBuilder: (context, command) { return ListTile( dense: true, - horizontalTitleGap: 8, + horizontalTitleGap: context.streamSpacing.sm, leading: StreamCommandIcon(command: command), - title: Row( + title: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( command.name.sentenceCase, - style: textTheme.bodyBold.copyWith( - color: colorTheme.textHighEmphasis, - ), + style: textTheme.bodyDefault, ), const SizedBox(width: 8), Text( - '/${command.name} ${command.args}', - style: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, + command.description, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, ), ), ], @@ -111,3 +110,29 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { ); } } + +/// A [ShapeBorder] that paints only a top border, with no rounding or sides. +class _TopBorderShape extends ShapeBorder { + const _TopBorderShape(this.top); + + final BorderSide top; + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.only(top: top.width); + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) => Path()..addRect(rect); + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) => Path()..addRect(rect); + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + final paint = top.toPaint()..strokeCap = StrokeCap.square; + final y = rect.top + top.width / 2; + canvas.drawLine(Offset(rect.left, y), Offset(rect.right, y), paint); + } + + @override + ShapeBorder scale(double t) => _TopBorderShape(top.scale(t)); +} From 8d56d3a95d25303622bd83c493516f945eecf9c3 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 19 Mar 2026 10:48:02 +0100 Subject: [PATCH 4/9] also update mentions autocomplete --- .../src/autocomplete/stream_autocomplete.dart | 53 +++++++++++++++++++ .../stream_command_autocomplete_options.dart | 41 +------------- .../stream_mention_autocomplete_options.dart | 27 ++++++---- 3 files changed, 72 insertions(+), 49 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart index 7fa1d486d9..0513cdff2a 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart @@ -564,6 +564,59 @@ enum AutocompleteOptionsStyle { floating, } +/// Resolves visual parameters for a [StreamAutocompleteOptions] widget based +/// on [AutocompleteOptionsStyle]. +extension AutocompleteOptionsStyleX on AutocompleteOptionsStyle { + /// Returns the elevation, margin, and shape for [StreamAutocompleteOptions]. + /// + /// [borderColor] is used for the top border (fixed) or outline (floating). + ({double elevation, EdgeInsetsGeometry margin, ShapeBorder shape}) resolve( + Color borderColor, + ) { + return switch (this) { + AutocompleteOptionsStyle.fixed => ( + elevation: 0.0, + margin: EdgeInsets.zero, + shape: _TopBorderShape(BorderSide(color: borderColor)), + ), + AutocompleteOptionsStyle.floating => ( + elevation: 4.0, + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(24)), + side: BorderSide(color: borderColor), + ), + ), + }; + } +} + +/// A [ShapeBorder] that paints only a top border, with no rounding or sides. +class _TopBorderShape extends ShapeBorder { + const _TopBorderShape(this.top); + + final BorderSide top; + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.only(top: top.width); + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) => Path()..addRect(rect); + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) => Path()..addRect(rect); + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + final paint = top.toPaint()..strokeCap = StrokeCap.square; + final y = rect.top + top.width / 2; + canvas.drawLine(Offset(rect.left, y), Offset(rect.right, y), paint); + } + + @override + ShapeBorder scale(double t) => _TopBorderShape(top.scale(t)); +} + /// A helper widget used to show the options of a [StreamAutocomplete]. class StreamAutocompleteOptions extends StatelessWidget { /// Creates a [StreamAutocompleteOptions] widget. diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart index a24f34fc1e..c75c511570 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart @@ -44,21 +44,7 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; - final (elevation, margin, shape) = switch (style) { - AutocompleteOptionsStyle.fixed => ( - 0.0, - EdgeInsets.zero, - _TopBorderShape(BorderSide(color: colorScheme.borderDefault)) as ShapeBorder, - ), - AutocompleteOptionsStyle.floating => ( - 4.0, - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16)), - ) - as ShapeBorder, - ), - }; + final (:elevation, :margin, :shape) = style.resolve(colorScheme.borderDefault); return StreamAutocompleteOptions( options: commands, @@ -111,28 +97,3 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { } } -/// A [ShapeBorder] that paints only a top border, with no rounding or sides. -class _TopBorderShape extends ShapeBorder { - const _TopBorderShape(this.top); - - final BorderSide top; - - @override - EdgeInsetsGeometry get dimensions => EdgeInsets.only(top: top.width); - - @override - Path getInnerPath(Rect rect, {TextDirection? textDirection}) => Path()..addRect(rect); - - @override - Path getOuterPath(Rect rect, {TextDirection? textDirection}) => Path()..addRect(rect); - - @override - void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { - final paint = top.toPaint()..strokeCap = StrokeCap.square; - final y = rect.top + top.width / 2; - canvas.drawLine(Offset(rect.left, y), Offset(rect.right, y), paint); - } - - @override - ShapeBorder scale(double t) => _TopBorderShape(top.scale(t)); -} diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart index cf99836f81..5fa238458a 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart @@ -16,6 +16,7 @@ class StreamMentionAutocompleteOptions extends StatefulWidget { this.mentionAllAppUsers = false, this.mentionsTileBuilder, this.onMentionUserTap, + this.style = AutocompleteOptionsStyle.fixed, }) : assert( channel.state != null, 'Channel ${channel.cid} is not yet initialized', @@ -48,6 +49,11 @@ class StreamMentionAutocompleteOptions extends StatefulWidget { /// Callback called when a user is selected. final ValueSetter? onMentionUserTap; + /// The visual style of the autocomplete options overlay. + /// + /// Defaults to [AutocompleteOptionsStyle.fixed]. + final AutocompleteOptionsStyle style; + @override _StreamMentionAutocompleteOptionsState createState() => _StreamMentionAutocompleteOptionsState(); } @@ -81,11 +87,15 @@ class _StreamMentionAutocompleteOptionsState extends State( options: users, - elevation: 0, - margin: EdgeInsets.zero, - shape: const RoundedRectangleBorder(), + elevation: elevation, + margin: margin, + shape: shape, optionBuilder: (context, user) { final mentionsTileBuilder = widget.mentionsTileBuilder; if (mentionsTileBuilder != null) { @@ -99,23 +109,22 @@ class _StreamMentionAutocompleteOptionsState extends State widget.onMentionUserTap!(user), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), child: Row( - spacing: 12, + spacing: spacing.sm, children: [ - StreamUserAvatar(size: .sm, user: user), + StreamUserAvatar(size: .md, user: user), Text( user.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: textTheme.bodyBold, + style: context.streamTextTheme.bodyDefault, ), ], ), From 903708149f11973e9875ed5b772ac9f244984148 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 19 Mar 2026 11:09:33 +0100 Subject: [PATCH 5/9] formatting --- .../src/autocomplete/stream_command_autocomplete_options.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart index c75c511570..9e9ef6a619 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart @@ -96,4 +96,3 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { ); } } - From 311e38800ce916ef7f511074c74ffc57851aa4c8 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 19 Mar 2026 11:18:20 +0100 Subject: [PATCH 6/9] fix autocomplete dispose issue --- .../lib/src/autocomplete/stream_autocomplete.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart index 0513cdff2a..c452b10e9c 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart @@ -491,7 +491,8 @@ class _StreamAutocompleteState extends State { _focusNode.dispose(); } _onChangedField.cancel(); - closeSuggestions(); + _currentQuery = null; + _currentTrigger = null; super.dispose(); } From 5fcb0dcf479f6f7ec6abf2d42feacfb3b1fe82a8 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 19 Mar 2026 12:55:15 +0100 Subject: [PATCH 7/9] using icontheme to clean the code --- .../stream_command_autocomplete_options.dart | 2 +- .../options/stream_command_icon.dart | 35 +++++++++---------- .../options/stream_command_picker.dart | 2 +- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart index 9e9ef6a619..59507ebb59 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart @@ -81,7 +81,7 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { command.name.sentenceCase, style: textTheme.bodyDefault, ), - const SizedBox(width: 8), + SizedBox(height: context.streamSpacing.xxs), Text( command.description, style: textTheme.captionDefault.copyWith( diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart index c8fa5cd46e..186ab63229 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart @@ -13,24 +13,21 @@ class StreamCommandIcon extends StatelessWidget { @override Widget build(BuildContext context) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - switch (command.name) { - case 'giphy': - return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.giphy); - case 'imgur': - return const StreamSvgIcon(size: 20, icon: StreamSvgIcons.imgur); - case 'ban': - return Icon(context.streamIcons.peopleRemove, size: 20, color: colorTheme.textHighEmphasis); - case 'flag': - return Icon(context.streamIcons.flag2, size: 20, color: colorTheme.textHighEmphasis); - case 'mute': - return Icon(context.streamIcons.mute, size: 20, color: colorTheme.textHighEmphasis); - case 'unban': - return Icon(context.streamIcons.peopleAdd, size: 20, color: colorTheme.textHighEmphasis); - case 'unmute': - return Icon(context.streamIcons.volumeFull, size: 20, color: colorTheme.textHighEmphasis); - default: - return Icon(context.streamIcons.thunder, size: 20, color: colorTheme.textHighEmphasis); - } + const size = 20.0; + final color = context.streamColorScheme.textSecondary; + + return IconTheme.merge( + data: IconThemeData(size: size, color: color), + child: switch (command.name) { + 'giphy' => const StreamSvgIcon(size: size, icon: StreamSvgIcons.giphy), + 'imgur' => const StreamSvgIcon(size: size, icon: StreamSvgIcons.imgur), + 'ban' => Icon(context.streamIcons.peopleRemove), + 'flag' => Icon(context.streamIcons.flag2), + 'mute' => Icon(context.streamIcons.mute), + 'unban' => Icon(context.streamIcons.peopleAdd), + 'unmute' => Icon(context.streamIcons.volumeFull), + _ => Icon(context.streamIcons.thunder), + }, + ); } } diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart index 2bd610b1b2..9218959a3b 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart @@ -55,7 +55,7 @@ class StreamCommandPicker extends StatelessWidget { ), Expanded( child: Text( - command.args, + '/${command.name} ${command.args}', maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.body.copyWith( From 9f3ab930c00ec64d790adf6304e13dbbca611cae Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 19 Mar 2026 12:59:07 +0100 Subject: [PATCH 8/9] also remove size from svg icon --- .../attachment_picker/options/stream_command_icon.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart index 186ab63229..bd5d2bb567 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart @@ -19,8 +19,8 @@ class StreamCommandIcon extends StatelessWidget { return IconTheme.merge( data: IconThemeData(size: size, color: color), child: switch (command.name) { - 'giphy' => const StreamSvgIcon(size: size, icon: StreamSvgIcons.giphy), - 'imgur' => const StreamSvgIcon(size: size, icon: StreamSvgIcons.imgur), + 'giphy' => const StreamSvgIcon(icon: StreamSvgIcons.giphy), + 'imgur' => const StreamSvgIcon(icon: StreamSvgIcons.imgur), 'ban' => Icon(context.streamIcons.peopleRemove), 'flag' => Icon(context.streamIcons.flag2), 'mute' => Icon(context.streamIcons.mute), From 61a32d54ad2255866fd3c51ff9f4379607952aeb Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 19 Mar 2026 13:44:36 +0100 Subject: [PATCH 9/9] add command picker type --- .../attachment_picker/stream_attachment_picker.dart | 2 +- .../stream_attachment_picker_option.dart | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart index 57e80fd36e..df1e23c791 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart @@ -491,7 +491,7 @@ Widget tabbedAttachmentPickerBuilder({ TabbedAttachmentPickerOption( key: 'command-picker', icon: context.streamIcons.runShortcut, - supportedTypes: const [], + supportedTypes: [AttachmentPickerType.command], optionViewBuilder: (context, controller) => StreamCommandPicker( onCommandSelected: onCommandSelected, ), diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_option.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_option.dart index ee618d84f3..6001a5405f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_option.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_option.dart @@ -150,8 +150,11 @@ sealed class AttachmentPickerType { /// The option will allow to create a poll. static const poll = PollPickerType(); + /// The option will allow to pick commands. + static const command = CommandPickerType(); + /// A list of all predefined attachment picker types. - static const values = [images, videos, audios, files, poll]; + static const values = [images, videos, audios, files, poll, command]; } /// A predefined attachment picker type that allows picking images. @@ -184,6 +187,12 @@ final class PollPickerType extends AttachmentPickerType { const PollPickerType(); } +/// A predefined attachment picker type that allows picking commands. +final class CommandPickerType extends AttachmentPickerType { + /// Creates a new command picker type. + const CommandPickerType(); +} + /// A custom picker type that can be extended to support custom types of /// attachments. This allows developers to create their own attachment picker /// options for specialized content types.