feat: add QR scanner with UR multipart support
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user