Surface/lib/widgets/chat/chat_message_input.dart

373 lines
13 KiB
Dart
Raw Normal View History

2024-12-08 03:37:03 +00:00
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
2024-12-08 03:37:03 +00:00
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
2024-11-17 16:55:39 +00:00
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
2024-11-21 16:28:29 +00:00
import 'package:pasteboard/pasteboard.dart';
2024-11-17 16:55:39 +00:00
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart';
2024-11-17 16:55:39 +00:00
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/user_directory.dart';
2024-11-18 14:33:03 +00:00
import 'package:surface/types/chat.dart';
2024-11-17 16:55:39 +00:00
import 'package:surface/widgets/dialog.dart';
2024-11-18 14:33:03 +00:00
import 'package:surface/widgets/markdown_content.dart';
2024-11-17 16:55:39 +00:00
import 'package:surface/widgets/post/post_media_pending_list.dart';
class ChatMessageInput extends StatefulWidget {
final ChatMessageController controller;
2024-12-08 05:45:51 +00:00
final SnChannelMember? otherMember;
2024-12-07 15:40:26 +00:00
2024-12-08 05:45:51 +00:00
const ChatMessageInput({super.key, required this.controller, this.otherMember});
@override
2024-11-18 14:33:03 +00:00
State<ChatMessageInput> createState() => ChatMessageInputState();
}
2024-11-18 14:33:03 +00:00
class ChatMessageInputState extends State<ChatMessageInput> {
2024-11-17 16:55:39 +00:00
bool _isBusy = false;
double? _progress;
SnChatMessage? _replyingMessage, _editingMessage;
2024-11-18 14:33:03 +00:00
final TextEditingController _contentController = TextEditingController();
final FocusNode _focusNode = FocusNode();
2024-11-18 14:33:03 +00:00
void setReply(SnChatMessage? value) {
setState(() => _replyingMessage = value);
}
void setEdit(SnChatMessage? value) {
_contentController.text = value?.body['text'] ?? '';
setState(() => _editingMessage = value);
}
Future<void> deleteMessage(SnChatMessage message) async {
final confirm = await context.showConfirmDialog(
'messageDelete'.tr(args: ['#${message.id}']),
'messageDeleteDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
setState(() => _isBusy = true);
await widget.controller.deleteMessage(message);
if (!mounted) return;
setState(() => _isBusy = false);
}
2024-11-17 16:55:39 +00:00
Future<void> _sendMessage() async {
if (_isBusy) return;
final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
try {
for (int i = 0; i < _attachments.length; i++) {
final media = _attachments[i];
if (media.attachment != null) continue; // Already uploaded, skip
if (media.isEmpty) continue; // Nothing to do, skip
final place = await attach.chunkedUploadInitialize(
(await media.length())!,
media.name,
'messaging',
2024-11-17 16:55:39 +00:00
null,
2024-12-07 15:40:26 +00:00
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
2024-11-17 16:55:39 +00:00
);
final item = await attach.chunkedUploadParts(
media.toFile()!,
place.$1,
place.$2,
onProgress: (progress) {
// Calculate overall progress for attachments
setState(() {
progress = (i + progress) / _attachments.length;
});
},
);
_attachments[i] = PostWriteMedia(item);
}
} catch (err) {
if (!mounted) return;
setState(() => _isBusy = false);
context.showErrorDialog(err);
return;
}
// Send the message
// NOTICE This future should not be awaited, so that the message can be sent in the background and the user can continue to type
widget.controller.sendMessage(
'messages.new',
_contentController.text,
2024-12-07 15:40:26 +00:00
attachments: _attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
relatedId: _editingMessage?.id,
2024-11-18 14:33:03 +00:00
quoteId: _replyingMessage?.id,
editingMessage: _editingMessage,
);
_contentController.clear();
2024-11-17 16:55:39 +00:00
_attachments.clear();
_editingMessage = null;
2024-11-18 14:33:03 +00:00
_replyingMessage = null;
2024-11-17 16:55:39 +00:00
setState(() => _isBusy = false);
}
final List<PostWriteMedia> _attachments = List.empty(growable: true);
final _imagePicker = ImagePicker();
2024-12-08 03:37:03 +00:00
void _takeMedia(bool isVideo) async {
final result = isVideo
? await _imagePicker.pickVideo(source: ImageSource.camera)
: await _imagePicker.pickImage(source: ImageSource.camera);
if (result == null) return;
_attachments.add(
PostWriteMedia.fromFile(result),
);
setState(() {});
}
2024-11-17 16:55:39 +00:00
void _selectMedia() async {
final result = await _imagePicker.pickMultipleMedia();
if (result.isEmpty) return;
_attachments.addAll(
result.map((e) => PostWriteMedia.fromFile(e)),
);
setState(() {});
}
2024-11-21 16:28:29 +00:00
void _pasteMedia() async {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) return;
_attachments.add(
PostWriteMedia.fromBytes(
imageBytes,
'attachmentPastedImage'.tr(),
PostWriteMediaType.image,
),
);
setState(() {});
}
@override
void dispose() {
_contentController.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
2024-12-08 05:45:51 +00:00
final ud = context.read<UserDirectoryProvider>();
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
2024-11-17 16:55:39 +00:00
if (_isBusy && _progress != null)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _progress),
duration: Duration(milliseconds: 300),
2024-12-07 15:40:26 +00:00
builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
2024-11-17 16:55:39 +00:00
)
else if (_isBusy)
const LinearProgressIndicator(value: null, minHeight: 2),
Padding(
2024-12-07 15:40:26 +00:00
padding: _attachments.isNotEmpty ? const EdgeInsets.only(top: 8) : EdgeInsets.zero,
2024-11-23 09:32:48 +00:00
child: PostMediaPendingList(
2024-11-17 16:55:39 +00:00
attachments: _attachments,
isBusy: _isBusy,
onUpdate: (idx, updatedMedia) async {
setState(() => _attachments[idx] = updatedMedia);
},
onRemove: (idx) async {
setState(() => _attachments.removeAt(idx));
},
onUpdateBusy: (state) => setState(() => _isBusy = state),
2024-11-18 14:33:03 +00:00
),
2024-12-07 15:40:26 +00:00
)
.height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
2024-11-18 14:33:03 +00:00
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Padding(
2024-12-07 15:40:26 +00:00
padding: _replyingMessage != null ? const EdgeInsets.only(top: 8) : EdgeInsets.zero,
2024-11-18 14:33:03 +00:00
child: _replyingMessage != null
? MaterialBanner(
padding: const EdgeInsets.only(left: 16.0),
leading: const Icon(Symbols.reply),
2024-12-07 15:40:26 +00:00
backgroundColor: Colors.transparent,
2024-11-18 14:33:03 +00:00
content: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_replyingMessage?.body['text'] != null)
MarkdownTextContent(
content: _replyingMessage?.body['text'],
),
],
),
),
actions: [
TextButton(
child: Text('cancel'.tr()),
onPressed: () {
setState(() => _replyingMessage = null);
},
),
],
)
: const SizedBox.shrink(),
),
2024-12-07 15:40:26 +00:00
)
.height(_replyingMessage != null ? 54 + 8 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Padding(
2024-12-07 15:40:26 +00:00
padding: _editingMessage != null ? const EdgeInsets.only(top: 8) : EdgeInsets.zero,
child: _editingMessage != null
? MaterialBanner(
padding: const EdgeInsets.only(left: 16.0),
leading: const Icon(Symbols.edit),
2024-12-07 15:40:26 +00:00
backgroundColor: Colors.transparent,
content: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_editingMessage?.body['text'] != null)
MarkdownTextContent(
content: _editingMessage?.body['text'],
),
],
),
),
actions: [
TextButton(
child: Text('cancel'.tr()),
onPressed: () {
setState(() => _editingMessage = null);
},
),
],
)
: const SizedBox.shrink(),
),
2024-12-07 15:40:26 +00:00
)
.height(_editingMessage != null ? 54 + 8 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SizedBox(
height: 56,
child: Row(
children: [
Expanded(
child: TextField(
focusNode: _focusNode,
controller: _contentController,
decoration: InputDecoration(
isCollapsed: true,
2024-12-08 05:45:51 +00:00
hintText: widget.otherMember != null
? 'fieldChatMessageDirect'.tr(args: [
'@${ud.getAccountFromCache(widget.otherMember?.accountId)?.name}',
])
: 'fieldChatMessage'.tr(args: [widget.controller.channel?.name ?? 'loading'.tr()]),
border: InputBorder.none,
),
2024-12-07 15:40:26 +00:00
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) {
2024-11-17 16:55:39 +00:00
if (_isBusy) return;
_sendMessage();
_focusNode.requestFocus();
},
),
),
const Gap(8),
2024-11-21 16:28:29 +00:00
PopupMenuButton(
2024-11-17 16:55:39 +00:00
icon: Icon(
Symbols.add_photo_alternate,
color: Theme.of(context).colorScheme.primary,
),
2024-11-21 16:28:29 +00:00
itemBuilder: (context) => [
2024-12-08 03:37:03 +00:00
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_camera),
const Gap(16),
Text('addAttachmentFromCameraPhoto').tr(),
],
),
onTap: () {
_takeMedia(false);
},
),
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.videocam),
const Gap(16),
Text('addAttachmentFromCameraVideo').tr(),
],
),
onTap: () {
_takeMedia(true);
},
),
2024-11-21 16:28:29 +00:00
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.photo_library),
const Gap(16),
Text('addAttachmentFromAlbum').tr(),
],
),
onTap: () {
_selectMedia();
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.content_paste),
const Gap(16),
Text('addAttachmentFromClipboard').tr(),
],
),
onTap: () {
_pasteMedia();
},
),
],
2024-11-17 16:55:39 +00:00
),
IconButton(
onPressed: _isBusy ? null : _sendMessage,
icon: Icon(
Symbols.send,
color: Theme.of(context).colorScheme.primary,
),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
],
),
).padding(horizontal: 16),
],
);
}
}