This commit is contained in:
agra
2026-05-07 09:22:01 +03:00
parent 6d8efafaa0
commit 26cdf63afc
11 changed files with 547 additions and 4 deletions

View File

@@ -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)`),

View File

@@ -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'
}

View File

@@ -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()
}
}

View File

@@ -9,6 +9,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
KeyboardPlugin(),
SensorPlugin(),
FilePlugin(),
ScannerPlugin(),
)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =

View File

@@ -502,7 +502,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.8.0"
version: "0.9.0"
vector_math:
dependency: transitive
description:

View File

@@ -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)
}
}
}

View File

@@ -9,6 +9,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
KeyboardPlugin(),
SensorPlugin(),
FilePlugin(),
ScannerPlugin(),
]
for plugin in plugins {
plugin.register(with: registrar)

View File

@@ -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' }

116
lib/src/scanner.dart Normal file
View File

@@ -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<bool> requestCamera() async {
if (defaultTargetPlatform != TargetPlatform.iOS &&
defaultTargetPlatform != TargetPlatform.android) {
return false;
}
final granted = await _channel.invokeMethod<bool>('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<String> onCode;
final List<BarcodeFormat> formats;
@override
State<UxScanner> createState() => _UxScannerState();
}
class _UxScannerState extends State<UxScanner> {
static const _events = EventChannel('ux/scanner/events');
StreamSubscription<dynamic>? _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 = <String, Object?>{
'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 <Factory<OneSequenceGestureRecognizer>>{},
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();
}
}

View File

@@ -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';

View File

@@ -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