Croppable post image

This commit is contained in:
LittleSheep 2024-11-11 21:48:50 +08:00
parent f23ffe61f5
commit b166a6e85c
8 changed files with 185 additions and 94 deletions

View File

@ -34,6 +34,9 @@
"preview": "Preview", "preview": "Preview",
"loading": "Loading...", "loading": "Loading...",
"delete": "Delete", "delete": "Delete",
"unlink": "Unlink",
"crop": "Crop",
"compress": "Compress",
"report": "Report", "report": "Report",
"repost": "Repost", "repost": "Repost",
"reply": "Reply", "reply": "Reply",

View File

@ -34,6 +34,9 @@
"create": "创建", "create": "创建",
"preview": "预览", "preview": "预览",
"delete": "删除", "delete": "删除",
"unlink": "解除链接",
"crop": "裁剪",
"compress": "压缩",
"report": "检举", "report": "检举",
"repost": "转帖", "repost": "转帖",
"reply": "回贴", "reply": "回贴",

View File

@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
@ -47,7 +48,10 @@ class PostWriteMedia {
PostWriteMedia.fromFile(this.file, {this.attachment, this.raw}) { PostWriteMedia.fromFile(this.file, {this.attachment, this.raw}) {
name = file!.name; name = file!.name;
switch (file?.mimeType?.split('/').firstOrNull) { String? mimetype = file!.mimeType;
mimetype ??= lookupMimeType(file!.path);
switch (mimetype?.split('/').firstOrNull) {
case 'image': case 'image':
type = PostWriteMediaType.image; type = PostWriteMediaType.image;
break; break;
@ -94,7 +98,17 @@ class PostWriteMedia {
}) { }) {
if (attachment != null) { if (attachment != null) {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
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) { } else if (file != null) {
final ImageProvider provider = final ImageProvider provider =
kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path)); kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
@ -324,6 +338,11 @@ class PostWriteController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setAttachmentAt(int idx, PostWriteMedia item) {
attachments[idx] = item;
notifyListeners();
}
void removeAttachmentAt(int idx) { void removeAttachmentAt(int idx) {
attachments.removeAt(idx); attachments.removeAt(idx);
notifyListeners(); notifyListeners();

View File

@ -1,4 +1,5 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@ -165,7 +166,10 @@ class SnAttachmentProvider {
for (final entry in chunks.entries) { for (final entry in chunks.entries) {
queue.add(() async { queue.add(() async {
final beginCursor = entry.value * chunkSize; final beginCursor = entry.value * chunkSize;
final endCursor = (entry.value + 1) * chunkSize; final endCursor = math.min<int>(
(entry.value + 1) * chunkSize,
await file.length(),
);
final data = Uint8List.fromList(await file final data = Uint8List.fromList(await file
.openRead(beginCursor, endCursor) .openRead(beginCursor, endCursor)
.expand((chunk) => chunk) .expand((chunk) => chunk)

View File

@ -343,10 +343,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
), ),
if (_writeController.attachments.isNotEmpty) if (_writeController.attachments.isNotEmpty)
PostMediaPendingList( PostMediaPendingList(
data: _writeController.attachments, controller: _writeController,
onRemove: (idx) {
_writeController.removeAttachmentAt(idx);
},
).padding(bottom: 8), ).padding(bottom: 8),
Material( Material(
elevation: 2, elevation: 2,

View File

@ -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:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart'; import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/widgets/attachment/attachment_detail.dart';
class PostMediaPendingList extends StatelessWidget { class PostMediaPendingList extends StatelessWidget {
final List<PostWriteMedia> data; final PostWriteController controller;
final Function(int idx)? onRemove; const PostMediaPendingList({super.key, required this.controller});
const PostMediaPendingList({
super.key, void _cropImage(BuildContext context, int idx) async {
required this.data, final media = controller.attachments[idx];
this.onRemove, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container( return ListenableBuilder(
constraints: const BoxConstraints(maxHeight: 120), listenable: controller,
child: ListView.separated( builder: (context, _) {
scrollDirection: Axis.horizontal, return Container(
padding: const EdgeInsets.symmetric(horizontal: 8), constraints: const BoxConstraints(maxHeight: 120),
separatorBuilder: (context, index) => const Gap(8), child: ListView.separated(
itemCount: data.length, scrollDirection: Axis.horizontal,
itemBuilder: (context, idx) { padding: const EdgeInsets.symmetric(horizontal: 8),
final file = data[idx]; separatorBuilder: (context, index) => const Gap(8),
return ContextMenuRegion( itemCount: controller.attachments.length,
contextMenu: ContextMenu( itemBuilder: (context, idx) {
entries: [ final media = controller.attachments[idx];
if (onRemove != null) return ContextMenuRegion(
MenuItem( contextMenu: ContextMenu(
label: 'delete'.tr(), entries: [
icon: Symbols.delete, if (media.type == PostWriteMediaType.image &&
onSelected: () { media.attachment != null)
onRemove!(idx); MenuItem(
}, label: 'preview'.tr(),
), icon: Symbols.preview,
], onSelected: () {
), context.pushTransparentRoute(
child: Container( AttachmentDetailPopup(data: media.attachment!),
decoration: BoxDecoration( rootNavigator: true,
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(),
), ),
}, 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(),
),
},
),
),
),
);
},
),
);
},
); );
} }
} }

View File

@ -5,23 +5,23 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "72.0.0" version: "76.0.0"
_macros: _macros:
dependency: transitive dependency: transitive
description: dart description: dart
source: sdk source: sdk
version: "0.3.2" version: "0.3.3"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.7.0" version: "6.11.0"
animations: animations:
dependency: "direct main" dependency: "direct main"
description: description:
@ -202,10 +202,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: collection name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.19.0"
connectivity_plus: connectivity_plus:
dependency: transitive dependency: transitive
description: description:
@ -790,18 +790,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.5" version: "10.0.8"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.5" version: "3.0.9"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@ -838,10 +838,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: macros name: macros
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.2-main.4" version: "0.1.3-main.0"
markdown: markdown:
dependency: "direct main" dependency: "direct main"
description: description:
@ -883,7 +883,7 @@ packages:
source: hosted source: hosted
version: "1.15.0" version: "1.15.0"
mime: mime:
dependency: transitive dependency: "direct main"
description: description:
name: mime name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
@ -1150,7 +1150,7 @@ packages:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.0"
source_gen: source_gen:
dependency: transitive dependency: transitive
description: description:
@ -1227,10 +1227,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.1" version: "1.12.0"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
@ -1251,10 +1251,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: string_scanner name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.3.0"
styled_widget: styled_widget:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1291,10 +1291,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" version: "0.7.3"
timing: timing:
dependency: transitive dependency: transitive
description: description:
@ -1411,10 +1411,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.5" version: "14.3.0"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:

View File

@ -72,6 +72,7 @@ dependencies:
shared_preferences: ^2.3.3 shared_preferences: ^2.3.3
path_provider: ^2.1.5 path_provider: ^2.1.5
collection: ^1.18.0 collection: ^1.18.0
mime: ^2.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: