From d74fc5ab447629f4b9830b6252af204373a78cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=88=E7=A5=A5?= Date: Mon, 18 May 2026 14:14:19 +0800 Subject: [PATCH] feat: add gallery image picker --- .../com/example/luban_imager/MainActivity.kt | 62 ++++- ios/Runner/SceneDelegate.swift | 96 ++++++- lib/main.dart | 255 +++++++++++++++++- test/widget_test.dart | 6 +- 4 files changed, 399 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/kotlin/com/example/luban_imager/MainActivity.kt b/android/app/src/main/kotlin/com/example/luban_imager/MainActivity.kt index 7d9c354..a7bda69 100644 --- a/android/app/src/main/kotlin/com/example/luban_imager/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/luban_imager/MainActivity.kt @@ -25,6 +25,7 @@ import top.zibin.luban.api.OnCompressListener class MainActivity : FlutterActivity() { private val channelName = "luban_imager/native_images" private val pickImagesRequest = 4101 + private val pickAlbumImageRequest = 4103 private var channel: MethodChannel? = null private var pendingPickResult: MethodChannel.Result? = null @@ -36,6 +37,7 @@ class MainActivity : FlutterActivity() { channel?.setMethodCallHandler { call, result -> when (call.method) { "pickImages" -> pickImages(result) + "pickAlbumImage" -> pickAlbumImage(result) "takeSharedImages" -> takeSharedImages(result) "compressImage" -> compressImage(call.arguments, result) "overwriteOriginal" -> overwriteOriginal(call.arguments, result) @@ -58,6 +60,7 @@ class MainActivity : FlutterActivity() { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { pickImagesRequest -> finishPickImages(resultCode, data) + pickAlbumImageRequest -> finishPickAlbumImage(resultCode, data) } } @@ -84,6 +87,33 @@ class MainActivity : FlutterActivity() { } } + private fun pickAlbumImage(result: MethodChannel.Result) { + if (pendingPickResult != null) { + result.error("busy", "正在选择图片", null) + return + } + + pendingPickResult = result + val intent = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Intent(MediaStore.ACTION_PICK_IMAGES).apply { + type = "image/*" + } + } else { + Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI).apply { + type = "image/*" + } + }).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + try { + startActivityForResult(intent, pickAlbumImageRequest) + } catch (error: Exception) { + pendingPickResult = null + result.error("gallery_unavailable", error.message, null) + } + } + private fun finishPickImages(resultCode: Int, data: Intent?) { val result = pendingPickResult ?: return pendingPickResult = null @@ -119,6 +149,28 @@ class MainActivity : FlutterActivity() { return data.data ?: data.clipData?.getItemAt(0)?.uri } + private fun finishPickAlbumImage(resultCode: Int, data: Intent?) { + val result = pendingPickResult ?: return + pendingPickResult = null + + if (resultCode != Activity.RESULT_OK || data == null) { + result.success(emptyList>()) + return + } + + try { + val uri = extractUri(data) + val payload = if (uri == null) { + emptyList() + } else { + listOf(buildGalleryImage(uri)) + } + result.success(payload) + } catch (error: Exception) { + result.error("gallery_pick_failed", error.message, null) + } + } + private fun takeSharedImages(result: MethodChannel.Result) { val images = pendingSharedImages.toList() pendingSharedImages.clear() @@ -169,8 +221,16 @@ class MainActivity : FlutterActivity() { } private fun buildSharedImage(uri: Uri): Map { + return buildCachedReadOnlyImage(uri, "shared-originals") + } + + private fun buildGalleryImage(uri: Uri): Map { + return buildCachedReadOnlyImage(uri, "gallery-originals") + } + + private fun buildCachedReadOnlyImage(uri: Uri, child: String): Map { val displayName = queryDisplayName(uri) ?: "image" - val previewFile = copyUriToCache(uri, displayName, "shared-originals") + val previewFile = copyUriToCache(uri, displayName, child) val dimensions = readDimensionsFromFile(previewFile) val originalSize = previewFile.length() diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index e127ed8..4f56a97 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -1,6 +1,7 @@ import Flutter import ImageIO import Photos +import PhotosUI import UniformTypeIdentifiers import UIKit @@ -29,7 +30,8 @@ class SceneDelegate: FlutterSceneDelegate { private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { private enum PendingOperation { - case pick(FlutterResult) + case pickFile(FlutterResult) + case pickAlbum(FlutterResult) } private weak var controller: FlutterViewController? @@ -57,6 +59,8 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { switch call.method { case "pickImages": pickImages(result: result) + case "pickAlbumImage": + pickAlbumImage(result: result) case "takeSharedImages": takeSharedImages(result: result) case "compressImage": @@ -117,7 +121,27 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { } picker.allowsMultipleSelection = false picker.delegate = self - pendingOperation = .pick(result) + pendingOperation = .pickFile(result) + controller?.present(picker, animated: true) + } + + private func pickAlbumImage(result: @escaping FlutterResult) { + guard pendingOperation == nil else { + result(FlutterError(code: "busy", message: "正在选择图片", details: nil)) + return + } + + guard #available(iOS 14.0, *) else { + pickImages(result: result) + return + } + + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = 1 + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + pendingOperation = .pickAlbum(result) controller?.present(picker, animated: true) } @@ -319,7 +343,7 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { pendingOperation = nil switch operation { - case .pick(let result): + case .pickFile(let result): do { if let url = urls.first { result([try buildPickedImage(sourceURL: url)]) @@ -331,6 +355,8 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { FlutterError(code: "pick_failed", message: error.localizedDescription, details: nil) ) } + case .pickAlbum(let result): + result([]) } } @@ -341,11 +367,60 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { pendingOperation = nil switch operation { - case .pick(let result): + case .pickFile(let result), .pickAlbum(let result): result([]) } } + @available(iOS 14.0, *) + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + + guard let operation = pendingOperation else { + return + } + pendingOperation = nil + + guard case .pickAlbum(let result) = operation else { + return + } + guard let provider = results.first?.itemProvider else { + result([]) + return + } + + provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { [weak self] url, error in + guard let self = self else { + return + } + + if let error = error { + DispatchQueue.main.async { + result(FlutterError(code: "gallery_pick_failed", message: error.localizedDescription, details: nil)) + } + return + } + + guard let url = url else { + DispatchQueue.main.async { + result([]) + } + return + } + + do { + let payload = try self.buildGalleryImage(sourceURL: url) + DispatchQueue.main.async { + result([payload]) + } + } catch { + DispatchQueue.main.async { + result(FlutterError(code: "gallery_pick_failed", message: error.localizedDescription, details: nil)) + } + } + } + } + private func buildPickedImage(sourceURL: URL) throws -> [String: Any] { let scoped = sourceURL.startAccessingSecurityScopedResource() defer { @@ -379,6 +454,14 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { } private func buildSharedImage(sourceURL: URL) throws -> [String: Any] { + return try buildCachedReadOnlyImage(sourceURL: sourceURL, child: "shared-originals") + } + + private func buildGalleryImage(sourceURL: URL) throws -> [String: Any] { + return try buildCachedReadOnlyImage(sourceURL: sourceURL, child: "gallery-originals") + } + + private func buildCachedReadOnlyImage(sourceURL: URL, child: String) throws -> [String: Any] { let scoped = sourceURL.startAccessingSecurityScopedResource() defer { if scoped { @@ -391,7 +474,7 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { : sourceURL.lastPathComponent let previewURL = try copyToCache( sourceURL: sourceURL, - child: "shared-originals", + child: child, preferredName: displayName ) let id = UUID().uuidString @@ -627,6 +710,9 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { } } +@available(iOS 14.0, *) +extension NativeImageBridge: PHPickerViewControllerDelegate {} + private struct PixelSize { let width: Int let height: Int diff --git a/lib/main.dart b/lib/main.dart index e3451de..7ae83b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -101,6 +101,7 @@ class _ImagerHomePageState extends State { ImageJob? _job; var _previewMode = PreviewMode.original; var _compareFraction = 0.5; + var _comparePreviewScale = 1.0; var _isPicking = false; OverlayEntry? _toastEntry; Timer? _toastTimer; @@ -170,6 +171,7 @@ class _ImagerHomePageState extends State { _job = ImageJob(image); _previewMode = PreviewMode.original; _compareFraction = 0.5; + _comparePreviewScale = 1.0; }); if (message != null) { _showMessage(message); @@ -177,7 +179,18 @@ class _ImagerHomePageState extends State { await _compressSelected(); } - Future _pickImages() async { + Future _pickFiles() async { + await _pickNativeImage(method: 'pickImages', errorMessage: '选择文件失败'); + } + + Future _pickAlbumImage() async { + await _pickNativeImage(method: 'pickAlbumImage', errorMessage: '选择相册图片失败'); + } + + Future _pickNativeImage({ + required String method, + required String errorMessage, + }) async { if (_isPicking) { return; } @@ -188,7 +201,7 @@ class _ImagerHomePageState extends State { }); try { - final response = await _channel.invokeMethod>('pickImages'); + final response = await _channel.invokeMethod>(method); final images = response ?? []; if (!mounted || images.isEmpty) { @@ -199,7 +212,7 @@ class _ImagerHomePageState extends State { Map.from(images.first as Map), ); } on PlatformException catch (error) { - _showMessage(error.message ?? '选择图片失败'); + _showMessage(error.message ?? errorMessage); } finally { if (mounted) { setState(() { @@ -252,6 +265,7 @@ class _ImagerHomePageState extends State { ..isCompressing = false ..overwritten = false; _previewMode = PreviewMode.compare; + _comparePreviewScale = 1.0; }); _showMessage( compressed.passthrough @@ -394,6 +408,16 @@ class _ImagerHomePageState extends State { _job = null; _previewMode = PreviewMode.original; _compareFraction = 0.5; + _comparePreviewScale = 1.0; + }); + } + + void _handleCompareScaleChanged(double scale) { + if (!mounted || (_comparePreviewScale - scale).abs() < 0.01) { + return; + } + setState(() { + _comparePreviewScale = scale; }); } @@ -478,9 +502,17 @@ class _ImagerHomePageState extends State { Padding( padding: const EdgeInsets.only(right: 8), child: TextButton.icon( - onPressed: _isPicking ? null : _pickImages, - icon: const Icon(Icons.add_photo_alternate_outlined), - label: const Text('重新选择'), + onPressed: _isPicking ? null : _pickFiles, + icon: const Icon(Icons.insert_drive_file_outlined), + label: const Text('选择文件'), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: TextButton.icon( + onPressed: _isPicking ? null : _pickAlbumImage, + icon: const Icon(Icons.photo_library_outlined), + label: const Text('相册'), ), ), ], @@ -505,16 +537,28 @@ class _ImagerHomePageState extends State { ), const SizedBox(height: 20), Text( - '选择图片开始压缩', + '选择文件或相册图片开始压缩', style: Theme.of( context, ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 16), - FilledButton.icon( - onPressed: _isPicking ? null : _pickImages, - icon: const Icon(Icons.add_photo_alternate_outlined), - label: const Text('选择图片'), + Wrap( + spacing: 10, + runSpacing: 10, + alignment: WrapAlignment.center, + children: [ + FilledButton.icon( + onPressed: _isPicking ? null : _pickFiles, + icon: const Icon(Icons.insert_drive_file_outlined), + label: const Text('选择文件'), + ), + FilledButton.tonalIcon( + onPressed: _isPicking ? null : _pickAlbumImage, + icon: const Icon(Icons.photo_library_outlined), + label: const Text('从相册选择'), + ), + ], ), ], ), @@ -653,10 +697,11 @@ class _ImagerHomePageState extends State { ), if (showCompareSlider) ...[ const SizedBox(height: 4), - Slider( + _CompareSlider( value: _compareFraction, min: 0.05, max: 0.95, + dragScale: _comparePreviewScale, onChanged: (value) { setState(() { _compareFraction = value; @@ -735,6 +780,7 @@ class _ImagerHomePageState extends State { Positioned(right: 12, top: 12, child: _PreviewBadge(label: '压缩')), ], ), + onScaleChanged: _handleCompareScaleChanged, child: LayoutBuilder( builder: (context, constraints) { return Stack( @@ -964,18 +1010,186 @@ class _MetricBlock extends StatelessWidget { } } +class _CompareSlider extends StatefulWidget { + const _CompareSlider({ + required this.value, + required this.min, + required this.max, + required this.dragScale, + required this.onChanged, + }); + + final double value; + final double min; + final double max; + final double dragScale; + final ValueChanged onChanged; + + @override + State<_CompareSlider> createState() => _CompareSliderState(); +} + +class _CompareSliderState extends State<_CompareSlider> { + static const _height = 48.0; + static const _horizontalPadding = 16.0; + static const _trackHeight = 4.0; + static const _thumbSize = 22.0; + + double? _dragValue; + + double _clampValue(double value) { + return value.clamp(widget.min, widget.max).toDouble(); + } + + double _valueFromLocalX(double localX, double trackWidth) { + final normal = (localX / trackWidth).clamp(0.0, 1.0).toDouble(); + return widget.min + normal * (widget.max - widget.min); + } + + void _jumpTo(double localX, double trackWidth) { + final value = _clampValue(_valueFromLocalX(localX, trackWidth)); + _dragValue = value; + widget.onChanged(value); + } + + void _handleDragStart(DragStartDetails details) { + _dragValue ??= widget.value; + } + + void _handleDragUpdate(DragUpdateDetails details, double trackWidth) { + if (trackWidth <= 0) { + return; + } + + final scale = math.max(1.0, widget.dragScale); + final range = widget.max - widget.min; + final delta = (details.primaryDelta ?? details.delta.dx) / trackWidth; + final nextValue = _clampValue( + (_dragValue ?? widget.value) + delta * range / scale, + ); + _dragValue = nextValue; + widget.onChanged(nextValue); + } + + void _handleDragEnd(DragEndDetails details) { + _dragValue = null; + } + + void _handleDragCancel() { + _dragValue = null; + } + + void _step(double delta) { + widget.onChanged(_clampValue(widget.value + delta)); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Semantics( + label: '对比位置', + value: '${(widget.value * 100).round()}%', + onIncrease: () => _step(0.02), + onDecrease: () => _step(-0.02), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final trackWidth = math.max(1.0, width - _horizontalPadding * 2); + final normal = + ((widget.value - widget.min) / (widget.max - widget.min)) + .clamp(0.0, 1.0) + .toDouble(); + final thumbCenter = _horizontalPadding + trackWidth * normal; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + _jumpTo( + details.localPosition.dx - _horizontalPadding, + trackWidth, + ); + }, + onTapUp: (details) { + _dragValue = null; + }, + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: (details) { + _handleDragUpdate(details, trackWidth); + }, + onHorizontalDragEnd: _handleDragEnd, + onHorizontalDragCancel: _handleDragCancel, + child: SizedBox( + height: _height, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Positioned( + left: _horizontalPadding, + right: _horizontalPadding, + top: (_height - _trackHeight) / 2, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(99), + ), + child: const SizedBox(height: _trackHeight), + ), + ), + Positioned( + left: _horizontalPadding, + width: trackWidth * normal, + top: (_height - _trackHeight) / 2, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(99), + ), + child: const SizedBox(height: _trackHeight), + ), + ), + Positioned( + left: thumbCenter - _thumbSize / 2, + top: (_height - _thumbSize) / 2, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: const SizedBox.square(dimension: _thumbSize), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + class _ZoomablePreview extends StatefulWidget { const _ZoomablePreview({ required this.width, required this.height, required this.child, this.overlay, + this.onScaleChanged, }); final int width; final int height; final Widget child; final Widget? overlay; + final ValueChanged? onScaleChanged; @override State<_ZoomablePreview> createState() => _ZoomablePreviewState(); @@ -999,11 +1213,26 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> { if (oldWidget.width != widget.width || oldWidget.height != widget.height) { _resetTransform(); } + if (oldWidget.onScaleChanged != widget.onScaleChanged) { + _scheduleScaleChanged(); + } } void _resetTransform() { _scale = _minScale; _offset = Offset.zero; + _scheduleScaleChanged(); + } + + void _scheduleScaleChanged() { + if (widget.onScaleChanged == null) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + widget.onScaleChanged?.call(_scale); + } + }); } double _clampScale(double scale) { @@ -1044,6 +1273,7 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> { _scale = nextScale; _offset = _clampOffset(nextOffset, nextScale); }); + widget.onScaleChanged?.call(nextScale); } void _endGesture(ScaleEndDetails details) { @@ -1051,6 +1281,7 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> { _scale = _clampScale(_scale); _offset = _clampOffset(_offset, _scale); }); + widget.onScaleChanged?.call(_scale); } @override diff --git a/test/widget_test.dart b/test/widget_test.dart index 9ab86a4..7425f35 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -8,7 +8,8 @@ void main() { await tester.pumpWidget(const LubanImagerApp()); expect(find.text(AppName.english), findsOneWidget); - expect(find.text('选择图片'), findsWidgets); + expect(find.text('选择文件'), findsWidgets); + expect(find.text('从相册选择'), findsOneWidget); }); testWidgets('uses Chinese app name for Chinese locale', (tester) async { @@ -21,6 +22,7 @@ void main() { expect(find.text(AppName.chinese), findsOneWidget); expect(find.text(AppName.english), findsNothing); - expect(find.text('选择图片'), findsWidgets); + expect(find.text('选择文件'), findsWidgets); + expect(find.text('从相册选择'), findsOneWidget); }); }