From 27cbb0357ad7271c7cd294f293bde06547879bb6 Mon Sep 17 00:00:00 2001 From: Efthymis Sarmpanis Date: Fri, 10 May 2024 13:18:44 +0300 Subject: [PATCH] feat(ui): voice recording attachment builder (#1907) * feat(llc,ui): voice recording attachment builder * test: add coverage * fix: fix formatting * uncomment logic --- packages/stream_chat/CHANGELOG.md | 5 + .../lib/src/core/models/attachment.dart | 1 + packages/stream_chat_flutter/CHANGELOG.md | 5 + .../ios/Runner.xcodeproj/project.pbxproj | 3 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- .../builder/attachment_widget_builder.dart | 18 +- .../stream_voice_recording_list_player.dart | 134 +++++ .../stream_voice_recording_loading.dart | 29 ++ .../stream_voice_recording_player.dart | 315 ++++++++++++ .../stream_voice_recording_slider.dart | 235 +++++++++ .../voice_recording_attachment_builder.dart | 50 ++ .../lib/src/misc/stream_svg_icon.dart | 13 + .../lib/src/theme/stream_chat_theme.dart | 12 + .../lib/src/theme/themes.dart | 1 + .../lib/src/theme/voice_attachment_theme.dart | 461 ++++++++++++++++++ .../lib/src/utils/extensions.dart | 28 ++ .../lib/stream_chat_flutter.dart | 4 + .../lib/svgs/filetype_AAC.svg | 7 + packages/stream_chat_flutter/pubspec.yaml | 1 + ...ream_voice_recording_list_player_test.dart | 56 +++ .../stream_voice_recording_loading_test.dart | 20 + .../stream_voice_recording_player_test.dart | 132 +++++ ...ice_recording_attachment_builder_test.dart | 53 ++ .../stream_chat_flutter/test/src/mocks.dart | 2 + 24 files changed, 1573 insertions(+), 14 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/theme/voice_attachment_theme.dart create mode 100644 packages/stream_chat_flutter/lib/svgs/filetype_AAC.svg create mode 100644 packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player_test.dart create mode 100644 packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading_test.dart create mode 100644 packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player_test.dart create mode 100644 packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder_test.dart diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index ceb53c732..1d72849c4 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +✅ Added +- Added `voiceRecording` attachment type + ## 7.2.0-hotfix.1 - Version to keep in sync with the rest of the packages diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index 86d3efedf..afc825b6a 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -17,6 +17,7 @@ mixin AttachmentType { static const giphy = 'giphy'; static const video = 'video'; static const audio = 'audio'; + static const voiceRecording = 'voiceRecording'; /// Application custom types. static const urlPreview = 'url_preview'; diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index ffe7775ec..ef501a167 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +✅ Added +- Added `VoiceRecordingAttachmentBuilder`, for displaying voice recording attachments in the chat. + ## 7.2.0-hotfix.1 🔄 Changed diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj index e5d8f7436..8022e694b 100644 --- a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -155,7 +155,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -204,6 +204,7 @@ files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3db53b6e1..b52b2e698 100644 --- a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ playList; + + /// The border radius of each audio. + final BorderRadiusGeometry? attachmentBorderRadiusGeometry; + + /// Constraints of audio attachments + final BoxConstraints? constraints; + + @override + State createState() => + _StreamVoiceRecordingListPlayerState(); +} + +class _StreamVoiceRecordingListPlayerState + extends State { + final _player = AudioPlayer(); + late StreamSubscription _playerStateChangedSubscription; + + Widget _createAudioPlayer(int index, PlayListItem item) { + final url = item.assetUrl; + Widget child; + + if (url == null) { + child = const StreamVoiceRecordingLoading(); + } else { + child = StreamVoiceRecordingPlayer( + player: _player, + duration: item.duration, + waveBars: item.waveForm, + index: index, + ); + } + + final theme = + StreamChatTheme.of(context).voiceRecordingTheme.listPlayerTheme; + + return Container( + margin: theme.margin, + constraints: widget.constraints, + decoration: BoxDecoration( + color: theme.backgroundColor, + border: Border.all( + color: theme.borderColor!, + ), + borderRadius: + widget.attachmentBorderRadiusGeometry ?? theme.borderRadius, + ), + child: child, + ); + } + + void _playerStateListener(PlayerState state) async { + if (state.processingState == ProcessingState.completed) { + await _player.stop(); + await _player.seek(Duration.zero, index: 0); + } + } + + @override + void initState() { + super.initState(); + + _playerStateChangedSubscription = + _player.playerStateStream.listen(_playerStateListener); + } + + @override + void dispose() { + super.dispose(); + + _playerStateChangedSubscription.cancel(); + _player.dispose(); + } + + @override + Widget build(BuildContext context) { + final playList = widget.playList + .where((attachment) => attachment.assetUrl != null) + .map((attachment) => AudioSource.uri(Uri.parse(attachment.assetUrl!))) + .toList(); + + final audioSource = ConcatenatingAudioSource(children: playList); + + _player + ..setShuffleModeEnabled(false) + ..setLoopMode(LoopMode.off) + ..setAudioSource(audioSource, preload: false); + + return Column( + children: widget.playList.mapIndexed(_createAudioPlayer).toList(), + ); + } +} + +/// {@template PlayListItem} +/// Represents an audio attachment meta data. +/// {@endtemplate} +class PlayListItem { + /// {@macro PlayListItem} + const PlayListItem({ + this.assetUrl, + required this.duration, + required this.waveForm, + }); + + /// The url of the audio. + final String? assetUrl; + + /// The duration of the audio. + final Duration duration; + + /// The wave form of the audio. + final List waveForm; +} 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 new file mode 100644 index 000000000..2ff3c0282 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template StreamVoiceRecordingLoading} +/// Loading widget for audio message. Use this when the url from the audio +/// message is still not available. One use situation in when the audio is +/// still being uploaded. +/// {@endtemplate} +class StreamVoiceRecordingLoading extends StatelessWidget { + /// {@macro StreamVoiceRecordingLoading} + const StreamVoiceRecordingLoading({super.key}); + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context).voiceRecordingTheme.loadingTheme; + + return Padding( + padding: theme.padding!, + child: SizedBox( + height: theme.size!.height, + width: theme.size!.width, + child: CircularProgressIndicator( + strokeWidth: theme.strokeWidth!, + color: theme.color, + ), + ), + ); + } +} 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 new file mode 100644 index 000000000..3ad5ebb93 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart @@ -0,0 +1,315 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template StreamVoiceRecordingPlayer} +/// Embedded player for audio messages. It displays the data for the audio +/// message and allow the user to interact with the player providing buttons +/// to play/pause, seek the audio and change the speed of reproduction. +/// +/// When waveBars are not provided they are shown as 0 bars. +/// {@endtemplate} +class StreamVoiceRecordingPlayer extends StatefulWidget { + /// {@macro StreamVoiceRecordingPlayer} + const StreamVoiceRecordingPlayer({ + super.key, + required this.player, + required this.duration, + this.waveBars, + this.index = 0, + this.fileSize, + this.actionButton, + }); + + /// The player of the audio. + final AudioPlayer player; + + /// The wave bars of the recorded audio from 0 to 1. When not provided + /// this Widget shows then as small dots. + final List? waveBars; + + /// The duration of the audio. + final Duration duration; + + /// The index of the audio inside the play list. If not provided, this is + /// assumed to be zero. + final int index; + + /// The file size in bits. + final int? fileSize; + + /// An action button to be used. + final Widget? actionButton; + + @override + _StreamVoiceRecordingPlayerState createState() => + _StreamVoiceRecordingPlayerState(); +} + +class _StreamVoiceRecordingPlayerState + extends State { + var _seeking = false; + + @override + void dispose() { + super.dispose(); + + widget.player.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.duration != Duration.zero) { + return _content(widget.duration); + } else { + return StreamBuilder( + stream: widget.player.durationStream, + builder: (context, snapshot) { + if (snapshot.hasData) { + return _content(snapshot.data!); + } else if (snapshot.hasError) { + return const Center(child: Text('Error!!')); + } else { + return const StreamVoiceRecordingLoading(); + } + }, + ); + } + } + + Widget _content(Duration totalDuration) { + return Container( + padding: const EdgeInsets.all(8), + height: 60, + child: Row( + children: [ + SizedBox( + width: 36, + height: 36, + child: _controlButton(), + ), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _timer(totalDuration), + _fileSizeWidget(widget.fileSize), + ], + ), + ), + _audioWaveSlider(totalDuration), + _speedAndActionButton(), + ], + ), + ); + } + + Widget _controlButton() { + final theme = StreamChatTheme.of(context).voiceRecordingTheme.playerTheme; + + return StreamBuilder( + initialData: false, + stream: _playingThisStream(), + builder: (context, snapshot) { + final playingThis = snapshot.data == true; + + final icon = playingThis ? theme.pauseIcon : theme.playIcon; + + final processingState = widget.player.playerStateStream + .map((event) => event.processingState); + + return StreamBuilder( + stream: processingState, + initialData: ProcessingState.idle, + builder: (context, snapshot) { + final state = snapshot.data ?? ProcessingState.idle; + if (state == ProcessingState.ready || + state == ProcessingState.idle || + !playingThis) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + elevation: theme.buttonElevation, + padding: theme.buttonPadding, + backgroundColor: theme.buttonBackgroundColor, + shape: theme.buttonShape, + ), + child: Icon(icon, color: theme.iconColor), + onPressed: () { + if (playingThis) { + _pause(); + } else { + _play(); + } + }, + ); + } else { + return const CircularProgressIndicator(strokeWidth: 3); + } + }, + ); + }, + ); + } + + Widget _speedAndActionButton() { + final theme = StreamChatTheme.of(context).voiceRecordingTheme.playerTheme; + + final speedStream = _playingThisStream().flatMap((showSpeed) => + widget.player.speedStream.map((speed) => showSpeed ? speed : -1.0)); + + return StreamBuilder( + initialData: -1, + stream: speedStream, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data! > 0) { + final speed = snapshot.data!; + return SizedBox( + width: theme.speedButtonSize!.width, + height: theme.speedButtonSize!.height, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + elevation: theme.speedButtonElevation, + backgroundColor: theme.speedButtonBackgroundColor, + padding: theme.speedButtonPadding, + shape: theme.speedButtonShape, + ), + child: Text( + '${speed}x', + style: theme.speedButtonTextStyle, + ), + onPressed: () { + setState(() { + if (speed == 2) { + widget.player.setSpeed(1); + } else { + widget.player.setSpeed(speed + 0.5); + } + }); + }, + ), + ); + } else { + if (widget.actionButton != null) { + return widget.actionButton!; + } else { + return SizedBox( + width: theme.speedButtonSize!.width, + height: theme.speedButtonSize!.height, + child: theme.fileTypeIcon, + ); + } + } + }, + ); + } + + Widget _fileSizeWidget(int? fileSize) { + final theme = StreamChatTheme.of(context).voiceRecordingTheme.playerTheme; + + if (fileSize != null) { + return Text( + fileSize.toHumanReadableSize(), + style: theme.fileSizeTextStyle, + ); + } else { + return const SizedBox.shrink(); + } + } + + Widget _timer(Duration totalDuration) { + final theme = StreamChatTheme.of(context).voiceRecordingTheme.playerTheme; + + return StreamBuilder( + stream: widget.player.positionStream, + builder: (context, snapshot) { + if (snapshot.hasData && + (widget.player.currentIndex == widget.index && + (widget.player.playing || + snapshot.data!.inMilliseconds > 0 || + _seeking))) { + return Text( + snapshot.data!.toMinutesAndSeconds(), + style: theme.timerTextStyle, + ); + } else { + return Text( + totalDuration.toMinutesAndSeconds(), + style: theme.timerTextStyle, + ); + } + }, + ); + } + + Widget _audioWaveSlider(Duration totalDuration) { + final positionStream = widget.player.currentIndexStream.flatMap( + (index) => widget.player.positionStream.map((duration) => _sliderValue( + duration, + totalDuration, + index, + )), + ); + + return Expanded( + child: StreamVoiceRecordingSlider( + waves: widget.waveBars ?? List.filled(50, 0), + progressStream: positionStream, + onChangeStart: (val) { + setState(() { + _seeking = true; + }); + }, + onChanged: (val) { + widget.player.pause(); + widget.player.seek( + totalDuration * val, + index: widget.index, + ); + }, + onChangeEnd: () { + setState(() { + _seeking = false; + }); + }, + ), + ); + } + + double _sliderValue( + Duration duration, + Duration totalDuration, + int? currentIndex, + ) { + if (widget.index != currentIndex) { + return 0; + } else { + return min(duration.inMicroseconds / totalDuration.inMicroseconds, 1); + } + } + + Stream _playingThisStream() { + return widget.player.playingStream.flatMap((playing) { + return widget.player.currentIndexStream.map( + (index) => playing && index == widget.index, + ); + }); + } + + Future _play() async { + if (widget.index != widget.player.currentIndex) { + widget.player.seek(Duration.zero, index: widget.index); + } + + widget.player.play(); + } + + Future _pause() { + return widget.player.pause(); + } +} 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 new file mode 100644 index 000000000..3db9b7728 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart @@ -0,0 +1,235 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template StreamVoiceRecordingSlider} +/// A Widget that draws the audio wave bars for an audio inside a Slider. +/// This Widget is indeed to be used to control the position of an audio message +/// and to get feedback of the position. +/// {@endtemplate} +class StreamVoiceRecordingSlider extends StatefulWidget { + /// {@macro StreamVoiceRecordingSlider} + const StreamVoiceRecordingSlider({ + super.key, + required this.waves, + required this.progressStream, + this.onChangeStart, + this.onChanged, + this.onChangeEnd, + this.customSliderButton, + this.customSliderButtonWidth, + }); + + /// The audio bars from 0.0 to 1.0. + final List waves; + + /// The progress of the audio. + final Stream progressStream; + + /// Callback called when Slider drag starts. + final Function(double)? onChangeStart; + + /// Callback called when Slider drag updates. + final Function(double)? onChanged; + + /// Callback called when Slider drag ends. + final Function()? onChangeEnd; + + /// A custom Slider button. Use this to substitute the default rounded + /// rectangle. + final Widget? customSliderButton; + + /// The width of the customSliderButton. This should match the width of the + /// provided Widget. + final double? customSliderButtonWidth; + + @override + _StreamVoiceRecordingSliderState createState() => + _StreamVoiceRecordingSliderState(); +} + +class _StreamVoiceRecordingSliderState + extends State { + var _dragging = false; + final _initialWidth = 7.0; + final _finalWidth = 14.0; + final _initialHeight = 30.0; + final _finalHeight = 35.0; + + Duration get animationDuration => + _dragging ? Duration.zero : const Duration(milliseconds: 300); + + double get _currentWidth { + if (widget.customSliderButtonWidth != null) { + return widget.customSliderButtonWidth!; + } else { + return _dragging ? _finalWidth : _initialWidth; + } + } + + double get _currentHeight => _dragging ? _finalHeight : _initialHeight; + + double _progressToWidth( + BoxConstraints constraints, double progress, double horizontalPadding) { + final availableWidth = constraints.maxWidth - horizontalPadding * 2; + + return availableWidth * progress - _currentWidth / 2 + horizontalPadding; + } + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context).voiceRecordingTheme.sliderTheme; + + return StreamBuilder( + initialData: 0, + stream: widget.progressStream, + builder: (context, snapshot) { + final progress = snapshot.data ?? 0; + + final sliderButton = widget.customSliderButton ?? + Container( + width: _currentWidth, + height: _currentHeight, + decoration: BoxDecoration( + color: theme.buttonColor, + boxShadow: [ + theme.buttonShadow!, + ], + border: Border.all( + color: theme.buttonBorderColor!, + width: theme.buttonBorderWidth!, + ), + borderRadius: theme.buttonBorderRadius, + ), + ); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _AudioBarsPainter( + bars: widget.waves, + spacingRatio: theme.spacingRatio, + barHeightRatio: theme.waveHeightRatio, + colorLeft: theme.waveColorPlayed!, + colorRight: theme.waveColorUnplayed!, + progressPercentage: progress, + padding: theme.horizontalPadding, + ), + ), + AnimatedPositioned( + duration: animationDuration, + left: _progressToWidth( + constraints, progress, theme.horizontalPadding), + curve: const ElasticOutCurve(1.05), + child: sliderButton, + ), + GestureDetector( + onHorizontalDragStart: (details) { + widget.onChangeStart + ?.call(details.localPosition.dx / constraints.maxWidth); + + setState(() { + _dragging = true; + }); + }, + onHorizontalDragEnd: (details) { + widget.onChangeEnd?.call(); + + setState(() { + _dragging = false; + }); + }, + onHorizontalDragUpdate: (details) { + widget.onChanged?.call( + min( + max(details.localPosition.dx / constraints.maxWidth, 0), + 1, + ), + ); + }, + ), + ], + ); + }, + ); + }, + ); + } +} + +class _AudioBarsPainter extends CustomPainter { + _AudioBarsPainter({ + required this.bars, + required this.progressPercentage, + this.colorLeft = Colors.blueAccent, + this.colorRight = Colors.grey, + this.spacingRatio = 0.01, + this.barHeightRatio = 1, + this.padding = 20, + }); + + final List bars; + final double progressPercentage; + final Color colorRight; + final Color colorLeft; + final double spacingRatio; + final double barHeightRatio; + final double padding; + + /// barWidth should include spacing, not only the width of the bar. + /// progressX should be the middle of the moving button of the slider, not + /// initial X position. + Color _barColor(double buttonCenter, double progressX) { + return (progressX > buttonCenter) ? colorLeft : colorRight; + } + + double _barHeight(double barValue, totalHeight) { + return max(barValue * totalHeight * barHeightRatio, 2); + } + + double _progressToWidth(double totalWidth, double progress) { + final availableWidth = totalWidth; + + return availableWidth * progress + padding; + } + + @override + void paint(Canvas canvas, Size size) { + final totalWidth = size.width - padding * 2; + + final spacingWidth = totalWidth * spacingRatio; + final totalBarWidth = totalWidth - spacingWidth * (bars.length - 1); + final barWidth = totalBarWidth / bars.length; + final barY = size.height / 2; + + bars.forEachIndexed((i, barValue) { + final barHeight = _barHeight(barValue, size.height); + final barX = i * (barWidth + spacingWidth) + barWidth / 2 + padding; + + final rect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(barX, barY), + width: barWidth, + height: barHeight, + ), + const Radius.circular(50), + ); + + final paint = Paint() + ..color = _barColor( + barX + barWidth / 2, + _progressToWidth(totalWidth, progressPercentage), + ); + canvas.drawRRect(rect, paint); + }); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} 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 new file mode 100644 index 000000000..286a30235 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder.dart @@ -0,0 +1,50 @@ +part of '../attachment_widget_builder.dart'; + +/// The default attachment builder for voice recordings +class VoiceRecordingAttachmentBuilder extends StreamAttachmentWidgetBuilder { + @override + bool canHandle(Message message, Map> attachments) { + final recordings = attachments[AttachmentType.voiceRecording]; + if (recordings != null && recordings.length == 1) return true; + + 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())) + .whereNotNull() + .toList(); + } + + @override + Widget build(BuildContext context, Message message, + Map> attachments) { + final recordings = attachments[AttachmentType.voiceRecording]!; + + return StreamVoiceRecordingListPlayer( + playList: recordings + .map( + (r) => PlayListItem( + assetUrl: r.assetUrl, + duration: _resolveDuration(r), + waveForm: _resolveWaveform(r), + ), + ) + .toList(), + attachmentBorderRadiusGeometry: BorderRadius.circular(16), + constraints: const BoxConstraints.tightFor(width: 400), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart index 75044ee4d..d69b83bad 100644 --- a/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart @@ -664,6 +664,19 @@ class StreamSvgIcon extends StatelessWidget { ); } + /// [StreamSvgIcon] type + factory StreamSvgIcon.filetypeAac({ + double? size, + Color? color, + }) { + return StreamSvgIcon( + assetName: 'filetype_AAC.svg', + color: color, + width: size, + height: size, + ); + } + /// [StreamSvgIcon] type factory StreamSvgIcon.filetype7z({ double? size, 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 39859a770..697e042f1 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 @@ -55,6 +55,7 @@ class StreamChatThemeData { StreamGalleryHeaderThemeData? imageHeaderTheme, StreamGalleryFooterThemeData? imageFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, + StreamVoiceRecordingThemeData? voiceRecordingTheme, }) { brightness ??= colorTheme?.brightness ?? Brightness.light; final isDark = brightness == Brightness.dark; @@ -81,6 +82,7 @@ class StreamChatThemeData { galleryHeaderTheme: imageHeaderTheme, galleryFooterTheme: imageFooterTheme, messageListViewTheme: messageListViewTheme, + voiceRecordingTheme: voiceRecordingTheme, ); return defaultData.merge(customizedData); @@ -108,6 +110,7 @@ class StreamChatThemeData { required this.galleryHeaderTheme, required this.galleryFooterTheme, required this.messageListViewTheme, + required this.voiceRecordingTheme, }); /// Creates a theme from a Material [Theme] @@ -277,6 +280,9 @@ class StreamChatThemeData { messageListViewTheme: StreamMessageListViewThemeData( backgroundColor: colorTheme.barsBg, ), + voiceRecordingTheme: colorTheme.brightness == Brightness.dark + ? StreamVoiceRecordingThemeData.dark() + : StreamVoiceRecordingThemeData.light(), ); } @@ -318,6 +324,9 @@ class StreamChatThemeData { /// Theme configuration for the [StreamMessageListView] widget. final StreamMessageListViewThemeData messageListViewTheme; + /// Theme configuration for the [StreamVoiceRecordingListPLayer] widget. + final StreamVoiceRecordingThemeData voiceRecordingTheme; + /// Creates a copy of [StreamChatThemeData] with specified attributes /// overridden. StreamChatThemeData copyWith({ @@ -337,6 +346,7 @@ class StreamChatThemeData { StreamGalleryHeaderThemeData? galleryHeaderTheme, StreamGalleryFooterThemeData? galleryFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, + StreamVoiceRecordingThemeData? voiceRecordingTheme, }) => StreamChatThemeData.raw( channelListHeaderTheme: @@ -353,6 +363,7 @@ class StreamChatThemeData { galleryHeaderTheme: galleryHeaderTheme ?? this.galleryHeaderTheme, galleryFooterTheme: galleryFooterTheme ?? this.galleryFooterTheme, messageListViewTheme: messageListViewTheme ?? this.messageListViewTheme, + voiceRecordingTheme: voiceRecordingTheme ?? this.voiceRecordingTheme, ); /// Merge themes @@ -373,6 +384,7 @@ class StreamChatThemeData { galleryFooterTheme: galleryFooterTheme.merge(other.galleryFooterTheme), messageListViewTheme: messageListViewTheme.merge(other.messageListViewTheme), + voiceRecordingTheme: voiceRecordingTheme.merge(other.voiceRecordingTheme), ); } } diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index 9e3f2dd93..1ecabd864 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -9,3 +9,4 @@ export 'message_input_theme.dart'; export 'message_list_view_theme.dart'; export 'message_theme.dart'; export 'text_theme.dart'; +export 'voice_attachment_theme.dart'; 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 new file mode 100644 index 000000000..f48dbc081 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/voice_attachment_theme.dart @@ -0,0 +1,461 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template StreamVoiceRecordingThemeData} +/// The theme data for the voice recording attachment builder. +/// {@endtemplate} +class StreamVoiceRecordingThemeData with Diagnosticable { + /// {@macro StreamVoiceRecordingThemeData} + const StreamVoiceRecordingThemeData({ + required this.loadingTheme, + required this.sliderTheme, + required this.listPlayerTheme, + required this.playerTheme, + }); + + /// {@template ThemeDataLight} + /// Creates a theme data with light values. + /// {@endtemplate} + factory StreamVoiceRecordingThemeData.light() { + return StreamVoiceRecordingThemeData( + loadingTheme: StreamVoiceRecordingLoadingThemeData.light(), + sliderTheme: StreamVoiceRecordingSliderTheme.light(), + listPlayerTheme: StreamVoiceRecordingListPlayerThemeData.light(), + playerTheme: StreamVoiceRecordingPlayerThemeData.light(), + ); + } + + /// {@template ThemeDataDark} + /// Creates a theme data with dark values. + /// {@endtemplate} + factory StreamVoiceRecordingThemeData.dark() { + return StreamVoiceRecordingThemeData( + loadingTheme: StreamVoiceRecordingLoadingThemeData.dark(), + sliderTheme: StreamVoiceRecordingSliderTheme.dark(), + listPlayerTheme: StreamVoiceRecordingListPlayerThemeData.dark(), + playerTheme: StreamVoiceRecordingPlayerThemeData.dark(), + ); + } + + /// The theme for the loading widget. + final StreamVoiceRecordingLoadingThemeData loadingTheme; + + /// The theme for the slider widget. + final StreamVoiceRecordingSliderTheme sliderTheme; + + /// The theme for the list player widget. + final StreamVoiceRecordingListPlayerThemeData listPlayerTheme; + + /// The theme for the player widget. + final StreamVoiceRecordingPlayerThemeData playerTheme; + + /// {@template ThemeDataMerge} + /// Used to merge the values of another theme data object into this. + /// {@endtemplate} + StreamVoiceRecordingThemeData merge(StreamVoiceRecordingThemeData? other) { + if (other == null) return this; + return StreamVoiceRecordingThemeData( + loadingTheme: loadingTheme.merge(other.loadingTheme), + sliderTheme: sliderTheme.merge(other.sliderTheme), + listPlayerTheme: listPlayerTheme.merge(other.listPlayerTheme), + playerTheme: playerTheme.merge(other.playerTheme), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('loadingTheme', loadingTheme)) + ..add(DiagnosticsProperty('sliderTheme', sliderTheme)) + ..add(DiagnosticsProperty('listPlayerTheme', listPlayerTheme)) + ..add(DiagnosticsProperty('playerTheme', playerTheme)); + } +} + +/// {@template StreamAudioPlayerLoadingTheme} +/// The theme data for the voice recording attachment builder +/// loading widget [StreamVoiceRecordingLoading]. +/// {@endtemplate} +class StreamVoiceRecordingLoadingThemeData with Diagnosticable { + /// {@macro StreamAudioPlayerLoadingTheme} + const StreamVoiceRecordingLoadingThemeData({ + this.size, + this.strokeWidth, + this.color, + this.padding, + }); + + /// {@macro ThemeDataLight} + factory StreamVoiceRecordingLoadingThemeData.light() { + return const StreamVoiceRecordingLoadingThemeData( + size: Size(20, 20), + strokeWidth: 2, + color: Color(0xFF005FFF), + padding: EdgeInsets.all(8), + ); + } + + /// {@macro ThemeDataDark} + factory StreamVoiceRecordingLoadingThemeData.dark() { + return const StreamVoiceRecordingLoadingThemeData( + size: Size(20, 20), + strokeWidth: 2, + color: Color(0xFF005FFF), + padding: EdgeInsets.all(8), + ); + } + + /// The size of the loading indicator. + final Size? size; + + /// The stroke width of the loading indicator. + final double? strokeWidth; + + /// The color of the loading indicator. + final Color? color; + + /// The padding around the loading indicator. + final EdgeInsets? padding; + + /// {@macro ThemeDataMerge} + StreamVoiceRecordingLoadingThemeData merge( + StreamVoiceRecordingLoadingThemeData? other) { + if (other == null) return this; + return StreamVoiceRecordingLoadingThemeData( + size: other.size, + strokeWidth: other.strokeWidth, + color: other.color, + padding: other.padding, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('size', size)) + ..add(DiagnosticsProperty('strokeWidth', strokeWidth)) + ..add(ColorProperty('color', color)) + ..add(DiagnosticsProperty('padding', padding)); + } +} + +/// {@template StreamAudioPlayerSliderTheme} +/// The theme data for the voice recording attachment builder audio player +/// slider [StreamVoiceRecordingSlider]. +/// {@endtemplate} +class StreamVoiceRecordingSliderTheme with Diagnosticable { + /// {@macro StreamAudioPlayerSliderTheme} + const StreamVoiceRecordingSliderTheme({ + this.horizontalPadding = 10, + this.spacingRatio = 0.007, + this.waveHeightRatio = 1, + this.buttonBorderRadius = const BorderRadius.all(Radius.circular(8)), + this.buttonColor, + this.buttonBorderColor, + this.buttonBorderWidth = 1, + this.waveColorPlayed, + this.waveColorUnplayed, + this.buttonShadow = const BoxShadow( + color: Color(0x33000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + }); + + /// {@macro ThemeDataLight} + factory StreamVoiceRecordingSliderTheme.light() { + return const StreamVoiceRecordingSliderTheme( + buttonColor: Color(0xFFFFFFFF), + buttonBorderColor: Color(0x3308070733), + waveColorPlayed: Color(0xFF005DFF), + waveColorUnplayed: Color(0xFF7E828B), + ); + } + + /// {@macro ThemeDataDark} + factory StreamVoiceRecordingSliderTheme.dark() { + return const StreamVoiceRecordingSliderTheme( + buttonColor: Color(0xFF005FFF), + buttonBorderColor: Color(0x3308070766), + waveColorPlayed: Color(0xFF337EFF), + waveColorUnplayed: Color(0xFF7E828B), + ); + } + + /// The color of the slider button. + final Color? buttonColor; + + /// The color of the border of the slider button. + final Color? buttonBorderColor; + + /// The width of the border of the slider button. + final double? buttonBorderWidth; + + /// The shadow of the slider button. + final BoxShadow? buttonShadow; + + /// The border radius of the slider button. + final BorderRadius buttonBorderRadius; + + /// The horizontal padding of the slider. + final double horizontalPadding; + + /// Spacing ratios. This is the percentage that the space takes from the whole + /// available space. Typically this value should be between 0.003 to 0.02. + /// Default = 0.01 + final double spacingRatio; + + /// The percentage maximum value of waves. This can be used to reduce the + /// height of bars. Default = 1; + final double waveHeightRatio; + + /// Color of the waves to the left side of the slider button. + final Color? waveColorPlayed; + + /// Color of the waves to the right side of the slider button. + final Color? waveColorUnplayed; + + /// {@macro ThemeDataMerge} + StreamVoiceRecordingSliderTheme merge( + StreamVoiceRecordingSliderTheme? other) { + if (other == null) return this; + return StreamVoiceRecordingSliderTheme( + buttonColor: other.buttonColor, + buttonBorderColor: other.buttonBorderColor, + buttonBorderRadius: other.buttonBorderRadius, + horizontalPadding: other.horizontalPadding, + spacingRatio: other.spacingRatio, + waveHeightRatio: other.waveHeightRatio, + waveColorPlayed: other.waveColorPlayed, + waveColorUnplayed: other.waveColorUnplayed, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('buttonColor', buttonColor)) + ..add(ColorProperty('buttonBorderColor', buttonBorderColor)) + ..add(DiagnosticsProperty('buttonBorderRadius', buttonBorderRadius)) + ..add(DoubleProperty('horizontalPadding', horizontalPadding)) + ..add(DoubleProperty('spacingRatio', spacingRatio)) + ..add(DoubleProperty('waveHeightRatio', waveHeightRatio)) + ..add(ColorProperty('waveColorPlayed', waveColorPlayed)) + ..add(ColorProperty('waveColorUnplayed', waveColorUnplayed)); + } +} + +/// {@template StreamAudioListPlayerTheme} +/// The theme data for the voice recording attachment builder audio player +/// [StreamVoiceRecordingListPlayer]. +/// {@endtemplate} +class StreamVoiceRecordingListPlayerThemeData with Diagnosticable { + /// {@macro StreamAudioListPlayerTheme} + const StreamVoiceRecordingListPlayerThemeData({ + this.backgroundColor, + this.borderColor, + this.borderRadius, + this.margin, + }); + + /// {@macro ThemeDataLight} + factory StreamVoiceRecordingListPlayerThemeData.light() { + return StreamVoiceRecordingListPlayerThemeData( + backgroundColor: const Color(0xFFFFFFFF), + borderColor: const Color(0xFFDBDDE1), + borderRadius: BorderRadius.circular(14), + margin: const EdgeInsets.all(4), + ); + } + + /// {@macro ThemeDataDark} + factory StreamVoiceRecordingListPlayerThemeData.dark() { + return StreamVoiceRecordingListPlayerThemeData( + backgroundColor: const Color(0xFF17191C), + borderColor: const Color(0xFF272A30), + borderRadius: BorderRadius.circular(14), + margin: const EdgeInsets.all(4), + ); + } + + /// The background color of the list. + final Color? backgroundColor; + + /// The border color of the list. + final Color? borderColor; + + /// The border radius of the list. + final BorderRadius? borderRadius; + + /// The margin of the list. + final EdgeInsets? margin; + + /// {@macro ThemeDataMerge} + StreamVoiceRecordingListPlayerThemeData merge( + StreamVoiceRecordingListPlayerThemeData? other) { + if (other == null) return this; + return StreamVoiceRecordingListPlayerThemeData( + backgroundColor: other.backgroundColor, + borderColor: other.borderColor, + borderRadius: other.borderRadius, + margin: other.margin, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(ColorProperty('borderColor', borderColor)) + ..add(DiagnosticsProperty('borderRadius', borderRadius)) + ..add(DiagnosticsProperty('margin', margin)); + } +} + +/// {@template StreamVoiceRecordingPlayerTheme} +/// The theme data for the voice recording attachment builder audio player +/// {@endtemplate} +class StreamVoiceRecordingPlayerThemeData with Diagnosticable { + /// {@macro StreamVoiceRecordingPlayerTheme} + const StreamVoiceRecordingPlayerThemeData({ + this.playIcon = Icons.play_arrow, + this.pauseIcon = Icons.pause, + this.iconColor, + this.buttonBackgroundColor, + this.buttonPadding = const EdgeInsets.symmetric(horizontal: 6), + this.buttonShape = const CircleBorder(), + this.buttonElevation = 2, + this.speedButtonSize = const Size(44, 36), + this.speedButtonElevation = 2, + this.speedButtonPadding = const EdgeInsets.symmetric(horizontal: 8), + this.speedButtonBackgroundColor = const Color(0xFFFFFFFF), + this.speedButtonShape = const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(50)), + ), + this.speedButtonTextStyle = const TextStyle( + fontSize: 12, + color: Color(0xFF080707), + ), + this.fileTypeIcon = const StreamSvgIcon( + assetName: 'filetype_AAC.svg', + ), + this.fileSizeTextStyle = const TextStyle(fontSize: 10), + this.timerTextStyle, + }); + + /// {@macro ThemeDataLight} + factory StreamVoiceRecordingPlayerThemeData.light() { + return const StreamVoiceRecordingPlayerThemeData( + iconColor: Color(0xFF080707), + buttonBackgroundColor: Color(0xFFFFFFFF), + ); + } + + /// {@macro ThemeDataDark} + factory StreamVoiceRecordingPlayerThemeData.dark() { + return const StreamVoiceRecordingPlayerThemeData( + iconColor: Color(0xFF080707), + buttonBackgroundColor: Color(0xFFFFFFFF), + ); + } + + /// The icon to display when the player is paused/stopped. + final IconData playIcon; + + /// The icon to display when the player is playing. + final IconData pauseIcon; + + /// The color of the icons. + final Color? iconColor; + + /// The background color of the buttons. + final Color? buttonBackgroundColor; + + /// The padding of the buttons. + final EdgeInsets? buttonPadding; + + /// The shape of the buttons. + final OutlinedBorder? buttonShape; + + /// The elevation of the buttons. + final double? buttonElevation; + + /// The size of the speed button. + final Size? speedButtonSize; + + /// The elevation of the speed button. + final double? speedButtonElevation; + + /// The padding of the speed button. + final EdgeInsets? speedButtonPadding; + + /// The background color of the speed button. + final Color? speedButtonBackgroundColor; + + /// The shape of the speed button. + final OutlinedBorder? speedButtonShape; + + /// The text style of the speed button. + final TextStyle? speedButtonTextStyle; + + /// The icon to display for the file type. + final Widget? fileTypeIcon; + + /// The text style of the file size. + final TextStyle? fileSizeTextStyle; + + /// The text style of the timer. + final TextStyle? timerTextStyle; + + /// {@macro ThemeDataMerge} + StreamVoiceRecordingPlayerThemeData merge( + StreamVoiceRecordingPlayerThemeData? other) { + if (other == null) return this; + return StreamVoiceRecordingPlayerThemeData( + playIcon: other.playIcon, + pauseIcon: other.pauseIcon, + iconColor: other.iconColor, + buttonBackgroundColor: other.buttonBackgroundColor, + buttonPadding: other.buttonPadding, + buttonShape: other.buttonShape, + buttonElevation: other.buttonElevation, + speedButtonSize: other.speedButtonSize, + speedButtonElevation: other.speedButtonElevation, + speedButtonPadding: other.speedButtonPadding, + speedButtonBackgroundColor: other.speedButtonBackgroundColor, + speedButtonShape: other.speedButtonShape, + speedButtonTextStyle: other.speedButtonTextStyle, + fileTypeIcon: other.fileTypeIcon, + fileSizeTextStyle: other.fileSizeTextStyle, + timerTextStyle: other.timerTextStyle, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('playIcon', playIcon)) + ..add(DiagnosticsProperty('pauseIcon', pauseIcon)) + ..add(ColorProperty('iconColor', iconColor)) + ..add(ColorProperty('buttonBackgroundColor', buttonBackgroundColor)) + ..add(DiagnosticsProperty('buttonPadding', buttonPadding)) + ..add(DiagnosticsProperty('buttonShape', buttonShape)) + ..add(DoubleProperty('buttonElevation', buttonElevation)) + ..add(DiagnosticsProperty('speedButtonSize', speedButtonSize)) + ..add(DoubleProperty('speedButtonElevation', speedButtonElevation)) + ..add(DiagnosticsProperty('speedButtonPadding', speedButtonPadding)) + ..add(ColorProperty( + 'speedButtonBackgroundColor', speedButtonBackgroundColor)) + ..add(DiagnosticsProperty('speedButtonShape', speedButtonShape)) + ..add(DiagnosticsProperty('speedButtonTextStyle', speedButtonTextStyle)) + ..add(DiagnosticsProperty('fileTypeIcon', fileTypeIcon)) + ..add(DiagnosticsProperty('fileSizeTextStyle', fileSizeTextStyle)) + ..add(DiagnosticsProperty('timerTextStyle', timerTextStyle)); + } +} diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index d4406e3ee..3e0a59d64 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -11,6 +11,34 @@ import 'package:image_size_getter/image_size_getter.dart' hide Size; import 'package:stream_chat_flutter/src/localization/translations.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +int _byteUnitConversionFactor = 1024; + +/// int extensions +extension IntExtension on int { + /// Parses int in bytes to human readable size. Like: 17 KB + /// instead of 17524 bytes; + String toHumanReadableSize() { + if (this <= 0) return '0 B'; + const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + final i = (log(this) / log(_byteUnitConversionFactor)).floor(); + final numberValue = + (this / pow(_byteUnitConversionFactor, i)).toStringAsFixed(2); + final suffix = suffixes[i]; + return '$numberValue $suffix'; + } +} + +/// Durations extensions. +extension DurationExtension on Duration { + /// Transforms Duration to a minutes and seconds time. Like: 04:13. + String toMinutesAndSeconds() { + final minutes = inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = inSeconds.remainder(60).toString().padLeft(2, '0'); + + return '$minutes:$seconds'; + } +} + /// String extension extension StringExtension on String { /// Returns the capitalized string diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 8dd9c0351..f403a3c17 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -7,6 +7,9 @@ export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; export 'src/attachment/attachment.dart'; export 'src/attachment/builder/attachment_widget_builder.dart'; +export 'src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart'; +export 'src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart'; +export 'src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart'; export 'src/attachment/gallery_attachment.dart'; export 'src/attachment/handler/stream_attachment_handler.dart'; export 'src/attachment/image_attachment.dart'; @@ -91,6 +94,7 @@ 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/lib/svgs/filetype_AAC.svg b/packages/stream_chat_flutter/lib/svgs/filetype_AAC.svg new file mode 100644 index 000000000..726cd62f9 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_AAC.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index c49b72226..9c2c8380f 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: image_picker: ^1.0.2 image_size_getter: ^2.1.2 jiffy: ^6.2.1 + just_audio: ^0.9.37 lottie: ">=2.6.0 <4.0.0" meta: ^1.9.1 path_provider: ^2.1.0 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 new file mode 100644 index 000000000..39e57365c --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player_test.dart @@ -0,0 +1,56 @@ +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 new file mode 100644 index 000000000..174cebe05 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading_test.dart @@ -0,0 +1,20 @@ +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 new file mode 100644 index 000000000..1cb95a441 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player_test.dart @@ -0,0 +1,132 @@ +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 new file mode 100644 index 000000000..158d6e02c --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder_test.dart @@ -0,0 +1,53 @@ +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/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index df9976593..c677b3592 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -67,3 +67,5 @@ class MockStreamMemberListController extends Mock @override PagedValue value = const PagedValue.loading(); } + +class MocMessage extends Mock implements Message {}