From 8b3c45ab294d091116346c44d5328bb2e3b1ce14 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 1 Aug 2024 22:13:08 +0800 Subject: [PATCH] :sparkles: Queued upload --- lib/controllers/post_editor_controller.dart | 12 +- lib/main.dart | 2 + lib/providers/attachment_uploader.dart | 165 +++++ lib/providers/content/attachment.dart | 15 +- lib/translations/en_us.dart | 5 + lib/translations/zh_cn.dart | 5 + .../attachments/attachment_attr_editor.dart | 132 ++++ .../attachments/attachment_editor.dart | 659 ++++++++++-------- lib/widgets/chat/chat_event.dart | 8 +- lib/widgets/chat/chat_message_input.dart | 15 +- 10 files changed, 706 insertions(+), 312 deletions(-) create mode 100644 lib/providers/attachment_uploader.dart create mode 100644 lib/widgets/attachments/attachment_attr_editor.dart diff --git a/lib/controllers/post_editor_controller.dart b/lib/controllers/post_editor_controller.dart index 1994336..1c5c346 100644 --- a/lib/controllers/post_editor_controller.dart +++ b/lib/controllers/post_editor_controller.dart @@ -114,10 +114,12 @@ class PostEditorController extends GetxController { context: context, builder: (context) => AttachmentEditorPopup( usage: 'i.attachment', - current: attachments, - onUpdate: (value) { - attachments.value = value; - attachments.refresh(); + initialAttachments: attachments, + onAdd: (value) { + attachments.add(value); + }, + onRemove: (value) { + attachments.remove(value); }, ), ); @@ -163,6 +165,8 @@ class PostEditorController extends GetxController { visibleUsers.clear(); invisibleUsers.clear(); visibility.value = 0; + publishedAt.value = null; + publishedUntil.value = null; isDraft.value = false; isRestoreFromLocal.value = false; lastSaveTime.value = null; diff --git a/lib/main.dart b/lib/main.dart index 749f018..9b6f0f5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:solian/bootstrapper.dart'; import 'package:solian/firebase_options.dart'; import 'package:solian/platform.dart'; +import 'package:solian/providers/attachment_uploader.dart'; import 'package:solian/providers/theme_switcher.dart'; import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/auth.dart'; @@ -125,5 +126,6 @@ class SolianApp extends StatelessWidget { Get.lazyPut(() => ChannelProvider()); Get.lazyPut(() => RealmProvider()); Get.lazyPut(() => ChatCallProvider()); + Get.lazyPut(() => AttachmentUploaderController()); } } diff --git a/lib/providers/attachment_uploader.dart b/lib/providers/attachment_uploader.dart new file mode 100644 index 0000000..0747463 --- /dev/null +++ b/lib/providers/attachment_uploader.dart @@ -0,0 +1,165 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:get/get.dart'; +import 'package:solian/models/attachment.dart'; +import 'package:solian/providers/content/attachment.dart'; + +class AttachmentUploadTask { + File file; + String usage; + Map? metadata; + + double progress = 0; + bool isUploading = false; + bool isCompleted = false; + + AttachmentUploadTask({ + required this.file, + required this.usage, + this.metadata, + }); +} + +class AttachmentUploaderController extends GetxController { + RxBool isUploading = false.obs; + RxDouble progressOfUpload = 0.0.obs; + RxList queueOfUpload = RxList.empty(growable: true); + + void enqueueTask(AttachmentUploadTask task) { + if (isUploading.value) throw Exception('uploading blocked'); + queueOfUpload.add(task); + } + + void dequeueTask(AttachmentUploadTask task) { + if (isUploading.value) throw Exception('uploading blocked'); + queueOfUpload.remove(task); + } + + Future performSingleTask(int queueIndex) async { + isUploading.value = true; + progressOfUpload.value = 0; + + queueOfUpload[queueIndex].isUploading = true; + queueOfUpload.refresh(); + + final task = queueOfUpload[queueIndex]; + final result = await _rawUploadAttachment( + await task.file.readAsBytes(), + task.file.path, + task.usage, + null, + onProgress: (value) { + queueOfUpload[queueIndex].progress = value; + queueOfUpload.refresh(); + progressOfUpload.value = value; + }, + ); + + queueOfUpload.removeAt(queueIndex); + queueOfUpload.refresh(); + + isUploading.value = false; + + return result; + } + + Future performUploadQueue({ + required Function(Attachment item) onData, + }) async { + isUploading.value = true; + progressOfUpload.value = 0; + + for (var idx = 0; idx < queueOfUpload.length; idx++) { + queueOfUpload[idx].isUploading = true; + queueOfUpload.refresh(); + + final task = queueOfUpload[idx]; + final result = await _rawUploadAttachment( + await task.file.readAsBytes(), + task.file.path, + task.usage, + null, + onProgress: (value) { + queueOfUpload[idx].progress = value; + queueOfUpload.refresh(); + progressOfUpload.value = (idx + value) / queueOfUpload.length; + }, + ); + progressOfUpload.value = (idx + 1) / queueOfUpload.length; + onData(result); + + queueOfUpload[idx].isUploading = false; + queueOfUpload[idx].isCompleted = false; + queueOfUpload.refresh(); + } + + queueOfUpload.clear(); + queueOfUpload.refresh(); + isUploading.value = false; + } + + Future uploadAttachmentWithCallback( + Uint8List data, + String path, + String usage, + Map? metadata, + Function(Attachment) callback, + ) async { + if (isUploading.value) throw Exception('uploading blocked'); + + isUploading.value = true; + final result = await _rawUploadAttachment( + data, + path, + usage, + metadata, + onProgress: (progress) { + progressOfUpload.value = progress; + }, + ); + isUploading.value = false; + callback(result); + } + + Future uploadAttachment( + Uint8List data, + String path, + String usage, + Map? metadata, + ) async { + if (isUploading.value) throw Exception('uploading blocked'); + + isUploading.value = true; + final result = await _rawUploadAttachment( + data, + path, + usage, + metadata, + onProgress: (progress) { + progressOfUpload.value = progress; + }, + ); + isUploading.value = false; + return result; + } + + Future _rawUploadAttachment( + Uint8List data, String path, String usage, Map? metadata, + {Function(double)? onProgress}) async { + final AttachmentProvider provider = Get.find(); + try { + final resp = await provider.createAttachment( + data, + path, + usage, + metadata, + onProgress: onProgress, + ); + var result = Attachment.fromJson(resp.body); + return result; + } catch (err) { + rethrow; + } + } +} diff --git a/lib/providers/content/attachment.dart b/lib/providers/content/attachment.dart index ab7b8d1..5c9af93 100644 --- a/lib/providers/content/attachment.dart +++ b/lib/providers/content/attachment.dart @@ -38,11 +38,8 @@ class AttachmentProvider extends GetConnect { } Future createAttachment( - Uint8List data, - String path, - String usage, - Map? metadata, - ) async { + Uint8List data, String path, String usage, Map? metadata, + {Function(double)? onProgress}) async { final AuthProvider auth = Get.find(); if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); @@ -71,7 +68,13 @@ class AttachmentProvider extends GetConnect { if (mimetypeOverride != null) 'mimetype': mimetypeOverride, 'metadata': jsonEncode(metadata), }); - final resp = await client.post('/attachments', payload); + final resp = await client.post( + '/attachments', + payload, + uploadProgress: (progress) { + if (onProgress != null) onProgress(progress); + }, + ); if (resp.statusCode != 200) { throw Exception(resp.bodyString); } diff --git a/lib/translations/en_us.dart b/lib/translations/en_us.dart index 1bfaf18..fded378 100644 --- a/lib/translations/en_us.dart +++ b/lib/translations/en_us.dart @@ -157,6 +157,11 @@ const i18nEnglish = { 'reactCompleted': 'Your reaction has been added', 'reactUncompleted': 'Your reaction has been removed', 'attachmentUploadBy': 'Upload by', + 'attachmentAutoUpload': 'Auto Upload', + 'attachmentUploadQueue': 'Upload Queue', + 'attachmentUploadQueueStart': 'Start All', + 'attachmentAttached': 'Exists Files', + 'attachmentUploadBlocked': 'Upload blocked, there is currently a task in progress...', 'attachmentAdd': 'Attach attachments', 'attachmentAddGalleryPhoto': 'Gallery photo', 'attachmentAddGalleryVideo': 'Gallery video', diff --git a/lib/translations/zh_cn.dart b/lib/translations/zh_cn.dart index 1c60f8b..2636635 100644 --- a/lib/translations/zh_cn.dart +++ b/lib/translations/zh_cn.dart @@ -146,6 +146,11 @@ const i18nSimplifiedChinese = { 'reactCompleted': '你的反应已被添加', 'reactUncompleted': '你的反应已被移除', 'attachmentUploadBy': '由上传', + 'attachmentAutoUpload': '自动上传', + 'attachmentUploadQueue': '上传队列', + 'attachmentUploadQueueStart': '整队上传', + 'attachmentAttached': '已附附件', + 'attachmentUploadBlocked': '上传受阻,当前已有任务进行中……', 'attachmentAdd': '附加附件', 'attachmentAddGalleryPhoto': '相册照片', 'attachmentAddGalleryVideo': '相册视频', diff --git a/lib/widgets/attachments/attachment_attr_editor.dart b/lib/widgets/attachments/attachment_attr_editor.dart new file mode 100644 index 0000000..2a38b15 --- /dev/null +++ b/lib/widgets/attachments/attachment_attr_editor.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:get/get.dart'; +import 'package:solian/exts.dart'; +import 'package:solian/models/attachment.dart'; +import 'package:solian/providers/content/attachment.dart'; + +class AttachmentAttrEditorDialog extends StatefulWidget { + final Attachment item; + final Function(Attachment item) onUpdate; + + const AttachmentAttrEditorDialog({ + super.key, + required this.item, + required this.onUpdate, + }); + + @override + State createState() => _AttachmentAttrEditorDialogState(); +} + +class _AttachmentAttrEditorDialogState extends State { + final _altController = TextEditingController(); + + bool _isBusy = false; + bool _isMature = false; + + Future _updateAttachment() async { + final AttachmentProvider provider = Get.find(); + + setState(() => _isBusy = true); + try { + final resp = await provider.updateAttachment( + widget.item.id, + _altController.value.text, + widget.item.usage, + isMature: _isMature, + ); + + Get.find().clearCache(id: widget.item.id); + + setState(() => _isBusy = false); + return Attachment.fromJson(resp.body); + } catch (e) { + context.showErrorDialog(e); + setState(() => _isBusy = false); + return null; + } + } + + void syncWidget() { + _isMature = widget.item.isMature; + _altController.text = widget.item.alt; + } + + @override + void initState() { + syncWidget(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('attachmentSetting'.tr), + content: Container( + constraints: const BoxConstraints(minWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isBusy) + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: const LinearProgressIndicator().animate().scaleX(), + ), + const SizedBox(height: 18), + TextField( + controller: _altController, + decoration: InputDecoration( + isDense: true, + prefixIcon: const Icon(Icons.image_not_supported), + border: const OutlineInputBorder(), + labelText: 'attachmentAlt'.tr, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + const SizedBox(height: 8), + CheckboxListTile( + contentPadding: const EdgeInsets.only(left: 4, right: 18), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10))), + title: Text('matureContent'.tr), + secondary: const Icon(Icons.visibility_off), + value: _isMature, + onChanged: (newValue) { + setState(() => _isMature = newValue ?? false); + }, + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ), + ), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + style: TextButton.styleFrom( + foregroundColor: + Theme.of(context).colorScheme.onSurfaceVariant), + onPressed: () => Navigator.pop(context), + child: Text('cancel'.tr), + ), + TextButton( + child: Text('apply'.tr), + onPressed: () { + _updateAttachment().then((value) { + if (value != null) { + widget.onUpdate(value); + Navigator.pop(context); + } + }); + }, + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/attachments/attachment_editor.dart b/lib/widgets/attachments/attachment_editor.dart index 9353ea1..d8166cd 100644 --- a/lib/widgets/attachments/attachment_editor.dart +++ b/lib/widgets/attachments/attachment_editor.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; -import 'dart:typed_data'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:dismissible_page/dismissible_page.dart'; @@ -15,20 +14,24 @@ import 'package:path/path.dart' show basename; import 'package:solian/exts.dart'; import 'package:solian/models/attachment.dart'; import 'package:solian/platform.dart'; +import 'package:solian/providers/attachment_uploader.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/providers/content/attachment.dart'; +import 'package:solian/widgets/attachments/attachment_attr_editor.dart'; import 'package:solian/widgets/attachments/attachment_fullscreen.dart'; class AttachmentEditorPopup extends StatefulWidget { final String usage; - final List current; - final void Function(List data) onUpdate; + final List initialAttachments; + final void Function(int) onAdd; + final void Function(int) onRemove; const AttachmentEditorPopup({ super.key, required this.usage, - required this.current, - required this.onUpdate, + required this.initialAttachments, + required this.onAdd, + required this.onRemove, }); @override @@ -37,6 +40,9 @@ class AttachmentEditorPopup extends StatefulWidget { class _AttachmentEditorPopupState extends State { final _imagePicker = ImagePicker(); + final AttachmentUploaderController _uploadController = Get.find(); + + bool _isAutoUpload = false; bool _isBusy = false; bool _isFirstTimeBusy = true; @@ -50,22 +56,12 @@ class _AttachmentEditorPopupState extends State { final medias = await _imagePicker.pickMultiImage(); if (medias.isEmpty) return; - setState(() => _isBusy = true); - for (final media in medias) { final file = File(media.path); - try { - await _uploadAttachment( - await file.readAsBytes(), - file.path, - null, - ); - } catch (err) { - context.showErrorDialog(err); - } + _enqueueTask( + AttachmentUploadTask(file: file, usage: widget.usage), + ); } - - setState(() => _isBusy = false); } Future _pickVideoToUpload() async { @@ -75,17 +71,10 @@ class _AttachmentEditorPopupState extends State { final media = await _imagePicker.pickVideo(source: ImageSource.gallery); if (media == null) return; - setState(() => _isBusy = true); - final file = File(media.path); - - try { - await _uploadAttachment(await file.readAsBytes(), file.path, null); - } catch (err) { - context.showErrorDialog(err); - } - - setState(() => _isBusy = false); + _enqueueTask( + AttachmentUploadTask(file: file, usage: widget.usage), + ); } Future _pickFileToUpload() async { @@ -97,19 +86,13 @@ class _AttachmentEditorPopupState extends State { ); if (result == null) return; - setState(() => _isBusy = true); - List files = result.paths.map((path) => File(path!)).toList(); for (final file in files) { - try { - await _uploadAttachment(await file.readAsBytes(), file.path, null); - } catch (err) { - context.showErrorDialog(err); - } + _enqueueTask( + AttachmentUploadTask(file: file, usage: widget.usage), + ); } - - setState(() => _isBusy = false); } Future _takeMediaToUpload(bool isVideo) async { @@ -124,50 +107,30 @@ class _AttachmentEditorPopupState extends State { } if (media == null) return; - setState(() => _isBusy = true); - final file = File(media.path); - try { - await _uploadAttachment(await file.readAsBytes(), file.path, null); - } catch (err) { - context.showErrorDialog(err); - } - - setState(() => _isBusy = false); + _enqueueTask( + AttachmentUploadTask(file: file, usage: widget.usage), + ); } void _pasteFileToUpload() async { final data = await Pasteboard.image; if (data == null) return; - setState(() => _isBusy = true); + if (_uploadController.isUploading.value) return; - _uploadAttachment(data, 'Pasted Image', null); - - setState(() => _isBusy = false); - } - - Future _uploadAttachment( - Uint8List data, String path, Map? metadata) async { - final AttachmentProvider provider = Get.find(); - try { - context.showSnackbar((PlatformInfo.isWeb - ? 'attachmentUploadingWebMode' - : 'attachmentUploading') - .trParams({'name': basename(path)})); - final resp = await provider.createAttachment( - data, - path, - widget.usage, - metadata, - ); - var result = Attachment.fromJson(resp.body); - setState(() => _attachments.add(result)); - widget.onUpdate(_attachments.map((e) => e!.id).toList()); - context.clearSnackbar(); - } catch (err) { - rethrow; - } + _uploadController.uploadAttachmentWithCallback( + data, + 'Pasted Image', + widget.usage, + null, + (item) { + widget.onAdd(item.id); + if (mounted) { + setState(() => _attachments.add(item)); + } + }, + ); } String _formatBytes(int bytes, {int decimals = 2}) { @@ -192,21 +155,21 @@ class _AttachmentEditorPopupState extends State { void _revertMetadataList() { final AttachmentProvider provider = Get.find(); - if (widget.current.isEmpty) { + if (widget.initialAttachments.isEmpty) { _isFirstTimeBusy = false; return; } else { - _attachments = List.filled(widget.current.length, null); + _attachments = List.filled(widget.initialAttachments.length, null); } setState(() => _isBusy = true); int progress = 0; - for (var idx = 0; idx < widget.current.length; idx++) { - provider.getMetadata(widget.current[idx]).then((resp) { + for (var idx = 0; idx < widget.initialAttachments.length; idx++) { + provider.getMetadata(widget.initialAttachments[idx]).then((resp) { progress++; _attachments[idx] = resp; - if (progress == widget.current.length) { + if (progress == widget.initialAttachments.length) { setState(() { _isBusy = false; _isFirstTimeBusy = false; @@ -230,11 +193,10 @@ class _AttachmentEditorPopupState extends State { showDialog( context: context, builder: (context) { - return AttachmentEditorDialog( + return AttachmentAttrEditorDialog( item: element, onUpdate: (item) { setState(() => _attachments[index] = item); - widget.onUpdate(_attachments.map((e) => e!.id).toList()); }, ); }, @@ -253,6 +215,107 @@ class _AttachmentEditorPopupState extends State { } } + Widget _buildQueueEntry(AttachmentUploadTask element, int index) { + return Container( + padding: const EdgeInsets.only(left: 16, right: 8, bottom: 16), + child: Card( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + SizedBox( + height: 54, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + basename(element.file.path), + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + ), + FutureBuilder( + future: element.file.length(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Text( + '- Bytes', + style: TextStyle(fontSize: 12), + ); + } + return Text( + _formatBytes(snapshot.data!), + style: const TextStyle(fontSize: 12), + ); + }, + ), + ], + ), + ), + if (element.isUploading) + SizedBox( + width: 40, + height: 38, + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + value: element.progress, + ), + ), + ), + ), + if (element.isCompleted) + const SizedBox( + width: 40, + height: 38, + child: Center( + child: Icon(Icons.check), + ), + ), + if (!element.isCompleted && !element.isUploading) + IconButton( + color: Colors.green, + icon: const Icon(Icons.play_arrow), + visualDensity: const VisualDensity(horizontal: -4), + onPressed: _uploadController.isUploading.value + ? null + : () { + _uploadController + .performSingleTask(index) + .then((r) { + widget.onAdd(r.id); + if (mounted) { + setState(() => _attachments.add(r)); + } + }); + }, + ), + if (!element.isCompleted && !element.isUploading) + IconButton( + color: Colors.red, + icon: const Icon(Icons.remove_circle), + visualDensity: const VisualDensity(horizontal: -4), + onPressed: () { + _uploadController.dequeueTask(element); + }, + ), + ], + ).paddingSymmetric(vertical: 8, horizontal: 16), + ), + ], + ), + ), + ); + } + Widget _buildListEntry(Attachment element, int index) { var fileType = element.mimetype.split('/').firstOrNull; fileType ??= 'unknown'; @@ -278,8 +341,9 @@ class _AttachmentEditorPopupState extends State { overflow: TextOverflow.ellipsis, maxLines: 1, style: const TextStyle( - fontWeight: FontWeight.bold, - fontFamily: 'monospace'), + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), ), Text( '${fileType[0].toUpperCase()}${fileType.substring(1)} · ${_formatBytes(element.size)}', @@ -314,21 +378,20 @@ class _AttachmentEditorPopupState extends State { onTap: () => _showEdit(element, index), ), PopupMenuItem( - child: ListTile( - title: Text('delete'.tr), - leading: const Icon(Icons.delete), - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - ), + child: ListTile( + title: Text('delete'.tr), + leading: const Icon(Icons.delete), + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, ), - onTap: () { - _deleteAttachment(element).then((_) { - setState(() => _attachments.removeAt(index)); - widget.onUpdate( - _attachments.map((e) => e!.id).toList(), - ); - }); - }), + ), + onTap: () { + _deleteAttachment(element).then((_) { + widget.onRemove(element.id); + setState(() => _attachments.removeAt(index)); + }); + }, + ), ], ), ], @@ -340,6 +403,22 @@ class _AttachmentEditorPopupState extends State { ); } + void _enqueueTask(AttachmentUploadTask task) { + _uploadController.enqueueTask(task); + if (_isAutoUpload) { + _startUploading(); + } + } + + void _startUploading() { + _uploadController.performUploadQueue(onData: (r) { + widget.onAdd(r.id); + if (mounted) { + setState(() => _attachments.add(r)); + } + }); + } + @override void initState() { super.initState(); @@ -353,222 +432,208 @@ class _AttachmentEditorPopupState extends State { return SafeArea( child: DropTarget( onDragDone: (detail) async { - setState(() => _isBusy = true); + if (_uploadController.isUploading.value) return; for (final file in detail.files) { - final data = await file.readAsBytes(); - _uploadAttachment(data, file.path, null); + _enqueueTask( + AttachmentUploadTask(file: File(file.path), usage: widget.usage), + ); } - setState(() => _isBusy = false); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'attachmentAdd'.tr, - style: Theme.of(context).textTheme.headlineSmall, + Row( + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'attachmentAdd'.tr, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(width: 10), + Obx(() { + if (_uploadController.isUploading.value) { + return const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2.5, + ), + ); + } + return const SizedBox(); + }), + ], + ), + ), + Text('attachmentAutoUpload'.tr), + const SizedBox(width: 8), + Switch( + value: _isAutoUpload, + onChanged: (bool? value) { + if (value != null) { + setState(() => _isAutoUpload = value); + } + }, + ), + ], ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), if (_isBusy) const LinearProgressIndicator().animate().scaleX(), Expanded( - child: Builder(builder: (context) { - if (_isFirstTimeBusy && _isBusy) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - return ListView.builder( - itemCount: _attachments.length, - itemBuilder: (context, index) { - final element = _attachments[index]; - return _buildListEntry(element!, index); - }, - ); - }), - ), - const Divider(thickness: 0.3, height: 0.3), - SizedBox( - height: 64, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Wrap( - spacing: 8, - runSpacing: 0, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - if (PlatformInfo.isDesktop || - PlatformInfo.isIOS || - PlatformInfo.isWeb) - ElevatedButton.icon( - icon: const Icon(Icons.paste), - label: Text('attachmentAddClipboard'.tr), - style: const ButtonStyle(visualDensity: density), - onPressed: () => _pasteFileToUpload(), + child: CustomScrollView( + slivers: [ + Obx(() { + if (_uploadController.queueOfUpload.isNotEmpty) { + return SliverPadding( + padding: const EdgeInsets.only(bottom: 8), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'attachmentUploadQueue'.tr, + style: Theme.of(context).textTheme.bodyLarge, + ), + Obx(() { + if (_uploadController.isUploading.value) { + return const SizedBox(); + } + return TextButton( + child: Text('attachmentUploadQueueStart'.tr), + onPressed: () { + _startUploading(); + }, + ); + }), + ], + ).paddingOnly(left: 24, right: 24), + ), + ); + } + return const SliverToBoxAdapter(child: SizedBox()); + }), + Obx(() { + if (_uploadController.queueOfUpload.isNotEmpty) { + return SliverPadding( + padding: const EdgeInsets.only(bottom: 8), + sliver: SliverList.builder( + itemCount: _uploadController.queueOfUpload.length, + itemBuilder: (context, index) { + final element = + _uploadController.queueOfUpload[index]; + return _buildQueueEntry(element, index); + }, + ), + ); + } + return const SliverToBoxAdapter(child: SizedBox()); + }), + if (_attachments.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.only(bottom: 8), + sliver: SliverToBoxAdapter( + child: Text( + 'attachmentAttached'.tr, + style: Theme.of(context).textTheme.bodyLarge, + ).paddingOnly(left: 24, right: 24), ), - ElevatedButton.icon( - icon: const Icon(Icons.add_photo_alternate), - label: Text('attachmentAddGalleryPhoto'.tr), - style: const ButtonStyle(visualDensity: density), - onPressed: () => _pickPhotoToUpload(), ), - ElevatedButton.icon( - icon: const Icon(Icons.add_road), - label: Text('attachmentAddGalleryVideo'.tr), - style: const ButtonStyle(visualDensity: density), - onPressed: () => _pickVideoToUpload(), - ), - ElevatedButton.icon( - icon: const Icon(Icons.photo_camera_back), - label: Text('attachmentAddCameraPhoto'.tr), - style: const ButtonStyle(visualDensity: density), - onPressed: () => _takeMediaToUpload(false), - ), - ElevatedButton.icon( - icon: const Icon(Icons.video_camera_back_outlined), - label: Text('attachmentAddCameraVideo'.tr), - style: const ButtonStyle(visualDensity: density), - onPressed: () => _takeMediaToUpload(true), - ), - ElevatedButton.icon( - icon: const Icon(Icons.file_present_rounded), - label: Text('attachmentAddFile'.tr), - style: const ButtonStyle(visualDensity: density), - onPressed: () => _pickFileToUpload(), - ), - ], - ).paddingSymmetric(horizontal: 12), + if (_attachments.isNotEmpty) + Builder(builder: (context) { + if (_isFirstTimeBusy && _isBusy) { + return const SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return SliverList.builder( + itemCount: _attachments.length, + itemBuilder: (context, index) { + final element = _attachments[index]; + return _buildListEntry(element!, index); + }, + ); + }), + ], ), - ) + ), + Obx(() { + return IgnorePointer( + ignoring: _uploadController.isUploading.value, + child: Container( + height: 64, + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + width: 0.3, + ), + ), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Wrap( + spacing: 8, + runSpacing: 0, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + if (PlatformInfo.isDesktop || + PlatformInfo.isIOS || + PlatformInfo.isWeb) + ElevatedButton.icon( + icon: const Icon(Icons.paste), + label: Text('attachmentAddClipboard'.tr), + style: const ButtonStyle(visualDensity: density), + onPressed: () => _pasteFileToUpload(), + ), + ElevatedButton.icon( + icon: const Icon(Icons.add_photo_alternate), + label: Text('attachmentAddGalleryPhoto'.tr), + style: const ButtonStyle(visualDensity: density), + onPressed: () => _pickPhotoToUpload(), + ), + ElevatedButton.icon( + icon: const Icon(Icons.add_road), + label: Text('attachmentAddGalleryVideo'.tr), + style: const ButtonStyle(visualDensity: density), + onPressed: () => _pickVideoToUpload(), + ), + ElevatedButton.icon( + icon: const Icon(Icons.photo_camera_back), + label: Text('attachmentAddCameraPhoto'.tr), + style: const ButtonStyle(visualDensity: density), + onPressed: () => _takeMediaToUpload(false), + ), + ElevatedButton.icon( + icon: const Icon(Icons.video_camera_back_outlined), + label: Text('attachmentAddCameraVideo'.tr), + style: const ButtonStyle(visualDensity: density), + onPressed: () => _takeMediaToUpload(true), + ), + ElevatedButton.icon( + icon: const Icon(Icons.file_present_rounded), + label: Text('attachmentAddFile'.tr), + style: const ButtonStyle(visualDensity: density), + onPressed: () => _pickFileToUpload(), + ), + ], + ).paddingSymmetric(horizontal: 12), + ), + ) + .animate( + target: _uploadController.isUploading.value ? 0 : 1, + ) + .fade(duration: 100.ms), + ); + }), ], ), ), ); } } - -class AttachmentEditorDialog extends StatefulWidget { - final Attachment item; - final Function(Attachment item) onUpdate; - - const AttachmentEditorDialog({ - super.key, - required this.item, - required this.onUpdate, - }); - - @override - State createState() => _AttachmentEditorDialogState(); -} - -class _AttachmentEditorDialogState extends State { - final _altController = TextEditingController(); - - bool _isBusy = false; - bool _isMature = false; - - Future _updateAttachment() async { - final AttachmentProvider provider = Get.find(); - - setState(() => _isBusy = true); - try { - final resp = await provider.updateAttachment( - widget.item.id, - _altController.value.text, - widget.item.usage, - isMature: _isMature, - ); - - Get.find().clearCache(id: widget.item.id); - - setState(() => _isBusy = false); - return Attachment.fromJson(resp.body); - } catch (e) { - context.showErrorDialog(e); - - setState(() => _isBusy = false); - return null; - } - } - - void syncWidget() { - _isMature = widget.item.isMature; - _altController.text = widget.item.alt; - } - - @override - void initState() { - syncWidget(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text('attachmentSetting'.tr), - content: Container( - constraints: const BoxConstraints(minWidth: 400), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_isBusy) - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: const LinearProgressIndicator().animate().scaleX(), - ), - const SizedBox(height: 18), - TextField( - controller: _altController, - decoration: InputDecoration( - isDense: true, - prefixIcon: const Icon(Icons.image_not_supported), - border: const OutlineInputBorder(), - labelText: 'attachmentAlt'.tr, - ), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), - ), - const SizedBox(height: 8), - CheckboxListTile( - contentPadding: const EdgeInsets.only(left: 4, right: 18), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10))), - title: Text('matureContent'.tr), - secondary: const Icon(Icons.visibility_off), - value: _isMature, - onChanged: (newValue) { - setState(() => _isMature = newValue ?? false); - }, - controlAffinity: ListTileControlAffinity.leading, - ), - ], - ), - ), - actionsAlignment: MainAxisAlignment.spaceBetween, - actions: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - style: TextButton.styleFrom( - foregroundColor: - Theme.of(context).colorScheme.onSurfaceVariant), - onPressed: () => Navigator.pop(context), - child: Text('cancel'.tr), - ), - TextButton( - child: Text('apply'.tr), - onPressed: () { - _updateAttachment().then((value) { - if (value != null) { - widget.onUpdate(value); - Navigator.pop(context); - } - }); - }, - ), - ], - ), - ], - ); - } -} diff --git a/lib/widgets/chat/chat_event.dart b/lib/widgets/chat/chat_event.dart index 119d3b7..2ddd1df 100644 --- a/lib/widgets/chat/chat_event.dart +++ b/lib/widgets/chat/chat_event.dart @@ -112,7 +112,9 @@ class ChatEvent extends StatelessWidget { case 'messages.edit': return ChatEventMessageActionLog( icon: const Icon(Icons.edit_note, size: 16), - text: 'messageEditDesc'.trParams({'id': '#${item.id}'}), + text: 'messageEditDesc'.trParams({ + 'id': '#${item.body['related_event']}', + }), isMerged: isMerged, isHasMerged: isHasMerged, isQuote: isQuote, @@ -120,7 +122,9 @@ class ChatEvent extends StatelessWidget { case 'messages.delete': return ChatEventMessageActionLog( icon: const Icon(Icons.cancel_schedule_send, size: 16), - text: 'messageDeleteDesc'.trParams({'id': '#${item.id}'}), + text: 'messageDeleteDesc'.trParams({ + 'id': '#${item.body['related_event']}', + }), isMerged: isMerged, isHasMerged: isHasMerged, isQuote: isQuote, diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index d76f801..275f171 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -38,7 +38,7 @@ class _ChatMessageInputState extends State { final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); - List _attachments = List.empty(growable: true); + final List _attachments = List.empty(growable: true); Event? _editTo; Event? _replyTo; @@ -48,8 +48,17 @@ class _ChatMessageInputState extends State { context: context, builder: (context) => AttachmentEditorPopup( usage: 'm.attachment', - current: _attachments, - onUpdate: (value) => _attachments = value, + initialAttachments: _attachments, + onAdd: (value) { + setState(() { + _attachments.add(value); + }); + }, + onRemove: (value) { + setState(() { + _attachments.remove(value); + }); + }, ), ); }