diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 21815f6..bc45fe7 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -294,6 +294,9 @@ "attachmentSetThumbnail": "Set thumbnail", "attachmentCopyRandomId": "Copy RID", "attachmentUpload": "Upload", + "attachmentInputDialog": "Upload attachments", + "attachmentInputUseRandomId": "Use Random ID", + "attachmentInputNew": "New Upload", "notification": "Notification", "notificationUnreadCount": { "zero": "All notifications read", @@ -509,5 +512,6 @@ "postCategoryKnowledge": "Knowledge", "postCategoryLiterature": "Literature", "postCategoryFunny": "Funny", - "postCategoryUncategorized": "Uncategorized" + "postCategoryUncategorized": "Uncategorized", + "waitingForUpload": "Waiting for upload" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 3c7e737..11efe3b 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -292,6 +292,9 @@ "attachmentSetThumbnail": "设置缩略图", "attachmentCopyRandomId": "复制访问 ID", "attachmentUpload": "上传", + "attachmentInputDialog": "上传附件", + "attachmentInputUseRandomId": "使用访问 ID", + "attachmentInputNew": "新上传附件", "notification": "通知", "notificationUnreadCount": { "zero": "无未读通知", @@ -507,5 +510,6 @@ "postCategoryKnowledge": "知识", "postCategoryLiterature": "文学", "postCategoryFunny": "搞笑", - "postCategoryUncategorized": "未分类" + "postCategoryUncategorized": "未分类", + "waitingForUpload": "等待上传" } diff --git a/lib/providers/sn_attachment.dart b/lib/providers/sn_attachment.dart index 1b2a54f..1ec2878 100644 --- a/lib/providers/sn_attachment.dart +++ b/lib/providers/sn_attachment.dart @@ -215,4 +215,18 @@ class SnAttachmentProvider { return place; } + + Future updateOne( + int id, + String alt, { + required Map metadata, + bool isMature = false, + }) async { + final resp = await _sn.client.put('/cgi/uc/attachments/$id', data: { + 'alt': alt, + 'metadata': metadata, + 'is_mature': isMature, + }); + return SnAttachment.fromJson(resp.data); + } } diff --git a/lib/widgets/attachment/attachment_input.dart b/lib/widgets/attachment/attachment_input.dart new file mode 100644 index 0000000..4334026 --- /dev/null +++ b/lib/widgets/attachment/attachment_input.dart @@ -0,0 +1,114 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/providers/sn_attachment.dart'; +import 'package:surface/widgets/dialog.dart'; + +class AttachmentInputDialog extends StatefulWidget { + final String? title; + + const AttachmentInputDialog({super.key, required this.title}); + + @override + State createState() => _AttachmentInputDialogState(); +} + +class _AttachmentInputDialogState extends State { + final _randomIdController = TextEditingController(); + + XFile? _thumbnailFile; + + void _pickImage() async { + final picker = ImagePicker(); + final result = await picker.pickImage(source: ImageSource.gallery); + if (result == null) return; + setState(() => _thumbnailFile = result); + } + + bool _isBusy = false; + + void _finishUp() async { + if (_isBusy) return; + setState(() => _isBusy = true); + + final attach = context.read(); + + if (_randomIdController.text.isNotEmpty) { + try { + final attachment = await attach.getOne(_randomIdController.text); + if (!mounted) return; + Navigator.pop(context, attachment); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } + } else if (_thumbnailFile != null) { + try { + final attachment = await attach.directUploadOne( + (await _thumbnailFile!.readAsBytes()).buffer.asUint8List(), + _thumbnailFile!.path, + 'interactive', + null, + ); + if (!mounted) return; + Navigator.pop(context, attachment); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title ?? 'attachmentInputDialog').tr(), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('attachmentInputUseRandomId').tr().fontSize(14), + const Gap(8), + TextField( + controller: _randomIdController, + decoration: InputDecoration( + labelText: 'fieldAttachmentRandomId'.tr(), + border: const OutlineInputBorder(), + ), + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + const Gap(24), + Text('attachmentInputNew').tr().fontSize(14), + Card( + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: const Icon(Symbols.add_photo_alternate), + trailing: const Icon(Symbols.chevron_right), + title: Text('addAttachmentFromAlbum').tr(), + subtitle: _thumbnailFile == null ? Text('unset').tr() : Text('waitingForUpload').tr(), + onTap: () { + _pickImage(); + }, + ), + ), + ], + ), + actions: [ + TextButton( + child: Text('dialogDismiss').tr(), + onPressed: _isBusy ? null : () { + Navigator.pop(context); + }, + ), + TextButton( + onPressed: _isBusy ? null : () => _finishUp(), + child: Text('dialogConfirm').tr(), + ), + ], + ); + } +} diff --git a/lib/widgets/post/post_media_pending_list.dart b/lib/widgets/post/post_media_pending_list.dart index 1b91e8e..e8645e1 100644 --- a/lib/widgets/post/post_media_pending_list.dart +++ b/lib/widgets/post/post_media_pending_list.dart @@ -17,9 +17,12 @@ import 'package:styled_widget/styled_widget.dart'; import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_network.dart'; +import 'package:surface/types/attachment.dart'; +import 'package:surface/widgets/attachment/attachment_input.dart'; import 'package:surface/widgets/attachment/attachment_zoom.dart'; import 'package:surface/widgets/context_menu.dart'; import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/universal_image.dart'; class PostMediaPendingList extends StatelessWidget { final PostWriteMedia? thumbnail; @@ -75,6 +78,32 @@ class PostMediaPendingList extends StatelessWidget { } } + Future _setThumbnail(BuildContext context, int idx) async { + if (idx == -1) { + // Thumbnail only can set on video or audio. And thumbnail of the post must be an image, so it's not possible to set thumbnail on the post thumbnail. + return; + } else if (attachments[idx].attachment == null) { + return; + } + + final thumbnail = await showDialog( + context: context, + builder: (context) => AttachmentInputDialog( + title: 'attachmentSetThumbnail'.tr(), + ), + ); + if (thumbnail == null) return; + if (!context.mounted) return; + + final attach = context.read(); + final newAttach = await attach.updateOne(attachments[idx].attachment!.id, thumbnail.alt, metadata: { + ...attachments[idx].attachment!.metadata, + 'thumbnail': thumbnail.rid, + }); + + onUpdate!(idx, PostWriteMedia(newAttach)); + } + Future _deleteAttachment(BuildContext context, int idx) async { final media = idx == -1 ? thumbnail! : attachments[idx]; if (media.attachment == null) return; @@ -99,7 +128,9 @@ class PostMediaPendingList extends StatelessWidget { MenuItem( label: 'attachmentSetThumbnail'.tr(), icon: Symbols.image, - onSelected: () {}, + onSelected: () { + _setThumbnail(context, idx); + }, ), if (media.attachment == null && onUpload != null) MenuItem( @@ -119,7 +150,7 @@ class PostMediaPendingList extends StatelessWidget { onPostSetThumbnail!(idx); }, ) - else if (media.attachment != null && onPostSetThumbnail != null) + else if (media.attachment != null && media.type == PostWriteMediaType.image && onPostSetThumbnail != null) MenuItem( label: 'attachmentUnsetAsPostThumbnail'.tr(), icon: Symbols.cancel, @@ -190,6 +221,8 @@ class PostMediaPendingList extends StatelessWidget { Widget build(BuildContext context) { final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + final sn = context.read(); + return Container( constraints: const BoxConstraints(maxHeight: 120), child: Row( @@ -198,40 +231,7 @@ class PostMediaPendingList extends StatelessWidget { if (thumbnail != null) ContextMenuArea( contextMenu: _createContextMenu(context, -1, thumbnail!), - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1, - ), - borderRadius: BorderRadius.circular(8), - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: AspectRatio( - aspectRatio: 1, - child: switch (thumbnail!.type) { - PostWriteMediaType.image => Container( - color: Theme.of(context).colorScheme.surfaceContainer, - child: LayoutBuilder(builder: (context, constraints) { - return Image( - image: thumbnail!.getImageProvider( - context, - width: (constraints.maxWidth * devicePixelRatio).round(), - height: (constraints.maxHeight * devicePixelRatio).round(), - )!, - fit: BoxFit.contain, - ); - }), - ), - _ => Container( - color: Theme.of(context).colorScheme.surface, - child: const Icon(Symbols.docs).center(), - ), - }, - ), - ), - ), + child: _PostMediaPendingItem(media: thumbnail!), ), if (thumbnail != null) const VerticalDivider(width: 1, thickness: 1).padding( @@ -248,44 +248,7 @@ class PostMediaPendingList extends StatelessWidget { final media = attachments[idx]; return ContextMenuArea( contextMenu: _createContextMenu(context, idx, media), - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1, - ), - borderRadius: BorderRadius.circular(8), - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: AspectRatio( - aspectRatio: 1, - child: switch (media.type) { - PostWriteMediaType.image => Container( - color: Theme.of(context).colorScheme.surfaceContainer, - child: LayoutBuilder(builder: (context, constraints) { - return Image( - image: media.getImageProvider( - context, - width: (constraints.maxWidth * devicePixelRatio).round(), - height: (constraints.maxHeight * devicePixelRatio).round(), - )!, - fit: BoxFit.contain, - ); - }), - ), - PostWriteMediaType.video => Container( - color: Theme.of(context).colorScheme.surfaceContainer, - child: const Icon(Symbols.videocam).center(), - ), - _ => Container( - color: Theme.of(context).colorScheme.surfaceContainer, - child: const Icon(Symbols.docs).center(), - ), - }, - ), - ), - ), + child: _PostMediaPendingItem(media: media), ); }, ), @@ -296,6 +259,62 @@ class PostMediaPendingList extends StatelessWidget { } } +class _PostMediaPendingItem extends StatelessWidget { + final PostWriteMedia media; + + const _PostMediaPendingItem({ + required this.media, + }); + + @override + Widget build(BuildContext context) { + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + + final sn = context.read(); + + return Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor, + width: 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AspectRatio( + aspectRatio: 1, + child: switch (media.type) { + PostWriteMediaType.image => Container( + color: Theme.of(context).colorScheme.surfaceContainer, + child: LayoutBuilder(builder: (context, constraints) { + return Image( + image: media.getImageProvider( + context, + width: (constraints.maxWidth * devicePixelRatio).round(), + height: (constraints.maxHeight * devicePixelRatio).round(), + )!, + fit: BoxFit.contain, + ); + }), + ), + PostWriteMediaType.video => Container( + color: Theme.of(context).colorScheme.surfaceContainer, + child: media.attachment?.metadata['thumbnail'] != null + ? AutoResizeUniversalImage(sn.getAttachmentUrl(media.attachment?.metadata['thumbnail'])) + : const Icon(Symbols.videocam).center(), + ), + _ => Container( + color: Theme.of(context).colorScheme.surfaceContainer, + child: const Icon(Symbols.docs).center(), + ), + }, + ), + ), + ); + } +} + class AddPostMediaButton extends StatelessWidget { final Function(Iterable) onAdd;