From 26cdf63afc072d425467497300c6af5fa922dd03 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 7 May 2026 09:22:01 +0300 Subject: [PATCH] scanner --- CHANGELOG.md | 8 + android/build.gradle | 12 + .../kotlin/io/swipelab/ux/ScannerPlugin.kt | 243 ++++++++++++++++++ .../main/kotlin/io/swipelab/ux/UxPlugin.kt | 1 + example/pubspec.lock | 2 +- ios/Classes/ScannerPlugin.swift | 161 ++++++++++++ ios/Classes/UxPlugin.swift | 1 + ios/ux.podspec | 4 +- lib/src/scanner.dart | 116 +++++++++ lib/ux.dart | 1 + pubspec.yaml | 2 +- 11 files changed, 547 insertions(+), 4 deletions(-) create mode 100644 android/src/main/kotlin/io/swipelab/ux/ScannerPlugin.kt create mode 100644 ios/Classes/ScannerPlugin.swift create mode 100644 lib/src/scanner.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ed43636..6043208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +### 0.9.0 +- `UxScanner`: new platform-view widget for QR-code (and forward-compat + barcode) scanning. iOS uses `AVCaptureMetadataOutput` (no extra pod); + Android uses CameraX preview + ZXing decoder + (`com.google.zxing:core:3.5.3`, ~470 KB jar, no Play Services dep). + `UxScannerPermission.requestCamera()` requests OS permission first; + decoded payloads stream through `EventChannel('ux/scanner/events')`. + ### 0.8.0 - `Log`: pretty, production-ready logger. Static entry (`Log.d/i/w/e/f/t`), scoped loggers via `Log.tag('KB')`, lazy messages (`Log.d(() => expensive)`), diff --git a/android/build.gradle b/android/build.gradle index b3d4e2a..c8af120 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -52,3 +52,15 @@ android { jvmTarget = '1.8' } } + +dependencies { + // CameraX for scanner preview + frame analysis. Pinned to a stable + // train; the `view` module pulls in the rest transitively. + def cameraxVersion = '1.3.4' + implementation "androidx.camera:camera-core:$cameraxVersion" + implementation "androidx.camera:camera-camera2:$cameraxVersion" + implementation "androidx.camera:camera-lifecycle:$cameraxVersion" + implementation "androidx.camera:camera-view:$cameraxVersion" + // Pure-Kotlin/Java QR decoder. ~470 KB jar, no Play Services dep. + implementation 'com.google.zxing:core:3.5.3' +} diff --git a/android/src/main/kotlin/io/swipelab/ux/ScannerPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/ScannerPlugin.kt new file mode 100644 index 0000000..94bcd5b --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/ScannerPlugin.kt @@ -0,0 +1,243 @@ +package io.swipelab.ux + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper +import android.view.View +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory +import java.util.concurrent.Executors + +class ScannerPlugin : + NativePlugin, + MethodChannel.MethodCallHandler { + companion object { + // Single shared event sink — the app only ever shows one scanner + // at a time (settings → scan), so collapsing to one channel keeps + // the platform-view factory free of per-view registrar wiring. + @JvmStatic + @Volatile + var eventSink: EventChannel.EventSink? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + + /// EventChannel methods are @UiThread; the analyzer runs on a + /// background pool. Bounce through the main looper. + @JvmStatic + fun emit(code: String) { + mainHandler.post { eventSink?.success(code) } + } + + private const val PERMISSION_REQUEST_CODE = 0xC1A0 + } + + private var methodChannel: MethodChannel? = null + private var eventChannel: EventChannel? = null + private var lifecycleOwner: LifecycleOwner? = null + private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null + private var pendingPermissionResult: MethodChannel.Result? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + val ec = EventChannel(binding.binaryMessenger, "ux/scanner/events") + ec.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + }) + eventChannel = ec + + val mc = MethodChannel(binding.binaryMessenger, "ux/scanner") + mc.setMethodCallHandler(this) + methodChannel = mc + + binding.platformViewRegistry.registerViewFactory( + "ux/scanner", + ScannerViewFactory { lifecycleOwner }, + ) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + eventChannel?.setStreamHandler(null) + eventChannel = null + eventSink = null + methodChannel?.setMethodCallHandler(null) + methodChannel = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + // The host app's MainActivity is a `FlutterActivity`, which extends + // `LifecycleOwner` indirectly via `androidx.activity.ComponentActivity`. + lifecycleOwner = binding.activity as? LifecycleOwner + activity = binding.activity + activityBinding = binding + binding.addRequestPermissionsResultListener { code, _, results -> + if (code != PERMISSION_REQUEST_CODE) return@addRequestPermissionsResultListener false + val granted = results.isNotEmpty() && + results[0] == PackageManager.PERMISSION_GRANTED + pendingPermissionResult?.success(granted) + pendingPermissionResult = null + true + } + } + + override fun onDetachedFromActivity() { + lifecycleOwner = null + activity = null + activityBinding = null + // If the activity tears down with a request still in flight, settle + // it cleanly rather than leaking the Result. + pendingPermissionResult?.success(false) + pendingPermissionResult = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "requestPermission" -> handleRequestPermission(result) + else -> result.notImplemented() + } + } + + private fun handleRequestPermission(result: MethodChannel.Result) { + val act = activity + ?: return result.error("no_activity", "plugin not attached to an activity", null) + val granted = ContextCompat.checkSelfPermission( + act, Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (granted) return result.success(true) + if (pendingPermissionResult != null) { + return result.error("in_progress", "another permission request is in flight", null) + } + pendingPermissionResult = result + ActivityCompat.requestPermissions( + act, + arrayOf(Manifest.permission.CAMERA), + PERMISSION_REQUEST_CODE, + ) + } +} + +private class ScannerViewFactory( + private val lifecycleOwnerProvider: () -> LifecycleOwner?, +) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + return ScannerPlatformView(context, lifecycleOwnerProvider()) + } +} + +private class ScannerPlatformView( + context: Context, + private val lifecycleOwner: LifecycleOwner?, +) : PlatformView { + // COMPATIBLE forces a TextureView under the hood; SurfaceView (the + // PERFORMANCE default) is its own window and won't composite under + // Flutter overlays in hybrid composition. + private val previewView = PreviewView(context).apply { + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + private val analysisExecutor = Executors.newSingleThreadExecutor() + private val reader: MultiFormatReader = MultiFormatReader().apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + ), + ) + } + + init { + if (lifecycleOwner != null) bindCamera(context, lifecycleOwner) + } + + private fun bindCamera(context: Context, lifecycleOwner: LifecycleOwner) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { it.setAnalyzer(analysisExecutor, ::analyze) } + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + } catch (_: Throwable) { + // Permission denied / camera unavailable — preview stays + // black. Caller surfaces a fallback (e.g. paste-hex). + } + }, androidx.core.content.ContextCompat.getMainExecutor(context)) + } + + private fun analyze(image: ImageProxy) { + try { + val plane = image.planes.firstOrNull() ?: return + val buffer = plane.buffer + val bytes = ByteArray(buffer.remaining()).also { buffer.get(it) } + val source = PlanarYUVLuminanceSource( + bytes, + plane.rowStride, + image.height, + 0, 0, + image.width, image.height, + false, + ) + val bitmap = BinaryBitmap(HybridBinarizer(source)) + val result = try { + reader.decode(bitmap) + } catch (_: Throwable) { + null + } finally { + reader.reset() + } + val text = result?.text + if (!text.isNullOrEmpty()) { + ScannerPlugin.emit(text) + } + } finally { + image.close() + } + } + + override fun getView(): View = previewView + + override fun dispose() { + analysisExecutor.shutdown() + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt index 494c766..c5c5ec6 100644 --- a/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt @@ -9,6 +9,7 @@ class UxPlugin : FlutterPlugin, ActivityAware { KeyboardPlugin(), SensorPlugin(), FilePlugin(), + ScannerPlugin(), ) override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) = diff --git a/example/pubspec.lock b/example/pubspec.lock index e64c39c..3b7ad36 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -502,7 +502,7 @@ packages: path: ".." relative: true source: path - version: "0.8.0" + version: "0.9.0" vector_math: dependency: transitive description: diff --git a/ios/Classes/ScannerPlugin.swift b/ios/Classes/ScannerPlugin.swift new file mode 100644 index 0000000..29f69e3 --- /dev/null +++ b/ios/Classes/ScannerPlugin.swift @@ -0,0 +1,161 @@ +import AVFoundation +import Flutter +import UIKit + +public class ScannerPlugin: NSObject, NativePlugin { + fileprivate static let eventEmitter = ScannerEventEmitter() + + public func register(with registrar: FlutterPluginRegistrar) { + let factory = ScannerViewFactory(messenger: registrar.messenger()) + registrar.register(factory, withId: "ux/scanner") + + let events = FlutterEventChannel( + name: "ux/scanner/events", + binaryMessenger: registrar.messenger(), + ) + events.setStreamHandler(ScannerPlugin.eventEmitter) + + let methods = FlutterMethodChannel( + name: "ux/scanner", + binaryMessenger: registrar.messenger(), + ) + methods.setMethodCallHandler { call, result in + switch call.method { + case "requestPermission": + ScannerPlugin.requestPermission(result: result) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private static func requestPermission(result: @escaping FlutterResult) { + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .authorized: + result(true) + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { result(granted) } + } + default: + result(false) + } + } +} + +fileprivate class ScannerEventEmitter: NSObject, FlutterStreamHandler { + private var sink: FlutterEventSink? + + func onListen(withArguments _: Any?, eventSink: @escaping FlutterEventSink) -> FlutterError? { + sink = eventSink + return nil + } + + func onCancel(withArguments _: Any?) -> FlutterError? { + sink = nil + return nil + } + + func send(_ code: String) { + DispatchQueue.main.async { [weak self] in self?.sink?(code) } + } +} + +fileprivate class ScannerViewFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView { + return ScannerPlatformView(frame: frame, args: args) + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } +} + +fileprivate class ScannerPreviewView: UIView { + let previewLayer: AVCaptureVideoPreviewLayer + + init(session: AVCaptureSession, frame: CGRect) { + previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + super.init(frame: frame) + backgroundColor = .black + layer.addSublayer(previewLayer) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not supported") } + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer.frame = bounds + if let connection = previewLayer.connection, connection.isVideoOrientationSupported { + connection.videoOrientation = .portrait + } + } +} + +fileprivate class ScannerPlatformView: NSObject, FlutterPlatformView, + AVCaptureMetadataOutputObjectsDelegate { + private let containerView: ScannerPreviewView + private let session = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "ux.scanner.session") + + init(frame: CGRect, args: Any?) { + containerView = ScannerPreviewView(session: session, frame: frame) + super.init() + + sessionQueue.async { [weak self] in + self?.configureSession(args: args) + } + } + + deinit { + sessionQueue.async { [session] in + if session.isRunning { session.stopRunning() } + } + } + + func view() -> UIView { + return containerView + } + + private func configureSession(args: Any?) { + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { + return + } + guard let input = try? AVCaptureDeviceInput(device: device) else { + return + } + session.beginConfiguration() + if session.canAddInput(input) { session.addInput(input) } + + let output = AVCaptureMetadataOutput() + if session.canAddOutput(output) { + session.addOutput(output) + // QR is the only format we support today; the `formats` arg is + // accepted for forward-compat but ignored. + _ = args + output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + output.metadataObjectTypes = [.qr] + } + session.commitConfiguration() + session.startRunning() + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + for obj in metadataObjects { + guard let readable = obj as? AVMetadataMachineReadableCodeObject else { continue } + guard let value = readable.stringValue, !value.isEmpty else { continue } + ScannerPlugin.eventEmitter.send(value) + } + } +} diff --git a/ios/Classes/UxPlugin.swift b/ios/Classes/UxPlugin.swift index d20d564..6505c90 100644 --- a/ios/Classes/UxPlugin.swift +++ b/ios/Classes/UxPlugin.swift @@ -9,6 +9,7 @@ public class UxPlugin: NSObject, FlutterPlugin { KeyboardPlugin(), SensorPlugin(), FilePlugin(), + ScannerPlugin(), ] for plugin in plugins { plugin.register(with: registrar) diff --git a/ios/ux.podspec b/ios/ux.podspec index ee9294f..0caa2c8 100644 --- a/ios/ux.podspec +++ b/ios/ux.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'ux' - s.version = '0.6.0' - s.summary = 'UX Kit — Flutter plugin: keyboard, sensor, and file share/open.' + s.version = '0.9.0' + s.summary = 'UX Kit — Flutter plugin: keyboard, sensor, file, and QR scanner.' s.homepage = 'https://swipelab.co/ux.html' s.license = { :file => '../LICENSE' } s.author = { 'Swipelab' => 'hello@swipelab.co' } diff --git a/lib/src/scanner.dart b/lib/src/scanner.dart new file mode 100644 index 0000000..e239512 --- /dev/null +++ b/lib/src/scanner.dart @@ -0,0 +1,116 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Barcode formats the platform decoder will look for. Today only QR is +/// supported; the enum exists for forward-compatibility with the native +/// channel arguments. +enum BarcodeFormat { qr } + +/// Static helpers exposed by the platform-side scanner plugin. +class UxScannerPermission { + UxScannerPermission._(); + + static const _channel = MethodChannel('ux/scanner'); + + /// Requests camera permission. Returns `true` once the OS has reported + /// authorized; `false` if the user denied or the platform is + /// unsupported. Safe to call multiple times — already-granted returns + /// `true` immediately. + static Future requestCamera() async { + if (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.android) { + return false; + } + final granted = await _channel.invokeMethod('requestPermission'); + return granted ?? false; + } +} + +/// Camera-preview widget that emits decoded barcode payloads. +/// +/// Backed by AVFoundation on iOS (built-in QR support, no extra dep) and +/// CameraX + ZXing on Android. The widget mounts a platform-view — +/// `UiKitView` on iOS, `AndroidView` on Android — and listens to a +/// per-process event channel for decoded strings. +/// +/// Camera permission must be granted by the host app before mounting. +/// On platforms other than iOS / Android the widget renders an empty +/// box. +class UxScanner extends StatefulWidget { + const UxScanner({ + super.key, + required this.onCode, + this.formats = const [BarcodeFormat.qr], + }); + + final ValueChanged onCode; + final List formats; + + @override + State createState() => _UxScannerState(); +} + +class _UxScannerState extends State { + static const _events = EventChannel('ux/scanner/events'); + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _sub = _events.receiveBroadcastStream().listen((event) { + final code = event as String?; + if (code == null || !mounted) return; + widget.onCode(code); + }); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final creationParams = { + 'formats': widget.formats.map((e) => e.name).toList(), + }; + if (defaultTargetPlatform == TargetPlatform.iOS) { + return UiKitView( + viewType: 'ux/scanner', + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + ); + } + if (defaultTargetPlatform == TargetPlatform.android) { + return PlatformViewLink( + viewType: 'ux/scanner', + surfaceFactory: (context, controller) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + ); + }, + onCreatePlatformView: (params) { + return PlatformViewsService.initSurfaceAndroidView( + id: params.id, + viewType: 'ux/scanner', + layoutDirection: TextDirection.ltr, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..create(); + }, + ); + } + return const SizedBox.shrink(); + } +} diff --git a/lib/ux.dart b/lib/ux.dart index 3f3bfe4..ebab76d 100644 --- a/lib/ux.dart +++ b/lib/ux.dart @@ -11,6 +11,7 @@ export 'src/bezier.dart'; export 'src/file.dart'; export 'src/keyboard.dart'; export 'src/auto_map.dart'; +export 'src/scanner.dart'; export 'src/sensor.dart'; export 'src/functional.dart'; export 'src/log.dart'; \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index d8a9724..5b71f6a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: >- Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate keyboard height tracking via FFI with interactive dismiss, bezier utilities, and layout primitives. -version: 0.8.0 +version: 0.9.0 homepage: https://swipelab.co/ux.html repository: https://github.com/swipelab/ux issue_tracker: https://github.com/swipelab/ux/issues