Move messaging input bar to separate file

This commit is contained in:
Nutcake 2023-05-21 17:27:29 +02:00
parent 1b7af5f4a7
commit 2987603b7a
3 changed files with 548 additions and 420 deletions

View file

@ -0,0 +1,539 @@
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:contacts_plus_plus/apis/record_api.dart';
import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/models/friend.dart';
import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/widgets/messages/message_attachment_list.dart';
import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:record/record.dart';
import 'package:uuid/uuid.dart';
class MessageInputBar extends StatefulWidget {
const MessageInputBar({this.showShadow=true, this.disabled=false, required this.recipient, this.onMessageSent, super.key});
final bool showShadow;
final bool disabled;
final Friend recipient;
final Function()? onMessageSent;
@override
State<StatefulWidget> createState() => _MessageInputBarState();
}
class _MessageInputBarState extends State<MessageInputBar> {
final TextEditingController _messageTextController = TextEditingController();
final List<(FileType, File)> _loadedFiles = [];
final Record _recorder = Record();
DateTime? _recordingStartTime;
bool _isSending = false;
bool _attachmentPickerOpen = false;
String _currentText = "";
double? _sendProgress;
bool get _isRecording => _recordingStartTime != null;
set _isRecording(value) => _recordingStartTime = value ? DateTime.now() : null;
bool _recordingCancelled = false;
Future<void> sendTextMessage(ApiClient client, MessagingClient mClient, String content) async {
if (content.isEmpty) return;
final message = Message(
id: Message.generateId(),
recipientId: widget.recipient.id,
senderId: client.userId,
type: MessageType.text,
content: content,
sendTime: DateTime.now().toUtc(),
state: MessageState.local,
);
mClient.sendMessage(message);
}
Future<void> sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId,
void Function(double progress) progressCallback) async {
final record = await RecordApi.uploadImage(
client,
image: file,
machineId: machineId,
progressCallback: progressCallback,
);
final message = Message(
id: record.extractMessageId() ?? Message.generateId(),
recipientId: widget.recipient.id,
senderId: client.userId,
type: MessageType.object,
content: jsonEncode(record.toMap()),
sendTime: DateTime.now().toUtc(),
state: MessageState.local
);
mClient.sendMessage(message);
}
Future<void> sendVoiceMessage(ApiClient client, MessagingClient mClient, File file, String machineId,
void Function(double progress) progressCallback) async {
final record = await RecordApi.uploadVoiceClip(
client,
voiceClip: file,
machineId: machineId,
progressCallback: progressCallback,
);
final message = Message(
id: record.extractMessageId() ?? Message.generateId(),
recipientId: widget.recipient.id,
senderId: client.userId,
type: MessageType.sound,
content: jsonEncode(record.toMap()),
sendTime: DateTime.now().toUtc(),
state: MessageState.local,
);
mClient.sendMessage(message);
}
Future<void> sendRawFileMessage(ApiClient client, MessagingClient mClient, File file, String machineId,
void Function(double progress) progressCallback) async {
final record = await RecordApi.uploadRawFile(
client,
file: file,
machineId: machineId,
progressCallback: progressCallback,
);
final message = Message(
id: record.extractMessageId() ?? Message.generateId(),
recipientId: widget.recipient.id,
senderId: client.userId,
type: MessageType.object,
content: jsonEncode(record.toMap()),
sendTime: DateTime.now().toUtc(),
state: MessageState.local,
);
mClient.sendMessage(message);
}
void _pointerMoveEventHandler(PointerMoveEvent event) {
if (!_isRecording) return;
final width = MediaQuery.of(context).size.width;
if (event.localPosition.dx < width - width/4) {
if (!_recordingCancelled) {
HapticFeedback.vibrate();
setState(() {
_recordingCancelled = true;
});
}
} else {
if (_recordingCancelled) {
HapticFeedback.vibrate();
setState(() {
_recordingCancelled = false;
});
}
}
}
Stream<Duration> _recordingDurationStream() async* {
while (_isRecording) {
yield DateTime.now().difference(_recordingStartTime!);
await Future.delayed(const Duration(milliseconds: 100));
}
}
@override
Widget build(BuildContext context) {
final mClient = Provider.of<MessagingClient>(context, listen: false);
return Listener(
onPointerMove: _pointerMoveEventHandler,
onPointerUp: (_) async {
// Do this here as the pointerUp event of the gesture detector on the mic button can be unreliable
final cHolder = ClientHolder.of(context);
if (_isRecording) {
if (_recordingCancelled) {
setState(() {
_recordingCancelled = false;
_isRecording = false;
});
final recording = await _recorder.stop();
if (recording == null) return;
final file = File(recording);
if (await file.exists()) {
await file.delete();
}
}
setState(() {
_recordingCancelled = false;
_isRecording = false;
});
if (await _recorder.isRecording()) {
final recording = await _recorder.stop();
if (recording == null) return;
final file = File(recording);
setState(() {
_isSending = true;
_sendProgress = 0;
});
final apiClient = cHolder.apiClient;
await sendVoiceMessage(
apiClient,
mClient,
file,
cHolder.settingsClient.currentSettings.machineId.valueOrDefault,
(progress) {
setState(() {
_sendProgress = progress;
});
}
);
setState(() {
_isSending = false;
_sendProgress = null;
});
}
}
},
child: AnimatedContainer(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
blurRadius: widget.showShadow ? 8 : 0,
color: Theme
.of(context)
.shadowColor,
offset: const Offset(0, 4),
),
],
color: Theme
.of(context)
.colorScheme
.background,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
duration: const Duration(milliseconds: 250),
child: Column(
children: [
if (_isSending && _sendProgress != null)
LinearProgressIndicator(value: _sendProgress),
Container(
decoration: BoxDecoration(
color: Theme
.of(context)
.colorScheme
.background,
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
transitionBuilder: (Widget child, animation) =>
SizeTransition(sizeFactor: animation, child: child,),
child: switch ((_attachmentPickerOpen, _loadedFiles)) {
(true, []) =>
Row(
key: const ValueKey("attachment-picker"),
children: [
TextButton.icon(
onPressed: _isSending ? null : () async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image, allowMultiple: true);
if (result != null) {
setState(() {
_loadedFiles.addAll(
result.files.map((e) =>
e.path != null ? (FileType.image, File(e.path!)) : null)
.whereNotNull());
});
}
},
icon: const Icon(Icons.image),
label: const Text("Gallery"),
),
TextButton.icon(
onPressed: _isSending ? null : () async {
final picture = await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?;
if (picture != null) {
setState(() {
_loadedFiles.add((FileType.image, picture));
});
}
},
icon: const Icon(Icons.camera_alt),
label: const Text("Camera"),
),
TextButton.icon(
onPressed: _isSending ? null : () async {
final result = await FilePicker.platform.pickFiles(
type: FileType.any, allowMultiple: true);
if (result != null) {
setState(() {
_loadedFiles.addAll(
result.files.map((e) =>
e.path != null ? (FileType.any, File(e.path!)) : null)
.whereNotNull());
});
}
},
icon: const Icon(Icons.file_present_rounded),
label: const Text("Document"),
),
],
),
(false, []) => null,
(_, _) =>
MessageAttachmentList(
disabled: _isSending,
initialFiles: _loadedFiles,
onChange: (List<(FileType, File)> loadedFiles) => setState(() {
_loadedFiles.clear();
_loadedFiles.addAll(loadedFiles);
}),
),
},
),
),
Row(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) =>
FadeTransition(
opacity: animation,
child: RotationTransition(
turns: Tween<double>(begin: 0.6, end: 1).animate(animation),
child: child,
),
),
child: switch((_attachmentPickerOpen, _isRecording)) {
(_, true) => IconButton(
onPressed: () {},
icon: Icon(Icons.delete, color: _recordingCancelled ? Theme.of(context).colorScheme.error : null,),
),
(false, _) => IconButton(
key: const ValueKey("add-attachment-icon"),
onPressed: _isSending ? null : () {
setState(() {
_attachmentPickerOpen = true;
});
},
icon: const Icon(Icons.attach_file,),
),
(true, _) => IconButton(
key: const ValueKey("remove-attachment-icon"),
onPressed: _isSending ? null : () async {
if (_loadedFiles.isNotEmpty) {
await showDialog(context: context, builder: (context) =>
AlertDialog(
title: const Text("Remove all attachments"),
content: const Text("This will remove all attachments, are you sure?"),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text("No"),
),
TextButton(
onPressed: () {
setState(() {
_loadedFiles.clear();
_attachmentPickerOpen = false;
});
Navigator.of(context).pop();
},
child: const Text("Yes"),
)
],
));
} else {
setState(() {
_attachmentPickerOpen = false;
});
}
},
icon: const Icon(Icons.close,),
),
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Stack(
children: [
TextField(
enabled: (!widget.disabled) && !_isSending,
autocorrect: true,
controller: _messageTextController,
showCursor: !_isRecording,
maxLines: 4,
minLines: 1,
onChanged: (text) {
if (text.isEmpty != _currentText.isEmpty) {
setState(() {
_currentText = text;
});
return;
}
_currentText = text;
},
decoration: InputDecoration(
isDense: true,
hintText: _isRecording ? "" : "Message ${widget.recipient
.username}...",
hintMaxLines: 1,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24)
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) =>
FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, .2),
end: const Offset(0, 0),
).animate(animation),
child: child,
),
),
child: _isRecording ? Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: _recordingCancelled ? Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(width: 8,),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Icon(Icons.cancel, color: Colors.red, size: 16,),
),
Text("Cancel Recording", style: Theme.of(context).textTheme.titleMedium),
],
) : Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(width: 8,),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Icon(Icons.circle, color: Colors.red, size: 16,),
),
StreamBuilder<Duration>(
stream: _recordingDurationStream(),
builder: (context, snapshot) {
return Text("Recording: ${snapshot.data?.format()}", style: Theme.of(context).textTheme.titleMedium);
}
),
],
),
) : const SizedBox.shrink(),
),
],
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) =>
FadeTransition(opacity: animation, child: RotationTransition(
turns: Tween<double>(begin: 0.5, end: 1).animate(animation), child: child,),),
child: _currentText.isNotEmpty || _loadedFiles.isNotEmpty ? IconButton(
key: const ValueKey("send-button"),
splashRadius: 24,
padding: EdgeInsets.zero,
onPressed: _isSending ? null : () async {
final cHolder = ClientHolder.of(context);
final sMsgnr = ScaffoldMessenger.of(context);
final settings = cHolder.settingsClient.currentSettings;
final toSend = List<(FileType, File)>.from(_loadedFiles);
setState(() {
_isSending = true;
_sendProgress = 0;
_attachmentPickerOpen = false;
_loadedFiles.clear();
});
try {
for (int i = 0; i < toSend.length; i++) {
final totalProgress = i / toSend.length;
final file = toSend[i];
if (file.$1 == FileType.image) {
await sendImageMessage(
cHolder.apiClient, mClient, file.$2, settings.machineId.valueOrDefault,
(progress) =>
setState(() {
_sendProgress = totalProgress + progress * 1 / toSend.length;
}),
);
} else {
await sendRawFileMessage(
cHolder.apiClient, mClient, file.$2, settings.machineId.valueOrDefault, (progress) =>
setState(() =>
_sendProgress = totalProgress + progress * 1 / toSend.length));
}
}
setState(() {
_sendProgress = null;
});
if (_currentText.isNotEmpty) {
await sendTextMessage(cHolder.apiClient, mClient, _messageTextController.text);
}
_messageTextController.clear();
_currentText = "";
_loadedFiles.clear();
_attachmentPickerOpen = false;
} catch (e, s) {
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
sMsgnr.showSnackBar(SnackBar(content: Text("Failed to send a message: $e")));
}
setState(() {
_isSending = false;
_sendProgress = null;
});
widget.onMessageSent?.call();
},
icon: const Icon(Icons.send),
) : GestureDetector(
onTapDown: widget.disabled ? null : (_) async {
HapticFeedback.vibrate();
final dir = await getTemporaryDirectory();
await _recorder.start(
path: "${dir.path}/A-${const Uuid().v4()}.ogg",
encoder: AudioEncoder.opus,
samplingRate: 44100,
);
setState(() {
_isRecording = true;
});
},
child: IconButton(
icon: const Icon(Icons.mic_outlined),
onPressed: () {
// Empty onPressed for that sweet sweet ripple effect
},
),
),
),
],
),
],
),
),
);
}
}

