From 28580bad887be76487b5dc9fd8c481c7bd485769 Mon Sep 17 00:00:00 2001 From: Christien Rioux Date: Thu, 5 Jun 2025 23:43:13 +0200 Subject: [PATCH] new qr code scanner --- assets/i18n/en.json | 7 +- ios/Podfile.lock | 7 - .../cubits/contact_invitation_list_cubit.dart | 10 + .../views/camera_qr_scanner.dart | 473 ++++++++++++++++++ .../views/scan_invitation_dialog.dart | 351 ++++--------- lib/contact_invitation/views/views.dart | 1 + lib/theme/views/scanner_error_widget.dart | 54 -- lib/theme/views/views.dart | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 2 - macos/Podfile.lock | 7 - .../lib/dht_support/src/dht_log/dht_log.dart | 2 +- .../src/dht_log/dht_log_spine.dart | 13 +- .../src/dht_log/dht_log_write.dart | 12 +- .../dht_short_array/dht_short_array_head.dart | 2 +- .../dht_support/src/interfaces/dht_add.dart | 22 +- .../src/interfaces/dht_random_swap.dart | 9 + .../src/interfaces/exceptions.dart | 19 +- pubspec.lock | 14 +- pubspec.yaml | 3 +- 19 files changed, 636 insertions(+), 373 deletions(-) create mode 100644 lib/contact_invitation/views/camera_qr_scanner.dart delete mode 100644 lib/theme/views/scanner_error_widget.dart create mode 100644 packages/veilid_support/lib/dht_support/src/interfaces/dht_random_swap.dart diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 50b0904..206c0b0 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -225,15 +225,16 @@ "scan_invitation_dialog": { "title": "Scan Contact Invite", "instructions": "Position the contact invite QR code in the frame", - "scan_qr_here": "Click here to scan a contact invite QR code:", - "paste_qr_here": "Camera scanning is only available on mobile devices. You can copy a QR code image and paste it here:", + "scan_qr_here": "Click here to scan a contact invite QR code with your device's camera:", + "paste_qr_here": "You can copy a QR code image and paste it by clicking here:", "scan": "Scan", "paste": "Paste", "not_an_image": "Pasted data is not an image", "could_not_decode_image": "Could not decode pasted image", "not_a_valid_qr_code": "Not a valid QR code", "error": "Failed to capture QR code", - "permission_error": "Capturing QR codes requires camera permisions. Allow camera permissions for VeilidChat in your settings." + "permission_error": "Capturing QR codes requires camera permisions. Allow camera permissions for VeilidChat in your settings.", + "camera_error": "Camera error" }, "enter_pin_dialog": { "enter_pin": "Enter PIN", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index add7488..0f5fb0f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,9 +6,6 @@ PODS: - Flutter (1.0.0) - flutter_native_splash (2.4.3): - Flutter - - mobile_scanner (7.0.0): - - Flutter - - FlutterMacOS - package_info_plus (0.4.5): - Flutter - pasteboard (0.0.1): @@ -38,7 +35,6 @@ DEPENDENCIES: - file_saver (from `.symlinks/plugins/file_saver/ios`) - Flutter (from `Flutter`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - pasteboard (from `.symlinks/plugins/pasteboard/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -59,8 +55,6 @@ EXTERNAL SOURCES: :path: Flutter flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" - mobile_scanner: - :path: ".symlinks/plugins/mobile_scanner/darwin" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" pasteboard: @@ -87,7 +81,6 @@ SPEC CHECKSUMS: file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf - mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 diff --git a/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart index 332341d..b31c453 100644 --- a/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart +++ b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:async_tools/async_tools.dart'; import 'package:bloc_advanced_tools/bloc_advanced_tools.dart'; +import 'package:convert/convert.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/foundation.dart'; @@ -165,6 +166,11 @@ class ContactInvitationListCubit }); }); + log.debug('createInvitation:\n' + 'contactRequestInboxKey=$contactRequestInboxKey\n' + 'bytes=${signedContactInvitationBytes.lengthInBytes}\n' + '${hex.encode(signedContactInvitationBytes)}'); + return (signedContactInvitationBytes, contactRequestInboxKey); } @@ -222,6 +228,10 @@ class ContactInvitationListCubit required GetEncryptionKeyCallback getEncryptionKeyCallback, required CancelRequest cancelRequest, }) async { + log.debug('validateInvitation:\n' + 'bytes=${inviteData.lengthInBytes}\n' + '${hex.encode(inviteData)}'); + final pool = DHTRecordPool.instance; // Get contact request inbox from invitation diff --git a/lib/contact_invitation/views/camera_qr_scanner.dart b/lib/contact_invitation/views/camera_qr_scanner.dart new file mode 100644 index 0000000..11d8f77 --- /dev/null +++ b/lib/contact_invitation/views/camera_qr_scanner.dart @@ -0,0 +1,473 @@ +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 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> 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 extends State> + 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: [ + 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: [ + _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 _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 _onNewCameraSelected(CameraDescription cameraDescription) { + if (_controller != null) { + return _controller!.setDescription(cameraDescription); + } else { + return _initializeCameraController(cameraDescription); + } + } + + Future _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 _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 _init(Completer cancel) async { + _cameras = await availableCameras(); + if (_cameras.isNotEmpty) { + await _onNewCameraSelected(_cameras.first); + } + } + + //////////////////////////////////////////////////////////////////////////// + + CameraController? _controller; + final _initWait = WaitSet(); + late final List _cameras; + var _minAvailableZoom = 1.0; + var _maxAvailableZoom = 1.0; + var _currentScale = 1.0; + var _baseScale = 1.0; + var _pointers = 0; + _FrameState _frameState = _FrameState.notFound; +} diff --git a/lib/contact_invitation/views/scan_invitation_dialog.dart b/lib/contact_invitation/views/scan_invitation_dialog.dart index 8fbdf5c..058383d 100644 --- a/lib/contact_invitation/views/scan_invitation_dialog.dart +++ b/lib/contact_invitation/views/scan_invitation_dialog.dart @@ -2,106 +2,19 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:image/image.dart' as img; -import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:provider/provider.dart'; import 'package:zxing2/qrcode.dart'; import '../../notifications/notifications.dart'; import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import 'camera_qr_scanner.dart'; import 'invitation_dialog.dart'; -// class BarcodeOverlay extends CustomPainter { -// BarcodeOverlay({ -// required this.barcode, -// required this.boxFit, -// required this.capture, -// required this.size, -// }); - -// final BarcodeCapture capture; -// final Barcode barcode; -// final BoxFit boxFit; -// final Size size; - -// @override -// void paint(Canvas canvas, Size size) { -// final adjustedSize = applyBoxFit(boxFit, size, size); - -// var verticalPadding = size.height - adjustedSize.destination.height; -// var horizontalPadding = size.width - adjustedSize.destination.width; -// if (verticalPadding > 0) { -// verticalPadding = verticalPadding / 2; -// } else { -// verticalPadding = 0; -// } - -// if (horizontalPadding > 0) { -// horizontalPadding = horizontalPadding / 2; -// } else { -// horizontalPadding = 0; -// } - -// final ratioWidth = (Platform.isIOS ? capture.size.width : size.width) / -// adjustedSize.destination.width; -// final ratioHeight = (Platform.isIOS ? capture.size.height : size.height) / -// adjustedSize.destination.height; - -// final adjustedOffset = []; -// for (final offset in barcode.corners) { -// adjustedOffset.add( -// Offset( -// offset.dx / ratioWidth + horizontalPadding, -// offset.dy / ratioHeight + verticalPadding, -// ), -// ); -// } -// final cutoutPath = Path()..addPolygon(adjustedOffset, true); - -// final backgroundPaint = Paint() -// ..color = Colors.red.withOpacity(0.3) -// ..style = PaintingStyle.fill -// ..blendMode = BlendMode.dstOut; - -// canvas.drawPath(cutoutPath, backgroundPaint); -// } - -// @override -// bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -// } - -class ScannerOverlay extends CustomPainter { - ScannerOverlay(this.scanWindow); - - final Rect scanWindow; - - @override - void paint(Canvas canvas, Size size) { - final backgroundPath = Path()..addRect(Rect.largest); - final cutoutPath = Path()..addRect(scanWindow); - - final backgroundPaint = Paint() - ..color = 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; -} - class ScanInvitationDialog extends StatefulWidget { const ScanInvitationDialog({required Locator locator, super.key}) : _locator = locator; @@ -122,7 +35,7 @@ class ScanInvitationDialog extends StatefulWidget { } class ScanInvitationDialogState extends State { - bool scanned = false; + var _scanned = false; @override void initState() { @@ -131,14 +44,14 @@ class ScanInvitationDialogState extends State { void onValidationCancelled() { setState(() { - scanned = false; + _scanned = false; }); } void onValidationSuccess() {} void onValidationFailed() { setState(() { - scanned = false; + _scanned = false; }); } @@ -146,142 +59,62 @@ class ScanInvitationDialogState extends State { Future scanQRImage(BuildContext context) async { final theme = Theme.of(context); - //final textTheme = theme.textTheme; final scale = theme.extension()!; - final windowSize = MediaQuery.of(context).size; - //final maxDialogWidth = min(windowSize.width - 64.0, 800.0 - 64.0); - //final maxDialogHeight = windowSize.height - 64.0; - final scanWindow = Rect.fromCenter( - center: MediaQuery.of(context).size.center(Offset.zero), - width: 200, - height: 200, - ); - - final cameraController = MobileScannerController(); try { return showDialog( context: context, builder: (context) => Stack( fit: StackFit.expand, children: [ - MobileScanner( - fit: BoxFit.contain, - scanWindow: scanWindow, - controller: cameraController, - errorBuilder: (context, error) => - ScannerErrorWidget(error: error), - onDetect: (c) { - final barcode = c.barcodes.firstOrNull; - - final barcodeBytes = barcode?.rawBytes; - if (barcodeBytes != null) { - cameraController.dispose(); - Navigator.pop(context, barcodeBytes); - } - }), - CustomPaint( - painter: ScannerOverlay(scanWindow), - ), - Align( - alignment: Alignment.bottomCenter, - child: Container( - alignment: Alignment.bottomCenter, - height: 100, - color: Colors.black.withAlpha(127), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - color: Colors.white, - icon: ValueListenableBuilder( - valueListenable: cameraController, - builder: (context, state, child) { - switch (state.torchState) { - case TorchState.off: - return Icon(Icons.flash_off, - color: - scale.grayScale.subtleBackground); - case TorchState.on: - return Icon(Icons.flash_on, - color: scale.primaryScale.primary); - case TorchState.auto: - return Icon(Icons.flash_auto, - color: scale.primaryScale.primary); - case TorchState.unavailable: - return Icon(Icons.no_flash, - color: scale.primaryScale.primary); - } - }, - ), - iconSize: 32, - onPressed: cameraController.toggleTorch, - ), - SizedBox( - width: windowSize.width - 120, - height: 50, - child: FittedBox( - child: Text( + CameraQRScanner( + scanSize: const Size(200, 200), + loadingBuilder: (context) => waitingPage(), + errorBuilder: (coRntext, e, st) => errorPage(e, st), + bottomRowBuilder: (context) => FittedBox( + fit: BoxFit.scaleDown, + child: Text( translate( 'scan_invitation_dialog.instructions'), - overflow: TextOverflow.fade, - style: Theme.of(context) - .textTheme - .labelLarge! - .copyWith(color: Colors.white), - ), - ), + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge), ), - IconButton( - color: Colors.white, - icon: ValueListenableBuilder( - valueListenable: cameraController, - builder: (context, state, child) { - switch (state.cameraDirection) { - case CameraFacing.front: - return const Icon(Icons.camera_front); - case CameraFacing.back: - return const Icon(Icons.camera_rear); - case CameraFacing.external: - return const Icon(Icons.camera_alt); - case CameraFacing.unknown: - return const Icon(Icons.question_mark); - } - }, - ), - iconSize: 32, - onPressed: cameraController.switchCamera, - ), - ], - ), - ), - ), + showNotification: (s) {}, + logError: log.error, + cameraErrorMessage: + translate('scan_invitation_dialog.camera_error'), + deniedErrorMessage: + translate('scan_invitation_dialog.permission_error'), + deniedWithoutPromptErrorMessage: + translate('scan_invitation_dialog.permission_error'), + restrictedErrorMessage: + translate('scan_invitation_dialog.permission_error'), + onDetect: (result) { + final byteSegments = result + .resultMetadata[ResultMetadataType.byteSegments]; + if (byteSegments != null) { + final segs = byteSegments as List; + + final byteData = Uint8List.fromList(segs[0].toList()); + return byteData; + } + return null; + }, + onDone: (result) { + Navigator.of(context).pop(result); + }), Align( alignment: Alignment.topRight, child: IconButton( color: Colors.white, - icon: - Icon(Icons.close, color: scale.grayScale.primary), - iconSize: 32, - onPressed: () => { - SchedulerBinding.instance - .addPostFrameCallback((_) { - cameraController.dispose(); - Navigator.pop(context); - }) - })), + icon: Icon(Icons.close, + color: scale.primaryScale.appText), + iconSize: 32.scaled(context), + onPressed: () { + Navigator.of(context).pop(); + })), ], )); - } on MobileScannerException catch (e) { - if (e.errorCode == MobileScannerErrorCode.permissionDenied) { - context - .read() - .error(text: translate('scan_invitation_dialog.permission_error')); - } else { - context - .read() - .error(text: translate('scan_invitation_dialog.error')); - } } on Exception catch (_) { context .read() @@ -342,76 +175,66 @@ class ScanInvitationDialogState extends State { InvitationDialogState dialogState, Future Function({required Uint8List inviteData}) validateInviteData) { - //final theme = Theme.of(context); - //final scale = theme.extension()!; - //final textTheme = theme.textTheme; - //final height = MediaQuery.of(context).size.height; - - if (isiOS || isAndroid) { - return Column(mainAxisSize: MainAxisSize.min, children: [ - if (!scanned) - Text( - translate('scan_invitation_dialog.scan_qr_here'), - ).paddingLTRB(0, 0, 0, 8), - if (!scanned) - Container( - constraints: const BoxConstraints(maxHeight: 200), - child: ElevatedButton( - onPressed: dialogState.isValidating - ? null - : () async { - final inviteData = await scanQRImage(context); - if (inviteData != null) { - setState(() { - scanned = true; - }); - await validateInviteData(inviteData: inviteData); - } - }, - child: Text(translate('scan_invitation_dialog.scan'))), - ).paddingLTRB(0, 0, 0, 8) - ]); + if (_scanned) { + return const SizedBox.shrink(); } - return Column(mainAxisSize: MainAxisSize.min, children: [ - if (!scanned) + + final children = []; + if (isiOS || isAndroid) { + children.addAll([ Text( - translate('scan_invitation_dialog.paste_qr_here'), + translate('scan_invitation_dialog.scan_qr_here'), ).paddingLTRB(0, 0, 0, 8), - if (!scanned) Container( constraints: const BoxConstraints(maxHeight: 200), child: ElevatedButton( onPressed: dialogState.isValidating ? null : () async { - final inviteData = await pasteQRImage(context); + final inviteData = await scanQRImage(context); if (inviteData != null) { - await validateInviteData(inviteData: inviteData); setState(() { - scanned = true; + _scanned = true; }); + await validateInviteData(inviteData: inviteData); } }, - child: Text(translate('scan_invitation_dialog.paste'))), + child: Text(translate('scan_invitation_dialog.scan'))), ).paddingLTRB(0, 0, 0, 8) + ]); + } + + children.addAll([ + Text( + translate('scan_invitation_dialog.paste_qr_here'), + ).paddingLTRB(0, 0, 0, 8), + Container( + constraints: const BoxConstraints(maxHeight: 200), + child: ElevatedButton( + onPressed: dialogState.isValidating + ? null + : () async { + final inviteData = await pasteQRImage(context); + if (inviteData != null) { + await validateInviteData(inviteData: inviteData); + setState(() { + _scanned = true; + }); + } + }, + child: Text(translate('scan_invitation_dialog.paste'))), + ).paddingLTRB(0, 0, 0, 8) ]); + + return Column(mainAxisSize: MainAxisSize.min, children: children); } @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - return InvitationDialog( - locator: widget._locator, - onValidationCancelled: onValidationCancelled, - onValidationSuccess: onValidationSuccess, - onValidationFailed: onValidationFailed, - inviteControlIsValid: inviteControlIsValid, - buildInviteControl: buildInviteControl); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('scanned', scanned)); - } + Widget build(BuildContext context) => InvitationDialog( + locator: widget._locator, + onValidationCancelled: onValidationCancelled, + onValidationSuccess: onValidationSuccess, + onValidationFailed: onValidationFailed, + inviteControlIsValid: inviteControlIsValid, + buildInviteControl: buildInviteControl); } diff --git a/lib/contact_invitation/views/views.dart b/lib/contact_invitation/views/views.dart index 241513d..319296b 100644 --- a/lib/contact_invitation/views/views.dart +++ b/lib/contact_invitation/views/views.dart @@ -1,3 +1,4 @@ +export 'camera_qr_scanner.dart'; export 'contact_invitation_display.dart'; export 'contact_invitation_item_widget.dart'; export 'contact_invitation_list_widget.dart'; diff --git a/lib/theme/views/scanner_error_widget.dart b/lib/theme/views/scanner_error_widget.dart deleted file mode 100644 index d5463f4..0000000 --- a/lib/theme/views/scanner_error_widget.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; - -class ScannerErrorWidget extends StatelessWidget { - const ScannerErrorWidget({required this.error, super.key}); - - final MobileScannerException error; - - @override - Widget build(BuildContext context) { - String errorMessage; - - switch (error.errorCode) { - case MobileScannerErrorCode.controllerUninitialized: - errorMessage = 'Controller not ready.'; - case MobileScannerErrorCode.permissionDenied: - errorMessage = 'Permission denied'; - case MobileScannerErrorCode.unsupported: - errorMessage = 'Scanning is unsupported on this device'; - default: - errorMessage = 'Generic Error'; - } - - return ColoredBox( - color: Colors.black, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Padding( - padding: EdgeInsets.only(bottom: 16), - child: Icon(Icons.error, color: Colors.white), - ), - Text( - errorMessage, - style: const TextStyle(color: Colors.white), - ), - Text( - error.errorDetails?.message ?? '', - style: const TextStyle(color: Colors.white), - ), - ], - ), - ), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('error', error)); - } -} diff --git a/lib/theme/views/views.dart b/lib/theme/views/views.dart index 1144440..b62c4bc 100644 --- a/lib/theme/views/views.dart +++ b/lib/theme/views/views.dart @@ -4,7 +4,6 @@ export 'pop_control.dart'; export 'preferences/preferences.dart'; export 'recovery_key_widget.dart'; export 'responsive.dart'; -export 'scanner_error_widget.dart'; export 'styled_widgets/styled_button_box.dart'; export 'styled_widgets/styled_widgets.dart'; export 'widget_helpers.dart'; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a599497..7e4a42f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,6 @@ import FlutterMacOS import Foundation import file_saver -import mobile_scanner import package_info_plus import pasteboard import path_provider_foundation @@ -21,7 +20,6 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) - MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index beb7d0e..bd9fccf 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -2,9 +2,6 @@ PODS: - file_saver (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - mobile_scanner (7.0.0): - - Flutter - - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS - pasteboard (0.0.1): @@ -34,7 +31,6 @@ PODS: DEPENDENCIES: - file_saver (from `Flutter/ephemeral/.symlinks/plugins/file_saver/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/darwin`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) @@ -52,8 +48,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/file_saver/macos FlutterMacOS: :path: Flutter/ephemeral - mobile_scanner: - :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/darwin package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos pasteboard: @@ -80,7 +74,6 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: file_saver: e35bd97de451dde55ff8c38862ed7ad0f3418d0f FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 pasteboard: 278d8100149f940fb795d6b3a74f0720c890ecb7 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart index 20471da..da74df1 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log.dart @@ -243,7 +243,7 @@ class DHTLog implements DHTDeleteable { /// Will execute the closure multiple times if a consistent write to the DHT /// is not achieved. Timeout if specified will be thrown as a /// TimeoutException. The closure should return a value if its changes also - /// succeeded, and throw DHTExceptionTryAgain to trigger another + /// succeeded, and throw DHTExceptionOutdated to trigger another /// eventual consistency pass. Future operateAppendEventual( Future Function(DHTLogWriteOperations) closure, diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart index 0aebd6c..ce231e2 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_spine.dart @@ -250,12 +250,13 @@ class _DHTLogSpine { final headDelta = _ringDistance(newHead, oldHead); final tailDelta = _ringDistance(newTail, oldTail); if (headDelta > _positionLimit ~/ 2 || tailDelta > _positionLimit ~/ 2) { - throw DHTExceptionInvalidData('_DHTLogSpine::_updateHead ' - '_head=$_head _tail=$_tail ' - 'oldHead=$oldHead oldTail=$oldTail ' - 'newHead=$newHead newTail=$newTail ' - 'headDelta=$headDelta tailDelta=$tailDelta ' - '_positionLimit=$_positionLimit'); + throw DHTExceptionInvalidData( + cause: '_DHTLogSpine::_updateHead ' + '_head=$_head _tail=$_tail ' + 'oldHead=$oldHead oldTail=$oldTail ' + 'newHead=$newHead newTail=$newTail ' + 'headDelta=$headDelta tailDelta=$tailDelta ' + '_positionLimit=$_positionLimit'); } } diff --git a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart index e3697c8..590fbb2 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_log/dht_log_write.dart @@ -18,7 +18,8 @@ class _DHTLogWrite extends _DHTLogRead implements DHTLogWriteOperations { final lookup = await _spine.lookupPosition(pos); if (lookup == null) { throw DHTExceptionInvalidData( - '_DHTLogRead::tryWriteItem pos=$pos _spine.length=${_spine.length}'); + cause: '_DHTLogRead::tryWriteItem pos=$pos ' + '_spine.length=${_spine.length}'); } // Write item to the segment @@ -75,10 +76,11 @@ class _DHTLogWrite extends _DHTLogRead implements DHTLogWriteOperations { final lookup = await _spine.lookupPosition(insertPos + valueIdx); if (lookup == null) { - throw DHTExceptionInvalidData('_DHTLogWrite::addAll ' - '_spine.length=${_spine.length}' - 'insertPos=$insertPos valueIdx=$valueIdx ' - 'values.length=${values.length} '); + throw DHTExceptionInvalidData( + cause: '_DHTLogWrite::addAll ' + '_spine.length=${_spine.length}' + 'insertPos=$insertPos valueIdx=$valueIdx ' + 'values.length=${values.length} '); } final sacount = min(remaining, DHTShortArray.maxElements - lookup.pos); diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart index 1785b28..5b224cb 100644 --- a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart @@ -531,7 +531,7 @@ class _DHTShortArrayHead { //////////////////////////////////////////////////////////////////////////// // Head/element mutex to ensure we keep the representation valid - final Mutex _headMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); + final _headMutex = Mutex(debugLockTimeout: kIsDebugMode ? 60 : null); // Subscription to head record internal changes StreamSubscription? _subscription; // Notify closure for external head changes diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart index dc79350..28d2fbb 100644 --- a/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_add.dart @@ -15,27 +15,37 @@ abstract class DHTAdd { Future add(Uint8List value); /// Try to add a list of items to the DHT container. - /// Return if the elements were successfully added. - /// Throws DHTExceptionTryAgain if the state changed before the elements could + /// Return the number of elements successfully added. + /// Throws DHTExceptionTryAgain if the state changed before any elements could /// be added or a newer value was found on the network. + /// Throws DHTConcurrencyLimit if the number values in the list was too large + /// at this time /// Throws a StateError if the container exceeds its maximum size. Future addAll(List values); } extension DHTAddExt on DHTAdd { /// Convenience function: - /// Like tryAddItem but also encodes the input value as JSON and parses the - /// returned element as JSON + /// Like add but also encodes the input value as JSON Future addJson( T newValue, ) => add(jsonEncodeBytes(newValue)); /// Convenience function: - /// Like tryAddItem but also encodes the input value as a protobuf object - /// and parses the returned element as a protobuf object + /// Like add but also encodes the input value as a protobuf object Future addProtobuf( T newValue, ) => add(newValue.writeToBuffer()); + + /// Convenience function: + /// Like addAll but also encodes the input values as JSON + Future addAllJson(List values) => + addAll(values.map(jsonEncodeBytes).toList()); + + /// Convenience function: + /// Like addAll but also encodes the input values as protobuf objects + Future addAllProtobuf(List values) => + addAll(values.map((x) => x.writeToBuffer()).toList()); } diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_swap.dart b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_swap.dart new file mode 100644 index 0000000..8aa4dc1 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/interfaces/dht_random_swap.dart @@ -0,0 +1,9 @@ +//////////////////////////////////////////////////////////////////////////// +// Writer interface +// ignore: one_member_abstracts +abstract class DHTRandomSwap { + /// Swap items at position 'aPos' and 'bPos' in the DHTArray. + /// Throws an IndexError if either of the positions swapped exceeds the length + /// of the container + Future swap(int aPos, int bPos); +} diff --git a/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart b/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart index 134f5fa..01354f0 100644 --- a/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart +++ b/packages/veilid_support/lib/dht_support/src/interfaces/exceptions.dart @@ -1,14 +1,25 @@ class DHTExceptionOutdated implements Exception { const DHTExceptionOutdated( - [this.cause = 'operation failed due to newer dht value']); + {this.cause = 'operation failed due to newer dht value'}); final String cause; @override String toString() => 'DHTExceptionOutdated: $cause'; } +class DHTConcurrencyLimit implements Exception { + const DHTConcurrencyLimit( + {required this.limit, + this.cause = 'failed due to maximum parallel operation limit'}); + final String cause; + final int limit; + + @override + String toString() => 'DHTConcurrencyLimit: $cause (limit=$limit)'; +} + class DHTExceptionInvalidData implements Exception { - const DHTExceptionInvalidData(this.cause); + const DHTExceptionInvalidData({this.cause = 'data was invalid'}); final String cause; @override @@ -16,7 +27,7 @@ class DHTExceptionInvalidData implements Exception { } class DHTExceptionCancelled implements Exception { - const DHTExceptionCancelled([this.cause = 'operation was cancelled']); + const DHTExceptionCancelled({this.cause = 'operation was cancelled'}); final String cause; @override @@ -25,7 +36,7 @@ class DHTExceptionCancelled implements Exception { class DHTExceptionNotAvailable implements Exception { const DHTExceptionNotAvailable( - [this.cause = 'request could not be completed at this time']); + {this.cause = 'request could not be completed at this time'}); final String cause; @override diff --git a/pubspec.lock b/pubspec.lock index 779806e..95c1262 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -274,7 +274,7 @@ packages: source: hosted version: "1.3.1" camera: - dependency: transitive + dependency: "direct main" description: name: camera sha256: "413d2b34fe28496c35c69ede5b232fb9dd5ca2c3a4cb606b14efc1c7546cc8cb" @@ -394,7 +394,7 @@ packages: source: hosted version: "1.19.1" convert: - dependency: transitive + dependency: "direct main" description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 @@ -912,14 +912,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - mobile_scanner: - dependency: "direct main" - description: - name: mobile_scanner - sha256: "72f06a071aa8b14acea3ab43ea7949eefe4a2469731ae210e006ba330a033a8c" - url: "https://pub.dev" - source: hosted - version: "7.0.0" nested: dependency: transitive description: @@ -1765,7 +1757,7 @@ packages: path: "../veilid/veilid-flutter" relative: true source: path - version: "0.4.6" + version: "0.4.7" veilid_support: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index cee2ec7..5cdbcb0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,10 +23,12 @@ dependencies: bloc: ^9.0.0 bloc_advanced_tools: ^0.1.13 blurry_modal_progress_hud: ^1.1.1 + camera: ^0.11.1 change_case: ^2.2.0 charcode: ^1.4.0 circular_profile_avatar: ^2.0.5 circular_reveal_animation: ^2.0.1 + convert: ^3.1.2 cupertino_icons: ^1.0.8 equatable: ^2.0.7 expansion_tile_group: ^2.2.0 @@ -58,7 +60,6 @@ dependencies: json_annotation: ^4.9.0 loggy: ^2.0.3 meta: ^1.16.0 - mobile_scanner: ^7.0.0 package_info_plus: ^8.3.0 pasteboard: ^0.4.0 path: ^1.9.1