diff --git a/packages/stream_chat_flutter/example/android/app/build.gradle b/packages/stream_chat_flutter/example/android/app/build.gradle index 0e98a6e3d..93cc9639d 100644 --- a/packages/stream_chat_flutter/example/android/app/build.gradle +++ b/packages/stream_chat_flutter/example/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 33 + compileSdkVersion 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj index e5d8f7436..8022e694b 100644 --- a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -155,7 +155,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -204,6 +204,7 @@ files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3db53b6e1..b52b2e698 100644 --- a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ createState() => + _StreamAudioMessageControllersState(); +} + +class _StreamAudioMessageControllersState + extends State with TickerProviderStateMixin { + Color? iconColor; + DateTime? _startTime; + Timer? _timer; + late final AnimationController _controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final audioRecordingMessageTheme = AudioRecordingMessageTheme.of(context); + + _controller = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _controller.forward(); + + Future.delayed( + const Duration(seconds: 1), + () { + if (mounted) { + setState(() { + iconColor = + audioRecordingMessageTheme.recordingIndicatorColorActive; + _startTime = DateTime.now(); + }); + } + }, + ); + + _timer = Timer.periodic( + const Duration(seconds: 1), + (timer) { + if (_startTime == null) { + return; + } + setState(() {}); + }, + ); + } + + @override + void dispose() { + _timer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + String get duration { + if (_startTime == null) { + return '0:00'; + } + final diff = DateTime.now().difference(_startTime!); + return '${diff.inMinutes}:${diff.inSeconds.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final audioRecordingMessageTheme = AudioRecordingMessageTheme.of(context); + + return SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeIn, + ), + ), + child: Row( + children: [ + const SizedBox(width: 8), + StreamSvgIcon.iconMic( + color: iconColor, + size: 24, + ), + if (_startTime != null) ...[ + const SizedBox(width: 8), + SizedBox( + width: 50, + child: Text( + duration, + style: TextStyle( + fontSize: 16, + color: audioRecordingMessageTheme.recordingIndicatorColorIdle, + ), + ), + ), + ] else + const SizedBox(width: 58), + const Expanded( + child: _CancelRecordingPanel( + key: ValueKey('cancelRecordingPanel'), + ), + ), + ], + ), + ); + } +} + +class _CancelRecordingPanel extends StatefulWidget { + const _CancelRecordingPanel({super.key}); + + @override + State<_CancelRecordingPanel> createState() => _CancelRecordingPanelState(); +} + +class _CancelRecordingPanelState extends State<_CancelRecordingPanel> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final audioRecordingMessageTheme = AudioRecordingMessageTheme.of(context); + + final state = GestureStateProvider.maybeOf(context); + final offset = state?.offset; + final width = MediaQuery.of(context).size.width / 3; + final opacity = offset != null ? 1 - (offset.dx.abs() / width) : 1.0; + + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.translate( + offset: offset != null ? Offset(offset.dx, 0) : Offset.zero, + child: Opacity( + opacity: max(min(opacity, 1), 0), + child: child, + ), + ); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Slide to cancel', + style: TextStyle( + fontSize: 16, + color: audioRecordingMessageTheme.cancelTextColor, + ), + ), + StreamSvgIcon.left( + size: 24, + color: audioRecordingMessageTheme.cancelTextColor, + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_message_send_button/stream_audio_message_gesture_state.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_message_send_button/stream_audio_message_gesture_state.dart new file mode 100644 index 000000000..530c35884 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_message_send_button/stream_audio_message_gesture_state.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.dart'; + +/// Tracks the offset of the drag gesture +class GestureStateProvider extends InheritedWidget { + /// Creates a new OffsetTracker + const GestureStateProvider({ + super.key, + required super.child, + required this.state, + }); + + /// The drag gestures' state information + final GestureState state; + + /// Returns the state of the drag gesture + static GestureState? maybeOf(BuildContext context) { + final provider = + context.dependOnInheritedWidgetOfExactType(); + return provider?.state; + } + + @override + bool updateShouldNotify(GestureStateProvider oldWidget) { + return oldWidget.state != state; + } +} + +/// Tracks any state that needs to be communicated between the audio recording +/// components +class GestureState extends Equatable { + /// Creates a new GestureState + const GestureState({ + required this.offset, + }); + + /// The offset of the drag gesture + final Offset offset; + + @override + List get props => [offset]; +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_message_send_button/stream_audio_message_overlays.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_message_send_button/stream_audio_message_overlays.dart new file mode 100644 index 000000000..2f9cd1743 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_message_send_button/stream_audio_message_overlays.dart @@ -0,0 +1,124 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A widget that displays a banner with usage information message. +class AudioMessageInfoBannerOverlay extends StatelessWidget { + /// Returns an [AudioMessageInfoBannerOverlay] with the given [bottomOffset].ยง + const AudioMessageInfoBannerOverlay({ + super.key, + required this.bottomOffset, + }); + + /// The offset to apply to the overlay. + final double bottomOffset; + + @override + Widget build(BuildContext context) { + final audioRecordingMessageTheme = AudioRecordingMessageTheme.of(context); + + return Positioned( + bottom: bottomOffset, + child: DefaultTextStyle( + style: TextStyle( + color: audioRecordingMessageTheme.audioButtonColor, + fontSize: 15, + ), + child: Container( + height: 28, + width: MediaQuery.of(context).size.width, + alignment: Alignment.center, + decoration: BoxDecoration( + color: audioRecordingMessageTheme.audioButtonBannerColor, + ), + child: const Text('Hold to start recording.'), + ), + ), + ); + } +} + +/// A widget that displays a lock button. +class LockButtonOverlay extends StatelessWidget { + /// Returns a [LockButtonOverlay] with the given [bottomOffset]. + const LockButtonOverlay({ + super.key, + required this.bottomOffset, + }); + + /// The offset to apply to the overlay. + final double bottomOffset; + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: bottomOffset, + right: 0, + child: const _LockButton( + key: ValueKey('lockButton'), + ), + ); + } +} + +class _LockButton extends StatefulWidget { + const _LockButton({ + super.key, + }); + + @override + State<_LockButton> createState() => _LockButtonState(); +} + +class _LockButtonState extends State<_LockButton> + with TickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final audioRecordingMessageTheme = AudioRecordingMessageTheme.of(context); + + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(_controller), + child: Container( + padding: const EdgeInsets.all(8), + width: 48, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: audioRecordingMessageTheme.lockButtonBackgroundColor, + ), + child: Column( + children: [ + StreamSvgIcon.iconLock( + size: 24, + color: audioRecordingMessageTheme.lockButtonForegroundColor, + ), + const SizedBox(height: 8), + StreamSvgIcon.up( + size: 24, + color: audioRecordingMessageTheme.lockButtonForegroundColor, + ), + ], + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_message_send_button/stream_audio_message_send_button.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_message_send_button/stream_audio_message_send_button.dart new file mode 100644 index 000000000..f33f38157 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_message_send_button/stream_audio_message_send_button.dart @@ -0,0 +1,225 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A callback called when the user starts recording an audio message. +typedef OnRecordingStart = void Function(); + +/// A callback called when the user ends recording an audio message. +typedef OnRecordingEnd = void Function(); + +/// A callback called when the user cancels recording an audio message. +typedef OnRecordingCanceled = void Function(); + +/// A widget that displays a sending button. +/// This widget is used when the user is recording an audio message. +class StreamAudioMessageSendButton extends StatefulWidget { + /// Returns a [StreamAudioMessageSendButton] with the given [overlayOffset], + /// [onRecordingStart]. + const StreamAudioMessageSendButton({ + super.key, + this.overlayOffset = 15, + this.overlayDuration = const Duration(milliseconds: 500), + this.useHapticFeedback = true, + required this.onRecordingStart, + required this.onRecordingEnd, + required this.onRecordingCanceled, + }); + + /// The offset to apply to the overlay. + final double overlayOffset; + + /// The duration of the overlay banner. + final Duration overlayDuration; + + /// The callback called when the user long presses the button. + final OnRecordingStart onRecordingStart; + + /// The callback called when the user stops pressing the button. + final OnRecordingEnd onRecordingEnd; + + /// The callback called when the user cancels the recording. + final OnRecordingCanceled onRecordingCanceled; + + /// If true, the button will use haptic feedback when + /// the user long presses it. + final bool useHapticFeedback; + + @override + State createState() => + _StreamAudioMessageSendButtonState(); +} + +class _StreamAudioMessageSendButtonState + extends State { + Offset _offset = Offset.zero; + final _infoBarOverlayController = OverlayPortalController(); + final _lockButtonOverlayController = OverlayPortalController(); + Timer? _lockButtonTimer; + + bool _isRecording = false; + + Color? iconColor; + Color? iconBackgroundColor; + + double get width => MediaQuery.of(context).size.width; + + @override + void dispose() { + if (_infoBarOverlayController.isShowing) { + _infoBarOverlayController.hide(); + } + if (_lockButtonOverlayController.isShowing) { + _lockButtonOverlayController.hide(); + } + _lockButtonTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final audioRecordingMessageTheme = AudioRecordingMessageTheme.of(context); + + return GestureDetector( + onTap: () => _onTap(context), + onLongPressMoveUpdate: (details) { + setState(() { + _offset = details.offsetFromOrigin; + }); + if (details.offsetFromOrigin.dx < -(width / 3)) { + if (widget.useHapticFeedback) { + HapticFeedback.heavyImpact(); + } + setState(() { + iconColor = null; + iconBackgroundColor = null; + _offset = Offset.zero; + _isRecording = false; + }); + + if (_lockButtonOverlayController.isShowing) { + _lockButtonOverlayController.hide(); + } + + widget.onRecordingCanceled(); + } + }, + onLongPressStart: (details) { + setState(() { + iconColor = audioRecordingMessageTheme.audioButtonPressedColor; + iconBackgroundColor = + audioRecordingMessageTheme.audioButtonPressedBackgroundColor; + }); + if (widget.useHapticFeedback) { + HapticFeedback.selectionClick(); + } + widget.onRecordingStart(); + setState(() { + _isRecording = true; + }); + + _lockButtonTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _lockButtonOverlayController.show(); + timer.cancel(); + }); + }, + onLongPressEnd: (details) { + if (_lockButtonTimer != null && _lockButtonTimer!.isActive) { + _lockButtonTimer!.cancel(); + } + if (_lockButtonOverlayController.isShowing) { + _lockButtonOverlayController.hide(); + } + + setState(() { + iconColor = null; + iconBackgroundColor = null; + }); + widget.onRecordingEnd(); + setState(() { + _isRecording = false; + _offset = Offset.zero; + }); + }, + child: OverlayPortal( + controller: _lockButtonOverlayController, + overlayChildBuilder: (context) => LockButtonOverlay( + bottomOffset: _overlayOffset, + ), + child: OverlayPortal( + controller: _infoBarOverlayController, + overlayChildBuilder: (context) => AudioMessageInfoBannerOverlay( + bottomOffset: _overlayOffset, + ), + child: Builder( + builder: (context) { + final icon = Padding( + padding: const EdgeInsets.all(8), + child: StreamSvgIcon.iconMic( + size: 24, + color: iconColor, + ), + ); + + if (!_isRecording) { + return icon; + } + return SizedBox( + width: MediaQuery.of(context).size.width, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: GestureStateProvider( + state: GestureState( + offset: _offset, + ), + child: const StreamAudioMessageControllers(), + ), + ), + Padding( + padding: const EdgeInsets.all(1.5), + child: DecoratedBox( + decoration: BoxDecoration( + color: iconBackgroundColor, + shape: BoxShape.circle, + ), + child: icon, + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); + } + + /// Account for the height of the keyboard if it's open + double get _overlayOffset { + final box = context.findRenderObject()! as RenderBox; + final offset = box.localToGlobal(Offset.zero); + final bottomPadding = + (MediaQuery.of(context).size.height - offset.dy) + widget.overlayOffset; + + return bottomPadding; + } + + Future _onTap(BuildContext context) async { + if (_infoBarOverlayController.isShowing) return; + + _infoBarOverlayController.show(); + + Timer.periodic( + widget.overlayDuration, + (timer) { + timer.cancel(); + _infoBarOverlayController.hide(); + }, + ); + } +} 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 f0625974a..8bf3dfcd4 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 @@ -721,13 +721,31 @@ class StreamMessageInputState extends State return Flex( direction: Axis.horizontal, children: [ - if (!_commandEnabled && widget.actionsLocation == ActionsLocation.left) + if (!_commandEnabled && + widget.actionsLocation == ActionsLocation.left && + !_effectiveController.isRecordingAudio) _buildExpandActionsButton(context), - _buildTextInput(context), - if (!_commandEnabled && widget.actionsLocation == ActionsLocation.right) + if (!_effectiveController.isRecordingAudio) _buildTextInput(context), + if (!_commandEnabled && + widget.actionsLocation == ActionsLocation.right && + !_effectiveController.isRecordingAudio) _buildExpandActionsButton(context), if (widget.sendButtonLocation == SendButtonLocation.outside) - _buildSendButton(context), + if (_effectiveController.textEditingValue.text.isNotEmpty || + _effectiveController.attachments.isNotEmpty) + _buildSendButton(context) + else + StreamAudioMessageSendButton( + onRecordingStart: () { + _effectiveController.isRecordingAudio = true; + }, + onRecordingEnd: () { + _effectiveController.isRecordingAudio = false; + }, + onRecordingCanceled: () { + _effectiveController.isRecordingAudio = false; + }, + ), ], ); } diff --git a/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart index 75044ee4d..9f2c288d2 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 @@ -1080,6 +1080,32 @@ class StreamSvgIcon extends StatelessWidget { ); } + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconMic({ + double? size, + Color? color, + }) { + return StreamSvgIcon( + assetName: 'Icon_Mic.svg', + color: color, + width: size, + height: size, + ); + } + + /// [StreamSvgIcon] type + factory StreamSvgIcon.iconLock({ + double? size, + Color? color, + }) { + return StreamSvgIcon( + assetName: 'Icon_Lock.svg', + color: color, + width: size, + height: size, + ); + } + /// Name of icon asset final String? assetName; diff --git a/packages/stream_chat_flutter/lib/src/theme/audio_recording_message_theme.dart b/packages/stream_chat_flutter/lib/src/theme/audio_recording_message_theme.dart new file mode 100644 index 000000000..bb4680a50 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/audio_recording_message_theme.dart @@ -0,0 +1,219 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// The theme for audio message components +class AudioRecordingMessageTheme extends InheritedTheme { + /// Creates a [AudioRecordingMessageTheme]. + /// + /// The [data] parameter must not be null. + const AudioRecordingMessageTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The configuration of this theme. + final AudioRecordingMessageThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [AudioRecordingMessageTheme] widget, then + /// [StreamChatThemeData.audioRecordingMessageTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// final theme = AudioRecordingMessageTheme.of(context); + /// ``` + static AudioRecordingMessageThemeData of(BuildContext context) { + final audioRecordingMessageTheme = context + .dependOnInheritedWidgetOfExactType(); + return audioRecordingMessageTheme?.data ?? + StreamChatTheme.of(context).audioRecordingMessageTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) => + AudioRecordingMessageTheme(data: data, child: child); + + @override + bool updateShouldNotify(AudioRecordingMessageTheme oldWidget) => + data != oldWidget.data; +} + +/// The theme for audio message components +class AudioRecordingMessageThemeData with Diagnosticable { + /// Creates a new instance of the audio message theme + AudioRecordingMessageThemeData({ + this.audioButtonColor = const Color(0XFFFFFFFF), + this.audioButtonBannerColor = const Color(0xFF747881), + this.audioButtonPressedColor = const Color(0xFF015DFF), + this.audioButtonPressedBackgroundColor = const Color(0xFFDBDDE1), + this.lockButtonForegroundColor = const Color(0xFF747881), + this.lockButtonBackgroundColor = const Color(0xFFDBDDE1), + this.recordingIndicatorColorIdle = const Color(0xFF747881), + this.recordingIndicatorColorActive = const Color(0xFFFF515A), + this.cancelTextColor = const Color(0xFF747881), + }); + + /// The color of the audio button + final Color audioButtonColor; + + /// The color of the audio button when pressed + final Color audioButtonPressedColor; + + /// The background color of the audio button when pressed + final Color audioButtonPressedBackgroundColor; + + /// The color of the audio button banner + final Color audioButtonBannerColor; + + /// The color of the lock button foreground + final Color lockButtonForegroundColor; + + /// The color of the lock button background + final Color lockButtonBackgroundColor; + + /// The color of the recording indicator when idle + final Color recordingIndicatorColorIdle; + + /// The color of the recording indicator when active + final Color recordingIndicatorColorActive; + + /// The color of the cancel text + final Color cancelTextColor; + + /// Creates a copy of this theme but with the given fields replaced + /// with the new values. + AudioRecordingMessageThemeData copyWith({ + Color? audioButtonColor, + Color? audioButtonBannerColor, + Color? audioButtonPressedColor, + Color? audioButtonPressedBackgroundColor, + Color? lockButtonForegroundColor, + Color? lockButtonBackgroundColor, + Color? recordingIndicatorColorIdle, + Color? recordingIndicatorColorActive, + Color? cancelTextColor, + }) { + return AudioRecordingMessageThemeData( + audioButtonColor: audioButtonColor ?? this.audioButtonColor, + audioButtonBannerColor: + audioButtonBannerColor ?? this.audioButtonBannerColor, + audioButtonPressedColor: + audioButtonPressedColor ?? this.audioButtonPressedColor, + audioButtonPressedBackgroundColor: audioButtonPressedBackgroundColor ?? + this.audioButtonPressedBackgroundColor, + lockButtonForegroundColor: + lockButtonForegroundColor ?? this.lockButtonForegroundColor, + lockButtonBackgroundColor: + lockButtonBackgroundColor ?? this.lockButtonBackgroundColor, + recordingIndicatorColorIdle: + recordingIndicatorColorIdle ?? this.recordingIndicatorColorIdle, + recordingIndicatorColorActive: + recordingIndicatorColorActive ?? this.recordingIndicatorColorActive, + cancelTextColor: cancelTextColor ?? this.cancelTextColor, + ); + } + + /// Linearly interpolate between two themes. + AudioRecordingMessageThemeData lerp( + covariant AudioRecordingMessageThemeData? other, + double t, + ) { + if (other is! AudioRecordingMessageThemeData) { + return this; + } + + return AudioRecordingMessageThemeData( + audioButtonColor: + Color.lerp(audioButtonColor, other.audioButtonColor, t)!, + audioButtonBannerColor: + Color.lerp(audioButtonBannerColor, other.audioButtonBannerColor, t)!, + audioButtonPressedColor: Color.lerp( + audioButtonPressedColor, other.audioButtonPressedColor, t)!, + audioButtonPressedBackgroundColor: Color.lerp( + audioButtonPressedBackgroundColor, + other.audioButtonPressedBackgroundColor, + t)!, + lockButtonForegroundColor: Color.lerp( + lockButtonForegroundColor, other.lockButtonForegroundColor, t)!, + lockButtonBackgroundColor: Color.lerp( + lockButtonBackgroundColor, other.lockButtonBackgroundColor, t)!, + recordingIndicatorColorIdle: Color.lerp( + recordingIndicatorColorIdle, other.recordingIndicatorColorIdle, t)!, + recordingIndicatorColorActive: Color.lerp(recordingIndicatorColorActive, + other.recordingIndicatorColorActive, t)!, + cancelTextColor: Color.lerp(cancelTextColor, other.cancelTextColor, t)!, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is AudioRecordingMessageThemeData && + other.audioButtonColor == audioButtonColor && + other.audioButtonBannerColor == audioButtonBannerColor && + other.audioButtonPressedColor == audioButtonPressedColor && + other.audioButtonPressedBackgroundColor == + audioButtonPressedBackgroundColor && + other.lockButtonForegroundColor == lockButtonForegroundColor && + other.lockButtonBackgroundColor == lockButtonBackgroundColor && + other.recordingIndicatorColorIdle == recordingIndicatorColorIdle && + other.recordingIndicatorColorActive == recordingIndicatorColorActive && + other.cancelTextColor == cancelTextColor; + } + + @override + int get hashCode => + audioButtonColor.hashCode ^ + audioButtonBannerColor.hashCode ^ + audioButtonPressedColor.hashCode ^ + audioButtonPressedBackgroundColor.hashCode ^ + lockButtonForegroundColor.hashCode ^ + lockButtonBackgroundColor.hashCode ^ + recordingIndicatorColorIdle.hashCode ^ + recordingIndicatorColorActive.hashCode ^ + cancelTextColor.hashCode; + + /// Merges one [AudioRecordingMessageThemeData] with the another + AudioRecordingMessageThemeData merge(AudioRecordingMessageThemeData? other) { + if (other == null) return this; + return copyWith( + audioButtonColor: other.audioButtonColor, + audioButtonBannerColor: other.audioButtonBannerColor, + audioButtonPressedColor: other.audioButtonPressedColor, + audioButtonPressedBackgroundColor: + other.audioButtonPressedBackgroundColor, + lockButtonForegroundColor: other.lockButtonForegroundColor, + lockButtonBackgroundColor: other.lockButtonBackgroundColor, + recordingIndicatorColorIdle: other.recordingIndicatorColorIdle, + recordingIndicatorColorActive: other.recordingIndicatorColorActive, + cancelTextColor: other.cancelTextColor, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('audioButtonColor', audioButtonColor)) + ..add( + DiagnosticsProperty('audioButtonBannerColor', audioButtonBannerColor)) + ..add(DiagnosticsProperty( + 'audioButtonPressedColor', audioButtonPressedColor)) + ..add(DiagnosticsProperty('audioButtonPressedBackgroundColor', + audioButtonPressedBackgroundColor)) + ..add(DiagnosticsProperty( + 'lockButtonForegroundColor', lockButtonForegroundColor)) + ..add(DiagnosticsProperty( + 'lockButtonBackgroundColor', lockButtonBackgroundColor)) + ..add(DiagnosticsProperty( + 'recordingIndicatorColorIdle', recordingIndicatorColorIdle)) + ..add(DiagnosticsProperty( + 'recordingIndicatorColorActive', recordingIndicatorColorActive)) + ..add(DiagnosticsProperty('cancelTextColor', cancelTextColor)); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart b/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart index 6e43fef83..9ef37ec2f 100644 --- a/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart @@ -64,6 +64,8 @@ class StreamMessageInputThemeData with Diagnosticable { this.sendButtonColor, this.actionButtonIdleColor, this.sendButtonIdleColor, + this.audioButtonColor, + this.audioButtonBannerColor, this.inputBackgroundColor, this.inputTextStyle, this.inputDecoration, @@ -98,6 +100,12 @@ class StreamMessageInputThemeData with Diagnosticable { /// Background color of [MessageInput] expand button final Color? expandButtonColor; + /// Background color of [MessageInput] audio button + final Color? audioButtonColor; + + /// Background color of [MessageInput] audio button banner + final Color? audioButtonBannerColor; + /// Background color of [MessageInput] final Color? inputBackgroundColor; @@ -135,6 +143,8 @@ class StreamMessageInputThemeData with Diagnosticable { Color? actionButtonIdleColor, Color? linkHighlightColor, Color? sendButtonIdleColor, + Color? audioButtonColor, + Color? audioButtonBannerColor, Color? expandButtonColor, TextStyle? inputTextStyle, InputDecoration? inputDecoration, @@ -157,6 +167,9 @@ class StreamMessageInputThemeData with Diagnosticable { expandButtonColor: expandButtonColor ?? this.expandButtonColor, inputTextStyle: inputTextStyle ?? this.inputTextStyle, sendButtonIdleColor: sendButtonIdleColor ?? this.sendButtonIdleColor, + audioButtonColor: audioButtonColor ?? this.audioButtonColor, + audioButtonBannerColor: + audioButtonBannerColor ?? this.audioButtonBannerColor, inputDecoration: inputDecoration ?? this.inputDecoration, activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient, idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient, @@ -191,6 +204,9 @@ class StreamMessageInputThemeData with Diagnosticable { sendButtonColor: Color.lerp(a.sendButtonColor, b.sendButtonColor, t), sendButtonIdleColor: Color.lerp(a.sendButtonIdleColor, b.sendButtonIdleColor, t), + audioButtonColor: Color.lerp(a.audioButtonColor, b.audioButtonColor, t), + audioButtonBannerColor: + Color.lerp(a.audioButtonBannerColor, b.audioButtonBannerColor, t), sendAnimationDuration: a.sendAnimationDuration, inputDecoration: a.inputDecoration, enableSafeArea: a.enableSafeArea, @@ -209,6 +225,8 @@ class StreamMessageInputThemeData with Diagnosticable { actionButtonIdleColor: other.actionButtonIdleColor, sendButtonColor: other.sendButtonColor, sendButtonIdleColor: other.sendButtonIdleColor, + audioButtonColor: other.audioButtonColor, + audioButtonBannerColor: other.audioButtonBannerColor, inputTextStyle: inputTextStyle?.merge(other.inputTextStyle) ?? other.inputTextStyle, inputDecoration: inputDecoration?.merge(other.inputDecoration) ?? @@ -233,6 +251,8 @@ class StreamMessageInputThemeData with Diagnosticable { sendButtonColor == other.sendButtonColor && actionButtonColor == other.actionButtonColor && sendButtonIdleColor == other.sendButtonIdleColor && + audioButtonColor == other.audioButtonColor && + audioButtonBannerColor == other.audioButtonBannerColor && actionButtonIdleColor == other.actionButtonIdleColor && expandButtonColor == other.expandButtonColor && inputBackgroundColor == other.inputBackgroundColor && @@ -252,6 +272,8 @@ class StreamMessageInputThemeData with Diagnosticable { sendButtonColor.hashCode ^ actionButtonColor.hashCode ^ sendButtonIdleColor.hashCode ^ + audioButtonColor.hashCode ^ + audioButtonBannerColor.hashCode ^ actionButtonIdleColor.hashCode ^ expandButtonColor.hashCode ^ inputBackgroundColor.hashCode ^ @@ -275,6 +297,8 @@ class StreamMessageInputThemeData with Diagnosticable { ..add(ColorProperty('actionButtonIdleColor', actionButtonIdleColor)) ..add(ColorProperty('sendButtonColor', sendButtonColor)) ..add(ColorProperty('sendButtonIdleColor', sendButtonIdleColor)) + ..add(ColorProperty('audioButtonColor', audioButtonColor)) + ..add(ColorProperty('audioButtonBannerColor', audioButtonBannerColor)) ..add(DiagnosticsProperty('inputTextStyle', inputTextStyle)) ..add(DiagnosticsProperty('inputDecoration', inputDecoration)) ..add(DiagnosticsProperty('activeBorderGradient', activeBorderGradient)) diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index 39859a770..2053f6956 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -55,6 +55,7 @@ class StreamChatThemeData { StreamGalleryHeaderThemeData? imageHeaderTheme, StreamGalleryFooterThemeData? imageFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, + AudioRecordingMessageThemeData? audioRecordingMessageTheme, }) { brightness ??= colorTheme?.brightness ?? Brightness.light; final isDark = brightness == Brightness.dark; @@ -81,6 +82,7 @@ class StreamChatThemeData { galleryHeaderTheme: imageHeaderTheme, galleryFooterTheme: imageFooterTheme, messageListViewTheme: messageListViewTheme, + audioRecordingMessageTheme: audioRecordingMessageTheme, ); return defaultData.merge(customizedData); @@ -108,6 +110,7 @@ class StreamChatThemeData { required this.galleryHeaderTheme, required this.galleryFooterTheme, required this.messageListViewTheme, + required this.audioRecordingMessageTheme, }); /// Creates a theme from a Material [Theme] @@ -277,6 +280,7 @@ class StreamChatThemeData { messageListViewTheme: StreamMessageListViewThemeData( backgroundColor: colorTheme.barsBg, ), + audioRecordingMessageTheme: AudioRecordingMessageThemeData(), ); } @@ -318,6 +322,9 @@ class StreamChatThemeData { /// Theme configuration for the [StreamMessageListView] widget. final StreamMessageListViewThemeData messageListViewTheme; + /// The theme and configuration for audio recording message components + final AudioRecordingMessageThemeData audioRecordingMessageTheme; + /// Creates a copy of [StreamChatThemeData] with specified attributes /// overridden. StreamChatThemeData copyWith({ @@ -337,6 +344,7 @@ class StreamChatThemeData { StreamGalleryHeaderThemeData? galleryHeaderTheme, StreamGalleryFooterThemeData? galleryFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, + AudioRecordingMessageThemeData? audioRecordingMessageTheme, }) => StreamChatThemeData.raw( channelListHeaderTheme: @@ -353,6 +361,8 @@ class StreamChatThemeData { galleryHeaderTheme: galleryHeaderTheme ?? this.galleryHeaderTheme, galleryFooterTheme: galleryFooterTheme ?? this.galleryFooterTheme, messageListViewTheme: messageListViewTheme ?? this.messageListViewTheme, + audioRecordingMessageTheme: + audioRecordingMessageTheme ?? this.audioRecordingMessageTheme, ); /// Merge themes @@ -373,6 +383,8 @@ class StreamChatThemeData { galleryFooterTheme: galleryFooterTheme.merge(other.galleryFooterTheme), messageListViewTheme: messageListViewTheme.merge(other.messageListViewTheme), + audioRecordingMessageTheme: + audioRecordingMessageTheme.merge(other.audioRecordingMessageTheme), ); } } diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index 9e3f2dd93..cc3cee0c7 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -1,3 +1,4 @@ +export 'audio_recording_message_theme.dart'; export 'avatar_theme.dart'; export 'channel_header_theme.dart'; export 'channel_list_header_theme.dart'; diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 8dd9c0351..5acb6de57 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -43,6 +43,7 @@ export 'src/localization/translations.dart' show DefaultTranslations; export 'src/message_actions_modal/message_action.dart'; export 'src/message_input/attachment_picker/stream_attachment_picker.dart'; export 'src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart'; +export 'src/message_input/audio_message_send_button/audio_message_send_button.dart'; export 'src/message_input/countdown_button.dart'; export 'src/message_input/enums.dart'; export 'src/message_input/stream_message_input.dart'; diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_Lock.svg b/packages/stream_chat_flutter/lib/svgs/Icon_Lock.svg new file mode 100644 index 000000000..bd00957b7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_Lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_Mic.svg b/packages/stream_chat_flutter/lib/svgs/Icon_Mic.svg new file mode 100644 index 000000000..e3ad35116 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_Mic.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/left.svg b/packages/stream_chat_flutter/lib/svgs/left.svg new file mode 100644 index 000000000..893d029e2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_message_button/audio_message_button_test.dart b/packages/stream_chat_flutter/test/src/message_input/audio_message_button/audio_message_button_test.dart new file mode 100644 index 000000000..fce9d8541 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/audio_message_button/audio_message_button_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class MockVoidCallback extends Mock { + void call(); +} + +void main() { + group('StreamAudioMessageButton', () { + testWidgets('should show info bar on tap', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StreamChatTheme( + data: StreamChatThemeData(), + child: StreamAudioMessageSendButton( + onRecordingStart: () {}, + onRecordingEnd: () {}, + onRecordingCanceled: () {}, + ), + ), + ), + ), + ), + ); + + expect(find.byType(StreamAudioMessageSendButton), findsOneWidget); + + await tester.tap(find.byType(StreamAudioMessageSendButton)); + await tester.pump(); + expect( + find.byType(AudioMessageInfoBannerOverlay, skipOffstage: false), + findsOneWidget, + ); + await tester.pumpAndSettle( + const Duration(milliseconds: 500), + ); + expect( + find.byType(AudioMessageInfoBannerOverlay, skipOffstage: false), + findsNothing, + ); + }); + + testWidgets('should show lock button on long press', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StreamChatTheme( + data: StreamChatThemeData( + audioRecordingMessageTheme: AudioRecordingMessageThemeData(), + ), + child: StreamAudioMessageSendButton( + onRecordingStart: () {}, + onRecordingEnd: () {}, + onRecordingCanceled: () {}, + ), + ), + ), + ), + ), + ); + + expect(find.byType(StreamAudioMessageSendButton), findsOneWidget); + + final position = + tester.getCenter(find.byType(StreamAudioMessageSendButton)); + await tester.startGesture(position, pointer: 1); + await tester.pumpAndSettle( + const Duration(seconds: 1), + ); + expect( + find.byType(LockButtonOverlay, skipOffstage: false), + findsOneWidget, + ); + }); + + testWidgets('should show lock button on long press', (tester) async { + final onRecordingStart = MockVoidCallback(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StreamChatTheme( + data: StreamChatThemeData( + audioRecordingMessageTheme: AudioRecordingMessageThemeData(), + ), + child: StreamAudioMessageSendButton( + onRecordingStart: onRecordingStart, + onRecordingEnd: () {}, + onRecordingCanceled: () {}, + ), + ), + ), + ), + ), + ); + + final position = + tester.getCenter(find.byType(StreamAudioMessageSendButton)); + await tester.startGesture(position, pointer: 1); + await tester.pumpAndSettle( + const Duration(milliseconds: 500), + ); + verify(onRecordingStart.call); + }); + + testWidgets('should show call the cancel callback', (tester) async { + final onRecordingCanceled = MockVoidCallback(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StreamChatTheme( + data: StreamChatThemeData( + audioRecordingMessageTheme: AudioRecordingMessageThemeData(), + ), + child: StreamAudioMessageSendButton( + onRecordingStart: () {}, + onRecordingEnd: () {}, + onRecordingCanceled: onRecordingCanceled, + ), + ), + ), + ), + ), + ); + + final position = + tester.getCenter(find.byType(StreamAudioMessageSendButton)); + final gesture = await tester.startGesture(position, pointer: 1); + await tester.pumpAndSettle( + const Duration(milliseconds: 500), + ); + await gesture.moveTo(position + const Offset(-300, 0)); + verify(onRecordingCanceled.call); + }); + }); +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart index ae331e054..d95a30cd8 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; - import 'package:stream_chat_flutter_core/src/message_text_field_controller.dart'; /// A value listenable builder related to a [Message]. @@ -66,6 +65,7 @@ class StreamMessageInputController extends ValueNotifier { MessageTextFieldController _textFieldController; Message _initialMessage; + bool _isRecordingAudio = false; static TextEditingValue _textEditingValueFromMessage(Message message) { final messageText = message.text; @@ -90,6 +90,15 @@ class StreamMessageInputController extends ValueNotifier { /// Sets the current message associated with this controller. set message(Message message) => value = message; + /// Returns true if the message is empty. + bool get isRecordingAudio => _isRecordingAudio; + + /// Sets the [isRecordingAudio] flag of the message. + set isRecordingAudio(bool isRecordingAudio) { + _isRecordingAudio = isRecordingAudio; + notifyListeners(); + } + @override set value(Message message) { super.value = message;