moxxy/lib/ui/widgets/messaging_textfield/controller.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;
}
}