feat: add gallery image picker
This commit is contained in:
@@ -25,6 +25,7 @@ import top.zibin.luban.api.OnCompressListener
|
|||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
private val channelName = "luban_imager/native_images"
|
private val channelName = "luban_imager/native_images"
|
||||||
private val pickImagesRequest = 4101
|
private val pickImagesRequest = 4101
|
||||||
|
private val pickAlbumImageRequest = 4103
|
||||||
|
|
||||||
private var channel: MethodChannel? = null
|
private var channel: MethodChannel? = null
|
||||||
private var pendingPickResult: MethodChannel.Result? = null
|
private var pendingPickResult: MethodChannel.Result? = null
|
||||||
@@ -36,6 +37,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
channel?.setMethodCallHandler { call, result ->
|
channel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"pickImages" -> pickImages(result)
|
"pickImages" -> pickImages(result)
|
||||||
|
"pickAlbumImage" -> pickAlbumImage(result)
|
||||||
"takeSharedImages" -> takeSharedImages(result)
|
"takeSharedImages" -> takeSharedImages(result)
|
||||||
"compressImage" -> compressImage(call.arguments, result)
|
"compressImage" -> compressImage(call.arguments, result)
|
||||||
"overwriteOriginal" -> overwriteOriginal(call.arguments, result)
|
"overwriteOriginal" -> overwriteOriginal(call.arguments, result)
|
||||||
@@ -58,6 +60,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
super.onActivityResult(requestCode, resultCode, data)
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
when (requestCode) {
|
when (requestCode) {
|
||||||
pickImagesRequest -> finishPickImages(resultCode, data)
|
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?) {
|
private fun finishPickImages(resultCode: Int, data: Intent?) {
|
||||||
val result = pendingPickResult ?: return
|
val result = pendingPickResult ?: return
|
||||||
pendingPickResult = null
|
pendingPickResult = null
|
||||||
@@ -119,6 +149,28 @@ class MainActivity : FlutterActivity() {
|
|||||||
return data.data ?: data.clipData?.getItemAt(0)?.uri
|
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) {
|
private fun takeSharedImages(result: MethodChannel.Result) {
|
||||||
val images = pendingSharedImages.toList()
|
val images = pendingSharedImages.toList()
|
||||||
pendingSharedImages.clear()
|
pendingSharedImages.clear()
|
||||||
@@ -169,8 +221,16 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSharedImage(uri: Uri): Map<String, Any> {
|
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 displayName = queryDisplayName(uri) ?: "image"
|
||||||
val previewFile = copyUriToCache(uri, displayName, "shared-originals")
|
val previewFile = copyUriToCache(uri, displayName, child)
|
||||||
val dimensions = readDimensionsFromFile(previewFile)
|
val dimensions = readDimensionsFromFile(previewFile)
|
||||||
val originalSize = previewFile.length()
|
val originalSize = previewFile.length()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import ImageIO
|
import ImageIO
|
||||||
import Photos
|
import Photos
|
||||||
|
import PhotosUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -29,7 +30,8 @@ class SceneDelegate: FlutterSceneDelegate {
|
|||||||
|
|
||||||
private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
||||||
private enum PendingOperation {
|
private enum PendingOperation {
|
||||||
case pick(FlutterResult)
|
case pickFile(FlutterResult)
|
||||||
|
case pickAlbum(FlutterResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
private weak var controller: FlutterViewController?
|
private weak var controller: FlutterViewController?
|
||||||
@@ -57,6 +59,8 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
|||||||
switch call.method {
|
switch call.method {
|
||||||
case "pickImages":
|
case "pickImages":
|
||||||
pickImages(result: result)
|
pickImages(result: result)
|
||||||
|
case "pickAlbumImage":
|
||||||
|
pickAlbumImage(result: result)
|
||||||
case "takeSharedImages":
|
case "takeSharedImages":
|
||||||
takeSharedImages(result: result)
|
takeSharedImages(result: result)
|
||||||
case "compressImage":
|
case "compressImage":
|
||||||
@@ -117,7 +121,27 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
|||||||
}
|
}
|
||||||
picker.allowsMultipleSelection = false
|
picker.allowsMultipleSelection = false
|
||||||
picker.delegate = self
|
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)
|
controller?.present(picker, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +343,7 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
|||||||
pendingOperation = nil
|
pendingOperation = nil
|
||||||
|
|
||||||
switch operation {
|
switch operation {
|
||||||
case .pick(let result):
|
case .pickFile(let result):
|
||||||
do {
|
do {
|
||||||
if let url = urls.first {
|
if let url = urls.first {
|
||||||
result([try buildPickedImage(sourceURL: url)])
|
result([try buildPickedImage(sourceURL: url)])
|
||||||
@@ -331,6 +355,8 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
|||||||
FlutterError(code: "pick_failed", message: error.localizedDescription, details: nil)
|
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
|
pendingOperation = nil
|
||||||
|
|
||||||
switch operation {
|
switch operation {
|
||||||
case .pick(let result):
|
case .pickFile(let result), .pickAlbum(let result):
|
||||||
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] {
|
private func buildPickedImage(sourceURL: URL) throws -> [String: Any] {
|
||||||
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
||||||
defer {
|
defer {
|
||||||
@@ -379,6 +454,14 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func buildSharedImage(sourceURL: URL) throws -> [String: Any] {
|
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()
|
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
||||||
defer {
|
defer {
|
||||||
if scoped {
|
if scoped {
|
||||||
@@ -391,7 +474,7 @@ private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate {
|
|||||||
: sourceURL.lastPathComponent
|
: sourceURL.lastPathComponent
|
||||||
let previewURL = try copyToCache(
|
let previewURL = try copyToCache(
|
||||||
sourceURL: sourceURL,
|
sourceURL: sourceURL,
|
||||||
child: "shared-originals",
|
child: child,
|
||||||
preferredName: displayName
|
preferredName: displayName
|
||||||
)
|
)
|
||||||
let id = UUID().uuidString
|
let id = UUID().uuidString
|
||||||
@@ -627,6 +710,9 @@ 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
|
||||||
|
|||||||
+242
-11
@@ -101,6 +101,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
ImageJob? _job;
|
ImageJob? _job;
|
||||||
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;
|
||||||
@@ -170,6 +171,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
_job = ImageJob(image);
|
_job = ImageJob(image);
|
||||||
_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);
|
||||||
@@ -177,7 +179,18 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
await _compressSelected();
|
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) {
|
if (_isPicking) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -188,7 +201,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _channel.invokeMethod<List<dynamic>>('pickImages');
|
final response = await _channel.invokeMethod<List<dynamic>>(method);
|
||||||
final images = response ?? [];
|
final images = response ?? [];
|
||||||
|
|
||||||
if (!mounted || images.isEmpty) {
|
if (!mounted || images.isEmpty) {
|
||||||
@@ -199,7 +212,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
Map<dynamic, dynamic>.from(images.first as Map),
|
Map<dynamic, dynamic>.from(images.first as Map),
|
||||||
);
|
);
|
||||||
} on PlatformException catch (error) {
|
} on PlatformException catch (error) {
|
||||||
_showMessage(error.message ?? '选择图片失败');
|
_showMessage(error.message ?? errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -252,6 +265,7 @@ 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
|
||||||
@@ -394,6 +408,16 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
_job = null;
|
_job = 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,9 +502,17 @@ 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 : _pickImages,
|
onPressed: _isPicking ? null : _pickFiles,
|
||||||
icon: const Icon(Icons.add_photo_alternate_outlined),
|
icon: const Icon(Icons.insert_drive_file_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('相册'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -505,16 +537,28 @@ 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),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
children: [
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isPicking ? null : _pickImages,
|
onPressed: _isPicking ? null : _pickFiles,
|
||||||
icon: const Icon(Icons.add_photo_alternate_outlined),
|
icon: const Icon(Icons.insert_drive_file_outlined),
|
||||||
label: const Text('选择图片'),
|
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) ...[
|
if (showCompareSlider) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Slider(
|
_CompareSlider(
|
||||||
value: _compareFraction,
|
value: _compareFraction,
|
||||||
min: 0.05,
|
min: 0.05,
|
||||||
max: 0.95,
|
max: 0.95,
|
||||||
|
dragScale: _comparePreviewScale,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_compareFraction = value;
|
_compareFraction = value;
|
||||||
@@ -735,6 +780,7 @@ class _ImagerHomePageState extends State<ImagerHomePage> {
|
|||||||
Positioned(right: 12, top: 12, child: _PreviewBadge(label: '压缩')),
|
Positioned(right: 12, top: 12, child: _PreviewBadge(label: '压缩')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
onScaleChanged: _handleCompareScaleChanged,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return Stack(
|
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 {
|
class _ZoomablePreview extends StatefulWidget {
|
||||||
const _ZoomablePreview({
|
const _ZoomablePreview({
|
||||||
required this.width,
|
required this.width,
|
||||||
required this.height,
|
required this.height,
|
||||||
required this.child,
|
required this.child,
|
||||||
this.overlay,
|
this.overlay,
|
||||||
|
this.onScaleChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int width;
|
final int width;
|
||||||
final int height;
|
final int height;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final Widget? overlay;
|
final Widget? overlay;
|
||||||
|
final ValueChanged<double>? onScaleChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ZoomablePreview> createState() => _ZoomablePreviewState();
|
State<_ZoomablePreview> createState() => _ZoomablePreviewState();
|
||||||
@@ -999,11 +1213,26 @@ 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) {
|
||||||
@@ -1044,6 +1273,7 @@ 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) {
|
||||||
@@ -1051,6 +1281,7 @@ 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
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ void main() {
|
|||||||
await tester.pumpWidget(const LubanImagerApp());
|
await tester.pumpWidget(const LubanImagerApp());
|
||||||
|
|
||||||
expect(find.text(AppName.english), findsOneWidget);
|
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 {
|
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.chinese), findsOneWidget);
|
||||||
expect(find.text(AppName.english), findsNothing);
|
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