Flutter plugin for scanning and generating QR codes using the ZXing library, supporting Android, iOS, and desktop platforms
flutterbarcode-generatorbarcode-scannergeneratorqrqrcodeqrcode-generatorqrcode-scannerscannerzxingbarcodezxscanner
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
357 lines
10 KiB
357 lines
10 KiB
import 'dart:async'; |
|
import 'dart:io'; |
|
import 'dart:math'; |
|
|
|
import 'package:camera/camera.dart'; |
|
import 'package:flutter/material.dart'; |
|
|
|
import '../../flutter_zxing.dart'; |
|
|
|
/// Widget to scan a code from the camera stream |
|
class ReaderWidget extends StatefulWidget { |
|
const ReaderWidget({ |
|
super.key, |
|
this.onScan, |
|
this.onScanFailure, |
|
this.onMultiScan, |
|
this.onMultiScanFailure, |
|
this.onControllerCreated, |
|
this.isMultiScan = false, |
|
this.codeFormat = Format.any, |
|
this.tryHarder = false, |
|
this.tryInverted = false, |
|
this.showScannerOverlay = true, |
|
this.scannerOverlay, |
|
this.showFlashlight = true, |
|
this.allowPinchZoom = true, |
|
this.scanDelay = const Duration(milliseconds: 1000), // 1000ms delay |
|
this.scanDelaySuccess = const Duration(milliseconds: 1000), // 1000ms delay |
|
this.cropPercent = 0.5, // 50% of the screen |
|
this.resolution = ResolutionPreset.high, |
|
this.loading = |
|
const DecoratedBox(decoration: BoxDecoration(color: Colors.black)), |
|
}); |
|
|
|
/// Called when a code is detected |
|
final Function(Code)? onScan; |
|
|
|
/// Called when a code is not detected |
|
final Function(Code)? onScanFailure; |
|
|
|
/// Called when a code is detected |
|
final Function(List<Code>)? onMultiScan; |
|
|
|
/// Called when a code is not detected |
|
final Function(List<Code>)? onMultiScanFailure; |
|
|
|
/// Called when the camera controller is created |
|
final Function(CameraController?)? onControllerCreated; |
|
|
|
/// Allow multiple scans |
|
final bool isMultiScan; |
|
|
|
/// Code format to scan |
|
final int codeFormat; |
|
|
|
/// Try harder to detect a code |
|
final bool tryHarder; |
|
|
|
/// Try to detect inverted code |
|
final bool tryInverted; |
|
|
|
/// Show cropping rect |
|
final bool showScannerOverlay; |
|
|
|
/// Custom scanner overlay |
|
final ScannerOverlay? scannerOverlay; |
|
|
|
/// Show flashlight button |
|
final bool showFlashlight; |
|
|
|
/// Allow pinch zoom |
|
final bool allowPinchZoom; |
|
|
|
/// Delay between scans when no code is detected |
|
final Duration scanDelay; |
|
|
|
/// Crop percent of the screen |
|
final double cropPercent; |
|
|
|
/// Camera resolution |
|
final ResolutionPreset resolution; |
|
|
|
/// Delay between scans when a code is detected |
|
final Duration scanDelaySuccess; |
|
|
|
/// Loading widget while camera is initializing. Default is a black screen |
|
final Widget loading; |
|
|
|
@override |
|
State<ReaderWidget> createState() => _ReaderWidgetState(); |
|
} |
|
|
|
class _ReaderWidgetState extends State<ReaderWidget> |
|
with TickerProviderStateMixin, WidgetsBindingObserver { |
|
List<CameraDescription> cameras = <CameraDescription>[]; |
|
CameraController? controller; |
|
bool _cameraOn = false; |
|
|
|
double _zoom = 1.0; |
|
double _scaleFactor = 1.0; |
|
double _maxZoomLevel = 1.0; |
|
double _minZoomLevel = 1.0; |
|
|
|
bool isAndroid() => Theme.of(context).platform == TargetPlatform.android; |
|
|
|
// true when code detecting is ongoing |
|
bool _isProcessing = false; |
|
|
|
@override |
|
void initState() { |
|
super.initState(); |
|
WidgetsBinding.instance.addObserver(this); |
|
initStateAsync(); |
|
} |
|
|
|
Future<void> initStateAsync() async { |
|
// Spawn a new isolate |
|
await zx.startCameraProcessing(); |
|
|
|
availableCameras().then((List<CameraDescription> cameras) { |
|
setState(() { |
|
this.cameras = cameras; |
|
if (cameras.isNotEmpty) { |
|
onNewCameraSelected(cameras.first); |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
@override |
|
void didChangeAppLifecycleState(AppLifecycleState state) { |
|
final CameraController? cameraController = controller; |
|
if (cameraController == null || !cameraController.value.isInitialized) { |
|
return; |
|
} |
|
|
|
switch (state) { |
|
case AppLifecycleState.resumed: |
|
if (cameras.isNotEmpty && !_cameraOn) { |
|
onNewCameraSelected(cameras.first); |
|
} |
|
break; |
|
case AppLifecycleState.inactive: |
|
case AppLifecycleState.paused: |
|
controller?.dispose(); |
|
setState(() { |
|
_cameraOn = false; |
|
}); |
|
break; |
|
case AppLifecycleState.detached: |
|
break; |
|
} |
|
} |
|
|
|
@override |
|
void dispose() { |
|
zx.stopCameraProcessing(); |
|
controller?.removeListener(rebuildOnMount); |
|
controller?.dispose(); |
|
WidgetsBinding.instance.removeObserver(this); |
|
super.dispose(); |
|
} |
|
|
|
void rebuildOnMount() { |
|
if (mounted) { |
|
setState(() { |
|
_cameraOn = true; |
|
}); |
|
} |
|
} |
|
|
|
Future<void> onNewCameraSelected(CameraDescription cameraDescription) async { |
|
final CameraController? oldController = controller; |
|
if (oldController != null) { |
|
// controller?.removeListener(rebuildOnMount); |
|
controller = null; |
|
await oldController.dispose(); |
|
} |
|
|
|
final CameraController cameraController = CameraController( |
|
cameraDescription, |
|
widget.resolution, |
|
enableAudio: false, |
|
imageFormatGroup: |
|
isAndroid() ? ImageFormatGroup.yuv420 : ImageFormatGroup.bgra8888, |
|
); |
|
controller = cameraController; |
|
cameraController.addListener(rebuildOnMount); |
|
try { |
|
await cameraController.initialize(); |
|
await cameraController.setFlashMode(FlashMode.off); |
|
cameraController |
|
.getMaxZoomLevel() |
|
.then((double value) => _maxZoomLevel = value); |
|
cameraController |
|
.getMinZoomLevel() |
|
.then((double value) => _minZoomLevel = value); |
|
cameraController.startImageStream(processImageStream); |
|
} on CameraException catch (e) { |
|
debugPrint('${e.code}: ${e.description}'); |
|
} catch (e) { |
|
debugPrint('Error: $e'); |
|
} |
|
rebuildOnMount(); |
|
widget.onControllerCreated?.call(controller); |
|
} |
|
|
|
Future<void> processImageStream(CameraImage image) async { |
|
if (!_isProcessing) { |
|
_isProcessing = true; |
|
try { |
|
final double cropPercent = |
|
widget.showScannerOverlay ? widget.cropPercent : 0; |
|
final int cropSize = |
|
(min(image.width, image.height) * cropPercent).round(); |
|
final DecodeParams params = DecodeParams( |
|
format: widget.codeFormat, |
|
cropWidth: cropSize, |
|
cropHeight: cropSize, |
|
tryHarder: widget.tryHarder, |
|
tryInverted: widget.tryInverted, |
|
isMultiScan: widget.isMultiScan, |
|
); |
|
if (widget.isMultiScan) { |
|
final List<Code> results = await zx.processCameraImageMulti( |
|
image, |
|
params: params, |
|
); |
|
if (results.isNotEmpty) { |
|
widget.onMultiScan?.call(results); |
|
await Future<void>.delayed(widget.scanDelaySuccess); |
|
setState(() {}); |
|
} else { |
|
widget.onMultiScanFailure?.call(results); |
|
} |
|
} else { |
|
final Code result = await zx.processCameraImage( |
|
image, |
|
params: params, |
|
); |
|
if (result.isValid) { |
|
widget.onScan?.call(result); |
|
setState(() {}); |
|
await Future<void>.delayed(widget.scanDelaySuccess); |
|
} else { |
|
widget.onScanFailure?.call(result); |
|
} |
|
} |
|
} on FileSystemException catch (e) { |
|
debugPrint(e.message); |
|
} catch (e) { |
|
debugPrint(e.toString()); |
|
} |
|
await Future<void>.delayed(widget.scanDelay); |
|
_isProcessing = false; |
|
} |
|
|
|
return; |
|
} |
|
|
|
@override |
|
Widget build(BuildContext context) { |
|
final bool isCameraReady = cameras.isNotEmpty && |
|
_cameraOn && |
|
controller != null && |
|
controller!.value.isInitialized; |
|
final Size size = MediaQuery.of(context).size; |
|
final double cameraMaxSize = max(size.width, size.height); |
|
final double cropSize = min(size.width, size.height) * widget.cropPercent; |
|
return Stack( |
|
children: <Widget>[ |
|
if (!isCameraReady) widget.loading, |
|
if (isCameraReady) |
|
SizedBox( |
|
width: cameraMaxSize, |
|
height: cameraMaxSize, |
|
child: ClipRRect( |
|
child: OverflowBox( |
|
child: FittedBox( |
|
fit: BoxFit.cover, |
|
child: SizedBox( |
|
width: cameraMaxSize, |
|
child: CameraPreview( |
|
controller!, |
|
), |
|
), |
|
), |
|
), |
|
), |
|
), |
|
if (widget.showScannerOverlay) |
|
Container( |
|
decoration: ShapeDecoration( |
|
shape: widget.scannerOverlay ?? |
|
FixedScannerOverlay( |
|
borderColor: Theme.of(context).primaryColor, |
|
overlayColor: Colors.black45, |
|
borderRadius: 1, |
|
borderLength: 16, |
|
borderWidth: 8, |
|
cutOutSize: cropSize, |
|
), |
|
), |
|
), |
|
if (widget.allowPinchZoom) |
|
GestureDetector( |
|
onScaleStart: (ScaleStartDetails details) { |
|
_zoom = _scaleFactor; |
|
}, |
|
onScaleUpdate: (ScaleUpdateDetails details) { |
|
_scaleFactor = |
|
(_zoom * details.scale).clamp(_minZoomLevel, _maxZoomLevel); |
|
controller?.setZoomLevel(_scaleFactor); |
|
}, |
|
), |
|
if (widget.showFlashlight && isCameraReady) |
|
Positioned( |
|
bottom: 20, |
|
left: 20, |
|
child: FloatingActionButton( |
|
onPressed: () { |
|
FlashMode mode = controller!.value.flashMode; |
|
if (mode == FlashMode.torch) { |
|
mode = FlashMode.off; |
|
} else { |
|
mode = FlashMode.torch; |
|
} |
|
controller?.setFlashMode(mode); |
|
setState(() {}); |
|
}, |
|
backgroundColor: Colors.black26, |
|
child: _FlashIcon( |
|
flashMode: controller?.value.flashMode ?? FlashMode.off)), |
|
), |
|
], |
|
); |
|
} |
|
} |
|
|
|
class _FlashIcon extends StatelessWidget { |
|
const _FlashIcon({required this.flashMode}); |
|
final FlashMode flashMode; |
|
|
|
@override |
|
Widget build(BuildContext context) { |
|
switch (flashMode) { |
|
case FlashMode.torch: |
|
return const Icon(Icons.flash_on); |
|
case FlashMode.off: |
|
return const Icon(Icons.flash_off); |
|
case FlashMode.always: |
|
return const Icon(Icons.flash_on); |
|
case FlashMode.auto: |
|
return const Icon(Icons.flash_auto); |
|
} |
|
} |
|
}
|
|
|