Navaron Bracke

reimplement parts of the MobileScannerController using the platform interface

import 'dart:async';
import 'dart:io';
// ignore: unnecessary_import
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
... ... @@ -12,53 +9,45 @@ import 'package:mobile_scanner/src/enums/mobile_scanner_error_code.dart';
import 'package:mobile_scanner/src/enums/mobile_scanner_state.dart';
import 'package:mobile_scanner/src/enums/torch_state.dart';
import 'package:mobile_scanner/src/mobile_scanner_exception.dart';
import 'package:mobile_scanner/src/objects/barcode.dart';
import 'package:mobile_scanner/src/mobile_scanner_platform_interface.dart';
import 'package:mobile_scanner/src/objects/barcode_capture.dart';
import 'package:mobile_scanner/src/objects/mobile_scanner_arguments.dart';
/// The [MobileScannerController] holds all the logic of this plugin,
/// where as the [MobileScanner] class is the frontend of this plugin.
class MobileScannerController {
/// The controller for the [MobileScanner] widget.
class MobileScannerController extends ValueNotifier<MobileScannerState> {
/// Construct a new [MobileScannerController] instance.
MobileScannerController({
this.facing = CameraFacing.back,
this.cameraResolution,
this.detectionSpeed = DetectionSpeed.normal,
this.detectionTimeoutMs = 250,
this.torchEnabled = false,
this.formats,
int detectionTimeoutMs = 250,
this.facing = CameraFacing.back,
this.formats = const <BarcodeFormat>[],
this.returnImage = false,
@Deprecated(
'Instead, use the result of calling `start()` to determine if permissions were granted.',
)
this.onPermissionSet,
this.autoStart = true,
this.cameraResolution,
this.torchEnabled = false,
this.useNewCameraSelector = false,
});
}) : detectionTimeoutMs = detectionSpeed == DetectionSpeed.normal ? detectionTimeoutMs : 0,
assert(detectionTimeoutMs >= 0, 'The detection timeout must be greater than or equal to 0.'),
super(MobileScannerState.uninitialized(facing));
/// Select which camera should be used.
/// The desired resolution for the camera.
///
/// Default: CameraFacing.back
final CameraFacing facing;
/// Enable or disable the torch (Flash) on start
/// When this value is provided, the camera will try to match this resolution,
/// or fallback to the closest available resolution.
/// When this is null, Android defaults to a resolution of 640x480.
///
/// Default: disabled
final bool torchEnabled;
/// Set to true if you want to return the image buffer with the Barcode event
/// Bear in mind that changing the resolution has an effect on the aspect ratio.
///
/// Only supported on iOS and Android
final bool returnImage;
/// If provided, the scanner will only detect those specific formats
final List<BarcodeFormat>? formats;
/// When the camera orientation changes,
/// the resolution will be flipped to match the new dimensions of the display.
///
/// Currently only supported on Android.
final Size? cameraResolution;
/// Sets the speed of detections.
/// The detection speed for the scanner.
///
/// WARNING: DetectionSpeed.unrestricted can cause memory issues on some devices
/// Defaults to [DetectionSpeed.normal].
final DetectionSpeed detectionSpeed;
/// Sets the timeout, in milliseconds, of the scanner.
/// The detection timeout, in milliseconds, for the scanner.
///
/// This timeout is ignored if the [detectionSpeed]
/// is not set to [DetectionSpeed.normal].
... ... @@ -67,444 +56,123 @@ class MobileScannerController {
/// which prevents memory issues on older devices.
final int detectionTimeoutMs;
/// Automatically start the mobileScanner on initialization.
final bool autoStart;
/// The facing direction for the camera.
///
/// Defaults to the back-facing camera.
final CameraFacing facing;
/// The desired resolution for the camera.
/// The formats that the scanner should detect.
///
/// When this value is provided, the camera will try to match this resolution,
/// or fallback to the closest available resolution.
/// When this is null, Android defaults to a resolution of 640x480.
/// If this is empty, all supported formats are detected.
final List<BarcodeFormat> formats;
/// Whether scanned barcodes should contain the image
/// that is embedded into the barcode.
///
/// Bear in mind that changing the resolution has an effect on the aspect ratio.
/// If this is false, [BarcodeCapture.image] will always be null.
///
/// When the camera orientation changes,
/// the resolution will be flipped to match the new dimensions of the display.
/// Defaults to false, and is only supported on iOS and Android.
final bool returnImage;
/// Whether the flashlight should be turned on when the camera is started.
///
/// Currently only supported on Android.
final Size? cameraResolution;
/// Defaults to false.
final bool torchEnabled;
/// Use the new resolution selector. Warning: not fully tested, may produce
/// unwanted/zoomed images.
/// Use the new resolution selector.
///
/// This feature is experimental and not fully tested yet.
/// Use caution when using this flag,
/// as the new resolution selector may produce unwanted or zoomed images.
///
/// Only supported on Android
/// Only supported on Android.
final bool useNewCameraSelector;
/// Sets the barcode stream
final StreamController<BarcodeCapture> _barcodesController =
StreamController.broadcast();
/// The internal barcode controller, that listens for detected barcodes.
final StreamController<BarcodeCapture> _barcodesController = StreamController.broadcast();
/// Get the stream of scanned barcodes.
Stream<BarcodeCapture> get barcodes => _barcodesController.stream;
static const MethodChannel _methodChannel =
MethodChannel('dev.steenbakker.mobile_scanner/scanner/method');
static const EventChannel _eventChannel =
EventChannel('dev.steenbakker.mobile_scanner/scanner/event');
@Deprecated(
'Instead, use the result of calling `start()` to determine if permissions were granted.',
)
Function(bool permissionGranted)? onPermissionSet;
/// Listen to events from the platform specific code
StreamSubscription? events;
/// A notifier that provides several arguments about the MobileScanner
final ValueNotifier<MobileScannerArguments?> startArguments =
ValueNotifier(null);
/// A notifier that provides the state of the Torch (Flash)
final ValueNotifier<TorchState> torchState = ValueNotifier(TorchState.off);
/// A notifier that provides the state of which camera is being used
late final ValueNotifier<CameraFacing> cameraFacingState =
ValueNotifier(facing);
/// A notifier that provides zoomScale.
final ValueNotifier<double> zoomScaleState = ValueNotifier(0.0);
bool isStarting = false;
/// A notifier that provides availability of the Torch (Flash)
final ValueNotifier<bool?> hasTorchState = ValueNotifier(false);
/// Returns whether the device has a torch.
/// Analyze an image file.
///
/// Throws an error if the controller is not initialized.
bool get hasTorch {
final hasTorch = hasTorchState.value;
if (hasTorch == null) {
throw const MobileScannerException(
errorCode: MobileScannerErrorCode.controllerUninitialized,
);
}
return hasTorch;
}
/// Set the starting arguments for the camera
Map<String, dynamic> _argumentsToMap({CameraFacing? cameraFacingOverride}) {
final Map<String, dynamic> arguments = {};
cameraFacingState.value = cameraFacingOverride ?? facing;
arguments['facing'] = cameraFacingState.value.rawValue;
arguments['torch'] = torchEnabled;
arguments['speed'] = detectionSpeed.rawValue;
arguments['timeout'] = detectionTimeoutMs;
arguments['returnImage'] = returnImage;
arguments['useNewCameraSelector'] = useNewCameraSelector;
/* if (scanWindow != null) {
arguments['scanWindow'] = [
scanWindow!.left,
scanWindow!.top,
scanWindow!.right,
scanWindow!.bottom,
];
} */
if (formats != null) {
if (kIsWeb || Platform.isIOS || Platform.isMacOS || Platform.isAndroid) {
arguments['formats'] = formats!.map((e) => e.rawValue).toList();
}
}
if (cameraResolution != null) {
arguments['cameraResolution'] = <int>[
cameraResolution!.width.toInt(),
cameraResolution!.height.toInt(),
];
}
return arguments;
}
/// Start scanning for barcodes.
/// Upon calling this method, the necessary camera permission will be requested.
/// The [path] points to a file on the device.
///
/// Returns an instance of [MobileScannerArguments]
/// when the scanner was successfully started.
/// Returns null if the scanner is currently starting.
/// This is only supported on Android and iOS.
///
/// Throws a [MobileScannerException] if starting the scanner failed.
Future<MobileScannerArguments?> start({
CameraFacing? cameraFacingOverride,
}) async {
if (isStarting) {
debugPrint("Called start() while starting.");
return null;
/// Returns the [BarcodeCapture] that was found in the image.
Future<BarcodeCapture?> analyzeImage(String path) {
return MobileScannerPlatform.instance.analyzeImage(path);
}
events ??= _eventChannel
.receiveBroadcastStream()
.listen((data) => _handleEvent(data as Map));
isStarting = true;
// Check authorization status
if (!kIsWeb) {
final MobileScannerState state;
try {
state = MobileScannerState.fromRawValue(
await _methodChannel.invokeMethod('state') as int? ?? 0,
);
} on PlatformException catch (error) {
isStarting = false;
throw MobileScannerException(
errorCode: MobileScannerErrorCode.genericError,
errorDetails: MobileScannerErrorDetails(
code: error.code,
details: error.details as Object?,
message: error.message,
),
);
/// Reset the zoom scale of the camera.
Future<void> resetZoomScale() async {
await MobileScannerPlatform.instance.resetZoomScale();
}
switch (state) {
// Android does not have an undetermined permission state.
// So if the permission state is denied, just request it now.
case MobileScannerState.undetermined:
case MobileScannerState.denied:
try {
final bool granted =
await _methodChannel.invokeMethod('request') as bool? ?? false;
if (!granted) {
isStarting = false;
/// Set the zoom scale of the camera.
///
/// The [zoomScale] must be between 0.0 and 1.0 (both inclusive).
Future<void> setZoomScale(double zoomScale) async {
if (zoomScale < 0 || zoomScale > 1) {
throw const MobileScannerException(
errorCode: MobileScannerErrorCode.permissionDenied,
);
}
} on PlatformException catch (error) {
isStarting = false;
throw MobileScannerException(
errorCode: MobileScannerErrorCode.genericError,
errorDetails: MobileScannerErrorDetails(
code: error.code,
details: error.details as Object?,
message: error.message,
message: 'The zoomScale must be between 0.0 and 1.0',
),
);
}
case MobileScannerState.authorized:
break;
}
await MobileScannerPlatform.instance.setZoomScale(zoomScale);
}
// Start the camera with arguments
Map<String, dynamic>? startResult = {};
try {
startResult = await _methodChannel.invokeMapMethod<String, dynamic>(
'start',
_argumentsToMap(cameraFacingOverride: cameraFacingOverride),
);
} on PlatformException catch (error) {
MobileScannerErrorCode errorCode = MobileScannerErrorCode.genericError;
final String? errorMessage = error.message;
if (kIsWeb) {
if (errorMessage == null) {
errorCode = MobileScannerErrorCode.genericError;
} else if (errorMessage.contains('NotFoundError') ||
errorMessage.contains('NotSupportedError')) {
errorCode = MobileScannerErrorCode.unsupported;
} else if (errorMessage.contains('NotAllowedError')) {
errorCode = MobileScannerErrorCode.permissionDenied;
} else {
errorCode = MobileScannerErrorCode.genericError;
}
}
isStarting = false;
throw MobileScannerException(
errorCode: errorCode,
errorDetails: MobileScannerErrorDetails(
code: error.code,
details: error.details as Object?,
message: error.message,
),
);
}
/// Stop the camera.
///
/// After calling this method, the camera can be restarted using [start].
Future<void> stop() async {
await MobileScannerPlatform.instance.stop();
if (startResult == null) {
isStarting = false;
throw const MobileScannerException(
errorCode: MobileScannerErrorCode.genericError,
);
// After the camera stopped, set the torch state to off,
// as the torch state callback is never called when the camera is stopped.
torchState.value = TorchState.off;
}
final hasTorch = startResult['torchable'] as bool? ?? false;
hasTorchState.value = hasTorch;
final Size size;
if (kIsWeb) {
size = Size(
startResult['videoWidth'] as double? ?? 0,
startResult['videoHeight'] as double? ?? 0,
);
} else {
final Map<Object?, Object?>? sizeInfo =
startResult['size'] as Map<Object?, Object?>?;
size = Size(
sizeInfo?['width'] as double? ?? 0,
sizeInfo?['height'] as double? ?? 0,
);
}
/// Switch between the front and back camera.
Future<void> switchCamera() async {
await MobileScannerPlatform.instance.stop();
isStarting = false;
return startArguments.value = MobileScannerArguments(
numberOfCameras: startResult['numberOfCameras'] as int?,
size: size,
hasTorch: hasTorch,
textureId: kIsWeb ? null : startResult['textureId'] as int?,
webId: kIsWeb ? startResult['ViewID'] as String? : null,
);
}
final CameraFacing cameraDirection;
/// Stops the camera, but does not dispose this controller.
Future<void> stop() async {
await _methodChannel.invokeMethod('stop');
// TODO: update the camera facing direction state
// After the camera stopped, set the torch state to off,
// as the torch state callback is never called when the camera is stopped.
torchState.value = TorchState.off;
await start(cameraDirection: cameraDirection);
}
/// Switches the torch on or off.
/// Switches the flashlight on or off.
///
/// Does nothing if the device has no torch.
///
/// Throws if the controller was not initialized.
Future<void> toggleTorch() async {
final hasTorch = hasTorchState.value;
if (hasTorch == null) {
throw const MobileScannerException(
errorCode: MobileScannerErrorCode.controllerUninitialized,
);
}
final bool hasTorch;
if (!hasTorch) {
return;
}
final TorchState newState =
torchState.value == TorchState.off ? TorchState.on : TorchState.off;
await _methodChannel.invokeMethod('torch', newState.rawValue);
}
final TorchState newState = torchState.value == TorchState.off ? TorchState.on : TorchState.off;
/// Changes the state of the camera (front or back).
///
/// Does nothing if the device has no front camera.
Future<void> switchCamera() async {
await _methodChannel.invokeMethod('stop');
final CameraFacing facingToUse =
cameraFacingState.value == CameraFacing.back
? CameraFacing.front
: CameraFacing.back;
await start(cameraFacingOverride: facingToUse);
// Update the torch state to the new state.
// When the platform has updated the torch state,
// it will send an update through the torch state event stream.
await MobileScannerPlatform.instance.setTorchState();
}
/// Handles a local image file.
/// Returns true if a barcode or QR code is found.
/// Returns false if nothing is found.
///
/// [path] The path of the image on the devices
Future<bool> analyzeImage(String path) async {
events ??= _eventChannel
.receiveBroadcastStream()
.listen((data) => _handleEvent(data as Map));
return _methodChannel
.invokeMethod<bool>('analyzeImage', path)
.then<bool>((bool? value) => value ?? false);
}
/// Set the zoomScale of the camera.
///
/// [zoomScale] must be within 0.0 and 1.0, where 1.0 is the max zoom, and 0.0
/// is zoomed out.
Future<void> setZoomScale(double zoomScale) async {
if (zoomScale < 0 || zoomScale > 1) {
throw const MobileScannerException(
errorCode: MobileScannerErrorCode.genericError,
errorDetails: MobileScannerErrorDetails(
message: 'The zoomScale must be between 0 and 1.',
),
);
}
await _methodChannel.invokeMethod('setScale', zoomScale);
}
/// Reset the zoomScale of the camera to use standard scale 1x.
Future<void> resetZoomScale() async {
await _methodChannel.invokeMethod('resetScale');
}
/// Disposes the MobileScannerController and closes all listeners.
///
/// If you call this, you cannot use this controller object anymore.
void dispose() {
stop();
events?.cancel();
_barcodesController.close();
}
/// Handles a returning event from the platform side
void _handleEvent(Map event) {
final name = event['name'];
final data = event['data'];
switch (name) {
case 'torchState':
final state = TorchState.values[data as int? ?? 0];
torchState.value = state;
case 'zoomScaleState':
zoomScaleState.value = data as double? ?? 0.0;
case 'barcode':
if (data == null) return;
final parsed = (data as List)
.map((value) => Barcode.fromNative(value as Map))
.toList();
final double? width = event['width'] as double?;
final double? height = event['height'] as double?;
_barcodesController.add(
BarcodeCapture(
raw: data,
barcodes: parsed,
image: event['image'] as Uint8List?,
size: width == null || height == null
? Size.zero
: Size(width, height),
),
);
case 'barcodeMac':
_barcodesController.add(
BarcodeCapture(
raw: data,
barcodes: [
Barcode(
rawValue: (data as Map)['payload'] as String?,
format: BarcodeFormat.fromRawValue(
data['symbology'] as int? ?? -1,
),
),
],
),
);
case 'barcodeWeb':
final barcode = data as Map?;
final corners = barcode?['corners'] as List<Object?>? ?? <Object?>[];
_barcodesController.add(
BarcodeCapture(
raw: data,
barcodes: [
if (barcode != null)
Barcode(
rawValue: barcode['rawValue'] as String?,
rawBytes: barcode['rawBytes'] as Uint8List?,
format: BarcodeFormat.fromRawValue(
barcode['format'] as int? ?? -1,
),
corners: List.unmodifiable(
corners.cast<Map<Object?, Object?>>().map(
(Map<Object?, Object?> e) {
return Offset(e['x']! as double, e['y']! as double);
},
),
),
),
],
),
);
case 'error':
throw MobileScannerException(
errorCode: MobileScannerErrorCode.genericError,
errorDetails: MobileScannerErrorDetails(message: data as String?),
);
default:
throw UnimplementedError(name as String?);
}
}
/// updates the native ScanWindow
Future<void> updateScanWindow(Rect? window) async {
List? data;
if (window != null) {
data = [window.left, window.top, window.right, window.bottom];
}
@override
Future<void> dispose() async {
await MobileScannerPlatform.instance.dispose();
unawaited(_barcodesController.close());
await _methodChannel.invokeMethod('updateScanWindow', {'rect': data});
super.dispose();
}
}
... ...