import 'dart:io'; import 'dart:ui'; import 'package:croppy/croppy.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_context_menu/flutter_context_menu.dart'; import 'package:gap/gap.dart'; import 'package:image_picker/image_picker.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:provider/provider.dart'; 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; final List attachments; final bool isBusy; final Future Function(int idx, PostWriteMedia updatedMedia)? onUpdate; final Future Function(int idx)? onRemove; final Future Function(int idx)? onUpload; final void Function(int? idx)? onPostSetThumbnail; final void Function(int idx)? onInsertLink; final void Function(bool state)? onUpdateBusy; const PostMediaPendingList({ super.key, this.thumbnail, required this.attachments, required this.isBusy, this.onUpdate, this.onRemove, this.onUpload, this.onPostSetThumbnail, this.onInsertLink, this.onUpdateBusy, }); Future _cropImage(BuildContext context, int idx) async { final media = attachments[idx]; final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) ? await showCupertinoImageCropper( // ignore: use_build_context_synchronously context, // ignore: use_build_context_synchronously imageProvider: media.getImageProvider(context)!, ) : await showMaterialImageCropper( // ignore: use_build_context_synchronously context, // ignore: use_build_context_synchronously imageProvider: media.getImageProvider(context)!, ); if (result == null) return; final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List(); if (onUpdate != null) { final updatedMedia = PostWriteMedia.fromBytes( rawBytes, media.name, media.type, ); await onUpdate!(idx, updatedMedia); } } 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; try { onUpdateBusy?.call(true); final sn = context.read(); await sn.client.delete('/cgi/uc/attachments/${media.attachment!.id}'); onRemove!(idx); } catch (err) { if (!context.mounted) return; context.showErrorDialog(err); } finally { onUpdateBusy?.call(false); } } ContextMenu _createContextMenu(BuildContext context, int idx, PostWriteMedia media) { return ContextMenu( entries: [ if (media.attachment != null && media.type == PostWriteMediaType.video) MenuItem( label: 'attachmentSetThumbnail'.tr(), icon: Symbols.image, onSelected: () { _setThumbnail(context, idx); }, ), if (media.attachment == null && onUpload != null) MenuItem( label: 'attachmentUpload'.tr(), icon: Symbols.upload, onSelected: () { onUpload!(idx); }), if (media.attachment != null && media.type == PostWriteMediaType.image && onPostSetThumbnail != null && idx != -1) MenuItem( label: 'attachmentSetAsPostThumbnail'.tr(), icon: Symbols.gallery_thumbnail, onSelected: () { onPostSetThumbnail!(idx); }, ) else if (media.attachment != null && media.type == PostWriteMediaType.image && onPostSetThumbnail != null) MenuItem( label: 'attachmentUnsetAsPostThumbnail'.tr(), icon: Symbols.cancel, onSelected: () { onPostSetThumbnail!(null); }, ), if (media.attachment != null && onInsertLink != null) MenuItem( label: 'attachmentInsertLink'.tr(), icon: Symbols.add_link, onSelected: () { onInsertLink!(idx); }, ), if (media.type == PostWriteMediaType.image && media.attachment != null) MenuItem( label: 'preview'.tr(), icon: Symbols.preview, onSelected: () { context.pushTransparentRoute( AttachmentZoomView(data: [media.attachment!]), rootNavigator: true, ); }, ), if (media.type == PostWriteMediaType.image && media.attachment == null) MenuItem( label: 'crop'.tr(), icon: Symbols.crop, onSelected: () => _cropImage(context, idx), ), if (media.attachment != null) MenuItem( label: 'attachmentCopyRandomId'.tr(), icon: Symbols.content_copy, onSelected: () { Clipboard.setData(ClipboardData(text: media.attachment!.rid)); }, ), if (media.attachment != null && onRemove != null) MenuItem( label: 'delete'.tr(), icon: Symbols.delete, onSelected: isBusy ? null : () => _deleteAttachment(context, idx), ), if (media.attachment == null && onRemove != null) MenuItem( label: 'delete'.tr(), icon: Symbols.delete, onSelected: () { onRemove!(idx); }, ) else if (onRemove != null) MenuItem( label: 'unlink'.tr(), icon: Symbols.link_off, onSelected: () { onRemove!(idx); }, ), ], ); } @override Widget build(BuildContext context) { final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final sn = context.read(); return Container( constraints: const BoxConstraints(maxHeight: 120), child: Row( children: [ const Gap(8), if (thumbnail != null) ContextMenuArea( contextMenu: _createContextMenu(context, -1, thumbnail!), child: _PostMediaPendingItem(media: thumbnail!), ), if (thumbnail != null) const VerticalDivider(width: 1, thickness: 1).padding( horizontal: 12, vertical: 16, ), Expanded( child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.only(right: 8), separatorBuilder: (context, index) => const Gap(8), itemCount: attachments.length, itemBuilder: (context, idx) { final media = attachments[idx]; return ContextMenuArea( contextMenu: _createContextMenu(context, idx, media), child: _PostMediaPendingItem(media: media), ); }, ), ), ], ), ); } } 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; const AddPostMediaButton({super.key, required this.onAdd}); void _takeMedia(bool isVideo) async { final picker = ImagePicker(); final result = isVideo ? await picker.pickVideo(source: ImageSource.camera) : await picker.pickImage(source: ImageSource.camera); if (result == null) return; onAdd([PostWriteMedia.fromFile(result)]); } void _selectMedia() async { final picker = ImagePicker(); final result = await picker.pickMultipleMedia(); if (result.isEmpty) return; onAdd( result.map((e) => PostWriteMedia.fromFile(e)), ); } void _pasteMedia() async { final imageBytes = await Pasteboard.image; if (imageBytes == null) return; onAdd([ PostWriteMedia.fromBytes( imageBytes, 'attachmentPastedImage'.tr(), PostWriteMediaType.image, ), ]); } void _linkRandomId(BuildContext context) async { final randomIdController = TextEditingController(); final randomId = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('addAttachmentFromRandomId').tr(), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: randomIdController, decoration: InputDecoration( labelText: 'fieldAttachmentRandomId'.tr(), border: const UnderlineInputBorder(), ), ), const Gap(8), ], ), actions: [ TextButton( child: Text('dialogDismiss').tr(), onPressed: () { Navigator.pop(context); }, ), TextButton( child: Text('dialogConfirm').tr(), onPressed: () { Navigator.pop(context, randomIdController.text); }, ), ], ), ); WidgetsBinding.instance.addPostFrameCallback((_) { randomIdController.dispose(); }); if (randomId == null || randomId.isEmpty) return; if (!context.mounted) return; final attach = context.read(); final attachment = await attach.getOne(randomId); onAdd([ PostWriteMedia(attachment), ]); } @override Widget build(BuildContext context) { return PopupMenuButton( icon: Icon( Symbols.add_photo_alternate, color: Theme.of(context).colorScheme.primary, ), itemBuilder: (context) => [ 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); }, ), PopupMenuItem( child: Row( children: [ const Icon(Symbols.photo_library), const Gap(16), Text('addAttachmentFromAlbum').tr(), ], ), onTap: () { _selectMedia(); }, ), PopupMenuItem( child: Row( children: [ const Icon(Symbols.link), const Gap(16), Text('addAttachmentFromRandomId').tr(), ], ), onTap: () { _linkRandomId(context); }, ), PopupMenuItem( child: Row( children: [ const Icon(Symbols.content_paste), const Gap(16), Text('addAttachmentFromClipboard').tr(), ], ), onTap: () { _pasteMedia(); }, ), ], ); } }