diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index c3d089af48..36341c04ab 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -12,6 +12,7 @@ ✅ Added +- Support for async audio messages. - Added `presence` property to `Channel::watch` method. ## 5.3.0 diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 931a42fd8b..d3455bbb86 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -7,6 +7,7 @@ ✅ Added +- Support for async audio messages. - Now it is possible to customize the max lines of the title of a url attachment. Before it was always 1 line. - Added `attachmentActionsModalBuilder` parameter to `StreamMessageWidget` that allows to customize `AttachmentActionsModal`. diff --git a/packages/stream_chat_flutter/lib/src/attachment/audio/audio_loading_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_loading_attachment.dart new file mode 100644 index 0000000000..b34175c5f8 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_loading_attachment.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +/// {@template AudioLoadingMessage} +/// 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 AudioLoadingMessage extends StatelessWidget { + /// {@macro AudioLoadingMessage} + const AudioLoadingMessage({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 3, + ), + ), + Padding( + padding: EdgeInsets.only(left: 16), + child: Icon(Icons.mic), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/audio/audio_player_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_player_attachment.dart new file mode 100644 index 0000000000..c233b2f316 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_player_attachment.dart @@ -0,0 +1,307 @@ +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/audio/audio_loading_attachment.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/audio_wave_slider.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template AudioPlayerMessage} +/// 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 AudioPlayerMessage extends StatefulWidget { + /// {@macro AudioPlayerMessage} + const AudioPlayerMessage({ + 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 + _AudioPlayerMessageState createState() => _AudioPlayerMessageState(); +} + +class _AudioPlayerMessageState 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 AudioLoadingMessage(); + } + }, + ); + } + } + + Widget _content(Duration totalDuration) { + final streamChatThemeData = StreamChatTheme.of(context).primaryIconTheme; + + return Container( + padding: const EdgeInsets.all(8), + height: 60, + child: Row( + children: [ + SizedBox( + width: 36, + height: 36, + child: _controlButton(streamChatThemeData), + ), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _timer(totalDuration), + _fileSizeWidget(widget.fileSize), + ], + ), + ), + _audioWaveSlider(totalDuration), + _speedAndActionButton(), + ], + ), + ); + } + + Widget _controlButton(IconThemeData iconTheme) { + return StreamBuilder( + initialData: false, + stream: _playingThisStream(), + builder: (context, snapshot) { + final playingThis = snapshot.data == true; + + final icon = playingThis ? Icons.pause : Icons.play_arrow; + + 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: 2, + padding: const EdgeInsets.symmetric(horizontal: 6), + backgroundColor: Colors.white, + shape: const CircleBorder(), + ), + child: Icon(icon, color: Colors.black), + onPressed: () { + if (playingThis) { + _pause(); + } else { + _play(); + } + }, + ); + } else { + return const CircularProgressIndicator(strokeWidth: 3); + } + }, + ); + }, + ); + } + + Widget _speedAndActionButton() { + final showSpeed = _playingThisStream().flatMap((showSpeed) => + widget.player.speedStream.map((speed) => showSpeed ? speed : -1.0)); + + return StreamBuilder( + initialData: -1, + stream: showSpeed, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data! > 0) { + final speed = snapshot.data!; + return SizedBox( + width: 44, + height: 36, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + elevation: 2, + backgroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + ), + child: Text( + '${speed}x', + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + 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 const SizedBox( + width: 44, + height: 36, + child: StreamSvgIcon.filetypeAac(), + ); + } + } + }, + ); + } + + Widget _fileSizeWidget(int? fileSize) { + if (fileSize != null) { + return Text( + fileSize.toHumanReadableSize(), + style: const TextStyle(fontSize: 10), + ); + } else { + return const SizedBox.shrink(); + } + } + + Widget _timer(Duration totalDuration) { + 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()); + } else { + return Text(totalDuration.toMinutesAndSeconds()); + } + }, + ); + } + + Widget _audioWaveSlider(Duration totalDuration) { + final positionStream = widget.player.currentIndexStream.flatMap( + (index) => widget.player.positionStream.map((duration) => _sliderValue( + duration, + totalDuration, + index, + )), + ); + + return Expanded( + child: AudioWaveSlider( + bars: 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/audio/audio_player_compose_message.dart b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_player_compose_message.dart new file mode 100644 index 0000000000..0652417e40 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_player_compose_message.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/audio_player_attachment.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template audioListPlayer} +/// AudioPlayer intended to be used inside message composer. This audio player +/// plays only one music at a time. +/// {@endtemplate} +class AudioPlayerComposeMessage extends StatefulWidget { + /// {@macro audioListPlayer} + const AudioPlayerComposeMessage({ + super.key, + required this.attachment, + this.actionButton, + }); + + /// Attachment of the audio + final Attachment attachment; + + /// An action button to that can be used to perform actions. Example: Delete + /// audio from compose. + final Widget? actionButton; + + @override + State createState() => + _AudioPlayerComposeMessageState(); +} + +class _AudioPlayerComposeMessageState extends State { + final _player = AudioPlayer(); + StreamSubscription? stateSubscription; + + @override + Widget build(BuildContext context) { + final audioFilePath = widget.attachment.file?.path; + Widget playerMessage; + + if (widget.attachment.file?.path != null) { + _player.setAudioSource(AudioSource.file(audioFilePath!)); + } else if (widget.attachment.assetUrl != null) { + _player.setAudioSource( + AudioSource.uri(Uri.parse(widget.attachment.assetUrl!)), + ); + } + + Duration duration; + if (widget.attachment.extraData['duration'] != null) { + duration = Duration( + milliseconds: widget.attachment.extraData['duration']! as int, + ); + } else { + duration = Duration.zero; + } + + List waveBars; + if (widget.attachment.extraData['waveList'] != null) { + waveBars = (widget.attachment.extraData['waveList']! as List) + .map((e) => double.tryParse(e.toString())) + .whereNotNull() + .toList(); + } else { + waveBars = List.filled(60, 0); + } + + stateSubscription = _player.playerStateStream.listen((state) { + if (state.processingState == ProcessingState.completed) { + _player.stop().then((value) => _player.seek(Duration.zero, index: 0)); + } + }); + + playerMessage = AudioPlayerMessage( + player: _player, + duration: duration, + waveBars: waveBars, + fileSize: widget.attachment.fileSize, + actionButton: widget.actionButton, + ); + + final colorTheme = StreamChatTheme.of(context).colorTheme; + + return Container( + decoration: BoxDecoration( + color: colorTheme.barsBg, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorTheme.borders, + ), + ), + width: MediaQuery.of(context).size.width * 0.65, + child: playerMessage, + ); + } + + @override + void dispose() { + super.dispose(); + _player.dispose(); + stateSubscription?.cancel(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/audio/audio_player_list.dart b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_player_list.dart new file mode 100644 index 0000000000..b190318a5f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_player_list.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/audio_loading_attachment.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/audio_player_attachment.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template AudioListPlayer} +/// Display many audios and displays a list of AudioPlayerMessage. +/// {@endtemplate} +class AudioListPlayer extends StatefulWidget { + /// {@macro AudioListPlayer} + const AudioListPlayer({ + super.key, + required this.attachments, + this.attachmentBorderRadiusGeometry, + this.constraints, + }); + + /// List of audio attachments. + final List attachments; + + /// The border radius of each audio. + final BorderRadiusGeometry? attachmentBorderRadiusGeometry; + + /// Constraints of audio attachments + final BoxConstraints? constraints; + + @override + State createState() => _AudioListPlayerState(); +} + +class _AudioListPlayerState extends State { + final _player = AudioPlayer(); + late StreamSubscription _playerStateChangedSubscription; + + Widget _createAudioPlayer(int index, Attachment attachment) { + final url = attachment.assetUrl; + Widget playerMessage; + + if (url == null) { + playerMessage = const AudioLoadingMessage(); + } else { + Duration duration; + if (attachment.extraData['duration'] != null) { + duration = + Duration(milliseconds: attachment.extraData['duration']! as int); + } else { + duration = Duration.zero; + } + + List? waveBars; + if (attachment.extraData['waveList'] != null) { + waveBars = (attachment.extraData['waveList']! as List) + .map((e) => double.tryParse(e.toString())) + .whereNotNull() + .toList(); + } else { + waveBars = null; + } + + playerMessage = AudioPlayerMessage( + player: _player, + duration: duration, + waveBars: waveBars, + index: index, + ); + } + + final colorTheme = StreamChatTheme.of(context).colorTheme; + + return Container( + margin: const EdgeInsets.all(2), + constraints: widget.constraints, + decoration: BoxDecoration( + color: colorTheme.barsBg, + border: Border.all( + color: colorTheme.borders, + ), + borderRadius: + widget.attachmentBorderRadiusGeometry ?? BorderRadius.circular(10), + ), + child: playerMessage, + ); + } + + 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.attachments + .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.attachments.mapIndexed(_createAudioPlayer).toList(), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/audio/audio_wave_bars_widget.dart b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_wave_bars_widget.dart new file mode 100644 index 0000000000..f976ca1263 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_wave_bars_widget.dart @@ -0,0 +1,197 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:record/record.dart'; + +const _maxAmplitude = 50; +const _maxBars = 70; + +/// {@template AudioWaveBars} +/// A Widget that draws the audio wave bars for an audio. This Widget is +/// intended to be used as a feedback for the input of voice recording. +/// {@endtemplate} +class AudioWaveBars extends StatefulWidget { + /// {@macro AudioWaveBars} + const AudioWaveBars({ + super.key, + required this.amplitudeStream, + this.numberOfBars = _maxBars, + this.colorLeft = Colors.blueAccent, + this.colorRight = Colors.grey, + this.barHeightRatio = 1, + this.spacingRatio = 0.007, + this.inverse = true, + }); + + /// Stream of Amplitude. The Amplitude will be converted to the wave bars. + final Stream amplitudeStream; + + /// The number of bars that will be draw in the screen. When the number of + /// bars is bigger than this number only the X last bars will be shown. + final int numberOfBars; + + /// The color of the bars showing audio that was already recorded. + final Color colorRight; + + /// The color of the bars showing audio that was not recorded. + final Color colorLeft; + + /// The percentage maximum value of bars. This can be used to reduce the + /// height of bars. Default = 1; + final double barHeightRatio; + + /// 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.01. + /// Default = 0.007 + final double spacingRatio; + + /// When inverse is enabled the bars grow from right to left. + final bool inverse; + + /// Creates a copy of AudioWaveBars use this to customize the default version + /// of AudioWaveBars. + AudioWaveBars copyWith({ + int? numberOfBars, + Color? colorRight, + Color? colorLeft, + double? barHeightRatio, + double? spacingRatio, + bool? inverse, + }) { + return AudioWaveBars( + amplitudeStream: amplitudeStream, + numberOfBars: numberOfBars ?? this.numberOfBars, + colorRight: colorRight ?? this.colorRight, + colorLeft: colorLeft ?? this.colorLeft, + barHeightRatio: barHeightRatio ?? this.barHeightRatio, + spacingRatio: spacingRatio ?? this.spacingRatio, + inverse: inverse ?? this.inverse, + ); + } + + @override + State createState() => _AudioWaveBarsState(); +} + +class _AudioWaveBarsState extends State { + final barsQueue = QueueList(_maxBars); + late Stream> barsStream; + + @override + void initState() { + super.initState(); + + barsStream = widget.amplitudeStream.map((amplitude) { + if (barsQueue.length == _maxBars) { + barsQueue.removeLast(); + } + + barsQueue.addFirst((amplitude.current + _maxAmplitude) / _maxAmplitude); + return barsQueue; + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return StreamBuilder>( + initialData: List.empty(), + stream: barsStream, + builder: (context, snapshot) { + return CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _AudioBarsPainter( + bars: snapshot.data ?? List.empty(), + numberOfBars: widget.numberOfBars, + colorLeft: widget.colorLeft, + colorRight: widget.colorRight, + barHeightRatio: widget.barHeightRatio, + spacingRatio: widget.spacingRatio, + inverse: widget.inverse, + ), + ); + }, + ); + }, + ); + } +} + +class _AudioBarsPainter extends CustomPainter { + _AudioBarsPainter({ + required this.bars, + this.numberOfBars = _maxBars, + this.colorLeft = Colors.blueAccent, + this.colorRight = Colors.grey, + this.barHeightRatio = 1, + this.spacingRatio = 0.007, + this.inverse = false, + }); + + final List bars; + final int numberOfBars; + final Color colorRight; + final Color colorLeft; + final double barHeightRatio; + final double spacingRatio; + final bool inverse; + + double _barHeight(double barValue, totalHeight) { + return max(barValue * totalHeight * barHeightRatio, 2); + } + + @override + void paint(Canvas canvas, Size size) { + final totalWidth = size.width; + final spacingWidth = totalWidth * spacingRatio; + final totalBarWidth = totalWidth - spacingWidth * (numberOfBars - 1); + final barWidth = totalBarWidth / numberOfBars; + final barY = size.height / 2; + final List dataBars; + var hasRemainingBars = false; + + void drawBar(int i, double barValue, Color color) { + final barHeight = _barHeight(barValue, size.height); + final barX = inverse + ? totalWidth - i * (barWidth + spacingWidth) + barWidth / 2 + : i * (barWidth + spacingWidth) + barWidth / 2; + + final rect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(barX, barY), + width: barWidth, + height: barHeight, + ), + const Radius.circular(50), + ); + + final paint = Paint()..color = color; + canvas.drawRRect(rect, paint); + } + + if (bars.length > numberOfBars) { + // Misconfiguration, bars.length should never be bigger + // than numberOfBars. + dataBars = bars.take(numberOfBars).toList(); + } else { + hasRemainingBars = numberOfBars > bars.length; + dataBars = bars; + } + + // Drawing bars with real data + dataBars.forEachIndexed((i, bar) => drawBar(i, bar, colorLeft)); + + // Drawing remaining bars + if (hasRemainingBars) { + for (var i = bars.length - 1; i < numberOfBars; i++) { + drawBar(i, 0, colorRight); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/audio/audio_wave_slider.dart b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_wave_slider.dart new file mode 100644 index 0000000000..d8f12464fe --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/audio/audio_wave_slider.dart @@ -0,0 +1,246 @@ +import 'dart:math'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +/// {@template AudioWaveSlider} +/// 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 AudioWaveSlider extends StatefulWidget { + /// {@macro AudioWaveSlider} + const AudioWaveSlider({ + super.key, + required this.bars, + required this.progressStream, + this.onChangeStart, + this.onChanged, + this.onChangeEnd, + this.customSliderButton, + this.customSliderButtonWidth, + this.horizontalPadding = 10, + this.spacingRatio = 0.01, + this.barHeightRatio = 1, + this.colorRight = Colors.grey, + this.colorLeft = Colors.blueAccent, + }); + + /// The audio bars from 0.0 to 1.0. + final List bars; + + /// 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; + + /// Horizontal padding. Use this when using a wide customSliderButton. + final int 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 bars. This can be used to reduce the + /// height of bars. Default = 1; + final double barHeightRatio; + + /// Color of the bars to the left side of the slider button. + final Color colorLeft; + + /// Color of the bars to the right side of the slider button. + final Color colorRight; + + @override + _AudioWaveSliderState createState() => _AudioWaveSliderState(); +} + +class _AudioWaveSliderState 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) { + final availableWidth = constraints.maxWidth - widget.horizontalPadding * 2; + + return availableWidth * progress - + _currentWidth / 2 + + widget.horizontalPadding; + } + + @override + Widget build(BuildContext context) { + 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: Colors.white, + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(6), + ), + ); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _AudioBarsPainter( + bars: widget.bars, + spacingRatio: widget.spacingRatio, + barHeightRatio: widget.barHeightRatio, + colorLeft: widget.colorLeft, + colorRight: widget.colorRight, + progressPercentage: progress, + padding: widget.horizontalPadding, + ), + ), + AnimatedPositioned( + duration: animationDuration, + left: _progressToWidth(constraints, progress), + 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 int 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/audio/list_normalization.dart b/packages/stream_chat_flutter/lib/src/attachment/audio/list_normalization.dart new file mode 100644 index 0000000000..258fa5e5de --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/audio/list_normalization.dart @@ -0,0 +1,137 @@ +import 'dart:math'; + +/// Normalizer of wave bars. This class shrinks bars, when they List has many +/// items, by calculating the median values, or expands the list then it has +/// to little items, by repeating items. +/// +/// It is important to normalize the bars to avoid audio message with too many +/// or too little bars, which would cause then to look ugly. +class ListNormalization { + /// Shrinks the list by taking medians. The resulting list will have the + /// listSize. + static List shrinkWidth(List inputList, int listSize) { + final resultList = List.empty(growable: true); + + final pace = inputList.length / listSize; + var acc = 0.0; + + /// Each time the pace is summed, it round is take. It we round pace only + /// one time, it deviates too much from the true median of all elements. + /// The last page is calculated separately. + while (acc <= inputList.length - pace) { + final median = inputList + .sublist(acc.round(), (acc + pace).round()) + .reduce((a, b) => a + b) / + pace.round(); + + resultList.add(median); + + acc += pace; + } + + final lastListSize = (inputList.length % pace).round(); + + /// The last page. + if (lastListSize > 0) { + final lastBar = inputList + .sublist(inputList.length - lastListSize) + .reduce((a, b) => a + b) / + lastListSize; + + resultList.add(lastBar); + } + + return resultList; + } + + /// Expands the list by repeating the values. The resulting list will be the + /// size of listSize. + static List _expandList(List inputList, int listSize) { + if (inputList.isEmpty) return List.filled(listSize, 0); + + final differenceRatio = listSize / inputList.length; + + final resultList = List.empty(growable: true); + + if (differenceRatio > 2) { + final pace = differenceRatio.round(); + + // Here we repeat the elements excluding the last page. It is done this + // because the last page can be a little bigger or a little shorter. + // Because of that, there a special logic for it. + inputList.take(inputList.length - 1).forEach((bar) { + resultList.addAll(List.filled(pace, bar)); + }); + + final remainingSize = listSize - resultList.length; + + // The last page. + if (remainingSize > 0) { + return resultList + List.filled(remainingSize, resultList.last); + } else { + return resultList; + } + } else { + /// In this case the resulting list must not be at least 2x the size of + /// the input list. Then only the first percentage of the bars is + /// duplicated. This case may produce bars that not very close of the + /// truth. + const pace = 2; + final duplicateElements = + ((differenceRatio - 1) * inputList.length).round(); + + inputList + .take(min(duplicateElements, inputList.length - 1)) + .forEach((bar) { + resultList.addAll(List.filled(pace, bar)); + }); + + final remainingSize = listSize - resultList.length; + + if (remainingSize > 0) { + return resultList + List.filled(remainingSize, resultList.last); + } else { + return resultList; + } + } + } + + /// This methods assumes that all elements are positives numbers . + static List _normalizeBarsHeight(List inputList) { + var maxValue = 0.0; + var minValue = 0.0; + inputList.forEach((e) { + maxValue = max(maxValue, e); + minValue = min(minValue, e); + }); + + final range = maxValue - minValue; + + if (range > 0) { + return inputList.map((e) => e / range).toList(); + } else { + return inputList; + } + } + + /// Normalizes the bars by expanding it or shrinking when needed. This also + /// normalizes the height by the highest value. + static List normalizeBars( + List inputList, + int listSize, + double minValue, + ) { + //First it is necessary to ensure that all element are positive. + final positiveList = + minValue < 0 ? inputList.map((e) => e - minValue).toList() : inputList; + + //Now we take the median of the elements + final widthNormalized = listSize > inputList.length + ? _expandList(positiveList, listSize) + : shrinkWidth(positiveList, listSize); + + //At last normalisation of the height of the bars. The result of this method + //will be a list of bars a bit bigger in high. + return _normalizeBarsHeight(widthNormalized); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/audio/record_controller.dart b/packages/stream_chat_flutter/lib/src/attachment/audio/record_controller.dart new file mode 100644 index 0000000000..3e056beccb --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/audio/record_controller.dart @@ -0,0 +1,134 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:record/record.dart'; +import 'package:rxdart/subjects.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/wave_bars_normalizer.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template recordController} +/// Controller of audio record. This call can be used to controll the recording +/// logic programmatically. +/// {@endtemplate} +class StreamRecordController { + /// {@macro WaveBarsNormalizer} + StreamRecordController({required this.audioRecorder}); + + /// Actual Recorder class + final Record audioRecorder; + + final _stopwatch = Stopwatch(); + final _amplitudeController = BehaviorSubject(); + final _recordStateController = BehaviorSubject(); + StreamSink? _amplitudeSink; + StreamSink? _recordStateSink; + + StreamSubscription? _recordStateSubscription; + + WaveBarsNormalizer? _waveBarsNormalizer; + RecordState _recordingState = RecordState.stop; + + /// Stream that provides the current record state. + Stream get recordState => _recordStateController.stream; + + /// A Stream that provides the amplitude variation of the record. + Stream get amplitudeStream => _amplitudeController.stream; + + /// Initialization of the controller. + void init() { + _amplitudeSink = _amplitudeController.sink; + _amplitudeSink?.addStream( + audioRecorder.onAmplitudeChanged( + const Duration(milliseconds: 100), + ), + ); + + _recordStateSink = _recordStateController.sink; + _recordStateSink?.addStream(audioRecorder.onStateChanged()); + + _recordStateSubscription = recordState.listen((state) { + _recordingState = state; + }); + + _waveBarsNormalizer = WaveBarsNormalizer(barsStream: amplitudeStream); + } + + /// Starts recording + Future record() async { + try { + if (await audioRecorder.hasPermission()) { + if (_recordingState == RecordState.stop) { + HapticFeedback.heavyImpact(); + await audioRecorder.start(); + _stopwatch + ..reset() + ..start(); + + _waveBarsNormalizer?.start(); + } else if (_recordingState == RecordState.pause) { + await audioRecorder.resume(); + _stopwatch.start(); + } + } + } catch (e) { + print(e); + } + } + + /// Pause recording. Recording can be resume by calling record(). + Future pauseRecording() async { + _stopwatch.stop(); + await audioRecorder.pause(); + } + + /// Cancel recording. Once cancelled, the recording can't be resumed. + Future cancelRecording() async { + _waveBarsNormalizer?.reset(); + await audioRecorder.stop(); + } + + /// Finishes recording and returns the audio file as an attachment. + Future finishRecording() async { + final recordDuration = _stopwatch.elapsed; + final path = await audioRecorder.stop(); + + if (path != null) { + final uri = Uri.parse(path); + final file = File(uri.path); + + final waveList = _waveBarsNormalizer?.normalizedBars(40); + _waveBarsNormalizer?.reset(); + + try { + final fileSize = await file.length(); + return Attachment( + type: 'audio_recording', + file: AttachmentFile( + size: fileSize, + path: uri.path, + ), + extraData: { + 'duration': recordDuration.inMilliseconds, + 'waveList': waveList, + }, + ); + } catch (e) { + return null; + } + } else { + return null; + } + } + + /// Disposes the controller. + void dispose() { + audioRecorder.dispose().then((value) { + _amplitudeSink?.close(); + _amplitudeController.close(); + _recordStateController.close(); + }); + + _recordStateSubscription?.cancel(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/audio/wave_bars_normalizer.dart b/packages/stream_chat_flutter/lib/src/attachment/audio/wave_bars_normalizer.dart new file mode 100644 index 0000000000..89fd4beeef --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/audio/wave_bars_normalizer.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/list_normalization.dart'; + +/// {@template WaveBarsNormalizer} +/// This class holds the value bars to later provide the normalized bars. +/// The values should be provided by the barsStream and they will be kept until +/// needed. +/// {@endtemplate} +class WaveBarsNormalizer { + /// {@macro WaveBarsNormalizer} + WaveBarsNormalizer({required this.barsStream}); + + /// The stream of amplitude of audio recorded. + final Stream barsStream; + + StreamSubscription? _barsSubscription; + final List _barsList = List.empty(growable: true); + + double _minValue = 0; + + /// Start listening to amplitude of the recoded sounds. + void start() { + _barsSubscription = barsStream + .where((amplitude) => amplitude.current > -1000) + .map((amplitude) { + return amplitude.current; + }).listen((barValue) { + _minValue = min(_minValue, barValue); + _barsList.add(barValue); + }); + } + + /// Provides the normalized bars. + List normalizedBars(int outputLength) { + return ListNormalization.normalizeBars(_barsList, outputLength, _minValue); + } + + /// Clear the state of this class. Use this after calling normalizedBars to + /// avoid using too much memory and causing memory overflow. + void reset() { + _minValue = 0; + _barsList.clear(); + } + + /// Disposes the class. + void dispose() { + _minValue = 0; + _barsList.clear(); + _barsSubscription?.cancel(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart b/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart index 64f5de8918..5d9675c08a 100644 --- a/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart @@ -84,6 +84,7 @@ class _EditMessageSheetState extends State { Navigator.of(context).pop(); return m; }, + enableAudioRecord: false, ), ], ), diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel/channel_preview.dart index 94c118c8bd..626e5ce6bd 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_preview.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_preview.dart @@ -431,6 +431,8 @@ class _LastMessage extends StatelessWidget { return '🎬'; } else if (e.type == 'giphy') { return '[GIF]'; + } else if (e.type == 'audio_recording') { + return '\u{1F399}'; } return e == lastMessage.attachments.last ? (e.title ?? 'File') 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 91e65d3717..900b064356 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 @@ -42,6 +42,8 @@ class StreamMessagePreviewText extends StatelessWidget { return '🎬'; } else if (it.type == 'giphy') { return '[GIF]'; + } else if (it.type == 'audio_recording') { + return '\u{1F399}'; } return it == message.attachments.last ? (it.title ?? 'File') diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index 71254e734c..14a1364a43 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -361,6 +361,9 @@ abstract class Translations { /// The text for "MUTE"/"UNMUTE" based on the value of [isMuted]. String toggleMuteUnmuteAction({required bool isMuted}); + + /// The text in the snack bar of tap in the record button. + String get holdToStartRecording; } /// Default implementation of Translation strings for the stream chat widgets @@ -808,4 +811,7 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String get allowFileAccessMessage => 'Allow access to files'; + + @override + String get holdToStartRecording => 'Hold to start recording.'; } 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 deleted file mode 100644 index e559b58acc..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_button.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template attachmentButton} -/// A button for adding attachments to a chat on mobile. -/// {@endtemplate} -class AttachmentButton extends StatelessWidget { - /// {@macro attachmentButton} - const AttachmentButton({ - super.key, - required this.color, - required this.onPressed, - }); - - /// The color of the button. - final Color color; - - /// The callback to perform when the button is tapped or clicked. - final VoidCallback onPressed; - - /// Returns a copy of this object with the given fields updated. - AttachmentButton copyWith({ - Key? key, - Color? color, - VoidCallback? onPressed, - }) { - return AttachmentButton( - key: key ?? this.key, - color: color ?? this.color, - onPressed: onPressed ?? this.onPressed, - ); - } - - @override - Widget build(BuildContext context) { - return IconButton( - icon: StreamSvgIcon.attach( - color: color, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - splashRadius: 24, - onPressed: onPressed, - ); - } -} 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 deleted file mode 100644 index 218cdf81f7..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_input/command_button.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart'; - -/// {@template commandButton} -/// The button that allows a user to use commands in a chat. -/// {@endtemplate} -class CommandButton extends StatelessWidget { - /// {@macro commandButton} - const CommandButton({ - super.key, - required this.color, - required this.onPressed, - }); - - /// The color of the button. - final Color color; - - /// The action to perform when the button is pressed or clicked. - final VoidCallback onPressed; - - /// Returns a copy of this object with the given fields updated. - CommandButton copyWith({ - Key? key, - Color? color, - VoidCallback? onPressed, - }) { - return CommandButton( - key: key ?? this.key, - color: color ?? this.color, - onPressed: onPressed ?? this.onPressed, - ); - } - - @override - Widget build(BuildContext context) { - return IconButton( - icon: StreamSvgIcon.lightning( - color: color, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - splashRadius: 24, - onPressed: onPressed, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_input/on_press_button.dart b/packages/stream_chat_flutter/lib/src/message_input/on_press_button.dart new file mode 100644 index 0000000000..f3f9dc9704 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/on_press_button.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart'; + +/// {@template onPressButton} +/// The button that allows a user to use commands in a chat. Use the redirection +/// constructors of this button to create pre defined buttons. +/// {@endtemplate} +class OnPressButton extends StatelessWidget { + /// {@macro onPressButton} + const OnPressButton({ + super.key, + required this.icon, + required this.onPressed, + this.padding, + }); + + /// Attachment button + OnPressButton.attachment({ + Key? key, + required Color color, + required VoidCallback onPressed, + }) : this( + key: key, + icon: StreamSvgIcon.attach(color: color), + onPressed: onPressed, + ); + + /// Command button + OnPressButton.command({ + Key? key, + required Color color, + required VoidCallback onPressed, + }) : this( + key: key, + icon: StreamSvgIcon.lightning(color: color), + onPressed: onPressed, + ); + + /// Command button + OnPressButton.confirmAudio({ + Key? key, + required VoidCallback onPressed, + Color? color, + }) : this( + key: key, + icon: StreamSvgIcon.checkSend(color: color), + onPressed: onPressed, + ); + + /// Command button + OnPressButton.pauseRecord({ + Key? key, + required VoidCallback onPressed, + Color color = Colors.red, + }) : this( + key: key, + icon: StreamSvgIcon.pause(color: color), + onPressed: onPressed, + ); + + /// Command button + OnPressButton.resumeRecord({ + Key? key, + required VoidCallback onPressed, + Color color = Colors.red, + }) : this( + key: key, + icon: StreamSvgIcon.microphone(color: color), + onPressed: onPressed, + ); + + /// Command button + OnPressButton.cancelRecord({ + Key? key, + required VoidCallback onPressed, + Color color = Colors.blue, + }) : this( + key: key, + icon: StreamSvgIcon.delete(color: color), + onPressed: onPressed, + ); + + /// Icon of the button + final StreamSvgIcon icon; + + /// The action to perform when the button is pressed or clicked. + final VoidCallback onPressed; + + /// Padding of button. + final EdgeInsetsGeometry? padding; + + /// Returns a copy of this object with the given fields updated. + OnPressButton copyWith({ + Key? key, + StreamSvgIcon? icon, + VoidCallback? onPressed, + }) { + return OnPressButton( + key: key ?? this.key, + icon: icon ?? this.icon, + onPressed: onPressed ?? this.onPressed, + ); + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: icon, + padding: padding ?? EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + onPressed: onPressed, + ); + } +} 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 8de663e178..2558e1bf83 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 @@ -1,4 +1,5 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; import 'package:stream_chat_flutter/src/message_input/clear_input_item_button.dart'; @@ -15,6 +16,7 @@ class StreamQuotedMessageWidget extends StatelessWidget { super.key, required this.message, required this.messageTheme, + required this.chatThemeData, this.reverse = false, this.showBorder = false, this.textLimit = 170, @@ -31,6 +33,9 @@ class StreamQuotedMessageWidget extends StatelessWidget { /// The message theme final StreamMessageThemeData messageTheme; + /// The chat theme + final StreamChatThemeData chatThemeData; + /// If true the widget will be mirrored final bool reverse; @@ -66,6 +71,7 @@ class StreamQuotedMessageWidget extends StatelessWidget { composing: composing, onQuotedMessageClear: onQuotedMessageClear, messageTheme: messageTheme, + chatThemeData: chatThemeData, showBorder: showBorder, reverse: reverse, attachmentThumbnailBuilders: attachmentThumbnailBuilders, @@ -107,6 +113,7 @@ class _QuotedMessage extends StatelessWidget { required this.composing, required this.onQuotedMessageClear, required this.messageTheme, + required this.chatThemeData, required this.showBorder, required this.reverse, this.attachmentThumbnailBuilders, @@ -117,6 +124,7 @@ class _QuotedMessage extends StatelessWidget { final bool composing; final VoidCallback? onQuotedMessageClear; final StreamMessageThemeData messageTheme; + final StreamChatThemeData chatThemeData; final bool showBorder; final bool reverse; @@ -126,6 +134,9 @@ class _QuotedMessage extends StatelessWidget { bool get _hasAttachments => message.attachments.isNotEmpty; + bool get _isVoiceMessage => + message.attachments.any((e) => e.type == 'audio_recording'); + bool get _containsText => message.text?.isNotEmpty == true; bool get _containsLinkAttachment => @@ -137,9 +148,19 @@ class _QuotedMessage extends StatelessWidget { @override Widget build(BuildContext context) { final isOnlyEmoji = message.text!.isOnlyEmoji; - var msg = _hasAttachments && !_containsText - ? message.copyWith(text: message.attachments.last.title ?? '') - : message; + Message msg; + final audioRecordDuration = message.attachments + .firstWhereOrNull((e) => e.type == 'audio_recording') + ?.extraData['duration'] as int?; + + if (_isVoiceMessage) { + msg = message.copyWith(text: 'Voice note'); + } else if (_hasAttachments && !_containsText) { + msg = message.copyWith(text: message.attachments.last.title ?? ''); + } else { + msg = message; + } + if (msg.text!.length > textLimit) { msg = msg.copyWith(text: '${msg.text!.substring(0, textLimit - 3)}...'); } @@ -161,19 +182,35 @@ class _QuotedMessage extends StatelessWidget { ), if (msg.text!.isNotEmpty && !_isGiphy) Flexible( - child: StreamMessageText( - message: msg, - messageTheme: isOnlyEmoji && _containsText - ? messageTheme.copyWith( - messageTextStyle: messageTheme.messageTextStyle?.copyWith( - fontSize: 32, - ), - ) - : messageTheme.copyWith( - messageTextStyle: messageTheme.messageTextStyle?.copyWith( - fontSize: 12, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamMessageText( + message: msg, + messageTheme: isOnlyEmoji && _containsText + ? messageTheme.copyWith( + messageTextStyle: + messageTheme.messageTextStyle?.copyWith( + fontSize: 32, + ), + ) + : messageTheme.copyWith( + messageTextStyle: + messageTheme.messageTextStyle?.copyWith( + fontSize: 12, + ), + ), + ), + if (_isVoiceMessage && audioRecordDuration != null) + Text( + Duration( + milliseconds: audioRecordDuration, + ).toMinutesAndSeconds(), + style: chatThemeData.textTheme.footnote.copyWith( + color: chatThemeData.colorTheme.textLowEmphasis, ), + ), + ], ), ), ].insertBetween(const SizedBox(width: 8)); @@ -240,8 +277,9 @@ class _ParseAttachments extends StatelessWidget { attachment = message.attachments.last; if (attachmentThumbnailBuilders?.containsKey(attachment.type) == true) { attachmentBuilder = attachmentThumbnailBuilders![attachment.type]; + } else { + attachmentBuilder = _defaultAttachmentBuilder[attachment.type]; } - attachmentBuilder = _defaultAttachmentBuilder[attachment.type]; if (attachmentBuilder == null) { child = const Offstage(); } else { @@ -252,7 +290,7 @@ class _ParseAttachments extends StatelessWidget { return Material( clipBehavior: Clip.hardEdge, type: MaterialType.transparency, - shape: attachment.type == 'file' + shape: attachment.type == 'file' || attachment.type == 'audio_recording' ? null : RoundedRectangleBorder( side: const BorderSide(width: 0, color: Colors.transparent), @@ -314,6 +352,13 @@ class _ParseAttachments extends StatelessWidget { ), ); }, + 'audio_recording': (_, attachment) { + return const SizedBox( + height: 32, + width: 32, + child: StreamSvgIcon.filetypeAac(), + ); + }, }; } } diff --git a/packages/stream_chat_flutter/lib/src/message_input/record/record_button.dart b/packages/stream_chat_flutter/lib/src/message_input/record/record_button.dart new file mode 100644 index 0000000000..c5d6f46a68 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/record/record_button.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart'; + +/// {@template recordButton} +/// Record button that start the recording process. This button doesn't have +/// any logic related to recording, this should be provided in the callbacks. +/// This button allows to set callback to onHold and onPressed. +/// {@endtemplate} +class RecordButton extends StatelessWidget { + /// {@macro recordButton} + const RecordButton({ + super.key, + required this.icon, + this.onPressed, + this.onHold, + this.padding, + }); + + /// Creates the default button to start the recording. + const RecordButton.startButton({ + Key? key, + required VoidCallback onHold, + VoidCallback? onPressed, + }) : this( + key: key, + onHold: onHold, + icon: const StreamSvgIcon.microphone(size: 20), + onPressed: onPressed, + padding: EdgeInsets.zero, + ); + + /// Callback for holding the button. + final VoidCallback? onHold; + + /// Callback for pressing the button. + final VoidCallback? onPressed; + + /// Icon of the button. + final Widget icon; + + /// Padding of button + final EdgeInsetsGeometry? padding; + + /// Returns a copy of this object with the given fields updated. + RecordButton copyWith({ + Key? key, + StreamSvgIcon? icon, + VoidCallback? onPressed, + VoidCallback? onHold, + }) { + return RecordButton( + key: key ?? this.key, + icon: icon ?? this.icon, + onPressed: onPressed ?? this.onPressed, + onHold: onHold ?? this.onPressed, + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onLongPress: () { + onHold?.call(); + }, + child: Padding( + padding: padding ?? const EdgeInsets.all(8), + child: IconButton( + icon: icon, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + onPressed: () { + onPressed?.call(); + }, + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/record/record_field.dart b/packages/stream_chat_flutter/lib/src/message_input/record/record_field.dart new file mode 100644 index 0000000000..29441f0d5c --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/record/record_field.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/audio_wave_bars_widget.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/record_controller.dart'; +import 'package:stream_chat_flutter/src/message_input/on_press_button.dart'; +import 'package:stream_chat_flutter/src/message_input/record/record_timer_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template recordField} +/// Record field. The version of text input of StreamMessageInput, but for +/// recording audio messages. +/// {@endtemplate} +class RecordField extends StatelessWidget { + /// {@macro recordField} + const RecordField({ + super.key, + required this.recordController, + this.resumeRecordButtonBuilder, + this.pauseRecordButtonBuilder, + this.cancelRecordButtonBuilder, + this.confirmRecordButtonBuilder, + this.recordTimerBuilder, + this.audioWaveBarsBuilder, + this.onAudioRecorded, + }); + + /// Controller of record. + final StreamRecordController recordController; + + /// Builder for customizing the resumeRecord button. + /// + /// The builder contains the default [OnPressButton.resumeRecord] that can be + /// customized by calling `.copyWith`. + final ResumeRecordButtonBuilder? resumeRecordButtonBuilder; + + /// Builder for customizing the resumeRecord button. + /// + /// The builder contains the default [OnPressButton.pause] that can be + /// customized by calling `.copyWith`. + final PauseRecordButtonBuilder? pauseRecordButtonBuilder; + + /// Builder for customizing the resumeRecord button. + /// + /// The builder contains the default [OnPressButton.pause] that can be + /// customized by calling `.copyWith`. + final CancelRecordButtonBuilder? cancelRecordButtonBuilder; + + /// Builder for customizing the confirmRecord button. + /// + /// The builder contains the default [OnPressButton.confirmAudio] that can be + /// customized by calling `.copyWith`. + final ConfirmRecordButtonBuilder? confirmRecordButtonBuilder; + + /// Builder for customizing the record timer. + /// + /// The builder contains the default [RecordTimer] that can be + /// customized by calling `.copyWith`. + final RecordTimerBuilder? recordTimerBuilder; + + /// Builder for customizing the audio wave bars. + /// + /// The builder contains the default [AudioWaveBars] that can be + /// customized by calling `.copyWith`. + final AudioWaveBarsBuilder? audioWaveBarsBuilder; + + /// Callback called when audio record is complete. + final void Function(BuildContext, Future)? onAudioRecorded; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildRecordTimer(context, recordController.recordState), + ), + Expanded( + child: SizedBox( + height: 30, + child: Padding( + padding: const EdgeInsets.only( + right: 16, + ), + child: _buildAudioWaveBars( + context, + recordController.amplitudeStream, + ), + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: StreamBuilder( + initialData: RecordState.record, + stream: recordController.recordState, + builder: (context, snapshot) { + final recordingState = snapshot.data ?? RecordState.stop; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildCancelRecordButton(context, recordingState), + if (recordingState == RecordState.record) + _buildPauseRecordButton(context), + if (recordingState != RecordState.record) + _buildResumeRecordButton(context), + _buildConfirmRecordButton(context), + ], + ); + }, + ), + ), + ], + ); + } + + Widget _buildRecordTimer( + BuildContext context, + Stream recordStateStream, + ) { + final defaultTimer = RecordTimer(recordState: recordStateStream); + return recordTimerBuilder?.call(context, defaultTimer) ?? defaultTimer; + } + + Widget _buildAudioWaveBars( + BuildContext context, + Stream amplitudeStream, + ) { + final defaultWaveBars = AudioWaveBars(amplitudeStream: amplitudeStream); + return audioWaveBarsBuilder?.call(context, defaultWaveBars) ?? + defaultWaveBars; + } + + Widget _buildCancelRecordButton( + BuildContext context, + RecordState recordingState, + ) { + if (recordingState == RecordState.record) { + return const StreamSvgIcon.microphone(color: Colors.red); + } else { + final defaultButton = OnPressButton.cancelRecord( + onPressed: recordController.cancelRecording, + ); + + return cancelRecordButtonBuilder?.call(context, defaultButton) ?? + defaultButton; + } + } + + Widget _buildPauseRecordButton(BuildContext context) { + final defaultButton = + OnPressButton.pauseRecord(onPressed: recordController.pauseRecording); + + return pauseRecordButtonBuilder?.call(context, defaultButton) ?? + defaultButton; + } + + Widget _buildResumeRecordButton(BuildContext context) { + final defaultButton = + OnPressButton.resumeRecord(onPressed: recordController.record); + + return resumeRecordButtonBuilder?.call(context, defaultButton) ?? + defaultButton; + } + + Widget _buildConfirmRecordButton(BuildContext context) { + final defaultButton = OnPressButton.confirmAudio( + onPressed: () { + onAudioRecorded?.call(context, recordController.finishRecording()); + }, + ); + + return confirmRecordButtonBuilder?.call(context, defaultButton) ?? + defaultButton; + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/record/record_timer_widget.dart b/packages/stream_chat_flutter/lib/src/message_input/record/record_timer_widget.dart new file mode 100644 index 0000000000..f5387758be --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/record/record_timer_widget.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template RecordTimer} +/// Widget the presents the elapsed in a form of a simple close with mm:ss. +/// {@endtemplate} +class RecordTimer extends StatefulWidget { + /// {@macro RecordTimer} + const RecordTimer({ + super.key, + required this.recordState, + this.textStyle = const TextStyle(fontSize: 18), + }); + + @override + State createState() => _RecordTimerState(); + + /// The state of the recoding. This is used to pause and resume the timer. + final Stream recordState; + + /// TextStyle of the text. + final TextStyle? textStyle; + + /// Copy with for RecordTimer. + RecordTimer copyWith(TextStyle? textStyle) { + return RecordTimer( + recordState: recordState, + textStyle: textStyle ?? this.textStyle, + ); + } +} + +class _RecordTimerState extends State { + Duration duration = Duration.zero; + late Timer timer; + late StreamSubscription recordStateSubscription; + + @override + void initState() { + super.initState(); + + recordStateSubscription = widget.recordState.listen((state) { + if (state == RecordState.record && !timer.isActive) { + timer = Timer.periodic( + const Duration(seconds: 1), + (_) => _addTime.call(), + ); + } else { + timer.cancel(); + } + }); + + timer = Timer.periodic( + const Duration(seconds: 1), + (_) => _addTime.call(), + ); + } + + @override + void dispose() { + super.dispose(); + recordStateSubscription.cancel(); + timer.cancel(); + } + + void _addTime() { + setState(() { + duration += const Duration(seconds: 1); + }); + } + + @override + Widget build(BuildContext context) { + return Text( + duration.toMinutesAndSeconds(), + style: widget.textStyle, + ); + } +} 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 4105e3627e..0f5ba5953e 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 @@ -7,15 +7,22 @@ import 'package:cached_network_image/cached_network_image.dart' hide ErrorListener; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:photo_manager/photo_manager.dart'; +import 'package:record/record.dart'; import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget_builder.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_button.dart'; -import 'package:stream_chat_flutter/src/message_input/command_button.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/audio_player_compose_message.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/audio_wave_bars_widget.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/record_controller.dart'; import 'package:stream_chat_flutter/src/message_input/dm_checkbox.dart'; +import 'package:stream_chat_flutter/src/message_input/on_press_button.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/record/record_button.dart'; +import 'package:stream_chat_flutter/src/message_input/record/record_field.dart'; +import 'package:stream_chat_flutter/src/message_input/record/record_timer_widget.dart'; import 'package:stream_chat_flutter/src/message_input/simple_safe_area.dart'; import 'package:stream_chat_flutter/src/message_input/tld.dart'; import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart'; @@ -80,6 +87,7 @@ class StreamMessageInput extends StatefulWidget { this.textCapitalization = TextCapitalization.sentences, this.disableAttachments = false, this.messageInputController, + this.recordController, this.actions = const [], this.actionsLocation = ActionsLocation.left, this.attachmentThumbnailBuilders, @@ -96,6 +104,13 @@ class StreamMessageInput extends StatefulWidget { this.attachmentLimit = 10, this.onAttachmentLimitExceed, this.attachmentButtonBuilder, + this.startRecordButtonBuilder, + this.resumeRecordButtonBuilder, + this.pauseRecordButtonBuilder, + this.cancelRecordButtonBuilder, + this.confirmRecordButtonBuilder, + this.recordTimerBuilder, + this.audioWaveBarsBuilder, this.commandButtonBuilder, this.customAutocompleteTriggers = const [], this.mentionAllAppUsers = false, @@ -110,6 +125,8 @@ class StreamMessageInput extends StatefulWidget { this.enableMentionsOverlay = true, this.onQuotedMessageCleared, this.enableActionAnimation = true, + this.sendVoiceRecordDirectly = false, + this.enableAudioRecord = true, }); /// If true the message input will animate the actions while you type @@ -161,6 +178,9 @@ class StreamMessageInput extends StatefulWidget { /// The text controller of the TextField. final StreamMessageInputController? messageInputController; + /// The audio controller of the RecordField. + final StreamRecordController? recordController; + /// List of action widgets. final List actions; @@ -201,10 +221,52 @@ class StreamMessageInput extends StatefulWidget { /// Builder for customizing the attachment button. /// - /// The builder contains the default [AttachmentButton] that can be customized - /// by calling `.copyWith`. + /// The builder contains the default [OnPressButton.attachment] that can be + /// customized by calling `.copyWith`. final AttachmentButtonBuilder? attachmentButtonBuilder; + /// Builder for customizing the startRecord button. + /// + /// The builder contains the default [RecordButton.startButton] that can be + /// customized by calling `.copyWith`. + final StartRecordButtonBuilder? startRecordButtonBuilder; + + /// Builder for customizing the resumeRecord button. + /// + /// The builder contains the default [OnPressButton.resumeRecord] that can be + /// customized by calling `.copyWith`. + final ResumeRecordButtonBuilder? resumeRecordButtonBuilder; + + /// Builder for customizing the resumeRecord button. + /// + /// The builder contains the default [OnPressButton.pause] that can be + /// customized by calling `.copyWith`. + final PauseRecordButtonBuilder? pauseRecordButtonBuilder; + + /// Builder for customizing the resumeRecord button. + /// + /// The builder contains the default [OnPressButton.pause] that can be + /// customized by calling `.copyWith`. + final CancelRecordButtonBuilder? cancelRecordButtonBuilder; + + /// Builder for customizing the confirmRecord button. + /// + /// The builder contains the default [OnPressButton.confirmAudio] that can be + /// customized by calling `.copyWith`. + final ConfirmRecordButtonBuilder? confirmRecordButtonBuilder; + + /// Builder for customizing the record timer. + /// + /// The builder contains the default [RecordTimer] that can be + /// customized by calling `.copyWith`. + final RecordTimerBuilder? recordTimerBuilder; + + /// Builder for customizing the audio wave bars. + /// + /// The builder contains the default [AudioWaveBars] that can be + /// customized by calling `.copyWith`. + final AudioWaveBarsBuilder? audioWaveBarsBuilder; + /// Builder for customizing the command button. /// /// The builder contains the default [CommandButton] that can be customized by @@ -246,9 +308,17 @@ class StreamMessageInput extends StatefulWidget { /// Enabled by default final bool enableMentionsOverlay; + /// Enables/disables recording audio and sending audio messages. + final bool enableAudioRecord; + /// Callback for when the quoted message is cleared final VoidCallback? onQuotedMessageCleared; + /// Enables sending messages directly without adding then to message compose + /// for review. + /// Disabled by default. + final bool sendVoiceRecordDirectly; + static bool _defaultValidator(Message message) => message.text?.isNotEmpty == true || message.attachments.isNotEmpty; @@ -282,11 +352,19 @@ class StreamMessageInputState extends State widget.messageInputController ?? _controller!.value; StreamRestorableMessageInputController? _controller; + StreamRecordController get _audioRecorder => + widget.recordController ?? _recordControllerInstance; + late StreamRecordController _recordControllerInstance; + void _createLocalController([Message? message]) { assert(_controller == null, ''); _controller = StreamRestorableMessageInputController(message: message); } + void _createLocalRecordController() { + _recordControllerInstance = StreamRecordController(audioRecorder: Record()); + } + void _registerController() { assert(_controller != null, ''); @@ -313,6 +391,14 @@ class StreamMessageInputState extends State } else { _initialiseEffectiveController(); } + + if (widget.enableAudioRecord) { + if (widget.recordController == null) { + _createLocalRecordController(); + } + _audioRecorder.init(); + } + _effectiveFocusNode.addListener(_focusNodeListener); } @@ -475,7 +561,7 @@ class StreamMessageInputState extends State ), Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: _buildTextField(context), + child: _buildInputField(context), ), if (_effectiveController.message.parentId != null && !widget.hideSendAsDm) @@ -584,7 +670,39 @@ class StreamMessageInputState extends State ); } - Flex _buildTextField(BuildContext context) { + Widget _buildRecordField(BuildContext context) { + return RecordField( + key: const Key('recordInputField'), + recordController: _audioRecorder, + resumeRecordButtonBuilder: widget.resumeRecordButtonBuilder, + pauseRecordButtonBuilder: widget.pauseRecordButtonBuilder, + cancelRecordButtonBuilder: widget.cancelRecordButtonBuilder, + confirmRecordButtonBuilder: widget.confirmRecordButtonBuilder, + recordTimerBuilder: widget.recordTimerBuilder, + audioWaveBarsBuilder: widget.audioWaveBarsBuilder, + onAudioRecorded: _handleRecordComplete, + ); + } + + Widget _buildInputField(BuildContext context) { + if (widget.enableAudioRecord) { + return StreamBuilder( + initialData: RecordState.stop, + stream: _audioRecorder.recordState, + builder: (context, snapshot) { + final state = snapshot.data ?? RecordState.stop; + + return state == RecordState.stop + ? _buildTextField(context) + : _buildRecordField(context); + }, + ); + } else { + return _buildTextField(context); + } + } + + Widget _buildTextField(BuildContext context) { return Flex( direction: Axis.horizontal, children: [ @@ -652,6 +770,7 @@ class StreamMessageInputState extends State ? const Offstage() : Wrap( children: [ + if (widget.enableAudioRecord) _buildStartRecordButton(), if (!widget.disableAttachments && channel.ownCapabilities .contains(PermissionType.uploadFile)) @@ -671,7 +790,7 @@ class StreamMessageInputState extends State } Widget _buildAttachmentButton(BuildContext context) { - final defaultButton = AttachmentButton( + final defaultButton = OnPressButton.attachment( color: _messageInputTheme.actionButtonIdleColor!, onPressed: _onAttachmentButtonPressed, ); @@ -680,6 +799,81 @@ class StreamMessageInputState extends State defaultButton; } + Future _handleRecordComplete( + BuildContext context, + Future attachmentFuture, + ) async { + final attachment = await attachmentFuture; + + if (attachment == null) return; + + if (widget.sendVoiceRecordDirectly) { + final resp = await StreamChannel.of(context) + .channel + .sendMessage(Message(attachments: [attachment])); + + widget.onMessageSent?.call(resp.message); + + var shouldKeepFocus = widget.shouldKeepFocusAfterMessage; + shouldKeepFocus ??= !_commandEnabled; + + if (shouldKeepFocus) { + FocusScope.of(context).requestFocus(_effectiveFocusNode); + } else { + FocusScope.of(context).unfocus(); + } + } else { + _effectiveController.attachments += [attachment]; + } + } + + Widget _buildStartRecordButton() { + final defaultButton = RecordButton.startButton( + key: const Key('startRecord'), + onHold: _audioRecorder.record, + onPressed: _showTapRecordHint, + ); + + return widget.startRecordButtonBuilder?.call(context, defaultButton) ?? + defaultButton; + } + + void _showTapRecordHint() { + HapticFeedback.heavyImpact(); + final renderBox = context.findRenderObject() as RenderBox?; + double positionY; + + if (renderBox?.size.height != null) { + positionY = renderBox!.size.height; + } else { + positionY = 0; + } + + final entry = OverlayEntry(builder: (context) { + return Positioned( + bottom: positionY, + child: Container( + width: MediaQuery.of(context).size.width, + color: Colors.grey, + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + context.translations.holdToStartRecording, + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + decoration: TextDecoration.none, + color: Colors.white, + ), + ), + ), + ), + ); + }); + + Overlay.of(context)?.insert(entry); + Future.delayed(const Duration(seconds: 2)).then((value) => entry.remove()); + } + /// Handle the platform-specific logic for selecting files. /// /// On mobile, this will open the file selection bottom sheet. On desktop, @@ -1023,6 +1217,7 @@ class StreamMessageInputState extends State reverse: true, showBorder: !containsUrl, message: _effectiveController.message.quotedMessage!, + chatThemeData: _streamChatTheme, messageTheme: _streamChatTheme.otherMessageTheme, padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), onQuotedMessageClear: widget.onQuotedMessageCleared, @@ -1035,10 +1230,10 @@ class StreamMessageInputState extends State ); if (nonOGAttachments.isEmpty) return const Offstage(); final fileAttachments = nonOGAttachments - .where((it) => it.type == 'file') + .where((it) => it.type == 'file' || it.type == 'audio_recording') .toList(growable: false); final remainingAttachments = nonOGAttachments - .where((it) => it.type != 'file') + .where((it) => it.type != 'file' && it.type != 'audio_recording') .toList(growable: false); return Column( children: [ @@ -1054,18 +1249,12 @@ class StreamMessageInputState extends State .map( (e) => ClipRRect( borderRadius: BorderRadius.circular(10), - child: StreamFileAttachment( - message: Message(), // dummy message - attachment: e, - constraints: BoxConstraints.loose(Size( - MediaQuery.of(context).size.width * 0.65, - 56, - )), - trailing: Padding( - padding: const EdgeInsets.all(8), - child: _buildRemoveButton(e), - ), - ), + child: e.type == 'audio_recording' + ? AudioPlayerComposeMessage( + attachment: e, + actionButton: _buildRemoveButton(e), + ) + : _buildFileAttachment(e), ), ) .insertBetween(const SizedBox(height: 8)), @@ -1090,7 +1279,7 @@ class StreamMessageInputState extends State child: SizedBox( height: 104, width: 104, - child: _buildAttachment(attachment), + child: _buildRemainingAttachment(attachment), ), ), Positioned( @@ -1110,43 +1299,61 @@ class StreamMessageInputState extends State ); } + Widget _buildFileAttachment(Attachment attachment) { + return StreamFileAttachment( + message: Message(), // dummy message + attachment: attachment, + constraints: BoxConstraints.loose(Size( + MediaQuery.of(context).size.width * 0.65, + 56, + )), + trailing: Padding( + padding: const EdgeInsets.all(8), + child: _buildRemoveButton(attachment), + ), + ); + } + Widget _buildRemoveButton(Attachment attachment) { - return SizedBox( - height: 24, - width: 24, - child: RawMaterialButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - hoverElevation: 0, - onPressed: () async { - final file = attachment.file; - final uploadState = attachment.uploadState; - - if (file != null && !uploadState.isSuccess && !isWeb) { - await StreamAttachmentHandler.instance.deleteAttachmentFile( - attachmentFile: file, - ); - } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 9), + child: SizedBox( + height: 24, + width: 24, + child: RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + hoverElevation: 0, + onPressed: () async { + final file = attachment.file; + final uploadState = attachment.uploadState; + + if (file != null && !uploadState.isSuccess && !isWeb) { + await StreamAttachmentHandler.instance.deleteAttachmentFile( + attachmentFile: file, + ); + } - _effectiveController.removeAttachmentById(attachment.id); - }, - fillColor: - _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5), - child: Center( - child: StreamSvgIcon.close( - size: 24, - color: _streamChatTheme.colorTheme.barsBg, + _effectiveController.removeAttachmentById(attachment.id); + }, + fillColor: + _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5), + child: Center( + child: StreamSvgIcon.close( + size: 24, + color: _streamChatTheme.colorTheme.barsBg, + ), ), ), ), ); } - Widget _buildAttachment(Attachment attachment) { + Widget _buildRemainingAttachment(Attachment attachment) { if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) == true) { return widget.attachmentThumbnailBuilders![attachment.type!]!( @@ -1217,7 +1424,7 @@ class StreamMessageInputState extends State Widget _buildCommandButton(BuildContext context) { final s = _effectiveController.text.trim(); final isCommandOptionsVisible = s.startsWith(_kCommandTrigger); - final defaultButton = CommandButton( + final defaultButton = OnPressButton.command( color: s.isNotEmpty ? _streamChatTheme.colorTheme.disabled : (isCommandOptionsVisible @@ -1379,6 +1586,11 @@ class StreamMessageInputState extends State _stopSlowMode(); _onChangedDebounced.cancel(); WidgetsBinding.instance.removeObserver(this); + + if (widget.enableAudioRecord) { + _audioRecorder.dispose(); + } + super.dispose(); } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index 7d51eb8bec..ebd352e47a 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/conditional_parent_builder/conditional_parent_builder.dart'; import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/audio_player_list.dart'; import 'package:stream_chat_flutter/src/context_menu_items/context_menu_reaction_picker.dart'; import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart'; import 'package:stream_chat_flutter/src/dialogs/dialogs.dart'; @@ -216,6 +217,20 @@ class StreamMessageWidget extends StatefulWidget { attachmentShape: border, ); }, + 'audio_recording': (context, defaultMessage, attachments) { + final border = RoundedRectangleBorder( + borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, + ); + + return WrapAttachmentWidget( + attachmentShape: border, + attachmentWidget: AudioListPlayer( + attachments: attachments, + attachmentBorderRadiusGeometry: attachmentBorderRadiusGeometry, + constraints: const BoxConstraints.tightFor(width: 400), + ), + ); + }, 'giphy': (context, message, attachments) { final border = RoundedRectangleBorder( side: attachmentBorderSide ?? diff --git a/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart index 028cc443b4..80ce0255ee 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart @@ -58,6 +58,7 @@ class _QuotedMessageState extends State { messageTheme: isMyMessage ? chatThemeData.otherMessageTheme : chatThemeData.ownMessageTheme, + chatThemeData: chatThemeData, reverse: widget.reverse, padding: EdgeInsets.only( right: 8, 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 a040bd4270..da51ea9601 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 @@ -287,6 +287,19 @@ class StreamSvgIcon extends StatelessWidget { ); } + /// [StreamSvgIcon] type + const StreamSvgIcon.pause({ + Key? key, + double? size, + Color? color, + }) : this( + key: key, + assetName: 'Icon_pause.svg', + color: color, + width: size, + height: size, + ); + /// [StreamSvgIcon] type factory StreamSvgIcon.penWrite({ double? size, @@ -391,6 +404,19 @@ class StreamSvgIcon extends StatelessWidget { ); } + /// [StreamSvgIcon] type + const StreamSvgIcon.microphone({ + Key? key, + double? size, + Color? color, + }) : this( + key: key, + assetName: 'Icon_microphone.svg', + color: color, + width: size, + height: size, + ); + /// [StreamSvgIcon] type factory StreamSvgIcon.emptyCircleLeft({ double? size, @@ -664,6 +690,19 @@ class StreamSvgIcon extends StatelessWidget { ); } + /// [StreamSvgIcon] type + const StreamSvgIcon.filetypeAac({ + Key? key, + double? size, + Color? color, + }) : this( + key: key, + assetName: 'filetype_AAC.svg', + color: color, + width: size, + height: size, + ); + /// [StreamSvgIcon] type factory StreamSvgIcon.filetypeCsv({ double? size, diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 67b622dba0..2c48b632d5 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -8,6 +8,34 @@ import 'package:image_picker/image_picker.dart'; 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/src/utils/typedefs.dart b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart index b6e1af4328..efb0656a59 100644 --- a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart +++ b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_button.dart'; -import 'package:stream_chat_flutter/src/message_input/command_button.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/audio_wave_bars_widget.dart'; +import 'package:stream_chat_flutter/src/message_input/on_press_button.dart'; +import 'package:stream_chat_flutter/src/message_input/record/record_button.dart'; +import 'package:stream_chat_flutter/src/message_input/record/record_timer_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template inProgressBuilder} @@ -179,18 +181,95 @@ typedef UserMentionTileBuilder = Widget Function( /// {@endtemplate} typedef CommandButtonBuilder = Widget Function( BuildContext context, - CommandButton commandButton, + OnPressButton commandButton, ); /// {@template actionButtonBuilder} /// A widget builder for building a custom action button. /// -/// [attachmentButton] is the default [AttachmentButton] configuration, +/// [attachmentButton] is the default [OnPressButton.attachment] configuration, /// use [attachmentButton.copyWith] to easily customize it. /// {@endtemplate} typedef AttachmentButtonBuilder = Widget Function( BuildContext context, - AttachmentButton attachmentButton, + OnPressButton attachmentButton, +); + +/// {@template startRecordButtonBuilder} +/// A widget builder for building a custom startRecord button. +/// +/// [startRecordButton] is the default [RecordButton.startRecord] configuration, +/// use [startRecordButton.copyWith] to easily customize it. +/// {@endtemplate} +typedef StartRecordButtonBuilder = Widget Function( + BuildContext context, + RecordButton startRecordButton, +); + +/// {@template resumeRecordButtonBuilder} +/// A widget builder for building a custom resumeRecord button. +/// +/// [resumeRecordButton] is the default [OnPressButton.resumeRecord] +/// configuration, use [resumeRecordButton.copyWith] to easily customize it. +/// {@endtemplate} +typedef ResumeRecordButtonBuilder = Widget Function( + BuildContext context, + OnPressButton resumeRecordButton, +); + +/// {@template pauseRecordButtonBuilder} +/// A widget builder for building a custom pauseRecord button. +/// +/// [pauseRecordButton] is the default [OnPressButton.pauseRecord] +/// configuration, use [pauseRecordButton.copyWith] to easily customize it. +/// {@endtemplate} +typedef PauseRecordButtonBuilder = Widget Function( + BuildContext context, + OnPressButton pauseRecordButton, +); + +/// {@template cancelRecordButtonBuilder} +/// A widget builder for building a custom cancelRecord button. +/// +/// [cancelRecordButton] is the default [OnPressButton.cancelRecord] +/// configuration, use [cancelRecordButton.copyWith] to easily customize it. +/// {@endtemplate} +typedef CancelRecordButtonBuilder = Widget Function( + BuildContext context, + OnPressButton cancelRecordButton, +); + +/// {@template confirmRecordButtonBuilder} +/// A widget builder for building a custom cancelRecord button. +/// +/// [confirmRecordButton] is the default [OnPressButton.confirmAudio] +/// configuration, use [confirmRecordButton.copyWith] to easily customize it. +/// {@endtemplate} +typedef ConfirmRecordButtonBuilder = Widget Function( + BuildContext context, + OnPressButton confirmRecordButton, +); + +/// {@template recordTimerBuilder} +/// A widget builder for building a custom recordTimer button. +/// +/// [recordTimer] is the default [RecordTimer] +/// configuration, use [recordTimer.copyWith] to easily customize it. +/// {@endtemplate} +typedef RecordTimerBuilder = Widget Function( + BuildContext context, + RecordTimer recordTimer, +); + +/// {@template audioWaveBarsBuilder} +/// A widget builder for building a custom audio wave bars widget. +/// +/// [audioWaveBars] is the default [AudioWaveBars] +/// configuration, use [audioWaveBars.copyWith] to easily customize it. +/// {@endtemplate} +typedef AudioWaveBarsBuilder = Widget Function( + BuildContext context, + AudioWaveBars audioWaveBars, ); /// {@template quotedMessageAttachmentThumbnailBuilder} diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_microphone.svg b/packages/stream_chat_flutter/lib/svgs/Icon_microphone.svg new file mode 100644 index 0000000000..406bbb7de2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_microphone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_pause.svg b/packages/stream_chat_flutter/lib/svgs/Icon_pause.svg new file mode 100644 index 0000000000..f33eb4a07c --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_pause.svg @@ -0,0 +1,3 @@ + + + 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 0000000000..726cd62f95 --- /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 d67aa740db..acfe1deea0 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: desktop_drop: ^0.4.0 diacritic: ^0.1.3 ezanimation: ^0.6.0 + fftea: ^1.2.0+1 file_picker: ^5.2.4 file_selector: ^0.9.0 flutter: @@ -29,11 +30,14 @@ dependencies: image_gallery_saver: ^1.7.1 image_picker: ^0.8.2 jiffy: ^5.0.0 + just_audio: ^0.9.31 + just_waveform: ^0.0.4 lottie: ^2.0.0 meta: ^1.8.0 path_provider: ^2.0.9 photo_manager: ^2.5.2 photo_view: ^0.14.0 + record: ^4.4.4 rxdart: ^0.27.0 share_plus: ^6.3.0 shimmer: ^2.0.0 diff --git a/packages/stream_chat_flutter/test/src/attachment/audio/audio_normalization_test.dart b/packages/stream_chat_flutter/test/src/attachment/audio/audio_normalization_test.dart new file mode 100644 index 0000000000..b03c156eeb --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/audio/audio_normalization_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/list_normalization.dart'; + +void main() { + group( + 'List normalization test', + () { + test('Width - Final size of list should be correct - shrink1', () async { + const listSize = 60; + final inputList = List.filled(10245, 0); + + final result = ListNormalization.normalizeBars(inputList, listSize, 0); + + expect(result.length, listSize); + }); + + test('Width - Final size of list should be correct2 - shrink2', () async { + const listSize = 60; + final inputList = List.filled(80, 0); + + final result = ListNormalization.normalizeBars(inputList, listSize, 0); + + expect(result.length, listSize); + }); + + test('Width - Final size of list should be correct2 - expand1', () async { + const listSize = 10245; + final inputList = List.filled(60, 0); + + final result = ListNormalization.normalizeBars(inputList, listSize, 0); + + expect(result.length, listSize); + }); + + test('Width - Final size of list should be correct2 - expand2', () async { + const listSize = 80; + final inputList = List.filled(60, 0); + + final result = ListNormalization.normalizeBars(inputList, listSize, 0); + + expect(result.length, listSize); + }); + + test('Width - Simple median should be correct1', () async { + const listSize = 60; + final inputList = List.filled(10245, 0); + + final result = ListNormalization.shrinkWidth(inputList, listSize); + expect(result.last, 0); + }); + + test('Width - Simple median should be correct2', () async { + const listSize = 60; + final inputList = List.filled(10245, 3); + + final result = ListNormalization.shrinkWidth(inputList, listSize); + expect(result.first, 3); + }); + + test('Width - Simple median should be correct3 - constant list', + () async { + const listSize = 60; + final inputList = List.filled(10245, 3); + + final result = ListNormalization.shrinkWidth(inputList, listSize); + expect(result.last, 3); + }); + + test('Width - Simple median should be correct4 - variable list', + () async { + const listSize = 1; + final inputList = + List.generate(10, (index) => index.toDouble()); + + final result = ListNormalization.shrinkWidth(inputList, listSize); + expect(result.first, 4.5); + }); + + test('Normalized list should be positive1', () async { + const listSize = 100; + final inputList = List.filled(60, -5); + + final result = ListNormalization.normalizeBars(inputList, listSize, -5); + + expect(result.any((element) => element < 0), false); + }); + + test('Normalized list should be positive2', () async { + const listSize = 100; + final inputList = List.generate(60, (e) => e - 30); + + final result = + ListNormalization.normalizeBars(inputList, listSize, -30); + + expect(result.any((element) => element < 0), false); + }); + + test('At least one number should be 1', () async { + const listSize = 100; + final inputList = List.generate(60, (e) => e - 30); + + final result = + ListNormalization.normalizeBars(inputList, listSize, -30); + + expect(result.any((element) => element == 1), true); + }); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/attachment/audio/record_controller_test.dart b/packages/stream_chat_flutter/test/src/attachment/audio/record_controller_test.dart new file mode 100644 index 0000000000..cf605b535c --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/audio/record_controller_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/record_controller.dart'; + +import '../../mocks.dart'; + +void main() { + group('Record Controller tests', () { + test('Init of controller should make it listen for streams', () async { + final mockRecorder = MockRecorder(); + final stateSubject = BehaviorSubject(); + final amplitudeSubject = BehaviorSubject(); + const duration = Duration(milliseconds: 100); + + when(mockRecorder.onStateChanged).thenAnswer((_) => stateSubject.stream); + when(() => mockRecorder.onAmplitudeChanged(duration)) + .thenAnswer((_) => amplitudeSubject.stream); + + stateSubject.sink.add(RecordState.pause); + + StreamRecordController( + audioRecorder: mockRecorder, + ).init(); + + verify(() => mockRecorder.onAmplitudeChanged(duration)).called(1); + verify(mockRecorder.onStateChanged).called(1); + stateSubject.close(); + amplitudeSubject.close(); + }); + + test('Start recording should work', () async { + final mockRecorder = MockRecorder(); + + final recordController = StreamRecordController( + audioRecorder: mockRecorder, + ); + + await recordController.record(); + + verify(mockRecorder.start).called(1); + }); + + test('Pause recording should work', () async { + final mockRecorder = MockRecorder(); + + when(mockRecorder.pause).thenAnswer((_) => Future.value()); + + final recordController = StreamRecordController( + audioRecorder: mockRecorder, + ); + + await recordController.pauseRecording(); + + verify(mockRecorder.pause).called(1); + }); + + test('Init of controller should make it listen for streams', () async { + final mockRecorder = MockRecorder(); + final stateSubject = BehaviorSubject(); + final amplitudeSubject = BehaviorSubject(); + const duration = Duration(milliseconds: 100); + + when(mockRecorder.onStateChanged).thenAnswer((_) => stateSubject.stream); + when(() => mockRecorder.onAmplitudeChanged(duration)) + .thenAnswer((_) => amplitudeSubject.stream); + + stateSubject.sink.add(RecordState.pause); + + final recordController = StreamRecordController( + audioRecorder: mockRecorder, + )..init(); + + await Future.delayed(const Duration(milliseconds: 100)); + await recordController.record(); + + verify(mockRecorder.resume).called(1); + stateSubject.close(); + amplitudeSubject.close(); + }); + + test('Finish method should work', () async { + final mockRecorder = MockRecorder(); + final stateSubject = BehaviorSubject(); + final amplitudeSubject = BehaviorSubject(); + const duration = Duration(milliseconds: 100); + + when(mockRecorder.onStateChanged).thenAnswer((_) => stateSubject.stream); + when(() => mockRecorder.onAmplitudeChanged(duration)) + .thenAnswer((_) => amplitudeSubject.stream); + when(mockRecorder.stop).thenAnswer((_) => Future.value('path')); + + final recordController = StreamRecordController( + audioRecorder: mockRecorder, + )..init(); + + await Future.delayed(const Duration(milliseconds: 100)); + + await recordController.finishRecording(); + + verify(mockRecorder.stop).called(1); + + stateSubject.close(); + amplitudeSubject.close(); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/message_input/attachment_button_test.dart b/packages/stream_chat_flutter/test/src/message_input/attachment_button_test.dart index 19a2e65965..89e93fd727 100644 --- a/packages/stream_chat_flutter/test/src/message_input/attachment_button_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/attachment_button_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:golden_toolkit/golden_toolkit.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_button.dart'; +import 'package:stream_chat_flutter/src/message_input/on_press_button.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; void main() { @@ -11,7 +11,7 @@ void main() { MaterialApp( home: Scaffold( body: Center( - child: AttachmentButton( + child: OnPressButton.attachment( color: StreamChatThemeData.light() .messageInputTheme .actionButtonIdleColor!, @@ -36,7 +36,7 @@ void main() { MaterialApp( home: Scaffold( body: Center( - child: AttachmentButton( + child: OnPressButton.attachment( color: StreamChatThemeData.light() .messageInputTheme .actionButtonIdleColor!, diff --git a/packages/stream_chat_flutter/test/src/message_input/command_button_test.dart b/packages/stream_chat_flutter/test/src/message_input/command_button_test.dart index fd712f544f..cae8ae21d0 100644 --- a/packages/stream_chat_flutter/test/src/message_input/command_button_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/command_button_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:golden_toolkit/golden_toolkit.dart'; -import 'package:stream_chat_flutter/src/message_input/command_button.dart'; +import 'package:stream_chat_flutter/src/message_input/on_press_button.dart'; void main() { testWidgets('CommandButton onPressed works', (tester) async { @@ -10,7 +10,7 @@ void main() { MaterialApp( home: Scaffold( body: Center( - child: CommandButton( + child: OnPressButton.command( color: Colors.red, onPressed: () { count++; @@ -32,7 +32,7 @@ void main() { MaterialApp( home: Scaffold( body: Center( - child: CommandButton( + child: OnPressButton.command( color: Colors.red, onPressed: () {}, ), diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart index 1fe25ac33f..0604574ad7 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/audio_player_compose_message.dart'; +import 'package:stream_chat_flutter/src/message_input/record/record_field.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../mocks.dart'; @@ -76,6 +80,486 @@ void main() { }, ); + testWidgets( + 'Test that start button is shown by default', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer( + (i) => Stream.value({ + 'name': 'test', + }), + ); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer( + (i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ]), + ); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer( + (i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]), + ); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: StreamMessageInput(), + ), + ), + ), + )); + + expect(find.byKey(const Key('startRecord')), findsOneWidget); + }, + ); + + testWidgets( + 'Test that start button is not shown when audio is disabled', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer( + (i) => Stream.value({ + 'name': 'test', + }), + ); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer( + (i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ]), + ); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer( + (i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]), + ); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: StreamMessageInput(enableAudioRecord: false), + ), + ), + ), + )); + + expect(find.byKey(const Key('startRecord')), findsNothing); + }, + ); + + testWidgets( + 'Test that is possible to write a message in default mode', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer( + (i) => Stream.value({ + 'name': 'test', + }), + ); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer( + (i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ]), + ); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer( + (i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]), + ); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: StreamMessageInput(enableAudioRecord: false), + ), + ), + ), + )); + + const text = 'hello test!'; + await tester.enterText(find.byType(TextField), text); + expect(find.text(text), findsOneWidget); + }, + ); + + testWidgets( + 'Test that is possible to add a audio message in default mode', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer( + (i) => Stream.value({ + 'name': 'test', + }), + ); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer( + (i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ]), + ); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer( + (i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]), + ); + + final messageInputController = StreamMessageInputController(); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamMessageInput( + enableAudioRecord: false, + messageInputController: messageInputController, + ), + ), + ), + ), + )); + + messageInputController.attachments += [ + Attachment(type: 'audio_recording'), + Attachment(type: 'audio_recording'), + ]; + + await tester.pump(); + + expect( + find.byWidgetPredicate((widget) => widget is AudioPlayerComposeMessage), + findsWidgets, + ); + }, + ); + + testWidgets( + 'Test that is possible to add a audio message with file attachments too', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer( + (i) => Stream.value({ + 'name': 'test', + }), + ); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer( + (i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ]), + ); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer( + (i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]), + ); + + final messageInputController = StreamMessageInputController(); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamMessageInput( + enableAudioRecord: false, + messageInputController: messageInputController, + ), + ), + ), + ), + )); + + messageInputController.attachments += [ + Attachment(type: 'audio_recording'), + Attachment(type: 'file'), + Attachment( + type: 'image', + assetUrl: + 'https://vignette.wikia.nocookie.net/starwars/images/b/b8/Dooku_Headshot.jpg'), + ]; + + await tester.pump(); + + expect( + find.byWidgetPredicate((widget) => widget is AudioPlayerComposeMessage), + findsOneWidget, + ); + + expect( + find.byWidgetPredicate((widget) => widget is StreamFileAttachment), + findsOneWidget, + ); + expect( + find.byWidgetPredicate((widget) => widget is Image), + findsWidgets, + ); + }, + ); + + testWidgets( + 'Test that start the audio input correctly appears', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + final mockRecordController = MockStreamRecordController(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer( + (i) => Stream.value({ + 'name': 'test', + }), + ); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer( + (i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ]), + ); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer( + (i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]), + ); + + final recordStateController = BehaviorSubject(); + final amplitudeController = BehaviorSubject(); + when(() => mockRecordController.recordState) + .thenAnswer((_) => recordStateController.stream); + + when(() => mockRecordController.amplitudeStream) + .thenAnswer((_) => amplitudeController.stream); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamMessageInput( + recordController: mockRecordController, + ), + ), + ), + ), + )); + + recordStateController.add(RecordState.record); + + await tester.pump(); + + expect(find.byKey(const Key('startRecord')), findsNothing); + expect( + find.byWidgetPredicate((widget) => widget is RecordField), + findsOneWidget, + ); + recordStateController.close(); + amplitudeController.close(); + }, + ); + testWidgets( 'checks message input slow mode', (WidgetTester tester) async { diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index c8e1a4b4e5..dc1d55bc83 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -1,8 +1,20 @@ import 'package:flutter/material.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/attachment/audio/record_controller.dart'; import 'package:stream_chat_flutter/src/video/vlc/vlc_manager_desktop.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +class MockRecorder extends Mock implements Record { + @override + Future hasPermission() { + return Future.value(true); + } +} + +class MockStreamRecordController extends Mock + implements StreamRecordController {} + class MockClient extends Mock implements StreamChatClient { MockClient() { when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected); diff --git a/packages/stream_chat_flutter/test/src/utils/extension_test.dart b/packages/stream_chat_flutter/test/src/utils/extension_test.dart index 269807274a..d75f75050c 100644 --- a/packages/stream_chat_flutter/test/src/utils/extension_test.dart +++ b/packages/stream_chat_flutter/test/src/utils/extension_test.dart @@ -2,6 +2,23 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; void main() { + group('IntExtension.toHumanReadableSize', () { + test('should convert file size to human readable size', () { + expect(10000.toHumanReadableSize(), '9.77 KB'); + expect(100000.toHumanReadableSize(), '97.66 KB'); + expect(100000000.toHumanReadableSize(), '95.37 MB'); + }); + }); + + group('DurationExtension.toMinutesAndSeconds', () { + test('should convert Duration to readable time', () { + expect(const Duration(seconds: 50).toMinutesAndSeconds(), '00:50'); + expect(const Duration(seconds: 100).toMinutesAndSeconds(), '01:40'); + expect(const Duration(seconds: 200).toMinutesAndSeconds(), '03:20'); + expect(const Duration(minutes: 45).toMinutesAndSeconds(), '45:00'); + }); + }); + group('List.search', () { test('should work fine', () { final tommaso = User(id: 'tommaso', name: 'Tommaso'); 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 3f3b5bdbbf..5d998c32b7 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -466,6 +466,9 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'Allow access to files'; + + @override + String get holdToStartRecording => 'Hold to record'; } 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 3c471e0510..d6a97a19fa 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 @@ -446,4 +446,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => "Permet l'accés als fitxers"; + + @override + String get holdToStartRecording => 'Mantén premut per enregistrar.'; } 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 df2a7766bd..1f3b60160d 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 @@ -440,4 +440,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'Zugriff auf Dateien zulassen'; + + @override + String get holdToStartRecording => 'Zum Aufnehmen halten'; } 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 c55bbf49d4..8528e589b3 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 @@ -443,4 +443,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'Allow access to files'; + + @override + String get holdToStartRecording => 'Hold to start recording.'; } 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 107e26166f..d9cf938386 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 @@ -448,4 +448,7 @@ No es posible añadir más de $limit archivos adjuntos @override String get allowFileAccessMessage => 'Permitir el acceso a los archivos'; + + @override + String get holdToStartRecording => 'Mantén presionado para grabar.'; } 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 8b501184ee..a0c0e86ed0 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 @@ -448,4 +448,7 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String get allowFileAccessMessage => "Autoriser l'accès aux fichiers"; + + @override + String get holdToStartRecording => 'Maintenir pour enregistrer'; } 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 4f27717a7f..d7158c81f7 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 @@ -441,4 +441,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'फाइलों तक पहुंच की अनुमति दें'; + + @override + String get holdToStartRecording => 'रिकॉर्ड करने के लिए दबाए रखें'; } 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 b407629d1e..03fa2722cf 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 @@ -450,4 +450,7 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String get allowFileAccessMessage => "Consenti l'accesso ai file"; + + @override + String get holdToStartRecording => 'Tieni premuto per registrare.'; } 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 26a5e8b3e5..9023732622 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 @@ -426,4 +426,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'ファイルへのアクセスを許可する'; + + @override + String get holdToStartRecording => '押しで録音'; } 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 7b907584e7..c084481f50 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 @@ -426,4 +426,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => '파일에 대한 액세스 허용'; + + @override + String get holdToStartRecording => '누르고 있기면 녹음'; } 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 88350979ef..1fef0223d1 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 @@ -433,4 +433,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get allowFileAccessMessage => 'Gi tilgang til filer'; + + @override + String get holdToStartRecording => 'Hold inne for å ta opp'; } 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 874eb11893..477a2cdaec 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 @@ -446,4 +446,7 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String get allowFileAccessMessage => 'Permitir acesso aos arquivos'; + + @override + String get holdToStartRecording => 'Segure para gravar'; } diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 4004994a33..feaef7d4c8 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -198,6 +198,7 @@ void main() { expect(localizations.unreadMessagesSeparatorText(2), isNotNull); expect(localizations.enableFileAccessMessage, isNotNull); expect(localizations.allowFileAccessMessage, isNotNull); + expect(localizations.holdToStartRecording, isNotNull); }); }