diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index fa50ffe..35f0db8 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -948,5 +948,8 @@ "postEditedHint": "edited", "splashScreenServer": "Server", "splashScreenServerName": "Potato", - "splashScreenCaption": "Trying to establishing connection with HyperNet™" + "splashScreenCaption": "Trying to establishing connection with HyperNet™", + "attachmentEditor": "Attachment editor", + "attachmentEditorUnUploadHint": "This attachment is not uploaded, metadata editing is unavailable, and you can crop this attachment.", + "attachmentEditorUploadHint": "This attachment is uploaded." } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 3bf180c..edf07ba 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -945,5 +945,8 @@ "postEditedHint": "已编辑", "splashScreenServer": "服务器", "splashScreenServerName": "土豆", - "splashScreenCaption": "正在尝试与 HyperNet™ 取得太阳链连接" + "splashScreenCaption": "正在尝试与 HyperNet™ 取得太阳链连接", + "attachmentEditor": "附件编辑器", + "attachmentEditorUnUploadHint": "该附件未上传,元数据编辑不可用,同时你可以裁剪本附件。", + "attachmentEditorUploadHint": "该附件已上传。" } diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index 047b832..d7894c2 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -458,7 +458,9 @@ class _PostEditorScreenState extends State isBusy: _writeController.isBusy, onUpload: (int idx) async { await _writeController.uploadSingleAttachment( - context, idx); + context, + idx, + ); }, onInsertLink: (int idx) async { _writeController.contentController.text += diff --git a/lib/types/attachment.dart b/lib/types/attachment.dart index 9ad6942..cec836a 100644 --- a/lib/types/attachment.dart +++ b/lib/types/attachment.dart @@ -30,6 +30,7 @@ abstract class SnAttachment with _$SnAttachment { required String hash, required int destination, required int refCount, + String? refUrl, @Default(0) int contentRating, @Default(0) int qualityRating, required DateTime? cleanedAt, diff --git a/lib/types/attachment.freezed.dart b/lib/types/attachment.freezed.dart index f4536fb..a78548e 100644 --- a/lib/types/attachment.freezed.dart +++ b/lib/types/attachment.freezed.dart @@ -28,6 +28,7 @@ mixin _$SnAttachment { String get hash; int get destination; int get refCount; + String? get refUrl; int get contentRating; int get qualityRating; DateTime? get cleanedAt; @@ -83,6 +84,7 @@ mixin _$SnAttachment { other.destination == destination) && (identical(other.refCount, refCount) || other.refCount == refCount) && + (identical(other.refUrl, refUrl) || other.refUrl == refUrl) && (identical(other.contentRating, contentRating) || other.contentRating == contentRating) && (identical(other.qualityRating, qualityRating) || @@ -132,6 +134,7 @@ mixin _$SnAttachment { hash, destination, refCount, + refUrl, contentRating, qualityRating, cleanedAt, @@ -155,7 +158,7 @@ mixin _$SnAttachment { @override String toString() { - return 'SnAttachment(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, rid: $rid, uuid: $uuid, size: $size, name: $name, alt: $alt, mimetype: $mimetype, hash: $hash, destination: $destination, refCount: $refCount, contentRating: $contentRating, qualityRating: $qualityRating, cleanedAt: $cleanedAt, isAnalyzed: $isAnalyzed, isSelfRef: $isSelfRef, isIndexable: $isIndexable, ref: $ref, refId: $refId, pool: $pool, poolId: $poolId, account: $account, accountId: $accountId, thumbnailId: $thumbnailId, thumbnail: $thumbnail, compressedId: $compressedId, compressed: $compressed, boosts: $boosts, usermeta: $usermeta, metadata: $metadata)'; + return 'SnAttachment(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, rid: $rid, uuid: $uuid, size: $size, name: $name, alt: $alt, mimetype: $mimetype, hash: $hash, destination: $destination, refCount: $refCount, refUrl: $refUrl, contentRating: $contentRating, qualityRating: $qualityRating, cleanedAt: $cleanedAt, isAnalyzed: $isAnalyzed, isSelfRef: $isSelfRef, isIndexable: $isIndexable, ref: $ref, refId: $refId, pool: $pool, poolId: $poolId, account: $account, accountId: $accountId, thumbnailId: $thumbnailId, thumbnail: $thumbnail, compressedId: $compressedId, compressed: $compressed, boosts: $boosts, usermeta: $usermeta, metadata: $metadata)'; } } @@ -179,6 +182,7 @@ abstract mixin class $SnAttachmentCopyWith<$Res> { String hash, int destination, int refCount, + String? refUrl, int contentRating, int qualityRating, DateTime? cleanedAt, @@ -231,6 +235,7 @@ class _$SnAttachmentCopyWithImpl<$Res> implements $SnAttachmentCopyWith<$Res> { Object? hash = null, Object? destination = null, Object? refCount = null, + Object? refUrl = freezed, Object? contentRating = null, Object? qualityRating = null, Object? cleanedAt = freezed, @@ -304,6 +309,10 @@ class _$SnAttachmentCopyWithImpl<$Res> implements $SnAttachmentCopyWith<$Res> { ? _self.refCount : refCount // ignore: cast_nullable_to_non_nullable as int, + refUrl: freezed == refUrl + ? _self.refUrl + : refUrl // ignore: cast_nullable_to_non_nullable + as String?, contentRating: null == contentRating ? _self.contentRating : contentRating // ignore: cast_nullable_to_non_nullable @@ -471,6 +480,7 @@ class _SnAttachment extends SnAttachment { required this.hash, required this.destination, required this.refCount, + this.refUrl, this.contentRating = 0, this.qualityRating = 0, required this.cleanedAt, @@ -524,6 +534,8 @@ class _SnAttachment extends SnAttachment { @override final int refCount; @override + final String? refUrl; + @override @JsonKey() final int contentRating; @override @@ -623,6 +635,7 @@ class _SnAttachment extends SnAttachment { other.destination == destination) && (identical(other.refCount, refCount) || other.refCount == refCount) && + (identical(other.refUrl, refUrl) || other.refUrl == refUrl) && (identical(other.contentRating, contentRating) || other.contentRating == contentRating) && (identical(other.qualityRating, qualityRating) || @@ -672,6 +685,7 @@ class _SnAttachment extends SnAttachment { hash, destination, refCount, + refUrl, contentRating, qualityRating, cleanedAt, @@ -695,7 +709,7 @@ class _SnAttachment extends SnAttachment { @override String toString() { - return 'SnAttachment(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, rid: $rid, uuid: $uuid, size: $size, name: $name, alt: $alt, mimetype: $mimetype, hash: $hash, destination: $destination, refCount: $refCount, contentRating: $contentRating, qualityRating: $qualityRating, cleanedAt: $cleanedAt, isAnalyzed: $isAnalyzed, isSelfRef: $isSelfRef, isIndexable: $isIndexable, ref: $ref, refId: $refId, pool: $pool, poolId: $poolId, account: $account, accountId: $accountId, thumbnailId: $thumbnailId, thumbnail: $thumbnail, compressedId: $compressedId, compressed: $compressed, boosts: $boosts, usermeta: $usermeta, metadata: $metadata)'; + return 'SnAttachment(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, rid: $rid, uuid: $uuid, size: $size, name: $name, alt: $alt, mimetype: $mimetype, hash: $hash, destination: $destination, refCount: $refCount, refUrl: $refUrl, contentRating: $contentRating, qualityRating: $qualityRating, cleanedAt: $cleanedAt, isAnalyzed: $isAnalyzed, isSelfRef: $isSelfRef, isIndexable: $isIndexable, ref: $ref, refId: $refId, pool: $pool, poolId: $poolId, account: $account, accountId: $accountId, thumbnailId: $thumbnailId, thumbnail: $thumbnail, compressedId: $compressedId, compressed: $compressed, boosts: $boosts, usermeta: $usermeta, metadata: $metadata)'; } } @@ -721,6 +735,7 @@ abstract mixin class _$SnAttachmentCopyWith<$Res> String hash, int destination, int refCount, + String? refUrl, int contentRating, int qualityRating, DateTime? cleanedAt, @@ -779,6 +794,7 @@ class __$SnAttachmentCopyWithImpl<$Res> Object? hash = null, Object? destination = null, Object? refCount = null, + Object? refUrl = freezed, Object? contentRating = null, Object? qualityRating = null, Object? cleanedAt = freezed, @@ -852,6 +868,10 @@ class __$SnAttachmentCopyWithImpl<$Res> ? _self.refCount : refCount // ignore: cast_nullable_to_non_nullable as int, + refUrl: freezed == refUrl + ? _self.refUrl + : refUrl // ignore: cast_nullable_to_non_nullable + as String?, contentRating: null == contentRating ? _self.contentRating : contentRating // ignore: cast_nullable_to_non_nullable diff --git a/lib/types/attachment.g.dart b/lib/types/attachment.g.dart index c5ffed2..838a771 100644 --- a/lib/types/attachment.g.dart +++ b/lib/types/attachment.g.dart @@ -23,6 +23,7 @@ _SnAttachment _$SnAttachmentFromJson(Map json) => hash: json['hash'] as String, destination: (json['destination'] as num).toInt(), refCount: (json['ref_count'] as num).toInt(), + refUrl: json['ref_url'] as String?, contentRating: (json['content_rating'] as num?)?.toInt() ?? 0, qualityRating: (json['quality_rating'] as num?)?.toInt() ?? 0, cleanedAt: json['cleaned_at'] == null @@ -75,6 +76,7 @@ Map _$SnAttachmentToJson(_SnAttachment instance) => 'hash': instance.hash, 'destination': instance.destination, 'ref_count': instance.refCount, + 'ref_url': instance.refUrl, 'content_rating': instance.contentRating, 'quality_rating': instance.qualityRating, 'cleaned_at': instance.cleanedAt?.toIso8601String(), diff --git a/lib/widgets/attachment/attachment_input.dart b/lib/widgets/attachment/attachment_input.dart index 7cbf7d8..7f4bb45 100644 --- a/lib/widgets/attachment/attachment_input.dart +++ b/lib/widgets/attachment/attachment_input.dart @@ -63,7 +63,8 @@ class _AttachmentInputDialogState extends State { } } else if (_file != null) { try { - final place = await attach.chunkedUploadInitialize(await _file!.length(), _file!.name, widget.pool, null); + final place = await attach.chunkedUploadInitialize( + await _file!.length(), _file!.name, widget.pool, null); final attachment = await attach.chunkedUploadParts( _file!, @@ -109,11 +110,17 @@ class _AttachmentInputDialogState extends State { child: Column( children: [ ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + 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: _file == null ? Text('unset').tr() : Text('waitingForUpload').tr(), + subtitle: _file == null + ? Text('unset').tr() + : Text('waitingForUpload').tr(), onTap: () { _pickMedia(); }, diff --git a/lib/widgets/attachment/pending_attachment_actions.dart b/lib/widgets/attachment/pending_attachment_actions.dart new file mode 100644 index 0000000..930cdca --- /dev/null +++ b/lib/widgets/attachment/pending_attachment_actions.dart @@ -0,0 +1,311 @@ +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:gap/gap.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.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/attachment/pending_attachment_alt.dart'; +import 'package:surface/widgets/attachment/pending_attachment_boost.dart'; +import 'package:surface/widgets/attachment/pending_attachment_compress.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/loading_indicator.dart'; + +class PendingAttachmentActionSheet extends StatefulWidget { + final PostWriteMedia media; + const PendingAttachmentActionSheet({super.key, required this.media}); + + @override + State createState() => + _PendingAttachmentActionSheetState(); +} + +class _PendingAttachmentActionSheetState + extends State { + bool _isBusy = false; + + Future _cropImage() async { + final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) + ? await showCupertinoImageCropper( + // ignore: use_build_context_synchronously + context, + // ignore: use_build_context_synchronously + imageProvider: widget.media.getImageProvider(context)!, + ) + : await showMaterialImageCropper( + // ignore: use_build_context_synchronously + context, + // ignore: use_build_context_synchronously + imageProvider: widget.media.getImageProvider(context)!, + ); + + if (result == null) return; + + final rawBytes = + (await result.uiImage.toByteData(format: ImageByteFormat.png))! + .buffer + .asUint8List(); + + if (!mounted) return; + final updatedMedia = PostWriteMedia.fromBytes( + rawBytes, widget.media.name, widget.media.type); + Navigator.pop(context, updatedMedia); + } + + Future _setThumbnail() async { + final thumbnail = await showDialog( + context: context, + builder: (context) => AttachmentInputDialog( + title: 'attachmentSetThumbnail'.tr(), + pool: 'interactive', + analyzeNow: true, + ), + ); + if (thumbnail == null) return; + if (!mounted) return; + + try { + final attach = context.read(); + final newAttach = await attach.updateOne(widget.media.attachment!, + thumbnailId: thumbnail.id); + if (mounted) Navigator.pop(context, newAttach); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } + } + + Future _deleteAttachment() async { + if (_isBusy) return; + if (widget.media.attachment == null) return; + + try { + setState(() => _isBusy = true); + final sn = context.read(); + await sn.client + .delete('/cgi/uc/attachments/${widget.media.attachment!.id}'); + if (!mounted) return; + Navigator.pop(context, false); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + Future _createBoost() async { + final result = await showDialog( + context: context, + builder: (context) => PendingAttachmentBoostDialog( + media: widget.media, + ), + ); + if (result == null) return; + + final newAttach = widget.media.attachment! + .copyWith(boosts: [...widget.media.attachment!.boosts, result]); + final newMedia = PostWriteMedia(newAttach); + + if (!mounted) return; + Navigator.pop(context, newMedia); + } + + Future _compressVideo() async { + final result = await showDialog( + context: context, + builder: (context) => PendingVideoCompressDialog(media: widget.media), + ); + if (result == null) return; + + if (!mounted) return; + Navigator.pop(context, result); + } + + Future _setAlt() async { + final result = await showDialog( + context: context, + builder: (context) => PendingAttachmentAltDialog(media: widget.media), + ); + if (result == null) return; + + if (!mounted) return; + Navigator.pop(context, PostWriteMedia(result)); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Symbols.edit, size: 24), + const Gap(16), + Text('attachmentEditor') + .tr() + .textStyle(Theme.of(context).textTheme.titleLarge!), + ], + ).padding(horizontal: 20, top: 16, bottom: 12), + if (widget.media.attachment == null) + Text('attachmentEditorUnUploadHint') + .tr() + .textStyle(Theme.of(context).textTheme.bodyMedium!) + .padding(horizontal: 20, bottom: 8) + .opacity(0.8) + else + Card( + margin: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.media.attachment!.alt), + Row( + spacing: 6, + children: [ + Text(widget.media.attachment!.size.formatBytes()), + Text( + widget.media.attachment!.mimetype, + style: GoogleFonts.robotoMono(), + ), + ], + ), + Text('attachmentEditorUploadHint') + .tr() + .textStyle(Theme.of(context).textTheme.bodyMedium!) + .opacity(0.8), + ], + ).padding(horizontal: 16, vertical: 8), + ).padding(horizontal: 16, bottom: 8), + LoadingIndicator(isActive: _isBusy), + if (widget.media.attachment == null) + Expanded( + child: ListView( + children: [ + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.upload), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('attachmentUpload').tr(), + onTap: () => Navigator.pop(context, true), + ), + if (widget.media.type == SnMediaType.video) + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.compress), + title: Text('attachmentCompressVideo').tr(), + onTap: () => _compressVideo(), + ), + if (widget.media.type == SnMediaType.image) + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.crop), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('crop').tr(), + onTap: () => _cropImage(), + ), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.delete), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('delete').tr(), + onTap: () => Navigator.pop(context, false), + ), + ], + ), + ) + else + Expanded( + child: ListView( + children: [ + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.preview), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('preview').tr(), + onTap: () { + context.pushTransparentRoute( + AttachmentZoomView(data: [widget.media.attachment!]), + rootNavigator: true, + ); + }, + ), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.copy_all), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('attachmentCopyRandomId').tr(), + onTap: () { + Clipboard.setData( + ClipboardData( + text: widget.media.attachment!.rid, + ), + ); + Navigator.pop(context); + }, + ), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.add_link), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('attachmentInsertLink').tr(), + onTap: () { + Navigator.pop(context, 'link'); + }, + ), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.bolt), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('attachmentBoost').tr(), + onTap: () => _createBoost(), + ), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.thumbnail_bar), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('attachmentSetThumbnail').tr(), + onTap: () => _setThumbnail(), + ), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.description), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('attachmentSetAlt').tr(), + onTap: () => _setAlt(), + ), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.link_off), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('unlink').tr(), + onTap: () => Navigator.pop(context, false), + ), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.delete), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('delete').tr(), + onTap: () => _deleteAttachment(), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/attachment/pending_attachment_alt.dart b/lib/widgets/attachment/pending_attachment_alt.dart index 4f56db0..299bf90 100644 --- a/lib/widgets/attachment/pending_attachment_alt.dart +++ b/lib/widgets/attachment/pending_attachment_alt.dart @@ -10,10 +10,12 @@ class PendingAttachmentAltDialog extends StatefulWidget { const PendingAttachmentAltDialog({super.key, required this.media}); @override - State createState() => _PendingAttachmentAltDialogState(); + State createState() => + _PendingAttachmentAltDialogState(); } -class _PendingAttachmentAltDialogState extends State { +class _PendingAttachmentAltDialogState + extends State { final _contentController = TextEditingController(); @override @@ -63,7 +65,7 @@ class _PendingAttachmentAltDialogState extends State controller: _contentController, decoration: InputDecoration( labelText: 'fieldAttachmentAlt'.tr(), - border: const UnderlineInputBorder(), + border: const OutlineInputBorder(), ), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), @@ -71,9 +73,11 @@ class _PendingAttachmentAltDialogState extends State ), actions: [ TextButton( - onPressed: _isBusy ? null : () { - Navigator.pop(context); - }, + onPressed: _isBusy + ? null + : () { + Navigator.pop(context); + }, child: Text('dialogDismiss'.tr()), ), TextButton( diff --git a/lib/widgets/attachment/pending_attachment_boost.dart b/lib/widgets/attachment/pending_attachment_boost.dart index 7fc32a4..3c7c137 100644 --- a/lib/widgets/attachment/pending_attachment_boost.dart +++ b/lib/widgets/attachment/pending_attachment_boost.dart @@ -14,10 +14,12 @@ class PendingAttachmentBoostDialog extends StatefulWidget { const PendingAttachmentBoostDialog({super.key, required this.media}); @override - State createState() => _PendingAttachmentBoostDialogState(); + State createState() => + _PendingAttachmentBoostDialogState(); } -class _PendingAttachmentBoostDialogState extends State { +class _PendingAttachmentBoostDialogState + extends State { List? _regions; SnAttachmentDestination? _selectedRegion; @@ -84,17 +86,23 @@ class _PendingAttachmentBoostDialogState extends State _selectedRegion = value); + if (value != null) { + setState(() => _selectedRegion = value); + } }, ); }, @@ -105,9 +113,11 @@ class _PendingAttachmentBoostDialogState extends State _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(), - pool: 'interactive', - analyzeNow: true, - ), - ); - if (thumbnail == null) return; - if (!context.mounted) return; - - try { - final attach = context.read(); - final newAttach = await attach.updateOne( - attachments[idx].attachment!, - thumbnailId: thumbnail.id, - ); - onUpdate!(idx, PostWriteMedia(newAttach)); - } catch (err) { - if (!context.mounted) return; - context.showErrorDialog(err); - } - } - - Future _deleteAttachment(BuildContext context, int idx) async { - final media = 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); - } - } - - Future _createBoost(BuildContext context, int idx) async { - if (attachments[idx].attachment == null) return; - - final result = await showDialog( - context: context, - builder: (context) => - PendingAttachmentBoostDialog(media: attachments[idx]), - ); - if (result == null) return; - - final newAttach = attachments[idx].attachment!.copyWith( - boosts: [...attachments[idx].attachment!.boosts, result], - ); - final newMedia = PostWriteMedia(newAttach); - - onUpdate!(idx, newMedia); - } - - Future _compressVideo(BuildContext context, int idx) async { - final result = await showDialog( - context: context, - builder: (context) => PendingVideoCompressDialog(media: attachments[idx]), - ); - if (result == null) return; - - onUpdate!(idx, result); - } - - Future _setAlt(BuildContext context, int idx) async { - final result = await showDialog( - context: context, - builder: (context) => PendingAttachmentAltDialog(media: attachments[idx]), - ); - if (result == null) return; - - onUpdate!(idx, PostWriteMedia(result)); - } - - ContextMenu _createContextMenu( - BuildContext context, int idx, PostWriteMedia media) { - final canCompressVideo = - !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); - return ContextMenu( - entries: [ - if (media.attachment == null && - media.type == SnMediaType.video && - canCompressVideo) - MenuItem( - label: 'attachmentCompressVideo'.tr(), - icon: Symbols.compress, - onSelected: () { - _compressVideo(context, idx); - }, - ), - if (media.attachment != null) - MenuItem( - label: 'attachmentSetAlt'.tr(), - icon: Symbols.description, - onSelected: () { - _setAlt(context, idx); - }, - ), - if (media.attachment != null) - MenuItem( - label: 'attachmentBoost'.tr(), - icon: Symbols.bolt, - onSelected: () { - _createBoost(context, idx); - }, - ), - if (media.attachment != null && media.type == SnMediaType.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 && onInsertLink != null) - MenuItem( - label: 'attachmentInsertLink'.tr(), - icon: Symbols.add_link, - onSelected: () { - onInsertLink!(idx); - }, - ), - if (media.type == SnMediaType.image && media.attachment != null) - MenuItem( - label: 'preview'.tr(), - icon: Symbols.preview, - onSelected: () { - context.pushTransparentRoute( - AttachmentZoomView(data: [media.attachment!]), - rootNavigator: true, - ); - }, - ), - if (media.type == SnMediaType.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) { return Container( @@ -287,9 +63,27 @@ class PostMediaPendingList extends StatelessWidget { itemCount: attachments.length, itemBuilder: (context, idx) { final media = attachments[idx]; - return ContextMenuArea( - contextMenu: _createContextMenu(context, idx, media), + return GestureDetector( child: _PostMediaPendingItem(media: media), + onTap: () { + showModalBottomSheet( + context: context, + builder: (context) => PendingAttachmentActionSheet( + media: media, + ), + ).then((value) async { + if (value is PostWriteMedia) { + await onUpdate!(idx, value); + } + if (value == 'link') { + onInsertLink!(idx); + } else if (value == false) { + onRemove!(idx); + } else if (value == true) { + onUpload!(idx); + } + }); + }, ); }, ), @@ -300,9 +94,7 @@ class PostMediaPendingList extends StatelessWidget { class _PostMediaPendingItem extends StatelessWidget { final PostWriteMedia media; - const _PostMediaPendingItem({ - required this.media, - }); + const _PostMediaPendingItem({required this.media}); @override Widget build(BuildContext context) { @@ -321,34 +113,36 @@ class _PostMediaPendingItem extends StatelessWidget { AspectRatio( aspectRatio: 1, child: switch (media.type) { - SnMediaType.image => - LayoutBuilder(builder: (context, constraints) { - return Image( - image: media.getImageProvider( - context, - width: - (constraints.maxWidth * devicePixelRatio).round(), - height: - (constraints.maxHeight * devicePixelRatio).round(), - )!, - fit: BoxFit.contain, - ); - }), + SnMediaType.image => LayoutBuilder( + builder: (context, constraints) { + return Image( + image: media.getImageProvider( + context, + width: + (constraints.maxWidth * devicePixelRatio).round(), + height: (constraints.maxHeight * devicePixelRatio) + .round(), + )!, + fit: BoxFit.contain, + ); + }, + ), SnMediaType.video => Stack( fit: StackFit.expand, children: [ if (media.attachment?.thumbnail != null) AutoResizeUniversalImage(sn.getAttachmentUrl( media.attachment!.thumbnail!.rid)), - const Icon(Symbols.videocam, - color: Colors.white, - shadows: [ - Shadow( + const Icon( + Symbols.videocam, + color: Colors.white, + shadows: [ + Shadow( offset: Offset(1, 1), blurRadius: 8.0, - color: Color.fromARGB(255, 0, 0, 0), - ), - ]), + color: Color.fromARGB(255, 0, 0, 0)) + ], + ), ], ), SnMediaType.audio => Stack( @@ -357,15 +151,16 @@ class _PostMediaPendingItem extends StatelessWidget { if (media.attachment?.thumbnail != null) AutoResizeUniversalImage(sn.getAttachmentUrl( media.attachment!.thumbnail!.rid)), - const Icon(Symbols.audio_file, - color: Colors.white, - shadows: [ - Shadow( + const Icon( + Symbols.audio_file, + color: Colors.white, + shadows: [ + Shadow( offset: Offset(1, 1), blurRadius: 8.0, - color: Color.fromARGB(255, 0, 0, 0), - ), - ]), + color: Color.fromARGB(255, 0, 0, 0)) + ], + ), ], ), _ => Container( @@ -387,11 +182,8 @@ class _PostMediaPendingItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (media.attachment != null) - Text( - media.attachment!.alt, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) + Text(media.attachment!.alt, + maxLines: 1, overflow: TextOverflow.ellipsis) else if (media.file != null) Text(media.file!.name, maxLines: 1, overflow: TextOverflow.ellipsis) @@ -468,11 +260,8 @@ class AddPostMediaButton extends StatelessWidget { final VisualDensity? visualDensity; final Function(Iterable) onAdd; - const AddPostMediaButton({ - super.key, - required this.onAdd, - this.visualDensity, - }); + const AddPostMediaButton( + {super.key, required this.onAdd, this.visualDensity}); void _takeMedia(bool isVideo) async { final picker = ImagePicker(); @@ -487,17 +276,13 @@ class AddPostMediaButton extends StatelessWidget { final picker = ImagePicker(); final result = await picker.pickMultipleMedia(); if (result.isEmpty) return; - onAdd( - result.map((e) => PostWriteMedia.fromFile(e)), - ); + onAdd(result.map((e) => PostWriteMedia.fromFile(e))); } void _selectFile() async { final result = await FilePicker.platform.pickFiles(type: FileType.any); if (result == null) return; - onAdd( - result.files.map((e) => PostWriteMedia.fromFile(e.xFile)), - ); + onAdd(result.files.map((e) => PostWriteMedia.fromFile(e.xFile))); } void _pasteMedia() async { @@ -505,10 +290,7 @@ class AddPostMediaButton extends StatelessWidget { if (imageBytes == null) return; onAdd([ PostWriteMedia.fromBytes( - imageBytes, - 'attachmentPastedImage'.tr(), - SnMediaType.image, - ), + imageBytes, 'attachmentPastedImage'.tr(), SnMediaType.image) ]); } @@ -556,19 +338,15 @@ class AddPostMediaButton extends StatelessWidget { final attach = context.read(); final attachment = await attach.getOne(randomId); - onAdd([ - PostWriteMedia(attachment), - ]); + onAdd([PostWriteMedia(attachment)]); } @override Widget build(BuildContext context) { return PopupMenuButton( style: ButtonStyle(visualDensity: visualDensity), - icon: Icon( - Symbols.add_photo_alternate, - color: Theme.of(context).colorScheme.primary, - ), + icon: Icon(Symbols.add_photo_alternate, + color: Theme.of(context).colorScheme.primary), itemBuilder: (context) => [ if (!kIsWeb && !Platform.isLinux && @@ -595,7 +373,7 @@ class AddPostMediaButton extends StatelessWidget { children: [ const Icon(Symbols.videocam), const Gap(16), - Text('addAttachmentFromCameraVideo').tr(), + Text('addAttachmentFromCameraVideo').tr() ], ), onTap: () { @@ -607,7 +385,7 @@ class AddPostMediaButton extends StatelessWidget { children: [ const Icon(Symbols.photo_library), const Gap(16), - Text('addAttachmentFromAlbum').tr(), + Text('addAttachmentFromAlbum').tr() ], ), onTap: () { @@ -619,7 +397,7 @@ class AddPostMediaButton extends StatelessWidget { children: [ const Icon(Symbols.file_upload), const Gap(16), - Text('addAttachmentFromFiles').tr(), + Text('addAttachmentFromFiles').tr() ], ), onTap: () { @@ -627,13 +405,11 @@ class AddPostMediaButton extends StatelessWidget { }, ), PopupMenuItem( - child: Row( - children: [ - const Icon(Symbols.link), - const Gap(16), - Text('addAttachmentFromRandomId').tr(), - ], - ), + child: Row(children: [ + const Icon(Symbols.link), + const Gap(16), + Text('addAttachmentFromRandomId').tr() + ]), onTap: () { _linkRandomId(context); }, @@ -643,7 +419,7 @@ class AddPostMediaButton extends StatelessWidget { children: [ const Icon(Symbols.content_paste), const Gap(16), - Text('addAttachmentFromClipboard').tr(), + Text('addAttachmentFromClipboard').tr() ], ), onTap: () {