View file

@ -2,9 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart'; import 'package:record/record.dart';
import 'package:uuid/uuid.dart';
class MessageRecordButton extends StatefulWidget { class MessageRecordButton extends StatefulWidget {
const MessageRecordButton({required this.disabled, this.onRecordStart, this.onRecordEnd, super.key}); const MessageRecordButton({required this.disabled, this.onRecordStart, this.onRecordEnd, super.key});

View file

@ -1,21 +1,10 @@
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:contacts_plus_plus/apis/record_api.dart';
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/clients/audio_cache_client.dart'; import 'package:contacts_plus_plus/clients/audio_cache_client.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/friend.dart';
import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; import 'package:contacts_plus_plus/widgets/default_error_widget.dart';
import 'package:contacts_plus_plus/widgets/friends/friend_online_status_indicator.dart'; import 'package:contacts_plus_plus/widgets/friends/friend_online_status_indicator.dart';
import 'package:contacts_plus_plus/widgets/messages/message_attachment_list.dart'; import 'package:contacts_plus_plus/widgets/messages/message_input_bar.dart';
import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart';
import 'package:contacts_plus_plus/widgets/messages/message_record_button.dart';
import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -31,15 +20,8 @@ class MessagesList extends StatefulWidget {
} }
class _MessagesListState extends State<MessagesList> with SingleTickerProviderStateMixin { class _MessagesListState extends State<MessagesList> with SingleTickerProviderStateMixin {
final TextEditingController _messageTextController = TextEditingController();
final ScrollController _sessionListScrollController = ScrollController(); final ScrollController _sessionListScrollController = ScrollController();
final ScrollController _messageScrollController = ScrollController(); final ScrollController _messageScrollController = ScrollController();
final List<(FileType, File)> _loadedFiles = [];
bool _hasText = false;
bool _isSending = false;
bool _attachmentPickerOpen = false;
double? _sendProgress;
bool _showBottomBarShadow = false; bool _showBottomBarShadow = false;
bool _showSessionListScrollChevron = false; bool _showSessionListScrollChevron = false;
@ -48,7 +30,6 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
@override @override
void dispose() { void dispose() {
_messageTextController.dispose();
_sessionListScrollController.dispose(); _sessionListScrollController.dispose();
super.dispose(); super.dispose();
} }
@ -71,11 +52,6 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
}); });
_messageScrollController.addListener(() { _messageScrollController.addListener(() {
if (!_messageScrollController.hasClients) return; if (!_messageScrollController.hasClients) return;
if (_attachmentPickerOpen && _loadedFiles.isEmpty) {
setState(() {
_attachmentPickerOpen = false;
});
}
if (_messageScrollController.position.atEdge && _messageScrollController.position.pixels == 0 && if (_messageScrollController.position.atEdge && _messageScrollController.position.pixels == 0 &&
_showBottomBarShadow) { _showBottomBarShadow) {
setState(() { setState(() {
@ -89,95 +65,10 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
}); });
} }
Future<void> sendTextMessage(ApiClient client, MessagingClient mClient, String content) async {
if (content.isEmpty) return;
final message = Message(
id: Message.generateId(),
recipientId: widget.friend.id,
senderId: client.userId,
type: MessageType.text,
content: content,
sendTime: DateTime.now().toUtc(),
state: MessageState.local,
);
mClient.sendMessage(message);
_messageTextController.clear();
_hasText = false;
}
Future<void> sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId,
void Function(double progress) progressCallback) async {
final record = await RecordApi.uploadImage(
client,
image: file,
machineId: machineId,
progressCallback: progressCallback,
);
final message = Message(
id: record.extractMessageId() ?? Message.generateId(),
recipientId: widget.friend.id,
senderId: client.userId,
type: MessageType.object,
content: jsonEncode(record.toMap()),
sendTime: DateTime.now().toUtc(),
state: MessageState.local
);
mClient.sendMessage(message);
_messageTextController.clear();
_hasText = false;
}
Future<void> sendVoiceMessage(ApiClient client, MessagingClient mClient, File file, String machineId,
void Function(double progress) progressCallback) async {
final record = await RecordApi.uploadVoiceClip(
client,
voiceClip: file,
machineId: machineId,
progressCallback: progressCallback,
);
final message = Message(
id: record.extractMessageId() ?? Message.generateId(),
recipientId: widget.friend.id,
senderId: client.userId,
type: MessageType.sound,
content: jsonEncode(record.toMap()),
sendTime: DateTime.now().toUtc(),
state: MessageState.local,
);
mClient.sendMessage(message);
_messageTextController.clear();
_hasText = false;
}
Future<void> sendRawFileMessage(ApiClient client, MessagingClient mClient, File file, String machineId,
void Function(double progress) progressCallback) async {
final record = await RecordApi.uploadRawFile(
client,
file: file,
machineId: machineId,
progressCallback: progressCallback,
);
final message = Message(
id: record.extractMessageId() ?? Message.generateId(),
recipientId: widget.friend.id,
senderId: client.userId,
type: MessageType.object,
content: jsonEncode(record.toMap()),
sendTime: DateTime.now().toUtc(),
state: MessageState.local,
);
mClient.sendMessage(message);
_messageTextController.clear();
_hasText = false;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final apiClient = ClientHolder final sessions = widget.friend.userStatus.activeSessions;
.of(context)
.apiClient;
var sessions = widget.friend.userStatus.activeSessions;
final appBarColor = Theme final appBarColor = Theme
.of(context) .of(context)
.colorScheme .colorScheme
@ -315,316 +206,16 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
); );
}, },
), ),
Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
blurRadius: 8,
color: Theme
.of(context)
.shadowColor,
offset: const Offset(0, 4),
),
],
color: Theme
.of(context)
.colorScheme
.background,
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
transitionBuilder: (Widget child, animation) =>
SizeTransition(sizeFactor: animation, child: child,),
child: switch ((_attachmentPickerOpen, _loadedFiles)) {
(true, []) =>
Row(
key: const ValueKey("attachment-picker"),
children: [
TextButton.icon(
onPressed: _isSending ? null : () async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image, allowMultiple: true);
if (result != null) {
setState(() {
_loadedFiles.addAll(
result.files.map((e) =>
e.path != null ? (FileType.image, File(e.path!)) : null)
.whereNotNull());
});
}
},
icon: const Icon(Icons.image),
label: const Text("Gallery"),
),
TextButton.icon(
onPressed: _isSending ? null : () async {
final picture = await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?;
if (picture != null) {
setState(() {
_loadedFiles.add((FileType.image, picture));
});
}
},
icon: const Icon(Icons.camera_alt),
label: const Text("Camera"),
),
TextButton.icon(
onPressed: _isSending ? null : () async {
final result = await FilePicker.platform.pickFiles(
type: FileType.any, allowMultiple: true);
if (result != null) {
setState(() {
_loadedFiles.addAll(
result.files.map((e) =>
e.path != null ? (FileType.any, File(e.path!)) : null)
.whereNotNull());
});
}
},
icon: const Icon(Icons.file_present_rounded),
label: const Text("Document"),
),
],
),
(false, []) => null,
(_, _) =>
MessageAttachmentList(
disabled: _isSending,
initialFiles: _loadedFiles,
onChange: (List<(FileType, File)> loadedFiles) =>
setState(() {
_loadedFiles.clear();
_loadedFiles.addAll(loadedFiles);
}),
)
},
),
),
],
),
),
if (_isSending && _sendProgress != null)
Align(
alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(value: _sendProgress),
),
], ],
), ),
), ),
AnimatedContainer( MessageInputBar(
decoration: BoxDecoration( recipient: widget.friend,
boxShadow: [ disabled: cache == null || cache.error != null,
BoxShadow( showShadow: _showBottomBarShadow,
blurRadius: _showBottomBarShadow && !_attachmentPickerOpen ? 8 : 0, onMessageSent: () {
color: Theme setState(() {});
.of(context) },
.shadowColor,
offset: const Offset(0, 4),
),
],
color: Theme
.of(context)
.colorScheme
.background,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
duration: const Duration(milliseconds: 250),
child: Row(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child, Animation<double> animation) =>
FadeTransition(
opacity: animation,
child: RotationTransition(
turns: Tween<double>(begin: 0.6, end: 1).animate(animation),
child: child,
),
),
child: !_attachmentPickerOpen ?
IconButton(
key: const ValueKey("add-attachment-icon"),
onPressed: _isSending ? null : () {
setState(() {
_attachmentPickerOpen = true;
});
},
icon: const Icon(Icons.attach_file, size: 28,),
) :
IconButton(
key: const ValueKey("remove-attachment-icon"),
onPressed: _isSending ? null : () async {
if (_loadedFiles.isNotEmpty) {
await showDialog(context: context, builder: (context) =>
AlertDialog(
title: const Text("Remove all attachments"),
content: const Text("This will remove all attachments, are you sure?"),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text("No"),
),
TextButton(
onPressed: () {
setState(() {
_loadedFiles.clear();
_attachmentPickerOpen = false;
});
Navigator.of(context).pop();
},
child: const Text("Yes"),
)
],
));
} else {
setState(() {
_attachmentPickerOpen = false;
});
}
},
icon: const Icon(Icons.close, size: 28,),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: TextField(
enabled: cache != null && cache.error == null && !_isSending,
autocorrect: true,
controller: _messageTextController,
maxLines: 4,
minLines: 1,
onChanged: (text) {
if (text.isNotEmpty && !_hasText) {
setState(() {
_hasText = true;
});
} else if (text.isEmpty && _hasText) {
setState(() {
_hasText = false;
});
}
},
decoration: InputDecoration(
isDense: true,
hintText: "Message ${widget.friend
.username}...",
hintMaxLines: 1,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24)
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(4),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) =>
FadeTransition(opacity: animation, child: RotationTransition(
turns: Tween<double>(begin: 0.5, end: 1).animate(animation), child: child,),),
child: _hasText || _loadedFiles.isNotEmpty ? IconButton(
key: const ValueKey("send-button"),
splashRadius: 24,
padding: EdgeInsets.zero,
onPressed: _isSending ? null : () async {
final sMsgnr = ScaffoldMessenger.of(context);
final settings = ClientHolder
.of(context)
.settingsClient
.currentSettings;
final toSend = List<(FileType, File)>.from(_loadedFiles);
setState(() {
_isSending = true;
_sendProgress = 0;
_attachmentPickerOpen = false;
_loadedFiles.clear();
});
try {
for (int i = 0; i < toSend.length; i++) {
final totalProgress = i / toSend.length;
final file = toSend[i];
if (file.$1 == FileType.image) {
await sendImageMessage(
apiClient, mClient, file.$2, settings.machineId.valueOrDefault,
(progress) =>
setState(() {
_sendProgress = totalProgress + progress * 1 / toSend.length;
}),
);
} else {
await sendRawFileMessage(
apiClient, mClient, file.$2, settings.machineId.valueOrDefault, (progress) =>
setState(() =>
_sendProgress = totalProgress + progress * 1 / toSend.length));
}
}
setState(() {
_sendProgress = null;
});
if (_hasText) {
await sendTextMessage(apiClient, mClient, _messageTextController.text);
}
_messageTextController.clear();
_loadedFiles.clear();
_attachmentPickerOpen = false;
} catch (e, s) {
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
sMsgnr.showSnackBar(SnackBar(content: Text("Failed to send a message: $e")));
}
setState(() {
_isSending = false;
_sendProgress = null;
});
},
icon: const Icon(Icons.send),
) : MessageRecordButton(
key: const ValueKey("mic-button"),
disabled: _isSending,
onRecordEnd: (File? file) async {
if (file == null) return;
setState(() {
_isSending = true;
_sendProgress = 0;
});
await sendVoiceMessage(
apiClient,
mClient,
file,
ClientHolder
.of(context)
.settingsClient
.currentSettings
.machineId
.valueOrDefault, (progress) {
setState(() {
_sendProgress = progress;
});
}
);
setState(() {
_isSending = false;
_sendProgress = null;
});
},
),
),
),
],
),
), ),
], ],
), ),