diff --git a/analysis_options.yaml b/analysis_options.yaml index 397c53b334..4ef2d45a61 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -82,7 +82,6 @@ linter: - prefer_const_constructors_in_immutables - prefer_const_declarations - prefer_const_literals_to_create_immutables - - prefer_constructors_over_static_methods - prefer_contains - prefer_equal_for_default_values - prefer_final_fields diff --git a/melos.yaml b/melos.yaml index 5ef2457fd2..7bb53cfa09 100644 --- a/melos.yaml +++ b/melos.yaml @@ -53,6 +53,7 @@ command: photo_manager: ^3.2.0 photo_view: ^0.15.0 rate_limiter: ^1.0.0 + record: ^5.2.0 responsive_builder: ^0.7.0 rxdart: ^0.28.0 share_plus: ^10.0.2 @@ -82,6 +83,8 @@ command: json_serializable: ^6.7.1 mocktail: ^1.0.0 path: ^1.8.3 + path_provider_platform_interface: ^2.0.0 + plugin_platform_interface: ^2.0.0 test: ^1.24.6 scripts: diff --git a/packages/stream_chat/lib/src/core/models/attachment_file.dart b/packages/stream_chat/lib/src/core/models/attachment_file.dart index 261d7db374..a796764c6c 100644 --- a/packages/stream_chat/lib/src/core/models/attachment_file.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_file.dart @@ -28,7 +28,7 @@ class AttachmentFile { 'File by path is not supported in web, Please provide bytes', ), assert( - name?.contains('.') ?? true, + name == null || name.isEmpty || name.contains('.'), 'Invalid file name, should also contain file extension', ), _name = name; @@ -47,8 +47,10 @@ class AttachmentFile { final String? _name; /// File name including its extension. - String? get name => - _name ?? path?.split(CurrentPlatform.isWindows ? r'\' : '/').last; + String? get name { + if (_name case final name? when name.isNotEmpty) return name; + return path?.split(CurrentPlatform.isWindows ? r'\' : '/').last; + } /// Byte data for this file. Particularly useful if you want to manipulate /// its data or easily upload to somewhere else. @@ -69,22 +71,18 @@ class AttachmentFile { /// Converts this into a [MultipartFile] Future toMultipartFile() async { - MultipartFile multiPartFile; - - if (CurrentPlatform.isWeb) { - multiPartFile = MultipartFile.fromBytes( - bytes!, - filename: name, - contentType: mediaType, - ); - } else { - multiPartFile = await MultipartFile.fromFile( - path!, - filename: name, - contentType: mediaType, - ); - } - return multiPartFile; + return switch (CurrentPlatform.type) { + PlatformType.web => MultipartFile.fromBytes( + bytes!, + filename: name, + contentType: mediaType, + ), + _ => await MultipartFile.fromFile( + path!, + filename: name, + contentType: mediaType, + ), + }; } /// Creates a copy of this [AttachmentFile] but with the given fields @@ -106,7 +104,7 @@ class AttachmentFile { /// Union class to hold various [UploadState] of a attachment. @freezed -class UploadState with _$UploadState { +sealed class UploadState with _$UploadState { // Dummy private constructor in order to use getters const UploadState._(); diff --git a/packages/stream_chat/lib/src/core/models/message_state.dart b/packages/stream_chat/lib/src/core/models/message_state.dart index 6429245196..06ca52fe93 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.dart @@ -134,7 +134,7 @@ extension MessageStateX on MessageState { /// Represents the various states a message can be in. @freezed -class MessageState with _$MessageState { +sealed class MessageState with _$MessageState { /// Initial state when the message is created. const factory MessageState.initial() = MessageInitial; @@ -243,7 +243,7 @@ class MessageState with _$MessageState { /// Represents the state of an outgoing message. @freezed -class OutgoingState with _$OutgoingState { +sealed class OutgoingState with _$OutgoingState { /// Sending state when the message is being sent. const factory OutgoingState.sending() = Sending; @@ -262,7 +262,7 @@ class OutgoingState with _$OutgoingState { /// Represents the completed state of a message. @freezed -class CompletedState with _$CompletedState { +sealed class CompletedState with _$CompletedState { /// Sent state when the message has been successfully sent. const factory CompletedState.sent() = Sent; @@ -281,7 +281,7 @@ class CompletedState with _$CompletedState { /// Represents the failed state of a message. @freezed -class FailedState with _$FailedState { +sealed class FailedState with _$FailedState { /// Sending failed state when the message fails to be sent. const factory FailedState.sendingFailed() = SendingFailed; diff --git a/packages/stream_chat/lib/src/core/util/extension.dart b/packages/stream_chat/lib/src/core/util/extension.dart index 132413a6fb..1d12fe44ef 100644 --- a/packages/stream_chat/lib/src/core/util/extension.dart +++ b/packages/stream_chat/lib/src/core/util/extension.dart @@ -22,12 +22,8 @@ extension MapX on Map { extension StringX on String { /// returns the media type from the passed file name. MediaType? get mediaType { - if (toLowerCase().endsWith('heic')) { - return MediaType.parse('image/heic'); - } else { - final mimeType = lookupMimeType(this); - if (mimeType == null) return null; - return MediaType.parse(mimeType); - } + final mimeType = lookupMimeType(this); + if (mimeType == null) return null; + return MediaType.parse(mimeType); } } diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index ac88e7f57f..bdb9536e26 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +✅ Added + +- Added support for `voiceRecording` type attachments. + ## 9.2.0+1 - Remove untracked files from the package. diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index 3718900e6c..52933e18cf 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -156,6 +156,7 @@ class _SplitViewState extends State { : Center( child: Text( 'Pick a channel to show the messages 💬', + textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall, ), ), @@ -225,124 +226,121 @@ class _ChannelPageState extends State { final focusNode = FocusNode(); @override - Widget build(BuildContext context) => Navigator( - onGenerateRoute: (settings) => MaterialPageRoute( - builder: (context) => Scaffold( - appBar: StreamChannelHeader( - onBackPressed: widget.onBackPressed != null - ? () { - widget.onBackPressed!(context); - } - : null, - onImageTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return StreamChannel( - channel: StreamChannel.of(context).channel, - child: const DebugChannelPage(), - ); - }, - ), + Widget build(BuildContext context) { + return Scaffold( + appBar: StreamChannelHeader( + onBackPressed: widget.onBackPressed != null + ? () { + widget.onBackPressed!(context); + } + : null, + onImageTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return StreamChannel( + channel: StreamChannel.of(context).channel, + child: const DebugChannelPage(), ); }, - showBackButton: widget.showBackButton, ), - body: Column( - children: [ - Expanded( - child: StreamMessageListView( - threadBuilder: (_, parent) => ThreadPage(parent: parent!), - messageBuilder: ( - context, - messageDetails, - messages, - defaultWidget, - ) { - // The threshold after which the message is considered - // swiped. - const threshold = 0.2; - - final isMyMessage = messageDetails.isMyMessage; - - // The direction in which the message can be swiped. - final swipeDirection = isMyMessage - ? SwipeDirection.endToStart // - : SwipeDirection.startToEnd; - - return Swipeable( - key: ValueKey(messageDetails.message.id), - direction: swipeDirection, - swipeThreshold: threshold, - onSwiped: (details) => reply(messageDetails.message), - backgroundBuilder: (context, details) { - // The alignment of the swipe action. - final alignment = isMyMessage - ? Alignment.centerRight // - : Alignment.centerLeft; - - // The progress of the swipe action. - final progress = - math.min(details.progress, threshold) / threshold; - - // The offset for the reply icon. - var offset = Offset.lerp( - const Offset(-24, 0), - const Offset(12, 0), - progress, - )!; - - // If the message is mine, we need to flip the offset. - if (isMyMessage) { - offset = Offset(-offset.dx, -offset.dy); - } - - final _streamTheme = StreamChatTheme.of(context); - - return Align( - alignment: alignment, - child: Transform.translate( - offset: offset, - child: Opacity( - opacity: progress, - child: SizedBox.square( - dimension: 30, - child: CustomPaint( - painter: AnimatedCircleBorderPainter( - progress: progress, - color: _streamTheme.colorTheme.borders, - ), - child: Center( - child: StreamSvgIcon( - icon: StreamSvgIcons.reply, - size: lerpDouble(0, 18, progress), - color: _streamTheme - .colorTheme.accentPrimary, - ), - ), - ), + ); + }, + showBackButton: widget.showBackButton, + ), + body: Column( + children: [ + Expanded( + child: StreamMessageListView( + threadBuilder: (_, parent) => ThreadPage(parent: parent!), + messageBuilder: ( + context, + messageDetails, + messages, + defaultWidget, + ) { + // The threshold after which the message is considered + // swiped. + const threshold = 0.2; + + final isMyMessage = messageDetails.isMyMessage; + + // The direction in which the message can be swiped. + final swipeDirection = isMyMessage + ? SwipeDirection.endToStart // + : SwipeDirection.startToEnd; + + return Swipeable( + key: ValueKey(messageDetails.message.id), + direction: swipeDirection, + swipeThreshold: threshold, + onSwiped: (details) => reply(messageDetails.message), + backgroundBuilder: (context, details) { + // The alignment of the swipe action. + final alignment = isMyMessage + ? Alignment.centerRight // + : Alignment.centerLeft; + + // The progress of the swipe action. + final progress = + math.min(details.progress, threshold) / threshold; + + // The offset for the reply icon. + var offset = Offset.lerp( + const Offset(-24, 0), + const Offset(12, 0), + progress, + )!; + + // If the message is mine, we need to flip the offset. + if (isMyMessage) { + offset = Offset(-offset.dx, -offset.dy); + } + + final _streamTheme = StreamChatTheme.of(context); + + return Align( + alignment: alignment, + child: Transform.translate( + offset: offset, + child: Opacity( + opacity: progress, + child: SizedBox.square( + dimension: 30, + child: CustomPaint( + painter: AnimatedCircleBorderPainter( + progress: progress, + color: _streamTheme.colorTheme.borders, + ), + child: Center( + child: StreamSvgIcon( + icon: StreamSvgIcons.reply, + size: lerpDouble(0, 18, progress), + color: _streamTheme.colorTheme.accentPrimary, ), ), ), - ); - }, - child: defaultWidget.copyWith(onReplyTap: reply), - ); - }, - ), - ), - StreamMessageInput( - onQuotedMessageCleared: - messageInputController.clearQuotedMessage, - focusNode: focusNode, - messageInputController: messageInputController, - ), - ], + ), + ), + ), + ); + }, + child: defaultWidget.copyWith(onReplyTap: reply), + ); + }, ), ), - ), - ); + StreamMessageInput( + enableVoiceRecording: true, + onQuotedMessageCleared: messageInputController.clearQuotedMessage, + focusNode: focusNode, + messageInputController: messageInputController, + ), + ], + ), + ); + } void reply(Message message) { messageInputController.quotedMessage = message; @@ -381,6 +379,7 @@ class ThreadPage extends StatelessWidget { ), ), StreamMessageInput( + enableVoiceRecording: true, messageInputController: StreamMessageInputController( message: Message(parentId: parent.id), ), diff --git a/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake b/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake index f4b063d1a4..516e21cdf6 100644 --- a/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake +++ b/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop file_selector_linux media_kit_video + record_linux sqlite3_flutter_libs url_launcher_linux ) diff --git a/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake b/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake index f54e294ad4..b68b6f09cd 100644 --- a/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake +++ b/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop file_selector_windows media_kit_video + record_windows screen_brightness_windows share_plus sqlite3_flutter_libs diff --git a/packages/stream_chat_flutter/lib/assets/icons/icon_link.svg b/packages/stream_chat_flutter/lib/assets/icons/icon_link.svg new file mode 100644 index 0000000000..16611bee85 --- /dev/null +++ b/packages/stream_chat_flutter/lib/assets/icons/icon_link.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/stream_chat_flutter/lib/assets/icons/icon_lock.svg b/packages/stream_chat_flutter/lib/assets/icons/icon_lock.svg new file mode 100644 index 0000000000..7f4aa5f350 --- /dev/null +++ b/packages/stream_chat_flutter/lib/assets/icons/icon_lock.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/stream_chat_flutter/lib/assets/icons/icon_mic.svg b/packages/stream_chat_flutter/lib/assets/icons/icon_mic.svg new file mode 100644 index 0000000000..a0ad41c5e7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/assets/icons/icon_mic.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/stream_chat_flutter/lib/assets/icons/icon_pause.svg b/packages/stream_chat_flutter/lib/assets/icons/icon_pause.svg new file mode 100644 index 0000000000..e7c5bf3944 --- /dev/null +++ b/packages/stream_chat_flutter/lib/assets/icons/icon_pause.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/stream_chat_flutter/lib/assets/icons/icon_play.svg b/packages/stream_chat_flutter/lib/assets/icons/icon_play.svg new file mode 100644 index 0000000000..8967ea4758 --- /dev/null +++ b/packages/stream_chat_flutter/lib/assets/icons/icon_play.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/stream_chat_flutter/lib/assets/icons/icon_stop.svg b/packages/stream_chat_flutter/lib/assets/icons/icon_stop.svg new file mode 100644 index 0000000000..89db48c288 --- /dev/null +++ b/packages/stream_chat_flutter/lib/assets/icons/icon_stop.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart index d5ce294127..851610d5b5 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart @@ -9,6 +9,7 @@ part 'image_attachment_builder.dart'; part 'mixed_attachment_builder.dart'; part 'url_attachment_builder.dart'; part 'video_attachment_builder.dart'; +part 'voice_recording_attachment_playlist_builder.dart'; part 'voice_recording_attachment_builder/voice_recording_attachment_builder.dart'; /// {@template streamAttachmentWidgetTapCallback} @@ -113,7 +114,12 @@ abstract class StreamAttachmentWidgetBuilder { onAttachmentTap: onAttachmentTap, ), - VoiceRecordingAttachmentBuilder(), + // Handles voice recording attachments. + VoiceRecordingAttachmentPlaylistBuilder( + shape: shape, + padding: padding, + onAttachmentTap: onAttachmentTap, + ), // We don't handle URL attachments if the message is a reply. if (message.quotedMessage == null) diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart index 3d0eb466b1..0de0abff0d 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart @@ -37,6 +37,11 @@ class MixedAttachmentBuilder extends StreamAttachmentWidgetBuilder { _urlAttachmentBuilder = UrlAttachmentBuilder( padding: EdgeInsets.zero, onAttachmentTap: onAttachmentTap, + ), + _voiceRecordingAttachmentPlaylistBuilder = + VoiceRecordingAttachmentPlaylistBuilder( + padding: EdgeInsets.zero, + onAttachmentTap: onAttachmentTap, ); /// The padding to apply to the mixed attachment widget. @@ -48,26 +53,33 @@ class MixedAttachmentBuilder extends StreamAttachmentWidgetBuilder { late final StreamAttachmentWidgetBuilder _galleryAttachmentBuilder; late final StreamAttachmentWidgetBuilder _fileAttachmentBuilder; late final StreamAttachmentWidgetBuilder _urlAttachmentBuilder; + late final StreamAttachmentWidgetBuilder + _voiceRecordingAttachmentPlaylistBuilder; @override bool canHandle( Message message, Map> attachments, ) { - final types = attachments.keys; + final types = {...attachments.keys}; - final containsImage = types.contains(AttachmentType.image); - final containsVideo = types.contains(AttachmentType.video); - final containsGiphy = types.contains(AttachmentType.giphy); - final containsFile = types.contains(AttachmentType.file); - final containsUrlPreview = types.contains(AttachmentType.urlPreview); + final mediaTypes = { + AttachmentType.image, + AttachmentType.video, + AttachmentType.giphy, + }; - final containsMedia = containsImage || containsVideo || containsGiphy; + final otherTypes = { + AttachmentType.file, + AttachmentType.urlPreview, + AttachmentType.voiceRecording, + }; - return containsMedia && containsFile || - containsMedia && containsUrlPreview || - containsFile && containsUrlPreview || - containsMedia && containsFile && containsUrlPreview; + // Check if there's at least one media type and one other type + final hasMedia = types.intersection(mediaTypes).isNotEmpty; + final hasOther = types.intersection(otherTypes).isNotEmpty; + + return hasMedia && hasOther || types.intersection(otherTypes).length > 1; } @override @@ -80,6 +92,7 @@ class MixedAttachmentBuilder extends StreamAttachmentWidgetBuilder { final urls = attachments[AttachmentType.urlPreview]; final files = attachments[AttachmentType.file]; + final voiceRecordings = attachments[AttachmentType.voiceRecording]; final images = attachments[AttachmentType.image]; final videos = attachments[AttachmentType.video]; final giphys = attachments[AttachmentType.giphy]; @@ -99,6 +112,10 @@ class MixedAttachmentBuilder extends StreamAttachmentWidgetBuilder { _fileAttachmentBuilder.build(context, message, { AttachmentType.file: files, }), + if (voiceRecordings != null) + _voiceRecordingAttachmentPlaylistBuilder.build(context, message, { + AttachmentType.voiceRecording: voiceRecordings, + }), if (shouldBuildGallery) _galleryAttachmentBuilder.build(context, message, { if (images != null) AttachmentType.image: images, diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart index aaae59c96f..d13e2ec620 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'dart:async'; import 'package:collection/collection.dart'; @@ -8,6 +10,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template StreamVoiceRecordingListPlayer} /// Display many audios and displays a list of AudioPlayerMessage. /// {@endtemplate} +@Deprecated('Use StreamVoiceRecordingAttachmentPlaylist instead') class StreamVoiceRecordingListPlayer extends StatefulWidget { /// {@macro StreamVoiceRecordingListPlayer} const StreamVoiceRecordingListPlayer({ @@ -31,6 +34,7 @@ class StreamVoiceRecordingListPlayer extends StatefulWidget { _StreamVoiceRecordingListPlayerState(); } +@Deprecated("Use 'StreamVoiceRecordingAttachmentPlaylist' instead") class _StreamVoiceRecordingListPlayerState extends State { final _player = AudioPlayer(); @@ -115,6 +119,7 @@ class _StreamVoiceRecordingListPlayerState /// {@template PlayListItem} /// Represents an audio attachment meta data. /// {@endtemplate} +@Deprecated("Use 'PlaylistTrack' instead") class PlayListItem { /// {@macro PlayListItem} const PlayListItem({ diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart index 2ff3c02825..739857139f 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -6,6 +8,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// message is still not available. One use situation in when the audio is /// still being uploaded. /// {@endtemplate} +@Deprecated('Will be removed in the next major version') class StreamVoiceRecordingLoading extends StatelessWidget { /// {@macro StreamVoiceRecordingLoading} const StreamVoiceRecordingLoading({super.key}); diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart index 3ad5ebb934..8c48a9af51 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'dart:async'; import 'dart:math'; @@ -14,6 +16,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// When waveBars are not provided they are shown as 0 bars. /// {@endtemplate} +@Deprecated("Use 'StreamVoiceRecordingAttachment' instead") class StreamVoiceRecordingPlayer extends StatefulWidget { /// {@macro StreamVoiceRecordingPlayer} const StreamVoiceRecordingPlayer({ @@ -51,6 +54,7 @@ class StreamVoiceRecordingPlayer extends StatefulWidget { _StreamVoiceRecordingPlayerState(); } +@Deprecated("Use 'StreamVoiceRecordingAttachment' instead") class _StreamVoiceRecordingPlayerState extends State { var _seeking = false; diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart index 3db9b77289..a3ed7ddbbb 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'dart:math'; import 'package:collection/collection.dart'; @@ -9,6 +11,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// This Widget is indeed to be used to control the position of an audio message /// and to get feedback of the position. /// {@endtemplate} +@Deprecated("Use 'StreamAudioWaveformSlider' instead") class StreamVoiceRecordingSlider extends StatefulWidget { /// {@macro StreamVoiceRecordingSlider} const StreamVoiceRecordingSlider({ @@ -50,6 +53,7 @@ class StreamVoiceRecordingSlider extends StatefulWidget { _StreamVoiceRecordingSliderState(); } +@Deprecated("Use 'StreamAudioWaveformSlider' instead") class _StreamVoiceRecordingSliderState extends State { var _dragging = false; diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder.dart index f1bf68c5c5..412b653cc0 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder.dart @@ -1,6 +1,9 @@ +// coverage:ignore-file + part of '../attachment_widget_builder.dart'; /// The default attachment builder for voice recordings +@Deprecated("Use 'VoiceRecordingAttachmentPlaylistBuilder' instead") class VoiceRecordingAttachmentBuilder extends StreamAttachmentWidgetBuilder { @override bool canHandle(Message message, Map> attachments) { @@ -10,21 +13,6 @@ class VoiceRecordingAttachmentBuilder extends StreamAttachmentWidgetBuilder { return false; } - Duration _resolveDuration(Attachment attachment) { - final duration = attachment.extraData['duration'] as double?; - if (duration == null) { - return Duration.zero; - } - - return Duration(milliseconds: duration.round() * 1000); - } - - List _resolveWaveform(Attachment attachment) { - final waveform = - attachment.extraData['waveform_data'] as List? ?? []; - return waveform.map((e) => double.tryParse(e.toString())).nonNulls.toList(); - } - @override Widget build(BuildContext context, Message message, Map> attachments) { @@ -35,8 +23,8 @@ class VoiceRecordingAttachmentBuilder extends StreamAttachmentWidgetBuilder { .map( (r) => PlayListItem( assetUrl: r.assetUrl, - duration: _resolveDuration(r), - waveForm: _resolveWaveform(r), + duration: r.duration, + waveForm: r.waveform, ), ) .toList(), diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_playlist_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_playlist_builder.dart new file mode 100644 index 0000000000..f53fea8642 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_playlist_builder.dart @@ -0,0 +1,64 @@ +part of 'attachment_widget_builder.dart'; + +/// {@template voiceRecordingAttachmentPlaylistBuilder} +/// A [StreamAttachmentWidgetBuilder] for building a voice recording attachment +/// playlist widget. +/// +/// This widget is used to display a list of voice recordings in a message. +/// +/// The widget is built when the message has at least one voice recording +/// attachment. +/// {@endtemplate} +class VoiceRecordingAttachmentPlaylistBuilder + extends StreamAttachmentWidgetBuilder { + /// {@macro voiceRecordingAttachmentPlaylistBuilder} + const VoiceRecordingAttachmentPlaylistBuilder({ + this.shape, + this.padding = const EdgeInsets.all(16), + this.constraints = const BoxConstraints(), + this.onAttachmentTap, + }); + + /// The shape of the video attachment. + final ShapeBorder? shape; + + /// The padding to apply to the video attachment widget. + final EdgeInsetsGeometry padding; + + /// The constraints to apply to the video attachment widget. + final BoxConstraints constraints; + + /// The callback to call when the attachment is tapped. + final StreamAttachmentWidgetTapCallback? onAttachmentTap; + + @override + bool canHandle( + Message message, + Map> attachments, + ) { + final playlist = attachments[AttachmentType.voiceRecording]; + return playlist != null && playlist.isNotEmpty; + } + + @override + Widget build( + BuildContext context, + Message message, + Map> attachments, + ) { + assert(debugAssertCanHandle(message, attachments), ''); + + final playlist = attachments[AttachmentType.voiceRecording]!; + + return Padding( + padding: padding, + child: StreamVoiceRecordingAttachmentPlaylist( + shape: shape, + message: message, + voiceRecordings: playlist, + constraints: constraints, + separatorBuilder: (_, __) => SizedBox(height: padding.vertical / 2), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart index 89374932a6..b6f14b291c 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart @@ -52,27 +52,21 @@ class StreamFileAttachmentThumbnail extends StatelessWidget { Widget build(BuildContext context) { final mediaType = file.title?.mediaType; - final isImage = mediaType?.type == AttachmentType.image; - if (isImage) { - return StreamImageAttachmentThumbnail( - image: file, - width: width, - height: height, - fit: fit, - ); - } - - final isVideo = mediaType?.type == AttachmentType.video; - if (isVideo) { - return StreamVideoAttachmentThumbnail( - video: file, - width: width, - height: height, - fit: fit, - ); - } - - // Return a generic file type icon. - return getFileTypeImage(mediaType?.mimeType); + return switch (mediaType?.type) { + AttachmentType.image => StreamImageAttachmentThumbnail( + image: file, + width: width, + height: height, + fit: fit, + ), + AttachmentType.video => StreamVideoAttachmentThumbnail( + video: file, + width: width, + height: height, + fit: fit, + ), + // Return a generic file type icon. + _ => getFileTypeImage(mediaType?.mimeType), + }; } } diff --git a/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment.dart new file mode 100644 index 0000000000..3681d30939 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment.dart @@ -0,0 +1,355 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; +import 'package:stream_chat_flutter/src/audio/audio_sampling.dart' as sampling; +import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +const _kDefaultWaveformLimit = 35; +const _kDefaultWaveformHeight = 28.0; + +/// Signature for building trailing widgets in voice recording attachments. +/// +/// Provides a flexible way to customize the trailing section of the +/// voice recording player based on the current track and playback state. +typedef StreamVoiceRecordingAttachmentTrailingWidgetBuilder = Widget Function( + BuildContext context, + PlaylistTrack track, + PlaybackSpeed speed, + ValueChanged? onChangeSpeed, +); + +/// {@template streamVoiceRecordingAttachment} +/// An embedded audio player for voice recordings with comprehensive playback +/// controls. +/// +/// Provides a rich audio message player with features including: +/// - Play/pause controls +/// - Waveform visualization +/// - Playback speed adjustment +/// - Optional title display +/// +/// Supports customizable appearance and interaction through various parameters. +/// {@endtemplate} +class StreamVoiceRecordingAttachment extends StatelessWidget { + /// {@macro streamVoiceRecordingAttachment} + const StreamVoiceRecordingAttachment({ + super.key, + required this.track, + required this.speed, + this.onTrackPause, + this.onTrackPlay, + this.onTrackReplay, + this.onTrackSeekStart, + this.onTrackSeekChanged, + this.onTrackSeekEnd, + this.onChangeSpeed, + this.shape, + this.constraints = const BoxConstraints(), + this.showTitle = false, + this.trailingBuilder = _defaultTrailingBuilder, + }); + + /// The audio track to display. + final PlaylistTrack track; + + /// The current playback speed of the audio track. + final PlaybackSpeed speed; + + /// Callback when the track is paused. + final VoidCallback? onTrackPause; + + /// Callback when the track is played. + final VoidCallback? onTrackPlay; + + /// Callback when the track is replayed. + final VoidCallback? onTrackReplay; + + /// Callback when the track seek is started. + final ValueChanged? onTrackSeekStart; + + /// Callback when the track seek is changed. + final ValueChanged? onTrackSeekChanged; + + /// Callback when the track seek is ended. + final ValueChanged? onTrackSeekEnd; + + /// Callback when the playback speed is changed. + final ValueChanged? onChangeSpeed; + + /// The shape of the attachment. + /// + /// Defaults to [RoundedRectangleBorder] with a radius of 14. + final ShapeBorder? shape; + + /// The constraints to use when displaying the voice recording. + final BoxConstraints constraints; + + /// Whether to show the title of the audio message. + /// + /// Defaults to `false`. + final bool showTitle; + + /// The builder to use for the trailing widget. + final StreamVoiceRecordingAttachmentTrailingWidgetBuilder trailingBuilder; + + static Widget _defaultTrailingBuilder( + BuildContext context, + PlaylistTrack track, + PlaybackSpeed speed, + ValueChanged? onChangeSpeed, + ) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: switch (track.state.isPlaying) { + true => SpeedControlButton( + speed: speed, + onChangeSpeed: onChangeSpeed, + ), + false => getFileTypeImage(track.title?.mediaType?.mimeType), + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = StreamVoiceRecordingAttachmentTheme.of(context); + final waveformSliderTheme = theme.audioWaveformSliderTheme; + final waveformTheme = waveformSliderTheme?.audioWaveformTheme; + + final shape = this.shape ?? + RoundedRectangleBorder( + side: BorderSide( + color: StreamChatTheme.of(context).colorTheme.borders, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.circular(14), + ); + + return Container( + constraints: constraints, + clipBehavior: Clip.hardEdge, + padding: const EdgeInsets.all(8), + decoration: ShapeDecoration( + shape: shape, + color: theme.backgroundColor, + ), + child: Row( + children: [ + AudioControlButton( + state: track.state, + onPlay: onTrackPlay, + onPause: onTrackPause, + onReplay: onTrackReplay, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (track.title case final title? when showTitle) ...[ + AudioTitleText( + title: title, + style: theme.titleTextStyle, + ), + const SizedBox(height: 6), + ], + Row( + children: [ + AudioDurationText( + duration: track.duration, + position: track.position, + style: theme.durationTextStyle, + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: _kDefaultWaveformHeight, + child: StreamAudioWaveformSlider( + limit: _kDefaultWaveformLimit, + waveform: sampling.resampleWaveformData( + track.waveform, + _kDefaultWaveformLimit, + ), + progress: track.progress, + onChangeStart: onTrackSeekStart, + onChanged: onTrackSeekChanged, + onChangeEnd: onTrackSeekEnd, + color: waveformTheme?.color, + progressColor: waveformTheme?.progressColor, + minBarHeight: waveformTheme?.minBarHeight, + spacingRatio: waveformTheme?.spacingRatio, + heightScale: waveformTheme?.heightScale, + thumbColor: waveformSliderTheme?.thumbColor, + thumbBorderColor: + waveformSliderTheme?.thumbBorderColor, + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 14), + trailingBuilder(context, track, speed, onChangeSpeed), + ], + ), + ); + } +} + +/// {@template audioTitleText} +/// A compact text widget for displaying audio file titles. +/// +/// Renders the title with ellipsis truncation and optional styling. +/// {@endtemplate} +class AudioTitleText extends StatelessWidget { + /// {@macro audioTitleText} + const AudioTitleText({ + super.key, + required this.title, + this.style, + }); + + /// The title to display. + final String title; + + /// The style to apply to the title. + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return Text( + title, + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } +} + +/// {@template audioDurationText} +/// Displays duration for audio playback with dynamic formatting. +/// +/// Shows either current position or total duration based on playback state. +/// {@endtemplate} +class AudioDurationText extends StatelessWidget { + /// {@macro audioDurationText} + const AudioDurationText({ + super.key, + required this.duration, + required this.position, + this.style, + }); + + /// The total duration of the audio track. + final Duration duration; + + /// The current position of the audio track. + final Duration position; + + /// The style to apply to the duration text. + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return Text( + switch (position.inMilliseconds > 0) { + true => position.toMinutesAndSeconds(), + false => duration.toMinutesAndSeconds(), + }, + style: style?.copyWith( + // Use mono space for each num character. + fontFeatures: [const FontFeature.tabularFigures()], + ), + ); + } +} + +/// {@template audioControlButton} +/// A control button for managing audio playback state. +/// +/// Adapts its icon and behavior based on the current track state: +/// - Loading: Shows a progress indicator +/// - Idle: Displays play icon +/// - Playing: Shows pause icon +/// - Paused: Shows play icon +/// {@endtemplate} +class AudioControlButton extends StatelessWidget { + /// {@macro audioControlButton} + const AudioControlButton({ + super.key, + required this.state, + this.onPlay, + this.onPause, + this.onReplay, + }); + + /// The current state of the audio track. + final TrackState state; + + /// Callback when the track is played. + final VoidCallback? onPlay; + + /// Callback when the track is paused. + final VoidCallback? onPause; + + /// Callback when the track is replayed. + final VoidCallback? onReplay; + + @override + Widget build(BuildContext context) { + final theme = StreamVoiceRecordingAttachmentTheme.of(context); + + return ElevatedButton( + style: theme.audioControlButtonStyle, + onPressed: switch (state) { + TrackState.loading => null, + TrackState.idle => onPlay, + TrackState.playing => onPause, + TrackState.paused => onPlay, + }, + child: switch (state) { + TrackState.loading => theme.loadingIndicator, + TrackState.idle => theme.playIcon, + TrackState.playing => theme.pauseIcon, + TrackState.paused => theme.playIcon, + }, + ); + } +} + +/// {@template speedControlButton} +/// A button for controlling audio playback speed. +/// +/// Allows cycling through predefined playback speeds when pressed. +/// {@endtemplate} +class SpeedControlButton extends StatelessWidget { + /// {@macro speedControlButton} + const SpeedControlButton({ + super.key, + required this.speed, + this.onChangeSpeed, + }); + + /// The current playback speed of the audio track. + final PlaybackSpeed speed; + + /// Callback when the playback speed is changed. + final ValueChanged? onChangeSpeed; + + @override + Widget build(BuildContext context) { + final theme = StreamVoiceRecordingAttachmentTheme.of(context); + + return ElevatedButton( + style: theme.speedControlButtonStyle, + onPressed: switch (onChangeSpeed) { + final it? => () => it(speed.next), + _ => null, + }, + child: Text('x${speed.speed}'), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment_playlist.dart b/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment_playlist.dart new file mode 100644 index 0000000000..aa062da2f9 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment_playlist.dart @@ -0,0 +1,156 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/attachment/voice_recording_attachment.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; + +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template streamVoiceRecordingAttachmentPlaylist} +/// Shows a voice recording attachment in a [StreamMessageWidget]. +/// {@endtemplate} +class StreamVoiceRecordingAttachmentPlaylist extends StatefulWidget { + /// {@macro streamVoiceRecordingAttachmentPlaylist} + const StreamVoiceRecordingAttachmentPlaylist({ + super.key, + this.shape, + required this.message, + required this.voiceRecordings, + this.padding, + this.itemBuilder, + this.separatorBuilder = _defaultVoiceRecordingPlaylistSeparatorBuilder, + this.constraints = const BoxConstraints(), + }); + + /// The shape of the attachment. + /// + /// Defaults to [RoundedRectangleBorder] with a radius of 14. + final ShapeBorder? shape; + + /// The [Message] that the voice recording is attached to. + final Message message; + + /// The list of [Attachment] object containing the voice recording + /// information. + final List voiceRecordings; + + /// The constraints to use when displaying the voice recording. + final BoxConstraints constraints; + + /// The amount of space by which to inset the children. + final EdgeInsetsGeometry? padding; + + /// The builder to use for each voice recording. + /// + /// If not provided, a default implementation will be used. + final IndexedWidgetBuilder? itemBuilder; + + /// The separator to use between the voice recordings. + final IndexedWidgetBuilder separatorBuilder; + + // Default separator builder for the voice recording playlist. + static Widget _defaultVoiceRecordingPlaylistSeparatorBuilder( + BuildContext context, + int index, + ) { + return const SizedBox.shrink(); + } + + @override + State createState() => + _StreamVoiceRecordingAttachmentPlaylistState(); +} + +class _StreamVoiceRecordingAttachmentPlaylistState + extends State { + late final _controller = StreamAudioPlaylistController( + widget.voiceRecordings.toPlaylist(), + ); + + @override + void initState() { + super.initState(); + _controller.initialize(); + } + + @override + void didUpdateWidget( + covariant StreamVoiceRecordingAttachmentPlaylist oldWidget, + ) { + super.didUpdateWidget(oldWidget); + final equals = const ListEquality().equals; + if (!equals(widget.voiceRecordings, oldWidget.voiceRecordings)) { + // If the playlist have changed, update the playlist. + _controller.updatePlaylist(widget.voiceRecordings.toPlaylist()); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, _) { + return MediaQuery.removePadding( + context: context, + // Workaround for the bottom padding issue. + // Link: https://github.com/flutter/flutter/issues/156149 + removeTop: true, + removeBottom: true, + child: ListView.separated( + shrinkWrap: true, + padding: widget.padding, + physics: const NeverScrollableScrollPhysics(), + itemCount: state.tracks.length, + separatorBuilder: widget.separatorBuilder, + itemBuilder: (context, index) { + if (widget.itemBuilder case final builder?) { + return builder(context, index); + } + + final track = state.tracks[index]; + return StreamVoiceRecordingAttachment( + track: track, + speed: state.speed, + showTitle: true, + shape: widget.shape, + constraints: widget.constraints, + onTrackPause: _controller.pause, + onChangeSpeed: _controller.setSpeed, + onTrackPlay: () async { + // Play the track directly if it is already loaded. + if (state.currentIndex == index) return _controller.play(); + // Otherwise, load the track first and then play it. + return _controller.skipToItem(index); + }, + // Only allow seeking if the current track is the one being + // interacted with. + onTrackSeekStart: (_) async { + if (state.currentIndex != index) return; + return _controller.pause(); + }, + onTrackSeekEnd: (_) async { + if (state.currentIndex != index) return; + return _controller.play(); + }, + onTrackSeekChanged: (progress) async { + if (state.currentIndex != index) return; + + final duration = track.duration.inMicroseconds; + final seekPosition = (duration * progress).toInt(); + final seekDuration = Duration(microseconds: seekPosition); + + return _controller.seek(seekDuration); + }, + ); + }, + ), + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/audio/audio_playlist_controller.dart b/packages/stream_chat_flutter/lib/src/audio/audio_playlist_controller.dart new file mode 100644 index 0000000000..2e8f13f5a1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/audio/audio_playlist_controller.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; + +/// {@template streamAudioPlaylistController} +/// A controller for managing an audio playlist. +/// {@endtemplate} +class StreamAudioPlaylistController extends ValueNotifier { + /// {@macro streamAudioPlaylistController} + factory StreamAudioPlaylistController(List tracks) { + return StreamAudioPlaylistController.raw( + player: AudioPlayer(), + state: AudioPlaylistState(tracks: tracks), + ); + } + + /// {@macro streamAudioPlaylistController} + @visibleForTesting + StreamAudioPlaylistController.raw({ + required AudioPlayer player, + AudioPlaylistState state = const AudioPlaylistState(tracks: []), + }) : _player = player, + super(state); + + final AudioPlayer _player; + + StreamSubscription? _playerStateSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _speedSubscription; + + /// Initializes the controller and starts listening to player state changes. + Future initialize() async { + // Listen to player state changes + _playerStateSubscription = _player.playerStateStream.listen((state) { + // Handle auto-advance when track completes + if (state.playing && state.processingState == ProcessingState.completed) { + return _onTrackComplete(); + } + + final currentIndex = value.currentIndex; + if (currentIndex == null) return; + + final tracks = [ + ...value.tracks.mapIndexed((index, track) { + final trackState = switch (index == currentIndex) { + true => state.playing + ? TrackState.playing + : switch (state.processingState) { + ProcessingState.idle => TrackState.idle, + ProcessingState.loading => TrackState.loading, + _ => TrackState.paused, + }, + false => switch (track.state) { + TrackState.idle => TrackState.idle, + _ => TrackState.paused, + }, + }; + + return track.copyWith(state: trackState); + }) + ]; + + value = value.copyWith(tracks: tracks); + }); + + // Listen to position changes + _positionSubscription = _player.positionStream.listen((position) { + final currentIndex = value.currentIndex; + if (currentIndex == null) return; + + final tracks = [ + ...value.tracks.mapIndexed((index, track) { + if (index != currentIndex) return track; + return track.copyWith(position: position); + }) + ]; + + value = value.copyWith(tracks: tracks); + }); + + // Listen to speed changes + _speedSubscription = _player.speedStream.listen((speed) { + value = value.copyWith(speed: PlaybackSpeed.fromValue(speed)); + }); + } + + void _onTrackComplete() async { + final currentIndex = value.currentIndex; + if (currentIndex == null) return; + + return pause().then((_) => seek(Duration.zero)).then((_) => skipToNext()); + } + + int? get _nextIndex => _getRelativeIndex(1); + int? get _previousIndex => _getRelativeIndex(-1); + int? _getRelativeIndex(int offset) { + final currentIndex = value.currentIndex; + if (currentIndex == null) return null; + + return switch (value.loopMode) { + PlaylistLoopMode.one => currentIndex, + PlaylistLoopMode.off => currentIndex + offset, + PlaylistLoopMode.all => (currentIndex + offset) % value.tracks.length, + }; + } + + /// Plays the current track. + Future play() => _player.play(); + + /// Pauses the current track. + Future pause() => _player.pause(); + + /// Stops the current track. + Future stop() => _player.stop(); + + /// Sets the speed of the current track. + Future setSpeed(PlaybackSpeed speed) => _player.setSpeed(speed.speed); + + /// Seeks to the given position in the current track. + Future seek(Duration position) => _player.seek(position); + + /// Sets the loop mode of the playlist. + Future setLoopMode(PlaylistLoopMode loopMode) async { + value = value.copyWith(loopMode: loopMode); + } + + /// Plays the next track in the playlist. + Future skipToNext({Duration? position}) async { + final index = _nextIndex; + if (index == null) return; + + return skipToItem(index, position: position); + } + + /// Plays the previous track in the playlist. + Future skipToPrevious({Duration? position}) async { + final index = _previousIndex; + if (index == null) return; + + return skipToItem(index, position: position); + } + + /// Seeks to the given position in the current track in the playlist and + /// resumes playing. + Future skipToItem(int index, {Duration? position}) async { + final tracks = value.tracks; + if (tracks.isEmpty) return; + + if (index < 0 || index >= tracks.length) return; + value = value.copyWith(currentIndex: index); + + final track = tracks[index]; + final seekPosition = position ?? track.position; + final audioSource = AudioSource.uri(track.uri); + + final duration = await _player.setAudioSource( + audioSource, + initialPosition: seekPosition, + ); + + value = value.copyWith( + tracks: [ + ...tracks.mapIndexed((i, track) { + if (i != index) return track; + return track.copyWith(duration: duration); + }), + ], + ); + + return play(); + } + + /// Updates the playlist with the given tracks. + /// + /// Note: This will stop the player if it is currently playing. + Future updatePlaylist(List tracks) async { + if (tracks.isEmpty) return; // No tracks to update + + unawaited(_player.stop()); + + value = AudioPlaylistState( + tracks: tracks, + speed: value.speed, + loopMode: value.loopMode, + ); + } + + @override + void dispose() { + _playerStateSubscription?.cancel(); + _positionSubscription?.cancel(); + _speedSubscription?.cancel(); + _player.dispose(); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/audio/audio_playlist_state.dart b/packages/stream_chat_flutter/lib/src/audio/audio_playlist_state.dart new file mode 100644 index 0000000000..36a3078037 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/audio/audio_playlist_state.dart @@ -0,0 +1,228 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; + +/// {@template playlistLoopMode} +/// Represents the loop mode of a playlist. +/// {@endtemplate} +enum PlaylistLoopMode { + /// The playlist will not loop. + off, + + /// The playlist will loop all tracks. + all, + + /// The playlist will loop the current track. + one, +} + +/// {@template audioPlaylistState} +/// Represents the current state of an audio playlist. +/// {@endtemplate} +class AudioPlaylistState { + /// {@macro audioPlaylistState} + const AudioPlaylistState({ + required this.tracks, + this.currentIndex, + this.speed = PlaybackSpeed.regular, + this.loopMode = PlaylistLoopMode.off, + }); + + /// The list of tracks in the playlist. + final List tracks; + + /// The index of the current track being played in the playlist. + /// + /// Defaults to `null`. + final int? currentIndex; + + /// The current playback speed of the playlist. + final PlaybackSpeed speed; + + /// The current loop mode of the playlist. + final PlaylistLoopMode loopMode; + + /// Creates a copy of this [AudioPlaylistState] but with the given fields + /// replaced by the new values. + AudioPlaylistState copyWith({ + List? tracks, + int? currentIndex, + PlaybackSpeed? speed, + PlaylistLoopMode? loopMode, + }) { + return AudioPlaylistState( + tracks: tracks ?? this.tracks, + currentIndex: currentIndex ?? this.currentIndex, + speed: speed ?? this.speed, + loopMode: loopMode ?? this.loopMode, + ); + } + + @override + int get hashCode => Object.hash(tracks, currentIndex, speed, loopMode); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AudioPlaylistState && + runtimeType == other.runtimeType && + const ListEquality().equals(tracks, other.tracks) && + currentIndex == other.currentIndex && + speed == other.speed && + loopMode == other.loopMode; +} + +/// {@template trackState} +/// Represents the current state of a track. +/// {@endtemplate} +enum TrackState { + /// The track has not been loaded yet. + idle, + + /// The track is currently being loaded. + loading, + + /// The track is currently playing. + playing, + + /// The track is currently paused. + paused; + + /// Returns `true` if the track is currently idle. + bool get isIdle => this == TrackState.idle; + + /// Returns `true` if the track is currently loading. + bool get isLoading => this == TrackState.loading; + + /// Returns `true` if the track is currently playing. + bool get isPlaying => this == TrackState.playing; + + /// Returns `true` if the track is currently paused. + bool get isPaused => this == TrackState.paused; +} + +/// {@template playbackSpeed} +/// Represents the speed of a track. +/// {@endtemplate} +enum PlaybackSpeed { + /// The regular speed of the playback (1x). + regular._(1), + + /// A faster speed of the playback (1.5x). + faster._(1.5), + + /// The fastest speed of the playback (2x). + fastest._(2); + + const PlaybackSpeed._(this.speed); + + /// Creates a [PlaybackSpeed] from the given value. + factory PlaybackSpeed.fromValue(double speed) { + return PlaybackSpeed.values.firstWhere( + (it) => it.speed == speed, + orElse: () => PlaybackSpeed.regular, + ); + } + + /// The speed of the playback. + final double speed; +} + +/// Helper extension for [PlaybackSpeed]. +extension StreamAudioPlayerExtension on PlaybackSpeed { + /// Returns the next [PlaybackSpeed] value. + PlaybackSpeed get next { + return switch (this) { + PlaybackSpeed.regular => PlaybackSpeed.faster, + PlaybackSpeed.faster => PlaybackSpeed.fastest, + PlaybackSpeed.fastest => PlaybackSpeed.regular, + }; + } +} + +/// {@template playlistTrack} +/// Represents a track in a playlist. +/// {@endtemplate} +class PlaylistTrack { + /// {@macro playlistTrack} + const PlaylistTrack({ + required this.uri, + this.title, + this.waveform = const [], + this.duration = Duration.zero, + this.position = Duration.zero, + this.state = TrackState.idle, + }); + + /// The uri of the track. + final Uri uri; + + /// The title of the track. + final String? title; + + /// The waveform of the track. + /// + /// Defaults to an empty list. + final List waveform; + + /// The total duration of the track. + /// + /// Defaults to `Duration.zero`. + final Duration duration; + + /// The current playback position of the track. + /// + /// Defaults to `Duration.zero`. + final Duration position; + + /// The current state of the track. + final TrackState state; + + /// The current progress of the track. + double get progress { + final position = this.position.inMicroseconds; + if (position == 0) return 0; + + final duration = this.duration.inMicroseconds; + if (duration == 0) return 0; + + return math.min(position / duration, 1); + } + + /// Creates a copy of this [PlaylistTrack] but with the given fields replaced + /// by the new values. + PlaylistTrack copyWith({ + Uri? uri, + String? title, + Duration? duration, + List? waveform, + Duration? position, + TrackState? state, + }) { + return PlaylistTrack( + uri: uri ?? this.uri, + title: title ?? this.title, + duration: duration ?? this.duration, + waveform: waveform ?? this.waveform, + position: position ?? this.position, + state: state ?? this.state, + ); + } + + @override + int get hashCode { + return Object.hash(uri, title, duration, waveform, position, state); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlaylistTrack && + runtimeType == other.runtimeType && + uri == other.uri && + title == other.title && + duration == other.duration && + waveform == other.waveform && + position == other.position && + state == other.state; +} diff --git a/packages/stream_chat_flutter/lib/src/audio/audio_sampling.dart b/packages/stream_chat_flutter/lib/src/audio/audio_sampling.dart new file mode 100644 index 0000000000..0381d273eb --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/audio/audio_sampling.dart @@ -0,0 +1,142 @@ +import 'dart:math' as math; + +/// Resamples the waveformData to the target size. +List resampleWaveformData( + List waveformData, + int amplitudesCount, +) { + if (waveformData.length > amplitudesCount) { + return downSample(waveformData, amplitudesCount); + } + + if (waveformData.length < amplitudesCount) { + return upSample(waveformData, amplitudesCount); + } + + return waveformData; +} + +/// Downsamples the [data] to the target output size. +/// +/// The downSample function uses the Largest-Triangle-Three-Buckets (LTTB) +/// algorithm. See the thesis Downsampling Time Series for Visual Representation +/// by Sveinn Steinarsson for more (https://skemman.is/bitstream/1946/15343/3/SS_MSthesis.pdf) +List downSample(List data, int targetOutputSize) { + if (data.length <= targetOutputSize || targetOutputSize == 0) return data; + if (targetOutputSize == 1) return [_mean(data)]; + + final result = []; + // bucket size adjusted due to the fact that the first and the last item in + // the original data array is kept in target output + final bucketSize = (data.length - 2) / (targetOutputSize - 2); + var lastSelectedPointIndex = 0; + result.add(data[lastSelectedPointIndex]); // Always add the first point + + for (var bucketIndex = 1; bucketIndex < targetOutputSize - 1; bucketIndex++) { + final previousBucketRefPoint = data[lastSelectedPointIndex]; + final nextBucketMean = _getNextBucketMean(data, bucketIndex, bucketSize); + + final currentBucketStartIndex = + ((bucketIndex - 1) * bucketSize).floor() + 1; + final nextBucketStartIndex = (bucketIndex * bucketSize).floor() + 1; + final countUnitsBetweenAtoC = + 1 + nextBucketStartIndex - currentBucketStartIndex; + + var maxArea = -1.0; + var triangleArea = -1.0; + double? maxAreaPoint; + + for (var currentPointIndex = currentBucketStartIndex; + currentPointIndex < nextBucketStartIndex; + currentPointIndex++) { + final countUnitsBetweenAtoB = + (currentPointIndex - currentBucketStartIndex).abs() + 1; + final countUnitsBetweenBtoC = + countUnitsBetweenAtoC - countUnitsBetweenAtoB; + final currentPointValue = data[currentPointIndex]; + + triangleArea = _triangleAreaHeron( + _triangleBase( + (previousBucketRefPoint - currentPointValue).abs(), + countUnitsBetweenAtoB.toDouble(), + ), + _triangleBase( + (currentPointValue - nextBucketMean).abs(), + countUnitsBetweenBtoC.toDouble(), + ), + _triangleBase( + (previousBucketRefPoint - nextBucketMean).abs(), + countUnitsBetweenAtoC.toDouble(), + ), + ); + + if (triangleArea > maxArea) { + maxArea = triangleArea; + maxAreaPoint = data[currentPointIndex]; + lastSelectedPointIndex = currentPointIndex; + } + } + + if (maxAreaPoint != null) { + result.add(maxAreaPoint); + } + } + + result.add(data[data.length - 1]); // Always add the last point + return result; +} + +double _triangleAreaHeron(double a, double b, double c) { + final s = (a + b + c) / 2; + return math.sqrt(s * (s - a) * (s - b) * (s - c)); +} + +double _triangleBase(double a, double b) { + return math.sqrt(math.pow(a, 2) + math.pow(b, 2)); +} + +double _mean(List values) { + return values.reduce((acc, value) => acc + value) / values.length; +} + +List _divMod(int num, int divisor) => [num ~/ divisor, num % divisor]; + +double _getNextBucketMean( + List data, + int currentBucketIndex, + double bucketSize, +) { + final nextBucketStartIndex = (currentBucketIndex * bucketSize).floor() + 1; + var nextNextBucketStartIndex = + ((currentBucketIndex + 1) * bucketSize).floor() + 1; + nextNextBucketStartIndex = nextNextBucketStartIndex < data.length + ? nextNextBucketStartIndex + : data.length; + + return _mean(data.sublist(nextBucketStartIndex, nextNextBucketStartIndex)); +} + +/// Upsamples the [data] to the target output size. +/// +/// The upSample function extends the array of amplitudes by repeating the +/// values in the array. +/// +/// If the target size is smaller than the length of the array, the function +/// returns the original array. +List upSample(List data, int targetOutputSize) { + if (data.isEmpty) return List.filled(targetOutputSize, 0); + if (data.length >= targetOutputSize || targetOutputSize == 0) return data; + + final divModResult = _divMod(targetOutputSize, data.length); + final bucketSize = divModResult[0]; + var remainder = divModResult[1]; + + final result = []; + + for (var i = 0; i < data.length; i++) { + final extra = remainder > 0 ? 1 : 0; + if (remainder > 0) remainder--; + result.addAll(List.filled(bucketSize + extra, data[i])); + } + return result; +} diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart index 18e113939d..4151acda63 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart @@ -60,7 +60,7 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { optionBuilder: (context, command) { return ListTile( dense: true, - horizontalTitleGap: 0, + horizontalTitleGap: 8, leading: _CommandIcon(command: command), title: Row( children: [ diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart index cb1d80f709..6d90acef10 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart @@ -44,23 +44,26 @@ class StreamMessagePreviewText extends StatelessWidget { AttachmentType.image => '📷', AttachmentType.video => '🎬', AttachmentType.giphy => '[GIF]', + AttachmentType.audio => '🎧', + AttachmentType.voiceRecording => '🎤', _ => it == message.attachments.last ? (it.title ?? 'File') - : '${it.title ?? 'File'} , ', + : '${it.title ?? 'File'},', }, ), if (message.poll?.name case final pollName?) '📊 $pollName', - if (messageText != null) + if (messageText != null && messageText.isNotEmpty) if (messageMentionedUsers.isNotEmpty) ...mentionedUsersRegex.allMatchesWithSep(messageText) else - messageText, + messageText.trim(), ] }; - final fontStyle = (message.isSystem || message.isDeleted) - ? FontStyle.italic - : FontStyle.normal; + final fontStyle = switch (message.isSystem || message.isDeleted) { + true => FontStyle.italic, + false => FontStyle.normal, + }; final regularTextStyle = textStyle?.copyWith(fontStyle: fontStyle); @@ -92,7 +95,7 @@ class StreamMessagePreviewText extends StatelessWidget { style: regularTextStyle, ); }) - ]; + ].insertBetween(const TextSpan(text: ' ')); return Text.rich( TextSpan(children: spans), diff --git a/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.g.dart b/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.g.dart index 34624a954e..c6a7fc2001 100644 --- a/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.g.dart +++ b/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.g.dart @@ -73,6 +73,12 @@ abstract final class StreamSvgIcons { package: package, ); + /// Stream SVG icon named 'pause'. + static const StreamSvgIconData pause = StreamSvgIconData( + 'lib/assets/icons/icon_pause.svg', + package: package, + ); + /// Stream SVG icon named 'error'. static const StreamSvgIconData error = StreamSvgIconData( 'lib/assets/icons/icon_error.svg', @@ -163,6 +169,12 @@ abstract final class StreamSvgIcons { package: package, ); + /// Stream SVG icon named 'mic'. + static const StreamSvgIconData mic = StreamSvgIconData( + 'lib/assets/icons/icon_mic.svg', + package: package, + ); + /// Stream SVG icon named 'download'. static const StreamSvgIconData download = StreamSvgIconData( 'lib/assets/icons/icon_download.svg', @@ -187,6 +199,12 @@ abstract final class StreamSvgIcons { package: package, ); + /// Stream SVG icon named 'play'. + static const StreamSvgIconData play = StreamSvgIconData( + 'lib/assets/icons/icon_play.svg', + package: package, + ); + /// Stream SVG icon named 'loveReaction'. static const StreamSvgIconData loveReaction = StreamSvgIconData( 'lib/assets/icons/icon_love_reaction.svg', @@ -283,6 +301,12 @@ abstract final class StreamSvgIcons { package: package, ); + /// Stream SVG icon named 'link'. + static const StreamSvgIconData link = StreamSvgIconData( + 'lib/assets/icons/icon_link.svg', + package: package, + ); + /// Stream SVG icon named 'userDelete'. static const StreamSvgIconData userDelete = StreamSvgIconData( 'lib/assets/icons/icon_user_delete.svg', @@ -301,6 +325,12 @@ abstract final class StreamSvgIcons { package: package, ); + /// Stream SVG icon named 'lock'. + static const StreamSvgIconData lock = StreamSvgIconData( + 'lib/assets/icons/icon_lock.svg', + package: package, + ); + /// Stream SVG icon named 'arrowRight'. static const StreamSvgIconData arrowRight = StreamSvgIconData( 'lib/assets/icons/icon_arrow_right.svg', @@ -343,6 +373,12 @@ abstract final class StreamSvgIcons { package: package, ); + /// Stream SVG icon named 'stop'. + static const StreamSvgIconData stop = StreamSvgIconData( + 'lib/assets/icons/icon_stop.svg', + package: package, + ); + /// Stream SVG icon named 'thumbsUpReaction'. static const StreamSvgIconData thumbsUpReaction = StreamSvgIconData( 'lib/assets/icons/icon_thumbs_up_reaction.svg', diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index 23ebb29ce3..9a396dfd87 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -487,6 +487,12 @@ abstract class Translations { /// The label for "$count new threads" String newThreadsLabel({required int count}); + + /// The label for "Slide to cancel" + String get slideToCancelLabel; + + /// The label for "Hold to record" + String get holdToRecordLabel; } /// Default implementation of Translation strings for the stream chat widgets @@ -1100,4 +1106,10 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments if (count == 1) return '1 new thread'; return '$count new threads'; } + + @override + String get slideToCancelLabel => 'Slide to cancel'; + + @override + String get holdToRecordLabel => 'Hold to record, release to send.'; } diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_button.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_button.dart index 2e17a5f96e..4212938baa 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_button.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -const double _kDefaultAttachmentButtonSize = 24; - /// {@template attachmentButton} /// A button for adding attachments to a chat on mobile. /// {@endtemplate} @@ -13,7 +12,7 @@ class AttachmentButton extends StatelessWidget { required this.onPressed, this.color, this.icon, - this.size = _kDefaultAttachmentButtonSize, + this.size = kDefaultMessageInputIconSize, }) : assert( (icon == null && color == null) || (icon != null && color == null) || @@ -54,19 +53,11 @@ class AttachmentButton extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( - icon: icon ?? - StreamSvgIcon( - icon: StreamSvgIcons.attach, - color: color, - ), - padding: EdgeInsets.zero, - constraints: BoxConstraints.tightFor( - height: size, - width: size, - ), - splashRadius: size, + return StreamMessageInputIconButton( + color: color, + iconSize: size, onPressed: onPressed, + icon: icon ?? const StreamSvgIcon(icon: StreamSvgIcons.attach), ); } } 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 b0da676b97..50d7e9c1a8 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 @@ -536,7 +536,6 @@ class _AttachmentPickerOptions extends StatelessWidget { } return IconButton( - iconSize: 22, color: colorTheme.accentPrimary, disabledColor: colorTheme.disabled, icon: const StreamSvgIcon( diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart new file mode 100644 index 0000000000..f011cfe75d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart @@ -0,0 +1,284 @@ +import 'dart:async'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/audio/audio_sampling.dart' as sampling; +import 'package:stream_chat_flutter/src/message_input/audio_recorder/audio_recorder_state.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@template streamAudioRecorderController} +/// A controller for recording audio tracks. It provides methods to start, +/// stop, cancel and finish the recording session. +/// +/// This controller uses the [AudioRecorder] to record audio tracks. It listens +/// to the recorder state changes and updates the [AudioRecorderState] +/// accordingly. +/// {@endtemplate} +class StreamAudioRecorderController extends ValueNotifier { + /// {@macro streamAudioRecorderController} + factory StreamAudioRecorderController({ + RecordConfig? config, + Duration amplitudeInterval = const Duration(milliseconds: 100), + }) { + return StreamAudioRecorderController.raw( + recorder: AudioRecorder(), + amplitudeInterval: amplitudeInterval, + config: switch (config) { + final config? => config, + _ => const RecordConfig( + numChannels: 1, + encoder: kIsWeb ? AudioEncoder.wav : AudioEncoder.aacLc, + ), + }, + ); + } + + /// {@macro streamAudioRecorderController} + @visibleForTesting + StreamAudioRecorderController.raw({ + required this.config, + required AudioRecorder recorder, + AudioRecorderState initialState = const RecordStateIdle(), + Duration amplitudeInterval = const Duration(milliseconds: 100), + }) : _recorder = recorder, + super(initialState) { + // Listen to the recorder amplitude changes + _recorderAmplitudeSubscription = _recorder + .onAmplitudeChanged(amplitudeInterval) // + .listen(_onRecorderAmplitudeChanged); + } + + /// The configuration for the recording session. + final RecordConfig config; + final AudioRecorder _recorder; + + /// Starts a new recording session. + Future startRecord() async { + // Only start the recorder if it is currently idle. + if (value case RecordStateIdle()) { + // Return if the recorder does not have permission to record audio. + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) return; + + // Start the recording session. + final tempPath = await _getOutputFilePath(config.encoder); + await _recorder.start(config, path: tempPath); + _startDurationTimer(); + + // Update the state to recording hold. + value = const RecordStateRecordingHold(); + } + } + + /// Stops the current recording session and returns the recorded audio track. + /// + /// Optionally, provide a [name] for the recorded audio attachment. + /// + /// Note: [name] only works on web platform. + Future stopRecord({String? name}) async { + // Only stop the recorder if it is currently recording. + if (value case final RecordStateRecording state) { + final path = await _recorder.stop(); + + // Stop the duration timer. + _durationTimer?.cancel(); + _durationTimer = null; + + if (path == null) throw Exception('Failed to stop the recorder'); + final fileName = name ?? 'audio.${config.encoder.extension}'; + final attachment = await state.toAttachment(path: path, name: fileName); + + // Update the state to stopped. + value = RecordStateStopped(audioRecording: attachment); + } + } + + /// Similar to [stopRecord] but does not update any state. Returns the + /// recorded audio track as an attachment. + /// + /// Optionally, you can provide a [name] for the attachment. + /// + /// Note: [name] only works on web platform. + Future finishRecord({String? name}) async { + // Return the audio recording directly if it is already stopped. + if (value case RecordStateStopped(audioRecording: final recording)) { + return recording; + } + + // Finish the recorder if it is currently recording. + if (value case final RecordStateRecording state) { + final path = await _recorder.stop(); + + // Stop the duration timer. + _durationTimer?.cancel(); + _durationTimer = null; + + if (path == null) throw Exception('Failed to stop the recorder'); + final fileName = name ?? 'audio.${config.encoder.extension}'; + final attachment = await state.toAttachment(path: path, name: fileName); + + return attachment; + } + + return null; + } + + /// Cancels the current recording session and discards the recorded track. + /// + /// Pass [discardTrack] as `false` to keep the recorded track, This is useful + /// when you want to cancel the recording session without losing the recorded + /// track. + Future cancelRecord({bool discardTrack = true}) async { + // Only cancel the recorder if it is currently recording or stopped. + if (value case RecordStateRecording() || RecordStateStopped()) { + if (discardTrack) await _recorder.cancel(); + + // Update the state to idle. + value = const RecordStateIdle(); + } + } + + /// Updates the current recording session in the locked state, no longer + /// requiring the user to hold the button. + void lockRecord() { + // Only lock the recorder if it is currently recording in the hold state. + if (value case final RecordStateRecordingHold state) { + // Update the state to recording locked. + value = RecordStateRecordingLocked( + duration: state.duration, + waveform: state.waveform, + ); + } + } + + /// Updates the drag offset of the recording session. + void dragRecord(Offset dragOffset) { + // Only update the offset if it is currently recording in the hold state. + if (value case final RecordStateRecordingHold state) { + // Update the drag offset. + value = state.copyWith(dragOffset: dragOffset); + } + } + + Timer? _infoTimer; + + /// Shows an info message to the user for the given [duration]. + /// + /// This is useful for showing messages like "Hold to record" or "Recording". + void showInfo( + String message, { + Duration duration = const Duration(seconds: 3), + }) { + // Only show the info message if the recorder is currently idle. + if (value case final RecordStateIdle state) { + // Do not show the same message if it is already being shown. + if (state.message == message) return; + + // Cancel the previous info timer. + _infoTimer?.cancel(); + _infoTimer = null; + + // Update the state to show the info message. + value = RecordStateIdle(message: message); + + // Start a timer to hide the info message after the given duration. + _infoTimer = Timer(duration, () { + // Only hide the info message if it is still being shown. + if (value case RecordStateIdle()) value = const RecordStateIdle(); + }); + } + } + + Future _getOutputFilePath(AudioEncoder encoder) async { + // Ignored on web platform. + if (CurrentPlatform.isWeb) return ''; + + // Generate a temporary path for the audio recording. + final tempDir = await getTemporaryDirectory(); + final currentTimestamp = DateTime.now().millisecondsSinceEpoch; + return '${tempDir.path}/audio_$currentTimestamp.${encoder.extension}'; + } + + StreamSubscription? _recorderAmplitudeSubscription; + void _onRecorderAmplitudeChanged(Amplitude amplitude) { + // Only update the waveform if the recorder is currently recording. + if (value case final RecordStateRecording state) { + final normalizedAmplitude = amplitude.current.normalize(-60, 0); + final updatedWaveForm = [...state.waveform, normalizedAmplitude]; + value = state.copyWith(waveform: updatedWaveForm); + } + } + + Timer? _durationTimer; + void _startDurationTimer() { + _durationTimer ??= Timer.periodic(const Duration(seconds: 1), (_) { + if (value case final RecordStateRecording state) { + final updatedDuration = state.duration + const Duration(seconds: 1); + value = state.copyWith(duration: updatedDuration); + } + }); + } + + @override + void dispose() { + _durationTimer?.cancel(); + _durationTimer = null; + _recorderAmplitudeSubscription?.cancel(); + _recorder.dispose(); + super.dispose(); + } +} + +extension on RecordStateRecording { + /// Converts the current recording state to an attachment. + /// + /// Optionally, provide a [name] for the attachment. + /// + /// Note: [name] only works on web platform. + Future toAttachment({ + required String path, + String? name, + }) async { + final attachmentFile = await XFile(path, name: name).toAttachmentFile; + + final attachment = Attachment( + file: attachmentFile, + type: AttachmentType.voiceRecording, + extraData: { + 'duration': duration.inMilliseconds / 1000, + 'waveform_data': sampling.resampleWaveformData(waveform, 100), + }, + ); + + return attachment; + } +} + +extension on double { + /// Normalizes the value between the given [lowerBound] and [upperBound]. + double normalize(double lowerBound, double upperBound) { + if (this < lowerBound) return 0; + if (this >= upperBound) return 1; + + final delta = upperBound - lowerBound; + return ((this - lowerBound) / delta).abs(); + } +} + +extension on AudioEncoder { + /// Returns the file extension for the audio encoder. + String get extension { + return switch (this) { + AudioEncoder.opus => 'opus', + AudioEncoder.flac => 'flac', + AudioEncoder.wav => 'wav', + AudioEncoder.pcm16bits => 'pcm', + AudioEncoder.amrNb || AudioEncoder.amrWb => '3gp', + AudioEncoder.aacLc || AudioEncoder.aacEld || AudioEncoder.aacHe => 'm4a', + }; + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart new file mode 100644 index 0000000000..4f1de25162 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart @@ -0,0 +1,131 @@ +import 'package:flutter/gestures.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// The state of the audio recorder. +sealed class AudioRecorderState { + const AudioRecorderState._(); +} + +/// {@template recordStateIdle} +/// The audio recorder is currently idle and not recording any audio track. +/// +/// Optionally, provide a [message] to display when the recorder is idle. +/// +/// For example, when the user has not long pressed the record button long +/// enough to start recording. +/// {@endtemplate} +final class RecordStateIdle extends AudioRecorderState { + /// {@macro recordStateIdle} + const RecordStateIdle({this.message}) : super._(); + + /// The optional message to display when the recorder is idle. + final String? message; +} + +/// {@template recordStateRecording} +/// The audio recorder is currently recording an audio track. +/// {@endtemplate} +sealed class RecordStateRecording extends AudioRecorderState { + /// {@macro recordStateRecording} + const RecordStateRecording({ + this.duration = Duration.zero, + this.waveform = const [], + }) : super._(); + + /// The current duration of the audio track being recorded. + /// + /// Defaults to [Duration.zero]. + final Duration duration; + + /// The waveform of the audio track being recorded. + /// + /// Defaults to an empty list. + final List waveform; + + /// Creates a copy of this [RecordStateRecording] but with the given fields + /// replaced by the new values. + RecordStateRecording copyWith({ + Duration? duration, + List? waveform, + }) { + return switch (this) { + RecordStateRecordingHold() => RecordStateRecordingHold( + duration: duration ?? this.duration, + waveform: waveform ?? this.waveform, + ), + RecordStateRecordingLocked() => RecordStateRecordingLocked( + duration: duration ?? this.duration, + waveform: waveform ?? this.waveform, + ), + }; + } +} + +/// {@template recordStateRecordingHold} +/// The audio recorder is currently recording an audio track in a hold state. +/// {@endtemplate} +final class RecordStateRecordingHold extends RecordStateRecording { + /// {@macro recordStateRecordingHold} + const RecordStateRecordingHold({ + super.duration = Duration.zero, + super.waveform = const [], + this.dragOffset = Offset.zero, + }); + + /// The drag offset of the recorder if it is being dragged. + /// + /// Defaults to [Offset.zero]. + final Offset dragOffset; + + /// Creates a copy of this [RecordStateRecordingHold] but with the given + /// fields replaced by the new values. + @override + RecordStateRecordingHold copyWith({ + String? path, + Duration? duration, + List? waveform, + Offset? dragOffset, + }) { + return RecordStateRecordingHold( + duration: duration ?? this.duration, + waveform: waveform ?? this.waveform, + dragOffset: dragOffset ?? this.dragOffset, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RecordStateRecordingHold && + runtimeType == other.runtimeType && + duration == other.duration && + waveform == other.waveform && + dragOffset == other.dragOffset; + + @override + int get hashCode => Object.hash(duration, waveform, dragOffset); +} + +/// {@template recordStateRecordingLocked} +/// The audio recorder is currently recording an audio track in a locked state. +/// {@endtemplate} +final class RecordStateRecordingLocked extends RecordStateRecording { + /// {@macro recordStateRecordingLocked} + const RecordStateRecordingLocked({ + super.duration = Duration.zero, + super.waveform = const [], + }); +} + +/// {@template recordStateStopped} +/// The audio recorder has stopped recording and has a recorded audio track. +/// {@endtemplate} +final class RecordStateStopped extends AudioRecorderState { + /// {@macro recordStateStopped} + const RecordStateStopped({ + required this.audioRecording, + }) : super._(); + + /// The audio recording that was recorded. + final Attachment audioRecording; +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/stream_audio_recorder.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/stream_audio_recorder.dart new file mode 100644 index 0000000000..189a65982c --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/stream_audio_recorder.dart @@ -0,0 +1,1022 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; +import 'package:stream_chat_flutter/src/audio/audio_sampling.dart'; +import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/message_input/audio_recorder/audio_recorder_state.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; +import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; + +/// {@template audioRecorderBuilder} +/// A builder function for constructing the audio recorder UI. +/// +/// See also: +/// - [StreamAudioRecorderButton], which uses this builder function. +/// - [StreamAudioRecorderState], which provides the state of the recorder. +/// {@endtemplate} +typedef AudioRecorderBuilder = Widget Function( + BuildContext, + AudioRecorderState, + Widget, +); + +/// {@template streamAudioRecorderButton} +/// A configurable audio recording button with interactive states and gestures. +/// +/// Manages different recording states: idle, recording, locked, and stopped. +/// Provides fine-grained control over recording interactions through callbacks. +/// +/// {@tool snippet} +/// Basic usage example: +/// ```dart +/// StreamAudioRecorderButton( +/// recordState: _recordState, +/// onRecordStart: () => _startRecording(), +/// onRecordFinish: () => _finishRecording(), +/// ) +/// ``` +/// {@end-tool} +/// {@endtemplate} +class StreamAudioRecorderButton extends StatelessWidget { + /// {@macro streamAudioRecorderButton} + const StreamAudioRecorderButton({ + super.key, + required this.recordState, + this.onRecordStart, + this.onRecordPause, + this.onRecordResume, + this.onRecordDragUpdate, + this.onRecordCancel, + this.onRecordLock, + this.onRecordFinish, + this.onRecordStop, + this.onRecordStartCancel, + this.lockRecordThreshold = 50, + this.cancelRecordThreshold = 75, + }); + + /// The current state of the recorder. + /// + /// This is used to determine the icon and the behavior of the button. + final AudioRecorderState recordState; + + /// The callback to call when the recording is started. + final VoidCallback? onRecordStart; + + /// The callback to call when the recording is paused. + final VoidCallback? onRecordPause; + + /// The callback to call when the recording is resumed. + final VoidCallback? onRecordResume; + + /// The callback to call when the recording is canceled. + final VoidCallback? onRecordCancel; + + /// The callback to call when the recording is locked. + final VoidCallback? onRecordLock; + + /// The callback to call when the recording is finished. + final VoidCallback? onRecordFinish; + + /// The callback to call when the recording is stopped. + final VoidCallback? onRecordStop; + + /// The callback to call when the recorder will not end up starting. + /// + /// This is called when the recording is canceled before it starts. + final VoidCallback? onRecordStartCancel; + + /// The callback to call when the recording drag is updated. + final ValueSetter? onRecordDragUpdate; + + /// The threshold to lock the recording. + final double lockRecordThreshold; + + /// The threshold to cancel the recording. + final double cancelRecordThreshold; + + @override + Widget build(BuildContext context) { + final isRecording = recordState is! RecordStateIdle; + final isLocked = isRecording && recordState is! RecordStateRecordingHold; + + return GestureDetector( + onLongPressStart: (_) { + // Return if the recording is already started. + if (isRecording) return; + return onRecordStart?.call(); + }, + onLongPressEnd: (_) { + // Return if the recording not yet started or already locked. + if (!isRecording || isLocked) return; + return onRecordFinish?.call(); + }, + onLongPressCancel: () { + // Notify the parent that the recorder is canceled before it starts. + return onRecordStartCancel?.call(); + }, + onLongPressMoveUpdate: (details) { + // Return if the recording not yet started or already locked. + if (!isRecording || isLocked) return; + final dragOffset = details.offsetFromOrigin; + + // Lock recording if the drag offset is greater than the threshold. + if (dragOffset.dy <= -lockRecordThreshold) { + return onRecordLock?.call(); + } + // Cancel recording if the drag offset is greater than the threshold. + if (dragOffset.dx <= -cancelRecordThreshold) { + return onRecordCancel?.call(); + } + + // Update the drag offset. + return onRecordDragUpdate?.call(dragOffset); + }, + child: StreamAudioRecorder( + state: recordState, + button: RecordButton( + onPressed: () {}, // Allows showing ripple effect on tap. + icon: const StreamSvgIcon(icon: StreamSvgIcons.mic), + ), + builder: (context, state, recordButton) => switch (state) { + // Show only the record button if the recording is not in progress. + RecordStateIdle() => RecordStateIdleContent( + state: state, + recordButton: recordButton, + ), + RecordStateRecordingHold() => RecordStateHoldRecordingContent( + state: state, + recordButton: recordButton, + cancelThreshold: cancelRecordThreshold, + ), + RecordStateRecordingLocked() => RecordStateLockedRecordingContent( + state: state, + onRecordEnd: onRecordFinish, + onRecordPause: onRecordPause, + onRecordCancel: onRecordCancel, + onRecordStop: onRecordStop, + ), + RecordStateStopped() => RecordStateStoppedContent( + state: state, + onRecordCancel: onRecordCancel, + onRecordFinish: onRecordFinish, + ), + }, + ), + ); + } +} + +/// {@template recordButton} +/// A widget representing the record button for the audio recorder. +/// {@endtemplate} +class RecordButton extends StatelessWidget { + /// {@macro recordButton} + const RecordButton({ + super.key, + required this.icon, + this.onPressed, + }); + + /// The icon to display inside the button. + final Widget icon; + + /// The callback that is called when the button is tapped or otherwise + /// activated. + /// + /// If this is set to null, the button will be disabled. + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return StreamMessageInputIconButton( + onPressed: onPressed, + icon: icon, + ); + } +} + +/// {@template recordStateIdleContent} +/// Represents the idle state content of the audio recorder. +/// +/// Displays the record button and potentially an informational tooltip. +/// +/// Used when no recording is in progress and the recorder is ready to start. +/// {@endtemplate} +class RecordStateIdleContent extends StatelessWidget { + /// {@macro recordStateIdleContent} + const RecordStateIdleContent({ + super.key, + required this.state, + required this.recordButton, + }); + + /// The record button widget to display. + final Widget recordButton; + + /// The current state of the recorder. + final RecordStateIdle state; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + final child = IconTheme( + data: IconThemeData(color: theme.colorTheme.textLowEmphasis), + child: recordButton, + ); + + final info = state.message; + if (info == null || info.isEmpty) return child; + + return PortalTarget( + anchor: const Aligned( + target: Alignment.topRight, + follower: Alignment.bottomRight, + ), + portalFollower: HoldToRecordInfoTooltip(message: info), + child: child, + ); + } +} + +/// {@template recordStateRecordingContent} +/// Represents the recording state when user is holding the record button. +/// +/// Provides visual feedback during recording with timer and cancellation +/// indicators. +/// +/// Manages the interactive state of recording while the button is being held. +/// Allows sliding to cancel or lock the recording. +/// {@endtemplate} +class RecordStateHoldRecordingContent extends StatelessWidget { + /// {@macro recordStateRecordingContent} + const RecordStateHoldRecordingContent({ + super.key, + required this.state, + required this.recordButton, + this.cancelThreshold = 96, + }); + + /// The record button widget to display. + final Widget recordButton; + + /// The threshold to cancel the recording. + final double cancelThreshold; + + /// The current state of the recorder. + final RecordStateRecordingHold state; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + final recordingTime = state.duration; + final dragOffset = Offset( + math.min(state.dragOffset.dx, 0), + math.min(state.dragOffset.dy, 0), + ); + + // Calculate the progress of the cancel threshold. + final cancelProgress = (dragOffset.dx.abs() / cancelThreshold).clamp(0, 1); + + return PortalTarget( + // Show the swipe to lock button once the recording starts. + visible: recordingTime.inSeconds > 0, + anchor: Aligned( + offset: Offset(4, dragOffset.dy - 16), + target: Alignment.topRight, + follower: Alignment.bottomRight, + ), + portalFollower: const SlideTransitionWidget( + begin: Offset(0, 0.7), + end: Offset.zero, + child: SwipeToLockButton(), + ), + child: Row( + children: [ + IgnorePointer( + child: PlaybackTimerIndicator(duration: recordingTime), + ), + Expanded( + child: IgnorePointer( + child: SlideToCancelIndicator( + progress: cancelProgress.toDouble(), + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorTheme.inputBg, + ), + child: IconTheme( + data: IconThemeData(color: theme.colorTheme.accentPrimary), + child: recordButton, + ), + ), + ].insertBetween(const SizedBox(width: 8)), + ), + ); + } +} + +/// {@template recordStateLockedRecordingContent} +/// Represents the locked recording state with full recording controls. +/// +/// Provides options to pause, stop, or finish the recording after locking. +/// +/// Activated when recording is locked, enabling advanced recording management. +/// {@endtemplate} +class RecordStateLockedRecordingContent extends StatelessWidget { + /// {@macro recordStateLockedRecordingContent} + const RecordStateLockedRecordingContent({ + super.key, + required this.state, + this.onRecordEnd, + this.onRecordPause, + this.onRecordCancel, + this.onRecordStop, + }); + + /// The current state of the recorder. + final RecordStateRecordingLocked state; + + /// The callback to call when the recording is finished. + final VoidCallback? onRecordEnd; + + /// The callback to call when the recording is paused. + final VoidCallback? onRecordPause; + + /// The callback to call when the recording is canceled. + final VoidCallback? onRecordCancel; + + /// The callback to call when the recording is stopped. + final VoidCallback? onRecordStop; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return PortalTarget( + anchor: const Aligned( + offset: Offset(4, -16), + target: Alignment.topRight, + follower: Alignment.bottomRight, + ), + portalFollower: const SwipeToLockButton(isLocked: true), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + PlaybackTimerText( + duration: state.duration, + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: kDefaultMessageInputIconSize, + child: StreamAudioWaveform( + limit: 50, + waveform: state.waveform, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StreamMessageInputIconButton( + icon: const StreamSvgIcon(icon: StreamSvgIcons.delete), + color: theme.colorTheme.accentPrimary, + onPressed: onRecordCancel, + ), + StreamMessageInputIconButton( + icon: const StreamSvgIcon(icon: StreamSvgIcons.stop), + color: theme.colorTheme.accentError, + onPressed: onRecordStop, + ), + StreamMessageInputIconButton( + icon: const StreamSvgIcon(icon: StreamSvgIcons.checkSend), + color: theme.colorTheme.accentPrimary, + onPressed: onRecordEnd, + ) + ], + ), + ], + ), + ); + } +} + +/// {@template recordStateStoppedContent} +/// Manages the stopped recording state with audio preview and interaction +/// options. +/// +/// Allows reviewing the recorded audio and provides actions to cancel or +/// finish. +/// +/// Provides a UI for previewing and managing a completed audio recording. +/// {@endtemplate} +class RecordStateStoppedContent extends StatefulWidget { + /// {@macro recordStateStoppedContent} + const RecordStateStoppedContent({ + super.key, + required this.state, + this.onRecordFinish, + this.onRecordCancel, + }); + + /// The current state of the recorder. + final RecordStateStopped state; + + /// The callback to call when the recording is finished. + final VoidCallback? onRecordCancel; + + /// The callback to call when the recording is canceled. + final VoidCallback? onRecordFinish; + + @override + State createState() => + _RecordStateStoppedContentState(); +} + +class _RecordStateStoppedContentState extends State { + StreamAudioPlaylistController? _audioController; + + @override + void initState() { + super.initState(); + if (widget.state.audioRecording case final recording) { + _audioController = StreamAudioPlaylistController( + [recording].toPlaylist(), + )..initialize(); + } + } + + @override + void dispose() { + _audioController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return PortalTarget( + anchor: const Aligned( + offset: Offset(4, -16), + target: Alignment.topRight, + follower: Alignment.bottomRight, + ), + portalFollower: const SwipeToLockButton(isLocked: true), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_audioController case final controller?) + ValueListenableBuilder( + valueListenable: controller, + builder: (context, state, _) { + final track = state.tracks.firstOrNull; + if (track == null) return const SizedBox.shrink(); + + return Row( + children: [ + PlaybackControlButton( + state: track.state, + onPause: _audioController?.pause, + onPlay: () async { + // Play the track directly if it is already loaded. + if (state.currentIndex != null) { + return _audioController?.play(); + } + + // Otherwise, load the track first and then play it. + return _audioController?.skipToItem(0); + }, + ), + const SizedBox(width: 2), + PlaybackTimerText( + duration: track.duration, + position: track.position, + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 28, + child: StreamAudioWaveformSlider( + limit: 50, + progress: track.progress, + waveform: resampleWaveformData(track.waveform, 50), + // Only allow seeking if the current track is the one + // being interacted with. + onChangeStart: (_) async { + if (state.currentIndex == null) return; + return _audioController?.pause(); + }, + onChangeEnd: (_) async { + if (state.currentIndex == null) return; + return _audioController?.play(); + }, + onChanged: (progress) async { + if (state.currentIndex == null) return; + + final duration = track.duration.inMicroseconds; + final seekPosition = (duration * progress).toInt(); + final seekDuration = Duration( + microseconds: seekPosition, + ); + + return _audioController?.seek(seekDuration); + }, + ), + ), + ), + const SizedBox(width: 8), + ], + ); + }, + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StreamMessageInputIconButton( + icon: const StreamSvgIcon(icon: StreamSvgIcons.delete), + color: theme.colorTheme.accentPrimary, + onPressed: widget.onRecordCancel, + ), + StreamMessageInputIconButton( + icon: const StreamSvgIcon(icon: StreamSvgIcons.checkSend), + color: theme.colorTheme.accentPrimary, + onPressed: widget.onRecordFinish, + ) + ], + ), + ], + ), + ); + } +} + +/// {@template swipeToLockButton} +/// Button indicating the ability to lock or unlock audio recording. +/// +/// Provides a visual representation of the recording lock state. +/// +/// Allows users to lock the recording mode, preventing accidental cancellation. +/// {@endtemplate} +class SwipeToLockButton extends StatelessWidget { + /// {@macro swipeToLockButton} + const SwipeToLockButton({ + super.key, + this.isLocked = false, + }); + + /// Determines if the recording is locked. + final bool isLocked; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: theme.colorTheme.inputBg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSvgIcon( + icon: StreamSvgIcons.lock, + size: kDefaultMessageInputIconSize, + color: switch (isLocked) { + true => theme.colorTheme.accentPrimary, + false => theme.colorTheme.textLowEmphasis, + }, + ), + if (!isLocked) ...[ + StreamSvgIcon( + icon: StreamSvgIcons.up, + color: theme.colorTheme.textLowEmphasis, + ), + ], + ].insertBetween(const SizedBox(height: 8)), + ), + ); + } +} + +/// {@template playbackControlButton} +/// Playback control button with state-based icon and interaction. +/// +/// Supports different interactions based on current track state. +/// +/// Provides a flexible button for controlling audio playback with multiple +/// states. +/// {@endtemplate} +class PlaybackControlButton extends StatelessWidget { + /// {@macro playbackControlButton} + const PlaybackControlButton({ + super.key, + required this.state, + this.onPlay, + this.onPause, + this.onReplay, + }); + + /// The current state of the track. + final TrackState state; + + /// The callback to call when the track is played. + final VoidCallback? onPlay; + + /// The callback to call when the track is paused. + final VoidCallback? onPause; + + /// The callback to call when the track is replayed. + final VoidCallback? onReplay; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return StreamMessageInputIconButton( + color: theme.colorTheme.accentPrimary, + onPressed: switch (state) { + TrackState.loading => null, + TrackState.idle => onPlay, + TrackState.playing => onPause, + TrackState.paused => onPlay, + }, + icon: switch (state) { + TrackState.loading => Builder( + builder: (context) { + final iconTheme = IconTheme.of(context); + return SizedBox.fromSize( + size: Size.square(iconTheme.size!), + child: Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator.adaptive( + valueColor: AlwaysStoppedAnimation( + theme.colorTheme.accentPrimary, + ), + ), + ), + ); + }, + ), + TrackState.idle => const StreamSvgIcon(icon: StreamSvgIcons.play), + TrackState.paused => const StreamSvgIcon(icon: StreamSvgIcons.play), + TrackState.playing => const StreamSvgIcon(icon: StreamSvgIcons.pause), + }, + ); + } +} + +/// {@template playbackTimerIndicator} +/// Displays the current recording or playback duration. +/// +/// Shows an icon and formatted time with low emphasis styling. +/// +/// Provides a visual representation of recording or playback time. +/// {@endtemplate} +class PlaybackTimerIndicator extends StatelessWidget { + /// {@macro playbackTimerIndicator} + const PlaybackTimerIndicator({ + super.key, + required this.duration, + }); + + /// The current duration of the recording or playback. + final Duration duration; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + return Row( + children: [ + StreamSvgIcon( + icon: StreamSvgIcons.mic, + size: kDefaultMessageInputIconSize, + color: switch (duration.inSeconds) { + > 0 => theme.colorTheme.accentError, + _ => theme.colorTheme.textLowEmphasis, + }, + ), + const SizedBox(width: 8), + PlaybackTimerText( + duration: duration, + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + ], + ); + } +} + +/// {@template playbackTimerText} +/// Displays the formatted time of the recording or playback. +/// +/// Formats the time in minutes and seconds with tabular figures. +/// {@endtemplate} +class PlaybackTimerText extends StatelessWidget { + /// {@macro playbackTimerText} + const PlaybackTimerText({ + super.key, + required this.duration, + this.position = Duration.zero, + this.style, + }); + + /// The total duration of the recording or playback. + final Duration duration; + + /// The current position of the recording or playback. + final Duration position; + + /// The text style to apply to the formatted time. + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return Text( + switch (position.inMilliseconds > 0) { + true => position.toMinutesAndSeconds(), + false => duration.toMinutesAndSeconds(), + }, + style: style?.copyWith( + // Use mono space for each num character. + fontFeatures: [const FontFeature.tabularFigures()], + ), + ); + } +} + +/// {@template slideToCancelIndicator} +/// Indicator showing progress of sliding to cancel recording. +/// +/// Provides visual feedback during recording cancellation gesture. +/// +/// Visualizes the user's progress when attempting to cancel a recording. +/// {@endtemplate} +class SlideToCancelIndicator extends StatelessWidget { + /// {@macro slideToCancelIndicator} + const SlideToCancelIndicator({ + super.key, + required this.progress, + }); + + /// The progress of the cancel threshold. + final double progress; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return Opacity( + opacity: 1 - progress, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + context.translations.slideToCancelLabel, + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + const SizedBox(width: 8), + StreamSvgIcon( + icon: StreamSvgIcons.left, + color: theme.colorTheme.textLowEmphasis, + ), + ], + ), + ); + } +} + +/// {@template streamAudioRecorder} +/// Builder widget for constructing audio recorder UI based on state. +/// +/// Allows dynamic UI rendering depending on the current audio recorder state. +/// +/// Provides a flexible mechanism for rendering audio recorder UI dynamically. +/// +/// see also: +/// - [StreamAudioRecorderButton], which uses this builder function. +/// - [StreamAudioRecorderState], which provides the state of the recorder. +/// {@endtemplate} +class StreamAudioRecorder extends StatelessWidget { + /// {@macro streamAudioRecorder} + const StreamAudioRecorder({ + super.key, + required this.state, + required this.builder, + required this.button, + }); + + /// The button widget to display. + final Widget button; + + /// The current state of the audio recorder. + final AudioRecorderState state; + + /// The builder function to construct the audio recorder UI. + final AudioRecorderBuilder builder; + + @override + Widget build(BuildContext context) => builder(context, state, button); +} + +/// {@template slideTransitionWidget} +/// Reusable widget for creating slide-based transitions. +/// +/// Provides a configurable animation for sliding widgets in and out. +/// +/// Enables smooth sliding transitions with customizable parameters. +/// {@endtemplate} +class SlideTransitionWidget extends StatefulWidget { + /// {@macro slideTransitionWidget} + const SlideTransitionWidget({ + super.key, + required this.begin, + required this.end, + this.curve = Curves.easeOut, + this.duration = const Duration(milliseconds: 300), + required this.child, + }); + + /// The starting offset of the slide transition. + final Offset begin; + + /// The ending offset of the slide transition. + final Offset end; + + /// The duration of the slide transition. + final Duration duration; + + /// The curve of the slide transition. + final Curve curve; + + /// The child widget to slide. + final Widget child; + + @override + State createState() => _SlideTransitionWidgetState(); +} + +class _SlideTransitionWidgetState extends State + with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + duration: widget.duration, + vsync: this, + )..forward(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final position = Tween( + begin: widget.begin, + end: widget.end, + ).animate(CurvedAnimation( + parent: _controller, + curve: widget.curve, + )); + + return SlideTransition( + position: position, + child: widget.child, + ); + } +} + +/// {@template holdToRecordInfoTooltip} +/// Tooltip to guide users on initiating audio recording. +/// +/// Provides an informative message with a custom-painted arrow and styling. +/// +/// Displays instructional information for audio recording interaction. +/// {@endtemplate} +class HoldToRecordInfoTooltip extends StatelessWidget { + /// {@macro holdToRecordInfoTooltip} + const HoldToRecordInfoTooltip({ + super.key, + required this.message, + }); + + /// The message to show in the tooltip. + final String message; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + const recordButtonWidth = kDefaultMessageInputIconSize + + kDefaultMessageInputIconPadding * 2; // right, left padding. + + const arrowSize = Size(recordButtonWidth / 2, 6); + + return Padding( + padding: EdgeInsets.only(bottom: arrowSize.height), + child: CustomPaint( + painter: TooltipPainter( + arrowSize: arrowSize, + arrowMargin: arrowSize.width / 2, + color: theme.colorTheme.textLowEmphasis, + borderRadius: BorderRadius.circular(24), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + child: Text( + message, + style: theme.textTheme.body.copyWith( + color: theme.colorTheme.barsBg, + ), + ), + ), + ), + ); + } +} + +/// {@template tooltipPainter} +/// Custom painter for creating tooltips with an arrow indicator. +/// +/// Enables precise rendering of custom-shaped tooltips with configurable +/// styling. +/// +/// Provides a flexible mechanism for painting custom-shaped tooltips. +/// {@endtemplate} +class TooltipPainter extends CustomPainter { + /// {@macro tooltipPainter} + const TooltipPainter({ + this.color = Colors.grey, + this.arrowSize = const Size(16, 8), + this.arrowMargin = 8, + this.borderRadius = BorderRadius.zero, + }); + + /// The background color of the tooltip. + final Color color; + + /// The size of the arrow indicator. + final Size arrowSize; + + /// The margin between the arrow and the tooltip. + final double arrowMargin; + + /// The border radius of the tooltip. + final BorderRadius borderRadius; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1 + ..style = PaintingStyle.fill; + + final width = size.width; + final height = size.height; + + final rect = Rect.fromLTRB(0, 0, width, height); + final outer = borderRadius.toRRect(rect); + canvas.drawRRect(outer, paint); + + final arrowWidth = arrowSize.width; + final arrowHeight = arrowSize.height; + + final arrowPath = Path() + ..moveTo(width - arrowWidth - arrowMargin, height) + ..lineTo(width - arrowWidth / 2 - arrowMargin, height + arrowHeight) + ..lineTo(width, height / 2) + ..close(); + + canvas.drawPath(arrowPath, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/command_button.dart b/packages/stream_chat_flutter/lib/src/message_input/command_button.dart index 083d6b0ea0..352a628f17 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/command_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/command_button.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; - -const double _kDefaultCommandButtonSize = 24; +import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; /// {@template commandButton} /// The button that allows a user to use commands in a chat. @@ -13,7 +12,7 @@ class CommandButton extends StatelessWidget { required this.onPressed, this.color, this.icon, - this.size = _kDefaultCommandButtonSize, + this.size = kDefaultMessageInputIconSize, }) : assert( (icon == null && color == null) || (icon != null && color == null) || @@ -54,19 +53,11 @@ class CommandButton extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( - icon: icon ?? - StreamSvgIcon( - color: color, - icon: StreamSvgIcons.lightning, - ), - padding: EdgeInsets.zero, - constraints: BoxConstraints.tightFor( - height: size, - width: size, - ), - splashRadius: size, + return StreamMessageInputIconButton( + color: color, + iconSize: size, onPressed: onPressed, + icon: icon ?? const StreamSvgIcon(icon: StreamSvgIcons.lightning), ); } } diff --git a/packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart b/packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart index 2b832a0d4b..155e549e4f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Button for showing visual component of slow mode. @@ -15,20 +16,14 @@ class StreamCountdownButton extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8), - child: DecoratedBox( - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.disabled, - shape: BoxShape.circle, - ), - child: SizedBox( - height: 24, - width: 24, - child: Center( - child: Text('$count'), - ), - ), + return DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: StreamChatTheme.of(context).colorTheme.disabled, + ), + child: SizedBox.square( + dimension: kDefaultMessageInputIconSize, + child: Center(child: Text('$count')), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart index 6707e01d01..0ee6116ac1 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart @@ -263,7 +263,8 @@ class _ParseAttachments extends StatelessWidget { var clipBehavior = Clip.none; ShapeDecoration? decoration; - if (attachment.type != AttachmentType.file) { + if (attachment.type != AttachmentType.file && + attachment.type != AttachmentType.voiceRecording) { clipBehavior = Clip.hardEdge; decoration = ShapeDecoration( shape: RoundedRectangleBorder( @@ -341,6 +342,7 @@ class _ParseAttachments extends StatelessWidget { AttachmentType.video: _createMediaThumbnail, AttachmentType.urlPreview: _createUrlThumbnail, AttachmentType.file: _createFileThumbnail, + AttachmentType.voiceRecording: _createFileThumbnail, }; } } diff --git a/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart b/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart index 53cb39f5f3..c81a5e1391 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template quotingMessageTopArea} @@ -28,27 +29,24 @@ class QuotingMessageTopArea extends StatelessWidget { final _streamChatTheme = StreamChatTheme.of(context); if (hasQuotedMessage) { return Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Padding( - padding: const EdgeInsets.all(8), - child: StreamSvgIcon( - icon: StreamSvgIcons.reply, - color: _streamChatTheme.colorTheme.disabled, - ), + StreamMessageInputIconButton( + iconSize: 24, + color: _streamChatTheme.colorTheme.disabled, + icon: const StreamSvgIcon(icon: StreamSvgIcons.reply), + onPressed: null, ), Text( context.translations.replyToMessageLabel, style: const TextStyle(fontWeight: FontWeight.bold), ), - IconButton( - visualDensity: VisualDensity.compact, - icon: StreamSvgIcon( - icon: StreamSvgIcons.closeSmall, - color: _streamChatTheme.colorTheme.textLowEmphasis, - ), + StreamMessageInputIconButton( + iconSize: 24, + color: _streamChatTheme.colorTheme.textLowEmphasis, + icon: const StreamSvgIcon(icon: StreamSvgIcons.closeSmall), onPressed: onQuotedMessageCleared?.call, ), ], 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 873d33d144..96336a8389 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 @@ -12,7 +12,9 @@ import 'package:stream_chat_flutter/src/message_input/dm_checkbox.dart'; import 'package:stream_chat_flutter/src/message_input/quoted_message_widget.dart'; import 'package:stream_chat_flutter/src/message_input/quoting_message_top_area.dart'; import 'package:stream_chat_flutter/src/message_input/simple_safe_area.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; import 'package:stream_chat_flutter/src/message_input/tld.dart'; +import 'package:stream_chat_flutter/src/misc/gradient_box_border.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; const _kCommandTrigger = '/'; @@ -108,17 +110,21 @@ class StreamMessageInput extends StatefulWidget { this.disableAttachments = false, this.messageInputController, this.actionsBuilder, - this.spaceBetweenActions = 8, + this.spaceBetweenActions = 0, this.actionsLocation = ActionsLocation.left, this.attachmentListBuilder, this.fileAttachmentListBuilder, this.mediaAttachmentListBuilder, + this.voiceRecordingAttachmentListBuilder, this.fileAttachmentBuilder, this.mediaAttachmentBuilder, + this.voiceRecordingAttachmentBuilder, this.focusNode, this.sendButtonLocation = SendButtonLocation.outside, this.autofocus = false, this.hideSendAsDm = false, + this.enableVoiceRecording = false, + this.sendVoiceRecordingAutomatically = false, this.idleSendButton, this.activeSendButton, this.showCommandsButton = true, @@ -207,6 +213,17 @@ class StreamMessageInput extends StatefulWidget { /// Hide send as dm checkbox. final bool hideSendAsDm; + /// If true the voice recording button will be displayed. + /// + /// Defaults to true. + final bool enableVoiceRecording; + + /// If True, the voice recording will be sent automatically after the user + /// releases the microphone button. + /// + /// Defaults to false. + final bool sendVoiceRecordingAutomatically; + /// The text controller of the TextField. final StreamMessageInputController? messageInputController; @@ -237,12 +254,21 @@ class StreamMessageInput extends StatefulWidget { /// [mediaAttachmentBuilder]. final AttachmentListBuilder? mediaAttachmentListBuilder; + /// Builder used to build the voice recording attachment list. + /// + /// In case you want to customize the attachment item, consider using + /// [voiceRecordingAttachmentBuilder]. + final AttachmentListBuilder? voiceRecordingAttachmentListBuilder; + /// Builder used to build the file attachment item. final AttachmentItemBuilder? fileAttachmentBuilder; /// Builder used to build the media attachment item. final AttachmentItemBuilder? mediaAttachmentBuilder; + /// Builder used to build the voice recording attachment item. + final AttachmentItemBuilder? voiceRecordingAttachmentBuilder; + /// Map that defines a thumbnail builder for an attachment type. /// /// This is used to build the thumbnail for the attachment in the quoted @@ -435,7 +461,7 @@ class StreamMessageInputState extends State bool get _isEditing => !_effectiveController.message.state.isInitial; - BoxBorder? _draggingBorder; + late final _audioRecorderController = StreamAudioRecorderController(); FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); @@ -638,15 +664,15 @@ class StreamMessageInputState extends State }, ), Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.all(8), child: _buildTextField(context), ), if (_effectiveController.message.parentId != null && !widget.hideSendAsDm) Padding( padding: const EdgeInsets.only( - right: 12, - left: 12, + right: 16, + left: 16, bottom: 12, ), child: DmCheckbox( @@ -746,18 +772,68 @@ class StreamMessageInputState extends State ); } - Flex _buildTextField(BuildContext context) { - return Flex( - direction: Axis.horizontal, - children: [ - if (!_commandEnabled && widget.actionsLocation == ActionsLocation.left) - _buildExpandActionsButton(context), - _buildTextInput(context), - if (!_commandEnabled && widget.actionsLocation == ActionsLocation.right) - _buildExpandActionsButton(context), - if (widget.sendButtonLocation == SendButtonLocation.outside) - _buildSendButton(context), - ], + Widget _buildTextField(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _audioRecorderController, + builder: (context, state, _) { + final isAudioRecordingFlowActive = state is! RecordStateIdle; + + return Row( + children: [ + if (!isAudioRecordingFlowActive) ...[ + if (!_commandEnabled && + widget.actionsLocation == ActionsLocation.left) + _buildExpandActionsButton(context), + const SizedBox(width: 4), + Expanded(child: _buildTextInput(context)), + const SizedBox(width: 4), + if (!_commandEnabled && + widget.actionsLocation == ActionsLocation.right) + _buildExpandActionsButton(context), + if (widget.sendButtonLocation == SendButtonLocation.outside) + _buildSendButton(context), + ], + if (widget.enableVoiceRecording) + Expanded( + // This is to make sure the audio recorder button will be given + // the full width when it's visible. + flex: isAudioRecordingFlowActive ? 1 : 0, + child: StreamAudioRecorderButton( + recordState: state, + onRecordStart: _audioRecorderController.startRecord, + onRecordCancel: _audioRecorderController.cancelRecord, + onRecordStop: _audioRecorderController.stopRecord, + onRecordLock: _audioRecorderController.lockRecord, + onRecordDragUpdate: _audioRecorderController.dragRecord, + onRecordStartCancel: () { + // Show a message to the user to hold to record. + _audioRecorderController.showInfo( + context.translations.holdToRecordLabel, + ); + }, + onRecordFinish: () async { + //isVoiceRecordingConfirmationRequiredEnabled + // Finish the recording session and add the audio to the + // message input controller. + final audio = await _audioRecorderController.finishRecord(); + if (audio != null) { + _effectiveController.addAttachment(audio); + } + + // Once the recording is finished, cancel the recorder. + _audioRecorderController.cancelRecord(discardTrack: false); + + // Send the message if the user has enabled the option to + // send the voice recording automatically. + if (widget.sendVoiceRecordingAutomatically) { + return sendMessage(); + } + }, + ), + ), + ], + ); + }, ); } @@ -770,55 +846,51 @@ class StreamMessageInputState extends State onSendMessage: sendMessage, timeOut: _timeOut, isIdle: !widget.validator(_effectiveController.message), - isEditEnabled: _isEditing, idleSendButton: widget.idleSendButton, activeSendButton: widget.activeSendButton, ); } Widget _buildExpandActionsButton(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: AnimatedCrossFade( - crossFadeState: (_actionsShrunk && widget.enableActionAnimation) - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstCurve: Curves.easeOut, - secondCurve: Curves.easeIn, - firstChild: IconButton( - onPressed: () { - if (_actionsShrunk) { - setState(() => _actionsShrunk = false); - } - }, - icon: Transform.rotate( - angle: (widget.actionsLocation == ActionsLocation.right || - widget.actionsLocation == ActionsLocation.rightInside) - ? pi - : 0, - child: StreamSvgIcon( - icon: StreamSvgIcons.emptyCircleRight, - color: _messageInputTheme.expandButtonColor, - ), - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - splashRadius: 24, - ), - secondChild: widget.disableAttachments && - !widget.showCommandsButton && - !(widget.actionsBuilder != null) - ? const Offstage() - : Wrap( - children: _actionsList() - .insertBetween(SizedBox(width: widget.spaceBetweenActions)), - ), - duration: const Duration(milliseconds: 300), + return AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: switch (widget.enableActionAnimation && _actionsShrunk) { + true => CrossFadeState.showFirst, + false => CrossFadeState.showSecond, + }, + layoutBuilder: (top, topKey, bottom, bottomKey) => Stack( + clipBehavior: Clip.none, alignment: Alignment.center, + children: [ + Positioned(key: bottomKey, top: 0, child: bottom), + Positioned(key: topKey, child: top), + ], ), + firstChild: StreamMessageInputIconButton( + color: _messageInputTheme.expandButtonColor, + icon: Transform.rotate( + angle: (widget.actionsLocation == ActionsLocation.right || + widget.actionsLocation == ActionsLocation.rightInside) + ? pi + : 0, + child: const StreamSvgIcon(icon: StreamSvgIcons.emptyCircleRight), + ), + onPressed: () { + if (_actionsShrunk) { + setState(() => _actionsShrunk = false); + } + }, + ), + secondChild: widget.disableAttachments && + !widget.showCommandsButton && + !(widget.actionsBuilder != null) + ? const Offstage() + : Row( + mainAxisSize: MainAxisSize.min, + children: _actionsList().insertBetween( + SizedBox(width: widget.spaceBetweenActions), + ), + ), ); } @@ -834,14 +906,12 @@ class StreamMessageInputState extends State channel.config?.commands.isNotEmpty == true) _buildCommandButton(context), ]; - if (widget.actionsBuilder != null) { - return widget.actionsBuilder!( - context, - defaultActions, - ); - } else { - return defaultActions; + + if (widget.actionsBuilder case final builder?) { + return builder(context, defaultActions); } + + return defaultActions; } Widget _buildAttachmentButton(BuildContext context) { @@ -932,7 +1002,7 @@ class StreamMessageInputState extends State await _createOrUpdatePoll(initialPoll, value.poll); } - Expanded _buildTextInput(BuildContext context) { + Widget _buildTextInput(BuildContext context) { final margin = (widget.sendButtonLocation == SendButtonLocation.inside ? const EdgeInsets.only(right: 8) : EdgeInsets.zero) + @@ -940,93 +1010,78 @@ class StreamMessageInputState extends State ? const EdgeInsets.only(left: 8) : EdgeInsets.zero); - return Expanded( - child: DropTarget( - onDragDone: (details) async { - final files = details.files; - final attachments = []; - for (final file in files) { - final attachment = await file.toAttachment(type: 'file'); - attachments.add(attachment); - } + return DropTarget( + onDragDone: (details) async { + final files = details.files; + final attachments = []; + for (final file in files) { + final attachment = await file.toAttachment(type: AttachmentType.file); + attachments.add(attachment); + } - if (attachments.isNotEmpty) _addAttachments(attachments); - }, - onDragEntered: (details) { - setState(() { - _draggingBorder = Border.all( - color: _streamChatTheme.colorTheme.accentPrimary, - ); - }); - }, - onDragExited: (details) { - setState(() => _draggingBorder = null); - }, - child: Container( - clipBehavior: Clip.hardEdge, - margin: margin, - decoration: BoxDecoration( - borderRadius: _messageInputTheme.borderRadius, + if (attachments.isNotEmpty) _addAttachments(attachments); + }, + onDragEntered: (details) { + setState(() {}); + }, + onDragExited: (details) {}, + child: Container( + margin: margin, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: _messageInputTheme.borderRadius, + color: _messageInputTheme.inputBackgroundColor, + border: GradientBoxBorder( gradient: _effectiveFocusNode.hasFocus - ? _messageInputTheme.activeBorderGradient - : _messageInputTheme.idleBorderGradient, - border: _draggingBorder, + ? _messageInputTheme.activeBorderGradient! + : _messageInputTheme.idleBorderGradient!, ), - child: Padding( - padding: const EdgeInsets.all(1.5), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: _messageInputTheme.borderRadius, - color: _messageInputTheme.inputBackgroundColor, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildReplyToMessage(), - _buildAttachments(), - LimitedBox( - maxHeight: widget.maxHeight, - child: PlatformWidgetBuilder( - web: (context, child) => Focus( - skipTraversal: true, - onKeyEvent: _handleKeyPressed, - child: child!, - ), - desktop: (context, child) => Focus( - skipTraversal: true, - onKeyEvent: _handleKeyPressed, - child: child!, - ), - mobile: (context, child) => Focus( - skipTraversal: true, - onKeyEvent: _handleKeyPressed, - child: child!, - ), - child: StreamMessageTextField( - key: const Key('messageInputText'), - maxLines: widget.maxLines, - minLines: widget.minLines, - textInputAction: widget.textInputAction, - onSubmitted: (_) => sendMessage(), - keyboardType: widget.keyboardType, - controller: _effectiveController, - focusNode: _effectiveFocusNode, - style: _messageInputTheme.inputTextStyle, - autofocus: widget.autofocus, - textAlignVertical: TextAlignVertical.center, - decoration: _getInputDecoration(context), - textCapitalization: widget.textCapitalization, - autocorrect: widget.autoCorrect, - contentInsertionConfiguration: - widget.contentInsertionConfiguration, - ), - ), - ), - ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildReplyToMessage(), + _buildAttachments(), + LimitedBox( + maxHeight: widget.maxHeight, + child: PlatformWidgetBuilder( + web: (context, child) => Focus( + skipTraversal: true, + onKeyEvent: _handleKeyPressed, + child: child!, + ), + desktop: (context, child) => Focus( + skipTraversal: true, + onKeyEvent: _handleKeyPressed, + child: child!, + ), + mobile: (context, child) => Focus( + skipTraversal: true, + onKeyEvent: _handleKeyPressed, + child: child!, + ), + child: StreamMessageTextField( + key: const Key('messageInputText'), + maxLines: widget.maxLines, + minLines: widget.minLines, + textInputAction: widget.textInputAction, + onSubmitted: (_) => sendMessage(), + keyboardType: widget.keyboardType, + controller: _effectiveController, + focusNode: _effectiveFocusNode, + style: _messageInputTheme.inputTextStyle, + autofocus: widget.autofocus, + textAlignVertical: TextAlignVertical.center, + decoration: _getInputDecoration(context), + textCapitalization: widget.textCapitalization, + autocorrect: widget.autoCorrect, + contentInsertionConfiguration: + widget.contentInsertionConfiguration, + ), ), ), - ), + ], ), ), ); @@ -1084,40 +1139,33 @@ class StreamMessageInputState extends State color: Colors.transparent, ), ), - contentPadding: const EdgeInsets.fromLTRB(16, 12, 13, 11), + contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), prefixIcon: _commandEnabled - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: Container( - constraints: BoxConstraints.tight(const Size(64, 24)), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: _streamChatTheme.colorTheme.accentPrimary, - ), - alignment: Alignment.center, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const StreamSvgIcon( - color: Colors.white, - size: 16, - icon: StreamSvgIcons.lightning, - ), - Text( - _effectiveController.message.command!.toUpperCase(), - style: - _streamChatTheme.textTheme.footnoteBold.copyWith( - color: Colors.white, - ), - ), - ], + ? Container( + margin: const EdgeInsets.all(6), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.accentPrimary, + borderRadius: _messageInputTheme.borderRadius?.add( + BorderRadius.circular(6), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const StreamSvgIcon( + size: 16, + color: Colors.white, + icon: StreamSvgIcons.lightning, + ), + Text( + _effectiveController.message.command!.toUpperCase(), + style: _streamChatTheme.textTheme.footnoteBold.copyWith( + color: Colors.white, ), ), - ), - ], + ], + ), ) : (widget.actionsLocation == ActionsLocation.leftInside ? Row( @@ -1133,14 +1181,10 @@ class StreamMessageInputState extends State if (_commandEnabled) Padding( padding: const EdgeInsets.only(right: 8), - child: IconButton( + child: StreamMessageInputIconButton( + iconSize: 24, + color: _messageInputTheme.actionButtonIdleColor, icon: const StreamSvgIcon(icon: StreamSvgIcons.closeSmall), - splashRadius: 24, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), onPressed: _effectiveController.clear, ), ), @@ -1149,7 +1193,7 @@ class StreamMessageInputState extends State _buildExpandActionsButton(context), if (widget.sendButtonLocation == SendButtonLocation.inside) _buildSendButton(context), - ], + ].nonNulls.toList(), ), ).merge(passedDecoration); } @@ -1334,8 +1378,11 @@ class StreamMessageInputState extends State onRemovePressed: _onAttachmentRemovePressed, fileAttachmentListBuilder: widget.fileAttachmentListBuilder, mediaAttachmentListBuilder: widget.mediaAttachmentListBuilder, + voiceRecordingAttachmentBuilder: widget.voiceRecordingAttachmentBuilder, fileAttachmentBuilder: widget.fileAttachmentBuilder, mediaAttachmentBuilder: widget.mediaAttachmentBuilder, + voiceRecordingAttachmentListBuilder: + widget.voiceRecordingAttachmentListBuilder, ), ); } @@ -1526,6 +1573,7 @@ class StreamMessageInputState extends State _focusNode?.dispose(); _stopSlowMode(); _onChangedDebounced.cancel(); + _audioRecorderController.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -1559,8 +1607,8 @@ class OGAttachmentPreview extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.all(8), - child: Icon( - Icons.link, + child: StreamSvgIcon( + icon: StreamSvgIcons.link, color: colorTheme.accentPrimary, ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart index a3e93309f8..436312b3cb 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart @@ -1,6 +1,9 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/attachment/file_attachment.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/media_attachment_thumbnail.dart'; +import 'package:stream_chat_flutter/src/attachment/voice_recording_attachment.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/utils.dart'; @@ -41,7 +44,7 @@ typedef AttachmentItemBuilder = Widget Function( /// You can override the default action of removing an attachment by providing /// [onRemovePressed]. /// {@endtemplate} -class StreamMessageInputAttachmentList extends StatefulWidget { +class StreamMessageInputAttachmentList extends StatelessWidget { /// {@macro stream_message_input_attachment_list} const StreamMessageInputAttachmentList({ super.key, @@ -49,8 +52,10 @@ class StreamMessageInputAttachmentList extends StatefulWidget { this.onRemovePressed, this.fileAttachmentBuilder, this.mediaAttachmentBuilder, + this.voiceRecordingAttachmentBuilder, this.fileAttachmentListBuilder, this.mediaAttachmentListBuilder, + this.voiceRecordingAttachmentListBuilder, }); /// List of attachments to display thumbnails for. @@ -64,59 +69,38 @@ class StreamMessageInputAttachmentList extends StatefulWidget { /// Builder used to build the media attachment item. final AttachmentItemBuilder? mediaAttachmentBuilder; + /// Builder used to build the voice recording attachment item. + final AttachmentItemBuilder? voiceRecordingAttachmentBuilder; + /// Builder used to build the file attachment list. final AttachmentListBuilder? fileAttachmentListBuilder; /// Builder used to build the media attachment list. final AttachmentListBuilder? mediaAttachmentListBuilder; + /// Builder used to build the voice recording attachment list. + final AttachmentListBuilder? voiceRecordingAttachmentListBuilder; + /// Callback called when the remove button is pressed. final ValueSetter? onRemovePressed; - @override - State createState() => - _StreamMessageInputAttachmentListState(); -} - -class _StreamMessageInputAttachmentListState - extends State { - List fileAttachments = []; - List mediaAttachments = []; - - void _updateAttachments() { - // Clear the lists. - fileAttachments.clear(); - mediaAttachments.clear(); - - // Split the attachments into file and media attachments. - for (final attachment in widget.attachments) { - if (attachment.type == AttachmentType.file) { - fileAttachments.add(attachment); - } else { - mediaAttachments.add(attachment); - } - } - } - - @override - void initState() { - super.initState(); - _updateAttachments(); - } - - @override - void didUpdateWidget(covariant StreamMessageInputAttachmentList oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.attachments != widget.attachments) { - _updateAttachments(); - } - } - @override Widget build(BuildContext context) { - // If there are no attachments, return an empty box. - if (fileAttachments.isEmpty && mediaAttachments.isEmpty) { - return const SizedBox(); + final groupedAttachments = attachments.groupListsBy((it) => it.type); + final (:files, :media, :voices) = ( + files: [...?groupedAttachments[AttachmentType.file]], + voices: [...?groupedAttachments[AttachmentType.voiceRecording]], + media: [ + ...?groupedAttachments[AttachmentType.image], + ...?groupedAttachments[AttachmentType.video], + ...?groupedAttachments[AttachmentType.giphy], + ...?groupedAttachments[AttachmentType.audio], + ], + ); + + // If there are no attachments, return an empty widget. + if (files.isEmpty && media.isEmpty && voices.isEmpty) { + return const SizedBox.shrink(); } return SingleChildScrollView( @@ -124,31 +108,38 @@ class _StreamMessageInputAttachmentListState child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (mediaAttachments.isNotEmpty) + if (media.isNotEmpty) Flexible( - child: widget.mediaAttachmentListBuilder?.call( - context, - mediaAttachments, - widget.onRemovePressed, - ) ?? - MessageInputMediaAttachments( - attachments: mediaAttachments, - attachmentBuilder: widget.mediaAttachmentBuilder, - onRemovePressed: widget.onRemovePressed, + child: switch (mediaAttachmentListBuilder) { + final builder? => builder(context, media, onRemovePressed), + _ => MessageInputMediaAttachments( + attachments: media, + attachmentBuilder: mediaAttachmentBuilder, + onRemovePressed: onRemovePressed, ), + }, ), - if (fileAttachments.isNotEmpty) + if (voices.isNotEmpty) Flexible( - child: widget.fileAttachmentListBuilder?.call( - context, - fileAttachments, - widget.onRemovePressed, - ) ?? - MessageInputFileAttachments( - attachments: fileAttachments, - attachmentBuilder: widget.fileAttachmentBuilder, - onRemovePressed: widget.onRemovePressed, + child: switch (voiceRecordingAttachmentListBuilder) { + final builder? => builder(context, voices, onRemovePressed), + _ => MessageInputVoiceRecordingAttachments( + attachments: voices, + attachmentBuilder: voiceRecordingAttachmentBuilder, + onRemovePressed: onRemovePressed, ), + }, + ), + if (files.isNotEmpty) + Flexible( + child: switch (fileAttachmentListBuilder) { + final builder? => builder(context, files, onRemovePressed), + _ => MessageInputFileAttachments( + attachments: files, + attachmentBuilder: fileAttachmentBuilder, + onRemovePressed: onRemovePressed, + ), + }, ), ].insertBetween( Divider( @@ -222,6 +213,131 @@ class MessageInputFileAttachments extends StatelessWidget { } } +/// Widget used to display the list of voice recording type attachments added to +/// the message input. +class MessageInputVoiceRecordingAttachments extends StatefulWidget { + /// Creates a new MessageInputVoiceRecordingAttachments widget. + const MessageInputVoiceRecordingAttachments({ + super.key, + required this.attachments, + this.attachmentBuilder, + this.onRemovePressed, + }); + + /// List of voice recording type attachments to display thumbnails for. + /// + /// Only attachments of type [AttachmentType.voiceRecording] are supported. + final List attachments; + + /// Builder used to build the voice recording type attachment item. + final AttachmentItemBuilder? attachmentBuilder; + + /// Callback called when the remove button is pressed. + final ValueSetter? onRemovePressed; + + @override + State createState() => + _MessageInputVoiceRecordingAttachmentsState(); +} + +class _MessageInputVoiceRecordingAttachmentsState + extends State { + late final _controller = StreamAudioPlaylistController( + widget.attachments.toPlaylist(), + ); + + @override + void initState() { + super.initState(); + _controller.initialize(); + } + + @override + void didUpdateWidget( + covariant MessageInputVoiceRecordingAttachments oldWidget, + ) { + super.didUpdateWidget(oldWidget); + final equals = const ListEquality().equals; + if (!equals(widget.attachments, oldWidget.attachments)) { + // If the attachments have changed, update the playlist. + _controller.updatePlaylist(widget.attachments.toPlaylist()); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, _) { + return MediaQuery.removePadding( + context: context, + // Workaround for the bottom padding issue. + // Link: https://github.com/flutter/flutter/issues/156149 + removeTop: true, + removeBottom: true, + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 8), + physics: const NeverScrollableScrollPhysics(), + itemCount: state.tracks.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final track = state.tracks[index]; + + return StreamVoiceRecordingAttachment( + track: track, + speed: state.speed, + trailingBuilder: (_, __, ___, ____) { + final attachment = widget.attachments[index]; + return RemoveAttachmentButton( + onPressed: switch (widget.onRemovePressed) { + final callback? => () => callback(attachment), + _ => null, + }, + ); + }, + onTrackPause: _controller.pause, + onChangeSpeed: _controller.setSpeed, + onTrackPlay: () async { + // Play the track directly if it is already loaded. + if (state.currentIndex == index) return _controller.play(); + // Otherwise, load the track first and then play it. + return _controller.skipToItem(index); + }, + // Only allow seeking if the current track is the one being + // interacted with. + onTrackSeekStart: (_) async { + if (state.currentIndex != index) return; + return _controller.pause(); + }, + onTrackSeekEnd: (_) async { + if (state.currentIndex != index) return; + return _controller.play(); + }, + onTrackSeekChanged: (progress) async { + if (state.currentIndex != index) return; + + final duration = track.duration.inMicroseconds; + final seekPosition = (duration * progress).toInt(); + final seekDuration = Duration(microseconds: seekPosition); + + return _controller.seek(seekDuration); + }, + ); + }, + ), + ); + }, + ); + } +} + /// Widget used to display the list of media type attachments added to the /// message input. class MessageInputMediaAttachments extends StatelessWidget { @@ -345,25 +461,16 @@ class RemoveAttachmentButton extends StatelessWidget { final theme = StreamChatTheme.of(context); final colorTheme = theme.colorTheme; - return SizedBox( - width: 24, - height: 24, - child: RawMaterialButton( - elevation: 0, - focusElevation: 0, - hoverElevation: 0, - highlightElevation: 0, - onPressed: onPressed, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + return IconButton.filled( + onPressed: onPressed, + color: colorTheme.barsBg, + padding: EdgeInsets.zero, + icon: const StreamSvgIcon(icon: StreamSvgIcons.close), + style: IconButton.styleFrom( + minimumSize: const Size(24, 24), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, // ignore: deprecated_member_use - fillColor: colorTheme.textHighEmphasis.withOpacity(0.5), - child: StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.close, - color: colorTheme.barsBg, - ), + backgroundColor: colorTheme.textHighEmphasis.withOpacity(0.6), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_icon_button.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_icon_button.dart new file mode 100644 index 0000000000..7708ba8c2d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_icon_button.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +/// The default size for the icon inside the message input icon button. +const double kDefaultMessageInputIconSize = 32; + +/// The default padding around the icon inside the message input icon button. +const double kDefaultMessageInputIconPadding = 4; + +/// {@template streamMessageInputIconButton} +/// A customized [IconButton] for the message input. +/// +/// This is used to create the send button, command button, and other icon +/// buttons in the message input. +/// {@endtemplate} +class StreamMessageInputIconButton extends StatelessWidget { + /// {@macro streamMessageInputIconButton} + const StreamMessageInputIconButton({ + super.key, + required this.icon, + required this.onPressed, + this.color, + this.disabledColor, + this.iconSize = kDefaultMessageInputIconSize, + this.padding = const EdgeInsets.all(kDefaultMessageInputIconPadding), + }); + + /// The icon to display inside the button. + final Widget icon; + + /// The color to use for the icon inside the button, if the icon is enabled. + /// Defaults to leaving this up to the [icon] widget. + final Color? color; + + /// The color to use for the icon inside the button, if the icon is disabled. + /// + /// The icon is disabled if [onPressed] is null. + final Color? disabledColor; + + /// The size of the icon inside the button. + /// + /// Defaults to 32.0. + final double iconSize; + + /// The padding around the button's icon. The entire padded icon will react + /// to input gestures. + /// + /// Defaults to EdgeInsets.zero. + final EdgeInsetsGeometry padding; + + /// The callback that is called when the button is tapped or otherwise + /// activated. + /// + /// If this is set to null, the button will be disabled. + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return IconButton( + icon: icon, + color: color, + disabledColor: disabledColor, + iconSize: iconSize, + onPressed: onPressed, + padding: padding, + style: ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: WidgetStateProperty.all(Size.square(iconSize)), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart index e1582c5509..1dfb29ae57 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// A widget that displays a sending button. @@ -10,7 +11,9 @@ class StreamMessageSendButton extends StatelessWidget { super.key, this.timeOut = 0, this.isIdle = true, + @Deprecated('Will be removed in the next major version') this.isCommandEnabled = false, + @Deprecated('Will be removed in the next major version') this.isEditEnabled = false, this.idleSendButton, this.activeSendButton, @@ -24,9 +27,11 @@ class StreamMessageSendButton extends StatelessWidget { final bool isIdle; /// True if a command is being sent. + @Deprecated('It will be removed in the next major version') final bool isCommandEnabled; /// True if in editing mode. + @Deprecated('It will be removed in the next major version') final bool isEditEnabled; /// The widget to display when the button is disabled. @@ -40,69 +45,36 @@ class StreamMessageSendButton extends StatelessWidget { @override Widget build(BuildContext context) { - final _streamChatTheme = StreamChatTheme.of(context); - - late Widget sendButton; - if (timeOut > 0) { - sendButton = StreamCountdownButton(count: timeOut); - } else if (isIdle) { - sendButton = idleSendButton ?? _buildIdleSendButton(context); - } else { - sendButton = activeSendButton != null - ? InkWell( - onTap: onSendMessage, - child: activeSendButton, - ) - : _buildSendButton(context); - } + final theme = StreamMessageInputTheme.of(context); + final button = _buildButton(context); return AnimatedSwitcher( - duration: _streamChatTheme.messageInputTheme.sendAnimationDuration!, - child: sendButton, + duration: theme.sendAnimationDuration!, + child: button, ); } - Widget _buildIdleSendButton(BuildContext context) { - final _messageInputTheme = StreamMessageInputTheme.of(context); - - return Padding( - padding: const EdgeInsets.all(8), - child: StreamSvgIcon( - icon: _getIdleSendIcon(), - color: _messageInputTheme.sendButtonIdleColor, - ), - ); - } - - Widget _buildSendButton(BuildContext context) { - final _messageInputTheme = StreamMessageInputTheme.of(context); + Widget _buildButton(BuildContext context) { + if (timeOut > 0) { + return StreamCountdownButton( + key: const Key('countdown_button'), + count: timeOut, + ); + } - return Padding( - padding: const EdgeInsets.all(8), - child: IconButton( - onPressed: onSendMessage, - padding: EdgeInsets.zero, - splashRadius: 24, - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - icon: StreamSvgIcon( - icon: _getSendIcon(), - color: _messageInputTheme.sendButtonColor, - ), - ), + final theme = StreamMessageInputTheme.of(context); + final onPressed = isIdle ? null : onSendMessage; + return StreamMessageInputIconButton( + key: const Key('send_button'), + icon: StreamSvgIcon(icon: _sendButtonIcon), + color: theme.sendButtonColor, + disabledColor: theme.sendButtonIdleColor, + onPressed: onPressed, ); } - StreamSvgIconData _getIdleSendIcon() { - if (isCommandEnabled) return StreamSvgIcons.search; - return StreamSvgIcons.emptyCircleRight; - } - - StreamSvgIconData _getSendIcon() { - if (isEditEnabled) return StreamSvgIcons.circleUp; - if (isCommandEnabled) return StreamSvgIcons.search; + StreamSvgIconData get _sendButtonIcon { + if (isIdle) return StreamSvgIcons.sendMessage; return StreamSvgIcons.circleUp; } } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index b5608c60db..9f86679faf 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -1290,6 +1290,9 @@ class _StreamMessageListViewState extends State { hasTimeDiff = !createdAt.isSame(nextCreatedAt, unit: Unit.minute); } + final hasVoiceRecordingAttachment = message.attachments + .any((it) => it.type == AttachmentType.voiceRecording); + final hasFileAttachment = message.attachments.any((it) => it.type == AttachmentType.file); @@ -1390,7 +1393,10 @@ class _StreamMessageListViewState extends State { ? Radius.circular(attachmentBorderRadius) : Radius.circular( (hasTimeDiff || !isNextUserSame) && - !(hasReplies || isThreadMessage || hasFileAttachment) + !(hasReplies || + isThreadMessage || + hasFileAttachment || + hasVoiceRecordingAttachment) ? 0 : attachmentBorderRadius, ), @@ -1398,7 +1404,10 @@ class _StreamMessageListViewState extends State { bottomRight: isMyMessage ? Radius.circular( (hasTimeDiff || !isNextUserSame) && - !(hasReplies || isThreadMessage || hasFileAttachment) + !(hasReplies || + isThreadMessage || + hasFileAttachment || + hasVoiceRecordingAttachment) ? 0 : attachmentBorderRadius, ) @@ -1408,7 +1417,7 @@ class _StreamMessageListViewState extends State { attachmentPadding: EdgeInsets.all( hasUrlAttachment ? 8 - : hasFileAttachment + : hasFileAttachment || hasVoiceRecordingAttachment ? 4 : 2, ), @@ -1507,9 +1516,9 @@ class _StreamMessageListViewState extends State { if (isLastItemFullyVisible) { // We are using the first message as the last fully visible message // because the messages are reversed in the list view. - final newLastFullyVisibleMessage = messages.first; + final newLastFullyVisibleMessage = messages.firstOrNull; final lastFullyVisibleMessageChanged = switch (_lastFullyVisibleMessage) { - final message? => message.id != newLastFullyVisibleMessage.id, + final message? => message.id != newLastFullyVisibleMessage?.id, null => true, // Allows setting the initial value. }; diff --git a/packages/stream_chat_flutter/lib/src/misc/audio_waveform.dart b/packages/stream_chat_flutter/lib/src/misc/audio_waveform.dart new file mode 100644 index 0000000000..e35f9277a5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/audio_waveform.dart @@ -0,0 +1,487 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/theme/audio_waveform_slider_theme.dart'; +import 'package:stream_chat_flutter/src/theme/audio_waveform_theme.dart'; + +const _kAudioWaveformSliderThumbWidth = 4.0; +const _kAudioWaveformSliderThumbHeight = 28.0; + +/// {@template streamAudioWaveformSlider} +/// A widget that displays an audio waveform and allows the user to interact +/// with it using a slider. +/// {@endtemplate} +class StreamAudioWaveformSlider extends StatefulWidget { + /// {@macro streamAudioWaveformSlider} + const StreamAudioWaveformSlider({ + super.key, + required this.waveform, + this.onChangeStart, + required this.onChanged, + this.onChangeEnd, + this.limit = 100, + this.color, + this.progress = 0, + this.progressColor, + this.minBarHeight, + this.spacingRatio, + this.heightScale, + this.inverse = true, + this.thumbColor, + this.thumbBorderColor, + }); + + /// The waveform data to be drawn. + /// + /// Note: The values should be between 0 and 1. + final List waveform; + + /// Called when the thumb starts being dragged. + final ValueChanged? onChangeStart; + + /// Called while the thumb is being dragged. + final ValueChanged? onChanged; + + /// Called when the thumb stops being dragged. + final ValueChanged? onChangeEnd; + + /// The color of the wave bars. + /// + /// Defaults to [StreamAudioWaveformSliderThemeData.color]. + final Color? color; + + /// The number of wave bars that will be draw in the screen. When the length + /// of [waveform] is bigger than [limit] only the X last bars will be shown. + /// + /// Defaults to 100. + final int limit; + + /// The progress of the audio track. Used to show the progress of the audio. + /// + /// Defaults to 0. + final double progress; + + /// The color of the progressed wave bars. + /// + /// Defaults to [StreamAudioWaveformSliderThemeData.progressColor]. + final Color? progressColor; + + /// The minimum height of the bars. + /// + /// Defaults to [StreamAudioWaveformSliderThemeData.minBarHeight]. + final double? minBarHeight; + + /// The ratio of the spacing between the bars. + /// + /// Defaults to [StreamAudioWaveformSliderThemeData.spacingRatio]. + final double? spacingRatio; + + /// The scale of the height of the bars. + /// + /// Defaults to [StreamAudioWaveformSliderThemeData.heightScale]. + final double? heightScale; + + /// If true, the bars grow from right to left otherwise they grow from left + /// to right. + /// + /// Defaults to true. + final bool inverse; + + /// The color of the slider thumb. + /// + /// Defaults to [StreamAudioWaveformSliderThemeData.thumbColor]. + final Color? thumbColor; + + /// The color of the slider thumb border. + /// + /// Defaults to [StreamAudioWaveformSliderThemeData.thumbBorderColor]. + final Color? thumbBorderColor; + + @override + State createState() => + _StreamAudioWaveformSliderState(); +} + +class _StreamAudioWaveformSliderState extends State { + @override + Widget build(BuildContext context) { + final theme = StreamAudioWaveformSliderTheme.of(context); + final waveformTheme = theme.audioWaveformTheme; + + final color = widget.color ?? waveformTheme!.color!; + final progressColor = widget.progressColor ?? waveformTheme!.progressColor!; + final minBarHeight = widget.minBarHeight ?? waveformTheme!.minBarHeight!; + final spacingRatio = widget.spacingRatio ?? waveformTheme!.spacingRatio!; + final heightScale = widget.heightScale ?? waveformTheme!.heightScale!; + final thumbColor = widget.thumbColor ?? theme.thumbColor!; + final thumbBorderColor = widget.thumbBorderColor ?? theme.thumbBorderColor!; + + return HorizontalSlider( + onChangeStart: widget.onChangeStart, + onChanged: widget.onChanged, + onChangeEnd: widget.onChangeEnd, + child: LayoutBuilder( + builder: (context, constraints) => Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + StreamAudioWaveform( + waveform: widget.waveform, + limit: widget.limit, + color: color, + progress: widget.progress, + progressColor: progressColor, + minBarHeight: minBarHeight, + spacingRatio: spacingRatio, + heightScale: heightScale, + inverse: widget.inverse, + ), + Builder( + // Just using it for the calculation of the thumb position. + builder: (context) { + final progressWidth = constraints.maxWidth * widget.progress; + return AnimatedPositioned( + curve: const ElasticOutCurve(1.05), + duration: const Duration(milliseconds: 300), + left: progressWidth - _kAudioWaveformSliderThumbWidth / 2, + child: StreamAudioWaveformSliderThumb( + color: thumbColor, + borderColor: thumbBorderColor, + height: constraints.maxHeight, + ), + ); + }, + ), + ], + ), + ), + ); + } +} + +/// {@template streamAudioWaveformSliderThumb} +/// A widget that represents the thumb of the [StreamAudioWaveformSlider]. +/// {@endtemplate} +class StreamAudioWaveformSliderThumb extends StatelessWidget { + /// {@macro streamAudioWaveformSliderThumb} + const StreamAudioWaveformSliderThumb({ + super.key, + this.width = _kAudioWaveformSliderThumbWidth, + this.height = _kAudioWaveformSliderThumbHeight, + this.color = Colors.white, + this.borderColor = const Color(0xffecebeb), + }); + + /// The width of the thumb. + final double width; + + /// The height of the thumb. + final double height; + + /// The color of the thumb. + final Color color; + + /// The border color of the thumb. + final Color borderColor; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: color, + border: Border.all( + color: borderColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.circular(2), + ), + ); + } +} + +/// {@template streamAudioWaveform} +/// A widget that displays an audio waveform. +/// +/// The waveform is drawn using the [waveform] data. The waveform is drawn +/// horizontally and the bars grow from right to left. +/// {@endtemplate} +class StreamAudioWaveform extends StatelessWidget { + /// {@macro streamAudioWaveform} + const StreamAudioWaveform({ + super.key, + required this.waveform, + this.limit = 100, + this.color, + this.progress = 0, + this.progressColor, + this.minBarHeight, + this.spacingRatio, + this.heightScale, + this.inverse = true, + }); + + /// The waveform data to be drawn. + /// + /// Note: The values should be between 0 and 1. + final List waveform; + + /// The color of the wave bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.color]. + final Color? color; + + /// The number of wave bars that will be draw in the screen. When the length + /// of [waveform] is bigger than [limit] only the X last bars will be shown. + /// + /// Defaults to 100. + final int limit; + + /// The progress of the audio track. Used to show the progress of the audio. + /// + /// Defaults to 0. + final double progress; + + /// The color of the progressed wave bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.progressColor]. + final Color? progressColor; + + /// The minimum height of the bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.minBarHeight]. + final double? minBarHeight; + + /// The ratio of the spacing between the bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.spacingRatio]. + final double? spacingRatio; + + /// The scale of the height of the bars. + /// + /// Defaults to [StreamAudioWaveformThemeData.heightScale]. + final double? heightScale; + + /// If true, the bars grow from right to left otherwise they grow from left + /// to right. + /// + /// Defaults to true. + final bool inverse; + + @override + Widget build(BuildContext context) { + final theme = StreamAudioWaveformTheme.of(context); + + final color = this.color ?? theme.color!; + final progressColor = this.progressColor ?? theme.progressColor!; + final minBarHeight = this.minBarHeight ?? theme.minBarHeight!; + final spacingRatio = this.spacingRatio ?? theme.spacingRatio!; + final heightScale = this.heightScale ?? theme.heightScale!; + + return CustomPaint( + willChange: true, + painter: _WaveformPainter( + waveform: waveform.reversed, + limit: limit, + color: color, + progress: progress, + progressColor: progressColor, + minBarHeight: minBarHeight, + spacingRatio: spacingRatio, + heightScale: heightScale, + inverse: inverse, + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + _WaveformPainter({ + required Iterable waveform, + this.limit = 100, + this.color = const Color(0xff7E828B), + this.progress = 0, + this.progressColor = const Color(0xff005FFF), + this.minBarHeight = 2, + double spacingRatio = 0.3, + this.heightScale = 1, + this.inverse = true, + }) : waveform = [ + ...waveform.take(limit), + if (waveform.length < limit) + // Fill the remaining bars with 0 value + ...List.filled(limit - waveform.length, 0) + ], + spacingRatio = spacingRatio.clamp(0, 1); + + final List waveform; + final Color color; + final int limit; + final double progress; + final Color progressColor; + final double minBarHeight; + final double spacingRatio; + final bool inverse; + final double heightScale; + + @override + void paint(Canvas canvas, Size size) { + final canvasWidth = size.width; + final canvasHeight = size.height; + + // The total spacing between the bars in the canvas. + final spacingWidth = canvasWidth * spacingRatio; + final barsWidth = canvasWidth - spacingWidth; + final barWidth = barsWidth / limit; + final barSpacing = spacingWidth / (limit - 1); + final progressWidth = progress * canvasWidth; + + void _paintBar(int index, double barValue) { + var dx = index * (barWidth + barSpacing) + barWidth / 2; + if (inverse) dx = canvasWidth - dx; + final dy = canvasHeight / 2; + + final barHeight = math.max(barValue * canvasHeight, minBarHeight); + + final rect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(dx, dy), + width: barWidth, + height: barHeight, + ), + const Radius.circular(2), + ); + + final waveColor = switch (dx <= progressWidth) { + true => progressColor, + false => color, + }; + + final wavePaint = Paint() + ..color = waveColor + ..strokeCap = StrokeCap.round; + + canvas.drawRRect(rect, wavePaint); + } + + // Paint all the bars + waveform.forEachIndexed(_paintBar); + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) => + !const ListEquality().equals(waveform, oldDelegate.waveform) || + color != oldDelegate.color || + limit != oldDelegate.limit || + progress != oldDelegate.progress || + progressColor != oldDelegate.progressColor || + minBarHeight != oldDelegate.minBarHeight || + spacingRatio != oldDelegate.spacingRatio || + heightScale != oldDelegate.heightScale || + inverse != oldDelegate.inverse; +} + +/// {@template horizontalSlider} +/// A widget that allows interactive horizontal sliding gestures. +/// +/// The `HorizontalSlider` widget wraps a child widget and allows users to +/// perform sliding gestures horizontally. It can be configured with callbacks +/// to notify the parent widget about the changes in the horizontal value. +/// {@endtemplate} +class HorizontalSlider extends StatefulWidget { + /// Creates a horizontal slider. + const HorizontalSlider({ + super.key, + required this.child, + required this.onChanged, + this.onChangeStart, + this.onChangeEnd, + }); + + /// The child widget wrapped by the slider. + final Widget child; + + /// Called when the horizontal value starts changing. + final ValueChanged? onChangeStart; + + /// Called when the horizontal value changes. + final ValueChanged? onChanged; + + /// Called when the horizontal value stops changing. + final ValueChanged? onChangeEnd; + + @override + State createState() => _HorizontalSliderState(); +} + +class _HorizontalSliderState extends State { + bool _active = false; + + /// Returns true if the slider is interactive. + bool get isInteractive => widget.onChanged != null; + + /// Converts the visual position to a value based on the text direction. + double _getValueFromVisualPosition(double visualPosition) { + final textDirection = Directionality.of(context); + final value = switch (textDirection) { + TextDirection.rtl => 1.0 - visualPosition, + TextDirection.ltr => visualPosition, + }; + + return clampDouble(value, 0, 1); + } + + /// Converts the local position to a horizontal value. + double _getValueFromLocalPosition(Offset globalPosition) { + final box = context.findRenderObject()! as RenderBox; + final localPosition = box.globalToLocal(globalPosition); + final visualPosition = localPosition.dx / box.size.width; + return _getValueFromVisualPosition(visualPosition); + } + + void _handleDragStart(DragStartDetails details) { + if (!_active && isInteractive) { + _active = true; + final value = _getValueFromLocalPosition(details.globalPosition); + widget.onChangeStart?.call(value); + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + _handleHorizontalDrag(details.globalPosition); + } + + void _handleDragEnd(DragEndDetails details) { + if (!mounted) return; + + if (_active && mounted) { + final value = _getValueFromLocalPosition(details.globalPosition); + widget.onChangeEnd?.call(value); + _active = false; + } + } + + /// Handles the sliding gesture. + void _handleHorizontalDrag(Offset globalPosition) { + if (!mounted) return; + + if (isInteractive) { + final value = _getValueFromLocalPosition(globalPosition); + widget.onChanged?.call(value); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + child: widget.child, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/gradient_box_border.dart b/packages/stream_chat_flutter/lib/src/misc/gradient_box_border.dart new file mode 100644 index 0000000000..7a881edd16 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/gradient_box_border.dart @@ -0,0 +1,334 @@ +import 'dart:math' as math; +import 'dart:ui' as ui show lerpDouble; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +const _kDefaultGradient = LinearGradient(colors: [ + Color(0xFF000000), + Color(0xFF000000), +]); + +const _kTransparentGradient = LinearGradient(colors: [ + Color(0x00000000), + Color(0x00000000), +]); + +/// {@template gradientBoxBorder} +/// A border that draws a gradient instead of a solid color. +/// {@endtemplate} +class GradientBoxBorder extends BoxBorder { + /// {@macro gradientBoxBorder} + const GradientBoxBorder({ + this.gradient = _kDefaultGradient, + this.width = 1.0, + this.style = BorderStyle.solid, + this.strokeAlign = strokeAlignInside, + }) : assert(width >= 0.0, 'The width must be greater than or equal to zero.'); + + /// A hairline default gradient border that is not rendered. + static const GradientBoxBorder none = GradientBoxBorder( + width: 0, + style: BorderStyle.none, + ); + + /// The gradient to use in the border. + final Gradient gradient; + + /// The width of the border, in logical pixels. + /// + /// Setting width to 0.0 will result in a hairline border. This means that + /// the border will have the width of one physical pixel. Hairline + /// rendering takes shortcuts when the path overlaps a pixel more than once. + /// This means that it will render faster than otherwise, but it might + /// double-hit pixels, giving it a slightly darker/lighter result. + /// + /// To omit the border entirely, set the [style] to [BorderStyle.none]. + final double width; + + /// The style of the border. + /// + /// To omit a side, set [style] to [BorderStyle.none]. This skips + /// painting the border, but the border still has a [width]. + final BorderStyle style; + + /// The relative position of the stroke on a [BorderSide] in an + /// [OutlinedBorder] or [Border]. + /// + /// Values typically range from -1.0 ([strokeAlignInside], inside border, + /// default) to 1.0 ([strokeAlignOutside], outside border), without any + /// bound constraints (e.g., a value of -2.0 is not typical, but allowed). + /// A value of 0 ([strokeAlignCenter]) will center the border on the edge + /// of the widget. + /// + /// When set to [strokeAlignInside], the stroke is drawn completely inside + /// the widget. For [strokeAlignCenter] and [strokeAlignOutside], a property + /// such as [Container.clipBehavior] can be used in an outside widget to clip + /// it. If [Container.decoration] has a border, the container may incorporate + /// [width] as additional padding: + /// - [strokeAlignInside] provides padding with full [width]. + /// - [strokeAlignCenter] provides padding with half [width]. + /// - [strokeAlignOutside] provides zero padding, as stroke is drawn entirely + /// outside. + /// + /// This property is not honored by [toPaint] (because the [Paint] object + /// cannot represent it); it is intended that classes that use [BorderSide] + /// objects implement this property when painting borders by suitably + /// inflating or deflating their regions. + /// + /// {@tool dartpad} + /// This example shows an animation of how [strokeAlign] affects the drawing + /// when applied to borders of various shapes. + /// + /// ** See code in examples/api/lib/painting/borders/border_side.stroke_align.0.dart ** + /// {@end-tool} + final double strokeAlign; + + /// The border is drawn fully inside of the border path. + /// + /// This is a constant for use with [strokeAlign]. + /// + /// This is the default value for [strokeAlign]. + static const double strokeAlignInside = -1; + + /// The border is drawn on the center of the border path, with half of the + /// [BorderSide.width] on the inside, and the other half on the outside of + /// the path. + /// + /// This is a constant for use with [strokeAlign]. + static const double strokeAlignCenter = 0; + + /// The border is drawn on the outside of the border path. + /// + /// This is a constant for use with [strokeAlign]. + static const double strokeAlignOutside = 1; + + /// Whether the two given [GradientBoxBorder]s can be merged using + /// [GradientBoxBorder.merge]. + /// + /// Two sides can be merged if one or both are zero-width with + /// [GradientBoxBorder.none], or if they both have the same gradient and style + bool canMerge(GradientBoxBorder b) { + if ((style == BorderStyle.none && width == 0.0) || + (b.style == BorderStyle.none && b.width == 0.0)) { + return true; + } + return style == b.style && gradient == b.gradient; + } + + /// Creates a [GradientBoxBorder] that represents the addition of the two + /// given [GradientBoxBorder]s. + /// + /// It is only valid to call this if [canMerge] returns true for the two + /// borders. + /// + /// If one of the border is zero-width with [BorderStyle.none], then the other + /// border is return as-is. If both of the border are zero-width with + /// [BorderStyle.none], then [GradientBoxBorder.none] is returned. + GradientBoxBorder merge(GradientBoxBorder b) { + assert(canMerge(b), ''); + final aIsNone = style == BorderStyle.none && width == 0.0; + final bIsNone = b.style == BorderStyle.none && b.width == 0.0; + if (aIsNone && bIsNone) return GradientBoxBorder.none; + if (aIsNone) return b; + if (bIsNone) return this; + + assert(gradient == b.gradient, ''); + assert(style == b.style, ''); + return GradientBoxBorder( + gradient: gradient, // == b.gradient + width: width + b.width, + strokeAlign: math.max(strokeAlign, b.strokeAlign), + style: style, // == b.style + ); + } + + @override + GradientBoxBorder scale(double t) { + return GradientBoxBorder( + gradient: gradient, + width: math.max(0, width * t), + style: t <= 0.0 ? BorderStyle.none : style, + ); + } + + @override + bool get isUniform => true; + + @override + BorderSide get bottom => BorderSide.none; + + @override + BorderSide get top => BorderSide.none; + + /// Get the amount of the stroke width that lies inside of the [BorderSide]. + /// + /// For example, this will return the [width] for a [strokeAlign] of -1, half + /// the [width] for a [strokeAlign] of 0, and 0 for a [strokeAlign] of 1. + double get strokeInset => width * (1 - (1 + strokeAlign) / 2); + + /// Get the amount of the stroke width that lies outside of the [BorderSide]. + /// + /// For example, this will return 0 for a [strokeAlign] of -1, half the + /// [width] for a [strokeAlign] of 0, and the [width] for a [strokeAlign] + /// of 1. + double get strokeOutset => width * (1 + strokeAlign) / 2; + + /// The offset of the stroke, taking into account the stroke alignment. + /// + /// For example, this will return the negative [width] of the stroke + /// for a [strokeAlign] of -1, 0 for a [strokeAlign] of 0, and the + /// [width] for a [strokeAlign] of -1. + double get strokeOffset => width * strokeAlign; + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.all(strokeInset); + + @override + GradientBoxBorder? add(ShapeBorder other, {bool reversed = false}) { + if (other is GradientBoxBorder && canMerge(other)) return merge(other); + return null; + } + + @override + ShapeBorder? lerpFrom(ShapeBorder? a, double t) { + if (a is GradientBoxBorder) { + return GradientBoxBorder.lerp(a, this, t); + } + return super.lerpFrom(a, t); + } + + @override + ShapeBorder? lerpTo(ShapeBorder? b, double t) { + if (b is GradientBoxBorder) { + return GradientBoxBorder.lerp(this, b, t); + } + return super.lerpTo(b, t); + } + + /// Linearly interpolate between two gradient borders. + /// + /// {@macro dart.ui.shadow.lerp} + static GradientBoxBorder? lerp( + GradientBoxBorder? a, GradientBoxBorder? b, double t) { + if (identical(a, b)) return a; + if (a == null) return b!.scale(t); + if (b == null) return a.scale(1.0 - t); + + final width = ui.lerpDouble(a.width, b.width, t)!; + if (width < 0.0) return GradientBoxBorder.none; + + if (a.style == b.style && a.strokeAlign == b.strokeAlign) { + return GradientBoxBorder( + gradient: Gradient.lerp(a.gradient, b.gradient, t)!, + width: width, + style: a.style, // == b.style + strokeAlign: a.strokeAlign, // == b.strokeAlign + ); + } + + final gradientA = switch (a.style) { + BorderStyle.solid => a.gradient, + BorderStyle.none => _kTransparentGradient, + }; + + final gradientB = switch (b.style) { + BorderStyle.solid => b.gradient, + BorderStyle.none => _kTransparentGradient, + }; + + if (a.strokeAlign != b.strokeAlign) { + return GradientBoxBorder( + gradient: Gradient.lerp(gradientA, gradientB, t)!, + width: width, + strokeAlign: ui.lerpDouble(a.strokeAlign, b.strokeAlign, t)!, + ); + } + + return GradientBoxBorder( + gradient: Gradient.lerp(gradientA, gradientB, t)!, + width: width, + strokeAlign: a.strokeAlign, // == b.strokeAlign + ); + } + + @override + void paint( + Canvas canvas, + Rect rect, { + TextDirection? textDirection, + BoxShape shape = BoxShape.rectangle, + BorderRadius? borderRadius, + }) { + assert( + shape != BoxShape.circle || borderRadius == null, + 'A borderRadius can only be given for rectangular boxes.', + ); + + if (style == BorderStyle.none) return; + + return switch (shape) { + BoxShape.circle => _paintUniformBorderWithCircle(canvas, rect), + BoxShape.rectangle => switch (borderRadius) { + final radius? when radius != BorderRadius.zero => + _paintUniformBorderWithRadius(canvas, rect, radius), + _ => _paintUniformBorderWithRectangle(canvas, rect), + }, + }; + } + + void _paintUniformBorderWithRadius( + Canvas canvas, + Rect rect, + BorderRadius borderRadius, + ) { + assert(style != BorderStyle.none, ''); + + if (width == 0) { + return canvas.drawRRect(borderRadius.toRRect(rect), _getPaint(rect)); + } + + final borderRect = borderRadius.toRRect(rect); + final inner = borderRect.deflate(strokeInset); + final outer = borderRect.inflate(strokeOutset); + canvas.drawDRRect(outer, inner, _getPaint(rect)); + } + + void _paintUniformBorderWithRectangle(Canvas canvas, Rect rect) { + assert(style != BorderStyle.none, ''); + return canvas.drawRect(rect.inflate(strokeOffset / 2), _getPaint(rect)); + } + + void _paintUniformBorderWithCircle(Canvas canvas, Rect rect) { + assert(style != BorderStyle.none, ''); + final radius = (rect.shortestSide + strokeOffset) / 2; + canvas.drawCircle(rect.center, radius, _getPaint(rect)); + } + + Paint _getPaint(Rect rect) { + return switch (style) { + BorderStyle.solid => Paint() + ..strokeWidth = width + ..style = PaintingStyle.stroke + ..shader = gradient.createShader(rect), + BorderStyle.none => Paint() + ..strokeWidth = 0.0 + ..style = PaintingStyle.stroke + ..shader = _kTransparentGradient.createShader(rect), + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is GradientBoxBorder && + other.gradient == gradient && + other.width == width && + other.style == style && + other.strokeAlign == strokeAlign; + } + + @override + int get hashCode => Object.hash(gradient, width, style, strokeAlign); +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index b1e34d22c6..eccb4bf14b 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -1,7 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/misc/timestamp.dart'; -import 'package:stream_chat_flutter/src/theme/thread_list_tile_theme.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamThreadListTile} diff --git a/packages/stream_chat_flutter/lib/src/theme/audio_waveform_slider_theme.dart b/packages/stream_chat_flutter/lib/src/theme/audio_waveform_slider_theme.dart new file mode 100644 index 0000000000..0b2b9b5424 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/audio_waveform_slider_theme.dart @@ -0,0 +1,139 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/audio_waveform_theme.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// {@template streamAudioWaveformSliderTheme} +/// Overrides the default style of [StreamAudioWaveformSlider] descendants. +/// +/// See also: +/// +/// * [StreamVoiceRecordingAttachmentThemeData], which is used to configure +/// this theme. +/// {@endtemplate} +class StreamAudioWaveformSliderTheme extends InheritedTheme { + /// Creates a [StreamAudioWaveformSliderTheme]. + /// + /// The [data] parameter must not be null. + const StreamAudioWaveformSliderTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The configuration of this theme. + final StreamAudioWaveformSliderThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [StreamAudioWaveformSliderTheme] widget, + /// then [StreamAudioWaveformSliderTheme.audioWaveformSliderTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// StreamAudioWaveformSliderTheme theme = + /// StreamAudioWaveformSliderTheme.of(context); + /// ``` + static StreamAudioWaveformSliderThemeData of(BuildContext context) { + final audioWaveformSliderTheme = context + .dependOnInheritedWidgetOfExactType(); + return audioWaveformSliderTheme?.data ?? + StreamChatTheme.of(context).audioWaveformSliderTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) => + StreamAudioWaveformSliderTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamAudioWaveformSliderTheme oldWidget) => + data != oldWidget.data; +} + +/// {@template streamAudioWaveformSliderThemeData} +/// A style that overrides the default appearance of +/// [StreamAudioWaveformSlider] widgets when used with +/// [StreamAudioWaveformSliderTheme] or with the overall +/// [StreamChatTheme]'s [StreamChatThemeData.audioWaveformSliderTheme]. +/// {@endtemplate} +class StreamAudioWaveformSliderThemeData with Diagnosticable { + /// {@macro streamVoiceRecordingAttachmentThemeData} + const StreamAudioWaveformSliderThemeData({ + this.audioWaveformTheme, + this.thumbColor, + this.thumbBorderColor, + }); + + /// The theme of the audio waveform. + final StreamAudioWaveformThemeData? audioWaveformTheme; + + /// The color of the thumb. + final Color? thumbColor; + + /// The color of the thumb border. + final Color? thumbBorderColor; + + /// A copy of [StreamAudioWaveformSliderThemeData] with specified attributes + /// overridden. + StreamAudioWaveformSliderThemeData copyWith({ + StreamAudioWaveformThemeData? audioWaveformTheme, + Color? thumbColor, + Color? thumbBorderColor, + }) { + return StreamAudioWaveformSliderThemeData( + audioWaveformTheme: audioWaveformTheme ?? this.audioWaveformTheme, + thumbColor: thumbColor ?? this.thumbColor, + thumbBorderColor: thumbBorderColor ?? this.thumbBorderColor, + ); + } + + /// Merges this [StreamPollOptionsDialogThemeData] with the [other]. + StreamAudioWaveformSliderThemeData merge( + StreamAudioWaveformSliderThemeData? other, + ) { + if (other == null) return this; + return copyWith( + audioWaveformTheme: other.audioWaveformTheme, + thumbColor: other.thumbColor, + thumbBorderColor: other.thumbBorderColor, + ); + } + + /// Linearly interpolate between two [StreamPollOptionsDialogThemeData]. + static StreamAudioWaveformSliderThemeData lerp( + StreamAudioWaveformSliderThemeData a, + StreamAudioWaveformSliderThemeData b, + double t, + ) => + StreamAudioWaveformSliderThemeData( + audioWaveformTheme: StreamAudioWaveformThemeData.lerp( + a.audioWaveformTheme!, b.audioWaveformTheme!, t), + thumbColor: Color.lerp(a.thumbColor, b.thumbColor, t), + thumbBorderColor: Color.lerp(a.thumbBorderColor, b.thumbBorderColor, t), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StreamAudioWaveformSliderThemeData && + other.audioWaveformTheme == audioWaveformTheme && + other.thumbColor == thumbColor && + other.thumbBorderColor == thumbBorderColor; + + @override + int get hashCode => + audioWaveformTheme.hashCode ^ + thumbColor.hashCode ^ + thumbBorderColor.hashCode; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'audioWaveformTheme', audioWaveformTheme)) + ..add(ColorProperty('thumbColor', thumbColor)) + ..add(ColorProperty('thumbBorderColor', thumbBorderColor)); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/audio_waveform_theme.dart b/packages/stream_chat_flutter/lib/src/theme/audio_waveform_theme.dart new file mode 100644 index 0000000000..dde8fc78df --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/audio_waveform_theme.dart @@ -0,0 +1,159 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// {@template streamAudioWaveformTheme} +/// Overrides the default style of [StreamAudioWaveform] descendants. +/// +/// See also: +/// +/// * [StreamVoiceRecordingAttachmentThemeData], which is used to configure +/// this theme. +/// {@endtemplate} +class StreamAudioWaveformTheme extends InheritedTheme { + /// Creates a [StreamAudioWaveformTheme]. + /// + /// The [data] parameter must not be null. + const StreamAudioWaveformTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The configuration of this theme. + final StreamAudioWaveformThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [StreamAudioWaveformTheme] widget, + /// then [StreamAudioWaveformTheme.audioWaveformSliderTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// StreamAudioWaveformTheme theme = StreamAudioWaveformTheme.of(context); + /// ``` + static StreamAudioWaveformThemeData of(BuildContext context) { + final audioWaveformTheme = + context.dependOnInheritedWidgetOfExactType(); + return audioWaveformTheme?.data ?? + StreamChatTheme.of(context).audioWaveformTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) => + StreamAudioWaveformTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamAudioWaveformTheme oldWidget) => + data != oldWidget.data; +} + +/// {@template streamVoiceRecordingAttachmentThemeData} +/// A style that overrides the default appearance of +/// [StreamAudioWaveformSlider] widgets when used with +/// [StreamAudioWaveformTheme] or with the overall +/// [StreamChatTheme]'s [StreamChatThemeData.audioWaveformSliderTheme]. +/// {@endtemplate} +class StreamAudioWaveformThemeData with Diagnosticable { + /// {@macro streamAudioWaveformThemeData} + const StreamAudioWaveformThemeData({ + this.color, + this.progressColor, + this.minBarHeight, + this.spacingRatio, + this.heightScale, + }); + + /// The color of the wave bars. + final Color? color; + + /// The color of the progressed wave bars. + final Color? progressColor; + + /// The minimum height of the bars. + final double? minBarHeight; + + /// The ratio of the spacing between the bars. + final double? spacingRatio; + + /// The scale of the height of the bars. + final double? heightScale; + + /// A copy of [StreamAudioWaveformThemeData] with specified attributes + /// overridden. + StreamAudioWaveformThemeData copyWith({ + Color? color, + Color? progressColor, + double? minBarHeight, + double? spacingRatio, + double? heightScale, + }) { + return StreamAudioWaveformThemeData( + color: color ?? this.color, + progressColor: progressColor ?? this.progressColor, + minBarHeight: minBarHeight ?? this.minBarHeight, + spacingRatio: spacingRatio ?? this.spacingRatio, + heightScale: heightScale ?? this.heightScale, + ); + } + + /// Merges this [StreamPollOptionsDialogThemeData] with the [other]. + StreamAudioWaveformThemeData merge( + StreamAudioWaveformThemeData? other, + ) { + if (other == null) return this; + return copyWith( + color: other.color, + progressColor: other.progressColor, + minBarHeight: other.minBarHeight, + spacingRatio: other.spacingRatio, + heightScale: other.heightScale, + ); + } + + /// Linearly interpolate between two [StreamPollOptionsDialogThemeData]. + static StreamAudioWaveformThemeData lerp( + StreamAudioWaveformThemeData a, + StreamAudioWaveformThemeData b, + double t, + ) => + StreamAudioWaveformThemeData( + color: Color.lerp(a.color, b.color, t), + progressColor: Color.lerp(a.progressColor, b.progressColor, t), + minBarHeight: lerpDouble(a.minBarHeight, b.minBarHeight, t), + spacingRatio: lerpDouble(a.spacingRatio, b.spacingRatio, t), + heightScale: lerpDouble(a.heightScale, b.heightScale, t), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StreamAudioWaveformThemeData && + other.color == color && + other.progressColor == progressColor && + other.minBarHeight == minBarHeight && + other.spacingRatio == spacingRatio && + other.heightScale == heightScale; + + @override + int get hashCode => + color.hashCode ^ + progressColor.hashCode ^ + minBarHeight.hashCode ^ + spacingRatio.hashCode ^ + heightScale.hashCode; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('color', color)) + ..add(ColorProperty('progressColor', progressColor)) + ..add(DoubleProperty('minBarHeight', minBarHeight)) + ..add(DoubleProperty('spacingRatio', spacingRatio)) + ..add(DoubleProperty('heightScale', heightScale)); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index e1bb3e5140..78e6323063 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -1,10 +1,7 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'package:flutter/material.dart' hide TextTheme; -import 'package:stream_chat_flutter/src/theme/poll_comments_dialog_theme.dart'; -import 'package:stream_chat_flutter/src/theme/poll_interactor_theme.dart'; -import 'package:stream_chat_flutter/src/theme/poll_option_votes_dialog_theme.dart'; -import 'package:stream_chat_flutter/src/theme/poll_options_dialog_theme.dart'; -import 'package:stream_chat_flutter/src/theme/poll_results_dialog_theme.dart'; -import 'package:stream_chat_flutter/src/theme/thread_list_tile_theme.dart'; +import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamChatTheme} @@ -61,6 +58,8 @@ class StreamChatThemeData { StreamGalleryHeaderThemeData? imageHeaderTheme, StreamGalleryFooterThemeData? imageFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, + @Deprecated( + "Use 'StreamChatThemeData.voiceRecordingAttachmentTheme' instead") StreamVoiceRecordingThemeData? voiceRecordingTheme, StreamPollCreatorThemeData? pollCreatorTheme, StreamPollInteractorThemeData? pollInteractorTheme, @@ -69,6 +68,9 @@ class StreamChatThemeData { StreamPollCommentsDialogThemeData? pollCommentsDialogTheme, StreamPollOptionVotesDialogThemeData? pollOptionVotesDialogTheme, StreamThreadListTileThemeData? threadListTileTheme, + StreamAudioWaveformThemeData? audioWaveformTheme, + StreamAudioWaveformSliderThemeData? audioWaveformSliderTheme, + StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, }) { brightness ??= colorTheme?.brightness ?? Brightness.light; final isDark = brightness == Brightness.dark; @@ -90,7 +92,6 @@ class StreamChatThemeData { defaultUserImage: defaultUserImage, placeholderUserImage: placeholderUserImage, primaryIconTheme: primaryIconTheme, - //ignore: deprecated_member_use_from_same_package reactionIcons: reactionIcons, galleryHeaderTheme: imageHeaderTheme, galleryFooterTheme: imageFooterTheme, @@ -103,6 +104,9 @@ class StreamChatThemeData { pollCommentsDialogTheme: pollCommentsDialogTheme, pollOptionVotesDialogTheme: pollOptionVotesDialogTheme, threadListTileTheme: threadListTileTheme, + audioWaveformTheme: audioWaveformTheme, + audioWaveformSliderTheme: audioWaveformSliderTheme, + voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme, ); return defaultData.merge(customizedData); @@ -138,6 +142,9 @@ class StreamChatThemeData { required this.pollCommentsDialogTheme, required this.pollOptionVotesDialogTheme, required this.threadListTileTheme, + required this.audioWaveformTheme, + required this.audioWaveformSliderTheme, + required this.voiceRecordingAttachmentTheme, }); /// Creates a theme from a Material [Theme] @@ -192,6 +199,21 @@ class StreamChatThemeData { ), indicatorIconSize: 16, ); + + final audioWaveformTheme = StreamAudioWaveformThemeData( + color: colorTheme.textLowEmphasis, + progressColor: colorTheme.accentPrimary, + minBarHeight: 2, + spacingRatio: 0.3, + heightScale: 1, + ); + + final audioWaveformSliderTheme = StreamAudioWaveformSliderThemeData( + audioWaveformTheme: audioWaveformTheme, + thumbColor: Colors.white, + thumbBorderColor: colorTheme.borders, + ); + return StreamChatThemeData.raw( textTheme: textTheme, colorTheme: colorTheme, @@ -308,9 +330,6 @@ class StreamChatThemeData { messageListViewTheme: StreamMessageListViewThemeData( backgroundColor: colorTheme.barsBg, ), - voiceRecordingTheme: colorTheme.brightness == Brightness.dark - ? StreamVoiceRecordingThemeData.dark() - : StreamVoiceRecordingThemeData.light(), pollCreatorTheme: StreamPollCreatorThemeData( backgroundColor: colorTheme.appBg, appBarBackgroundColor: colorTheme.barsBg, @@ -507,6 +526,50 @@ class StreamChatThemeData { color: colorTheme.textLowEmphasis, ), ), + audioWaveformTheme: audioWaveformTheme, + audioWaveformSliderTheme: audioWaveformSliderTheme, + voiceRecordingAttachmentTheme: StreamVoiceRecordingAttachmentThemeData( + backgroundColor: colorTheme.barsBg, + playIcon: const StreamSvgIcon(icon: StreamSvgIcons.play), + pauseIcon: const StreamSvgIcon(icon: StreamSvgIcons.pause), + loadingIndicator: SizedBox.fromSize( + size: const Size.square(24 - /* Padding */ 2), + child: Center( + child: CircularProgressIndicator.adaptive( + valueColor: AlwaysStoppedAnimation(colorTheme.accentPrimary), + ), + ), + ), + audioControlButtonStyle: ElevatedButton.styleFrom( + elevation: 2, + iconColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 6), + backgroundColor: Colors.white, + shape: const CircleBorder(), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(36, 36), + ), + titleTextStyle: textTheme.bodyBold.copyWith( + color: colorTheme.textHighEmphasis, + ), + durationTextStyle: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + speedControlButtonStyle: ElevatedButton.styleFrom( + elevation: 2, + textStyle: textTheme.footnote, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 8), + backgroundColor: Colors.white, + shape: const StadiumBorder(), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 28), + ), + audioWaveformSliderTheme: audioWaveformSliderTheme, + ), + voiceRecordingTheme: colorTheme.brightness == Brightness.dark + ? StreamVoiceRecordingThemeData.dark() + : StreamVoiceRecordingThemeData.light(), ); } @@ -549,6 +612,7 @@ class StreamChatThemeData { final StreamMessageListViewThemeData messageListViewTheme; /// Theme configuration for the [StreamVoiceRecordingListPLayer] widget. + @Deprecated("Use 'StreamChatThemeData.voiceRecordingAttachmentTheme' instead") final StreamVoiceRecordingThemeData voiceRecordingTheme; /// Theme configuration for the [StreamPollCreatorWidget] widget. @@ -572,6 +636,15 @@ class StreamChatThemeData { /// Theme configuration for the [StreamThreadListTile] widget. final StreamThreadListTileThemeData threadListTileTheme; + /// Theme configuration for the [StreamAudioWaveform] widget. + final StreamAudioWaveformThemeData audioWaveformTheme; + + /// Theme configuration for the [StreamAudioWaveformSlider] widget. + final StreamAudioWaveformSliderThemeData audioWaveformSliderTheme; + + /// Theme configuration for the [StreamVoiceRecordingAttachment] widget. + final StreamVoiceRecordingAttachmentThemeData voiceRecordingAttachmentTheme; + /// Creates a copy of [StreamChatThemeData] with specified attributes /// overridden. StreamChatThemeData copyWith({ @@ -591,6 +664,7 @@ class StreamChatThemeData { StreamGalleryHeaderThemeData? galleryHeaderTheme, StreamGalleryFooterThemeData? galleryFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, + @Deprecated("Use 'voiceRecordingAttachmentTheme' instead") StreamVoiceRecordingThemeData? voiceRecordingTheme, StreamPollCreatorThemeData? pollCreatorTheme, StreamPollInteractorThemeData? pollInteractorTheme, @@ -599,6 +673,9 @@ class StreamChatThemeData { StreamPollCommentsDialogThemeData? pollCommentsDialogTheme, StreamPollOptionVotesDialogThemeData? pollOptionVotesDialogTheme, StreamThreadListTileThemeData? threadListTileTheme, + StreamAudioWaveformThemeData? audioWaveformTheme, + StreamAudioWaveformSliderThemeData? audioWaveformSliderTheme, + StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, }) => StreamChatThemeData.raw( channelListHeaderTheme: @@ -627,6 +704,11 @@ class StreamChatThemeData { pollOptionVotesDialogTheme: pollOptionVotesDialogTheme ?? this.pollOptionVotesDialogTheme, threadListTileTheme: threadListTileTheme ?? this.threadListTileTheme, + audioWaveformTheme: audioWaveformTheme ?? this.audioWaveformTheme, + audioWaveformSliderTheme: + audioWaveformSliderTheme ?? this.audioWaveformSliderTheme, + voiceRecordingAttachmentTheme: + voiceRecordingAttachmentTheme ?? this.voiceRecordingAttachmentTheme, ); /// Merge themes @@ -659,6 +741,11 @@ class StreamChatThemeData { pollOptionVotesDialogTheme: pollOptionVotesDialogTheme.merge(other.pollOptionVotesDialogTheme), threadListTileTheme: threadListTileTheme.merge(other.threadListTileTheme), + audioWaveformTheme: audioWaveformTheme.merge(other.audioWaveformTheme), + audioWaveformSliderTheme: + audioWaveformSliderTheme.merge(other.audioWaveformSliderTheme), + voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme + .merge(other.voiceRecordingAttachmentTheme), ); } } diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index 39189d23ab..9e41282aa6 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -1,3 +1,5 @@ +export 'audio_waveform_slider_theme.dart'; +export 'audio_waveform_theme.dart'; export 'avatar_theme.dart'; export 'channel_header_theme.dart'; export 'channel_list_header_theme.dart'; @@ -8,6 +10,13 @@ export 'gallery_header_theme.dart'; export 'message_input_theme.dart'; export 'message_list_view_theme.dart'; export 'message_theme.dart'; +export 'poll_comments_dialog_theme.dart'; export 'poll_creator_theme.dart'; +export 'poll_interactor_theme.dart'; +export 'poll_option_votes_dialog_theme.dart'; +export 'poll_options_dialog_theme.dart'; +export 'poll_results_dialog_theme.dart'; export 'text_theme.dart'; +export 'thread_list_tile_theme.dart'; export 'voice_attachment_theme.dart'; +export 'voice_recording_attachment_theme.dart'; diff --git a/packages/stream_chat_flutter/lib/src/theme/thread_list_tile_theme.dart b/packages/stream_chat_flutter/lib/src/theme/thread_list_tile_theme.dart index 1aee782fb1..0597ba0261 100644 --- a/packages/stream_chat_flutter/lib/src/theme/thread_list_tile_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/thread_list_tile_theme.dart @@ -25,7 +25,7 @@ class StreamThreadListTileTheme extends InheritedTheme { /// The closest instance of this class that encloses the given context. /// - /// If there is no enclosing [StreamPollOptionVotesDialogTheme] widget, then + /// If there is no enclosing [StreamThreadListTileTheme] widget, then /// [StreamChatThemeData.pollOptionVotesDialogTheme] is used. static StreamThreadListTileThemeData of(BuildContext context) { final threadListTileTheme = diff --git a/packages/stream_chat_flutter/lib/src/theme/voice_attachment_theme.dart b/packages/stream_chat_flutter/lib/src/theme/voice_attachment_theme.dart index 16af40892a..db1ed7db65 100644 --- a/packages/stream_chat_flutter/lib/src/theme/voice_attachment_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/voice_attachment_theme.dart @@ -5,6 +5,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template StreamVoiceRecordingThemeData} /// The theme data for the voice recording attachment builder. /// {@endtemplate} +@Deprecated("Use 'StreamVoiceRecordingAttachmentThemeData' instead.") class StreamVoiceRecordingThemeData with Diagnosticable { /// {@macro StreamVoiceRecordingThemeData} const StreamVoiceRecordingThemeData({ @@ -78,6 +79,7 @@ class StreamVoiceRecordingThemeData with Diagnosticable { /// The theme data for the voice recording attachment builder /// loading widget [StreamVoiceRecordingLoading]. /// {@endtemplate} +@Deprecated("Use 'StreamVoiceRecordingAttachmentThemeData' instead.") class StreamVoiceRecordingLoadingThemeData with Diagnosticable { /// {@macro StreamAudioPlayerLoadingTheme} const StreamVoiceRecordingLoadingThemeData({ @@ -146,6 +148,7 @@ class StreamVoiceRecordingLoadingThemeData with Diagnosticable { /// The theme data for the voice recording attachment builder audio player /// slider [StreamVoiceRecordingSlider]. /// {@endtemplate} +@Deprecated("Use 'StreamVoiceRecordingAttachmentThemeData' instead.") class StreamVoiceRecordingSliderTheme with Diagnosticable { /// {@macro StreamAudioPlayerSliderTheme} const StreamVoiceRecordingSliderTheme({ @@ -253,6 +256,7 @@ class StreamVoiceRecordingSliderTheme with Diagnosticable { /// The theme data for the voice recording attachment builder audio player /// [StreamVoiceRecordingListPlayer]. /// {@endtemplate} +@Deprecated("Use 'StreamVoiceRecordingAttachmentThemeData' instead.") class StreamVoiceRecordingListPlayerThemeData with Diagnosticable { /// {@macro StreamAudioListPlayerTheme} const StreamVoiceRecordingListPlayerThemeData({ @@ -320,6 +324,7 @@ class StreamVoiceRecordingListPlayerThemeData with Diagnosticable { /// {@template StreamVoiceRecordingPlayerTheme} /// The theme data for the voice recording attachment builder audio player /// {@endtemplate} +@Deprecated("Use 'StreamVoiceRecordingAttachmentThemeData' instead.") class StreamVoiceRecordingPlayerThemeData with Diagnosticable { /// {@macro StreamVoiceRecordingPlayerTheme} const StreamVoiceRecordingPlayerThemeData({ diff --git a/packages/stream_chat_flutter/lib/src/theme/voice_recording_attachment_theme.dart b/packages/stream_chat_flutter/lib/src/theme/voice_recording_attachment_theme.dart new file mode 100644 index 0000000000..6103156c04 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/voice_recording_attachment_theme.dart @@ -0,0 +1,198 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/theme/audio_waveform_slider_theme.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// {@template streamVoiceRecordingAttachmentTheme} +/// Overrides the default style of [StreamVoiceRecordingAttachment] descendants. +/// +/// See also: +/// +/// * [StreamVoiceRecordingAttachmentThemeData], which is used to configure +/// this theme. +/// {@endtemplate} +class StreamVoiceRecordingAttachmentTheme extends InheritedTheme { + /// Creates a [StreamVoiceRecordingAttachmentTheme]. + /// + /// The [data] parameter must not be null. + const StreamVoiceRecordingAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The configuration of this theme. + final StreamVoiceRecordingAttachmentThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [StreamVoiceRecordingAttachmentTheme] widget, + /// then [StreamVoiceRecordingAttachmentTheme.voiceRecordingTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// StreamVoiceRecordingAttachmentTheme theme = + /// StreamVoiceRecordingAttachmentTheme.of(context); + /// ``` + static StreamVoiceRecordingAttachmentThemeData of(BuildContext context) { + final voiceRecordingTheme = context.dependOnInheritedWidgetOfExactType< + StreamVoiceRecordingAttachmentTheme>(); + return voiceRecordingTheme?.data ?? + StreamChatTheme.of(context).voiceRecordingAttachmentTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) => + StreamVoiceRecordingAttachmentTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamVoiceRecordingAttachmentTheme oldWidget) => + data != oldWidget.data; +} + +/// {@template streamVoiceRecordingAttachmentThemeData} +/// A style that overrides the default appearance of +/// [StreamVoiceRecordingAttachment] widgets when used with +/// [StreamVoiceRecordingAttachmentTheme] or with the overall +/// [StreamChatTheme]'s [StreamChatThemeData.voiceRecordingAttachmentTheme]. +/// {@endtemplate} +class StreamVoiceRecordingAttachmentThemeData with Diagnosticable { + /// {@macro streamVoiceRecordingAttachmentThemeData} + const StreamVoiceRecordingAttachmentThemeData({ + this.backgroundColor, + this.playIcon, + this.pauseIcon, + this.loadingIndicator, + this.audioControlButtonStyle, + this.titleTextStyle, + this.durationTextStyle, + this.speedControlButtonStyle, + this.audioWaveformSliderTheme, + }); + + /// The background color of the attachment. + final Color? backgroundColor; + + /// The icon widget to show when the recording is playing. + final Widget? playIcon; + + /// The icon widget to show when the recording is paused. + final Widget? pauseIcon; + + /// The widget to show when the recording is loading. + final Widget? loadingIndicator; + + /// The style for the audio control button. + final ButtonStyle? audioControlButtonStyle; + + /// The text style for the title. + final TextStyle? titleTextStyle; + + /// The text style for the duration. + final TextStyle? durationTextStyle; + + /// The style for the speed control button. + final ButtonStyle? speedControlButtonStyle; + + /// The theme for the audio waveform slider. + final StreamAudioWaveformSliderThemeData? audioWaveformSliderTheme; + + /// A copy of [StreamVoiceRecordingAttachmentThemeData] with specified + /// attributes overridden. + StreamVoiceRecordingAttachmentThemeData copyWith({ + Color? backgroundColor, + Widget? playIcon, + Widget? pauseIcon, + Widget? loadingIndicator, + ButtonStyle? audioControlButtonStyle, + TextStyle? titleTextStyle, + TextStyle? durationTextStyle, + ButtonStyle? speedControlButtonStyle, + StreamAudioWaveformSliderThemeData? audioWaveformSliderTheme, + }) => + StreamVoiceRecordingAttachmentThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + playIcon: playIcon ?? this.playIcon, + pauseIcon: pauseIcon ?? this.pauseIcon, + loadingIndicator: loadingIndicator ?? this.loadingIndicator, + audioControlButtonStyle: + audioControlButtonStyle ?? this.audioControlButtonStyle, + titleTextStyle: titleTextStyle ?? this.titleTextStyle, + durationTextStyle: durationTextStyle ?? this.durationTextStyle, + speedControlButtonStyle: + speedControlButtonStyle ?? this.speedControlButtonStyle, + audioWaveformSliderTheme: + audioWaveformSliderTheme ?? this.audioWaveformSliderTheme, + ); + + /// Merges this [StreamVoiceRecordingAttachmentThemeData] with the [other]. + StreamVoiceRecordingAttachmentThemeData merge( + StreamVoiceRecordingAttachmentThemeData? other, + ) { + if (other == null) return this; + return copyWith( + backgroundColor: other.backgroundColor, + playIcon: other.playIcon, + pauseIcon: other.pauseIcon, + loadingIndicator: other.loadingIndicator, + audioControlButtonStyle: other.audioControlButtonStyle, + titleTextStyle: other.titleTextStyle, + durationTextStyle: other.durationTextStyle, + speedControlButtonStyle: other.speedControlButtonStyle, + audioWaveformSliderTheme: audioWaveformSliderTheme?.merge( + other.audioWaveformSliderTheme, + ), + ); + } + + /// Linearly interpolate between two [StreamVoiceRecordingAttachmentThemeData] + /// objects. + static StreamVoiceRecordingAttachmentThemeData lerp( + StreamVoiceRecordingAttachmentThemeData a, + StreamVoiceRecordingAttachmentThemeData b, + double t, + ) { + return StreamVoiceRecordingAttachmentThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + playIcon: t < 0.5 ? a.playIcon : b.playIcon, + pauseIcon: t < 0.5 ? a.pauseIcon : b.pauseIcon, + loadingIndicator: t < 0.5 ? a.loadingIndicator : b.loadingIndicator, + audioControlButtonStyle: ButtonStyle.lerp( + a.audioControlButtonStyle, b.audioControlButtonStyle, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + durationTextStyle: + TextStyle.lerp(a.durationTextStyle, b.durationTextStyle, t), + speedControlButtonStyle: ButtonStyle.lerp( + a.speedControlButtonStyle, b.speedControlButtonStyle, t), + audioWaveformSliderTheme: StreamAudioWaveformSliderThemeData.lerp( + a.audioWaveformSliderTheme!, b.audioWaveformSliderTheme!, t), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StreamVoiceRecordingAttachmentThemeData && + other.backgroundColor == backgroundColor && + other.playIcon == playIcon && + other.pauseIcon == pauseIcon && + other.loadingIndicator == loadingIndicator && + other.audioControlButtonStyle == audioControlButtonStyle && + other.titleTextStyle == titleTextStyle && + other.durationTextStyle == durationTextStyle && + other.speedControlButtonStyle == speedControlButtonStyle && + other.audioWaveformSliderTheme == audioWaveformSliderTheme; + + @override + int get hashCode => + backgroundColor.hashCode ^ + playIcon.hashCode ^ + pauseIcon.hashCode ^ + loadingIndicator.hashCode ^ + audioControlButtonStyle.hashCode ^ + titleTextStyle.hashCode ^ + durationTextStyle.hashCode ^ + speedControlButtonStyle.hashCode ^ + audioWaveformSliderTheme.hashCode; +} diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index d1e6c5efe2..2e69728631 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:image_size_getter/file_input.dart'; // For compatibility with flutter web. import 'package:image_size_getter/image_size_getter.dart' hide Size; +import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; import 'package:stream_chat_flutter/src/localization/translations.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -134,7 +135,8 @@ extension PlatformFileX on PlatformFile { /// Converts the [PlatformFile] into [AttachmentFile] AttachmentFile get toAttachmentFile { return AttachmentFile( - path: kIsWeb ? null : path, + // Path is not supported on web. + path: CurrentPlatform.isWeb ? null : path, name: name, bytes: bytes, size: size, @@ -170,9 +172,10 @@ extension XFileX on XFile { Future get toAttachmentFile async { final bytes = await readAsBytes(); return AttachmentFile( + // Path is not supported on web. + path: CurrentPlatform.isWeb ? null : path, name: name, size: bytes.length, - path: path, bytes: bytes, ); } @@ -471,15 +474,15 @@ extension FileTypeX on FileType { String toAttachmentType() { switch (this) { case FileType.image: - return 'image'; + return AttachmentType.image; case FileType.video: - return 'video'; + return AttachmentType.video; case FileType.audio: - return 'audio'; + return AttachmentType.audio; case FileType.any: case FileType.media: case FileType.custom: - return 'file'; + return AttachmentType.file; } } } @@ -621,3 +624,72 @@ extension ChannelModelX on ChannelModel { }; } } + +/// {@template voiceRecordingAttachmentExtension} +/// Extension on [Attachment] to provide the voice recording attachment specific +/// properties. +/// {@endtemplate} +extension VoiceRecordingAttachmentExtension on Attachment { + /// Returns the duration of the voice recording attachment if available else + /// returns [Duration.zero]. + Duration get duration { + final duration = extraData['duration'] as num?; + if (duration == null) return Duration.zero; + + return Duration(milliseconds: duration.round() * 1000); + } + + /// Returns the waveform data of the voice recording attachment if available + /// else returns an empty list. + List get waveform { + final waveform = extraData['waveform_data'] as List?; + if (waveform == null) return []; + + return [...waveform.map((e) => double.tryParse(e.toString())).nonNulls]; + } +} + +/// {@template attachmentPlaylistExtension} +/// Extension on [Iterable] to provide the playlist specific +/// properties. +/// {@endtemplate} +extension AttachmentPlaylistExtension on Iterable { + /// Converts the list of attachments to a list of [PlaylistTrack]. + List toPlaylist() { + return [ + ...map((it) { + final uri = switch (it.uploadState) { + Preparing() || InProgress() || Failed() => () { + if (CurrentPlatform.isWeb) { + final bytes = it.file?.bytes; + final mimeType = it.file?.mediaType?.mimeType; + if (bytes == null || mimeType == null) return null; + + return Uri.dataFromBytes(bytes, mimeType: mimeType); + } + + final path = it.file?.path; + if (path == null) return null; + + return Uri.file(path, windows: CurrentPlatform.isWindows); + }(), + Success() => () { + final url = it.assetUrl; + if (url == null) return null; + + return Uri.tryParse(url); + }(), + }; + + if (uri == null) return null; + + return PlaylistTrack( + uri: uri, + title: it.title, + waveform: it.waveform, + duration: it.duration, + ); + }).nonNulls, + ]; + } +} diff --git a/packages/stream_chat_flutter/lib/src/utils/helpers.dart b/packages/stream_chat_flutter/lib/src/utils/helpers.dart index 7cce7fe8b2..991bfc3dfd 100644 --- a/packages/stream_chat_flutter/lib/src/utils/helpers.dart +++ b/packages/stream_chat_flutter/lib/src/utils/helpers.dart @@ -332,28 +332,59 @@ String fileSize(dynamic size, [int round = 2]) { } } -/// +// TODO: Use file extension instead of mime type to get the file type icon. +/// Returns a [StreamSvgIcon] based on the [mimeType] of the file. StreamSvgIcon getFileTypeImage([String? mimeType]) { - final subtype = mimeType?.split('/').last; return StreamSvgIcon( - icon: switch (subtype) { - '7z' => StreamSvgIcons.filetypeCompression7z, - 'csv' => StreamSvgIcons.filetypeCodeCsv, - 'doc' => StreamSvgIcons.filetypeTextDoc, - 'docx' => StreamSvgIcons.filetypeTextDocx, - 'html' => StreamSvgIcons.filetypeCodeHtml, - 'md' => StreamSvgIcons.filetypeCodeMd, - 'odt' => StreamSvgIcons.filetypeTextOdt, - 'pdf' => StreamSvgIcons.filetypeOtherPdf, - 'ppt' => StreamSvgIcons.filetypePresentationPpt, - 'pptx' => StreamSvgIcons.filetypePresentationPptx, - 'rar' => StreamSvgIcons.filetypeCompressionRar, - 'rtf' => StreamSvgIcons.filetypeTextRtf, - 'tar' => StreamSvgIcons.filetypeCodeTar, - 'txt' => StreamSvgIcons.filetypeTextTxt, - 'xls' => StreamSvgIcons.filetypeSpreadsheetXls, - 'xlsx' => StreamSvgIcons.filetypeSpreadsheetXlsx, - 'zip' => StreamSvgIcons.filetypeCompressionZip, + size: 40, + icon: switch (mimeType) { + 'audio/mpeg' => StreamSvgIcons.filetypeAudioMp3, + 'audio/aac' => StreamSvgIcons.filetypeAudioAac, + 'audio/wav' || 'audio/x-wav' => StreamSvgIcons.filetypeAudioWav, + 'audio/flac' => StreamSvgIcons.filetypeAudioFlac, + 'audio/mp4' => StreamSvgIcons.filetypeAudioM4a, + 'audio/ogg' => StreamSvgIcons.filetypeAudioOgg, + 'audio/aiff' => StreamSvgIcons.filetypeAudioAiff, + 'audio/alac' => StreamSvgIcons.filetypeAudioAlac, + 'application/zip' => StreamSvgIcons.filetypeCompressionZip, + 'application/x-7z-compressed' => StreamSvgIcons.filetypeCompression7z, + 'application/x-arj' => StreamSvgIcons.filetypeCompressionArj, + 'application/vnd.debian.binary-package' => + StreamSvgIcons.filetypeCompressionDeb, + 'application/x-apple-diskimage' => StreamSvgIcons.filetypeCompressionPkg, + 'application/x-rar-compressed' => StreamSvgIcons.filetypeCompressionRar, + 'application/x-rpm' => StreamSvgIcons.filetypeCompressionRpm, + 'application/x-tar' => StreamSvgIcons.filetypeCodeTar, + 'application/x-compress' => StreamSvgIcons.filetypeCompressionZ, + 'application/vnd.ms-powerpoint' => StreamSvgIcons.filetypePresentationPpt, + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => + StreamSvgIcons.filetypePresentationPptx, + 'application/vnd.apple.keynote' => StreamSvgIcons.filetypePresentationKey, + 'application/vnd.oasis.opendocument.presentation' => + StreamSvgIcons.filetypePresentationOdp, + 'application/vnd.ms-excel' => StreamSvgIcons.filetypeSpreadsheetXls, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => + StreamSvgIcons.filetypeSpreadsheetXlsx, + 'application/vnd.ms-excel.sheet.macroEnabled.12' => + StreamSvgIcons.filetypeSpreadsheetXlsm, + 'application/vnd.oasis.opendocument.spreadsheet' => + StreamSvgIcons.filetypeSpreadsheetOds, + 'application/msword' => StreamSvgIcons.filetypeTextDoc, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => + StreamSvgIcons.filetypeTextDocx, + 'application/vnd.oasis.opendocument.text' => + StreamSvgIcons.filetypeTextOdt, + 'text/plain' => StreamSvgIcons.filetypeTextTxt, + 'application/rtf' => StreamSvgIcons.filetypeTextRtf, + 'application/x-tex' => StreamSvgIcons.filetypeTextTex, + 'application/vnd.wordperfect' => StreamSvgIcons.filetypeTextWdp, + 'text/html' => StreamSvgIcons.filetypeCodeHtml, + 'text/csv' => StreamSvgIcons.filetypeCodeCsv, + 'application/xml' => StreamSvgIcons.filetypeCodeXml, + 'text/markdown' => StreamSvgIcons.filetypeCodeMd, + 'application/octet-stream' => StreamSvgIcons.filetypeOtherStandard, + 'application/pdf' => StreamSvgIcons.filetypeOtherPdf, + 'application/x-wiki' => StreamSvgIcons.filetypeOtherWkq, _ => StreamSvgIcons.filetypeOtherStandard, }, ); diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index f2f1e8df58..9f42644272 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -23,6 +23,7 @@ export 'src/attachment/thumbnail/thumbnail_error.dart'; export 'src/attachment/thumbnail/video_attachment_thumbnail.dart'; export 'src/attachment/url_attachment.dart'; export 'src/attachment/video_attachment.dart'; +export 'src/attachment/voice_recording_attachment_playlist.dart'; export 'src/attachment_actions_modal/attachment_actions_modal.dart'; export 'src/autocomplete/stream_autocomplete.dart'; export 'src/avatars/gradient_avatar.dart'; @@ -54,6 +55,9 @@ export 'src/localization/translations.dart' show DefaultTranslations; export 'src/message_actions_modal/message_action.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/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'; export 'src/message_input/countdown_button.dart'; export 'src/message_input/enums.dart'; export 'src/message_input/stream_message_input.dart'; @@ -117,7 +121,6 @@ export 'src/stream_chat.dart'; export 'src/stream_chat_configuration.dart'; export 'src/theme/stream_chat_theme.dart'; export 'src/theme/themes.dart'; -export 'src/theme/voice_attachment_theme.dart'; export 'src/user/user_mention_tile.dart'; export 'src/utils/device_segmentation.dart'; export 'src/utils/extensions.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 349b8697d9..a1cb007375 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: photo_manager: ^3.2.0 photo_view: ^0.15.0 rate_limiter: ^1.0.0 + record: ^5.2.0 rxdart: ^0.28.0 share_plus: ^10.0.2 shimmer: ^3.0.0 @@ -69,6 +70,8 @@ dev_dependencies: sdk: flutter mocktail: ^1.0.0 path: ^1.8.3 + path_provider_platform_interface: ^2.0.0 + plugin_platform_interface: ^2.0.0 flutter: assets: diff --git a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player_test.dart b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player_test.dart deleted file mode 100644 index 39e57365ce..0000000000 --- a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - group('StreamVoiceRecordingListPlayer', () { - const totalDuration = Duration(seconds: 20); - - testWidgets('should show the loading widget', (tester) async { - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StreamChatTheme( - data: StreamChatThemeData( - voiceRecordingTheme: StreamVoiceRecordingThemeData.dark(), - ), - child: const StreamVoiceRecordingListPlayer( - playList: [ - PlayListItem( - duration: totalDuration, - waveForm: [0.1, 0.2, 0.3], - ), - ], - ), - ), - ), - ); - - expect(find.byType(StreamVoiceRecordingLoading), findsOneWidget); - }); - - testWidgets('should show the player widget', (tester) async { - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StreamChatTheme( - data: StreamChatThemeData( - voiceRecordingTheme: StreamVoiceRecordingThemeData.dark(), - ), - child: const StreamVoiceRecordingListPlayer( - playList: [ - PlayListItem( - assetUrl: 'url', - duration: totalDuration, - waveForm: [0.1, 0.2, 0.3], - ), - ], - ), - ), - ), - ); - - expect(find.byType(StreamVoiceRecordingPlayer), findsOneWidget); - }); - }); -} diff --git a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading_test.dart b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading_test.dart deleted file mode 100644 index 174cebe057..0000000000 --- a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - group('StreamVoiceRecordingLoading', () { - testWidgets('should show a progress indicator', (tester) async { - await tester.pumpWidget( - StreamChatTheme( - data: StreamChatThemeData( - voiceRecordingTheme: StreamVoiceRecordingThemeData.dark(), - ), - child: const StreamVoiceRecordingLoading(), - ), - ); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); - }); - }); -} diff --git a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player_test.dart b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player_test.dart deleted file mode 100644 index 1cb95a4411..0000000000 --- a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player_test.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -class MockAudioPlayer extends Mock implements AudioPlayer { - @override - Future dispose() async {} -} - -void main() { - group('StreamVoiceRecordingPlayer', () { - const totalDuration = Duration(seconds: 20); - - testWidgets('should show the total duration', (tester) async { - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StreamChatTheme( - data: StreamChatThemeData( - voiceRecordingTheme: StreamVoiceRecordingThemeData.dark(), - ), - child: StreamVoiceRecordingPlayer( - player: AudioPlayer(), - duration: totalDuration, - ), - ), - ), - ); - - expect(find.text(totalDuration.toMinutesAndSeconds()), findsOneWidget); - }); - - testWidgets('should show the current duration', (tester) async { - const aSecondLater = Duration(seconds: 1); - final durationStream = StreamController.broadcast(); - final audioPlayer = MockAudioPlayer(); - when(() => audioPlayer.positionStream) - .thenAnswer((_) => durationStream.stream); - when(() => audioPlayer.playing).thenReturn(true); - when(() => audioPlayer.playingStream) - .thenAnswer((_) => Stream.value(true)); - when(() => audioPlayer.currentIndex).thenReturn(0); - when(() => audioPlayer.currentIndexStream) - .thenAnswer((_) => Stream.value(0)); - when(() => audioPlayer.playerStateStream).thenAnswer( - (_) => Stream.value(PlayerState(true, ProcessingState.completed))); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StreamChatTheme( - data: StreamChatThemeData( - voiceRecordingTheme: StreamVoiceRecordingThemeData.dark(), - ), - child: StreamVoiceRecordingPlayer( - player: audioPlayer, - duration: totalDuration, - ), - ), - ), - ); - await tester.pump(const Duration(milliseconds: 200)); - durationStream.add(aSecondLater); - await tester.pump(const Duration(milliseconds: 200)); - expect(find.text(aSecondLater.toMinutesAndSeconds()), findsOneWidget); - durationStream.close(); - }); - - testWidgets('should show the file size if passed', (tester) async { - const fileSize = 1024; - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StreamChatTheme( - data: StreamChatThemeData( - voiceRecordingTheme: StreamVoiceRecordingThemeData.dark(), - ), - child: StreamVoiceRecordingPlayer( - player: AudioPlayer(), - duration: totalDuration, - fileSize: fileSize, - ), - ), - ), - ); - - expect(find.text(fileSize.toHumanReadableSize()), findsOneWidget); - }); - - testWidgets('should show the default speed value', (tester) async { - final audioPlayer = MockAudioPlayer(); - - when(() => audioPlayer.positionStream) - .thenAnswer((_) => Stream.value(const Duration(milliseconds: 100))); - when(() => audioPlayer.playingStream) - .thenAnswer((_) => Stream.value(true)); - when(() => audioPlayer.playing).thenReturn(true); - when(() => audioPlayer.currentIndex).thenReturn(0); - when(() => audioPlayer.currentIndexStream) - .thenAnswer((_) => Stream.value(0)); - when(() => audioPlayer.speedStream).thenAnswer( - (_) => Stream.value(1), - ); - when(() => audioPlayer.playerStateStream).thenAnswer( - (_) => Stream.value(PlayerState(true, ProcessingState.completed))); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StreamChatTheme( - data: StreamChatThemeData( - voiceRecordingTheme: StreamVoiceRecordingThemeData.dark(), - ), - child: StreamVoiceRecordingPlayer( - player: audioPlayer, - duration: totalDuration, - ), - ), - ), - ); - - await tester.pump(const Duration(milliseconds: 200)); - - expect(find.text('1.0x'), findsOneWidget); - }); - }); -} diff --git a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder_test.dart b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder_test.dart deleted file mode 100644 index 158d6e02cc..0000000000 --- a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder_test.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../../../mocks.dart'; - -void main() { - group('VoiceRecordingAttachmentBuilder', () { - test('should handle voiceRecording attachment type', () { - final builder = VoiceRecordingAttachmentBuilder(); - final message = MocMessage(); - final attachments = { - 'voiceRecording': [Attachment()], - }; - - expect(builder.canHandle(message, attachments), true); - }); - - test('should not handle other than voiceRecording attachment type', () { - final builder = VoiceRecordingAttachmentBuilder(); - final message = MocMessage(); - final attachments = { - 'gify': [Attachment()], - }; - - expect(builder.canHandle(message, attachments), false); - }); - - testWidgets('should build StreamVoiceRecordingListPlayer', (tester) async { - final builder = VoiceRecordingAttachmentBuilder(); - final message = MocMessage(); - final attachments = { - 'voiceRecording': [Attachment()], - }; - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StreamChatTheme( - data: StreamChatThemeData( - voiceRecordingTheme: StreamVoiceRecordingThemeData.dark(), - ), - child: Builder(builder: (context) { - return builder.build(context, message, attachments); - }), - ), - ), - ); - - expect(find.byType(StreamVoiceRecordingListPlayer), findsOneWidget); - }); - }); -} diff --git a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_playlist_builder.dart b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_playlist_builder.dart new file mode 100644 index 0000000000..eef7c8279e --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_playlist_builder.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + group( + 'VoiceRecordingAttachmentPlaylistBuilder', + () { + const builder = VoiceRecordingAttachmentPlaylistBuilder(); + + test('canHandle returns true when voice recordings exist', () { + final attachments = { + AttachmentType.voiceRecording: [Attachment(), Attachment()], + }; + + expect(builder.canHandle(Message(), attachments), isTrue); + }); + + test('canHandle returns false when no voice recordings exist', () { + final attachments = >{}; + + expect(builder.canHandle(Message(), attachments), isFalse); + }); + + test('canHandle returns false for other attachment types', () { + final attachments = { + AttachmentType.image: [Attachment()], + AttachmentType.giphy: [Attachment()], + AttachmentType.video: [Attachment()], + }; + + expect(builder.canHandle(Message(), attachments), isFalse); + }); + + test('canHandle returns false when voice recording list is empty', () { + final attachments = {AttachmentType.voiceRecording: []}; + + expect(builder.canHandle(Message(), attachments), isFalse); + }); + + testWidgets( + 'build returns correct widget', + (WidgetTester tester) async { + final attachments = { + AttachmentType.voiceRecording: [Attachment(), Attachment()], + }; + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) => builder.build( + context, + Message(), + attachments, + ), + ), + ), + ); + + expect( + find.byType(StreamVoiceRecordingAttachmentPlaylist), + findsOneWidget, + ); + }, + ); + }, + ); +} + +Widget _wrapWithStreamChatApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_dark.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_dark.png new file mode 100644 index 0000000000..cf951c9a8d Binary files /dev/null and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_light.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_light.png new file mode 100644 index 0000000000..247512100d Binary files /dev/null and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_light.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_dark.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_dark.png new file mode 100644 index 0000000000..7a927e33ca Binary files /dev/null and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_light.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_light.png new file mode 100644 index 0000000000..8e26369492 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_light.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_dark.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_dark.png new file mode 100644 index 0000000000..c00c4952f6 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_light.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_light.png new file mode 100644 index 0000000000..d4e41db472 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_light.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart b/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart new file mode 100644 index 0000000000..0f6786f3bb --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart @@ -0,0 +1,268 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/attachment/voice_recording_attachment.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + group('StreamVoiceRecordingAttachmentPlaylist', () { + final fakeAudioRecording1 = Attachment( + type: AttachmentType.voiceRecording, + title: 'test1.m4a', + file: AttachmentFile( + size: 10000, + path: 'voice_recordings/test1.m4a', + ), + extraData: { + 'duration': 300, + 'waveform_data': List.filled(50, 0.5), + }, + ); + + final fakeAudioRecording2 = Attachment( + type: AttachmentType.voiceRecording, + title: 'test2.m4a', + file: AttachmentFile( + size: 30000, + path: 'voice_recordings/test2.m4a', + ), + extraData: { + 'duration': 900, + 'waveform_data': List.filled(50, 0.5), + }, + ); + + testWidgets( + 'renders playlist with correct number of attachments', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: [fakeAudioRecording1, fakeAudioRecording2], + ), + ), + ); + + expect(find.byType(StreamVoiceRecordingAttachment), findsNWidgets(2)); + }, + ); + + testWidgets( + 'uses custom shape when provided', + (WidgetTester tester) async { + final customShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: [fakeAudioRecording1], + shape: customShape, + ), + ), + ); + + expect(find.byType(StreamVoiceRecordingAttachment), findsOneWidget); + + final attachment = tester.widget( + find.byType(StreamVoiceRecordingAttachment), + ); + + expect(attachment.shape, customShape); + }, + ); + + testWidgets( + 'updates playlist when recordings change', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: [fakeAudioRecording1], + ), + ), + ); + + expect(find.byType(StreamVoiceRecordingAttachment), findsOneWidget); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + // Add a new recording + voiceRecordings: [fakeAudioRecording1, fakeAudioRecording2], + ), + ), + ); + + expect(find.byType(StreamVoiceRecordingAttachment), findsNWidgets(2)); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + // Add a new recording + voiceRecordings: [fakeAudioRecording1], + ), + ), + ); + + expect(find.byType(StreamVoiceRecordingAttachment), findsNWidgets(1)); + }, + ); + + testWidgets( + 'respects provided constraints', + (WidgetTester tester) async { + const constraints = BoxConstraints( + minWidth: 100, + maxWidth: 300, + minHeight: 50, + maxHeight: 200, + ); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: [fakeAudioRecording1], + constraints: constraints, + ), + ), + ); + + expect(find.byType(StreamVoiceRecordingAttachment), findsOneWidget); + + final attachment = tester.widget( + find.byType(StreamVoiceRecordingAttachment), + ); + + expect(attachment.constraints, constraints); + }, + ); + + testWidgets( + 'respects provided padding', + (WidgetTester tester) async { + const padding = EdgeInsets.all(16); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: [fakeAudioRecording1], + padding: padding, + ), + ), + ); + + expect(find.byType(StreamVoiceRecordingAttachment), findsOneWidget); + + final playlist = tester.widget( + find.ancestor( + of: find.byType(StreamVoiceRecordingAttachment), + matching: find.byType(ListView), + ), + ); + + expect(playlist.padding, padding); + }, + ); + + testWidgets( + 'allows custom item', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: [fakeAudioRecording1], + itemBuilder: (context, index) { + return const Text('Custom Item'); + }, + ), + ), + ); + + expect(find.byType(Text), findsOneWidget); + expect(find.text('Custom Item'), findsOneWidget); + }, + ); + + testWidgets( + 'allows custom separator', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: [fakeAudioRecording1, fakeAudioRecording2], + separatorBuilder: (context, index) => const Divider( + color: Colors.red, + ), + ), + ), + ); + + expect(find.byType(Divider), findsNWidgets(1)); + }, + ); + + testWidgets( + 'handles empty voice recordings', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: const [], + ), + ), + ); + + expect(find.byType(StreamVoiceRecordingAttachment), findsNothing); + }, + ); + for (final brightness in Brightness.values) { + final theme = brightness.name; + goldenTest( + '[$theme] -> should look fine', + fileName: 'stream_voice_recording_attachment_playlist_$theme', + constraints: const BoxConstraints.tightFor(width: 412, height: 400), + builder: () => _wrapWithStreamChatApp( + brightness: brightness, + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: [fakeAudioRecording1, fakeAudioRecording2], + padding: const EdgeInsets.all(16), + separatorBuilder: (context, index) => const SizedBox(height: 8), + ), + ), + ); + } + }); +} + +Widget _wrapWithStreamChatApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + 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/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_test.dart new file mode 100644 index 0000000000..ef95e4b220 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_test.dart @@ -0,0 +1,297 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/attachment/voice_recording_attachment.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; +import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; +import '../utils/finders.dart'; + +void main() { + group( + 'StreamVoiceRecordingAttachment', + () { + final fakePlaylistTrack = PlaylistTrack( + title: 'test.m4a', + uri: Uri.file('voice_recordings/test.m4a'), + duration: const Duration(seconds: 30), + waveform: List.filled(50, 0.5), + ); + + testWidgets( + 'renders basic components', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachment( + track: fakePlaylistTrack, + speed: PlaybackSpeed.regular, + ), + ), + ); + + // Verify key components are present + expect(find.byType(AudioControlButton), findsOneWidget); + expect(find.byType(StreamAudioWaveformSlider), findsOneWidget); + expect( + find.bySvgIcon(StreamSvgIcons.filetypeAudioM4a), findsOneWidget); + }, + ); + + testWidgets( + 'shows title when enabled', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachment( + showTitle: true, + track: fakePlaylistTrack, + speed: PlaybackSpeed.regular, + ), + ), + ); + + // Verify key components are present + expect(find.text('test.m4a'), findsOneWidget); + expect(find.byType(AudioTitleText), findsOneWidget); + }, + ); + + testWidgets( + 'shows title when enabled', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachment( + showTitle: true, + track: fakePlaylistTrack, + speed: PlaybackSpeed.regular, + ), + ), + ); + + // Verify key components are present + expect(find.text('test.m4a'), findsOneWidget); + expect(find.byType(AudioTitleText), findsOneWidget); + }, + ); + + testWidgets( + 'shows control speed button when playing', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachment( + track: fakePlaylistTrack.copyWith(state: TrackState.playing), + speed: PlaybackSpeed.regular, + ), + ), + ); + + expect(find.text('x1.0'), findsOneWidget); + expect(find.byType(SpeedControlButton), findsOneWidget); + }, + ); + + testWidgets( + 'handles track play callback', + (WidgetTester tester) async { + for (final state in [TrackState.idle, TrackState.paused]) { + final onTrackPlay = MockVoidCallback(); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachment( + track: fakePlaylistTrack.copyWith(state: state), + speed: PlaybackSpeed.regular, + onTrackPlay: onTrackPlay, + ), + ), + ); + + // Simulate play/pause button tap + await tester.tap(find.byType(AudioControlButton)); + + // Verify callbacks + verify(onTrackPlay).called(1); + } + }, + ); + + testWidgets( + 'handles track pause callback', + (WidgetTester tester) async { + final onTrackPause = MockVoidCallback(); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachment( + track: fakePlaylistTrack.copyWith(state: TrackState.playing), + speed: PlaybackSpeed.regular, + onTrackPause: onTrackPause, + ), + ), + ); + + // Simulate play/pause button tap + await tester.tap(find.byType(AudioControlButton)); + + // Verify callbacks + verify(onTrackPause).called(1); + }, + ); + + testWidgets( + 'handles track seek callback', + (WidgetTester tester) async { + final onTrackSeekStart = MockValueChanged(); + final onTrackSeekChanged = MockValueChanged(); + final onTrackSeekEnd = MockValueChanged(); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachment( + track: fakePlaylistTrack.copyWith(state: TrackState.playing), + speed: PlaybackSpeed.regular, + onTrackSeekStart: onTrackSeekStart, + onTrackSeekChanged: onTrackSeekChanged, + onTrackSeekEnd: onTrackSeekEnd, + ), + ), + ); + + final sliderFinder = find.byType(StreamAudioWaveformSlider); + final topLeft = tester.getTopLeft(sliderFinder); + final sliderSize = tester.getSize(sliderFinder); + + // Start gesture + final gesture = await tester.startGesture(topLeft); + verify(() => onTrackSeekStart(0)).called(1); + + // Move gesture to the middle of the slider + await gesture.moveBy(Offset(sliderSize.width * 0.5, 0)); + verify(() => onTrackSeekChanged(0.5)).called(1); + + // Move gesture to the end of the slider + await gesture.moveBy(Offset(sliderSize.width, 0)); + verify(() => onTrackSeekChanged(1)).called(1); + + // End gesture + await gesture.up(); + verify(() => onTrackSeekEnd(1)).called(1); + }, + ); + + testWidgets( + 'handles speed change callback', + (WidgetTester tester) async { + for (final speed in PlaybackSpeed.values) { + final onChangeSpeed = MockValueChanged(); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachment( + track: fakePlaylistTrack.copyWith(state: TrackState.playing), + speed: speed, + onChangeSpeed: onChangeSpeed, + ), + ), + ); + + await tester.tap(find.byType(SpeedControlButton)); + verify(() => onChangeSpeed(speed.next)).called(1); + } + }, + ); + + testWidgets( + 'custom trailing builder works', + (WidgetTester tester) async { + Widget customTrailingBuilder( + BuildContext context, + PlaylistTrack track, + PlaybackSpeed speed, + ValueChanged? onChangeSpeed, + ) { + return const StreamSvgIcon(icon: StreamSvgIcons.closeSmall); + } + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachment( + track: fakePlaylistTrack, + speed: PlaybackSpeed.regular, + trailingBuilder: customTrailingBuilder, + ), + ), + ); + + // Verify custom trailing widget is rendered + expect(find.bySvgIcon(StreamSvgIcons.closeSmall), findsOneWidget); + expect(find.bySvgIcon(StreamSvgIcons.filetypeAudioM4a), findsNothing); + }, + ); + + for (final brightness in Brightness.values) { + final theme = brightness.name; + goldenTest( + '[$theme] -> should look fine in idle state', + fileName: 'stream_voice_recording_attachment_idle_$theme', + constraints: const BoxConstraints.tightFor(width: 412, height: 200), + builder: () => _wrapWithStreamChatApp( + brightness: brightness, + Padding( + padding: const EdgeInsets.all(8), + child: StreamVoiceRecordingAttachment( + showTitle: true, + track: fakePlaylistTrack, + speed: PlaybackSpeed.regular, + ), + ), + ), + ); + + goldenTest( + '[$theme] -> should look fine in playing state', + fileName: 'stream_voice_recording_attachment_playing_$theme', + constraints: const BoxConstraints.tightFor(width: 412, height: 200), + builder: () => _wrapWithStreamChatApp( + brightness: brightness, + Padding( + padding: const EdgeInsets.all(8), + child: StreamVoiceRecordingAttachment( + showTitle: true, + track: fakePlaylistTrack.copyWith( + state: TrackState.playing, + position: const Duration(seconds: 10), + ), + speed: PlaybackSpeed.regular, + ), + ), + ), + ); + } + }, + ); +} + +Widget _wrapWithStreamChatApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/audio/audio_playlist_controller_test.dart b/packages/stream_chat_flutter/test/src/audio/audio_playlist_controller_test.dart new file mode 100644 index 0000000000..2a5cb09b5c --- /dev/null +++ b/packages/stream_chat_flutter/test/src/audio/audio_playlist_controller_test.dart @@ -0,0 +1,244 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; + +class MockAudioPlayer extends Mock implements AudioPlayer {} + +class FakeAudioSource extends Fake implements AudioSource {} + +void main() { + group('StreamAudioPlaylistController', () { + late MockAudioPlayer mockPlayer; + late StreamAudioPlaylistController controller; + late PublishSubject stateController; + late PublishSubject positionController; + late PublishSubject speedController; + + final fakeTrack1 = PlaylistTrack( + title: 'test1.m4a', + uri: Uri.file('voice_recordings/test1.m4a'), + waveform: List.filled(50, 0.5), + duration: const Duration(seconds: 300), + ); + + final fakeTrack2 = PlaylistTrack( + title: 'test2.m4a', + uri: Uri.file('voice_recordings/test2.m4a'), + waveform: List.filled(50, 0.5), + duration: const Duration(seconds: 900), + ); + + Future _setAudioSource() { + return mockPlayer.setAudioSource( + any(), + initialPosition: any(named: 'initialPosition'), + ); + } + + setUpAll(() { + registerFallbackValue(Duration.zero); + registerFallbackValue(FakeAudioSource()); + }); + + setUp(() { + mockPlayer = MockAudioPlayer(); + when(() => mockPlayer.dispose()).thenAnswer((_) async {}); + + stateController = PublishSubject(); + positionController = PublishSubject(); + speedController = PublishSubject(); + + // Default mock behaviors + when(() => mockPlayer.playerStateStream) + .thenAnswer((_) => stateController.stream); + when(() => mockPlayer.positionStream) + .thenAnswer((_) => positionController.stream); + when(() => mockPlayer.speedStream) + .thenAnswer((_) => speedController.stream); + + controller = StreamAudioPlaylistController.raw( + player: mockPlayer, + state: AudioPlaylistState(tracks: [fakeTrack1, fakeTrack2]), + )..initialize(); + }); + + tearDown(() { + stateController.close(); + positionController.close(); + speedController.close(); + controller.dispose(); + }); + + test('controller initializes with correct default state', () { + expect(controller.value.tracks.length, equals(2)); + expect(controller.value.currentIndex, isNull); + expect(controller.value.speed, equals(PlaybackSpeed.regular)); + expect(controller.value.loopMode, equals(PlaylistLoopMode.off)); + }); + + group('Track Navigation', () { + test('skipToItem selects correct track', () async { + when(_setAudioSource).thenAnswer((_) async => null); + when(() => mockPlayer.play()).thenAnswer((_) async {}); + + await controller.skipToItem(1); + + expect(controller.value.currentIndex, equals(1)); + verify(_setAudioSource).called(1); + verify(() => mockPlayer.play()).called(1); + }); + + test('skipToNext cycles through tracks', () async { + when(_setAudioSource).thenAnswer((_) async => null); + when(() => mockPlayer.play()).thenAnswer((_) async {}); + + await controller.skipToItem(0); + await controller.skipToNext(); + + expect(controller.value.currentIndex, equals(1)); + verify(_setAudioSource).called(2); + verify(() => mockPlayer.play()).called(2); + }); + + test('skipToPrevious cycles through tracks', () async { + when(_setAudioSource).thenAnswer((_) async => null); + when(() => mockPlayer.play()).thenAnswer((_) async {}); + + await controller.skipToItem(1); + await controller.skipToPrevious(); + + expect(controller.value.currentIndex, equals(0)); + verify(_setAudioSource).called(2); + verify(() => mockPlayer.play()).called(2); + }); + }); + + group('Playlist Management', () { + test('updatePlaylist replaces tracks', () async { + when(() => mockPlayer.stop()).thenAnswer((_) async {}); + + final newTracks = [ + PlaylistTrack( + title: 'new-track.mp3', + uri: Uri.parse('https://example.com/new-track.mp3'), + ) + ]; + + await controller.updatePlaylist(newTracks); + + expect(controller.value.currentIndex, isNull); + expect(controller.value.tracks, equals(newTracks)); + verify(() => mockPlayer.stop()).called(1); + }); + + test('setLoopMode updates playlist loop configuration', () async { + await controller.setLoopMode(PlaylistLoopMode.all); + expect(controller.value.loopMode, equals(PlaylistLoopMode.all)); + + await controller.setLoopMode(PlaylistLoopMode.one); + expect(controller.value.loopMode, equals(PlaylistLoopMode.one)); + + await controller.setLoopMode(PlaylistLoopMode.off); + expect(controller.value.loopMode, equals(PlaylistLoopMode.off)); + }); + }); + + group('Playback Control', () { + test('play invokes audio player', () async { + when(() => mockPlayer.play()).thenAnswer((_) async {}); + + await controller.play(); + + verify(() => mockPlayer.play()).called(1); + }); + + test('pause stops playback', () async { + when(() => mockPlayer.pause()).thenAnswer((_) async {}); + + await controller.pause(); + + verify(() => mockPlayer.pause()).called(1); + }); + + test('setSpeed changes playback rate', () async { + when(() => mockPlayer.setSpeed(any())).thenAnswer((_) async {}); + + const playbackSpeed = PlaybackSpeed.faster; + await controller.setSpeed(playbackSpeed); + + verify(() => mockPlayer.setSpeed(playbackSpeed.speed)).called(1); + }); + }); + + group('Stream Interactions', () { + setUp(() { + when(_setAudioSource).thenAnswer((_) async => null); + when(() => mockPlayer.play()).thenAnswer((_) async {}); + when(() => mockPlayer.pause()).thenAnswer((_) async {}); + when(() => mockPlayer.seek(any())).thenAnswer((_) async {}); + }); + + test('playerStateStream updates track state', () async { + await controller.skipToItem(0); + + // Simulate track idle + stateController.add(PlayerState(false, ProcessingState.idle)); + await Future.delayed(Duration.zero); + expect(controller.value.tracks[0].state, equals(TrackState.idle)); + + // Simulate track loading + stateController.add(PlayerState(false, ProcessingState.loading)); + await Future.delayed(Duration.zero); + expect(controller.value.tracks[0].state, equals(TrackState.loading)); + + // Simulate track paused + stateController.add(PlayerState(false, ProcessingState.ready)); + await Future.delayed(Duration.zero); + expect(controller.value.tracks[0].state, equals(TrackState.paused)); + + // Simulate track playing + stateController.add(PlayerState(true, ProcessingState.ready)); + await Future.delayed(Duration.zero); + expect(controller.value.tracks[0].state, equals(TrackState.playing)); + }); + + test('positionStream updates track position', () async { + await controller.skipToItem(0); + + const testPosition = Duration(seconds: 30); + positionController.add(testPosition); + await Future.delayed(Duration.zero); + + expect(controller.value.tracks[0].position, equals(testPosition)); + }); + + test('speedStream updates playback speed', () async { + speedController.add(1.5); + await Future.delayed(Duration.zero); + expect(controller.value.speed, equals(PlaybackSpeed.faster)); + + speedController.add(2); + await Future.delayed(Duration.zero); + expect(controller.value.speed, equals(PlaybackSpeed.fastest)); + + speedController.add(1); + await Future.delayed(Duration.zero); + expect(controller.value.speed, equals(PlaybackSpeed.regular)); + }); + + test('track completes and auto-advances', () async { + await controller.skipToItem(0); + + // Simulate track completion + stateController.add(PlayerState(true, ProcessingState.completed)); + await Future.delayed(Duration.zero); + + // Verify auto-advance occurs + expect(controller.value.currentIndex, equals(1)); + }); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/audio/audio_sampling_test.dart b/packages/stream_chat_flutter/test/src/audio/audio_sampling_test.dart new file mode 100644 index 0000000000..a1f2918274 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/audio/audio_sampling_test.dart @@ -0,0 +1,120 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/audio/audio_sampling.dart'; + +void main() { + group('resampleWaveformData', () { + test('returns original data when target size equals input size', () { + final input = [1.0, 2.0, 3.0, 4.0]; + expect(resampleWaveformData(input, 4), equals(input)); + }); + + test('downsamples when target size is smaller', () { + final input = List.generate(10, (i) => i.toDouble()); + final result = resampleWaveformData(input, 5); + expect(result.length, equals(5)); + // First and last points should be preserved + expect(result.first, equals(input.first)); + expect(result.last, equals(input.last)); + }); + + test('upsamples when target size is larger', () { + final input = [1.0, 2.0, 3.0]; + final result = resampleWaveformData(input, 6); + expect(result.length, equals(6)); + // Check if values are repeated appropriately + expect(result.where((x) => x == 1.0).length, equals(2)); + expect(result.where((x) => x == 2.0).length, equals(2)); + expect(result.where((x) => x == 3.0).length, equals(2)); + }); + }); + + group('downSample', () { + test('handles single point target size', () { + final input = [1.0, 2.0, 3.0, 4.0]; + final result = downSample(input, 1); + expect(result.length, equals(1)); + expect(result[0], equals(2.5)); // Mean of all values + }); + + test('preserves first and last points', () { + final input = List.generate(100, (i) => i.toDouble()); + final result = downSample(input, 10); + expect(result.first, equals(input.first)); + expect(result.last, equals(input.last)); + expect(result.length, equals(10)); + }); + + test('returns original data when target size is larger', () { + final input = [1.0, 2.0, 3.0]; + expect(downSample(input, 5), equals(input)); + }); + + test('handles empty input', () { + expect(downSample([], 5), isEmpty); + }); + }); + + group('upSample', () { + test('handles empty input', () { + expect(upSample([], 5), equals([0.0, 0.0, 0.0, 0.0, 0.0])); + }); + + test('maintains original values when target size equals input size', () { + final input = [1.0, 2.0, 3.0]; + expect(upSample(input, 3), equals(input)); + }); + + test('correctly distributes remainder', () { + final input = [1.0, 2.0, 3.0]; + final result = upSample(input, 7); + expect(result.length, equals(7)); + // Check if the remainder is distributed correctly + final countMap = {}; + for (final value in result) { + countMap[value] = (countMap[value] ?? 0) + 1; + } + // Each value should appear either 2 or 3 times + expect( + countMap.values.every((count) => count == 2 || count == 3), isTrue); + }); + + test('returns original data when target size is smaller', () { + final input = [1.0, 2.0, 3.0, 4.0]; + expect(upSample(input, 2), equals(input)); + }); + }); + + group('edge cases', () { + test('handles negative values', () { + final input = [-1.0, -2.0, -3.0, -4.0]; + final result = resampleWaveformData(input, 2); + expect(result.length, equals(2)); + expect(result.every((x) => x < 0), isTrue); + }); + + test('handles repeated values', () { + final input = [1.0, 1.0, 1.0, 1.0]; + final result = resampleWaveformData(input, 2); + expect(result.length, equals(2)); + expect(result.every((x) => x == 1.0), isTrue); + }); + + test('handles very large numbers', () { + final input = List.generate(5, (i) => pow(10, i).toDouble()); + final result = resampleWaveformData(input, 3); + expect(result.length, equals(3)); + expect(result.first, equals(input.first)); + expect(result.last, equals(input.last)); + }); + + test('handles very small numbers', () { + final input = List.generate(5, (i) => pow(0.1, i).toDouble()); + final result = resampleWaveformData(input, 3); + expect(result.length, equals(3)); + expect(result.first, equals(input.first)); + expect(result.last, equals(input.last)); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/edit_message_sheet_0.png b/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/edit_message_sheet_0.png index bd698238dd..035e74551f 100644 Binary files a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/edit_message_sheet_0.png and b/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/edit_message_sheet_0.png differ diff --git a/packages/stream_chat_flutter/test/src/fakes.dart b/packages/stream_chat_flutter/test/src/fakes.dart new file mode 100644 index 0000000000..40e7f1fd2d --- /dev/null +++ b/packages/stream_chat_flutter/test/src/fakes.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +const String kTemporaryPath = 'temporaryPath'; +const String kApplicationSupportPath = 'applicationSupportPath'; +const String kDownloadsPath = 'downloadsPath'; +const String kLibraryPath = 'libraryPath'; +const String kApplicationDocumentsPath = 'applicationDocumentsPath'; +const String kExternalCachePath = 'externalCachePath'; +const String kExternalStoragePath = 'externalStoragePath'; + +class FakePathProviderPlatform extends Fake + with MockPlatformInterfaceMixin + implements PathProviderPlatform { + @override + Future getTemporaryPath() async { + return kTemporaryPath; + } + + @override + Future getApplicationSupportPath() async { + return kApplicationSupportPath; + } + + @override + Future getLibraryPath() async { + return kLibraryPath; + } + + @override + Future getApplicationDocumentsPath() async { + return kApplicationDocumentsPath; + } + + @override + Future getExternalStoragePath() async { + return kExternalStoragePath; + } + + @override + Future?> getExternalCachePaths() async { + return [kExternalCachePath]; + } + + @override + Future?> getExternalStoragePaths({ + StorageDirectory? type, + }) async { + return [kExternalStoragePath]; + } + + @override + Future getDownloadsPath() async { + return kDownloadsPath; + } +} + +class AllNullFakePathProviderPlatform extends Fake + with MockPlatformInterfaceMixin + implements PathProviderPlatform { + @override + Future getTemporaryPath() async { + return null; + } + + @override + Future getApplicationSupportPath() async { + return null; + } + + @override + Future getLibraryPath() async { + return null; + } + + @override + Future getApplicationDocumentsPath() async { + return null; + } + + @override + Future getExternalStoragePath() async { + return null; + } + + @override + Future?> getExternalCachePaths() async { + return null; + } + + @override + Future?> getExternalStoragePaths({ + StorageDirectory? type, + }) async { + return null; + } + + @override + Future getDownloadsPath() async { + return null; + } +} diff --git a/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_dark.png b/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_dark.png index 20eaab0003..e498b63bf8 100644 Binary files a/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_dark.png and b/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_light.png b/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_light.png index 59d6930a86..3e82952ab4 100644 Binary files a/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_light.png and b/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_light.png differ diff --git a/packages/stream_chat_flutter/test/src/icons/stream_svg_icon_test.dart b/packages/stream_chat_flutter/test/src/icons/stream_svg_icon_test.dart index 6d11d93fd6..5c4fc86d9c 100644 --- a/packages/stream_chat_flutter/test/src/icons/stream_svg_icon_test.dart +++ b/packages/stream_chat_flutter/test/src/icons/stream_svg_icon_test.dart @@ -86,6 +86,12 @@ void main() { _buildIcon(StreamSvgIcons.files), _buildIcon(StreamSvgIcons.reload), _buildIcon(StreamSvgIcons.down), + _buildIcon(StreamSvgIcons.lock), + _buildIcon(StreamSvgIcons.mic), + _buildIcon(StreamSvgIcons.pause), + _buildIcon(StreamSvgIcons.play), + _buildIcon(StreamSvgIcons.stop), + _buildIcon(StreamSvgIcons.link), ], ), ), diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/assets/audio.m4a b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/assets/audio.m4a new file mode 100644 index 0000000000..8134b16b9e Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/assets/audio.m4a differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart new file mode 100644 index 0000000000..86ad93a548 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart @@ -0,0 +1,229 @@ +// ignore_for_file: cascade_invocations + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:record/record.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/src/message_input/audio_recorder/audio_recorder_controller.dart'; +import 'package:stream_chat_flutter/src/message_input/audio_recorder/audio_recorder_state.dart'; + +import '../../fakes.dart'; + +class MockAudioRecorder extends Mock implements AudioRecorder {} + +class MockAmplitude extends Mock implements Amplitude {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockAudioRecorder mockRecorder; + const config = RecordConfig(numChannels: 1); + late StreamAudioRecorderController controller; + late PublishSubject amplitudeController; + + setUpAll(() { + registerFallbackValue(Duration.zero); + }); + + setUp(() { + PathProviderPlatform.instance = FakePathProviderPlatform(); + + mockRecorder = MockAudioRecorder(); + when(() => mockRecorder.dispose()).thenAnswer((_) async {}); + + amplitudeController = PublishSubject(); + when(() => mockRecorder.onAmplitudeChanged(any())) + .thenAnswer((_) => amplitudeController.stream); + + controller = StreamAudioRecorderController.raw( + config: config, + recorder: mockRecorder, + ); + }); + + tearDown(() { + amplitudeController.close(); + controller.dispose(); + }); + + test('initial state should be idle', () { + expect(controller.value, isA()); + }); + + group('startRecord', () { + setUp(() { + when(() => mockRecorder.start(config, path: any(named: 'path'))) + .thenAnswer((_) async {}); + }); + + test( + 'starts recording when permission is granted', + () async { + when(() => mockRecorder.hasPermission()).thenAnswer((_) async => true); + + await controller.startRecord(); + + expect(controller.value, isA()); + verify(() => mockRecorder.start(config, path: any(named: 'path'))); + }, + ); + + test('does not start recording when permission is denied', () async { + when(() => mockRecorder.hasPermission()).thenAnswer((_) async => false); + + await controller.startRecord(); + + expect(controller.value, isA()); + verifyNever(() => mockRecorder.start(config, path: any(named: 'path'))); + }); + }); + + group('stopRecord', () { + const pathPrefix = './test/src/message_input/audio_recorder/assets'; + const testPath = '$pathPrefix/audio.m4a'; + + setUp(() async { + when(() => mockRecorder.hasPermission()).thenAnswer((_) async => true); + when(() => mockRecorder.stop()).thenAnswer((_) async => testPath); + when(() => mockRecorder.start(config, path: any(named: 'path'))) + .thenAnswer((_) async {}); + }); + + test('stops recording and updates state to stopped', () async { + await controller.startRecord(); + await controller.stopRecord(); + + expect(controller.value, isA()); + verify(() => mockRecorder.stop()).called(1); + }); + + test('includes duration and waveform in stopped state', () async { + // Simulate some recording time and amplitude changes + final controller = StreamAudioRecorderController.raw( + recorder: mockRecorder, + config: config, + initialState: const RecordStateRecordingHold( + duration: Duration(seconds: 5), + waveform: [0.5, 0.6, 0.7], + ), + ); + + await controller.startRecord(); + await controller.stopRecord(); + + final stoppedState = controller.value as RecordStateStopped; + expect(stoppedState.audioRecording.extraData['duration'], isNotNull); + expect(stoppedState.audioRecording.extraData['waveform_data'], isNotNull); + }); + }); + + group('cancelRecord', () { + setUp(() { + when(() => mockRecorder.hasPermission()).thenAnswer((_) async => true); + when(() => mockRecorder.cancel()).thenAnswer((_) async {}); + when(() => mockRecorder.start(config, path: any(named: 'path'))) + .thenAnswer((_) async {}); + }); + + test('cancels recording and returns to idle state', () async { + await controller.startRecord(); + await controller.cancelRecord(); + + expect(controller.value, isA()); + verify(() => mockRecorder.cancel()).called(1); + }); + }); + + group('lockRecord', () { + test('transitions from hold to locked state', () async { + final controller = StreamAudioRecorderController.raw( + recorder: mockRecorder, + config: config, + initialState: const RecordStateRecordingHold(), + ); + + controller.lockRecord(); + + expect(controller.value, isA()); + }); + + test('preserves duration and waveform when locking', () async { + const duration = Duration(seconds: 3); + final waveform = [0.1, 0.2, 0.3]; + + final controller = StreamAudioRecorderController.raw( + recorder: mockRecorder, + config: config, + initialState: RecordStateRecordingHold( + duration: duration, + waveform: waveform, + ), + ); + + controller.lockRecord(); + + final lockedState = controller.value as RecordStateRecordingLocked; + expect(lockedState.duration, duration); + expect(lockedState.waveform, waveform); + }); + }); + + group('dragRecord', () { + test('updates drag offset in hold state', () async { + final controller = StreamAudioRecorderController.raw( + recorder: mockRecorder, + config: config, + initialState: const RecordStateRecordingHold(), + ); + + const dragOffset = Offset(10, 20); + controller.dragRecord(dragOffset); + + final holdState = controller.value as RecordStateRecordingHold; + expect(holdState.dragOffset, dragOffset); + }); + }); + + group('showInfo', () { + test('shows info message in idle state', () { + const message = 'Test Message'; + controller.showInfo(message); + + final idleState = controller.value as RecordStateIdle; + expect(idleState.message, message); + }); + + test('clears info message after duration', () async { + const message = 'Test Message'; + controller.showInfo(message, duration: const Duration(milliseconds: 100)); + + await Future.delayed(const Duration(milliseconds: 150)); + + final idleState = controller.value as RecordStateIdle; + expect(idleState.message, isNull); + }); + }); + + group('amplitude changes', () { + setUp(() { + when(() => mockRecorder.hasPermission()).thenAnswer((_) async => true); + when(() => mockRecorder.start(config, path: any(named: 'path'))) + .thenAnswer((_) async {}); + }); + + test('updates waveform data when amplitude changes', () async { + await controller.startRecord(); + + final mockAmplitude = MockAmplitude(); + when(() => mockAmplitude.current).thenReturn(-30); + amplitudeController.add(mockAmplitude); + + // Allow the stream to process + await Future.delayed(Duration.zero); + + final recordingState = controller.value as RecordStateRecording; + expect(recordingState.waveform, isNotEmpty); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_dark.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_dark.png new file mode 100644 index 0000000000..a928f3e658 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_light.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_light.png new file mode 100644 index 0000000000..68f02ee6f6 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_dark.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_dark.png new file mode 100644 index 0000000000..5c7a7afaa2 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_light.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_light.png new file mode 100644 index 0000000000..5dcce88080 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_dark.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_dark.png new file mode 100644 index 0000000000..ff99d92898 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_light.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_light.png new file mode 100644 index 0000000000..6de3d7b6dd Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_dark.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_dark.png new file mode 100644 index 0000000000..6710cf41f4 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_light.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_light.png new file mode 100644 index 0000000000..40a2c8be8f Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/stream_audio_recorder_test.dart b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/stream_audio_recorder_test.dart new file mode 100644 index 0000000000..5aced84d01 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/stream_audio_recorder_test.dart @@ -0,0 +1,418 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../../utils/finders.dart'; + +void main() { + group('StreamAudioRecorderButton', () { + final fakeAudioRecording = Attachment( + type: AttachmentType.voiceRecording, + file: AttachmentFile( + size: 1000000, + path: 'voice_recordings/test.m4a', + ), + extraData: { + 'duration': 3000, + 'waveform_data': List.filled(50, 0.5), + }, + ); + + testWidgets( + 'renders mic icon in idle state', + (tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + const StreamAudioRecorderButton( + recordState: RecordStateIdle(), + ), + ), + ); + + expect(find.bySvgIcon(StreamSvgIcons.mic), findsOneWidget); + }, + ); + + testWidgets( + 'calls onRecordStart when long pressed', + (tester) async { + var recordStartCalled = false; + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: const RecordStateIdle(), + onRecordStart: () { + recordStartCalled = true; + }, + ), + ), + ); + + final center = tester.getCenter(find.byType(StreamAudioRecorderButton)); + await tester.startGesture(center); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50)); + + expect(recordStartCalled, true); + }, + ); + + testWidgets( + 'shows recording UI when in RecordStateRecordingHold', + (tester) async { + final recordingState = RecordStateRecordingHold( + duration: const Duration(seconds: 5), + waveform: List.filled(50, 0.5), + ); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: recordingState, + onRecordStart: () {}, + ), + ), + ); + + expect(find.byType(PlaybackTimerIndicator), findsOneWidget); + expect(find.byType(SlideToCancelIndicator), findsOneWidget); + }, + ); + + testWidgets( + 'calls onRecordFinish when long press is released', + (tester) async { + var onRecordFinishCalled = false; + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: const RecordStateRecordingHold(), + onRecordFinish: () { + onRecordFinishCalled = true; + }, + ), + ), + ); + + final center = tester.getCenter(find.byType(RecordButton)); + final gesture = await tester.startGesture(center); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50)); + await gesture.up(); + + expect(onRecordFinishCalled, true); + }, + ); + + testWidgets( + 'calls onRecordCancel when dragged left beyond threshold', + (tester) async { + const cancelThreshold = 60.0; + + var onRecordCancelCalled = false; + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: const RecordStateRecordingHold(), + cancelRecordThreshold: cancelThreshold, + onRecordCancel: () { + onRecordCancelCalled = true; + }, + ), + ), + ); + + final center = tester.getCenter(find.byType(RecordButton)); + final gesture = await tester.startGesture(center); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50)); + // Move beyond threshold + await gesture.moveBy(const Offset(-cancelThreshold, 0)); + + expect(onRecordCancelCalled, true); + }, + ); + + testWidgets( + 'calls onRecordLock when dragged up beyond threshold', + (tester) async { + const lockThreshold = 60.0; + + var lockCalled = false; + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: const RecordStateRecordingHold(), + lockRecordThreshold: lockThreshold, + onRecordLock: () { + lockCalled = true; + }, + ), + ), + ); + + final center = tester.getCenter(find.byType(RecordButton)); + final gesture = await tester.startGesture(center); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50)); + // Move beyond threshold + await gesture.moveBy(const Offset(0, -lockThreshold)); + + expect(lockCalled, true); + }, + ); + + testWidgets( + 'shows locked recording UI when in RecordStateRecordingLocked', + (tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: RecordStateRecordingLocked( + duration: const Duration(seconds: 5), + waveform: List.filled(50, 0.5), + ), + ), + ), + ); + + expect(find.byType(StreamAudioWaveform), findsOneWidget); + expect(find.bySvgIcon(StreamSvgIcons.delete), findsOneWidget); + expect(find.bySvgIcon(StreamSvgIcons.stop), findsOneWidget); + expect(find.bySvgIcon(StreamSvgIcons.checkSend), findsOneWidget); + }, + ); + + testWidgets( + 'calls onRecordCancel when delete is tap in RecordStateRecordingLocked', + (tester) async { + var onRecordCancelCalled = false; + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: RecordStateRecordingLocked( + duration: const Duration(seconds: 5), + waveform: List.filled(50, 0.5), + ), + onRecordCancel: () { + onRecordCancelCalled = true; + }, + ), + ), + ); + + await tester.tap(find.bySvgIcon(StreamSvgIcons.delete)); + + expect(onRecordCancelCalled, true); + }, + ); + + testWidgets( + 'calls onRecordStop when stop is tap in RecordStateRecordingLocked', + (tester) async { + var onRecordStopCalled = false; + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: RecordStateRecordingLocked( + duration: const Duration(seconds: 5), + waveform: List.filled(50, 0.5), + ), + onRecordStop: () { + onRecordStopCalled = true; + }, + ), + ), + ); + + await tester.tap(find.bySvgIcon(StreamSvgIcons.stop)); + + expect(onRecordStopCalled, true); + }, + ); + + testWidgets( + 'calls onRecordFinish when checkSend is tap RecordStateRecordingLocked', + (tester) async { + var onRecordFinishCalled = false; + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: RecordStateRecordingLocked( + duration: const Duration(seconds: 5), + waveform: List.filled(50, 0.5), + ), + onRecordFinish: () { + onRecordFinishCalled = true; + }, + ), + ), + ); + + await tester.tap(find.bySvgIcon(StreamSvgIcons.checkSend)); + + expect(onRecordFinishCalled, true); + }, + ); + + testWidgets( + 'shows stopped recording UI with playback controls', + (tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: RecordStateStopped( + audioRecording: fakeAudioRecording, + ), + ), + ), + ); + + expect(find.byType(PlaybackControlButton), findsOneWidget); + expect(find.byType(PlaybackTimerText), findsOneWidget); + expect(find.byType(StreamAudioWaveformSlider), findsOneWidget); + expect(find.bySvgIcon(StreamSvgIcons.delete), findsOneWidget); + expect(find.bySvgIcon(StreamSvgIcons.checkSend), findsOneWidget); + }, + ); + + testWidgets( + 'calls onRecordFinish when checkSend is tap in RecordStateStopped', + (tester) async { + var onRecordFinishCalled = false; + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: RecordStateStopped( + audioRecording: fakeAudioRecording, + ), + onRecordFinish: () { + onRecordFinishCalled = true; + }, + ), + ), + ); + + await tester.tap(find.bySvgIcon(StreamSvgIcons.checkSend)); + + expect(onRecordFinishCalled, true); + }, + ); + + testWidgets( + 'calls onRecordFinish when delete is tap in RecordStateStopped', + (tester) async { + var onRecordCancelCalled = false; + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamAudioRecorderButton( + recordState: RecordStateStopped( + audioRecording: fakeAudioRecording, + ), + onRecordCancel: () { + onRecordCancelCalled = true; + }, + ), + ), + ); + + await tester.tap(find.bySvgIcon(StreamSvgIcons.delete)); + + expect(onRecordCancelCalled, true); + }, + ); + + for (final brightness in Brightness.values) { + goldenTest( + '[${brightness.name}] -> should look fine in idol state', + fileName: 'stream_audio_recorder_button_idle_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 400, height: 160), + builder: () => _wrapWithStreamChatApp( + brightness: brightness, + const StreamAudioRecorderButton( + recordState: RecordStateIdle(), + ), + ), + ); + + goldenTest( + '[${brightness.name}] -> should look fine in recording hold state', + fileName: + 'stream_audio_recorder_button_recording_hold_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 400, height: 160), + builder: () => _wrapWithStreamChatApp( + brightness: brightness, + StreamAudioRecorderButton( + recordState: RecordStateRecordingHold( + duration: const Duration(seconds: 5), + waveform: List.filled(50, 0.5), + ), + ), + ), + ); + + goldenTest( + '[${brightness.name}] -> should look fine in recording locked state', + fileName: + 'stream_audio_recorder_button_recording_locked_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 400, height: 160), + builder: () => _wrapWithStreamChatApp( + brightness: brightness, + StreamAudioRecorderButton( + recordState: RecordStateRecordingLocked( + duration: const Duration(seconds: 5), + waveform: List.filled(50, 0.5), + ), + onRecordCancel: () {}, + onRecordStop: () {}, + onRecordFinish: () {}, + ), + ), + ); + + goldenTest( + '[${brightness.name}] -> should look fine in recording stopped state', + fileName: + 'stream_audio_recorder_button_recording_stopped_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 400, height: 160), + builder: () => _wrapWithStreamChatApp( + brightness: brightness, + StreamAudioRecorderButton( + recordState: RecordStateStopped( + audioRecording: fakeAudioRecording, + ), + onRecordCancel: () {}, + onRecordFinish: () {}, + ), + ), + ); + } + }); +} + +Widget _wrapWithStreamChatApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Portal( + child: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + bottomNavigationBar: Material( + elevation: 10, + color: theme.colorTheme.barsBg, + child: Padding( + padding: const EdgeInsets.all(8), + child: widget, + ), + ), + ); + }), + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/attachment_button_0.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/attachment_button_0.png index e7db213550..cac3a620ac 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/attachment_button_0.png and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/attachment_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/command_button_0.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/command_button_0.png index e3be49ff0c..588d580602 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/command_button_0.png and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/command_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/countdown_button_0.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/countdown_button_0.png index 9d4da4d30c..519ff3f2c0 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/countdown_button_0.png and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/countdown_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart index fc4b68c0da..15a655d9c1 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart @@ -24,7 +24,7 @@ void main() { final attachments = [ Attachment(type: 'file', id: 'file1'), Attachment(type: 'file', id: 'file2'), - Attachment(type: 'media', id: 'media1'), + Attachment(type: 'image', id: 'image1'), ]; await tester.pumpWidget( diff --git a/packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart index 95008eab24..291d43e2fb 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart @@ -28,7 +28,7 @@ void main() { testWidgets('BottomRow', (tester) async { final theme = StreamChatThemeData.light(); - final onThreadTap = MockVoidSingleParamCallback(); + final onThreadTap = MockValueChanged(); await tester.pumpWidget( MaterialApp( diff --git a/packages/stream_chat_flutter/test/src/misc/audio_waveform_test.dart b/packages/stream_chat_flutter/test/src/misc/audio_waveform_test.dart new file mode 100644 index 0000000000..e9a8b75902 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/misc/audio_waveform_test.dart @@ -0,0 +1,230 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + group('StreamAudioWaveformSlider', () { + // Prepare random waveform data + final waveformData = generateStaticWaveform(length: 30); + + testWidgets('Handles seek interactions', (WidgetTester tester) async { + final onChangeStart = MockValueChanged(); + final onChanged = MockValueChanged(); + final onChangeEnd = MockValueChanged(); + + await tester.pumpWidget( + _wrapWithMaterialApp( + ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 300, height: 50), + child: StreamAudioWaveformSlider( + limit: 35, + waveform: waveformData, + onChangeStart: onChangeStart, + onChanged: onChanged, + onChangeEnd: onChangeEnd, + ), + ), + ), + ); + + final topLeft = tester.getTopLeft(find.byType(StreamAudioWaveformSlider)); + + // Start gesture + final gesture = await tester.startGesture(topLeft); + verify(() => onChangeStart(0)).called(1); + + // Move gesture to the middle of the slider + await gesture.moveBy(const Offset(150, 0)); + verify(() => onChanged(0.5)).called(1); + + // Move gesture to the end of the slider + await gesture.moveBy(const Offset(300, 0)); + verify(() => onChanged(1)).called(1); + + // End gesture + await gesture.up(); + verify(() => onChangeEnd(1)).called(1); + }); + + for (final brightness in Brightness.values) { + final theme = brightness.name; + + goldenTest( + '[$theme] -> should look fine', + fileName: 'stream_audio_waveform_slider_$theme', + constraints: const BoxConstraints.tightFor(width: 300, height: 50), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + Padding( + padding: const EdgeInsets.all(8), + child: StreamAudioWaveformSlider( + limit: 30, + waveform: waveformData, + onChanged: (double value) {}, + ), + ), + ), + ); + + goldenTest( + '[$theme] -> should paint waveform bars inverted', + fileName: 'stream_audio_waveform_slider_inverted_$theme', + constraints: const BoxConstraints.tightFor(width: 300, height: 50), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + Padding( + padding: const EdgeInsets.all(8), + child: StreamAudioWaveformSlider( + limit: 30, + inverse: false, + waveform: waveformData, + onChanged: (double value) {}, + ), + ), + ), + ); + + goldenTest( + '[$theme] -> should look fine with progress', + fileName: 'stream_audio_waveform_slider_progress_$theme', + constraints: const BoxConstraints.tightFor(width: 300, height: 50), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + Padding( + padding: const EdgeInsets.all(8), + child: StreamAudioWaveformSlider( + limit: 30, + progress: 0.5, + waveform: waveformData, + onChanged: (double value) {}, + ), + ), + ), + ); + + goldenTest( + '[$theme] -> should look fine with custom properties', + fileName: 'stream_audio_waveform_slider_custom_$theme', + constraints: const BoxConstraints.tightFor(width: 300, height: 50), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + Padding( + padding: const EdgeInsets.all(8), + child: StreamAudioWaveformSlider( + limit: 30, + color: Colors.blue, + progress: 0.5, + progressColor: Colors.green, + waveform: waveformData, + onChanged: (double value) {}, + ), + ), + ), + ); + + goldenTest( + '[$theme] -> should build empty waveform if no data', + fileName: 'stream_audio_waveform_slider_empty_$theme', + constraints: const BoxConstraints.tightFor(width: 300, height: 50), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + Padding( + padding: const EdgeInsets.all(8), + child: StreamAudioWaveformSlider( + limit: 30, + waveform: const [], + onChanged: (double value) {}, + ), + ), + ), + ); + + goldenTest( + '[$theme] -> should build empty waveform if less data', + fileName: 'stream_audio_waveform_slider_less_data_$theme', + constraints: const BoxConstraints.tightFor(width: 300, height: 50), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + Padding( + padding: const EdgeInsets.all(8), + child: StreamAudioWaveformSlider( + limit: 50, + waveform: waveformData, + onChanged: (double value) {}, + ), + ), + ), + ); + } + }); +} + +List generateStaticWaveform({ + int length = 35, +}) { + // A predefined pattern that mimics audio signal variations + final basePattern = [ + 0.2, + 0.4, + 0.6, + 0.3, + 0.5, + 0.7, + 0.1, + 0.8, + 0.2, + 0.6, + 0.4, + 0.9, + 0.3, + 0.7, + 0.5, + 0.2, + 0.8, + 0.1, + 0.6, + 0.4, + 0.7, + 0.3, + 0.5, + 0.2, + 0.8, + 0.1, + 0.6, + 0.4, + 0.7, + 0.3, + 0.5, + 0.2, + 0.8, + 0.1, + 0.6, + ]; + + // Ensure the returned list matches the requested length + return basePattern.take(length).toList(); +} + +Widget _wrapWithMaterialApp( + 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: Center(child: widget), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_dark.png new file mode 100644 index 0000000000..8480b35ee1 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_light.png new file mode 100644 index 0000000000..38e1ce5bf7 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_dark.png new file mode 100644 index 0000000000..d0e6aab285 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_dark.png new file mode 100644 index 0000000000..6d63fa50ba Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_light.png new file mode 100644 index 0000000000..f914ffb2ad Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_dark.png new file mode 100644 index 0000000000..2d05d18ac3 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_light.png new file mode 100644 index 0000000000..1434322d64 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_dark.png new file mode 100644 index 0000000000..77a1e1299b Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_light.png new file mode 100644 index 0000000000..12b942eebc Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_light.png new file mode 100644 index 0000000000..26add2a992 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_dark.png new file mode 100644 index 0000000000..9d1854a8e0 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_light.png new file mode 100644 index 0000000000..4951487559 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_properties_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_properties_dark.png new file mode 100644 index 0000000000..c821b5e445 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_properties_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_properties_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_properties_light.png new file mode 100644 index 0000000000..38e1ce5bf7 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_properties_light.png differ diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index ff472daa83..0f30c7d002 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -56,8 +56,8 @@ class MockVoidCallback extends Mock { void call(); } -class MockVoidSingleParamCallback extends Mock { - void call(T param); +class MockValueChanged extends Mock { + void call(T value); } class MockAttachmentHandler extends Mock implements StreamAttachmentHandler {} @@ -78,4 +78,4 @@ class MockStreamMemberListController extends Mock PagedValue value = const PagedValue.loading(); } -class MocMessage extends Mock implements Message {} +class MockMessage extends Mock implements Message {} diff --git a/packages/stream_chat_flutter/test/src/utils/finders.dart b/packages/stream_chat_flutter/test/src/utils/finders.dart new file mode 100644 index 0000000000..c03ae7b712 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/utils/finders.dart @@ -0,0 +1,29 @@ +import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:svg_icon_widget/svg_icon_widget.dart'; + +extension SvgIconWidgetFinder on CommonFinders { + /// Asserts that the SvgIcon widget is found. + /// + /// ## Sample code + /// + /// ```dart + /// expect(find.bySvgIcon(StreamSvgIcons.mic), findsOneWidget); + /// ``` + Finder bySvgIcon(SvgIconData icon) => _SvgIconWidgetFinder(icon); +} + +class _SvgIconWidgetFinder extends MatchFinder { + _SvgIconWidgetFinder(this.icon); + + final SvgIconData icon; + + @override + String get description => 'SvgIcon "$icon"'; + + @override + bool matches(Element candidate) { + final widget = candidate.widget; + return widget is SvgIcon && widget.icon == icon; + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart b/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart index 0209c223cb..aebd3ad2d4 100644 --- a/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart +++ b/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart @@ -83,7 +83,7 @@ abstract class PagedValueNotifier /// Paged value that can be used with [PagedValueNotifier]. @freezed -class PagedValue with _$PagedValue { +sealed class PagedValue with _$PagedValue { /// Represents the success state of the [PagedValue] // @Assert( // 'nextPageKey != null', diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart index 73e8136f29..6fccd13007 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart @@ -258,7 +258,7 @@ class StreamPollController extends ValueNotifier { /// while creating a poll. /// {@endtemplate} @freezed -class PollValidationError with _$PollValidationError { +sealed class PollValidationError with _$PollValidationError { /// Occurs when the poll contains duplicate options. const factory PollValidationError.duplicateOptions( List options, diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index da6ade3231..4f6de22495 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,3 +1,8 @@ +## 9.3.0 + +- Added translations for new `slideToCancelLabel` label. +- Added translations for new `holdToRecordLabel` label. + ## 9.2.0 - Updated `stream_chat_flutter` dependency to [`9.2.0+1`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index db15ac1050..5f1575377d 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -632,6 +632,12 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { if (count == 1) return '1 new thread'; return '$count new threads'; } + + @override + String get slideToCancelLabel => 'Slide to cancel'; + + @override + String get holdToRecordLabel => 'Hold to record, release to send.'; } void main() async { diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index 52d2c9d099..a27d599110 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -613,4 +613,11 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { if (count == 1) return '1 fil nou'; return '$count fils nous'; } + + @override + String get slideToCancelLabel => 'Llisca per cancel·lar'; + + @override + String get holdToRecordLabel => + 'Mantén premut per gravar, deixa anar per enviar'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index da184bc888..02f6fe9e9f 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -607,4 +607,10 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { if (count == 1) return '1 neuer Thread'; return '$count neue Threads'; } + + @override + String get slideToCancelLabel => 'Zum Abbrechen schieben'; + + @override + String get holdToRecordLabel => 'Zum Aufnehmen halten, zum Senden loslassen'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index d4b59b05c5..ee1d9c91bc 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -609,4 +609,10 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { if (count == 1) return '1 new thread'; return '$count new threads'; } + + @override + String get slideToCancelLabel => 'Slide to cancel'; + + @override + String get holdToRecordLabel => 'Hold to record, release to send.'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 10647823cd..b03824ab90 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -615,4 +615,11 @@ No es posible añadir más de $limit archivos adjuntos if (count == 1) return '1 nuevo hilo'; return '$count nuevos hilos'; } + + @override + String get slideToCancelLabel => 'Desliza para cancelar'; + + @override + String get holdToRecordLabel => + 'Mantén pulsado para grabar, suelta para enviar'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index e09746980e..2bb0230c7a 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -618,4 +618,11 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ if (count == 1) return '1 Nouveau fil'; return '$count Nouveaux fils'; } + + @override + String get slideToCancelLabel => 'Glissez pour annuler'; + + @override + String get holdToRecordLabel => + 'Maintenez pour enregistrer, relâchez pour envoyer'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index 6e3113ed5f..c527d5f7c9 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -609,4 +609,11 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { if (count == 1) return '1 नया थ्रेड'; return '$count नए थ्रेड्स'; } + + @override + String get slideToCancelLabel => 'रद्द करने के लिए स्लाइड करें'; + + @override + String get holdToRecordLabel => + 'रिकॉर्ड करने के लिए दबाए रखें, भेजने के लिए छोड़ें'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index c4a08edb59..c57577a182 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -618,4 +618,11 @@ Attenzione: il limite massimo di $limit file è stato superato. if (count == 1) return '1 nuovo thread'; return '$count nuovi thread'; } + + @override + String get slideToCancelLabel => 'Scorri per annullare'; + + @override + String get holdToRecordLabel => + 'Tieni premuto per registrare, rilascia per inviare'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 75036bf524..0d384c1817 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -590,4 +590,10 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String newThreadsLabel({required int count}) { return '$count 件の新しいスレッド'; } + + @override + String get slideToCancelLabel => 'スライドでキャンセル'; + + @override + String get holdToRecordLabel => '長押しで録音、離すと送信'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index fb3090b861..970f5b2450 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -591,4 +591,10 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String newThreadsLabel({required int count}) { return '$count개의 새 스레드'; } + + @override + String get slideToCancelLabel => '슬라이드하여 취소'; + + @override + String get holdToRecordLabel => '길게 눌러서 녹음, 놓아서 전송'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 05a8ed2989..e584ee8377 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -600,4 +600,10 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { if (count == 1) return '1 ny tråd'; return '$count nye tråder'; } + + @override + String get slideToCancelLabel => 'Gli for å avbryte'; + + @override + String get holdToRecordLabel => 'Hold for å ta opp, slipp for å sende'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index c055496592..b5a33f22e8 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -612,4 +612,11 @@ Não é possível adicionar mais de $limit arquivos de uma vez if (count == 1) return '1 novo tópico'; return '$count novos tópicos'; } + + @override + String get slideToCancelLabel => 'Deslize para cancelar'; + + @override + String get holdToRecordLabel => + 'Mantenha pressionado para gravar, solte para enviar'; } diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 182b4bbe18..935b1b7f81 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -289,6 +289,8 @@ void main() { expect(localizations.voteCountLabel(count: 3), isNotNull); expect(localizations.repliedToLabel, isNotNull); expect(localizations.newThreadsLabel(count: 3), isNotNull); + expect(localizations.slideToCancelLabel, isNotNull); + expect(localizations.holdToRecordLabel, isNotNull); }); }