diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 341f5ecbe7..8f61c916b6 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -22,6 +22,12 @@ - Fixed `GradientAvatars` for users with same-length IDs would have identical colors. [[#2369]](https://github.com/GetStream/stream-chat-flutter/issues/2369) +✅ Added + +- Added `optionsBuilder` to `showStreamAttachmentPickerModalBottomSheet`, + `mobileAttachmentPickerBuilder`, and `webOrDesktopAttachmentPickerBuilder` + to allow full control over the attachment picker options ordering / display. + ## 9.16.0 🐞 Fixed 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..f7b50f1e0a 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 @@ -220,6 +220,28 @@ typedef AttachmentPickerOptionViewBuilder = Widget Function( StreamAttachmentPickerController controller, ); +/// Function signature for building the list of attachment picker options. +/// +/// The [defaultOptions] parameter contains the standard attachment picker +/// options(gallery, file, image, video, poll pickers). Developers can use +/// these as-is, reorder them, or combine them with custom options. +typedef AttachmentPickerOptionsBuilder = List Function( + BuildContext context, + List defaultOptions, +); + +/// Function signature for building the list of web/desktop attachment picker +/// options. +/// +/// The [defaultOptions] parameter contains the standard web/desktop attachment +/// picker options (image, video, file, poll pickers). Developers can use these +/// as-is,reorder them, or combine them with custom options. +typedef WebOrDesktopAttachmentPickerOptionsBuilder + = List Function( + BuildContext context, + List defaultOptions, +); + /// Model class for the attachment picker options. class AttachmentPickerOption { /// Creates a new instance of [AttachmentPickerOption]. @@ -751,6 +773,7 @@ Widget mobileAttachmentPickerBuilder({ Poll? initialPoll, PollConfig? pollConfig, Iterable? customOptions, + AttachmentPickerOptionsBuilder? optionsBuilder, List allowedTypes = AttachmentPickerType.values, ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg, @@ -758,131 +781,148 @@ Widget mobileAttachmentPickerBuilder({ double attachmentThumbnailScale = 1, ErrorListener? onError, }) { - return StreamMobileAttachmentPickerBottomSheet( - controller: controller, - onSendValue: Navigator.of(context).pop, - options: { - ...{ - if (customOptions != null) ...customOptions, - AttachmentPickerOption( - key: 'gallery-picker', - icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), - supportedTypes: [ - AttachmentPickerType.images, - AttachmentPickerType.videos, - ], - optionViewBuilder: (context, controller) { - final attachment = controller.value.attachments; - final selectedIds = attachment.map((it) => it.id); - return StreamGalleryPicker( - selectedMediaItems: selectedIds, - mediaThumbnailSize: attachmentThumbnailSize, - mediaThumbnailFormat: attachmentThumbnailFormat, - mediaThumbnailQuality: attachmentThumbnailQuality, - mediaThumbnailScale: attachmentThumbnailScale, - onMediaItemSelected: (media) async { - try { - if (selectedIds.contains(media.id)) { - return await controller.removeAssetAttachment(media); - } - return await controller.addAssetAttachment(media); - } catch (e, stk) { - if (onError != null) return onError.call(e, stk); - rethrow; - } - }, - ); + assert( + optionsBuilder == null || customOptions == null, + 'Cannot use both optionsBuilder and customOptions. ' + 'Use optionsBuilder for full control over options, ' + 'or customOptions for simple prepending of options.', + ); + + // Build default options + final defaultOptions = [ + AttachmentPickerOption( + key: 'gallery-picker', + icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), + supportedTypes: [ + AttachmentPickerType.images, + AttachmentPickerType.videos, + ], + optionViewBuilder: (context, controller) { + final attachment = controller.value.attachments; + final selectedIds = attachment.map((it) => it.id); + return StreamGalleryPicker( + selectedMediaItems: selectedIds, + mediaThumbnailSize: attachmentThumbnailSize, + mediaThumbnailFormat: attachmentThumbnailFormat, + mediaThumbnailQuality: attachmentThumbnailQuality, + mediaThumbnailScale: attachmentThumbnailScale, + onMediaItemSelected: (media) async { + try { + if (selectedIds.contains(media.id)) { + return await controller.removeAssetAttachment(media); + } + return await controller.addAssetAttachment(media); + } catch (e, stk) { + if (onError != null) return onError.call(e, stk); + rethrow; + } }, - ), - AttachmentPickerOption( - 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); - - rethrow; - } - }, - ); + ); + }, + ), + AttachmentPickerOption( + 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); + + rethrow; + } }, - ), - AttachmentPickerOption( - 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); - - rethrow; - } - }, - ); + ); + }, + ), + AttachmentPickerOption( + 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); + + rethrow; + } }, - ), - AttachmentPickerOption( - 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); - - rethrow; - } - }, - ); + ); + }, + ), + AttachmentPickerOption( + 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); + + rethrow; + } }, - ), - AttachmentPickerOption( - key: 'poll-creator', - icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), - supportedTypes: [AttachmentPickerType.poll], - 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); - - rethrow; - } - }, - ); + ); + }, + ), + AttachmentPickerOption( + key: 'poll-creator', + icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), + supportedTypes: [AttachmentPickerType.poll], + 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); + + rethrow; + } }, - ), - }.where((option) => option.supportedTypes.every(allowedTypes.contains)), - }, + ); + }, + ), + ] + .where((option) => option.supportedTypes.every(allowedTypes.contains)) + .toList(); + + // Determine final options based on builder or custom options + final finalOptions = optionsBuilder != null + ? optionsBuilder(context, defaultOptions).toSet() + : { + if (customOptions != null) ...customOptions, + ...defaultOptions, + }; + + return StreamMobileAttachmentPickerBottomSheet( + controller: controller, + onSendValue: Navigator.of(context).pop, + options: finalOptions, ); } @@ -893,6 +933,7 @@ Widget webOrDesktopAttachmentPickerBuilder({ Poll? initialPoll, PollConfig? pollConfig, Iterable? customOptions, + WebOrDesktopAttachmentPickerOptionsBuilder? optionsBuilder, List allowedTypes = AttachmentPickerType.values, ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg, @@ -900,37 +941,58 @@ Widget webOrDesktopAttachmentPickerBuilder({ double attachmentThumbnailScale = 1, ErrorListener? onError, }) { + assert( + optionsBuilder == null || customOptions == null, + 'Cannot use both optionsBuilder and customOptions. ' + 'Use optionsBuilder for full control over options, ' + 'or customOptions for simple prepending of options.', + ); + + // Build default options + final defaultOptions = [ + WebOrDesktopAttachmentPickerOption( + key: 'image-picker', + type: AttachmentPickerType.images, + icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), + title: context.translations.uploadAPhotoLabel, + ), + WebOrDesktopAttachmentPickerOption( + key: 'video-picker', + type: AttachmentPickerType.videos, + icon: const StreamSvgIcon(icon: StreamSvgIcons.record), + title: context.translations.uploadAVideoLabel, + ), + WebOrDesktopAttachmentPickerOption( + key: 'file-picker', + type: AttachmentPickerType.files, + icon: const StreamSvgIcon(icon: StreamSvgIcons.files), + title: context.translations.uploadAFileLabel, + ), + WebOrDesktopAttachmentPickerOption( + key: 'poll-creator', + type: AttachmentPickerType.poll, + icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), + title: context.translations.createPollLabel(isNew: true), + ), + ] + .where((option) => option.supportedTypes.every(allowedTypes.contains)) + .toList(); + + // Use options builder if provided, otherwise fall back to legacy behavior + final Set finalOptions; + if (optionsBuilder != null) { + finalOptions = optionsBuilder(context, defaultOptions).toSet(); + } else { + // Legacy behavior: combine custom and default options + finalOptions = { + if (customOptions != null) ...customOptions, + ...defaultOptions, + }; + } + return StreamWebOrDesktopAttachmentPickerBottomSheet( controller: controller, - options: { - ...{ - if (customOptions != null) ...customOptions, - WebOrDesktopAttachmentPickerOption( - key: 'image-picker', - type: AttachmentPickerType.images, - icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), - title: context.translations.uploadAPhotoLabel, - ), - WebOrDesktopAttachmentPickerOption( - key: 'video-picker', - type: AttachmentPickerType.videos, - icon: const StreamSvgIcon(icon: StreamSvgIcons.record), - title: context.translations.uploadAVideoLabel, - ), - WebOrDesktopAttachmentPickerOption( - key: 'file-picker', - type: AttachmentPickerType.files, - icon: const StreamSvgIcon(icon: StreamSvgIcons.files), - title: context.translations.uploadAFileLabel, - ), - WebOrDesktopAttachmentPickerOption( - key: 'poll-creator', - type: AttachmentPickerType.poll, - icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), - title: context.translations.createPollLabel(isNew: true), - ), - }.where((option) => option.supportedTypes.every(allowedTypes.contains)), - }, + options: finalOptions, onOptionTap: (context, controller, option) async { // Handle the polls type option separately if (option.type case AttachmentPickerType.poll) { 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..cd34e5bf0f 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 @@ -65,6 +65,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; Future showStreamAttachmentPickerModalBottomSheet({ required BuildContext context, Iterable? customOptions, + AttachmentPickerOptionsBuilder? optionsBuilder, List allowedTypes = AttachmentPickerType.values, Poll? initialPoll, PollConfig? pollConfig, @@ -91,6 +92,13 @@ Future showStreamAttachmentPickerModalBottomSheet({ int attachmentThumbnailQuality = 100, double attachmentThumbnailScale = 1, }) { + assert( + optionsBuilder == null || customOptions == null, + 'Cannot use both optionsBuilder and customOptions. ' + 'Use optionsBuilder for full control over options, ' + 'or customOptions for simple prepending of options.', + ); + final colorTheme = StreamChatTheme.of(context).colorTheme; final color = backgroundColor ?? colorTheme.inputBg; @@ -135,6 +143,14 @@ Future showStreamAttachmentPickerModalBottomSheet({ customOptions: customOptions?.map( WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption, ), + optionsBuilder: optionsBuilder == null + ? null + : (context, defaultOptions) { + return optionsBuilder(context, defaultOptions) + .map(WebOrDesktopAttachmentPickerOption + .fromAttachmentPickerOption) + .toList(); + }, initialPoll: initialPoll, pollConfig: pollConfig, attachmentThumbnailSize: attachmentThumbnailSize, @@ -150,6 +166,7 @@ Future showStreamAttachmentPickerModalBottomSheet({ controller: controller, allowedTypes: allowedTypes, customOptions: customOptions, + optionsBuilder: optionsBuilder, initialPoll: initialPoll, pollConfig: pollConfig, attachmentThumbnailSize: attachmentThumbnailSize, diff --git a/packages/stream_chat_flutter/test/src/message_input/attachment_picker/options_builder_test.dart b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/options_builder_test.dart new file mode 100644 index 0000000000..58901e713c --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/options_builder_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + group('AttachmentPickerOptionsBuilder', () { + testWidgets( + 'mobileAttachmentPickerBuilder uses optionsBuilder when provided', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final controller = StreamAttachmentPickerController(); + + const customOption = AttachmentPickerOption( + key: 'custom-option', + icon: Icon(Icons.star), + supportedTypes: [AttachmentPickerType.files], + ); + + final widget = mobileAttachmentPickerBuilder( + context: context, + controller: controller, + optionsBuilder: (context, defaultOptions) { + // Custom options after default options + return [ + ...defaultOptions, + customOption, + ]; + }, + ); + + expect(widget, isA()); + + final bottomSheet = + widget as StreamMobileAttachmentPickerBottomSheet; + final options = bottomSheet.options.toList(); + + // Custom option should be last when added after default options + expect(options.last.key, equals('custom-option')); + + return Container(); + }, + ), + ), + ); + }); + + testWidgets( + 'mobileAttachmentPickerBuilder can reorder options with optionsBuilder', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final controller = StreamAttachmentPickerController(); + + const customOption = AttachmentPickerOption( + key: 'custom-option', + icon: Icon(Icons.star), + supportedTypes: [AttachmentPickerType.files], + ); + + final widget = mobileAttachmentPickerBuilder( + context: context, + controller: controller, + optionsBuilder: (context, defaultOptions) { + // Custom option first, then only specific default options + return [ + customOption, + // Only include gallery picker from defaults + ...defaultOptions + .where((option) => option.key == 'gallery-picker'), + ]; + }, + ); + + expect(widget, isA()); + + final bottomSheet = + widget as StreamMobileAttachmentPickerBottomSheet; + final options = bottomSheet.options.toList(); + + // Should have exactly 2 options: custom + gallery + expect(options.length, equals(2)); + expect(options.first.key, equals('custom-option')); + expect(options.last.key, equals('gallery-picker')); + + return Container(); + }, + ), + ), + ); + }); + + test('AttachmentPickerOptionsBuilder typedef is defined correctly', () { + // Test that the typedef is properly defined and can be used + AttachmentPickerOptionsBuilder? builder; + + builder = (context, defaultOptions) { + return [ + ...defaultOptions, + const AttachmentPickerOption( + key: 'test-option', + icon: Icon(Icons.star), + supportedTypes: [AttachmentPickerType.files], + ), + ]; + }; + + expect(builder, isNotNull); + expect(builder, isA()); + }); + + test( + 'WebOrDesktopAttachmentPickerOptionsBuilder typedef is defined ' + 'correctly', () { + // Test that the typedef is properly defined and can be used + WebOrDesktopAttachmentPickerOptionsBuilder? builder; + + builder = (context, defaultOptions) { + return [ + ...defaultOptions, + WebOrDesktopAttachmentPickerOption( + key: 'test-option', + type: AttachmentPickerType.files, + icon: const Icon(Icons.star), + title: 'Test Option', + ), + ]; + }; + + expect(builder, isNotNull); + expect(builder, isA()); + }); + + testWidgets( + 'customOptions behavior still works when optionsBuilder is null', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final controller = StreamAttachmentPickerController(); + + const customOption = AttachmentPickerOption( + key: 'custom-option', + icon: Icon(Icons.star), + supportedTypes: [AttachmentPickerType.files], + ); + + final widget = mobileAttachmentPickerBuilder( + context: context, + controller: controller, + customOptions: [customOption], + ); + + expect(widget, isA()); + + final bottomSheet = + widget as StreamMobileAttachmentPickerBottomSheet; + final options = bottomSheet.options.toList(); + + expect(options.first.key, equals('custom-option')); + + return Container(); + }, + ), + ), + ); + }); + + testWidgets('Cannot use both customOptions and optionsBuilder', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + expect( + () => showStreamAttachmentPickerModalBottomSheet( + context: context, + customOptions: [ + const AttachmentPickerOption( + key: 'custom', + icon: Icon(Icons.star), + supportedTypes: [AttachmentPickerType.files], + ), + ], + optionsBuilder: (context, defaultOptions) => defaultOptions, + ), + throwsAssertionError, + ); + + return Container(); + }, + ), + ), + ); + }); + }); +}