diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index 4f56a97..5ea5425 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -1,7 +1,6 @@ import Flutter import ImageIO import Photos -import PhotosUI import UniformTypeIdentifiers import UIKit @@ -28,7 +27,7 @@ class SceneDelegate: FlutterSceneDelegate { } } -private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { +private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate { private enum PendingOperation { case pickFile(FlutterResult) case pickAlbum(FlutterResult) @@ -130,19 +129,22 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { result(FlutterError(code: "busy", message: "正在选择图片", details: nil)) return } - - guard #available(iOS 14.0, *) else { - pickImages(result: result) + guard let controller = controller else { + result(FlutterError(code: "picker_unavailable", message: "无法打开相册", details: nil)) + return + } + guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { + result(FlutterError(code: "picker_unavailable", message: "相册不可用", details: nil)) return } - var configuration = PHPickerConfiguration(photoLibrary: .shared()) - configuration.filter = .images - configuration.selectionLimit = 1 - let picker = PHPickerViewController(configuration: configuration) + let picker = UIImagePickerController() + picker.sourceType = .photoLibrary + picker.mediaTypes = ["public.image"] + picker.allowsEditing = false picker.delegate = self pendingOperation = .pickAlbum(result) - controller?.present(picker, animated: true) + controller.present(picker, animated: true) } private func compressImage(arguments: Any?, result: @escaping FlutterResult) { @@ -372,8 +374,10 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { } } - @available(iOS 14.0, *) - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { picker.dismiss(animated: true) guard let operation = pendingOperation else { @@ -384,40 +388,33 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { guard case .pickAlbum(let result) = operation else { return } - guard let provider = results.first?.itemProvider else { + + do { + if let url = info[.imageURL] as? URL { + result([try buildGalleryImage(sourceURL: url)]) + return + } + if let image = info[.originalImage] as? UIImage { + result([try buildGalleryImage(image: image)]) + return + } result([]) + } catch { + result(FlutterError(code: "gallery_pick_failed", message: error.localizedDescription, details: nil)) + } + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + + guard let operation = pendingOperation else { return } + pendingOperation = nil - 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)) - } - } + switch operation { + case .pickFile(let result), .pickAlbum(let result): + result([]) } } @@ -461,6 +458,26 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate { return try buildCachedReadOnlyImage(sourceURL: sourceURL, child: "gallery-originals") } + private func buildGalleryImage(image: UIImage) throws -> [String: Any] { + let hasAlpha = Self.hasAlpha(image) + let displayName = hasAlpha ? "album-image.png" : "album-image.jpg" + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "gallery-picker", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let targetURL = directory.appendingPathComponent("\(UUID().uuidString)-\(displayName)") + let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: 1) + guard let data else { + throw NativeImageError.message("无法读取相册图片") + } + try data.write(to: targetURL, options: .atomic) + return try buildGalleryImage(sourceURL: targetURL) + } + private func buildCachedReadOnlyImage(sourceURL: URL, child: String) throws -> [String: Any] { let scoped = sourceURL.startAccessingSecurityScopedResource() defer { @@ -710,9 +727,6 @@ 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 7ae83b3..67698e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -99,9 +99,9 @@ class _ImagerHomePageState extends State { ); ImageJob? _job; + ImagePickSource? _currentPickSource; var _previewMode = PreviewMode.original; var _compareFraction = 0.5; - var _comparePreviewScale = 1.0; var _isPicking = false; OverlayEntry? _toastEntry; Timer? _toastTimer; @@ -162,16 +162,20 @@ class _ImagerHomePageState extends State { ); } - Future _startImageJob(NativeImage image, {String? message}) async { + Future _startImageJob( + NativeImage image, { + String? message, + ImagePickSource? source, + }) async { if (!mounted) { return; } setState(() { _job = ImageJob(image); + _currentPickSource = source; _previewMode = PreviewMode.original; _compareFraction = 0.5; - _comparePreviewScale = 1.0; }); if (message != null) { _showMessage(message); @@ -180,16 +184,35 @@ class _ImagerHomePageState extends State { } Future _pickFiles() async { - await _pickNativeImage(method: 'pickImages', errorMessage: '选择文件失败'); + await _pickNativeImage( + method: 'pickImages', + errorMessage: '选择文件失败', + source: ImagePickSource.file, + ); } Future _pickAlbumImage() async { - await _pickNativeImage(method: 'pickAlbumImage', errorMessage: '选择相册图片失败'); + await _pickNativeImage( + method: 'pickAlbumImage', + errorMessage: '选择相册图片失败', + source: ImagePickSource.album, + ); + } + + Future _repickCurrentSource() async { + switch (_currentPickSource) { + case ImagePickSource.album: + await _pickAlbumImage(); + case ImagePickSource.file: + case null: + await _pickFiles(); + } } Future _pickNativeImage({ required String method, required String errorMessage, + required ImagePickSource source, }) async { if (_isPicking) { return; @@ -222,7 +245,7 @@ class _ImagerHomePageState extends State { } if (picked != null && mounted) { - await _startImageJob(picked); + await _startImageJob(picked, source: source); } } @@ -265,7 +288,6 @@ class _ImagerHomePageState extends State { ..isCompressing = false ..overwritten = false; _previewMode = PreviewMode.compare; - _comparePreviewScale = 1.0; }); _showMessage( compressed.passthrough @@ -297,7 +319,7 @@ class _ImagerHomePageState extends State { 'compressedPath': compressed.path, 'suggestedName': _suggestedName(job.original.displayName), }); - _showMessage('已保存到相册'); + _showMessage('已保存到系统相册'); } on PlatformException catch (error) { _showMessage(error.message ?? '保存失败'); } @@ -406,18 +428,9 @@ class _ImagerHomePageState extends State { void _clearImage() { setState(() { _job = null; + _currentPickSource = 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; }); } @@ -475,7 +488,7 @@ class _ImagerHomePageState extends State { }, ); overlay.insert(_toastEntry!); - _toastTimer = Timer(const Duration(seconds: 1), () { + _toastTimer = Timer(const Duration(milliseconds: 1500), () { _toastEntry?.remove(); _toastEntry = null; _toastTimer = null; @@ -502,17 +515,9 @@ class _ImagerHomePageState extends State { Padding( padding: const EdgeInsets.only(right: 8), child: TextButton.icon( - 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('相册'), + onPressed: _isPicking ? null : _repickCurrentSource, + icon: const Icon(Icons.add_photo_alternate_outlined), + label: const Text('重新选择'), ), ), ], @@ -537,7 +542,7 @@ class _ImagerHomePageState extends State { ), const SizedBox(height: 20), Text( - '选择文件或相册图片开始压缩', + '选择图片开始压缩', style: Theme.of( context, ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), @@ -556,7 +561,7 @@ class _ImagerHomePageState extends State { FilledButton.tonalIcon( onPressed: _isPicking ? null : _pickAlbumImage, icon: const Icon(Icons.photo_library_outlined), - label: const Text('从相册选择'), + label: const Text('相册选择'), ), ], ), @@ -683,25 +688,25 @@ class _ImagerHomePageState extends State { const SizedBox(height: 14), SizedBox( height: previewHeight, - child: DecoratedBox( + child: Container( + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: const Color(0xFFEDEFEA), borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFFDADFD8)), ), - child: Padding( - padding: const EdgeInsets.all(10), - child: _buildImagePreview(job, mode), - ), + child: _buildImagePreview(job, mode), ), ), if (showCompareSlider) ...[ const SizedBox(height: 4), _CompareSlider( value: _compareFraction, - min: 0.05, - max: 0.95, - dragScale: _comparePreviewScale, + min: 0.0, + max: 1.0, onChanged: (value) { setState(() { _compareFraction = value; @@ -774,33 +779,20 @@ class _ImagerHomePageState extends State { return _ZoomablePreview( width: job.original.width, height: job.original.height, - overlay: const Stack( - children: [ - Positioned(left: 12, top: 12, child: _PreviewBadge(label: '原图')), - Positioned(right: 12, top: 12, child: _PreviewBadge(label: '压缩')), - ], - ), - onScaleChanged: _handleCompareScaleChanged, - child: LayoutBuilder( - builder: (context, constraints) { - return Stack( - fit: StackFit.expand, - children: [ - _PreviewImage(path: compressedPath), - ClipRect( - clipper: _CompareClipper(_compareFraction), - child: _PreviewImage(path: job.original.previewPath), - ), - Positioned( - left: constraints.maxWidth * _compareFraction - 1, - top: 0, - bottom: 0, - child: Container(width: 2, color: Colors.white), - ), - ], - ); - }, - ), + overlay: _CompareOverlay(fraction: _compareFraction), + childBuilder: (context, metrics) { + final clipX = metrics.canvasXForViewportFraction(_compareFraction); + return Stack( + fit: StackFit.expand, + children: [ + _PreviewImage(path: compressedPath), + ClipRect( + clipper: _CompareClipper(clipX), + child: _PreviewImage(path: job.original.previewPath), + ), + ], + ); + }, ); } return _ZoomablePreview( @@ -858,6 +850,8 @@ class _ImagerHomePageState extends State { enum PreviewMode { original, compressed, compare } +enum ImagePickSource { file, album } + class NativeImage { NativeImage({ required this.sourceHandle, @@ -1015,14 +1009,12 @@ class _CompareSlider extends StatefulWidget { 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 @@ -1031,7 +1023,6 @@ class _CompareSlider extends StatefulWidget { 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; @@ -1061,12 +1052,9 @@ class _CompareSliderState extends State<_CompareSlider> { 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, - ); + final nextValue = _clampValue((_dragValue ?? widget.value) + delta * range); _dragValue = nextValue; widget.onChanged(nextValue); } @@ -1095,20 +1083,17 @@ class _CompareSliderState extends State<_CompareSlider> { child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - final trackWidth = math.max(1.0, width - _horizontalPadding * 2); + final trackWidth = math.max(1.0, width); final normal = ((widget.value - widget.min) / (widget.max - widget.min)) .clamp(0.0, 1.0) .toDouble(); - final thumbCenter = _horizontalPadding + trackWidth * normal; + final thumbCenter = trackWidth * normal; return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) { - _jumpTo( - details.localPosition.dx - _horizontalPadding, - trackWidth, - ); + _jumpTo(details.localPosition.dx, trackWidth); }, onTapUp: (details) { _dragValue = null; @@ -1125,8 +1110,8 @@ class _CompareSliderState extends State<_CompareSlider> { alignment: Alignment.centerLeft, children: [ Positioned( - left: _horizontalPadding, - right: _horizontalPadding, + left: 0, + right: 0, top: (_height - _trackHeight) / 2, child: DecoratedBox( decoration: BoxDecoration( @@ -1137,7 +1122,7 @@ class _CompareSliderState extends State<_CompareSlider> { ), ), Positioned( - left: _horizontalPadding, + left: 0, width: trackWidth * normal, top: (_height - _trackHeight) / 2, child: DecoratedBox( @@ -1176,20 +1161,80 @@ class _CompareSliderState extends State<_CompareSlider> { } } +typedef _ZoomablePreviewChildBuilder = + Widget Function(BuildContext context, _ZoomablePreviewMetrics metrics); + +class _ZoomablePreviewMetrics { + const _ZoomablePreviewMetrics({ + required this.viewportSize, + required this.canvasSize, + required this.scale, + required this.offset, + }); + + final Size viewportSize; + final Size canvasSize; + final double scale; + final Offset offset; + + double canvasXForViewportFraction(double fraction) { + final viewportX = viewportSize.width * fraction.clamp(0.0, 1.0).toDouble(); + return canvasSize.width / 2 + + (viewportX - viewportSize.width / 2 - offset.dx) / scale; + } +} + +class _CompareOverlay extends StatelessWidget { + const _CompareOverlay({required this.fraction}); + + final double fraction; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final dividerX = + constraints.maxWidth * fraction.clamp(0.0, 1.0).toDouble(); + + return Stack( + children: [ + Positioned( + left: dividerX - 1, + top: 0, + bottom: 0, + child: Container(width: 2, color: Colors.white), + ), + const Positioned( + left: 12, + top: 12, + child: _PreviewBadge(label: '原图'), + ), + const Positioned( + right: 12, + top: 12, + child: _PreviewBadge(label: '压缩'), + ), + ], + ); + }, + ); + } +} + class _ZoomablePreview extends StatefulWidget { const _ZoomablePreview({ required this.width, required this.height, - required this.child, + this.child, + this.childBuilder, this.overlay, - this.onScaleChanged, - }); + }) : assert(child != null || childBuilder != null); final int width; final int height; - final Widget child; + final Widget? child; + final _ZoomablePreviewChildBuilder? childBuilder; final Widget? overlay; - final ValueChanged? onScaleChanged; @override State<_ZoomablePreview> createState() => _ZoomablePreviewState(); @@ -1213,26 +1258,11 @@ 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) { @@ -1273,7 +1303,6 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> { _scale = nextScale; _offset = _clampOffset(nextOffset, nextScale); }); - widget.onScaleChanged?.call(nextScale); } void _endGesture(ScaleEndDetails details) { @@ -1281,7 +1310,6 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> { _scale = _clampScale(_scale); _offset = _clampOffset(_offset, _scale); }); - widget.onScaleChanged?.call(_scale); } @override @@ -1303,6 +1331,14 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> { _canvasSize = Size(canvasWidth, canvasHeight); _scale = _clampScale(_scale); _offset = _clampOffset(_offset, _scale); + final metrics = _ZoomablePreviewMetrics( + viewportSize: _viewportSize, + canvasSize: _canvasSize, + scale: _scale, + offset: _offset, + ); + final previewChild = + widget.childBuilder?.call(context, metrics) ?? widget.child!; return ClipRect( child: Stack( @@ -1322,7 +1358,7 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> { child: SizedBox( width: canvasWidth, height: canvasHeight, - child: widget.child, + child: previewChild, ), ), ), @@ -1385,17 +1421,18 @@ class _PreviewBadge extends StatelessWidget { } class _CompareClipper extends CustomClipper { - const _CompareClipper(this.fraction); + const _CompareClipper(this.clipX); - final double fraction; + final double clipX; @override Rect getClip(Size size) { - return Rect.fromLTWH(0, 0, size.width * fraction, size.height); + final width = clipX.clamp(0.0, size.width).toDouble(); + return Rect.fromLTWH(0, 0, width, size.height); } @override bool shouldReclip(_CompareClipper oldClipper) { - return oldClipper.fraction != fraction; + return oldClipper.clipX != clipX; } } diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 7425f35..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:luban_imager/main.dart'; - -void main() { - testWidgets('uses English app name by default', (tester) async { - await tester.pumpWidget(const LubanImagerApp()); - - expect(find.text(AppName.english), findsOneWidget); - expect(find.text('选择文件'), findsWidgets); - expect(find.text('从相册选择'), findsOneWidget); - }); - - testWidgets('uses Chinese app name for Chinese locale', (tester) async { - tester.binding.platformDispatcher.localesTestValue = const [ - Locale('zh', 'CN'), - ]; - addTearDown(tester.binding.platformDispatcher.clearLocalesTestValue); - - await tester.pumpWidget(const LubanImagerApp()); - - expect(find.text(AppName.chinese), findsOneWidget); - expect(find.text(AppName.english), findsNothing); - expect(find.text('选择文件'), findsWidgets); - expect(find.text('从相册选择'), findsOneWidget); - }); -}