Files
me.elcaju/lib/widgets/scanner/qr_scanner_widget.dart
T

452 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../src/rust/api/token.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) usando TokenDecoder de cdk-flutter
TokenDecoder? _urDecoder;
bool _isCapturingUr = false;
final Set<String> _urFragmentsSeen = {}; // Trackear fragmentos únicos
// Evitar procesar el mismo código múltiples veces
String? _lastProcessedCode;
@override
void initState() {
super.initState();
_controller = MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates,
facing: CameraFacing.back,
torchEnabled: false,
// Usar resolución más alta para QRs densos
cameraResolution: const Size(1920, 1080),
);
}
@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 (ur:bytes/, ur:cashu/, etc.)
if (rawValue.toLowerCase().startsWith('ur:')) {
_handleUrFragment(rawValue);
} else {
// QR simple - emitir directamente
_resetUrState();
widget.onDetect(rawValue);
}
}
void _handleUrFragment(String fragment) {
// Inicializar decoder si es necesario
if (_urDecoder == null) {
_urDecoder = TokenDecoder();
_isCapturingUr = true;
_urFragmentsSeen.clear();
}
// Ignorar fragmentos ya vistos
if (_urFragmentsSeen.contains(fragment)) {
return;
}
// Pasar fragmento al decoder (usa fountain codes internamente)
try {
_urDecoder!.receive(input: fragment);
_urFragmentsSeen.add(fragment);
} catch (e) {
// Fragmento inválido o incompatible, ignorar
return;
}
// Actualizar UI
if (mounted) setState(() {});
// Verificar si está completo
if (_urDecoder!.isComplete()) {
try {
final token = _urDecoder!.value();
if (token != null) {
final encodedToken = token.encoded;
_resetUrState();
widget.onDetect(encodedToken);
} else {
widget.onError?.call('Error: token vacío');
_resetUrState();
}
} catch (e) {
widget.onError?.call('Error decodificando UR: $e');
_resetUrState();
}
}
}
void _resetUrState() {
_urDecoder = null;
_isCapturingUr = false;
_urFragmentsSeen.clear();
_lastProcessedCode = null;
}
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 ??
L10n.of(context)!.cameraPermissionDenied,
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() {
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(
'${_urFragmentsSeen.length} fragmentos',
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
const SizedBox(height: 8),
// Indicador indeterminado ya que TokenDecoder no expone progreso
const LinearProgressIndicator(
backgroundColor: Colors.black26,
valueColor: 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 _CornerPainter oldDelegate) =>
color != oldDelegate.color;
}