239 lines
7.7 KiB
Dart
239 lines
7.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:fluttertoast/fluttertoast.dart';
|
|
import 'package:moxxy_native/moxxy_native.dart';
|
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
|
import 'package:moxxyv2/shared/commands.dart';
|
|
import 'package:moxxyv2/ui/widgets/messaging_textfield/overlay.dart';
|
|
import 'package:moxxyv2/ui/widgets/messaging_textfield/slider.dart';
|
|
import 'package:moxxyv2/ui/widgets/timer/controller.dart';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:record/record.dart';
|
|
|
|
typedef PositionValueNotifier = ValueNotifier<Offset>;
|
|
typedef BooleanValueNotifier = ValueNotifier<bool>;
|
|
|
|
class MobileMessagingTextFieldController {
|
|
MobileMessagingTextFieldController(this.conversationJid);
|
|
|
|
/// A notifier that carries the current (right, top) coordinates of the recording
|
|
/// button overlay.
|
|
final PositionValueNotifier positionNotifier =
|
|
ValueNotifier<Offset>(Offset.zero);
|
|
|
|
/// A notifier that carries the current lock state of the recording, i.e. if the
|
|
/// recording should continue after the pointer up event (true) or end (false).
|
|
final BooleanValueNotifier lockedNotifier = BooleanValueNotifier(false);
|
|
|
|
/// A notifier that carries a flag indicating whether the recording button is currently
|
|
/// being dragged (true) or not (false).
|
|
final BooleanValueNotifier draggingNotifier = BooleanValueNotifier(false);
|
|
|
|
/// A notifier that carries a flag indicating whether the overlay should still be visible
|
|
/// (true) or not (false). This is used to allow animating the overlay "out" instead of it
|
|
/// just disappearing.
|
|
final BooleanValueNotifier keepSliderNotifier = BooleanValueNotifier(false);
|
|
|
|
/// A notifier that carries a flag indicating whether we've started the process of recording
|
|
/// (true) or not (false).
|
|
final BooleanValueNotifier isRecordingNotifier = BooleanValueNotifier(false);
|
|
|
|
/// A notifier that carries a flag indicating whether the recording should be cancelled (true)
|
|
/// or not (false).
|
|
final BooleanValueNotifier isCancellingNotifier = BooleanValueNotifier(false);
|
|
|
|
/// The [AnimationController] that controls the animation of the [TextFieldSlider].
|
|
AnimationController? _animationController;
|
|
|
|
/// The controller that manages starting/stopping the recording time indicator.
|
|
final TimerController timerController = TimerController();
|
|
|
|
/// The currently displayed overlay managed by this class.
|
|
OverlayEntry? _overlayEntry;
|
|
|
|
/// Flag whether we're currently requesting permission to access the microphone (true)
|
|
/// or not (false). Useful to prevent weird things from happening if the permission popup
|
|
/// causes a [PointerUpEvent].
|
|
bool requestingPermission = false;
|
|
|
|
/// The audio recorder.
|
|
final AudioRecorder _recorder = AudioRecorder();
|
|
|
|
/// The JID of the currently opened chat.
|
|
final String conversationJid;
|
|
|
|
/// Prepare the controller for real usage.
|
|
void register(AnimationController controller) {
|
|
_animationController = controller;
|
|
_animationController!.addStatusListener(_onAnimationStatusChanged);
|
|
isCancellingNotifier.addListener(_onIsCancellingChanged);
|
|
}
|
|
|
|
/// Dispose of everything in the class.
|
|
Future<void> dispose() async {
|
|
_animationController?.removeStatusListener(_onAnimationStatusChanged);
|
|
isCancellingNotifier.removeListener(_onIsCancellingChanged);
|
|
_removeOverlay();
|
|
|
|
// Get rid of the audio recorder.
|
|
if (await _recorder.isRecording()) {
|
|
await _cancelAudioRecording();
|
|
}
|
|
await _recorder.dispose();
|
|
}
|
|
|
|
void _onIsCancellingChanged() {
|
|
if (isCancellingNotifier.value == true) {
|
|
endRecording();
|
|
}
|
|
}
|
|
|
|
void _onAnimationStatusChanged(AnimationStatus status) {
|
|
if (status == AnimationStatus.dismissed && !isRecordingNotifier.value) {
|
|
keepSliderNotifier.value = false;
|
|
lockedNotifier.value = false;
|
|
_removeOverlay();
|
|
}
|
|
}
|
|
|
|
Future<void> _startAudioRecording() async {
|
|
final now = DateTime.now();
|
|
final filename =
|
|
'audio_${now.year}${now.month}${now.day}${now.hour}${now.second}.aac';
|
|
final recordingFilePath = path.join(
|
|
await MoxxyPlatformApi().getCacheDataPath(),
|
|
filename,
|
|
);
|
|
await _recorder.start(
|
|
const RecordConfig(),
|
|
path: recordingFilePath,
|
|
);
|
|
}
|
|
|
|
Future<void> _cancelAudioRecording() async {
|
|
final file = await _recorder.stop();
|
|
|
|
if (file != null) {
|
|
unawaited(File(file).delete());
|
|
}
|
|
}
|
|
|
|
Future<void> _endAudioRecording() async {
|
|
final file = await _recorder.stop();
|
|
if (file == null) {
|
|
await Fluttertoast.showToast(
|
|
msg: t.errors.conversation.audioRecordingError,
|
|
gravity: ToastGravity.SNACKBAR,
|
|
toastLength: Toast.LENGTH_SHORT,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Send the file.
|
|
await getForegroundService().send(
|
|
SendFilesCommand(
|
|
paths: [file],
|
|
recipients: [conversationJid],
|
|
),
|
|
awaitable: false,
|
|
);
|
|
}
|
|
|
|
Future<void> startRecording(BuildContext context) async {
|
|
// Make sure that part of the UI looks locked at this point.
|
|
draggingNotifier.value = false;
|
|
isRecordingNotifier.value = true;
|
|
|
|
// Prevent weird issues with the popup leading to us losing track of the pointer.
|
|
requestingPermission = true;
|
|
final canRecord =
|
|
await Permission.microphone.status == PermissionStatus.granted;
|
|
|
|
// "Forward" the UI
|
|
unawaited(_animationController!.forward());
|
|
isCancellingNotifier.value = false;
|
|
keepSliderNotifier.value = true;
|
|
|
|
if (!canRecord) {
|
|
// Make the UI appear as if we just locked it
|
|
lockedNotifier.value = true;
|
|
|
|
final requestResult = await Permission.microphone.request();
|
|
requestingPermission = false;
|
|
|
|
// If we successfully requested the permission, actually start recording. If not,
|
|
// tell the user and cancel the process.
|
|
if (requestResult == PermissionStatus.granted) {
|
|
timerController.runningNotifier.value = true;
|
|
await _startAudioRecording();
|
|
} else {
|
|
isCancellingNotifier.value = true;
|
|
await endRecording();
|
|
await Fluttertoast.showToast(
|
|
msg: t.warnings.conversation.microphoneDenied,
|
|
toastLength: Toast.LENGTH_LONG,
|
|
);
|
|
return;
|
|
}
|
|
} else {
|
|
requestingPermission = false;
|
|
draggingNotifier.value = true;
|
|
|
|
timerController.runningNotifier.value = true;
|
|
// ignore: use_build_context_synchronously
|
|
_createOverlay(context);
|
|
await _startAudioRecording();
|
|
}
|
|
}
|
|
|
|
/// Wrapper around [endRecording] that also sets [isCancellingNotifier]'s value
|
|
/// to true to discard a recording.
|
|
void cancelRecording() {
|
|
isCancellingNotifier.value = true;
|
|
endRecording();
|
|
}
|
|
|
|
/// Ends audio recording and either discards the recording or sends the file
|
|
/// to the currently opened chat.
|
|
Future<void> endRecording() async {
|
|
draggingNotifier.value = false;
|
|
isRecordingNotifier.value = false;
|
|
await _animationController?.reverse();
|
|
timerController.runningNotifier.value = false;
|
|
|
|
if (!isCancellingNotifier.value) {
|
|
if (timerController.runtime >= 1) {
|
|
await _endAudioRecording();
|
|
} else {
|
|
await _cancelAudioRecording();
|
|
await Fluttertoast.showToast(
|
|
msg: t.warnings.conversation.holdForLonger,
|
|
toastLength: Toast.LENGTH_SHORT,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Mak sure that the time starts at 0 again.
|
|
timerController.reset();
|
|
await _cancelAudioRecording();
|
|
}
|
|
|
|
void _createOverlay(BuildContext context) {
|
|
_removeOverlay();
|
|
|
|
_overlayEntry = OverlayEntry(
|
|
builder: (context) {
|
|
return RecordButtonOverlay(this);
|
|
},
|
|
);
|
|
Overlay.of(context).insert(_overlayEntry!);
|
|
}
|
|
|
|
void _removeOverlay() {
|
|
_overlayEntry?.remove();
|
|
_overlayEntry = null;
|
|
}
|
|
}
|