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 f408b628b..4c742b7a7 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 @@ -428,8 +428,8 @@ Widget tabbedAttachmentPickerBuilder({ required StreamAttachmentPickerController controller, PollConfig? pollConfig, GalleryPickerConfig? galleryPickerConfig, - Iterable? customOptions, List allowedTypes = AttachmentPickerType.values, + AttachmentPickerOptionsBuilder? optionsBuilder, }) { Future _handleSingePick( StreamAttachmentPickerController controller, @@ -443,127 +443,144 @@ Widget tabbedAttachmentPickerBuilder({ } } + final defaultOptions = [ + 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, + 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); + } + }, + ); + }, + ), + 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(controller, file); + return Navigator.pop(context, result); + }, + ), + ), + 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(controller, image); + return Navigator.pop(context, result); + }, + ), + ), + 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(controller, video); + return Navigator.pop(context, result); + }, + ), + ), + 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) { + if (poll == null) return Navigator.pop(context); + controller.poll = poll; + + final result = PollCreated(poll: poll); + return Navigator.pop(context, result); + }, + ); + }, + ), + ]; + + final allOptions = switch (optionsBuilder) { + final builder? => builder(context, defaultOptions), + _ => defaultOptions, + }; + + final validOptions = allOptions.whereType(); + + if (validOptions.length < allOptions.length) { + throw ArgumentError( + 'custom options must be of type TabbedAttachmentPickerOption when using ' + 'the tabbed attachment picker (default on mobile).', + ); + } + return StreamTabbedAttachmentPickerBottomSheet( controller: controller, onSendValue: Navigator.of(context).pop, options: { - ...{ - 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, - 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); - } - }, - ); - }, - ), - 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(controller, file); - return Navigator.pop(context, result); - }, - ), - ), - 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(controller, image); - return Navigator.pop(context, result); - }, - ), - ), - 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(controller, video); - return Navigator.pop(context, result); - }, - ), - ), - 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) { - if (poll == null) return Navigator.pop(context); - controller.poll = poll; - - final result = PollCreated(poll: poll); - return Navigator.pop(context, result); - }, - ); - }, - ), - ...?customOptions, - }.where((option) => option.supportedTypes.every(allowedTypes.contains)), + ...validOptions.where( + (option) => option.supportedTypes.every(allowedTypes.contains), + ), }, ); } @@ -582,8 +599,8 @@ Widget systemAttachmentPickerBuilder({ required StreamAttachmentPickerController controller, PollConfig? pollConfig = const PollConfig(), GalleryPickerConfig? galleryPickerConfig = const GalleryPickerConfig(), - Iterable? customOptions, List allowedTypes = AttachmentPickerType.values, + AttachmentPickerOptionsBuilder? optionsBuilder, }) { Future _pickSystemFile( StreamAttachmentPickerController controller, @@ -599,62 +616,79 @@ Widget systemAttachmentPickerBuilder({ } } + final defaultOptions = [ + 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(controller, FileType.image); + return Navigator.pop(context, result); + }, + ), + 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(controller, FileType.video); + return Navigator.pop(context, result); + }, + ), + 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(controller, FileType.any); + return Navigator.pop(context, result); + }, + ), + 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: pollConfig, + ); + + if (poll == null) return Navigator.pop(context); + controller.poll = poll; + + final result = PollCreated(poll: poll); + return Navigator.pop(context, result); + }, + ), + ]; + + final allOptions = switch (optionsBuilder) { + final builder? => builder(context, defaultOptions), + _ => defaultOptions, + }; + + final validOptions = allOptions.whereType(); + + if (validOptions.length < allOptions.length) { + throw ArgumentError( + 'custom options must be of type SystemAttachmentPickerOption when using ' + 'the system attachment picker (enabled explicitly or on web/desktop).', + ); + } + return StreamSystemAttachmentPickerBottomSheet( controller: controller, options: { - ...{ - 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(controller, FileType.image); - return Navigator.pop(context, result); - }, - ), - 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(controller, FileType.video); - return Navigator.pop(context, result); - }, - ), - 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(controller, FileType.any); - return Navigator.pop(context, result); - }, - ), - 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: pollConfig, - ); - - if (poll == null) return Navigator.pop(context); - controller.poll = poll; - - final result = PollCreated(poll: poll); - return Navigator.pop(context, result); - }, - ), - ...?customOptions, - }.where((option) => option.supportedTypes.every(allowedTypes.contains)), + ...validOptions.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 5ddb5e7df..cbfbe318f 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 @@ -4,6 +4,16 @@ 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'; +/// {@template streamAttachmentPickerOptionsBuilder} +/// Signature for a function that creates a list of [AttachmentPickerOption]s +/// to be used in the attachment picker. +/// +/// The function receives the [BuildContext] and a list of [defaultOptions] +/// that can be modified or extended. +/// {@endtemplate} +typedef AttachmentPickerOptionsBuilder + = List Function(BuildContext context, List defaultOptions); + /// Shows a modal bottom sheet with the Stream attachment picker. /// /// The picker supports two modes: @@ -60,7 +70,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// or `null` if the sheet was dismissed. Future showStreamAttachmentPickerModalBottomSheet({ required BuildContext context, - Iterable? customOptions, + AttachmentPickerOptionsBuilder? optionsBuilder, List allowedTypes = AttachmentPickerType.values, Poll? initialPoll, PollConfig? pollConfig, @@ -113,60 +123,18 @@ Future showStreamAttachmentPickerModalBottomSheet({ 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).', - ); - } - - return systemAttachmentPickerBuilder.call( - context: context, - controller: controller, - allowedTypes: allowedTypes, - customOptions: customSystemOptions, - pollConfig: pollConfig, - galleryPickerConfig: galleryPickerConfig, - ); - } - - 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).', - ); - } + final builder = switch (useSystemPicker) { + true => systemAttachmentPickerBuilder, + false => tabbedAttachmentPickerBuilder, + }; - return tabbedAttachmentPickerBuilder.call( + return builder.call( context: context, controller: controller, allowedTypes: allowedTypes, - customOptions: customTabbedOptions, pollConfig: pollConfig, galleryPickerConfig: galleryPickerConfig, + optionsBuilder: optionsBuilder, ); }, ); 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 index 770fc4422..1a2e681f3 100644 --- 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 @@ -1,14 +1,11 @@ -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'dart:async'; -/// Signature for a function that is called when a custom attachment picker -/// result is received. -typedef OnCustomAttachmentPickerResult - = OnAttachmentPickerResult; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// Signature for a function that is called when a attachment picker result /// is received. -typedef OnAttachmentPickerResult = void - Function(T result); +typedef OnAttachmentPickerResult + = FutureOr Function(T result); /// {@template streamAttachmentPickerAction} /// A sealed class that represents different results that can be returned 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 c76604860..e75e59d85 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 @@ -158,8 +158,8 @@ class StreamMessageInput extends StatefulWidget { this.contentInsertionConfiguration, this.useSystemAttachmentPicker = false, this.pollConfig, - this.customAttachmentPickerOptions = const [], - this.onCustomAttachmentPickerResult, + this.attachmentPickerOptionsBuilder, + this.onAttachmentPickerResult, this.padding = const EdgeInsets.all(8), this.textInputMargin, }); @@ -394,15 +394,19 @@ 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; + /// Builder for customizing the attachment picker options. + /// + /// The builder receives the [BuildContext] and a list of default options + /// that can be modified or extended. + /// + /// If not provided, the default options are presented. + final AttachmentPickerOptionsBuilder? attachmentPickerOptionsBuilder; - /// Callback that is called when the custom attachment picker result is - /// received. + /// Callback that is called when the attachment picker result is received. /// - /// This is used to handle the result of the custom attachment picker - final OnCustomAttachmentPickerResult? onCustomAttachmentPickerResult; + /// Return `true` if the result is handled. Otherwise, return `false` to + /// allow the result to be handled internally. + final OnAttachmentPickerResult? onAttachmentPickerResult; /// Padding for the message input. /// @@ -991,11 +995,15 @@ class StreamMessageInputState extends State pollConfig: widget.pollConfig, initialAttachments: initialAttachments, useSystemAttachmentPicker: useSystemPicker, - customOptions: widget.customAttachmentPickerOptions, + optionsBuilder: widget.attachmentPickerOptionsBuilder, ); if (result == null || result is! StreamAttachmentPickerResult) return; + // Returns early if the result is already handled by the user. + final resultHandled = await widget.onAttachmentPickerResult?.call(result); + if (resultHandled ?? false) return; + void _onAttachmentsPicked(List attachments) { _effectiveController.attachments = attachments; } @@ -1004,19 +1012,14 @@ class StreamMessageInputState extends State return widget.onError?.call(error.error, error.stackTrace); } - 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), + _ => () {}, // Ignore other results. }; } diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart index be6537b8a..67a140a14 100644 --- a/sample_app/lib/pages/channel_page.dart +++ b/sample_app/lib/pages/channel_page.dart @@ -135,10 +135,11 @@ class _ChannelPageState extends State { if (config?.sharedLocations == true && channel.canShareLocation) const LocationPickerType(), ], - onCustomAttachmentPickerResult: (result) { - return _onCustomAttachmentPickerResult(channel, result).ignore(); + onAttachmentPickerResult: (result) { + return _onCustomAttachmentPickerResult(channel, result); }, - customAttachmentPickerOptions: [ + attachmentPickerOptionsBuilder: (context, defaultOptions) => [ + ...defaultOptions, TabbedAttachmentPickerOption( key: 'location-picker', icon: const Icon(Icons.near_me_rounded), @@ -171,16 +172,15 @@ class _ChannelPageState extends State { ); } - Future _onCustomAttachmentPickerResult( + bool _onCustomAttachmentPickerResult( Channel channel, - CustomAttachmentPickerResult result, - ) async { - final response = switch (result) { - LocationPicked() => _onShareLocationPicked(channel, result.location), - _ => null, - }; + StreamAttachmentPickerResult result, + ) { + // Notify that the result was not handled. + if (result is! LocationPicked) return false; - return response?.ignore(); + _onShareLocationPicked(channel, result.location).ignore(); + return true; // Notify that the result was handled. } Future _onShareLocationPicked(