scanner
This commit is contained in:
@@ -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
|
### 0.8.0
|
||||||
- `Log`: pretty, production-ready logger. Static entry (`Log.d/i/w/e/f/t`),
|
- `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)`),
|
scoped loggers via `Log.tag('KB')`, lazy messages (`Log.d(() => expensive)`),
|
||||||
|
|||||||
@@ -52,3 +52,15 @@ android {
|
|||||||
jvmTarget = '1.8'
|
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'
|
||||||
|
}
|
||||||
|
|||||||
243
android/src/main/kotlin/io/swipelab/ux/ScannerPlugin.kt
Normal file
243
android/src/main/kotlin/io/swipelab/ux/ScannerPlugin.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
|
|||||||
KeyboardPlugin(),
|
KeyboardPlugin(),
|
||||||
SensorPlugin(),
|
SensorPlugin(),
|
||||||
FilePlugin(),
|
FilePlugin(),
|
||||||
|
ScannerPlugin(),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ packages:
|
|||||||
path: ".."
|
path: ".."
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.8.0"
|
version: "0.9.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
161
ios/Classes/ScannerPlugin.swift
Normal file
161
ios/Classes/ScannerPlugin.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
|
|||||||
KeyboardPlugin(),
|
KeyboardPlugin(),
|
||||||
SensorPlugin(),
|
SensorPlugin(),
|
||||||
FilePlugin(),
|
FilePlugin(),
|
||||||
|
ScannerPlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
plugin.register(with: registrar)
|
plugin.register(with: registrar)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
Pod::Spec.new do |s|
|
Pod::Spec.new do |s|
|
||||||
s.name = 'ux'
|
s.name = 'ux'
|
||||||
s.version = '0.6.0'
|
s.version = '0.9.0'
|
||||||
s.summary = 'UX Kit — Flutter plugin: keyboard, sensor, and file share/open.'
|
s.summary = 'UX Kit — Flutter plugin: keyboard, sensor, file, and QR scanner.'
|
||||||
s.homepage = 'https://swipelab.co/ux.html'
|
s.homepage = 'https://swipelab.co/ux.html'
|
||||||
s.license = { :file => '../LICENSE' }
|
s.license = { :file => '../LICENSE' }
|
||||||
s.author = { 'Swipelab' => 'hello@swipelab.co' }
|
s.author = { 'Swipelab' => 'hello@swipelab.co' }
|
||||||
|
|||||||
116
lib/src/scanner.dart
Normal file
116
lib/src/scanner.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export 'src/bezier.dart';
|
|||||||
export 'src/file.dart';
|
export 'src/file.dart';
|
||||||
export 'src/keyboard.dart';
|
export 'src/keyboard.dart';
|
||||||
export 'src/auto_map.dart';
|
export 'src/auto_map.dart';
|
||||||
|
export 'src/scanner.dart';
|
||||||
export 'src/sensor.dart';
|
export 'src/sensor.dart';
|
||||||
export 'src/functional.dart';
|
export 'src/functional.dart';
|
||||||
export 'src/log.dart';
|
export 'src/log.dart';
|
||||||
@@ -3,7 +3,7 @@ description: >-
|
|||||||
Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate
|
Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate
|
||||||
keyboard height tracking via FFI with interactive dismiss, bezier utilities,
|
keyboard height tracking via FFI with interactive dismiss, bezier utilities,
|
||||||
and layout primitives.
|
and layout primitives.
|
||||||
version: 0.8.0
|
version: 0.9.0
|
||||||
homepage: https://swipelab.co/ux.html
|
homepage: https://swipelab.co/ux.html
|
||||||
repository: https://github.com/swipelab/ux
|
repository: https://github.com/swipelab/ux
|
||||||
issue_tracker: https://github.com/swipelab/ux/issues
|
issue_tracker: https://github.com/swipelab/ux/issues
|
||||||
|
|||||||
Reference in New Issue
Block a user