From a63343ccdd62912f0e929018ef91cbb9ea3fbcb8 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 7 Dec 2025 00:51:43 +0100 Subject: [PATCH] fix #323 --- lib/src/localization/app_de.arb | 4 +- lib/src/localization/app_en.arb | 4 +- .../generated/app_localizations.dart | 12 + .../generated/app_localizations_de.dart | 6 + .../generated/app_localizations_en.dart | 6 + .../entries/chat_audio_entry.dart | 26 +- .../message_input.dart | 275 +++++++++++++----- .../views/components/media_view_sizing.dart | 2 +- 8 files changed, 238 insertions(+), 97 deletions(-) diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index 3c344f2..66c9b26 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -829,5 +829,7 @@ "inProcess": "Wird verarbeitet", "draftMessage": "Entwurf", "exportMemories": "Memories exportieren (Beta)", - "importMemories": "Memories importieren (Beta)" + "importMemories": "Memories importieren (Beta)", + "voiceMessageSlideToCancel": "Zum Abbrechen ziehen", + "voiceMessageCancel": "Abbrechen" } \ No newline at end of file diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index 26ada4e..434d47a 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -607,5 +607,7 @@ "inProcess": "In process", "draftMessage": "Draft", "exportMemories": "Export memories (Beta)", - "importMemories": "Import memories (Beta)" + "importMemories": "Import memories (Beta)", + "voiceMessageSlideToCancel": "Slide to cancel", + "voiceMessageCancel": "Cancel" } \ No newline at end of file diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 9cadfcf..bc38aad 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -2749,6 +2749,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Import memories (Beta)'** String get importMemories; + + /// No description provided for @voiceMessageSlideToCancel. + /// + /// In en, this message translates to: + /// **'Slide to cancel'** + String get voiceMessageSlideToCancel; + + /// No description provided for @voiceMessageCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get voiceMessageCancel; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 2f14fda..5f74d51 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1516,4 +1516,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get importMemories => 'Memories importieren (Beta)'; + + @override + String get voiceMessageSlideToCancel => 'Zum Abbrechen ziehen'; + + @override + String get voiceMessageCancel => 'Abbrechen'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index d011a43..92fd360 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1506,4 +1506,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get importMemories => 'Import memories (Beta)'; + + @override + String get voiceMessageSlideToCancel => 'Slide to cancel'; + + @override + String get voiceMessageCancel => 'Cancel'; } diff --git a/lib/src/views/chats/chat_messages_components/entries/chat_audio_entry.dart b/lib/src/views/chats/chat_messages_components/entries/chat_audio_entry.dart index 24267fb..759ac7b 100644 --- a/lib/src/views/chats/chat_messages_components/entries/chat_audio_entry.dart +++ b/lib/src/views/chats/chat_messages_components/entries/chat_audio_entry.dart @@ -84,12 +84,7 @@ class ChatAudioEntry extends StatelessWidget { path: mediaService.tempPath.path, message: message, ) - : (mediaService.originalPath.existsSync()) - ? InChatAudioPlayer( - path: mediaService.originalPath.path, - message: message, - ) - : Container() + : Container() else MessageSendStateIcon([message], [mediaService.mediaFile]), ], @@ -138,14 +133,22 @@ class _InChatAudioPlayerState extends State { } }); - _playerController.onCurrentDurationChanged.listen((duration) { + _playerController.onPlayerStateChanged.listen((a) async { + if (a == PlayerState.initialized) { + _displayDuration = + await _playerController.getDuration(DurationType.max); + _maxDuration = _displayDuration; + setState(() {}); + } + }); + + _playerController.onCurrentDurationChanged.listen((duration) async { if (mounted) { setState(() { _displayDuration = _maxDuration - duration; }); } }); - initAsync(); } @override @@ -154,13 +157,6 @@ class _InChatAudioPlayerState extends State { super.dispose(); } - Future initAsync() async { - _displayDuration = await _playerController.getDuration(DurationType.max); - _maxDuration = _displayDuration; - if (!mounted) return; - setState(() {}); - } - bool _isPlaying = false; @override diff --git a/lib/src/views/chats/chat_messages_components/message_input.dart b/lib/src/views/chats/chat_messages_components/message_input.dart index 2e979b6..504f936 100644 --- a/lib/src/views/chats/chat_messages_components/message_input.dart +++ b/lib/src/views/chats/chat_messages_components/message_input.dart @@ -15,6 +15,7 @@ import 'package:twonly/src/services/api/mediafiles/upload.service.dart'; import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/camera/camera_send_to_view.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_audio_entry.dart'; class MessageInput extends StatefulWidget { const MessageInput({ @@ -41,6 +42,10 @@ class _MessageInputState extends State { late final RecorderController recorderController; final bool isApple = Platform.isIOS; bool _emojiShowing = false; + bool _audioRecordingLock = false; + int _currentDuration = 0; + double _cancelSlideOffset = 0; + Offset _recordingOffset = Offset.zero; RecordingState _recordingState = RecordingState.none; Future _sendMessage() async { @@ -72,11 +77,17 @@ class _MessageInputState extends State { void dispose() { widget.textFieldFocus.removeListener(_handleTextFocusChange); widget.textFieldFocus.dispose(); + recorderController.dispose(); super.dispose(); } void _initializeControllers() { recorderController = RecorderController(); + recorderController.onCurrentDuration.listen((duration) { + setState(() { + _currentDuration = duration.inMilliseconds; + }); + }); } void _handleTextFocusChange() { @@ -87,9 +98,37 @@ class _MessageInputState extends State { } } + Future _startAudioRecording() async { + if (!await Permission.microphone.isGranted) { + final statuses = await [ + Permission.microphone, + ].request(); + if (statuses[Permission.microphone]!.isPermanentlyDenied) { + await openAppSettings(); + return; + } + if (!await Permission.microphone.isGranted) { + return; + } + } + setState(() { + _recordingState = RecordingState.recording; + }); + await HapticFeedback.heavyImpact(); + final audioTmpPath = + '${(await getApplicationCacheDirectory()).path}/recording.m4a'; + unawaited( + recorderController.record( + path: audioTmpPath, + ), + ); + } + Future _stopAudioRecording() async { await HapticFeedback.heavyImpact(); setState(() { + _audioRecordingLock = false; + _cancelSlideOffset = 0; _recordingState = RecordingState.none; }); @@ -114,6 +153,19 @@ class _MessageInputState extends State { ); } + Future _cancelAudioRecording() async { + setState(() { + _audioRecordingLock = false; + _cancelSlideOffset = 0; + _recordingState = RecordingState.none; + }); + final path = await recorderController.stop(); + if (path == null) return; + if (File(path).existsSync()) { + File(path).deleteSync(); + } + } + @override Widget build(BuildContext context) { return Column( @@ -169,27 +221,53 @@ class _MessageInputState extends State { ), Expanded( child: (_recordingState == RecordingState.recording) - ? AudioWaveforms( - enableGesture: true, - size: Size( - MediaQuery.of(context).size.width / 2, - 50, - ), - recorderController: recorderController, - waveStyle: WaveStyle( - waveColor: isDarkMode(context) - ? Colors.white - : Colors.black, - extendWaveform: true, - showMiddleLine: false, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: context.color.surfaceContainer, - ), - padding: const EdgeInsets.only(left: 18), - margin: - const EdgeInsets.symmetric(horizontal: 15), + ? Row( + children: [ + const Padding( + padding: EdgeInsets.only( + top: 14, + bottom: 14, + left: 12, + right: 8, + ), + child: FaIcon( + FontAwesomeIcons.microphone, + size: 20, + color: Colors.red, + ), + ), + const SizedBox(width: 10), + Text( + formatMsToMinSec( + _currentDuration, + ), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + if (!_audioRecordingLock) ...[ + SizedBox( + width: (100 - _cancelSlideOffset) % 101, + ), + Text( + context.lang.voiceMessageSlideToCancel), + ] else ...[ + Expanded( + child: Container(), + ), + GestureDetector( + onTap: _cancelAudioRecording, + child: Text( + context.lang.voiceMessageCancel, + style: const TextStyle( + color: Colors.red, + ), + ), + ), + const SizedBox(width: 20) + ], + ], ) : TextField( controller: _textFieldController, @@ -220,47 +298,84 @@ class _MessageInputState extends State { ), if (_textFieldController.text == '') GestureDetector( - onLongPressStart: (a) async { - if (!await Permission.microphone.isGranted) { - final statuses = await [ - Permission.microphone, - ].request(); - if (statuses[Permission.microphone]! - .isPermanentlyDenied) { - await openAppSettings(); - return; - } - if (!await Permission.microphone.isGranted) { - return; - } + onLongPressMoveUpdate: (details) { + if (_audioRecordingLock) return; + if (_recordingOffset.dy - + details.localPosition.dy >= + 100) { + HapticFeedback.heavyImpact(); + setState(() { + _audioRecordingLock = true; + }); } - setState(() { - _recordingState = RecordingState.recording; - }); - await HapticFeedback.heavyImpact(); - final audioTmpPath = - '${(await getApplicationCacheDirectory()).path}/recording.m4a'; - unawaited( - recorderController.record( - path: audioTmpPath, - ), - ); - }, - onLongPressCancel: () async { - final path = await recorderController.stop(); - if (path == null) return; - if (File(path).existsSync()) { - File(path).deleteSync(); - } - setState(() { + if (_recordingOffset.dx - + details.localPosition.dx >= + 90 && + _recordingState == RecordingState.recording) { _recordingState = RecordingState.none; + HapticFeedback.heavyImpact(); + _cancelAudioRecording(); + } + + setState(() { + final a = _recordingOffset.dx - + details.localPosition.dx; + if (a > 0 && a <= 90) { + _cancelSlideOffset = _recordingOffset.dx - + details.localPosition.dx; + } }); }, - onLongPressEnd: (a) => _stopAudioRecording(), + onLongPressStart: (a) { + _recordingOffset = a.localPosition; + _startAudioRecording(); + }, + onLongPressCancel: _cancelAudioRecording, + onLongPressEnd: (a) { + if (_recordingState != RecordingState.recording) { + return; + } + if (!_audioRecordingLock) { + _stopAudioRecording(); + } + }, child: Stack( clipBehavior: Clip.none, children: [ - if (_recordingState == RecordingState.recording) + if (_recordingState == RecordingState.recording && + !_audioRecordingLock) + Positioned.fill( + top: -120, + left: -5, + child: Align( + alignment: AlignmentGeometry.topCenter, + child: Container( + padding: const EdgeInsets.only(top: 13), + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(90), + color: Colors.black, + ), + child: const Center( + child: Column( + children: [ + FaIcon( + FontAwesomeIcons.lock, + size: 16, + ), + SizedBox(height: 5), + FaIcon( + FontAwesomeIcons.angleUp, + size: 16, + ), + ], + ), + ), + ), + ), + ), + if (_recordingState == RecordingState.recording && + !_audioRecordingLock) Positioned.fill( top: -20, left: -25, @@ -275,30 +390,31 @@ class _MessageInputState extends State { height: 60, ), ), - ColoredBox( - color: Colors.transparent, - child: Padding( - padding: const EdgeInsets.only( - top: 8, - bottom: 8, - left: 8, - right: 12, - ), - child: FaIcon( - size: 20, - color: (_recordingState == - RecordingState.recording) - ? Colors.white - : null, - (_recordingState == RecordingState.none) - ? FontAwesomeIcons.microphone - : (_recordingState == - RecordingState.recording) - ? FontAwesomeIcons.stop - : FontAwesomeIcons.play, + if (!_audioRecordingLock) + ColoredBox( + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.only( + top: 8, + bottom: 8, + left: 8, + right: 12, + ), + child: FaIcon( + size: 20, + color: (_recordingState == + RecordingState.recording) + ? Colors.white + : null, + (_recordingState == RecordingState.none) + ? FontAwesomeIcons.microphone + : (_recordingState == + RecordingState.recording) + ? FontAwesomeIcons.stop + : FontAwesomeIcons.play, + ), ), ), - ), ], ), ), @@ -306,14 +422,15 @@ class _MessageInputState extends State { ), ), ), - if (_textFieldController.text != '') + if (_textFieldController.text != '' || _audioRecordingLock) IconButton( padding: const EdgeInsets.all(15), icon: FaIcon( color: context.color.primary, FontAwesomeIcons.solidPaperPlane, ), - onPressed: _sendMessage, + onPressed: + _audioRecordingLock ? _stopAudioRecording : _sendMessage, ) else IconButton( diff --git a/lib/src/views/components/media_view_sizing.dart b/lib/src/views/components/media_view_sizing.dart index 6510979..0ebc55d 100644 --- a/lib/src/views/components/media_view_sizing.dart +++ b/lib/src/views/components/media_view_sizing.dart @@ -48,7 +48,7 @@ class _MediaViewSizingState extends State { Widget imageChild = Align( alignment: Alignment.topCenter, child: SizedBox( - height: availableHeight, + // height: availableHeight, child: AspectRatio( aspectRatio: 9 / 16, child: ClipRRect(