veilidchat/lib/contact_invitation/views/camera_qr_scanner.dart
2025-06-05 23:46:46 +02:00

473 lines
15 KiB
Dart

import 'dart:async';
import 'package:async_tools/async_tools.dart';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:zxing2/qrcode.dart';
import '../../theme/theme.dart';
enum _FrameState {
notFound,
formatError,
checksumError,
}
class _ScannerOverlay extends CustomPainter {
_ScannerOverlay(this.scanWindow, this.frameColor);
final Rect scanWindow;
final Color? frameColor;
@override
void paint(Canvas canvas, Size size) {
final backgroundPath = Path()..addRect(Rect.largest);
final cutoutPath = Path()..addRect(scanWindow);
final backgroundPaint = Paint()
..color = (frameColor ?? Colors.black).withAlpha(127)
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut;
final backgroundWithCutout = Path.combine(
PathOperation.difference,
backgroundPath,
cutoutPath,
);
canvas.drawPath(backgroundWithCutout, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
/// Camera QR scanner
class CameraQRScanner<T> extends StatefulWidget {
const CameraQRScanner(
{required Widget Function(BuildContext) loadingBuilder,
required Widget Function(
BuildContext, Object error, StackTrace? stackTrace)
errorBuilder,
required Widget Function(BuildContext) bottomRowBuilder,
required void Function(String) showNotification,
required void Function(String, Object? error, StackTrace? stackTrace)
logError,
required void Function(T) onDone,
T? Function(Result)? onDetect,
T? Function(CameraImage)? onImageAvailable,
Size? scanSize,
Color? formatErrorFrameColor,
Color? checksumErrorFrameColor,
String? cameraErrorMessage,
String? deniedErrorMessage,
String? deniedWithoutPromptErrorMessage,
String? restrictedErrorMessage,
super.key})
: _loadingBuilder = loadingBuilder,
_errorBuilder = errorBuilder,
_bottomRowBuilder = bottomRowBuilder,
_showNotification = showNotification,
_logError = logError,
_scanSize = scanSize,
_onDetect = onDetect,
_onDone = onDone,
_onImageAvailable = onImageAvailable,
_formatErrorFrameColor = formatErrorFrameColor,
_checksumErrorFrameColor = checksumErrorFrameColor,
_cameraErrorMessage = cameraErrorMessage,
_deniedErrorMessage = deniedErrorMessage,
_deniedWithoutPromptErrorMessage = deniedWithoutPromptErrorMessage,
_restrictedErrorMessage = restrictedErrorMessage;
@override
State<CameraQRScanner<T>> createState() => _CameraQRScannerState();
////////////////////////////////////////////////////////////////////////////
final Widget Function(BuildContext) _loadingBuilder;
final Widget Function(BuildContext, Object error, StackTrace? stackTrace)
_errorBuilder;
final Widget Function(BuildContext) _bottomRowBuilder;
final void Function(String) _showNotification;
final void Function(String, Object? error, StackTrace? stackTrace) _logError;
final T? Function(Result)? _onDetect;
final void Function(T) _onDone;
final T? Function(CameraImage)? _onImageAvailable;
final Size? _scanSize;
final Color? _formatErrorFrameColor;
final Color? _checksumErrorFrameColor;
final String? _cameraErrorMessage;
final String? _deniedErrorMessage;
final String? _deniedWithoutPromptErrorMessage;
final String? _restrictedErrorMessage;
}
class _CameraQRScannerState<T> extends State<CameraQRScanner<T>>
with WidgetsBindingObserver, TickerProviderStateMixin {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Async Init
_initWait.add(_init);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
unawaited(_controller?.dispose());
super.dispose();
}
// #docregion AppLifecycle
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final cameraController = _controller;
// App state changed before we got the chance to initialize.
if (cameraController == null || !cameraController.value.isInitialized) {
return;
}
if (state == AppLifecycleState.inactive) {
unawaited(cameraController.dispose());
} else if (state == AppLifecycleState.resumed) {
unawaited(_initializeCameraController(cameraController.description));
}
}
// #enddocregion AppLifecycle
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeColor = theme.colorScheme.primary;
final inactiveColor = theme.colorScheme.onPrimary;
final scanSize = widget._scanSize;
final scanWindow = scanSize == null
? null
: Rect.fromCenter(
center: Offset.zero,
width: scanSize.width,
height: scanSize.height,
);
return Scaffold(
body: FutureBuilder(
future: _initWait(),
builder: (context, av) => av.when(
error: (e, st) => widget._errorBuilder(context, e, st),
loading: () => widget._loadingBuilder(context),
data: (data, isComplete) => Column(
children: <Widget>[
Expanded(
child: Padding(
padding: const EdgeInsets.all(1),
child: Center(
child: Stack(
alignment: AlignmentDirectional.center,
children: [
_cameraPreviewWidget(context),
if (scanWindow != null)
IgnorePointer(
child: CustomPaint(
foregroundPainter: _ScannerOverlay(
scanWindow,
switch (_frameState) {
_FrameState.notFound => null,
_FrameState.formatError =>
widget._formatErrorFrameColor,
_FrameState.checksumError =>
widget._checksumErrorFrameColor
}),
)),
]),
),
),
),
widget._bottomRowBuilder(context),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_cameraToggleWidget(),
_torchToggleWidget(activeColor, inactiveColor)
],
),
],
),
)));
}
/// Display the preview from the camera
/// (or a message if the preview is not available).
Widget _cameraPreviewWidget(BuildContext context) {
final cameraController = _controller;
if (cameraController == null || !cameraController.value.isInitialized) {
return widget._loadingBuilder(context);
} else {
return Listener(
onPointerDown: (_) => _pointers++,
onPointerUp: (_) => _pointers--,
child: CameraPreview(
cameraController,
child: LayoutBuilder(
builder: (context, constraints) => GestureDetector(
behavior: HitTestBehavior.opaque,
onScaleStart: _handleScaleStart,
onScaleUpdate: _handleScaleUpdate,
onTapDown: (details) =>
_onViewFinderTap(details, constraints),
)),
),
);
}
}
void _handleScaleStart(ScaleStartDetails details) {
_baseScale = _currentScale;
}
Future<void> _handleScaleUpdate(ScaleUpdateDetails details) async {
// When there are not exactly two fingers on screen don't scale
if (_controller == null || _pointers != 2) {
return;
}
_currentScale = (_baseScale * details.scale)
.clamp(_minAvailableZoom, _maxAvailableZoom);
await _controller!.setZoomLevel(_currentScale);
}
Widget _torchToggleWidget(Color activeColor, Color inactiveColor) =>
IconButton(
icon: const Icon(Icons.highlight),
color: _controller?.value.flashMode == FlashMode.torch
? activeColor
: inactiveColor,
onPressed: _controller != null
? () => _onSetFlashModeButtonPressed(
_controller?.value.flashMode == FlashMode.torch
? FlashMode.off
: FlashMode.torch)
: null,
);
Widget _cameraToggleWidget() {
final currentCameraDescription = _controller?.description;
return IconButton(
icon:
Icon(isAndroid ? Icons.flip_camera_android : Icons.flip_camera_ios),
onPressed: (currentCameraDescription == null || _cameras.isEmpty)
? null
: () {
final nextCameraIndex =
(_cameras.indexOf(currentCameraDescription) + 1) %
_cameras.length;
unawaited(_onNewCameraSelected(_cameras[nextCameraIndex]));
});
}
void _onViewFinderTap(TapDownDetails details, BoxConstraints constraints) {
final cameraController = _controller;
if (cameraController == null) {
return;
}
final offset = Offset(
details.localPosition.dx / constraints.maxWidth,
details.localPosition.dy / constraints.maxHeight,
);
unawaited(cameraController.setExposurePoint(offset));
unawaited(cameraController.setFocusPoint(offset));
}
Future<void> _onNewCameraSelected(CameraDescription cameraDescription) {
if (_controller != null) {
return _controller!.setDescription(cameraDescription);
} else {
return _initializeCameraController(cameraDescription);
}
}
Future<void> _initializeCameraController(
CameraDescription cameraDescription) async {
final cameraController = CameraController(
cameraDescription,
kIsWeb ? ResolutionPreset.max : ResolutionPreset.medium,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.jpeg,
);
_controller = cameraController;
// If the controller is updated then update the UI.
cameraController.addListener(() {
if (mounted) {
setState(() {});
}
if (cameraController.value.hasError &&
(cameraController.value.errorDescription?.isNotEmpty ?? false)) {
widget._showNotification(
'${widget._cameraErrorMessage ?? 'Camera error'}: '
'${cameraController.value.errorDescription!}');
}
});
try {
await cameraController.initialize();
try {
_maxAvailableZoom = await cameraController.getMaxZoomLevel();
_minAvailableZoom = await cameraController.getMinZoomLevel();
} on PlatformException {
_maxAvailableZoom = 1;
_minAvailableZoom = 1;
}
await cameraController.startImageStream((cameraImage) {
final out =
(widget._onImageAvailable ?? _onImageAvailable)(cameraImage);
if (out != null) {
_controller = null;
unawaited(cameraController.dispose());
widget._onDone(out);
}
});
} on CameraException catch (e, st) {
switch (e.code) {
case 'CameraAccessDenied':
widget._showNotification(
widget._deniedErrorMessage ?? 'You have denied camera access.');
case 'CameraAccessDeniedWithoutPrompt':
// iOS only
widget._showNotification(widget._deniedWithoutPromptErrorMessage ??
'Please go to Settings app to enable camera access.');
case 'CameraAccessRestricted':
// iOS only
widget._showNotification(
widget._restrictedErrorMessage ?? 'Camera access is restricted.');
default:
_showCameraException(e, st);
}
}
if (mounted) {
setState(() {});
}
}
T? _onImageAvailable(CameraImage cameraImage) {
try {
final plane = cameraImage.planes.firstOrNull;
if (plane == null) {
return null;
}
// final image = JpegDecoder().decode(plane.bytes);
// if (image == null) {
// return;
// }
// final abgrImage = image
// .convert(numChannels: 4)
// .getBytes(order: ChannelOrder.abgr)
// .buffer
// .asInt32List();
final abgrImage = plane.bytes.buffer.asInt32List();
final source =
RGBLuminanceSource(cameraImage.width, cameraImage.height, abgrImage);
final bitmap = BinaryBitmap(HybridBinarizer(source));
final reader = QRCodeReader();
try {
final result = reader.decode(bitmap);
return widget._onDetect?.call(result);
} on NotFoundException {
_setFrameState(_FrameState.notFound);
} on FormatReaderException {
_setFrameState(_FrameState.formatError);
} on ChecksumException {
_setFrameState(_FrameState.checksumError);
}
// Should also catch errors from QRCodeReader
// ignore: avoid_catches_without_on_clauses
} catch (e, st) {
widget._logError('Unexpected error: $e\n$st', e, st);
}
return null;
}
void _setFrameState(_FrameState frameState) {
if (mounted) {
if (_frameState != frameState) {
setState(() {
_frameState = frameState;
});
}
}
}
void _onSetFlashModeButtonPressed(FlashMode mode) {
unawaited(_setFlashMode(mode).then((_) {
if (mounted) {
setState(() {});
}
}));
}
Future<void> _setFlashMode(FlashMode mode) async {
if (_controller == null) {
return;
}
try {
await _controller!.setFlashMode(mode);
} on CameraException catch (e, st) {
_showCameraException(e, st);
rethrow;
}
}
void _showCameraException(CameraException e, StackTrace st) {
_logCameraException(e, st);
widget._showNotification('Error: ${e.code}\n${e.description}');
}
void _logCameraException(CameraException e, StackTrace st) {
final code = e.code;
final message = e.description;
widget._logError(
'CameraException: $code${message == null ? '' : '\nMessage: $message'}',
e,
st);
}
Future<void> _init(Completer<void> cancel) async {
_cameras = await availableCameras();
if (_cameras.isNotEmpty) {
await _onNewCameraSelected(_cameras.first);
}
}
////////////////////////////////////////////////////////////////////////////
CameraController? _controller;
final _initWait = WaitSet<void, void>();
late final List<CameraDescription> _cameras;
var _minAvailableZoom = 1.0;
var _maxAvailableZoom = 1.0;
var _currentScale = 1.0;
var _baseScale = 1.0;
var _pointers = 0;
_FrameState _frameState = _FrameState.notFound;
}