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 9cbcc3d66..c452b10e9 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(); } @@ -551,6 +552,72 @@ 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, +} + +/// 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. @@ -617,10 +684,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 ce8c22c14..59507ebb5 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'; @@ -12,6 +13,7 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { required this.query, required this.channel, this.onCommandSelected, + this.style = AutocompleteOptionsStyle.fixed, super.key, }); @@ -24,6 +26,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) { @@ -34,25 +41,29 @@ 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) = style.resolve(colorScheme.borderDefault); return StreamAutocompleteOptions( options: commands, + elevation: elevation, + 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), ), ), ); @@ -60,21 +71,21 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { optionBuilder: (context, command) { return ListTile( dense: true, - horizontalTitleGap: 8, - leading: _CommandIcon(command: command), - title: Row( + horizontalTitleGap: context.streamSpacing.sm, + leading: StreamCommandIcon(command: command), + 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), + SizedBox(height: context.streamSpacing.xxs), Text( - '/${command.name} ${command.args}', - style: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, + command.description, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, ), ), ], @@ -85,95 +96,3 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { ); } } - -class _CommandIcon extends StatelessWidget { - const _CommandIcon({required this.command}); - - final Command command; - - @override - Widget build(BuildContext context) { - final _streamChatTheme = StreamChatTheme.of(context); - switch (command.name) { - case 'giphy': - return const CircleAvatar( - radius: 12, - child: StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.giphy, - ), - ); - case 'ban': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.peopleRemove, - size: 16, - color: Colors.white, - ), - ); - 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, - ), - ), - ); - case 'mute': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.mute, - size: 16, - color: Colors.white, - ), - ); - case 'unban': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.peopleAdd, - size: 16, - color: Colors.white, - ), - ); - case 'unmute': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.volumeFull, - size: 16, - color: Colors.white, - ), - ); - default: - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: Icon( - context.streamIcons.thunder, - size: 16, - color: Colors.white, - ), - ); - } - } -} 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 e528ab483..5fa238458 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,15 +87,48 @@ class _StreamMentionAutocompleteOptionsState extends State( options: users, + elevation: elevation, + margin: margin, + shape: shape, 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), + ), + ); + } + + return Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.xxs), 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: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), + child: Row( + spacing: spacing.sm, + children: [ + StreamUserAvatar(size: .md, user: user), + Text( + user.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.streamTextTheme.bodyDefault, + ), + ], + ), + ), ), ); }, 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 2e34169c5..f7c187f3e 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_icon.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart new file mode 100644 index 000000000..bd5d2bb56 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart @@ -0,0 +1,33 @@ +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) { + 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(icon: StreamSvgIcons.giphy), + 'imgur' => const StreamSvgIcon(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 new file mode 100644 index 000000000..9218959a3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart @@ -0,0 +1,75 @@ +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. +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: [ + StreamCommandIcon(command: command), + Text( + command.name.sentenceCase, + style: textTheme.bodyBold.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + Expanded( + child: Text( + '/${command.name} ${command.args}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} 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 aec668176..df1e23c79 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: [AttachmentPickerType.command], + optionViewBuilder: (context, controller) => StreamCommandPicker( + onCommandSelected: onCommandSelected, + ), + ), ]; final allOptions = switch (optionsBuilder) { 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 ee618d84f..6001a5405 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. 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 d23df1b20..6dde41757 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) {