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..4e098a4b8 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), + _ => Future.value(), // Ignore other results. }; } diff --git a/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_attachment_picker_test.dart b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_attachment_picker_test.dart new file mode 100644 index 000000000..8675d0545 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_attachment_picker_test.dart @@ -0,0 +1,403 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + group('showStreamAttachmentPickerModalBottomSheet', () { + group('attachmentPickerOptionsBuilder', () { + testWidgets( + 'should call optionsBuilder with default options', + (tester) async { + var builderCalled = false; + int? defaultOptionsCount; + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + showStreamAttachmentPickerModalBottomSheet( + context: context, + optionsBuilder: (context, defaultOptions) { + builderCalled = true; + defaultOptionsCount = defaultOptions.length; + return defaultOptions; + }, + ); + }, + child: const Text('Show Picker'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Show Picker')); + await tester.pumpAndSettle(); + + expect(builderCalled, isTrue); + expect(defaultOptionsCount, isNotNull); + expect(defaultOptionsCount, greaterThan(0)); + }, + ); + + testWidgets( + 'should allow filtering default options', + (tester) async { + int? defaultOptionsCount; + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + showStreamAttachmentPickerModalBottomSheet( + context: context, + optionsBuilder: (context, defaultOptions) { + defaultOptionsCount = defaultOptions.length; + // Return only first option + return [defaultOptions.first]; + }, + ); + }, + child: const Text('Show Picker'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Show Picker')); + await tester.pumpAndSettle(); + + final bottomSheet = + tester.widget( + find.byType(StreamSystemAttachmentPickerBottomSheet), + ); + + expect(bottomSheet.options.length, equals(1)); + expect(bottomSheet.options.length, lessThan(defaultOptionsCount!)); + }, + ); + + testWidgets( + 'should allow adding custom options', + (tester) async { + int? defaultOptionsCount; + const customOptionKey = 'custom-location'; + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + showStreamAttachmentPickerModalBottomSheet( + context: context, + optionsBuilder: (context, defaultOptions) { + defaultOptionsCount = defaultOptions.length; + return [ + ...defaultOptions, + SystemAttachmentPickerOption( + key: customOptionKey, + icon: const Icon(Icons.location_on), + supportedTypes: [AttachmentPickerType.images], + title: 'Send Location', + onTap: (context, controller) async {}, + ), + ]; + }, + ); + }, + child: const Text('Show Picker'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Show Picker')); + await tester.pumpAndSettle(); + + final bottomSheet = + tester.widget( + find.byType(StreamSystemAttachmentPickerBottomSheet), + ); + + // Should have one more option than default + expect(bottomSheet.options.length, equals(defaultOptionsCount! + 1)); + + // Verify our custom option exists + expect( + bottomSheet.options.any((option) => option.key == customOptionKey), + isTrue, + ); + }, + ); + + testWidgets( + 'should allow reordering options', + (tester) async { + String? firstDefaultKey; + String? firstReversedKey; + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + showStreamAttachmentPickerModalBottomSheet( + context: context, + optionsBuilder: (context, defaultOptions) { + firstDefaultKey = defaultOptions.first.key; + final reversed = defaultOptions.reversed.toList(); + firstReversedKey = reversed.first.key; + return reversed; + }, + ); + }, + child: const Text('Show Picker'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Show Picker')); + await tester.pumpAndSettle(); + + // Verify first option changed after reversing + expect(firstDefaultKey, isNotNull); + expect(firstReversedKey, isNotNull); + expect(firstDefaultKey, isNot(equals(firstReversedKey))); + }, + ); + + testWidgets( + 'should throw ArgumentError when wrong option types are provided', + (tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + showStreamAttachmentPickerModalBottomSheet( + context: context, + optionsBuilder: (context, defaultOptions) { + // Return tabbed option for system picker (wrong type) + return [ + TabbedAttachmentPickerOption( + key: 'wrong', + icon: const Icon(Icons.error), + title: 'Wrong', + supportedTypes: [AttachmentPickerType.images], + optionViewBuilder: (context, controller) { + return const Text('Wrong'); + }, + ), + ]; + }, + ); + }, + child: const Text('Show Picker'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Show Picker')); + + // Should throw ArgumentError + await tester.pumpAndSettle(); + + expect(tester.takeException(), isA()); + }, + ); + }); + + group('allowedTypes', () { + testWidgets( + 'should filter options based on allowedTypes', + (tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + showStreamAttachmentPickerModalBottomSheet( + context: context, + allowedTypes: [AttachmentPickerType.images], + ); + }, + child: const Text('Show Picker'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Show Picker')); + await tester.pumpAndSettle(); + + final bottomSheet = + tester.widget( + find.byType(StreamSystemAttachmentPickerBottomSheet), + ); + + // All options should support images + expect( + bottomSheet.options.every( + (option) => + option.supportedTypes.contains(AttachmentPickerType.images), + ), + isTrue, + ); + }, + ); + + testWidgets( + 'should work with optionsBuilder and allowedTypes together', + (tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + showStreamAttachmentPickerModalBottomSheet( + context: context, + allowedTypes: [ + AttachmentPickerType.images, + AttachmentPickerType.videos, + ], + optionsBuilder: (context, defaultOptions) { + return defaultOptions; + }, + ); + }, + child: const Text('Show Picker'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Show Picker')); + await tester.pumpAndSettle(); + + expect( + find.byType(StreamSystemAttachmentPickerBottomSheet), + findsOneWidget, + ); + }, + ); + }); + + group('System picker with optionsBuilder', () { + testWidgets( + 'should use optionsBuilder with system picker', + (tester) async { + var builderCalled = false; + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + showStreamAttachmentPickerModalBottomSheet( + context: context, + useSystemAttachmentPicker: true, + optionsBuilder: (context, defaultOptions) { + builderCalled = true; + return defaultOptions; + }, + ); + }, + child: const Text('Show Picker'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Show Picker')); + await tester.pumpAndSettle(); + + expect(builderCalled, isTrue); + expect( + find.byType(StreamSystemAttachmentPickerBottomSheet), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'should allow adding custom system picker options', + (tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + showStreamAttachmentPickerModalBottomSheet( + context: context, + useSystemAttachmentPicker: true, + optionsBuilder: (context, defaultOptions) { + return [ + ...defaultOptions, + SystemAttachmentPickerOption( + key: 'custom-upload', + icon: const Icon(Icons.cloud_upload), + title: 'Custom Upload', + supportedTypes: [AttachmentPickerType.files], + onTap: (context, controller) async {}, + ), + ]; + }, + ); + }, + child: const Text('Show Picker'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Show Picker')); + await tester.pumpAndSettle(); + + expect(find.text('Custom Upload'), findsOneWidget); + }, + ); + }); + }); +} + +Widget _wrapWithStreamChatApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: widget, + ); + }, + ), + ), + ); +} diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart index be6537b8a..1906a152d 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,16 @@ 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, + ) { + if (result is LocationPicked) { + _onShareLocationPicked(channel, result.location).ignore(); + return true; // Notify that the result was handled. + } - return response?.ignore(); + return false; // Notify that the result was not handled. } Future _onShareLocationPicked(