From b166a6e85ca23605696e143d805980100109941d Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 11 Nov 2024 21:48:50 +0800 Subject: [PATCH] :sparkles: Croppable post image --- assets/translations/en-US.json | 3 + assets/translations/zh-CN.json | 3 + lib/controllers/post_write_controller.dart | 23 ++- lib/providers/sn_attachment.dart | 6 +- lib/screens/post/post_editor.dart | 5 +- lib/widgets/post/post_media_pending_list.dart | 192 ++++++++++++------ pubspec.lock | 46 ++--- pubspec.yaml | 1 + 8 files changed, 185 insertions(+), 94 deletions(-) diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 4274881..7b712fb 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -34,6 +34,9 @@ "preview": "Preview", "loading": "Loading...", "delete": "Delete", + "unlink": "Unlink", + "crop": "Crop", + "compress": "Compress", "report": "Report", "repost": "Repost", "reply": "Reply", diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 182273e..74fe9b9 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -34,6 +34,9 @@ "create": "创建", "preview": "预览", "delete": "删除", + "unlink": "解除链接", + "crop": "裁剪", + "compress": "压缩", "report": "检举", "repost": "转帖", "reply": "回贴", diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart index e66a6b5..0877a07 100644 --- a/lib/controllers/post_write_controller.dart +++ b/lib/controllers/post_write_controller.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; import 'package:provider/provider.dart'; import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_network.dart'; @@ -47,7 +48,10 @@ class PostWriteMedia { PostWriteMedia.fromFile(this.file, {this.attachment, this.raw}) { name = file!.name; - switch (file?.mimeType?.split('/').firstOrNull) { + String? mimetype = file!.mimeType; + mimetype ??= lookupMimeType(file!.path); + + switch (mimetype?.split('/').firstOrNull) { case 'image': type = PostWriteMediaType.image; break; @@ -94,7 +98,17 @@ class PostWriteMedia { }) { if (attachment != null) { final sn = context.read(); - return UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); + final ImageProvider provider = + UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); + if (width != null && height != null) { + return ResizeImage( + provider, + width: width, + height: height, + policy: ResizeImagePolicy.fit, + ); + } + return provider; } else if (file != null) { final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path)); @@ -324,6 +338,11 @@ class PostWriteController extends ChangeNotifier { notifyListeners(); } + void setAttachmentAt(int idx, PostWriteMedia item) { + attachments[idx] = item; + notifyListeners(); + } + void removeAttachmentAt(int idx) { attachments.removeAt(idx); notifyListeners(); diff --git a/lib/providers/sn_attachment.dart b/lib/providers/sn_attachment.dart index 2323e07..67cefbc 100644 --- a/lib/providers/sn_attachment.dart +++ b/lib/providers/sn_attachment.dart @@ -1,4 +1,5 @@ import 'dart:collection'; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:dio/dio.dart'; @@ -165,7 +166,10 @@ class SnAttachmentProvider { for (final entry in chunks.entries) { queue.add(() async { final beginCursor = entry.value * chunkSize; - final endCursor = (entry.value + 1) * chunkSize; + final endCursor = math.min( + (entry.value + 1) * chunkSize, + await file.length(), + ); final data = Uint8List.fromList(await file .openRead(beginCursor, endCursor) .expand((chunk) => chunk) diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index 8910079..314e815 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -343,10 +343,7 @@ class _PostEditorScreenState extends State { ), if (_writeController.attachments.isNotEmpty) PostMediaPendingList( - data: _writeController.attachments, - onRemove: (idx) { - _writeController.removeAttachmentAt(idx); - }, + controller: _writeController, ).padding(bottom: 8), Material( elevation: 2, diff --git a/lib/widgets/post/post_media_pending_list.dart b/lib/widgets/post/post_media_pending_list.dart index ff4e5ce..f46ccb6 100644 --- a/lib/widgets/post/post_media_pending_list.dart +++ b/lib/widgets/post/post_media_pending_list.dart @@ -1,83 +1,147 @@ +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_context_menu/flutter_context_menu.dart'; import 'package:gap/gap.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/controllers/post_write_controller.dart'; +import 'package:surface/widgets/attachment/attachment_detail.dart'; class PostMediaPendingList extends StatelessWidget { - final List data; - final Function(int idx)? onRemove; - const PostMediaPendingList({ - super.key, - required this.data, - this.onRemove, - }); + final PostWriteController controller; + const PostMediaPendingList({super.key, required this.controller}); + + void _cropImage(BuildContext context, int idx) async { + final media = controller.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; + if (!context.mounted) return; + + final rawBytes = + (await result.uiImage.toByteData(format: ImageByteFormat.png))! + .buffer + .asUint8List(); + controller.setAttachmentAt( + idx, + PostWriteMedia.fromBytes(rawBytes, media.name, media.type), + ); + } @override Widget build(BuildContext context) { final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; - return Container( - constraints: const BoxConstraints(maxHeight: 120), - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - separatorBuilder: (context, index) => const Gap(8), - itemCount: data.length, - itemBuilder: (context, idx) { - final file = data[idx]; - return ContextMenuRegion( - contextMenu: ContextMenu( - entries: [ - if (onRemove != null) - MenuItem( - label: 'delete'.tr(), - icon: Symbols.delete, - onSelected: () { - onRemove!(idx); - }, - ), - ], - ), - 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 (file.type) { - PostWriteMediaType.image => - LayoutBuilder(builder: (context, constraints) { - return Image( - image: file.getImageProvider( - context, - width: (constraints.maxWidth * devicePixelRatio) - .round(), - height: (constraints.maxHeight * devicePixelRatio) - .round(), - )!, - fit: BoxFit.cover, - ); - }), - _ => Container( - color: Theme.of(context).colorScheme.surface, - child: const Icon(Symbols.docs).center(), + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + return Container( + constraints: const BoxConstraints(maxHeight: 120), + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + separatorBuilder: (context, index) => const Gap(8), + itemCount: controller.attachments.length, + itemBuilder: (context, idx) { + final media = controller.attachments[idx]; + return ContextMenuRegion( + contextMenu: ContextMenu( + entries: [ + if (media.type == PostWriteMediaType.image && + media.attachment != null) + MenuItem( + label: 'preview'.tr(), + icon: Symbols.preview, + onSelected: () { + context.pushTransparentRoute( + AttachmentDetailPopup(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: 'delete'.tr(), + icon: Symbols.delete, + onSelected: () { + controller.removeAttachmentAt(idx); + }, + ) + else + MenuItem( + label: 'unlink'.tr(), + icon: Symbols.link_off, + onSelected: () { + controller.removeAttachmentAt(idx); + }, + ), + ], ), - ), - ), - ); - }, - ), + 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 => + LayoutBuilder(builder: (context, constraints) { + return Image( + image: media.getImageProvider( + context, + width: (constraints.maxWidth * devicePixelRatio) + .round(), + height: + (constraints.maxHeight * devicePixelRatio) + .round(), + )!, + fit: BoxFit.cover, + ); + }), + _ => Container( + color: Theme.of(context).colorScheme.surface, + child: const Icon(Symbols.docs).center(), + ), + }, + ), + ), + ), + ); + }, + ), + ); + }, ); } } diff --git a/pubspec.lock b/pubspec.lock index 188d160..f254b06 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "72.0.0" + version: "76.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "6.11.0" animations: dependency: "direct main" description: @@ -202,10 +202,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" connectivity_plus: dependency: transitive description: @@ -790,18 +790,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -838,10 +838,10 @@ packages: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" markdown: dependency: "direct main" description: @@ -883,7 +883,7 @@ packages: source: hosted version: "1.15.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" @@ -1150,7 +1150,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: @@ -1227,10 +1227,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" stream_channel: dependency: transitive description: @@ -1251,10 +1251,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" styled_widget: dependency: "direct main" description: @@ -1291,10 +1291,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" timing: dependency: transitive description: @@ -1411,10 +1411,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bf392cc..e296054 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: shared_preferences: ^2.3.3 path_provider: ^2.1.5 collection: ^1.18.0 + mime: ^2.0.0 dev_dependencies: flutter_test: