feat: add gallery image picker

This commit is contained in:
呈祥
2026-05-18 14:14:19 +08:00
parent 344af390d5
commit d74fc5ab44
4 changed files with 399 additions and 20 deletions
@@ -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()
+91 -5
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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);
}); });
} }