mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-04-19 03:52:54 +00:00
536 lines
21 KiB
Dart
536 lines
21 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:audio_waveforms/audio_waveforms.dart';
|
|
import 'package:drift/drift.dart' show Value;
|
|
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:twonly/globals.dart';
|
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
|
import 'package:twonly/src/database/twonly.db.dart';
|
|
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/bottom_sheets/share_additional.bottom_sheet.dart';
|
|
import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_audio_entry.dart';
|
|
|
|
class MessageInput extends StatefulWidget {
|
|
const MessageInput({
|
|
required this.group,
|
|
required this.quotesMessage,
|
|
required this.textFieldFocus,
|
|
required this.onMessageSend,
|
|
super.key,
|
|
});
|
|
|
|
final Group group;
|
|
final FocusNode textFieldFocus;
|
|
final Message? quotesMessage;
|
|
final VoidCallback onMessageSend;
|
|
|
|
@override
|
|
State<MessageInput> createState() => _MessageInputState();
|
|
}
|
|
|
|
enum RecordingState { none, recording, finished }
|
|
|
|
class _MessageInputState extends State<MessageInput> {
|
|
late final TextEditingController _textFieldController;
|
|
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<void> _sendMessage() async {
|
|
if (_textFieldController.text == '') return;
|
|
|
|
await insertAndSendTextMessage(
|
|
widget.group.groupId,
|
|
_textFieldController.text,
|
|
widget.quotesMessage?.messageId,
|
|
);
|
|
|
|
_textFieldController.clear();
|
|
widget.onMessageSend();
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_textFieldController = TextEditingController();
|
|
if (widget.group.draftMessage != null) {
|
|
_textFieldController.text = widget.group.draftMessage!;
|
|
}
|
|
widget.textFieldFocus.addListener(_handleTextFocusChange);
|
|
_initializeControllers();
|
|
}
|
|
|
|
@override
|
|
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() {
|
|
if (widget.textFieldFocus.hasFocus) {
|
|
setState(() {
|
|
_emojiShowing = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _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;
|
|
_currentDuration = 0;
|
|
});
|
|
await HapticFeedback.heavyImpact();
|
|
final audioTmpPath =
|
|
'${(await getApplicationCacheDirectory()).path}/recording.m4a';
|
|
unawaited(
|
|
recorderController.record(
|
|
path: audioTmpPath,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _stopAudioRecording() async {
|
|
await HapticFeedback.heavyImpact();
|
|
setState(() {
|
|
_audioRecordingLock = false;
|
|
_cancelSlideOffset = 0;
|
|
_recordingState = RecordingState.none;
|
|
});
|
|
|
|
final audioTmpPath = await recorderController.stop();
|
|
|
|
if (audioTmpPath == null) return;
|
|
|
|
final mediaFileService = await initializeMediaUpload(
|
|
MediaType.audio,
|
|
null,
|
|
);
|
|
|
|
if (mediaFileService == null) return;
|
|
|
|
File(audioTmpPath)
|
|
..copySync(mediaFileService.originalPath.path)
|
|
..deleteSync();
|
|
|
|
await insertMediaFileInMessagesTable(
|
|
mediaFileService,
|
|
[widget.group.groupId],
|
|
);
|
|
}
|
|
|
|
Future<void> _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();
|
|
}
|
|
}
|
|
|
|
Future<void> _showAdditionalShareModal(BuildContext context) async {
|
|
// ignore: inference_failure_on_function_invocation
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.black,
|
|
builder: (context) {
|
|
return ShareAdditionalView(
|
|
group: widget.group,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
bottom: 10,
|
|
left: 10,
|
|
top: 10,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 3,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: context.color.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (_recordingState != RecordingState.recording)
|
|
GestureDetector(
|
|
onTap: () {
|
|
setState(() {
|
|
_emojiShowing = !_emojiShowing;
|
|
if (_emojiShowing) {
|
|
widget.textFieldFocus.unfocus();
|
|
} else {
|
|
widget.textFieldFocus.requestFocus();
|
|
}
|
|
});
|
|
},
|
|
child: ColoredBox(
|
|
color: Colors.transparent,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(
|
|
top: 8,
|
|
bottom: 8,
|
|
left: 12,
|
|
right: 8,
|
|
),
|
|
child: FaIcon(
|
|
size: 20,
|
|
_emojiShowing
|
|
? FontAwesomeIcons.keyboard
|
|
: FontAwesomeIcons.faceSmile,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
TextField(
|
|
controller: _textFieldController,
|
|
focusNode: widget.textFieldFocus,
|
|
keyboardType: TextInputType.multiline,
|
|
showCursor:
|
|
_recordingState != RecordingState.recording,
|
|
maxLines: 4,
|
|
minLines: 1,
|
|
onChanged: (value) async {
|
|
setState(() {});
|
|
await twonlyDB.groupsDao.updateGroup(
|
|
widget.group.groupId,
|
|
GroupsCompanion(
|
|
draftMessage:
|
|
Value(_textFieldController.text),
|
|
),
|
|
);
|
|
},
|
|
onSubmitted: (_) {
|
|
_sendMessage();
|
|
},
|
|
style: const TextStyle(fontSize: 17),
|
|
decoration: InputDecoration(
|
|
hintText: context.lang.chatListDetailInput,
|
|
contentPadding: EdgeInsets.zero,
|
|
border: InputBorder.none,
|
|
),
|
|
),
|
|
if (_recordingState == RecordingState.recording)
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: context.color.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: 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: TextStyle(
|
|
color: isDarkMode(context)
|
|
? Colors.white
|
|
: Colors.black,
|
|
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),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_textFieldController.text == '')
|
|
IconButton(
|
|
icon: const FaIcon(FontAwesomeIcons.camera),
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) {
|
|
return CameraSendToView(widget.group);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
if (_textFieldController.text == '')
|
|
GestureDetector(
|
|
onLongPressMoveUpdate: (details) {
|
|
if (_audioRecordingLock) return;
|
|
if (_recordingOffset.dy -
|
|
details.localPosition.dy >=
|
|
100) {
|
|
HapticFeedback.heavyImpact();
|
|
setState(() {
|
|
_audioRecordingLock = true;
|
|
});
|
|
}
|
|
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;
|
|
}
|
|
});
|
|
},
|
|
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 &&
|
|
!_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: isDarkMode(context)
|
|
? Colors.black
|
|
: Colors.white,
|
|
),
|
|
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,
|
|
bottom: -20,
|
|
right: -20,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.red,
|
|
borderRadius: BorderRadius.circular(90),
|
|
),
|
|
width: 60,
|
|
height: 60,
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (_textFieldController.text != '' || _audioRecordingLock)
|
|
IconButton(
|
|
padding: const EdgeInsets.all(15),
|
|
icon: FaIcon(
|
|
color: context.color.primary,
|
|
FontAwesomeIcons.solidPaperPlane,
|
|
),
|
|
onPressed:
|
|
_audioRecordingLock ? _stopAudioRecording : _sendMessage,
|
|
)
|
|
else
|
|
IconButton(
|
|
icon: const FaIcon(FontAwesomeIcons.plus),
|
|
padding: const EdgeInsets.all(15),
|
|
onPressed: () => _showAdditionalShareModal(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Offstage(
|
|
offstage: !_emojiShowing,
|
|
child: EmojiPicker(
|
|
textEditingController: _textFieldController,
|
|
onEmojiSelected: (category, emoji) {
|
|
setState(() {});
|
|
},
|
|
onBackspacePressed: () {
|
|
setState(() {});
|
|
},
|
|
config: Config(
|
|
height: 300,
|
|
locale: Localizations.localeOf(context),
|
|
viewOrderConfig: const ViewOrderConfig(
|
|
top: EmojiPickerItem.searchBar,
|
|
// middle: EmojiPickerItem.emojiView,
|
|
bottom: EmojiPickerItem.categoryBar,
|
|
),
|
|
emojiTextStyle:
|
|
TextStyle(fontSize: 24 * (Platform.isIOS ? 1.2 : 1)),
|
|
emojiViewConfig: EmojiViewConfig(
|
|
backgroundColor: context.color.surfaceContainer,
|
|
recentsLimit: 40,
|
|
),
|
|
searchViewConfig: SearchViewConfig(
|
|
backgroundColor: context.color.surfaceContainer,
|
|
buttonIconColor: Colors.white,
|
|
),
|
|
categoryViewConfig: CategoryViewConfig(
|
|
backgroundColor: context.color.surfaceContainer,
|
|
dividerColor: Colors.white,
|
|
indicatorColor: context.color.primary,
|
|
iconColorSelected: context.color.primary,
|
|
iconColor: context.color.secondary,
|
|
),
|
|
bottomActionBarConfig: BottomActionBarConfig(
|
|
backgroundColor: context.color.surfaceContainer,
|
|
buttonColor: context.color.surfaceContainer,
|
|
buttonIconColor: context.color.secondary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|