From 0a2e2dba35237ca73c4b67c59e32e5c7b5c027ab Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Jul 2025 16:40:53 +0200 Subject: [PATCH 1/5] refactor(ui)!: Add support for non-attachment types in attachment picker --- .../options/stream_gallery_picker.dart | 63 +- .../stream_attachment_picker.dart | 794 ++++++------------ ...stream_attachment_picker_bottom_sheet.dart | 209 ++--- .../stream_attachment_picker_controller.dart | 259 ++++++ .../stream_attachment_picker_option.dart | 198 +++++ ...eam_attachment_picker_options_builder.dart | 284 +++++++ .../stream_attachment_picker_result.dart | 58 ++ .../message_input/stream_message_input.dart | 103 +-- .../lib/src/utils/extensions.dart | 40 +- .../lib/stream_chat_flutter.dart | 3 + 10 files changed, 1264 insertions(+), 747 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_controller.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_option.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_options_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_result.dart diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart index 905df5f48c..c2914d2007 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_controller.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/photo_gallery/stream_photo_gallery.dart'; import 'package:stream_chat_flutter/src/scroll_view/photo_gallery/stream_photo_gallery_controller.dart'; @@ -14,7 +15,7 @@ import 'package:stream_chat_flutter/src/utils/utils.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// Max image resolution which can be resized by the CDN. -// Taken from https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart#image-resizing +/// Taken from https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart#image-resizing const maxCDNImageResolution = 16800000; /// Widget used to pick media from the device gallery. @@ -23,42 +24,23 @@ class StreamGalleryPicker extends StatefulWidget { const StreamGalleryPicker({ super.key, this.limit = 50, + GalleryPickerConfig? config, required this.selectedMediaItems, required this.onMediaItemSelected, - this.mediaThumbnailSize = const ThumbnailSize(400, 400), - this.mediaThumbnailFormat = ThumbnailFormat.jpeg, - this.mediaThumbnailQuality = 100, - this.mediaThumbnailScale = 1, - }); + }) : config = config ?? const GalleryPickerConfig(); /// Maximum number of media items that can be selected. final int limit; + /// Configuration for the gallery picker. + final GalleryPickerConfig config; + /// List of selected media items. final Iterable selectedMediaItems; /// Callback called when an media item is selected. final ValueSetter onMediaItemSelected; - /// Size of the attachment thumbnails. - /// - /// Defaults to (400, 400). - final ThumbnailSize mediaThumbnailSize; - - /// Format of the attachment thumbnails. - /// - /// Defaults to [ThumbnailFormat.jpeg]. - final ThumbnailFormat mediaThumbnailFormat; - - /// The quality value for the attachment thumbnails. - /// - /// Valid from 1 to 100. - /// Defaults to 100. - final int mediaThumbnailQuality; - - /// The scale to apply on the [attachmentThumbnailSize]. - final double mediaThumbnailScale; - @override State createState() => _StreamGalleryPickerState(); } @@ -159,10 +141,10 @@ class _StreamGalleryPickerState extends State { onMediaTap: widget.onMediaItemSelected, loadMoreTriggerIndex: 10, padding: const EdgeInsets.all(2), - thumbnailSize: widget.mediaThumbnailSize, - thumbnailFormat: widget.mediaThumbnailFormat, - thumbnailQuality: widget.mediaThumbnailQuality, - thumbnailScale: widget.mediaThumbnailScale, + thumbnailSize: widget.config.mediaThumbnailSize, + thumbnailFormat: widget.config.mediaThumbnailFormat, + thumbnailQuality: widget.config.mediaThumbnailQuality, + thumbnailScale: widget.config.mediaThumbnailScale, itemBuilder: (context, mediaItems, index, defaultWidget) { final media = mediaItems[index]; return defaultWidget.copyWith( @@ -178,6 +160,29 @@ class _StreamGalleryPickerState extends State { } } +/// Configuration for the [StreamGalleryPicker]. +class GalleryPickerConfig { + /// Creates a [GalleryPickerConfig] instance. + const GalleryPickerConfig({ + this.mediaThumbnailSize = const ThumbnailSize(400, 400), + this.mediaThumbnailFormat = ThumbnailFormat.jpeg, + this.mediaThumbnailQuality = 100, + this.mediaThumbnailScale = 1, + }); + + /// Size of the attachment thumbnails. + final ThumbnailSize mediaThumbnailSize; + + /// Format of the attachment thumbnails. + final ThumbnailFormat mediaThumbnailFormat; + + /// The quality value for the attachment thumbnails. + final int mediaThumbnailQuality; + + /// The scale to apply on the [mediaThumbnailSize]. + final double mediaThumbnailScale; +} + /// extension StreamImagePickerX on StreamAttachmentPickerController { /// 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 97f78ae088..f408b628bc 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 @@ -1,400 +1,64 @@ import 'dart:async'; -import 'package:collection/collection.dart'; +import 'package:file_picker/file_picker.dart' show FileType; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/options.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// The default maximum size for media attachments. -const kDefaultMaxAttachmentSize = 100 * 1024 * 1024; // 100MB in Bytes - -/// The default maximum number of media attachments. -const kDefaultMaxAttachmentCount = 10; - -/// Value class for [AttachmentPickerController]. -/// -/// This class holds the list of [Poll] and [Attachment] objects. -class AttachmentPickerValue { - /// Creates a new instance of [AttachmentPickerValue]. - const AttachmentPickerValue({ - this.poll, - this.attachments = const [], - }); - - /// The poll object. - final Poll? poll; - - /// The list of [Attachment] objects. - final List attachments; - - /// Returns a copy of this object with the provided values. - AttachmentPickerValue copyWith({ - Poll? poll, - List? attachments, - }) { - return AttachmentPickerValue( - poll: poll ?? this.poll, - attachments: attachments ?? this.attachments, - ); - } -} - -/// Controller class for [StreamAttachmentPicker]. -class StreamAttachmentPickerController - extends ValueNotifier { - /// Creates a new instance of [StreamAttachmentPickerController]. - StreamAttachmentPickerController({ - this.initialPoll, - this.initialAttachments, - this.maxAttachmentSize = kDefaultMaxAttachmentSize, - this.maxAttachmentCount = kDefaultMaxAttachmentCount, - }) : assert( - (initialAttachments?.length ?? 0) <= maxAttachmentCount, - '''The initial attachments count must be less than or equal to maxAttachmentCount''', - ), - super( - AttachmentPickerValue( - poll: initialPoll, - attachments: initialAttachments ?? const [], - ), - ); - - /// The max attachment size allowed in bytes. - final int maxAttachmentSize; - - /// The max attachment count allowed. - final int maxAttachmentCount; - - /// The initial poll. - final Poll? initialPoll; - - /// The initial attachments. - final List? initialAttachments; - - @override - set value(AttachmentPickerValue newValue) { - if (newValue.attachments.length > maxAttachmentCount) { - throw ArgumentError( - 'The maximum number of attachments is $maxAttachmentCount.', - ); - } - super.value = newValue; - } - - /// Adds a new [poll] to the message. - set poll(Poll poll) { - value = value.copyWith(poll: poll); - } - - Future _saveToCache(AttachmentFile file) async { - // Cache the attachment in a temporary file. - return StreamAttachmentHandler.instance.saveAttachmentFile( - attachmentFile: file, - ); - } - - Future _removeFromCache(AttachmentFile file) { - // Remove the cached attachment file. - return StreamAttachmentHandler.instance.deleteAttachmentFile( - attachmentFile: file, - ); - } - - /// Adds a new attachment to the message. - Future addAttachment(Attachment attachment) async { - assert(attachment.fileSize != null, ''); - if (attachment.fileSize! > maxAttachmentSize) { - throw ArgumentError( - 'The size of the attachment is ${attachment.fileSize} bytes, ' - 'but the maximum size allowed is $maxAttachmentSize bytes.', - ); - } - - final file = attachment.file; - final uploadState = attachment.uploadState; - - // No need to cache the attachment if it's already uploaded - // or we are on web. - if (file == null || uploadState.isSuccess || isWeb) { - value = value.copyWith(attachments: [...value.attachments, attachment]); - return; - } - - // Cache the attachment in a temporary file. - final tempFilePath = await _saveToCache(file); - - value = value.copyWith(attachments: [ - ...value.attachments, - attachment.copyWith( - file: file.copyWith( - path: tempFilePath, - ), - ), - ]); - } - - /// Removes the specified [attachment] from the message. - Future removeAttachment(Attachment attachment) async { - final file = attachment.file; - final uploadState = attachment.uploadState; - - if (file == null || uploadState.isSuccess || isWeb) { - final updatedAttachments = [...value.attachments]..remove(attachment); - value = value.copyWith(attachments: updatedAttachments); - return; - } - - // Remove the cached attachment file. - await _removeFromCache(file); - - final updatedAttachments = [...value.attachments]..remove(attachment); - value = value.copyWith(attachments: updatedAttachments); - } - - /// Remove the attachment with the given [attachmentId]. - void removeAttachmentById(String attachmentId) { - final attachment = value.attachments.firstWhereOrNull( - (attachment) => attachment.id == attachmentId, - ); - - if (attachment == null) return; - - removeAttachment(attachment); - } - - /// Clears all the attachments. - Future clear() async { - final attachments = [...value.attachments]; - for (final attachment in attachments) { - final file = attachment.file; - final uploadState = attachment.uploadState; - - if (file == null || uploadState.isSuccess || isWeb) continue; - - await _removeFromCache(file); - } - value = const AttachmentPickerValue(); - } - - /// Resets the controller to its initial state. - Future reset() async { - final attachments = [...value.attachments]; - for (final attachment in attachments) { - final file = attachment.file; - final uploadState = attachment.uploadState; - - if (file == null || uploadState.isSuccess || isWeb) continue; - - await _removeFromCache(file); - } - - value = AttachmentPickerValue( - poll: initialPoll, - attachments: initialAttachments ?? const [], - ); - } -} - -/// The possible picker types of the attachment picker. -enum AttachmentPickerType { - /// The attachment picker will only allow to pick images. - images, - - /// The attachment picker will only allow to pick videos. - videos, - - /// The attachment picker will only allow to pick audios. - audios, - - /// The attachment picker will only allow to pick files or documents. - files, - - /// The attachment picker will only allow to create poll. - poll, -} - -/// Function signature for building the attachment picker option view. -typedef AttachmentPickerOptionViewBuilder = Widget Function( - BuildContext context, - StreamAttachmentPickerController controller, -); - -/// Model class for the attachment picker options. -class AttachmentPickerOption { - /// Creates a new instance of [AttachmentPickerOption]. - const AttachmentPickerOption({ - this.key, - required this.supportedTypes, - required this.icon, - this.title, - this.optionViewBuilder, - }); - - /// A key to identify the option. - final String? key; - - /// The icon of the option. - final Widget icon; - - /// The title of the option. - final String? title; - - /// The supported types of the option. - final Iterable supportedTypes; - - /// The option view builder. - final AttachmentPickerOptionViewBuilder? optionViewBuilder; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is AttachmentPickerOption && - runtimeType == other.runtimeType && - key == other.key && - const IterableEquality().equals(supportedTypes, other.supportedTypes); - - @override - int get hashCode => - key.hashCode ^ const IterableEquality().hash(supportedTypes); -} - -/// The attachment picker option for the web or desktop platforms. -class WebOrDesktopAttachmentPickerOption extends AttachmentPickerOption { - /// Creates a new instance of [WebOrDesktopAttachmentPickerOption]. - WebOrDesktopAttachmentPickerOption({ - super.key, - required AttachmentPickerType type, - required super.icon, - required super.title, - }) : super(supportedTypes: [type]); - - /// Creates a new instance of [WebOrDesktopAttachmentPickerOption] from - /// [option]. - factory WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption( - AttachmentPickerOption option, - ) { - return WebOrDesktopAttachmentPickerOption( - key: option.key, - type: option.supportedTypes.first, - icon: option.icon, - title: option.title, - ); - } - - @override - String get title => super.title!; - - /// Type of the option. - AttachmentPickerType get type => supportedTypes.first; -} - -/// Helpful extensions for [StreamAttachmentPickerController]. -extension AttachmentPickerOptionTypeX on StreamAttachmentPickerController { - /// Returns the list of available attachment picker options. - Set get currentAttachmentPickerTypes { - final attach = value.attachments; - final containsImage = attach.any((it) => it.type == AttachmentType.image); - final containsVideo = attach.any((it) => it.type == AttachmentType.video); - final containsAudio = attach.any((it) => it.type == AttachmentType.audio); - final containsFile = attach.any((it) => it.type == AttachmentType.file); - final containsPoll = value.poll != null; - - return { - if (containsImage) AttachmentPickerType.images, - if (containsVideo) AttachmentPickerType.videos, - if (containsAudio) AttachmentPickerType.audios, - if (containsFile) AttachmentPickerType.files, - if (containsPoll) AttachmentPickerType.poll, - }; - } - - /// Returns the list of enabled picker types. - Set filterEnabledTypes({ - required Iterable options, - }) { - final availableTypes = currentAttachmentPickerTypes; - final enabledTypes = {}; - for (final option in options) { - final supportedTypes = option.supportedTypes; - if (availableTypes.any(supportedTypes.contains)) { - enabledTypes.addAll(supportedTypes); - } - } - return enabledTypes; - } - - /// Returns true if the [initialAttachments] are changed. - bool get isValueChanged { - final isPollEqual = value.poll == initialPoll; - final areAttachmentsEqual = UnorderedIterableEquality( - EqualityBy((attachment) => attachment.id), - ).equals(value.attachments, initialAttachments); - - return !isPollEqual || !areAttachmentsEqual; - } -} - -/// Function signature for the callback when the web or desktop attachment -/// picker option gets tapped. -typedef OnWebOrDesktopAttachmentPickerOptionTap = void Function( - BuildContext context, - StreamAttachmentPickerController controller, - WebOrDesktopAttachmentPickerOption option, -); - -/// Bottom sheet widget for the web or desktop version of the attachment picker. -class StreamWebOrDesktopAttachmentPickerBottomSheet extends StatelessWidget { - /// Creates a new instance of [StreamWebOrDesktopAttachmentPickerBottomSheet]. - const StreamWebOrDesktopAttachmentPickerBottomSheet({ +/// Bottom sheet widget for the system attachment picker interface. +/// This is used when the attachment picker uses system integration, +/// typically on web/desktop or when useSystemAttachmentPicker is true. +class StreamSystemAttachmentPickerBottomSheet extends StatelessWidget { + /// Creates a new instance of [StreamSystemAttachmentPickerBottomSheet]. + const StreamSystemAttachmentPickerBottomSheet({ super.key, required this.options, required this.controller, - this.onOptionTap, }); /// The list of options. - final Set options; + final Set options; /// The controller of the attachment picker. final StreamAttachmentPickerController controller; - /// The callback when the option gets tapped. - final OnWebOrDesktopAttachmentPickerOptionTap? onOptionTap; - @override Widget build(BuildContext context) { - final enabledTypes = controller.filterEnabledTypes(options: options); - return ListView( - shrinkWrap: true, - children: [ - ...options.map((option) { - VoidCallback? onOptionTap; - if (this.onOptionTap != null) { - onOptionTap = () { - this.onOptionTap?.call(context, controller, option); - }; - } - - final enabled = enabledTypes.isEmpty || - enabledTypes.any((it) => it == option.type); - - return ListTile( - enabled: enabled, - leading: option.icon, - title: Text(option.title), - onTap: onOptionTap, - ); - }), - ], + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) { + final enabledTypes = value.filterEnabledTypes(options: options); + + return ListView( + shrinkWrap: true, + children: [ + ...options.map( + (option) { + final supported = option.supportedTypes; + final isEnabled = enabledTypes.any(supported.contains); + + return ListTile( + enabled: isEnabled, + leading: option.icon, + title: Text(option.title), + onTap: () => option.onTap(context, controller), + ); + }, + ), + ], + ); + }, ); } } -/// Bottom sheet widget for the mobile version of the attachment picker. -class StreamMobileAttachmentPickerBottomSheet extends StatefulWidget { - /// Creates a new instance of [StreamMobileAttachmentPickerBottomSheet]. - const StreamMobileAttachmentPickerBottomSheet({ +/// Bottom sheet widget for the tabbed attachment picker interface. +/// This is used when the attachment picker displays a tabbed interface, +/// typically on mobile when useSystemAttachmentPicker is false. +class StreamTabbedAttachmentPickerBottomSheet extends StatefulWidget { + /// Creates a new instance of [StreamTabbedAttachmentPickerBottomSheet]. + const StreamTabbedAttachmentPickerBottomSheet({ super.key, required this.options, required this.controller, @@ -403,54 +67,49 @@ class StreamMobileAttachmentPickerBottomSheet extends StatefulWidget { }); /// The list of options. - final Set options; + final Set options; /// The initial option to be selected. - final AttachmentPickerOption? initialOption; + final TabbedAttachmentPickerOption? initialOption; /// The controller of the attachment picker. final StreamAttachmentPickerController controller; /// The callback when the send button gets tapped. - final ValueSetter? onSendValue; + final ValueSetter? onSendValue; @override - State createState() => - _StreamMobileAttachmentPickerBottomSheetState(); + State createState() => + _StreamTabbedAttachmentPickerBottomSheetState(); } -class _StreamMobileAttachmentPickerBottomSheetState - extends State { - late AttachmentPickerOption _currentOption; +class _StreamTabbedAttachmentPickerBottomSheetState + extends State { + // The current option selected in the tabbed attachment picker. + late var _currentOption = _calculateInitialOption(); + TabbedAttachmentPickerOption _calculateInitialOption() { + if (widget.initialOption case final option?) return option; - @override - void initState() { - super.initState(); - if (widget.initialOption == null) { - final enabledTypes = widget.controller.filterEnabledTypes( - options: widget.options, - ); - if (enabledTypes.isNotEmpty) { - _currentOption = widget.options.firstWhere((it) { - return it.supportedTypes.contains(enabledTypes.first); - }); - } else { - _currentOption = widget.options.first; - } - } else { - _currentOption = widget.initialOption!; - } + final options = widget.options; + final currentValue = widget.controller.value; + final enabledTypes = currentValue.filterEnabledTypes(options: options); + + if (enabledTypes.isEmpty) return options.first; + + return options.firstWhere( + (it) => enabledTypes.any(it.supportedTypes.contains), + ); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: widget.controller, - builder: (context, attachments, _) { + builder: (context, value, _) { return Column( mainAxisSize: MainAxisSize.min, children: [ - _AttachmentPickerOptions( + _TabbedAttachmentPickerOptions( controller: widget.controller, options: widget.options, currentOption: _currentOption, @@ -460,9 +119,10 @@ class _StreamMobileAttachmentPickerBottomSheetState }, ), Expanded( - child: _currentOption.optionViewBuilder - ?.call(context, widget.controller) ?? - const Empty(), + child: _currentOption.optionViewBuilder( + context, + widget.controller, + ), ), ], ); @@ -471,8 +131,8 @@ class _StreamMobileAttachmentPickerBottomSheetState } } -class _AttachmentPickerOptions extends StatelessWidget { - const _AttachmentPickerOptions({ +class _TabbedAttachmentPickerOptions extends StatelessWidget { + const _TabbedAttachmentPickerOptions({ required this.options, required this.currentOption, required this.controller, @@ -480,19 +140,20 @@ class _AttachmentPickerOptions extends StatelessWidget { this.onSendValue, }); - final Iterable options; - final AttachmentPickerOption currentOption; + final Iterable options; + final TabbedAttachmentPickerOption currentOption; final StreamAttachmentPickerController controller; - final ValueSetter? onOptionSelected; - final ValueSetter? onSendValue; + final ValueSetter? onOptionSelected; + final ValueSetter? onSendValue; @override Widget build(BuildContext context) { final colorTheme = StreamChatTheme.of(context).colorTheme; return ValueListenableBuilder( valueListenable: controller, - builder: (context, attachments, __) { - final enabledTypes = controller.filterEnabledTypes(options: options); + builder: (context, value, __) { + final enabledTypes = value.filterEnabledTypes(options: options); + return Row( children: [ Expanded( @@ -502,18 +163,19 @@ class _AttachmentPickerOptions extends StatelessWidget { children: [ ...options.map( (option) { - final supportedTypes = option.supportedTypes; - + final supported = option.supportedTypes; + final isEnabled = enabledTypes.any(supported.contains); final isSelected = option == currentOption; - final isEnabled = enabledTypes.isEmpty || - enabledTypes.any(supportedTypes.contains); - final color = isSelected - ? colorTheme.accentPrimary - : colorTheme.textLowEmphasis; + final color = switch (isSelected) { + true => colorTheme.accentPrimary, + _ => colorTheme.textLowEmphasis, + }; - final onPressed = - isEnabled ? () => onOptionSelected!(option) : null; + final onPressed = switch (isEnabled) { + true => () => onOptionSelected?.call(option), + _ => null, + }; return IconButton( color: color, @@ -529,12 +191,18 @@ class _AttachmentPickerOptions extends StatelessWidget { ), Builder( builder: (context) { - final VoidCallback? onPressed; - if (onSendValue != null && controller.isValueChanged) { - onPressed = () => onSendValue!(attachments); - } else { - onPressed = null; - } + final initialValue = controller.initialValue; + final isValueChanged = value != initialValue; + + final onPressed = switch (onSendValue) { + final onSendValue? when isValueChanged => () { + final result = AttachmentsPicked( + attachments: value.attachments, + ); + return onSendValue(result); + }, + _ => null, + }; return IconButton( color: colorTheme.accentPrimary, @@ -744,42 +412,62 @@ class OptionDrawer extends StatelessWidget { } } -/// Returns the mobile version of the attachment picker. -Widget mobileAttachmentPickerBuilder({ +/// Builds a tabbed attachment picker with custom interfaces for different +/// attachment types. +/// +/// Shows horizontal tabs for gallery, files, camera, video, and polls. Each +/// tab displays a specialized interface for selecting that type of +/// attachment. Tabs get enabled or disabled based on what you've already +/// selected. +/// +/// This is the default interface for mobile platforms. Configure with +/// [customOptions], [galleryPickerConfig], [pollConfig], and +/// [allowedTypes]. +Widget tabbedAttachmentPickerBuilder({ required BuildContext context, required StreamAttachmentPickerController controller, - Poll? initialPoll, PollConfig? pollConfig, - Iterable? customOptions, + GalleryPickerConfig? galleryPickerConfig, + Iterable? customOptions, List allowedTypes = AttachmentPickerType.values, - ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), - ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg, - int attachmentThumbnailQuality = 100, - double attachmentThumbnailScale = 1, - ErrorListener? onError, }) { - return StreamMobileAttachmentPickerBottomSheet( + Future _handleSingePick( + StreamAttachmentPickerController controller, + Attachment? attachment, + ) async { + try { + if (attachment != null) await controller.addAttachment(attachment); + return AttachmentsPicked(attachments: controller.value.attachments); + } catch (error, stk) { + return AttachmentPickerError(error: error, stackTrace: stk); + } + } + + return StreamTabbedAttachmentPickerBottomSheet( controller: controller, onSendValue: Navigator.of(context).pop, options: { ...{ - if (customOptions != null) ...customOptions, - AttachmentPickerOption( + TabbedAttachmentPickerOption( key: 'gallery-picker', icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), supportedTypes: [ AttachmentPickerType.images, AttachmentPickerType.videos, ], + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; + + // Otherwise, enable only if there is at least a image or a video. + return value.attachments.any((it) => it.isImage || it.isVideo); + }, optionViewBuilder: (context, controller) { final attachment = controller.value.attachments; final selectedIds = attachment.map((it) => it.id); return StreamGalleryPicker( + config: galleryPickerConfig, selectedMediaItems: selectedIds, - mediaThumbnailSize: attachmentThumbnailSize, - mediaThumbnailFormat: attachmentThumbnailFormat, - mediaThumbnailQuality: attachmentThumbnailQuality, - mediaThumbnailScale: attachmentThumbnailScale, onMediaItemSelected: (media) async { try { if (selectedIds.contains(media.id)) { @@ -787,178 +475,186 @@ Widget mobileAttachmentPickerBuilder({ } return await controller.addAssetAttachment(media); } catch (e, stk) { - if (onError != null) return onError.call(e, stk); - rethrow; + final err = AttachmentPickerError(error: e, stackTrace: stk); + return Navigator.pop(context, err); } }, ); }, ), - AttachmentPickerOption( + TabbedAttachmentPickerOption( key: 'file-picker', icon: const StreamSvgIcon(icon: StreamSvgIcons.files), supportedTypes: [AttachmentPickerType.files], - optionViewBuilder: (context, controller) { - return StreamFilePicker( - onFilePicked: (file) async { - try { - if (file != null) await controller.addAttachment(file); - return Navigator.pop(context, controller.value); - } catch (e, stk) { - Navigator.pop(context, controller.value); - if (onError != null) return onError.call(e, stk); + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; - rethrow; - } - }, - ); + // Otherwise, enable only if there is at least a file. + return value.attachments.any((it) => it.isFile); }, + optionViewBuilder: (context, controller) => StreamFilePicker( + onFilePicked: (file) async { + final result = await _handleSingePick(controller, file); + return Navigator.pop(context, result); + }, + ), ), - AttachmentPickerOption( + TabbedAttachmentPickerOption( key: 'image-picker', icon: const StreamSvgIcon(icon: StreamSvgIcons.camera), supportedTypes: [AttachmentPickerType.images], - optionViewBuilder: (context, controller) { - return StreamImagePicker( - onImagePicked: (image) async { - try { - if (image != null) { - await controller.addAttachment(image); - } - return Navigator.pop(context, controller.value); - } catch (e, stk) { - Navigator.pop(context, controller.value); - if (onError != null) return onError.call(e, stk); + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; - rethrow; - } - }, - ); + // Otherwise, enable only if there is at least a image. + return value.attachments.any((it) => it.isImage); }, + optionViewBuilder: (context, controller) => StreamImagePicker( + onImagePicked: (image) async { + final result = await _handleSingePick(controller, image); + return Navigator.pop(context, result); + }, + ), ), - AttachmentPickerOption( + TabbedAttachmentPickerOption( key: 'video-picker', icon: const StreamSvgIcon(icon: StreamSvgIcons.record), supportedTypes: [AttachmentPickerType.videos], - optionViewBuilder: (context, controller) { - return StreamVideoPicker( - onVideoPicked: (video) async { - try { - if (video != null) { - await controller.addAttachment(video); - } - return Navigator.pop(context, controller.value); - } catch (e, stk) { - Navigator.pop(context, controller.value); - if (onError != null) return onError.call(e, stk); + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; - rethrow; - } - }, - ); + // Otherwise, enable only if there is at least a video. + return value.attachments.any((it) => it.isVideo); }, + optionViewBuilder: (context, controller) => StreamVideoPicker( + onVideoPicked: (video) async { + final result = await _handleSingePick(controller, video); + return Navigator.pop(context, result); + }, + ), ), - AttachmentPickerOption( + TabbedAttachmentPickerOption( key: 'poll-creator', icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), supportedTypes: [AttachmentPickerType.poll], + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; + + // Otherwise, enable only if there is a poll. + return value.poll != null; + }, optionViewBuilder: (context, controller) { final initialPoll = controller.value.poll; return StreamPollCreator( poll: initialPoll, config: pollConfig, onPollCreated: (poll) { - try { - if (poll != null) controller.poll = poll; - return Navigator.pop(context, controller.value); - } catch (e, stk) { - Navigator.pop(context, controller.value); - if (onError != null) return onError.call(e, stk); + if (poll == null) return Navigator.pop(context); + controller.poll = poll; - rethrow; - } + final result = PollCreated(poll: poll); + return Navigator.pop(context, result); }, ); }, ), + ...?customOptions, }.where((option) => option.supportedTypes.every(allowedTypes.contains)), }, ); } -/// Returns the web or desktop version of the attachment picker. -Widget webOrDesktopAttachmentPickerBuilder({ +/// Builds a system attachment picker that opens native platform dialogs. +/// +/// Shows a simple list of options that immediately launch your device's +/// built-in file browser, camera app, or other native tools instead of +/// custom interfaces. +/// +/// This is the default for web and desktop platforms, and can be enabled on +/// mobile with `useSystemAttachmentPicker`. Configure with [customOptions], +/// [pollConfig], and [allowedTypes]. +Widget systemAttachmentPickerBuilder({ required BuildContext context, required StreamAttachmentPickerController controller, - Poll? initialPoll, - PollConfig? pollConfig, - Iterable? customOptions, + PollConfig? pollConfig = const PollConfig(), + GalleryPickerConfig? galleryPickerConfig = const GalleryPickerConfig(), + Iterable? customOptions, List allowedTypes = AttachmentPickerType.values, - ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), - ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg, - int attachmentThumbnailQuality = 100, - double attachmentThumbnailScale = 1, - ErrorListener? onError, }) { - return StreamWebOrDesktopAttachmentPickerBottomSheet( + Future _pickSystemFile( + StreamAttachmentPickerController controller, + FileType type, + ) async { + try { + final file = await StreamAttachmentHandler.instance.pickFile(type: type); + if (file != null) await controller.addAttachment(file); + + return AttachmentsPicked(attachments: controller.value.attachments); + } catch (error, stk) { + return AttachmentPickerError(error: error, stackTrace: stk); + } + } + + return StreamSystemAttachmentPickerBottomSheet( controller: controller, options: { ...{ - if (customOptions != null) ...customOptions, - WebOrDesktopAttachmentPickerOption( + SystemAttachmentPickerOption( key: 'image-picker', - type: AttachmentPickerType.images, + supportedTypes: [AttachmentPickerType.images], icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), title: context.translations.uploadAPhotoLabel, + onTap: (context, controller) async { + final result = await _pickSystemFile(controller, FileType.image); + return Navigator.pop(context, result); + }, ), - WebOrDesktopAttachmentPickerOption( + SystemAttachmentPickerOption( key: 'video-picker', - type: AttachmentPickerType.videos, + supportedTypes: [AttachmentPickerType.videos], icon: const StreamSvgIcon(icon: StreamSvgIcons.record), title: context.translations.uploadAVideoLabel, + onTap: (context, controller) async { + final result = await _pickSystemFile(controller, FileType.video); + return Navigator.pop(context, result); + }, ), - WebOrDesktopAttachmentPickerOption( + SystemAttachmentPickerOption( key: 'file-picker', - type: AttachmentPickerType.files, + supportedTypes: [AttachmentPickerType.files], icon: const StreamSvgIcon(icon: StreamSvgIcons.files), title: context.translations.uploadAFileLabel, + onTap: (context, controller) async { + final result = await _pickSystemFile(controller, FileType.any); + return Navigator.pop(context, result); + }, ), - WebOrDesktopAttachmentPickerOption( + SystemAttachmentPickerOption( key: 'poll-creator', - type: AttachmentPickerType.poll, + supportedTypes: [AttachmentPickerType.poll], icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), title: context.translations.createPollLabel(isNew: true), - ), - }.where((option) => option.supportedTypes.every(allowedTypes.contains)), - }, - onOptionTap: (context, controller, option) async { - // Handle the polls type option separately - if (option.type case AttachmentPickerType.poll) { - final poll = await showStreamPollCreatorDialog( - context: context, - poll: initialPoll, - config: pollConfig, - ); - - if (poll != null) controller.poll = poll; - return Navigator.pop(context, controller.value); - } + onTap: (context, controller) async { + final initialPoll = controller.value.poll; + final poll = await showStreamPollCreatorDialog( + context: context, + poll: initialPoll, + config: pollConfig, + ); - // Handle the remaining option types. - try { - final attachment = await StreamAttachmentHandler.instance.pickFile( - type: option.type.fileType, - ); - if (attachment != null) { - await controller.addAttachment(attachment); - } - return Navigator.pop(context, controller.value); - } catch (e, stk) { - Navigator.pop(context, controller.value); - if (onError != null) return onError.call(e, stk); + if (poll == null) return Navigator.pop(context); + controller.poll = poll; - rethrow; - } + final result = PollCreated(poll: poll); + return Navigator.pop(context, result); + }, + ), + ...?customOptions, + }.where((option) => option.supportedTypes.every(allowedTypes.contains)), }, ); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart index cbd164120e..2313552a1b 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart @@ -1,76 +1,73 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_gallery_picker.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// Shows a modal material design bottom sheet. +/// Shows a modal bottom sheet with the Stream attachment picker. /// -/// A modal bottom sheet is an alternative to a menu or a dialog and prevents -/// the user from interacting with the rest of the app. +/// The picker supports two modes: /// -/// A closely related widget is a persistent bottom sheet, which shows -/// information that supplements the primary content of the app without -/// preventing the use from interacting with the app. Persistent bottom sheets -/// can be created and displayed with the [showBottomSheet] function or the -/// [ScaffoldState.showBottomSheet] method. +/// - **Tabbed interface**: Typically used on mobile platforms. Provide +/// [TabbedAttachmentPickerOption] values in [customOptions]. This mode is +/// active when [useSystemAttachmentPicker] is false (default). /// -/// The `context` argument is used to look up the [Navigator] and [Theme] for -/// the bottom sheet. It is only used when the method is called. Its -/// corresponding widget can be safely removed from the tree before the bottom -/// sheet is closed. +/// - **System integration**: Used on web, desktop, or when +/// [useSystemAttachmentPicker] is true. Provide +/// [SystemAttachmentPickerOption] values in [customOptions]. /// -/// The `isScrollControlled` parameter specifies whether this is a route for -/// a bottom sheet that will utilize [DraggableScrollableSheet]. If you wish -/// to have a bottom sheet that has a scrollable child such as a [ListView] or -/// a [GridView] and have the bottom sheet be draggable, you should set this -/// parameter to true. +/// When using the system picker, all [customOptions] must be +/// [SystemAttachmentPickerOption] instances. If any other type is included, +/// an [ArgumentError] is thrown. /// -/// The `useRootNavigator` parameter ensures that the root navigator is used to -/// display the [BottomSheet] when set to `true`. This is useful in the case -/// that a modal [BottomSheet] needs to be displayed above all other content -/// but the caller is inside another [Navigator]. +/// Example using the tabbed interface: +/// ```dart +/// showStreamAttachmentPickerModalBottomSheet( +/// context: context, +/// customOptions: [ +/// TabbedAttachmentPickerOption( +/// key: 'gallery', +/// icon: Icon(Icons.photo), +/// supportedTypes: [AttachmentPickerType.images], +/// optionViewBuilder: (context, controller) { +/// return CustomGalleryWidget(); +/// }, +/// ), +/// ], +/// ); +/// ``` /// -/// The [isDismissible] parameter specifies whether the bottom sheet will be -/// dismissed when user taps on the scrim. +/// Example using the system picker: +/// ```dart +/// showStreamAttachmentPickerModalBottomSheet( +/// context: context, +/// useSystemAttachmentPicker: true, +/// customOptions: [ +/// SystemAttachmentPickerOption( +/// key: 'upload', +/// type: AttachmentPickerType.files, +/// icon: Icon(Icons.upload_file), +/// title: 'Upload File', +/// onTap: (context, controller) async { +/// // Handle file picker +/// }, +/// ), +/// ], +/// ); +/// ``` /// -/// The [enableDrag] parameter specifies whether the bottom sheet can be -/// dragged up and down and dismissed by swiping downwards. -/// -/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], -/// [constraints] and [transitionAnimationController] -/// parameters can be passed in to customize the appearance and behavior of -/// modal bottom sheets (see the documentation for these on [BottomSheet] -/// for more details). -/// -/// The [transitionAnimationController] controls the bottom sheet's entrance and -/// exit animations if provided. -/// -/// The optional `routeSettings` parameter sets the [RouteSettings] -/// of the modal bottom sheet sheet. -/// This is particularly useful in the case that a user wants to observe -/// [PopupRoute]s within a [NavigatorObserver]. -/// -/// Returns a `Future` that resolves to the value (if any) that was passed to -/// [Navigator.pop] when the modal bottom sheet was closed. -/// -/// See also: -/// -/// * [BottomSheet], which becomes the parent of the widget returned by the -/// function passed as the `builder` argument to [showModalBottomSheet]. -/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing -/// non-modal bottom sheets. -/// * [DraggableScrollableSheet], which allows you to create a bottom sheet -/// that grows and then becomes scrollable once it reaches its maximum size. -/// * +/// Returns a [Future] that completes with the value passed to [Navigator.pop], +/// or `null` if the sheet was dismissed. Future showStreamAttachmentPickerModalBottomSheet({ required BuildContext context, Iterable? customOptions, List allowedTypes = AttachmentPickerType.values, Poll? initialPoll, PollConfig? pollConfig, + GalleryPickerConfig? galleryPickerConfig, List? initialAttachments, + Map? initialExtraData, StreamAttachmentPickerController? controller, - ErrorListener? onError, Color? backgroundColor, double? elevation, BoxConstraints? constraints, @@ -86,15 +83,11 @@ Future showStreamAttachmentPickerModalBottomSheet({ AnimationController? transitionAnimationController, Clip? clipBehavior = Clip.hardEdge, ShapeBorder? shape, - ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), - ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg, - int attachmentThumbnailQuality = 100, - double attachmentThumbnailScale = 1, }) { final colorTheme = StreamChatTheme.of(context).colorTheme; final color = backgroundColor ?? colorTheme.inputBg; - return showModalBottomSheet( + return showModalBottomSheet( context: context, backgroundColor: color, elevation: elevation, @@ -109,53 +102,73 @@ Future showStreamAttachmentPickerModalBottomSheet({ routeSettings: routeSettings, transitionAnimationController: transitionAnimationController, builder: (BuildContext context) { - return StreamPlatformAttachmentPickerBottomSheetBuilder( + return StreamAttachmentPickerBottomSheetBuilder( controller: controller, initialPoll: initialPoll, initialAttachments: initialAttachments, + initialExtraData: initialExtraData, builder: (context, controller, child) { final isWebOrDesktop = switch (CurrentPlatform.type) { - PlatformType.web || - PlatformType.macOS || - PlatformType.linux || - PlatformType.windows => - true, - _ => false, + PlatformType.android || PlatformType.ios => false, + _ => true, }; - final useSystemPicker = useSystemAttachmentPicker || // - useNativeAttachmentPickerOnMobile; + final useSystemPicker = useSystemAttachmentPicker || isWebOrDesktop; + + if (useSystemPicker) { + final invalidOptions = []; + final customSystemOptions = []; + + for (final option in customOptions ?? []) { + if (option is SystemAttachmentPickerOption) { + customSystemOptions.add(option); + } else { + invalidOptions.add(option); + } + } + + if (invalidOptions.isNotEmpty) { + throw ArgumentError( + 'customOptions must be SystemAttachmentPickerOption when using ' + 'the attachment picker (enabled explicitly or on web/desktop).', + ); + } - if (useSystemPicker || isWebOrDesktop) { - return webOrDesktopAttachmentPickerBuilder.call( + return systemAttachmentPickerBuilder.call( context: context, - onError: onError, controller: controller, allowedTypes: allowedTypes, - customOptions: customOptions?.map( - WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption, - ), - initialPoll: initialPoll, + customOptions: customSystemOptions, pollConfig: pollConfig, - attachmentThumbnailSize: attachmentThumbnailSize, - attachmentThumbnailFormat: attachmentThumbnailFormat, - attachmentThumbnailQuality: attachmentThumbnailQuality, - attachmentThumbnailScale: attachmentThumbnailScale, + galleryPickerConfig: galleryPickerConfig, ); } - return mobileAttachmentPickerBuilder.call( + final invalidOptions = []; + final customTabbedOptions = []; + + for (final option in customOptions ?? []) { + if (option is TabbedAttachmentPickerOption) { + customTabbedOptions.add(option); + } else { + invalidOptions.add(option); + } + } + + if (invalidOptions.isNotEmpty == true) { + throw ArgumentError( + 'customOptions must be TabbedAttachmentPickerOption when using ' + 'the tabbed picker (default on mobile).', + ); + } + + return tabbedAttachmentPickerBuilder.call( context: context, - onError: onError, controller: controller, allowedTypes: allowedTypes, - customOptions: customOptions, - initialPoll: initialPoll, + customOptions: customTabbedOptions, pollConfig: pollConfig, - attachmentThumbnailSize: attachmentThumbnailSize, - attachmentThumbnailFormat: attachmentThumbnailFormat, - attachmentThumbnailQuality: attachmentThumbnailQuality, - attachmentThumbnailScale: attachmentThumbnailScale, + galleryPickerConfig: galleryPickerConfig, ); }, ); @@ -164,13 +177,13 @@ Future showStreamAttachmentPickerModalBottomSheet({ } /// Builds the attachment picker bottom sheet. -class StreamPlatformAttachmentPickerBottomSheetBuilder extends StatefulWidget { +class StreamAttachmentPickerBottomSheetBuilder extends StatefulWidget { /// Creates a new instance of the widget. - const StreamPlatformAttachmentPickerBottomSheetBuilder({ + const StreamAttachmentPickerBottomSheetBuilder({ super.key, - this.customOptions, this.initialPoll, this.initialAttachments, + this.initialExtraData, this.child, this.controller, required this.builder, @@ -186,25 +199,25 @@ class StreamPlatformAttachmentPickerBottomSheetBuilder extends StatefulWidget { Widget? child, ) builder; - /// The custom options to be displayed in the attachment picker. - final List? customOptions; - /// The initial poll. final Poll? initialPoll; /// The initial attachments. final List? initialAttachments; + /// The initial extra data for the attachment picker. + final Map? initialExtraData; + /// The controller. final StreamAttachmentPickerController? controller; @override - State createState() => - _StreamPlatformAttachmentPickerBottomSheetBuilderState(); + State createState() => + _StreamAttachmentPickerBottomSheetBuilderState(); } -class _StreamPlatformAttachmentPickerBottomSheetBuilderState - extends State { +class _StreamAttachmentPickerBottomSheetBuilderState + extends State { late StreamAttachmentPickerController _controller; @override @@ -214,6 +227,7 @@ class _StreamPlatformAttachmentPickerBottomSheetBuilderState StreamAttachmentPickerController( initialPoll: widget.initialPoll, initialAttachments: widget.initialAttachments, + initialExtraData: widget.initialExtraData, ); } @@ -231,6 +245,7 @@ class _StreamPlatformAttachmentPickerBottomSheetBuilderState _controller = StreamAttachmentPickerController( initialPoll: widget.initialPoll, initialAttachments: widget.initialAttachments, + initialExtraData: widget.initialExtraData, ); } else { _controller = current; @@ -239,7 +254,7 @@ class _StreamPlatformAttachmentPickerBottomSheetBuilderState @override void didUpdateWidget( - StreamPlatformAttachmentPickerBottomSheetBuilder oldWidget, + StreamAttachmentPickerBottomSheetBuilder oldWidget, ) { super.didUpdateWidget(oldWidget); _updateAttachmentPickerController( diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_controller.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_controller.dart new file mode 100644 index 0000000000..1353931a42 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_controller.dart @@ -0,0 +1,259 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// The default maximum size for media attachments. +const kDefaultMaxAttachmentSize = 100 * 1024 * 1024; // 100MB in Bytes + +/// The default maximum number of media attachments. +const kDefaultMaxAttachmentCount = 10; + +/// Controller class for [StreamAttachmentPicker]. +class StreamAttachmentPickerController + extends ValueNotifier { + /// Creates a new instance of [StreamAttachmentPickerController]. + factory StreamAttachmentPickerController({ + Poll? initialPoll, + List? initialAttachments, + Map? initialExtraData, + int maxAttachmentSize = kDefaultMaxAttachmentSize, + int maxAttachmentCount = kDefaultMaxAttachmentCount, + }) { + return StreamAttachmentPickerController._fromValue( + AttachmentPickerValue( + poll: initialPoll, + attachments: initialAttachments ?? const [], + extraData: initialExtraData ?? const {}, + ), + maxAttachmentSize: maxAttachmentSize, + maxAttachmentCount: maxAttachmentCount, + ); + } + + StreamAttachmentPickerController._fromValue( + this.initialValue, { + this.maxAttachmentSize = kDefaultMaxAttachmentSize, + this.maxAttachmentCount = kDefaultMaxAttachmentCount, + }) : assert( + (initialValue.attachments.length) <= maxAttachmentCount, + '''The initial attachments count must be less than or equal to maxAttachmentCount''', + ), + super(initialValue); + + /// Initial value for the controller. + final AttachmentPickerValue initialValue; + + /// The max attachment size allowed in bytes. + final int maxAttachmentSize; + + /// The max attachment count allowed. + final int maxAttachmentCount; + + @override + set value(AttachmentPickerValue newValue) { + if (newValue.attachments.length > maxAttachmentCount) { + throw ArgumentError( + 'The maximum number of attachments is $maxAttachmentCount.', + ); + } + super.value = newValue; + } + + /// Adds a new [poll] to the message. + set poll(Poll? poll) => value = value.copyWith(poll: poll); + + /// Sets the extra data value for the controller. + /// + /// This can be used to store custom attachment values in case a custom + /// attachment picker option is used. + set extraData(Map? extraData) { + value = value.copyWith(extraData: extraData); + } + + Future _saveToCache(AttachmentFile file) async { + // Cache the attachment in a temporary file. + return StreamAttachmentHandler.instance.saveAttachmentFile( + attachmentFile: file, + ); + } + + Future _removeFromCache(AttachmentFile file) { + // Remove the cached attachment file. + return StreamAttachmentHandler.instance.deleteAttachmentFile( + attachmentFile: file, + ); + } + + /// Adds a new attachment to the message. + Future addAttachment(Attachment attachment) async { + assert(attachment.fileSize != null, ''); + if (attachment.fileSize! > maxAttachmentSize) { + throw ArgumentError( + 'The size of the attachment is ${attachment.fileSize} bytes, ' + 'but the maximum size allowed is $maxAttachmentSize bytes.', + ); + } + + final file = attachment.file; + final uploadState = attachment.uploadState; + + // No need to cache the attachment if it's already uploaded + // or we are on web. + if (file == null || uploadState.isSuccess || CurrentPlatform.isWeb) { + value = value.copyWith(attachments: [...value.attachments, attachment]); + return; + } + + // Cache the attachment in a temporary file. + final tempFilePath = await _saveToCache(file); + + value = value.copyWith(attachments: [ + ...value.attachments, + attachment.copyWith( + file: file.copyWith( + path: tempFilePath, + ), + ), + ]); + } + + /// Removes the specified [attachment] from the message. + Future removeAttachment(Attachment attachment) async { + final file = attachment.file; + final uploadState = attachment.uploadState; + + if (file == null || uploadState.isSuccess || CurrentPlatform.isWeb) { + final updatedAttachments = [...value.attachments]..remove(attachment); + value = value.copyWith(attachments: updatedAttachments); + return; + } + + // Remove the cached attachment file. + await _removeFromCache(file); + + final updatedAttachments = [...value.attachments]..remove(attachment); + value = value.copyWith(attachments: updatedAttachments); + } + + /// Remove the attachment with the given [attachmentId]. + void removeAttachmentById(String attachmentId) { + final attachment = value.attachments.firstWhereOrNull( + (attachment) => attachment.id == attachmentId, + ); + + if (attachment == null) return; + + removeAttachment(attachment); + } + + /// Clears all the attachments. + Future clear() async { + final attachments = [...value.attachments]; + for (final attachment in attachments) { + final file = attachment.file; + final uploadState = attachment.uploadState; + + if (file == null || uploadState.isSuccess || CurrentPlatform.isWeb) { + continue; + } + + await _removeFromCache(file); + } + value = const AttachmentPickerValue(); + } + + /// Resets the controller to its initial state. + Future reset() async { + final attachments = [...value.attachments]; + for (final attachment in attachments) { + final file = attachment.file; + final uploadState = attachment.uploadState; + + if (file == null || uploadState.isSuccess || CurrentPlatform.isWeb) { + continue; + } + + await _removeFromCache(file); + } + + value = initialValue; + } +} + +/// Value class for [AttachmentPickerController]. +/// +/// This class holds the list of [Poll] and [Attachment] objects. +class AttachmentPickerValue { + /// Creates a new instance of [AttachmentPickerValue]. + const AttachmentPickerValue({ + this.poll, + this.attachments = const [], + this.extraData = const {}, + }); + + /// The poll object. + final Poll? poll; + + /// The list of [Attachment] objects. + final List attachments; + + /// Extra data that can be used to store custom attachment values. + final Map extraData; + + /// Returns true if the value is empty, meaning it has no poll, + /// no attachments and no extra data set. + bool get isEmpty { + if (poll != null) return false; + if (attachments.isNotEmpty) return false; + if (extraData.isNotEmpty) return false; + + return true; + } + + /// Returns a copy of this object with the provided values. + AttachmentPickerValue copyWith({ + Poll? poll, + List? attachments, + Map? extraData, + }) { + return AttachmentPickerValue( + poll: poll ?? this.poll, + attachments: attachments ?? this.attachments, + extraData: extraData ?? this.extraData, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + if (other is! AttachmentPickerValue) return false; + + final isPollEqual = other.poll == poll; + + final areAttachmentsEqual = UnorderedIterableEquality( + EqualityBy((attachment) => attachment.id), + ).equals(other.attachments, attachments); + + final isExtraDataEqual = const DeepCollectionEquality.unordered().equals( + other.extraData, + extraData, + ); + + return isPollEqual && areAttachmentsEqual && isExtraDataEqual; + } + + @override + int get hashCode { + final attachmentsHash = UnorderedIterableEquality( + EqualityBy((attachment) => attachment.id), + ).hash(attachments); + + final extraDataHash = const DeepCollectionEquality.unordered().hash( + extraData, + ); + + return poll.hashCode ^ attachmentsHash ^ extraDataHash; + } +} 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 new file mode 100644 index 0000000000..1eec1d4638 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_option.dart @@ -0,0 +1,198 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_controller.dart'; + +/// Function signature for building the attachment picker option view. +typedef AttachmentPickerOptionViewBuilder = Widget Function( + BuildContext context, + StreamAttachmentPickerController controller, +); + +/// Function signature for system attachment picker option callback. +typedef OnSystemAttachmentPickerOptionTap = Future Function( + BuildContext context, + StreamAttachmentPickerController controller, +); + +/// Base class for attachment picker options. +abstract class AttachmentPickerOption { + /// Creates a new instance of [AttachmentPickerOption]. + const AttachmentPickerOption({ + this.key, + required this.supportedTypes, + required this.icon, + this.title, + this.isEnabled = _defaultIsEnabled, + }); + + /// A key to identify the option. + final String? key; + + /// The icon of the option. + final Widget icon; + + /// The title of the option. + final String? title; + + /// The supported types of the option. + final Iterable supportedTypes; + + /// Determines if this option is enabled based on the current value. + /// + /// If not provided, defaults to always returning true. + final bool Function(AttachmentPickerValue value) isEnabled; + static bool _defaultIsEnabled(AttachmentPickerValue value) => true; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! AttachmentPickerOption) return false; + if (runtimeType != other.runtimeType) return false; + + final areSupportedTypesEqual = const UnorderedIterableEquality().equals( + supportedTypes, + other.supportedTypes, + ); + + return key == other.key && areSupportedTypesEqual; + } + + @override + int get hashCode { + final supportedTypesHash = const UnorderedIterableEquality().hash( + supportedTypes, + ); + + return key.hashCode ^ supportedTypesHash; + } +} + +/// Attachment picker option that shows custom UI inside the attachment picker's +/// tabbed interface. Use this when you want to display your own custom +/// interface for selecting attachments. +/// +/// This option is used when the attachment picker displays a tabbed interface +/// (typically on mobile when useSystemAttachmentPicker is false). +class TabbedAttachmentPickerOption extends AttachmentPickerOption { + /// Creates a new instance of [TabbedAttachmentPickerOption]. + const TabbedAttachmentPickerOption({ + super.key, + required super.supportedTypes, + required super.icon, + required this.optionViewBuilder, + super.title, + super.isEnabled, + }); + + /// The option view builder for custom UI. + final AttachmentPickerOptionViewBuilder optionViewBuilder; +} + +/// Attachment picker option that uses system integration +/// (file dialogs, camera, etc.). +/// +/// Use this when you want to open system pickers or perform system actions. +class SystemAttachmentPickerOption extends AttachmentPickerOption { + /// Creates a new instance of [SystemAttachmentPickerOption]. + const SystemAttachmentPickerOption({ + super.key, + required super.supportedTypes, + required super.icon, + required this.title, + required this.onTap, + super.isEnabled, + }); + + @override + final String title; + + /// The callback that is called when the option is tapped. + final OnSystemAttachmentPickerOptionTap onTap; +} + +/// Helpful extensions for [StreamAttachmentPickerController]. +extension AttachmentPickerOptionTypeX on AttachmentPickerValue { + /// Returns the list of enabled picker types. + Set filterEnabledTypes({ + required Iterable options, + }) { + final enabledTypes = {}; + for (final option in options) { + if (option.isEnabled.call(this)) { + enabledTypes.addAll(option.supportedTypes); + } + } + return enabledTypes; + } +} + +/// {@template streamAttachmentPickerType} +/// A sealed class that represents different types of attachment which a picker +/// option can support. +/// {@endtemplate} +sealed class AttachmentPickerType { + const AttachmentPickerType(); + + /// The option will allow to pick images. + static const images = ImagesPickerType(); + + /// The option will allow to pick videos. + static const videos = VideosPickerType(); + + /// The option will allow to pick audios. + static const audios = AudiosPickerType(); + + /// The option will allow to pick files or documents. + static const files = FilesPickerType(); + + /// The option will allow to create a poll. + static const poll = PollPickerType(); + + /// A list of all predefined attachment picker types. + static const values = [images, videos, audios, files, poll]; +} + +/// A predefined attachment picker type that allows picking images. +final class ImagesPickerType extends AttachmentPickerType { + /// Creates a new images picker type. + const ImagesPickerType(); +} + +/// A predefined attachment picker type that allows picking videos. +final class VideosPickerType extends AttachmentPickerType { + /// Creates a new videos picker type. + const VideosPickerType(); +} + +/// A predefined attachment picker type that allows picking audios. +final class AudiosPickerType extends AttachmentPickerType { + /// Creates a new audios picker type. + const AudiosPickerType(); +} + +/// A predefined attachment picker type that allows picking files or documents. +final class FilesPickerType extends AttachmentPickerType { + /// Creates a new files picker type. + const FilesPickerType(); +} + +/// A predefined attachment picker type that allows creating polls. +final class PollPickerType extends AttachmentPickerType { + /// Creates a new poll picker type. + const PollPickerType(); +} + +/// 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. +/// +/// Example: +/// ```dart +/// class DocumentPickerType extends CustomAttachmentPickerType { +/// const DocumentPickerType(); +/// } +/// ``` +abstract class CustomAttachmentPickerType extends AttachmentPickerType { + /// Creates a new custom picker type. + const CustomAttachmentPickerType(); +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_options_builder.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_options_builder.dart new file mode 100644 index 0000000000..88691c855d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_options_builder.dart @@ -0,0 +1,284 @@ +import 'package:file_picker/file_picker.dart' show FileType; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler.dart'; +import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_file_picker.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_gallery_picker.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_image_picker.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_poll_creator.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_video_picker.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_controller.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_option.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_result.dart'; +import 'package:stream_chat_flutter/src/poll/creator/stream_poll_creator_dialog.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +class StreamAttachmentPickerOptionsBuilder { + /// Private constructor to prevent instantiation + const StreamAttachmentPickerOptionsBuilder._(); + + static List buildTabbedOptions({ + required BuildContext context, + PollConfig? pollConfig, + GalleryPickerConfig? galleryPickerConfig, + Iterable? customOptions, + }) { + final pickerOptions = [ + _buildGalleryPickerOption(config: galleryPickerConfig), + _buildTabbedFilePickerOption(), + _buildTabbedImagePickerOption(), + _buildTabbedVideoPickerOption(), + _buildTabbedPollCreatorOption(config: pollConfig), + ...?customOptions, + ]; + + return pickerOptions; + } + + static List buildSystemOptions({ + required BuildContext context, + PollConfig? pollConfig, + GalleryPickerConfig? galleryPickerConfig, + Iterable? customOptions, + }) { + final pickerOptions = [ + _buildSystemImagePickerOption(context: context), + _buildSystemVideoPickerOption(context: context), + _buildSystemFilePickerOption(context: context), + _buildSystemPollCreatorOption(context: context, config: pollConfig), + ...?customOptions, + ]; + + return pickerOptions; + } +} + +TabbedAttachmentPickerOption _buildGalleryPickerOption({ + GalleryPickerConfig? config, +}) { + return TabbedAttachmentPickerOption( + key: 'gallery-picker', + icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), + supportedTypes: [AttachmentPickerType.images, AttachmentPickerType.videos], + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; + + // Otherwise, enable only if there is at least a image or a video. + return value.attachments.any((it) => it.isImage || it.isVideo); + }, + optionViewBuilder: (context, controller) { + final attachments = controller.value.attachments; + final selectedIds = attachments.map((it) => it.id); + return StreamGalleryPicker( + config: config, + selectedMediaItems: selectedIds, + onMediaItemSelected: (media) async { + try { + if (selectedIds.contains(media.id)) { + return await controller.removeAssetAttachment(media); + } + + return await controller.addAssetAttachment(media); + } catch (e, stk) { + final err = AttachmentPickerError(error: e, stackTrace: stk); + return Navigator.pop(context, err); + } + }, + ); + }, + ); +} + +Future _handleSingePick( + Attachment? attachment, + StreamAttachmentPickerController controller, +) async { + try { + if (attachment != null) await controller.addAttachment(attachment); + return AttachmentsPicked(attachments: controller.value.attachments); + } catch (error, stk) { + return AttachmentPickerError(error: error, stackTrace: stk); + } +} + +TabbedAttachmentPickerOption _buildTabbedFilePickerOption() { + return TabbedAttachmentPickerOption( + key: 'file-picker', + icon: const StreamSvgIcon(icon: StreamSvgIcons.files), + supportedTypes: [AttachmentPickerType.files], + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; + + // Otherwise, enable only if there is at least a file. + return value.attachments.any((it) => it.isFile); + }, + optionViewBuilder: (context, controller) => StreamFilePicker( + onFilePicked: (file) async { + final result = await _handleSingePick(file, controller); + return Navigator.pop(context, result); + }, + ), + ); +} + +TabbedAttachmentPickerOption _buildTabbedImagePickerOption() { + return TabbedAttachmentPickerOption( + key: 'image-picker', + icon: const StreamSvgIcon(icon: StreamSvgIcons.camera), + supportedTypes: [AttachmentPickerType.images], + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; + + // Otherwise, enable only if there is at least a image. + return value.attachments.any((it) => it.isImage); + }, + optionViewBuilder: (context, controller) => StreamImagePicker( + onImagePicked: (image) async { + final result = await _handleSingePick(image, controller); + return Navigator.pop(context, result); + }, + ), + ); +} + +TabbedAttachmentPickerOption _buildTabbedVideoPickerOption() { + return TabbedAttachmentPickerOption( + key: 'video-picker', + icon: const StreamSvgIcon(icon: StreamSvgIcons.record), + supportedTypes: [AttachmentPickerType.videos], + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; + + // Otherwise, enable only if there is at least a video. + return value.attachments.any((it) => it.isVideo); + }, + optionViewBuilder: (context, controller) => StreamVideoPicker( + onVideoPicked: (video) async { + final result = await _handleSingePick(video, controller); + return Navigator.pop(context, result); + }, + ), + ); +} + +TabbedAttachmentPickerOption _buildTabbedPollCreatorOption({ + PollConfig? config, +}) { + return TabbedAttachmentPickerOption( + key: 'poll-creator', + icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), + supportedTypes: [AttachmentPickerType.poll], + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; + + // Otherwise, enable only if there is a poll. + return value.poll != null; + }, + optionViewBuilder: (context, controller) { + final initialPoll = controller.value.poll; + return StreamPollCreator( + poll: initialPoll, + config: config, + onPollCreated: (poll) { + if (poll == null) return Navigator.pop(context); + controller.poll = poll; + + final result = PollCreated(poll: poll); + return Navigator.pop(context, result); + }, + ); + }, + ); +} + +Future _pickSystemFile( + FileType type, + StreamAttachmentPickerController controller, +) async { + try { + final file = await StreamAttachmentHandler.instance.pickFile(type: type); + if (file != null) await controller.addAttachment(file); + + return AttachmentsPicked(attachments: controller.value.attachments); + } catch (error, stk) { + return AttachmentPickerError(error: error, stackTrace: stk); + } +} + +SystemAttachmentPickerOption _buildSystemImagePickerOption({ + required BuildContext context, +}) { + return SystemAttachmentPickerOption( + key: 'image-picker', + supportedTypes: [AttachmentPickerType.images], + icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), + title: context.translations.uploadAPhotoLabel, + onTap: (context, controller) async { + final result = await _pickSystemFile(FileType.image, controller); + return Navigator.pop(context, result); + }, + ); +} + +SystemAttachmentPickerOption _buildSystemVideoPickerOption({ + required BuildContext context, +}) { + return SystemAttachmentPickerOption( + key: 'video-picker', + supportedTypes: [AttachmentPickerType.videos], + icon: const StreamSvgIcon(icon: StreamSvgIcons.record), + title: context.translations.uploadAVideoLabel, + onTap: (context, controller) async { + final result = await _pickSystemFile(FileType.video, controller); + return Navigator.pop(context, result); + }, + ); +} + +SystemAttachmentPickerOption _buildSystemFilePickerOption({ + required BuildContext context, +}) { + return SystemAttachmentPickerOption( + key: 'file-picker', + supportedTypes: [AttachmentPickerType.files], + icon: const StreamSvgIcon(icon: StreamSvgIcons.files), + title: context.translations.uploadAFileLabel, + onTap: (context, controller) async { + final result = await _pickSystemFile(FileType.any, controller); + return Navigator.pop(context, result); + }, + ); +} + +SystemAttachmentPickerOption _buildSystemPollCreatorOption({ + required BuildContext context, + PollConfig? config, +}) { + return SystemAttachmentPickerOption( + key: 'poll-creator', + supportedTypes: [AttachmentPickerType.poll], + icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), + title: context.translations.createPollLabel(isNew: true), + onTap: (context, controller) async { + final initialPoll = controller.value.poll; + final poll = await showStreamPollCreatorDialog( + context: context, + poll: initialPoll, + config: config, + ); + + // If the poll is null, it means the user cancelled the dialog. + if (poll == null) return Navigator.pop(context); + + // Otherwise, set the poll in the controller and pop the dialog. + final result = PollCreated(poll: controller.poll = poll); + return Navigator.pop(context, result); + }, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_result.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_result.dart new file mode 100644 index 0000000000..770fc4422b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_result.dart @@ -0,0 +1,58 @@ +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Signature for a function that is called when a custom attachment picker +/// result is received. +typedef OnCustomAttachmentPickerResult + = OnAttachmentPickerResult; + +/// Signature for a function that is called when a attachment picker result +/// is received. +typedef OnAttachmentPickerResult = void + Function(T result); + +/// {@template streamAttachmentPickerAction} +/// A sealed class that represents different results that can be returned +/// from the attachment picker. +/// {@endtemplate} +sealed class StreamAttachmentPickerResult { + const StreamAttachmentPickerResult(); +} + +/// A result indicating that the attachment picker was met with an error. +final class AttachmentPickerError extends StreamAttachmentPickerResult { + /// Create a new attachment picker error result + const AttachmentPickerError({required this.error, this.stackTrace}); + + /// The error that occurred in the attachment picker. + final Object error; + + /// The stack trace associated with the error, if available. + final StackTrace? stackTrace; +} + +/// A result indicating that some attachments were picked using the media +/// related options in the attachment picker (e.g., camera, gallery). +final class AttachmentsPicked extends StreamAttachmentPickerResult { + /// Create a new attachments picked result + const AttachmentsPicked({required this.attachments}); + + /// The list of attachments that were picked. + final List attachments; +} + +/// A result indicating that a poll was created using the create poll option +/// in the attachment picker. +final class PollCreated extends StreamAttachmentPickerResult { + /// Create a new poll created result + const PollCreated({required this.poll}); + + /// The poll that was created. + final Poll poll; +} + +/// A custom attachment picker result that can be extended to support +/// custom type of results from the attachment picker. +class CustomAttachmentPickerResult extends StreamAttachmentPickerResult { + /// Create a new custom attachment picker result + const CustomAttachmentPickerResult(); +} 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 52f723d324..7ae8d3a440 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 @@ -165,6 +165,8 @@ class StreamMessageInput extends StatefulWidget { ) bool useNativeAttachmentPickerOnMobile = false, this.pollConfig, + this.customAttachmentPickerOptions = const [], + this.onCustomAttachmentPickerResult, }) : assert( idleSendIcon == null || idleSendButton == null, 'idleSendIcon and idleSendButton cannot be used together', @@ -424,6 +426,16 @@ class StreamMessageInput extends StatefulWidget { /// If not provided, the default configuration is used. final PollConfig? pollConfig; + /// A list of custom attachment picker options that can be used to extend the + /// attachment picker functionality. + final List customAttachmentPickerOptions; + + /// Callback that is called when the custom attachment picker result is + /// received. + /// + /// This is used to handle the result of the custom attachment picker + final OnCustomAttachmentPickerResult? onCustomAttachmentPickerResult; + static String? _defaultHintGetter( BuildContext context, HintType type, @@ -946,39 +958,28 @@ class StreamMessageInputState extends State defaultButton; } - Future _sendPoll(Poll poll) { - final streamChannel = StreamChannel.of(context); - final channel = streamChannel.channel; - - return channel.sendPoll(poll); - } - - Future _updatePoll(Poll poll) { - final streamChannel = StreamChannel.of(context); - final channel = streamChannel.channel; - - return channel.updatePoll(poll); - } - - Future _deletePoll(Poll poll) { - final streamChannel = StreamChannel.of(context); - final channel = streamChannel.channel; - - return channel.deletePoll(poll); + Future _onPollCreated(Poll poll) async { + final channel = StreamChannel.of(context).channel; + return channel.sendPoll(poll).ignore(); } - Future _createOrUpdatePoll(Poll? old, Poll? current) async { - // If both are null or the same, return - if ((old == null && current == null) || old == current) return; + // Returns the list of allowed attachment picker types based on the + // current channel configuration and context. + List _getAllowedAttachmentPickerTypes() { + final allowedTypes = widget.allowedAttachmentPickerTypes.where((type) { + if (type != AttachmentPickerType.poll) return true; - // If old is null, i.e., there was no poll before, create the poll. - if (old == null) return _sendPoll(current!); + // We don't allow editing polls. + if (_isEditing) return false; + // We don't allow creating polls in threads. + if (_effectiveController.message.parentId != null) return false; - // If current is null, i.e., the poll is removed, delete the poll. - if (current == null) return _deletePoll(old); + // Otherwise, check if the user has the permission to send polls. + final channel = StreamChannel.of(context).channel; + return channel.config?.polls == true && channel.canSendPoll; + }); - // Otherwise, update the poll. - return _updatePoll(current); + return allowedTypes.toList(growable: false); } /// Handle the platform-specific logic for selecting files. @@ -989,40 +990,46 @@ class StreamMessageInputState extends State Future _onAttachmentButtonPressed() async { final initialPoll = _effectiveController.poll; final initialAttachments = _effectiveController.attachments; - - // Remove AttachmentPickerType.poll if the user doesn't have the permission - // to send a poll or if this is a thread message. - final allowedTypes = [...widget.allowedAttachmentPickerTypes] - ..removeWhere((it) { - if (it != AttachmentPickerType.poll) return false; - if (_effectiveController.message.parentId != null) return true; - final channel = StreamChannel.of(context).channel; - if (channel.config?.polls == true && channel.canSendPoll) return false; - - return true; - }); + final allowedTypes = _getAllowedAttachmentPickerTypes(); final messageInputTheme = StreamMessageInputTheme.of(context); final useSystemPicker = widget.useSystemAttachmentPicker || (messageInputTheme.useSystemAttachmentPicker ?? false); - final value = await showStreamAttachmentPickerModalBottomSheet( + final result = await showStreamAttachmentPickerModalBottomSheet( context: context, - onError: widget.onError, allowedTypes: allowedTypes, - pollConfig: widget.pollConfig, initialPoll: initialPoll, + pollConfig: widget.pollConfig, initialAttachments: initialAttachments, useSystemAttachmentPicker: useSystemPicker, + customOptions: widget.customAttachmentPickerOptions, ); - if (value == null || value is! AttachmentPickerValue) return; + if (result == null || result is! StreamAttachmentPickerResult) return; + + void _onAttachmentsPicked(List attachments) { + _effectiveController.attachments = attachments; + } - // Add the attachments to the controller. - _effectiveController.attachments = value.attachments; + void _onAttachmentPickerError(AttachmentPickerError error) { + return widget.onError?.call(error.error, error.stackTrace); + } - // Create or update the poll. - await _createOrUpdatePoll(initialPoll, value.poll); + void _onCustomAttachmentPickerResult(CustomAttachmentPickerResult result) { + return widget.onCustomAttachmentPickerResult?.call(result); + } + + return switch (result) { + // Add the attachments to the controller. + AttachmentsPicked() => _onAttachmentsPicked(result.attachments), + // Send the created poll in the channel. + PollCreated() => _onPollCreated(result.poll), + // Handle custom attachment picker results. + CustomAttachmentPickerResult() => _onCustomAttachmentPickerResult(result), + // Handle/Notify returned errors. + AttachmentPickerError() => _onAttachmentPickerError(result), + }; } Widget _buildTextInput(BuildContext context) { diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index f98af3526c..dfda73cc6a 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -472,18 +472,12 @@ extension TypeX on T? { extension FileTypeX on FileType { /// Converts the [FileType] to a [String]. String toAttachmentType() { - switch (this) { - case FileType.image: - return AttachmentType.image; - case FileType.video: - return AttachmentType.video; - case FileType.audio: - return AttachmentType.audio; - case FileType.any: - case FileType.media: - case FileType.custom: - return AttachmentType.file; - } + return switch (this) { + FileType.image => AttachmentType.image, + FileType.video => AttachmentType.video, + FileType.audio => AttachmentType.audio, + FileType.any || FileType.media || FileType.custom => AttachmentType.file, + }; } } @@ -491,18 +485,16 @@ extension FileTypeX on FileType { extension AttachmentPickerTypeX on AttachmentPickerType { /// Converts the [AttachmentPickerType] to a [FileType]. FileType get fileType { - switch (this) { - case AttachmentPickerType.images: - return FileType.image; - case AttachmentPickerType.videos: - return FileType.video; - case AttachmentPickerType.files: - return FileType.any; - case AttachmentPickerType.audios: - return FileType.audio; - case AttachmentPickerType.poll: - throw Exception('Polls do not have a file type'); - } + return switch (this) { + ImagesPickerType() => FileType.image, + VideosPickerType() => FileType.video, + AudiosPickerType() => FileType.audio, + FilesPickerType() => FileType.any, + _ => throw Exception( + 'Unsupported AttachmentPickerType: $this. ' + 'Only Images, Videos, Audios and Files are supported.', + ), + }; } } diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 8d581e796f..34acade704 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -57,6 +57,9 @@ export 'src/message_action/message_action_item.dart'; export 'src/message_action/message_actions_builder.dart'; export 'src/message_input/attachment_picker/stream_attachment_picker.dart'; export 'src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart'; +export 'src/message_input/attachment_picker/stream_attachment_picker_controller.dart'; +export 'src/message_input/attachment_picker/stream_attachment_picker_option.dart'; +export 'src/message_input/attachment_picker/stream_attachment_picker_result.dart'; export 'src/message_input/audio_recorder/audio_recorder_controller.dart'; export 'src/message_input/audio_recorder/audio_recorder_state.dart'; export 'src/message_input/audio_recorder/stream_audio_recorder.dart'; From be223f26300ab03bb8c158bfbdd16fa65b20fe01 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Jul 2025 16:52:44 +0200 Subject: [PATCH 2/5] chore: revert options builder --- ...eam_attachment_picker_options_builder.dart | 284 ------------------ 1 file changed, 284 deletions(-) delete mode 100644 packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_options_builder.dart diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_options_builder.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_options_builder.dart deleted file mode 100644 index 88691c855d..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_options_builder.dart +++ /dev/null @@ -1,284 +0,0 @@ -import 'package:file_picker/file_picker.dart' show FileType; -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_file_picker.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_gallery_picker.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_image_picker.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_poll_creator.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_video_picker.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_controller.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_option.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_result.dart'; -import 'package:stream_chat_flutter/src/poll/creator/stream_poll_creator_dialog.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -class StreamAttachmentPickerOptionsBuilder { - /// Private constructor to prevent instantiation - const StreamAttachmentPickerOptionsBuilder._(); - - static List buildTabbedOptions({ - required BuildContext context, - PollConfig? pollConfig, - GalleryPickerConfig? galleryPickerConfig, - Iterable? customOptions, - }) { - final pickerOptions = [ - _buildGalleryPickerOption(config: galleryPickerConfig), - _buildTabbedFilePickerOption(), - _buildTabbedImagePickerOption(), - _buildTabbedVideoPickerOption(), - _buildTabbedPollCreatorOption(config: pollConfig), - ...?customOptions, - ]; - - return pickerOptions; - } - - static List buildSystemOptions({ - required BuildContext context, - PollConfig? pollConfig, - GalleryPickerConfig? galleryPickerConfig, - Iterable? customOptions, - }) { - final pickerOptions = [ - _buildSystemImagePickerOption(context: context), - _buildSystemVideoPickerOption(context: context), - _buildSystemFilePickerOption(context: context), - _buildSystemPollCreatorOption(context: context, config: pollConfig), - ...?customOptions, - ]; - - return pickerOptions; - } -} - -TabbedAttachmentPickerOption _buildGalleryPickerOption({ - GalleryPickerConfig? config, -}) { - return TabbedAttachmentPickerOption( - key: 'gallery-picker', - icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), - supportedTypes: [AttachmentPickerType.images, AttachmentPickerType.videos], - isEnabled: (value) { - // Enable if nothing has been selected yet. - if (value.isEmpty) return true; - - // Otherwise, enable only if there is at least a image or a video. - return value.attachments.any((it) => it.isImage || it.isVideo); - }, - optionViewBuilder: (context, controller) { - final attachments = controller.value.attachments; - final selectedIds = attachments.map((it) => it.id); - return StreamGalleryPicker( - config: config, - selectedMediaItems: selectedIds, - onMediaItemSelected: (media) async { - try { - if (selectedIds.contains(media.id)) { - return await controller.removeAssetAttachment(media); - } - - return await controller.addAssetAttachment(media); - } catch (e, stk) { - final err = AttachmentPickerError(error: e, stackTrace: stk); - return Navigator.pop(context, err); - } - }, - ); - }, - ); -} - -Future _handleSingePick( - Attachment? attachment, - StreamAttachmentPickerController controller, -) async { - try { - if (attachment != null) await controller.addAttachment(attachment); - return AttachmentsPicked(attachments: controller.value.attachments); - } catch (error, stk) { - return AttachmentPickerError(error: error, stackTrace: stk); - } -} - -TabbedAttachmentPickerOption _buildTabbedFilePickerOption() { - return TabbedAttachmentPickerOption( - key: 'file-picker', - icon: const StreamSvgIcon(icon: StreamSvgIcons.files), - supportedTypes: [AttachmentPickerType.files], - isEnabled: (value) { - // Enable if nothing has been selected yet. - if (value.isEmpty) return true; - - // Otherwise, enable only if there is at least a file. - return value.attachments.any((it) => it.isFile); - }, - optionViewBuilder: (context, controller) => StreamFilePicker( - onFilePicked: (file) async { - final result = await _handleSingePick(file, controller); - return Navigator.pop(context, result); - }, - ), - ); -} - -TabbedAttachmentPickerOption _buildTabbedImagePickerOption() { - return TabbedAttachmentPickerOption( - key: 'image-picker', - icon: const StreamSvgIcon(icon: StreamSvgIcons.camera), - supportedTypes: [AttachmentPickerType.images], - isEnabled: (value) { - // Enable if nothing has been selected yet. - if (value.isEmpty) return true; - - // Otherwise, enable only if there is at least a image. - return value.attachments.any((it) => it.isImage); - }, - optionViewBuilder: (context, controller) => StreamImagePicker( - onImagePicked: (image) async { - final result = await _handleSingePick(image, controller); - return Navigator.pop(context, result); - }, - ), - ); -} - -TabbedAttachmentPickerOption _buildTabbedVideoPickerOption() { - return TabbedAttachmentPickerOption( - key: 'video-picker', - icon: const StreamSvgIcon(icon: StreamSvgIcons.record), - supportedTypes: [AttachmentPickerType.videos], - isEnabled: (value) { - // Enable if nothing has been selected yet. - if (value.isEmpty) return true; - - // Otherwise, enable only if there is at least a video. - return value.attachments.any((it) => it.isVideo); - }, - optionViewBuilder: (context, controller) => StreamVideoPicker( - onVideoPicked: (video) async { - final result = await _handleSingePick(video, controller); - return Navigator.pop(context, result); - }, - ), - ); -} - -TabbedAttachmentPickerOption _buildTabbedPollCreatorOption({ - PollConfig? config, -}) { - return TabbedAttachmentPickerOption( - key: 'poll-creator', - icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), - supportedTypes: [AttachmentPickerType.poll], - isEnabled: (value) { - // Enable if nothing has been selected yet. - if (value.isEmpty) return true; - - // Otherwise, enable only if there is a poll. - return value.poll != null; - }, - optionViewBuilder: (context, controller) { - final initialPoll = controller.value.poll; - return StreamPollCreator( - poll: initialPoll, - config: config, - onPollCreated: (poll) { - if (poll == null) return Navigator.pop(context); - controller.poll = poll; - - final result = PollCreated(poll: poll); - return Navigator.pop(context, result); - }, - ); - }, - ); -} - -Future _pickSystemFile( - FileType type, - StreamAttachmentPickerController controller, -) async { - try { - final file = await StreamAttachmentHandler.instance.pickFile(type: type); - if (file != null) await controller.addAttachment(file); - - return AttachmentsPicked(attachments: controller.value.attachments); - } catch (error, stk) { - return AttachmentPickerError(error: error, stackTrace: stk); - } -} - -SystemAttachmentPickerOption _buildSystemImagePickerOption({ - required BuildContext context, -}) { - return SystemAttachmentPickerOption( - key: 'image-picker', - supportedTypes: [AttachmentPickerType.images], - icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), - title: context.translations.uploadAPhotoLabel, - onTap: (context, controller) async { - final result = await _pickSystemFile(FileType.image, controller); - return Navigator.pop(context, result); - }, - ); -} - -SystemAttachmentPickerOption _buildSystemVideoPickerOption({ - required BuildContext context, -}) { - return SystemAttachmentPickerOption( - key: 'video-picker', - supportedTypes: [AttachmentPickerType.videos], - icon: const StreamSvgIcon(icon: StreamSvgIcons.record), - title: context.translations.uploadAVideoLabel, - onTap: (context, controller) async { - final result = await _pickSystemFile(FileType.video, controller); - return Navigator.pop(context, result); - }, - ); -} - -SystemAttachmentPickerOption _buildSystemFilePickerOption({ - required BuildContext context, -}) { - return SystemAttachmentPickerOption( - key: 'file-picker', - supportedTypes: [AttachmentPickerType.files], - icon: const StreamSvgIcon(icon: StreamSvgIcons.files), - title: context.translations.uploadAFileLabel, - onTap: (context, controller) async { - final result = await _pickSystemFile(FileType.any, controller); - return Navigator.pop(context, result); - }, - ); -} - -SystemAttachmentPickerOption _buildSystemPollCreatorOption({ - required BuildContext context, - PollConfig? config, -}) { - return SystemAttachmentPickerOption( - key: 'poll-creator', - supportedTypes: [AttachmentPickerType.poll], - icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), - title: context.translations.createPollLabel(isNew: true), - onTap: (context, controller) async { - final initialPoll = controller.value.poll; - final poll = await showStreamPollCreatorDialog( - context: context, - poll: initialPoll, - config: config, - ); - - // If the poll is null, it means the user cancelled the dialog. - if (poll == null) return Navigator.pop(context); - - // Otherwise, set the poll in the controller and pop the dialog. - final result = PollCreated(poll: controller.poll = poll); - return Navigator.pop(context, result); - }, - ); -} From d9c04cb3a268aff629a0b69df8d0a7ee28962e04 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Jul 2025 16:58:11 +0200 Subject: [PATCH 3/5] chore: minor improvement --- .../lib/src/bottom_sheets/edit_message_sheet.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart b/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart index b53db1f2cb..ced57b6ecc 100644 --- a/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart @@ -115,10 +115,6 @@ class _EditMessageSheetState extends State { StreamMessageInput( elevation: 0, messageInputController: controller, - // Disallow editing poll for now as it's not supported. - allowedAttachmentPickerTypes: [ - ...AttachmentPickerType.values, - ]..remove(AttachmentPickerType.poll), preMessageSending: (m) { FocusScope.of(context).unfocus(); Navigator.of(context).pop(); From aca4ba3920c3cfe7cc0152f0d6974d2c56fa0840 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 11 Jul 2025 12:10:13 +0200 Subject: [PATCH 4/5] chore: update CHANGELOG.md --- packages/stream_chat_flutter/CHANGELOG.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index afc8f5517b..c4a0e2d839 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -2,9 +2,19 @@ ๐Ÿ›‘๏ธ Breaking -- `PollMessage` widget has been removed and replaced with `PollAttachment` for better integration - with the attachment system. Polls can now be customized through `PollAttachmentBuilder` or by - creating custom poll attachment widgets via the attachment builder system. +- `PollMessage` widget has been removed and replaced with `PollAttachment` for better integration with the attachment system. Polls can now be customized through `PollAttachmentBuilder` or by creating custom poll attachment widgets via the attachment builder system. +- `AttachmentPickerType` enum has been replaced with a sealed class to support extensible custom types like contact and location pickers. Use built-in types like `AttachmentPickerType.images` or define your own via `CustomAttachmentPickerType`. +- `StreamAttachmentPickerOption` has been replaced with two sealed classes to support layout-specific picker options: `SystemAttachmentPickerOption` for system pickers (e.g. camera, files) and `TabbedAttachmentPickerOption` for tabbed pickers (e.g. gallery, polls, location). +- `showStreamAttachmentPickerModalBottomSheet` now returns a `StreamAttachmentPickerResult` instead of `AttachmentPickerValue` for improved type safety and clearer intent handling. +- `StreamMobileAttachmentPickerBottomSheet` has been renamed to `StreamTabbedAttachmentPickerBottomSheet`, and `StreamWebOrDesktopAttachmentPickerBottomSheet` has been renamed to `StreamSystemAttachmentPickerBottomSheet` to better reflect their respective layouts. + +For more details, please refer to the [migration guide](../../migrations/v10-migration.md). + +โœ… Added + +- Added `extraData` field to `AttachmentPickerValue` to support storing and retrieving custom picker state (e.g. tab-specific config). +- Added `customAttachmentPickerOptions` to `StreamMessageInput` to allow injecting custom picker tabs like contact and location pickers. +- Added `onCustomAttachmentPickerResult` callback to `StreamMessageInput` to handle results returned by custom picker tabs. ## 10.0.0-beta.2 From fbcca9415ed565c1b709823f40f8f9f4786dadf3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 11 Jul 2025 12:52:26 +0200 Subject: [PATCH 5/5] chore: update v10-migration.md --- migrations/v10-migration.md | 190 ++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/migrations/v10-migration.md b/migrations/v10-migration.md index 61a1fc0062..0575d9eac0 100644 --- a/migrations/v10-migration.md +++ b/migrations/v10-migration.md @@ -8,6 +8,13 @@ This guide includes breaking changes grouped by release phase: +### ๐Ÿšง v10.0.0-beta.3 + +- [AttachmentPickerType](#-attachmentpickertype) +- [StreamAttachmentPickerOption](#-streamattachmentpickeroption) +- [showStreamAttachmentPickerModalBottomSheet](#-showstreamattachmentpickermodalbottomsheet) +- [AttachmentPickerBottomSheet](#-attachmentpickerbottomsheet) + ### ๐Ÿšง v10.0.0-beta.1 - [StreamReactionPicker](#-streamreactionpicker) @@ -17,6 +24,183 @@ This guide includes breaking changes grouped by release phase: --- +## ๐Ÿงช Migration for v10.0.0-beta.3 + +### ๐Ÿ›  AttachmentPickerType + +#### Key Changes: + +- `AttachmentPickerType` enum replaced with sealed class hierarchy +- Now supports extensible custom types like contact and location pickers +- Use built-in types like `AttachmentPickerType.images` or define your own via `CustomAttachmentPickerType` + +#### Migration Steps: + +**Before:** +```dart +// Using enum-based attachment types +final attachmentType = AttachmentPickerType.images; +``` + +**After:** +```dart +// Using sealed class attachment types +final attachmentType = AttachmentPickerType.images; + +// For custom types +class LocationAttachmentPickerType extends CustomAttachmentPickerType { + const LocationAttachmentPickerType(); +} +``` + +> โš ๏ธ **Important:** +> The enum is now a sealed class, but the basic usage remains the same for built-in types. + +--- + +### ๐Ÿ›  StreamAttachmentPickerOption + +#### Key Changes: + +- `StreamAttachmentPickerOption` replaced with two sealed classes: + - `SystemAttachmentPickerOption` for system pickers (camera, files) + - `TabbedAttachmentPickerOption` for tabbed pickers (gallery, polls, location) + +#### Migration Steps: + +**Before:** +```dart +final option = AttachmentPickerOption( + title: 'Gallery', + icon: Icon(Icons.photo_library), + supportedTypes: [AttachmentPickerType.images, AttachmentPickerType.videos], + optionViewBuilder: (context, controller) { + return GalleryPickerView(controller: controller); + }, +); + +final webOrDesktopOption = WebOrDesktopAttachmentPickerOption( + title: 'File Upload', + icon: Icon(Icons.upload_file), + type: AttachmentPickerType.files, +); +``` + +**After:** +```dart +// For custom UI pickers (gallery, polls) +final tabbedOption = TabbedAttachmentPickerOption( + title: 'Gallery', + icon: Icon(Icons.photo_library), + supportedTypes: [AttachmentPickerType.images, AttachmentPickerType.videos], + optionViewBuilder: (context, controller) { + return GalleryPickerView(controller: controller); + }, +); + +// For system pickers (camera, file dialogs) +final systemOption = SystemAttachmentPickerOption( + title: 'Camera', + icon: Icon(Icons.camera_alt), + supportedTypes: [AttachmentPickerType.images], + onTap: (context, controller) => pickFromCamera(), +); +``` + +> โš ๏ธ **Important:** +> - Use `SystemAttachmentPickerOption` for system pickers (camera, file dialogs) +> - Use `TabbedAttachmentPickerOption` for custom UI pickers (gallery, polls) + +--- + +### ๐Ÿ›  showStreamAttachmentPickerModalBottomSheet + +#### Key Changes: + +- Now returns `StreamAttachmentPickerResult` instead of `AttachmentPickerValue` +- Improved type safety and clearer intent handling + +#### Migration Steps: + +**Before:** +```dart +final result = await showStreamAttachmentPickerModalBottomSheet( + context: context, + controller: controller, +); + +// result is AttachmentPickerValue +``` + +**After:** +```dart +final result = await showStreamAttachmentPickerModalBottomSheet( + context: context, + controller: controller, +); + +// result is StreamAttachmentPickerResult +switch (result) { + case AttachmentsPicked(): + // Handle picked attachments + case PollCreated(): + // Handle created poll + case AttachmentPickerError(): + // Handle error + case CustomAttachmentPickerResult(): + // Handle custom result +} +``` + +> โš ๏ธ **Important:** +> Always handle the new `StreamAttachmentPickerResult` return type with proper switch cases. + +--- + +### ๐Ÿ›  AttachmentPickerBottomSheet + +#### Key Changes: + +- `StreamMobileAttachmentPickerBottomSheet` โ†’ `StreamTabbedAttachmentPickerBottomSheet` +- `StreamWebOrDesktopAttachmentPickerBottomSheet` โ†’ `StreamSystemAttachmentPickerBottomSheet` + +#### Migration Steps: + +**Before:** +```dart +StreamMobileAttachmentPickerBottomSheet( + context: context, + controller: controller, + customOptions: [option], +); + +StreamWebOrDesktopAttachmentPickerBottomSheet( + context: context, + controller: controller, + customOptions: [option], +); +``` + +**After:** +```dart +StreamTabbedAttachmentPickerBottomSheet( + context: context, + controller: controller, + customOptions: [tabbedOption], +); + +StreamSystemAttachmentPickerBottomSheet( + context: context, + controller: controller, + customOptions: [systemOption], +); +``` + +> โš ๏ธ **Important:** +> The new names better reflect their respective layouts and functionality. + +--- + ## ๐Ÿงช Migration for v10.0.0-beta.1 ### ๐Ÿ›  StreamReactionPicker @@ -182,6 +366,12 @@ StreamMessageWidget( ## ๐ŸŽ‰ You're Ready to Migrate! +### For v10.0.0-beta.3: +- โœ… Update attachment picker options to use `SystemAttachmentPickerOption` or `TabbedAttachmentPickerOption` +- โœ… Handle new `StreamAttachmentPickerResult` return type from attachment picker +- โœ… Use renamed bottom sheet classes (`StreamTabbedAttachmentPickerBottomSheet`, `StreamSystemAttachmentPickerBottomSheet`) + +### For v10.0.0-beta.1: - โœ… Use `StreamReactionPicker.builder` or supply `onReactionPicked` - โœ… Convert all `StreamMessageAction` instances to type-safe generic usage - โœ… Centralize handling with `onCustomActionTap`