fix: refine image selection flow
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import ImageIO
|
import ImageIO
|
||||||
import Photos
|
import Photos
|
||||||
import PhotosUI
|
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import UIKit
|
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 {
|
private enum PendingOperation {
|
||||||
case pickFile(FlutterResult)
|
case pickFile(FlutterResult)
|
||||||
case pickAlbum(FlutterResult)
|
case pickAlbum(FlutterResult)
|
||||||
@@ -130,19 +129,22 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
|||||||
result(FlutterError(code: "busy", message: "正在选择图片", details: nil))
|
result(FlutterError(code: "busy", message: "正在选择图片", details: nil))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard let controller = controller else {
|
||||||
guard #available(iOS 14.0, *) else {
|
result(FlutterError(code: "picker_unavailable", message: "无法打开相册", details: nil))
|
||||||
pickImages(result: result)
|
return
|
||||||
|
}
|
||||||
|
guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else {
|
||||||
|
result(FlutterError(code: "picker_unavailable", message: "相册不可用", details: nil))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
let picker = UIImagePickerController()
|
||||||
configuration.filter = .images
|
picker.sourceType = .photoLibrary
|
||||||
configuration.selectionLimit = 1
|
picker.mediaTypes = ["public.image"]
|
||||||
let picker = PHPickerViewController(configuration: configuration)
|
picker.allowsEditing = false
|
||||||
picker.delegate = self
|
picker.delegate = self
|
||||||
pendingOperation = .pickAlbum(result)
|
pendingOperation = .pickAlbum(result)
|
||||||
controller?.present(picker, animated: true)
|
controller.present(picker, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func compressImage(arguments: Any?, result: @escaping FlutterResult) {
|
private func compressImage(arguments: Any?, result: @escaping FlutterResult) {
|
||||||
@@ -372,8 +374,10 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 14.0, *)
|
func imagePickerController(
|
||||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
_ picker: UIImagePickerController,
|
||||||
|
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
|
||||||
|
) {
|
||||||
picker.dismiss(animated: true)
|
picker.dismiss(animated: true)
|
||||||
|
|
||||||
guard let operation = pendingOperation else {
|
guard let operation = pendingOperation else {
|
||||||
@@ -384,40 +388,33 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
|||||||
guard case .pickAlbum(let result) = operation else {
|
guard case .pickAlbum(let result) = operation else {
|
||||||
return
|
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 {
|
do {
|
||||||
let payload = try self.buildGalleryImage(sourceURL: url)
|
if let url = info[.imageURL] as? URL {
|
||||||
DispatchQueue.main.async {
|
result([try buildGalleryImage(sourceURL: url)])
|
||||||
result([payload])
|
return
|
||||||
}
|
}
|
||||||
|
if let image = info[.originalImage] as? UIImage {
|
||||||
|
result([try buildGalleryImage(image: image)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result([])
|
||||||
} catch {
|
} catch {
|
||||||
DispatchQueue.main.async {
|
|
||||||
result(FlutterError(code: "gallery_pick_failed", message: error.localizedDescription, details: nil))
|
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
|
||||||
|
|
||||||
|
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")
|
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] {
|
private func buildCachedReadOnlyImage(sourceURL: URL, child: String) throws -> [String: Any] {
|
||||||
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
||||||
defer {
|
defer {
|
||||||
@@ -710,9 +727,6 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 14.0, *)
|
|
||||||
extension NativeImageBridge: PHPickerViewControllerDelegate {}
|
|
||||||
|
|
||||||
private struct PixelSize {
|
private struct PixelSize {
|
||||||
let width: Int
|
let width: Int
|
||||||
let height: Int
|
let height: Int
|
||||||
|
|||||||
+136
-99
@@ -99,9 +99,9 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
ImageJob? _job;
|
ImageJob? _job;
|
||||||
|
ImagePickSource? _currentPickSource;
|
||||||
var _previewMode = PreviewMode.original;
|
var _previewMode = PreviewMode.original;
|
||||||
var _compareFraction = 0.5;
|
var _compareFraction = 0.5;
|
||||||
var _comparePreviewScale = 1.0;
|
|
||||||
var _isPicking = false;
|
var _isPicking = false;
|
||||||
OverlayEntry? _toastEntry;
|
OverlayEntry? _toastEntry;
|
||||||
Timer? _toastTimer;
|
Timer? _toastTimer;
|
||||||
@@ -162,16 +162,20 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startImageJob(NativeImage image, {String? message}) async {
|
Future<void> _startImageJob(
|
||||||
|
NativeImage image, {
|
||||||
|
String? message,
|
||||||
|
ImagePickSource? source,
|
||||||
|
}) async {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_job = ImageJob(image);
|
_job = ImageJob(image);
|
||||||
|
_currentPickSource = source;
|
||||||
_previewMode = PreviewMode.original;
|
_previewMode = PreviewMode.original;
|
||||||
_compareFraction = 0.5;
|
_compareFraction = 0.5;
|
||||||
_comparePreviewScale = 1.0;
|
|
||||||
});
|
});
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
_showMessage(message);
|
_showMessage(message);
|
||||||
@@ -180,16 +184,35 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickFiles() async {
|
Future<void> _pickFiles() async {
|
||||||
await _pickNativeImage(method: 'pickImages', errorMessage: '选择文件失败');
|
await _pickNativeImage(
|
||||||
|
method: 'pickImages',
|
||||||
|
errorMessage: '选择文件失败',
|
||||||
|
source: ImagePickSource.file,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickAlbumImage() async {
|
Future<void> _pickAlbumImage() async {
|
||||||
await _pickNativeImage(method: 'pickAlbumImage', errorMessage: '选择相册图片失败');
|
await _pickNativeImage(
|
||||||
|
method: 'pickAlbumImage',
|
||||||
|
errorMessage: '选择相册图片失败',
|
||||||
|
source: ImagePickSource.album,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _repickCurrentSource() async {
|
||||||
|
switch (_currentPickSource) {
|
||||||
|
case ImagePickSource.album:
|
||||||
|
await _pickAlbumImage();
|
||||||
|
case ImagePickSource.file:
|
||||||
|
case null:
|
||||||
|
await _pickFiles();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickNativeImage({
|
Future<void> _pickNativeImage({
|
||||||
required String method,
|
required String method,
|
||||||
required String errorMessage,
|
required String errorMessage,
|
||||||
|
required ImagePickSource source,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isPicking) {
|
if (_isPicking) {
|
||||||
return;
|
return;
|
||||||
@@ -222,7 +245,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (picked != null && mounted) {
|
if (picked != null && mounted) {
|
||||||
await _startImageJob(picked);
|
await _startImageJob(picked, source: source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +288,6 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
..isCompressing = false
|
..isCompressing = false
|
||||||
..overwritten = false;
|
..overwritten = false;
|
||||||
_previewMode = PreviewMode.compare;
|
_previewMode = PreviewMode.compare;
|
||||||
_comparePreviewScale = 1.0;
|
|
||||||
});
|
});
|
||||||
_showMessage(
|
_showMessage(
|
||||||
compressed.passthrough
|
compressed.passthrough
|
||||||
@@ -297,7 +319,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
'compressedPath': compressed.path,
|
'compressedPath': compressed.path,
|
||||||
'suggestedName': _suggestedName(job.original.displayName),
|
'suggestedName': _suggestedName(job.original.displayName),
|
||||||
});
|
});
|
||||||
_showMessage('已保存到相册');
|
_showMessage('已保存到系统相册');
|
||||||
} on PlatformException catch (error) {
|
} on PlatformException catch (error) {
|
||||||
_showMessage(error.message ?? '保存失败');
|
_showMessage(error.message ?? '保存失败');
|
||||||
}
|
}
|
||||||
@@ -406,18 +428,9 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
void _clearImage() {
|
void _clearImage() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_job = null;
|
_job = null;
|
||||||
|
_currentPickSource = null;
|
||||||
_previewMode = PreviewMode.original;
|
_previewMode = PreviewMode.original;
|
||||||
_compareFraction = 0.5;
|
_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<ImagerHomePage> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
overlay.insert(_toastEntry!);
|
overlay.insert(_toastEntry!);
|
||||||
_toastTimer = Timer(const Duration(seconds: 1), () {
|
_toastTimer = Timer(const Duration(milliseconds: 1500), () {
|
||||||
_toastEntry?.remove();
|
_toastEntry?.remove();
|
||||||
_toastEntry = null;
|
_toastEntry = null;
|
||||||
_toastTimer = null;
|
_toastTimer = null;
|
||||||
@@ -502,17 +515,9 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: _isPicking ? null : _pickFiles,
|
onPressed: _isPicking ? null : _repickCurrentSource,
|
||||||
icon: const Icon(Icons.insert_drive_file_outlined),
|
icon: const Icon(Icons.add_photo_alternate_outlined),
|
||||||
label: const Text('选择文件'),
|
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('相册'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -537,7 +542,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
'选择文件或相册图片开始压缩',
|
'选择图片开始压缩',
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
|
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||||
@@ -556,7 +561,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: _isPicking ? null : _pickAlbumImage,
|
onPressed: _isPicking ? null : _pickAlbumImage,
|
||||||
icon: const Icon(Icons.photo_library_outlined),
|
icon: const Icon(Icons.photo_library_outlined),
|
||||||
label: const Text('从相册选择'),
|
label: const Text('相册选择'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -683,25 +688,25 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: previewHeight,
|
height: previewHeight,
|
||||||
child: DecoratedBox(
|
child: Container(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEDEFEA),
|
color: const Color(0xFFEDEFEA),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
foregroundDecoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: const Color(0xFFDADFD8)),
|
border: Border.all(color: const Color(0xFFDADFD8)),
|
||||||
),
|
),
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
child: _buildImagePreview(job, mode),
|
child: _buildImagePreview(job, mode),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (showCompareSlider) ...[
|
if (showCompareSlider) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_CompareSlider(
|
_CompareSlider(
|
||||||
value: _compareFraction,
|
value: _compareFraction,
|
||||||
min: 0.05,
|
min: 0.0,
|
||||||
max: 0.95,
|
max: 1.0,
|
||||||
dragScale: _comparePreviewScale,
|
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_compareFraction = value;
|
_compareFraction = value;
|
||||||
@@ -774,33 +779,20 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
return _ZoomablePreview(
|
return _ZoomablePreview(
|
||||||
width: job.original.width,
|
width: job.original.width,
|
||||||
height: job.original.height,
|
height: job.original.height,
|
||||||
overlay: const Stack(
|
overlay: _CompareOverlay(fraction: _compareFraction),
|
||||||
children: [
|
childBuilder: (context, metrics) {
|
||||||
Positioned(left: 12, top: 12, child: _PreviewBadge(label: '原图')),
|
final clipX = metrics.canvasXForViewportFraction(_compareFraction);
|
||||||
Positioned(right: 12, top: 12, child: _PreviewBadge(label: '压缩')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onScaleChanged: _handleCompareScaleChanged,
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_PreviewImage(path: compressedPath),
|
_PreviewImage(path: compressedPath),
|
||||||
ClipRect(
|
ClipRect(
|
||||||
clipper: _CompareClipper(_compareFraction),
|
clipper: _CompareClipper(clipX),
|
||||||
child: _PreviewImage(path: job.original.previewPath),
|
child: _PreviewImage(path: job.original.previewPath),
|
||||||
),
|
),
|
||||||
Positioned(
|
|
||||||
left: constraints.maxWidth * _compareFraction - 1,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: Container(width: 2, color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _ZoomablePreview(
|
return _ZoomablePreview(
|
||||||
@@ -858,6 +850,8 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
|
|
||||||
enum PreviewMode { original, compressed, compare }
|
enum PreviewMode { original, compressed, compare }
|
||||||
|
|
||||||
|
enum ImagePickSource { file, album }
|
||||||
|
|
||||||
class NativeImage {
|
class NativeImage {
|
||||||
NativeImage({
|
NativeImage({
|
||||||
required this.sourceHandle,
|
required this.sourceHandle,
|
||||||
@@ -1015,14 +1009,12 @@ class _CompareSlider extends StatefulWidget {
|
|||||||
required this.value,
|
required this.value,
|
||||||
required this.min,
|
required this.min,
|
||||||
required this.max,
|
required this.max,
|
||||||
required this.dragScale,
|
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final double value;
|
final double value;
|
||||||
final double min;
|
final double min;
|
||||||
final double max;
|
final double max;
|
||||||
final double dragScale;
|
|
||||||
final ValueChanged<double> onChanged;
|
final ValueChanged<double> onChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1031,7 +1023,6 @@ class _CompareSlider extends StatefulWidget {
|
|||||||
|
|
||||||
class _CompareSliderState extends State<_CompareSlider> {
|
class _CompareSliderState extends State<_CompareSlider> {
|
||||||
static const _height = 48.0;
|
static const _height = 48.0;
|
||||||
static const _horizontalPadding = 16.0;
|
|
||||||
static const _trackHeight = 4.0;
|
static const _trackHeight = 4.0;
|
||||||
static const _thumbSize = 22.0;
|
static const _thumbSize = 22.0;
|
||||||
|
|
||||||
@@ -1061,12 +1052,9 @@ class _CompareSliderState extends State<_CompareSlider> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final scale = math.max(1.0, widget.dragScale);
|
|
||||||
final range = widget.max - widget.min;
|
final range = widget.max - widget.min;
|
||||||
final delta = (details.primaryDelta ?? details.delta.dx) / trackWidth;
|
final delta = (details.primaryDelta ?? details.delta.dx) / trackWidth;
|
||||||
final nextValue = _clampValue(
|
final nextValue = _clampValue((_dragValue ?? widget.value) + delta * range);
|
||||||
(_dragValue ?? widget.value) + delta * range / scale,
|
|
||||||
);
|
|
||||||
_dragValue = nextValue;
|
_dragValue = nextValue;
|
||||||
widget.onChanged(nextValue);
|
widget.onChanged(nextValue);
|
||||||
}
|
}
|
||||||
@@ -1095,20 +1083,17 @@ class _CompareSliderState extends State<_CompareSlider> {
|
|||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
final width = constraints.maxWidth;
|
||||||
final trackWidth = math.max(1.0, width - _horizontalPadding * 2);
|
final trackWidth = math.max(1.0, width);
|
||||||
final normal =
|
final normal =
|
||||||
((widget.value - widget.min) / (widget.max - widget.min))
|
((widget.value - widget.min) / (widget.max - widget.min))
|
||||||
.clamp(0.0, 1.0)
|
.clamp(0.0, 1.0)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
final thumbCenter = _horizontalPadding + trackWidth * normal;
|
final thumbCenter = trackWidth * normal;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTapDown: (details) {
|
onTapDown: (details) {
|
||||||
_jumpTo(
|
_jumpTo(details.localPosition.dx, trackWidth);
|
||||||
details.localPosition.dx - _horizontalPadding,
|
|
||||||
trackWidth,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onTapUp: (details) {
|
onTapUp: (details) {
|
||||||
_dragValue = null;
|
_dragValue = null;
|
||||||
@@ -1125,8 +1110,8 @@ class _CompareSliderState extends State<_CompareSlider> {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Positioned(
|
||||||
left: _horizontalPadding,
|
left: 0,
|
||||||
right: _horizontalPadding,
|
right: 0,
|
||||||
top: (_height - _trackHeight) / 2,
|
top: (_height - _trackHeight) / 2,
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1137,7 +1122,7 @@ class _CompareSliderState extends State<_CompareSlider> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: _horizontalPadding,
|
left: 0,
|
||||||
width: trackWidth * normal,
|
width: trackWidth * normal,
|
||||||
top: (_height - _trackHeight) / 2,
|
top: (_height - _trackHeight) / 2,
|
||||||
child: DecoratedBox(
|
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 {
|
class _ZoomablePreview extends StatefulWidget {
|
||||||
const _ZoomablePreview({
|
const _ZoomablePreview({
|
||||||
required this.width,
|
required this.width,
|
||||||
required this.height,
|
required this.height,
|
||||||
required this.child,
|
this.child,
|
||||||
|
this.childBuilder,
|
||||||
this.overlay,
|
this.overlay,
|
||||||
this.onScaleChanged,
|
}) : assert(child != null || childBuilder != null);
|
||||||
});
|
|
||||||
|
|
||||||
final int width;
|
final int width;
|
||||||
final int height;
|
final int height;
|
||||||
final Widget child;
|
final Widget? child;
|
||||||
|
final _ZoomablePreviewChildBuilder? childBuilder;
|
||||||
final Widget? overlay;
|
final Widget? overlay;
|
||||||
final ValueChanged<double>? onScaleChanged;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ZoomablePreview> createState() => _ZoomablePreviewState();
|
State<_ZoomablePreview> createState() => _ZoomablePreviewState();
|
||||||
@@ -1213,26 +1258,11 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
|
|||||||
if (oldWidget.width != widget.width || oldWidget.height != widget.height) {
|
if (oldWidget.width != widget.width || oldWidget.height != widget.height) {
|
||||||
_resetTransform();
|
_resetTransform();
|
||||||
}
|
}
|
||||||
if (oldWidget.onScaleChanged != widget.onScaleChanged) {
|
|
||||||
_scheduleScaleChanged();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resetTransform() {
|
void _resetTransform() {
|
||||||
_scale = _minScale;
|
_scale = _minScale;
|
||||||
_offset = Offset.zero;
|
_offset = Offset.zero;
|
||||||
_scheduleScaleChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleScaleChanged() {
|
|
||||||
if (widget.onScaleChanged == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
widget.onScaleChanged?.call(_scale);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
double _clampScale(double scale) {
|
double _clampScale(double scale) {
|
||||||
@@ -1273,7 +1303,6 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
|
|||||||
_scale = nextScale;
|
_scale = nextScale;
|
||||||
_offset = _clampOffset(nextOffset, nextScale);
|
_offset = _clampOffset(nextOffset, nextScale);
|
||||||
});
|
});
|
||||||
widget.onScaleChanged?.call(nextScale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _endGesture(ScaleEndDetails details) {
|
void _endGesture(ScaleEndDetails details) {
|
||||||
@@ -1281,7 +1310,6 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
|
|||||||
_scale = _clampScale(_scale);
|
_scale = _clampScale(_scale);
|
||||||
_offset = _clampOffset(_offset, _scale);
|
_offset = _clampOffset(_offset, _scale);
|
||||||
});
|
});
|
||||||
widget.onScaleChanged?.call(_scale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1303,6 +1331,14 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
|
|||||||
_canvasSize = Size(canvasWidth, canvasHeight);
|
_canvasSize = Size(canvasWidth, canvasHeight);
|
||||||
_scale = _clampScale(_scale);
|
_scale = _clampScale(_scale);
|
||||||
_offset = _clampOffset(_offset, _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(
|
return ClipRect(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -1322,7 +1358,7 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: canvasWidth,
|
width: canvasWidth,
|
||||||
height: canvasHeight,
|
height: canvasHeight,
|
||||||
child: widget.child,
|
child: previewChild,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1385,17 +1421,18 @@ class _PreviewBadge extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CompareClipper extends CustomClipper<Rect> {
|
class _CompareClipper extends CustomClipper<Rect> {
|
||||||
const _CompareClipper(this.fraction);
|
const _CompareClipper(this.clipX);
|
||||||
|
|
||||||
final double fraction;
|
final double clipX;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Rect getClip(Size size) {
|
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
|
@override
|
||||||
bool shouldReclip(_CompareClipper oldClipper) {
|
bool shouldReclip(_CompareClipper oldClipper) {
|
||||||
return oldClipper.fraction != fraction;
|
return oldClipper.clipX != clipX;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user