From 9ec585d9a2438bc081a6eb6fc7cd5d2a74437c4a Mon Sep 17 00:00:00 2001 From: Forte11Cuba Date: Sat, 7 Feb 2026 23:20:22 -0600 Subject: [PATCH] feat: add QR scanner with UR multipart support --- android/app/src/main/AndroidManifest.xml | 2 + lib/core/utils/incoming_data_parser.dart | 165 +++++++ lib/l10n/app_en.arb | 17 +- lib/l10n/app_es.arb | 17 +- lib/screens/10_scanner/scan_screen.dart | 327 +++++++++++++ lib/screens/3_home/home_screen.dart | 46 +- lib/screens/4_receive/receive_screen.dart | 69 ++- lib/screens/7_melt/melt_screen.dart | 64 ++- lib/widgets/scanner/qr_scanner_widget.dart | 443 ++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 8 + pubspec.yaml | 1 + 12 files changed, 1152 insertions(+), 9 deletions(-) create mode 100644 lib/core/utils/incoming_data_parser.dart create mode 100644 lib/screens/10_scanner/scan_screen.dart create mode 100644 lib/widgets/scanner/qr_scanner_widget.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f035861..0f65b13 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + type != IncomingDataType.unknown; +} + +/// Parser estático para detectar tipo de dato +class IncomingDataParser { + /// Detecta el tipo de dato y extrae información relevante + static ParsedData parse(String data) { + final trimmed = data.trim(); + final lower = trimmed.toLowerCase(); + + // Token Cashu (cashuA... o cashuB...) + if (lower.startsWith('cashua') || lower.startsWith('cashub')) { + return ParsedData( + type: IncomingDataType.cashuToken, + raw: trimmed, + ); + } + + // UR encoded token (ur:cashu/...) + if (lower.startsWith('ur:cashu')) { + return ParsedData( + type: IncomingDataType.cashuToken, + raw: trimmed, + ); + } + + // Invoice Lightning (lnbc..., lntb..., lnbcrt...) + if (lower.startsWith('lnbc') || + lower.startsWith('lntb') || + lower.startsWith('lnbcrt') || + lower.startsWith('lightning:')) { + // Remover prefijo lightning: si existe + final invoice = lower.startsWith('lightning:') + ? trimmed.substring(10) + : trimmed; + return ParsedData( + type: IncomingDataType.lightningInvoice, + raw: trimmed, + invoiceBolt11: invoice, + ); + } + + // Payment Request (creqA...) + if (lower.startsWith('creqa')) { + return ParsedData( + type: IncomingDataType.paymentRequest, + raw: trimmed, + ); + } + + // URL de mint (https://...) + if (lower.startsWith('https://')) { + // Verificar si parece una URL de mint (heurística simple) + // Los mints típicos tienen /v1/info o son URLs simples + return ParsedData( + type: IncomingDataType.mintUrl, + raw: trimmed, + mintUrl: trimmed, + ); + } + + // Tipo desconocido + return ParsedData( + type: IncomingDataType.unknown, + raw: trimmed, + ); + } + + /// Verifica si el dato es un fragmento UR + static bool isUrFragment(String data) { + return data.toLowerCase().startsWith('ur:'); + } + + /// Extrae información del header UR (índice y total) + /// Formato: ur:cashu/1-5/payload... + /// Retorna (currentIndex, totalFragments) o null si no es válido + static (int, int)? parseUrHeader(String data) { + final lower = data.toLowerCase(); + if (!lower.startsWith('ur:')) return null; + + // Buscar el patrón X-Y después del tipo + final parts = data.split('/'); + if (parts.length < 2) return null; + + // El segundo segmento debería ser "index-total" o solo el index si es único + final indexPart = parts[1]; + + // Verificar si es formato multipart (X-Y) + if (indexPart.contains('-')) { + final indices = indexPart.split('-'); + if (indices.length == 2) { + final current = int.tryParse(indices[0]); + final total = int.tryParse(indices[1]); + if (current != null && total != null) { + return (current, total); + } + } + } + + return null; + } + + /// Verifica si un dato es válido para un modo específico + static bool isValidForMode(ParsedData data, ScanMode mode) { + switch (mode) { + case ScanMode.any: + return data.type != IncomingDataType.unknown; + case ScanMode.cashuOnly: + return data.type == IncomingDataType.cashuToken; + case ScanMode.invoiceOnly: + return data.type == IncomingDataType.lightningInvoice; + } + } +} + +/// Modo de escaneo +enum ScanMode { + any, // Desde HomeScreen - detecta y navega automáticamente + cashuOnly, // Desde ReceiveScreen - solo acepta tokens Cashu + invoiceOnly, // Desde MeltScreen - solo acepta invoices Lightning +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1a3fdd3..904282b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -326,5 +326,20 @@ "amount": { "type": "String" }, "unit": { "type": "String" } } - } + }, + + "scan": "Scan", + "scanQrCode": "Scan QR Code", + "scanCashuToken": "Scan Cashu Token", + "scanLightningInvoice": "Scan Invoice", + "scanningAnimatedQr": "Scanning animated QR...", + "pointCameraAtQr": "Point the camera at the QR code", + "pointCameraAtCashuQr": "Point the camera at the Cashu token QR", + "pointCameraAtInvoiceQr": "Point the camera at the invoice QR", + "unrecognizedQrCode": "Unrecognized QR code", + "scanCashuTokenHint": "Scan a Cashu token (cashuA... or cashuB...)", + "scanLightningInvoiceHint": "Scan a Lightning invoice (lnbc...)", + "addMintQuestion": "Add this mint?", + "cameraPermissionDenied": "Camera permission denied", + "paymentRequestNotSupported": "Payment requests are not yet supported" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 39b2f8d..dcf0a22 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -446,5 +446,20 @@ "amount": { "type": "String" }, "unit": { "type": "String" } } - } + }, + + "scan": "Escanear", + "scanQrCode": "Escanear QR", + "scanCashuToken": "Escanear token Cashu", + "scanLightningInvoice": "Escanear invoice", + "scanningAnimatedQr": "Escaneando QR animado...", + "pointCameraAtQr": "Apunta la cámara al código QR", + "pointCameraAtCashuQr": "Apunta la cámara al QR del token Cashu", + "pointCameraAtInvoiceQr": "Apunta la cámara al QR del invoice", + "unrecognizedQrCode": "Código QR no reconocido", + "scanCashuTokenHint": "Escanea un token Cashu (cashuA... o cashuB...)", + "scanLightningInvoiceHint": "Escanea un invoice Lightning (lnbc...)", + "addMintQuestion": "¿Agregar este mint?", + "cameraPermissionDenied": "Permiso de cámara denegado", + "paymentRequestNotSupported": "Los payment requests aún no están soportados" } diff --git a/lib/screens/10_scanner/scan_screen.dart b/lib/screens/10_scanner/scan_screen.dart new file mode 100644 index 0000000..293d734 --- /dev/null +++ b/lib/screens/10_scanner/scan_screen.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import '../../core/constants/colors.dart'; +import '../../core/utils/incoming_data_parser.dart'; +import '../../widgets/scanner/qr_scanner_widget.dart'; +import '../../providers/wallet_provider.dart'; +import '../4_receive/receive_screen.dart'; +import '../7_melt/melt_screen.dart'; + +/// Pantalla de escaneo QR con soporte para diferentes modos +class ScanScreen extends StatefulWidget { + /// Modo de escaneo + final ScanMode mode; + + /// Callback cuando se detecta un dato válido (para modos específicos) + final void Function(String data)? onDataScanned; + + const ScanScreen({ + super.key, + this.mode = ScanMode.any, + this.onDataScanned, + }); + + @override + State createState() => _ScanScreenState(); +} + +class _ScanScreenState extends State { + bool _isProcessing = false; + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context)!; + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(LucideIcons.x, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + title: Text( + _getTitleForMode(l10n), + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + centerTitle: true, + ), + body: Stack( + children: [ + // Scanner + QrScannerWidget( + onDetect: _onCodeDetected, + showFlashControl: true, + showCameraSwitch: false, + ), + + // Instrucciones en la parte inferior + Positioned( + bottom: 100, + left: 24, + right: 24, + child: Text( + _getInstructionForMode(l10n), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + color: Colors.white.withValues(alpha: 0.8), + ), + textAlign: TextAlign.center, + ), + ), + + // Indicador de procesamiento + if (_isProcessing) + Container( + color: Colors.black54, + child: const Center( + child: CircularProgressIndicator( + color: AppColors.primaryAction, + ), + ), + ), + ], + ), + ); + } + + String _getTitleForMode(L10n l10n) { + switch (widget.mode) { + case ScanMode.any: + return l10n.scanQrCode; + case ScanMode.cashuOnly: + return l10n.scanCashuToken; + case ScanMode.invoiceOnly: + return l10n.scanLightningInvoice; + } + } + + String _getInstructionForMode(L10n l10n) { + switch (widget.mode) { + case ScanMode.any: + return l10n.pointCameraAtQr; + case ScanMode.cashuOnly: + return l10n.pointCameraAtCashuQr; + case ScanMode.invoiceOnly: + return l10n.pointCameraAtInvoiceQr; + } + } + + void _onCodeDetected(String rawData) async { + if (_isProcessing) return; + + setState(() => _isProcessing = true); + + try { + // Parsear los datos + final parsed = IncomingDataParser.parse(rawData); + + // Verificar si es válido para el modo actual + if (!IncomingDataParser.isValidForMode(parsed, widget.mode)) { + _showInvalidTypeError(parsed.type); + setState(() => _isProcessing = false); + return; + } + + // Procesar según el tipo y modo + await _processData(parsed); + } catch (e) { + _showError(e.toString()); + } finally { + if (mounted) { + setState(() => _isProcessing = false); + } + } + } + + Future _processData(ParsedData data) async { + switch (widget.mode) { + case ScanMode.any: + await _handleAnyMode(data); + break; + case ScanMode.cashuOnly: + _handleCashuOnlyMode(data); + break; + case ScanMode.invoiceOnly: + _handleInvoiceOnlyMode(data); + break; + } + } + + Future _handleAnyMode(ParsedData data) async { + final l10n = L10n.of(context)!; + + switch (data.type) { + case IncomingDataType.cashuToken: + // Navegar a ReceiveScreen con el token pre-cargado + if (mounted) { + Navigator.pop(context); // Cerrar scanner + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ReceiveScreen(initialToken: data.raw), + ), + ); + } + break; + + case IncomingDataType.lightningInvoice: + // Navegar a MeltScreen con el invoice pre-cargado + if (mounted) { + Navigator.pop(context); // Cerrar scanner + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MeltScreen(initialInvoice: data.invoiceBolt11), + ), + ); + } + break; + + case IncomingDataType.mintUrl: + // Mostrar diálogo para agregar mint + if (mounted) { + await _showAddMintDialog(data.mintUrl!); + } + break; + + case IncomingDataType.paymentRequest: + // TODO: Implementar manejo de payment requests (post-MVP) + _showError(l10n.paymentRequestNotSupported); + break; + + case IncomingDataType.unknown: + _showError(l10n.unrecognizedQrCode); + break; + } + } + + void _handleCashuOnlyMode(ParsedData data) { + if (data.type == IncomingDataType.cashuToken) { + // Retornar el token vía callback + Navigator.pop(context, data.raw); + widget.onDataScanned?.call(data.raw); + } else { + _showInvalidTypeError(data.type); + } + } + + void _handleInvoiceOnlyMode(ParsedData data) { + if (data.type == IncomingDataType.lightningInvoice) { + // Retornar el invoice vía callback + Navigator.pop(context, data.invoiceBolt11 ?? data.raw); + widget.onDataScanned?.call(data.invoiceBolt11 ?? data.raw); + } else { + _showInvalidTypeError(data.type); + } + } + + Future _showAddMintDialog(String mintUrl) async { + final l10n = L10n.of(context)!; + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppColors.deepVoidPurple, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text( + l10n.addMintQuestion, + style: const TextStyle(color: Colors.white), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mintUrl, + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text( + l10n.cancel, + style: const TextStyle(color: Colors.white70), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + l10n.add, + style: const TextStyle(color: AppColors.primaryAction), + ), + ), + ], + ), + ); + + if (result == true && mounted) { + try { + final walletProvider = context.read(); + await walletProvider.addMint(mintUrl); + + if (mounted) { + Navigator.pop(context); // Cerrar scanner + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.mintAddedSuccessfully), + backgroundColor: AppColors.success, + ), + ); + } + } catch (e) { + _showError(l10n.couldNotConnectToMint); + } + } + } + + void _showInvalidTypeError(IncomingDataType detectedType) { + final l10n = L10n.of(context)!; + + String message; + switch (widget.mode) { + case ScanMode.cashuOnly: + message = l10n.scanCashuTokenHint; + break; + case ScanMode.invoiceOnly: + message = l10n.scanLightningInvoiceHint; + break; + case ScanMode.any: + message = l10n.unrecognizedQrCode; + break; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: AppColors.warning, + duration: const Duration(seconds: 2), + ), + ); + } + + void _showError(String message) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: AppColors.error, + duration: const Duration(seconds: 3), + ), + ); + } +} diff --git a/lib/screens/3_home/home_screen.dart b/lib/screens/3_home/home_screen.dart index 9236e76..75c9298 100644 --- a/lib/screens/3_home/home_screen.dart +++ b/lib/screens/3_home/home_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../../core/constants/colors.dart'; import '../../core/constants/dimensions.dart'; import '../../core/utils/formatters.dart'; +import '../../core/utils/incoming_data_parser.dart'; import '../../widgets/common/gradient_background.dart'; import '../../widgets/common/glass_card.dart'; import '../../widgets/common/animated_action_button.dart'; @@ -18,6 +19,7 @@ import '../7_melt/melt_screen.dart'; import '../8_settings/settings_screen.dart'; import '../8_settings/mints_screen.dart'; import '../9_history/history_screen.dart'; +import '../10_scanner/scan_screen.dart'; /// Pantalla principal - Home /// Muestra balance, acciones principales e historial @@ -366,7 +368,10 @@ class _HomeScreenState extends State { onTap: _showSendOptions, ), ), - const SizedBox(width: AppDimensions.paddingMedium), + const SizedBox(width: AppDimensions.paddingSmall), + // Botón scan circular (centro) + _buildScanButton(), + const SizedBox(width: AppDimensions.paddingSmall), // Recibir (segundo) - acción importante pero segura Expanded( child: AnimatedActionButton( @@ -380,6 +385,45 @@ class _HomeScreenState extends State { ); } + Widget _buildScanButton() { + return GestureDetector( + onTap: _openScanner, + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppColors.buttonGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: AppColors.primaryAction.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + LucideIcons.scan, + color: Colors.white, + size: 24, + ), + ), + ); + } + + void _openScanner() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ScanScreen(mode: ScanMode.any), + ), + ); + } + void _showReceiveOptions() { final l10n = L10n.of(context)!; showModalBottomSheet( diff --git a/lib/screens/4_receive/receive_screen.dart b/lib/screens/4_receive/receive_screen.dart index f70626a..1565a93 100644 --- a/lib/screens/4_receive/receive_screen.dart +++ b/lib/screens/4_receive/receive_screen.dart @@ -6,15 +6,20 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../../core/constants/colors.dart'; import '../../core/constants/dimensions.dart'; import '../../core/utils/formatters.dart'; +import '../../core/utils/incoming_data_parser.dart' hide TokenInfo; import '../../widgets/common/gradient_background.dart'; import '../../widgets/common/glass_card.dart'; import '../../widgets/common/primary_button.dart'; import '../../widgets/effects/cashu_confetti.dart'; import '../../providers/wallet_provider.dart'; +import '../10_scanner/scan_screen.dart'; /// Pantalla para recibir tokens Cashu class ReceiveScreen extends StatefulWidget { - const ReceiveScreen({super.key}); + /// Token inicial (pre-cargado desde QR scanner o deep link) + final String? initialToken; + + const ReceiveScreen({super.key, this.initialToken}); @override State createState() => _ReceiveScreenState(); @@ -33,6 +38,18 @@ class _ReceiveScreenState extends State { TokenInfo? _tokenInfo; String? _errorMessage; + @override + void initState() { + super.initState(); + // Pre-cargar token inicial si existe + if (widget.initialToken != null && widget.initialToken!.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _tokenController.text = widget.initialToken!; + _onTokenChanged(widget.initialToken!); + }); + } + } + @override void dispose() { _tokenController.dispose(); @@ -99,8 +116,8 @@ class _ReceiveScreenState extends State { const SizedBox(height: AppDimensions.paddingMedium), - // Botón pegar del portapapeles - _buildPasteButton(), + // Botones pegar y escanear + _buildActionButtons(), const SizedBox(height: AppDimensions.paddingLarge), @@ -211,6 +228,18 @@ class _ReceiveScreenState extends State { ); } + Widget _buildActionButtons() { + return Row( + children: [ + // Botón pegar (expandido) + Expanded(child: _buildPasteButton()), + const SizedBox(width: 12), + // Botón escanear QR + _buildScanButton(), + ], + ); + } + Widget _buildPasteButton() { return GestureDetector( onTap: _pasteFromClipboard, @@ -251,6 +280,40 @@ class _ReceiveScreenState extends State { ); } + Widget _buildScanButton() { + return GestureDetector( + onTap: _openScanner, + child: Container( + padding: const EdgeInsets.all(AppDimensions.paddingSmall + 4), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppColors.buttonGradient, + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + LucideIcons.scan, + color: Colors.white, + size: 24, + ), + ), + ); + } + + Future _openScanner() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ScanScreen(mode: ScanMode.cashuOnly), + ), + ); + + if (result != null && result.isNotEmpty && mounted) { + _tokenController.text = result; + _onTokenChanged(result); + } + } + Widget _buildTokenPreview() { final walletProvider = context.read(); final amount = _tokenInfo!.amount; diff --git a/lib/screens/7_melt/melt_screen.dart b/lib/screens/7_melt/melt_screen.dart index 00f9edd..66a590a 100644 --- a/lib/screens/7_melt/melt_screen.dart +++ b/lib/screens/7_melt/melt_screen.dart @@ -7,14 +7,19 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../../core/constants/colors.dart'; import '../../core/constants/dimensions.dart'; import '../../core/utils/formatters.dart'; +import '../../core/utils/incoming_data_parser.dart'; import '../../widgets/common/gradient_background.dart'; import '../../widgets/common/glass_card.dart'; import '../../widgets/common/primary_button.dart'; import '../../providers/wallet_provider.dart'; +import '../10_scanner/scan_screen.dart'; /// Pantalla para retirar sats a Lightning (Melt) class MeltScreen extends StatefulWidget { - const MeltScreen({super.key}); + /// Invoice inicial (pre-cargado desde QR scanner o deep link) + final String? initialInvoice; + + const MeltScreen({super.key, this.initialInvoice}); @override State createState() => _MeltScreenState(); @@ -40,6 +45,13 @@ class _MeltScreenState extends State { super.initState(); _activeUnit = context.read().activeUnit; _loadBalance(); + // Pre-cargar invoice inicial si existe + if (widget.initialInvoice != null && widget.initialInvoice!.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _invoiceController.text = widget.initialInvoice!; + _onInvoiceChanged(widget.initialInvoice!); + }); + } } /// Obtiene la etiqueta de la unidad para display @@ -109,8 +121,8 @@ class _MeltScreenState extends State { const SizedBox(height: AppDimensions.paddingMedium), - // Botón pegar del portapapeles - _buildPasteButton(), + // Botones pegar y escanear + _buildActionButtons(), const SizedBox(height: AppDimensions.paddingLarge), @@ -176,6 +188,18 @@ class _MeltScreenState extends State { ); } + Widget _buildActionButtons() { + return Row( + children: [ + // Botón pegar (expandido) + Expanded(child: _buildPasteButton()), + const SizedBox(width: 12), + // Botón escanear QR + _buildScanButton(), + ], + ); + } + Widget _buildPasteButton() { return GestureDetector( onTap: _pasteFromClipboard, @@ -216,6 +240,40 @@ class _MeltScreenState extends State { ); } + Widget _buildScanButton() { + return GestureDetector( + onTap: _openScanner, + child: Container( + padding: const EdgeInsets.all(AppDimensions.paddingSmall + 4), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppColors.buttonGradient, + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + LucideIcons.scan, + color: Colors.white, + size: 24, + ), + ), + ); + } + + Future _openScanner() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ScanScreen(mode: ScanMode.invoiceOnly), + ), + ); + + if (result != null && result.isNotEmpty && mounted) { + _invoiceController.text = result; + _onInvoiceChanged(result); + } + } + Widget _buildLoadingQuote() { return GlassCard( padding: const EdgeInsets.all(AppDimensions.paddingMedium), diff --git a/lib/widgets/scanner/qr_scanner_widget.dart b/lib/widgets/scanner/qr_scanner_widget.dart new file mode 100644 index 0000000..67f39b3 --- /dev/null +++ b/lib/widgets/scanner/qr_scanner_widget.dart @@ -0,0 +1,443 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../core/constants/colors.dart'; +import '../../core/utils/incoming_data_parser.dart'; + +/// Widget reutilizable para escanear QR codes +/// Soporta QR estáticos y animados (UR multipartes) +class QrScannerWidget extends StatefulWidget { + /// Callback cuando se detecta un código completo + final void Function(String data) onDetect; + + /// Callback para mostrar errores + final void Function(String error)? onError; + + /// Mostrar controles de flash (default: true) + final bool showFlashControl; + + /// Mostrar botón para cambiar cámara (default: false) + final bool showCameraSwitch; + + const QrScannerWidget({ + super.key, + required this.onDetect, + this.onError, + this.showFlashControl = true, + this.showCameraSwitch = false, + }); + + @override + State createState() => _QrScannerWidgetState(); +} + +class _QrScannerWidgetState extends State { + late MobileScannerController _controller; + + // Estado para QR animados (UR multipartes) + final Map _urFragments = {}; + int _urTotalFragments = 0; + bool _isCapturingUr = false; + + // Evitar procesar el mismo código múltiples veces + String? _lastProcessedCode; + + @override + void initState() { + super.initState(); + _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + facing: CameraFacing.back, + torchEnabled: false, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + final List barcodes = capture.barcodes; + if (barcodes.isEmpty) return; + + final barcode = barcodes.first; + final String? rawValue = barcode.rawValue; + if (rawValue == null || rawValue.isEmpty) return; + + // Evitar procesar el mismo código consecutivamente + if (rawValue == _lastProcessedCode) return; + _lastProcessedCode = rawValue; + + // Verificar si es un fragmento UR + if (IncomingDataParser.isUrFragment(rawValue)) { + _handleUrFragment(rawValue); + } else { + // QR simple - emitir directamente + _isCapturingUr = false; + _urFragments.clear(); + widget.onDetect(rawValue); + } + } + + void _handleUrFragment(String fragment) { + final headerInfo = IncomingDataParser.parseUrHeader(fragment); + + if (headerInfo == null) { + // UR sin formato multipart (fragmento único) + widget.onDetect(fragment); + return; + } + + final (currentIndex, totalFragments) = headerInfo; + + // Inicializar o verificar consistencia + if (!_isCapturingUr) { + _isCapturingUr = true; + _urTotalFragments = totalFragments; + _urFragments.clear(); + } else if (_urTotalFragments != totalFragments) { + // Nuevo QR con diferente número de fragmentos, resetear + _urFragments.clear(); + _urTotalFragments = totalFragments; + } + + // Guardar fragmento + _urFragments[currentIndex] = fragment; + + // Actualizar UI + if (mounted) setState(() {}); + + // Verificar si tenemos todos los fragmentos + if (_urFragments.length == _urTotalFragments) { + // Reconstruir datos completos + final sortedFragments = List.generate( + _urTotalFragments, + (i) => _urFragments[i + 1] ?? '', + ); + + // Emitir todos los fragmentos como lista separada por newlines + // El receptor debe usar cdk.decodeQrToken con la lista + final completeData = sortedFragments.join('\n'); + + _isCapturingUr = false; + _urFragments.clear(); + widget.onDetect(completeData); + } + } + + void _toggleFlash() async { + await _controller.toggleTorch(); + } + + void _switchCamera() async { + await _controller.switchCamera(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Scanner + MobileScanner( + controller: _controller, + onDetect: _onDetect, + errorBuilder: (context, error, child) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.cameraOff, + color: AppColors.error, + size: 48, + ), + const SizedBox(height: 16), + Text( + error.errorDetails?.message ?? 'Error de cámara', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + }, + ), + + // Overlay con visor + _buildOverlay(), + + // Indicador de progreso UR (si está capturando) + if (_isCapturingUr) _buildUrProgress(), + + // Controles + _buildControls(), + ], + ); + } + + Widget _buildOverlay() { + return LayoutBuilder( + builder: (context, constraints) { + final scannerSize = constraints.maxWidth * 0.7; + final horizontalPadding = (constraints.maxWidth - scannerSize) / 2; + final verticalPadding = (constraints.maxHeight - scannerSize) / 2; + + return Stack( + children: [ + // Sombra superior + Positioned( + top: 0, + left: 0, + right: 0, + height: verticalPadding, + child: Container(color: Colors.black54), + ), + // Sombra inferior + Positioned( + bottom: 0, + left: 0, + right: 0, + height: verticalPadding, + child: Container(color: Colors.black54), + ), + // Sombra izquierda + Positioned( + top: verticalPadding, + left: 0, + width: horizontalPadding, + height: scannerSize, + child: Container(color: Colors.black54), + ), + // Sombra derecha + Positioned( + top: verticalPadding, + right: 0, + width: horizontalPadding, + height: scannerSize, + child: Container(color: Colors.black54), + ), + // Marco del visor + Positioned( + top: verticalPadding, + left: horizontalPadding, + child: Container( + width: scannerSize, + height: scannerSize, + decoration: BoxDecoration( + border: Border.all( + color: _isCapturingUr + ? AppColors.warning + : AppColors.primaryAction, + width: 2, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Stack( + children: [ + // Esquinas decorativas + _buildCorner(Alignment.topLeft), + _buildCorner(Alignment.topRight), + _buildCorner(Alignment.bottomLeft), + _buildCorner(Alignment.bottomRight), + ], + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildCorner(Alignment alignment) { + final color = _isCapturingUr ? AppColors.warning : AppColors.primaryAction; + const size = 24.0; + const thickness = 4.0; + + return Positioned( + top: alignment == Alignment.topLeft || alignment == Alignment.topRight + ? 0 + : null, + bottom: + alignment == Alignment.bottomLeft || alignment == Alignment.bottomRight + ? 0 + : null, + left: alignment == Alignment.topLeft || alignment == Alignment.bottomLeft + ? 0 + : null, + right: + alignment == Alignment.topRight || alignment == Alignment.bottomRight + ? 0 + : null, + child: SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _CornerPainter( + color: color, + thickness: thickness, + alignment: alignment, + ), + ), + ), + ); + } + + Widget _buildUrProgress() { + final progress = _urFragments.length / _urTotalFragments; + + return Positioned( + top: 16, + left: 16, + right: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'QR Animado', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + Text( + '${_urFragments.length}/$_urTotalFragments', + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.black26, + valueColor: const AlwaysStoppedAnimation(Colors.black), + ), + ], + ), + ), + ); + } + + Widget _buildControls() { + return Positioned( + bottom: 24, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.showFlashControl) + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, child) { + final torchEnabled = state.torchState == TorchState.on; + return GestureDetector( + onTap: _toggleFlash, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: torchEnabled + ? AppColors.warning + : Colors.white.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Icon( + torchEnabled ? LucideIcons.zapOff : LucideIcons.zap, + color: torchEnabled ? Colors.black : Colors.white, + size: 24, + ), + ), + ); + }, + ), + if (widget.showFlashControl && widget.showCameraSwitch) + const SizedBox(width: 24), + if (widget.showCameraSwitch) + GestureDetector( + onTap: _switchCamera, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.switchCamera, + color: Colors.white, + size: 24, + ), + ), + ), + ], + ), + ); + } +} + +/// Painter para dibujar esquinas decorativas +class _CornerPainter extends CustomPainter { + final Color color; + final double thickness; + final Alignment alignment; + + _CornerPainter({ + required this.color, + required this.thickness, + required this.alignment, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = thickness + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + final path = Path(); + + if (alignment == Alignment.topLeft) { + path.moveTo(0, size.height); + path.lineTo(0, 0); + path.lineTo(size.width, 0); + } else if (alignment == Alignment.topRight) { + path.moveTo(0, 0); + path.lineTo(size.width, 0); + path.lineTo(size.width, size.height); + } else if (alignment == Alignment.bottomLeft) { + path.moveTo(0, 0); + path.lineTo(0, size.height); + path.lineTo(size.width, size.height); + } else if (alignment == Alignment.bottomRight) { + path.moveTo(size.width, 0); + path.lineTo(size.width, size.height); + path.lineTo(0, size.height); + } + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 999de3e..6344580 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import flutter_secure_storage_macos +import mobile_scanner import path_provider_foundation import share_plus import shared_preferences_foundation @@ -14,6 +15,7 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index a455da4..d7eab60 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -438,6 +438,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 + url: "https://pub.dev" + source: hosted + version: "5.2.3" nested: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f22d0bb..198c1d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: # QR Code qr_flutter: ^4.1.0 share_plus: ^12.0.1 + mobile_scanner: ^5.1.1 # URL Launcher url_launcher: ^6.2.0