feat: add gallery image picker
This commit is contained in:
@@ -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<Map<String, Any>>())
|
||||
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<String, Any> {
|
||||
return buildCachedReadOnlyImage(uri, "shared-originals")
|
||||
}
|
||||
|
||||
private fun buildGalleryImage(uri: Uri): Map<String, Any> {
|
||||
return buildCachedReadOnlyImage(uri, "gallery-originals")
|
||||
}
|
||||
|
||||
private fun buildCachedReadOnlyImage(uri: Uri, child: String): Map<String, Any> {
|
||||
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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+242
-11
@@ -101,6 +101,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
||||
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<ImagerHomePage> {
|
||||
_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<ImagerHomePage> {
|
||||
await _compressSelected();
|
||||
}
|
||||
|
||||
Future<void> _pickImages() async {
|
||||
Future<void> _pickFiles() async {
|
||||
await _pickNativeImage(method: 'pickImages', errorMessage: '选择文件失败');
|
||||
}
|
||||
|
||||
Future<void> _pickAlbumImage() async {
|
||||
await _pickNativeImage(method: 'pickAlbumImage', errorMessage: '选择相册图片失败');
|
||||
}
|
||||
|
||||
Future<void> _pickNativeImage({
|
||||
required String method,
|
||||
required String errorMessage,
|
||||
}) async {
|
||||
if (_isPicking) {
|
||||
return;
|
||||
}
|
||||
@@ -188,7 +201,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await _channel.invokeMethod<List<dynamic>>('pickImages');
|
||||
final response = await _channel.invokeMethod<List<dynamic>>(method);
|
||||
final images = response ?? [];
|
||||
|
||||
if (!mounted || images.isEmpty) {
|
||||
@@ -199,7 +212,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
||||
Map<dynamic, dynamic>.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<ImagerHomePage> {
|
||||
..isCompressing = false
|
||||
..overwritten = false;
|
||||
_previewMode = PreviewMode.compare;
|
||||
_comparePreviewScale = 1.0;
|
||||
});
|
||||
_showMessage(
|
||||
compressed.passthrough
|
||||
@@ -394,6 +408,16 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
||||
_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<ImagerHomePage> {
|
||||
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<ImagerHomePage> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'选择图片开始压缩',
|
||||
'选择文件或相册图片开始压缩',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
FilledButton.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('选择文件'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _isPicking ? null : _pickAlbumImage,
|
||||
icon: const Icon(Icons.photo_library_outlined),
|
||||
label: const Text('从相册选择'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -653,10 +697,11 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
||||
),
|
||||
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<ImagerHomePage> {
|
||||
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<double> 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<double>? 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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user