mirror of
https://gitlab.com/veilid/veilidchat.git
synced 2025-07-20 05:09:51 -04:00
473 lines
15 KiB
Dart
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;
|
|
}
|