feat: add QR scanner with UR multipart support

This commit is contained in:
Forte11Cuba
2026-02-07 23:20:22 -06:00
parent a25fb9d7c1
commit 9ec585d9a2
12 changed files with 1152 additions and 9 deletions
+2
View File
@@ -2,6 +2,8 @@
<!-- Permisos de red requeridos para conectar con mints Cashu -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- Permiso de cámara para escanear QR codes -->
<uses-permission android:name="android.permission.CAMERA"/>
<application
android:label="ElCaju"
+165
View File
@@ -0,0 +1,165 @@
// Parser para detectar el tipo de dato entrante (QR, clipboard, etc.)
// Soporta: tokens Cashu (A/B), invoices Lightning, URLs de mint, payment requests
/// Tipo de dato detectado
enum IncomingDataType {
cashuToken, // cashuA... / cashuB...
lightningInvoice, // lnbc... / lntb... / lnbcrt...
mintUrl, // https://...
paymentRequest, // creqA... (post-MVP, Cashu payment request)
unknown,
}
/// Información de un token parseado (para preview)
class TokenInfo {
final BigInt amount;
final String mintUrl;
final String? unit;
final String? memo;
TokenInfo({
required this.amount,
required this.mintUrl,
this.unit,
this.memo,
});
}
/// Resultado del parsing
class ParsedData {
final IncomingDataType type;
final String raw;
final TokenInfo? tokenInfo;
final String? invoiceBolt11;
final String? mintUrl;
ParsedData({
required this.type,
required this.raw,
this.tokenInfo,
this.invoiceBolt11,
this.mintUrl,
});
/// True si el tipo es conocido y puede ser procesado
bool get isValid => 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
}
+16 -1
View File
@@ -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"
}
+16 -1
View File
@@ -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"
}
+327
View File
@@ -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<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
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<void> _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<void> _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<void> _showAddMintDialog(String mintUrl) async {
final l10n = L10n.of(context)!;
final result = await showDialog<bool>(
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<WalletProvider>();
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),
),
);
}
}
+45 -1
View File
@@ -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<HomeScreen> {
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<HomeScreen> {
);
}
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(
+66 -3
View File
@@ -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<ReceiveScreen> createState() => _ReceiveScreenState();
@@ -33,6 +38,18 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
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<ReceiveScreen> {
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<ReceiveScreen> {
);
}
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<ReceiveScreen> {
);
}
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<void> _openScanner() async {
final result = await Navigator.push<String>(
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<WalletProvider>();
final amount = _tokenInfo!.amount;
+61 -3
View File
@@ -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<MeltScreen> createState() => _MeltScreenState();
@@ -40,6 +45,13 @@ class _MeltScreenState extends State<MeltScreen> {
super.initState();
_activeUnit = context.read<WalletProvider>().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<MeltScreen> {
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<MeltScreen> {
);
}
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<MeltScreen> {
);
}
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<void> _openScanner() async {
final result = await Navigator.push<String>(
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),
+443
View File
@@ -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<QrScannerWidget> createState() => _QrScannerWidgetState();
}
class _QrScannerWidgetState extends State<QrScannerWidget> {
late MobileScannerController _controller;
// Estado para QR animados (UR multipartes)
final Map<int, String> _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<Barcode> 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<Color>(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;
}
@@ -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"))
+8
View File
@@ -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:
+1
View File
@@ -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