Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0236df966 | ||
|
|
6b1cdabf14 |
@@ -0,0 +1,148 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Modelo de un proof Cashu local.
|
||||
/// Representa una "nota" de ecash con una denominación específica.
|
||||
class LocalProof {
|
||||
/// Primary key (Y point)
|
||||
final Uint8List y;
|
||||
|
||||
/// URL del mint que emitió este proof
|
||||
final String mintUrl;
|
||||
|
||||
/// Estado del proof
|
||||
final ProofState state;
|
||||
|
||||
/// Unidad (sat, usd, eur)
|
||||
final String unit;
|
||||
|
||||
/// Denominación (1, 2, 4, 8, 16, 32, 64, 128, 256, 512...)
|
||||
final BigInt amount;
|
||||
|
||||
/// ID del keyset
|
||||
final String keysetId;
|
||||
|
||||
/// Secret del proof
|
||||
final String secret;
|
||||
|
||||
/// Signature (C point)
|
||||
final Uint8List c;
|
||||
|
||||
/// Witness (opcional, para P2PK)
|
||||
final String? witness;
|
||||
|
||||
/// DLEQ proof components (opcional)
|
||||
final Uint8List? dleqE;
|
||||
final Uint8List? dleqS;
|
||||
final Uint8List? dleqR;
|
||||
|
||||
LocalProof({
|
||||
required this.y,
|
||||
required this.mintUrl,
|
||||
required this.state,
|
||||
required this.unit,
|
||||
required this.amount,
|
||||
required this.keysetId,
|
||||
required this.secret,
|
||||
required this.c,
|
||||
this.witness,
|
||||
this.dleqE,
|
||||
this.dleqS,
|
||||
this.dleqR,
|
||||
});
|
||||
|
||||
/// Crea un LocalProof desde un row de SQLite.
|
||||
factory LocalProof.fromSqlite(Map<String, dynamic> row) {
|
||||
return LocalProof(
|
||||
y: row['y'] as Uint8List,
|
||||
mintUrl: row['mint_url'] as String,
|
||||
state: ProofState.fromString(row['state'] as String),
|
||||
unit: row['unit'] as String,
|
||||
amount: BigInt.from(row['amount'] as int),
|
||||
keysetId: row['keyset_id'] as String,
|
||||
secret: row['secret'] as String,
|
||||
c: row['c'] as Uint8List,
|
||||
witness: row['witness'] as String?,
|
||||
dleqE: row['dleq_e'] as Uint8List?,
|
||||
dleqS: row['dleq_s'] as Uint8List?,
|
||||
dleqR: row['dleq_r'] as Uint8List?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convierte a formato JSON para token V3.
|
||||
Map<String, dynamic> toTokenProof() {
|
||||
final proof = <String, dynamic>{
|
||||
'id': keysetId,
|
||||
'amount': amount.toInt(),
|
||||
'secret': secret,
|
||||
'C': _bytesToHex(c),
|
||||
};
|
||||
|
||||
// Agregar DLEQ si existe
|
||||
if (dleqE != null && dleqS != null) {
|
||||
proof['dleq'] = {
|
||||
'e': _bytesToHex(dleqE!),
|
||||
's': _bytesToHex(dleqS!),
|
||||
if (dleqR != null) 'r': _bytesToHex(dleqR!),
|
||||
};
|
||||
}
|
||||
|
||||
// Agregar witness si existe
|
||||
if (witness != null && witness!.isNotEmpty) {
|
||||
proof['witness'] = witness;
|
||||
}
|
||||
|
||||
return proof;
|
||||
}
|
||||
|
||||
/// Convierte bytes a hex string.
|
||||
static String _bytesToHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// Convierte Y a hex para identificación.
|
||||
String get yHex => _bytesToHex(y);
|
||||
|
||||
@override
|
||||
String toString() => 'LocalProof(amount: $amount, unit: $unit, state: $state)';
|
||||
}
|
||||
|
||||
/// Estados posibles de un proof.
|
||||
enum ProofState {
|
||||
unspent,
|
||||
spent,
|
||||
pending,
|
||||
reserved,
|
||||
pendingSpent;
|
||||
|
||||
static ProofState fromString(String s) {
|
||||
switch (s.toUpperCase()) {
|
||||
case 'UNSPENT':
|
||||
return ProofState.unspent;
|
||||
case 'SPENT':
|
||||
return ProofState.spent;
|
||||
case 'PENDING':
|
||||
return ProofState.pending;
|
||||
case 'RESERVED':
|
||||
return ProofState.reserved;
|
||||
case 'PENDING_SPENT':
|
||||
return ProofState.pendingSpent;
|
||||
default:
|
||||
return ProofState.unspent;
|
||||
}
|
||||
}
|
||||
|
||||
String toSqlite() {
|
||||
switch (this) {
|
||||
case ProofState.unspent:
|
||||
return 'UNSPENT';
|
||||
case ProofState.spent:
|
||||
return 'SPENT';
|
||||
case ProofState.pending:
|
||||
return 'PENDING';
|
||||
case ProofState.reserved:
|
||||
return 'RESERVED';
|
||||
case ProofState.pendingSpent:
|
||||
return 'PENDING_SPENT';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import '../models/proof.dart';
|
||||
|
||||
/// Servicio para acceder a los proofs locales en SQLite.
|
||||
/// Permite leer proofs, crear tokens offline y actualizar estados.
|
||||
class ProofService {
|
||||
Database? _db;
|
||||
static bool _ffiInitialized = false;
|
||||
|
||||
/// Inicializa sqflite FFI para plataformas desktop (Linux, Windows, macOS).
|
||||
static void _initFfiIfNeeded() {
|
||||
if (_ffiInitialized) return;
|
||||
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
debugPrint('sqflite FFI initialized for desktop');
|
||||
}
|
||||
_ffiInitialized = true;
|
||||
}
|
||||
|
||||
/// Obtiene la instancia de la base de datos.
|
||||
Future<Database> get database async {
|
||||
if (_db != null) return _db!;
|
||||
|
||||
// Inicializar FFI para desktop
|
||||
_initFfiIfNeeded();
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
|
||||
|
||||
_db = await openDatabase(dbPath);
|
||||
return _db!;
|
||||
}
|
||||
|
||||
/// Obtiene todos los proofs UNSPENT de un mint y unidad específicos.
|
||||
/// Ordenados de mayor a menor denominación.
|
||||
Future<List<LocalProof>> getUnspentProofs({
|
||||
required String mintUrl,
|
||||
required String unit,
|
||||
}) async {
|
||||
final db = await database;
|
||||
|
||||
final rows = await db.query(
|
||||
'proof',
|
||||
where: 'mint_url = ? AND unit = ? AND state = ?',
|
||||
whereArgs: [mintUrl, unit, 'UNSPENT'],
|
||||
orderBy: 'amount DESC',
|
||||
);
|
||||
|
||||
return rows.map((row) => LocalProof.fromSqlite(row)).toList();
|
||||
}
|
||||
|
||||
/// Agrupa proofs por denominación.
|
||||
/// Retorna Map ordenado de mayor a menor.
|
||||
Map<BigInt, List<LocalProof>> groupByDenomination(List<LocalProof> proofs) {
|
||||
final grouped = <BigInt, List<LocalProof>>{};
|
||||
|
||||
for (final proof in proofs) {
|
||||
grouped.putIfAbsent(proof.amount, () => []).add(proof);
|
||||
}
|
||||
|
||||
// Ordenar por denominación descendente
|
||||
final sorted = Map.fromEntries(
|
||||
grouped.entries.toList()..sort((a, b) => b.key.compareTo(a.key)),
|
||||
);
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/// Calcula el total de proofs seleccionados.
|
||||
BigInt calculateTotal(List<LocalProof> proofs) {
|
||||
return proofs.fold(BigInt.zero, (sum, p) => sum + p.amount);
|
||||
}
|
||||
|
||||
/// Selecciona proofs que sumen exactamente el monto.
|
||||
/// Retorna null si no hay combinación exacta.
|
||||
List<LocalProof>? selectExactProofs(
|
||||
List<LocalProof> available,
|
||||
BigInt targetAmount,
|
||||
) {
|
||||
final selected = <LocalProof>[];
|
||||
var remaining = targetAmount;
|
||||
|
||||
// Ordenar de mayor a menor
|
||||
final sorted = List<LocalProof>.from(available)
|
||||
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||
|
||||
// Greedy selection
|
||||
for (final proof in sorted) {
|
||||
if (remaining >= proof.amount) {
|
||||
selected.add(proof);
|
||||
remaining -= proof.amount;
|
||||
}
|
||||
if (remaining == BigInt.zero) break;
|
||||
}
|
||||
|
||||
return remaining == BigInt.zero ? selected : null;
|
||||
}
|
||||
|
||||
/// Marca proofs como PENDING_SPENT en la base de datos.
|
||||
Future<void> markProofsPendingSpent(List<LocalProof> proofs) async {
|
||||
final db = await database;
|
||||
|
||||
await db.transaction((txn) async {
|
||||
for (final proof in proofs) {
|
||||
await txn.update(
|
||||
'proof',
|
||||
{'state': 'PENDING_SPENT'},
|
||||
where: 'y = ?',
|
||||
whereArgs: [proof.y],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint('Marked ${proofs.length} proofs as PENDING_SPENT');
|
||||
}
|
||||
|
||||
/// Marca proofs como UNSPENT (para revertir si falla el envío).
|
||||
Future<void> markProofsUnspent(List<LocalProof> proofs) async {
|
||||
final db = await database;
|
||||
|
||||
await db.transaction((txn) async {
|
||||
for (final proof in proofs) {
|
||||
await txn.update(
|
||||
'proof',
|
||||
{'state': 'UNSPENT'},
|
||||
where: 'y = ?',
|
||||
whereArgs: [proof.y],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint('Reverted ${proofs.length} proofs to UNSPENT');
|
||||
}
|
||||
|
||||
/// Crea un token Cashu V3 desde proofs seleccionados.
|
||||
/// Formato: cashuA + base64(JSON)
|
||||
String createTokenV3({
|
||||
required String mintUrl,
|
||||
required List<LocalProof> proofs,
|
||||
String? memo,
|
||||
String? unit,
|
||||
}) {
|
||||
// Estructura del token V3
|
||||
final tokenData = <String, dynamic>{
|
||||
'token': [
|
||||
{
|
||||
'mint': mintUrl,
|
||||
'proofs': proofs.map((p) => p.toTokenProof()).toList(),
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
// Agregar memo si existe
|
||||
if (memo != null && memo.isNotEmpty) {
|
||||
tokenData['memo'] = memo;
|
||||
}
|
||||
|
||||
// Agregar unit si existe
|
||||
if (unit != null && unit.isNotEmpty) {
|
||||
tokenData['unit'] = unit;
|
||||
}
|
||||
|
||||
// Codificar a JSON y luego base64
|
||||
final jsonStr = jsonEncode(tokenData);
|
||||
final base64Str = base64.encode(utf8.encode(jsonStr));
|
||||
|
||||
return 'cashuA$base64Str';
|
||||
}
|
||||
|
||||
/// Cierra la conexión a la base de datos.
|
||||
Future<void> close() async {
|
||||
await _db?.close();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/models/proof.dart';
|
||||
import '../../core/services/proof_service.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/proof/proof_selector.dart';
|
||||
import 'share_token_screen.dart';
|
||||
|
||||
/// Pantalla para enviar tokens de forma offline seleccionando proofs manualmente.
|
||||
class OfflineSendScreen extends StatefulWidget {
|
||||
final String mintUrl;
|
||||
final String unit;
|
||||
|
||||
const OfflineSendScreen({
|
||||
super.key,
|
||||
required this.mintUrl,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OfflineSendScreen> createState() => _OfflineSendScreenState();
|
||||
}
|
||||
|
||||
class _OfflineSendScreenState extends State<OfflineSendScreen> {
|
||||
final TextEditingController _memoController = TextEditingController();
|
||||
final ProofService _proofService = ProofService();
|
||||
|
||||
List<LocalProof> _availableProofs = [];
|
||||
Set<String> _selectedIds = {};
|
||||
bool _isLoading = true;
|
||||
bool _isCreating = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProofs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_memoController.dispose();
|
||||
_proofService.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadProofs() async {
|
||||
try {
|
||||
final proofs = await _proofService.getUnspentProofs(
|
||||
mintUrl: widget.mintUrl,
|
||||
unit: widget.unit,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableProofs = proofs;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = 'Error cargando proofs: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene los proofs seleccionados.
|
||||
List<LocalProof> get _selectedProofs {
|
||||
return _availableProofs
|
||||
.where((p) => _selectedIds.contains(p.yHex))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Calcula el total seleccionado.
|
||||
BigInt get _selectedTotal {
|
||||
return _proofService.calculateTotal(_selectedProofs);
|
||||
}
|
||||
|
||||
/// Toggle selección de un proof.
|
||||
void _toggleProof(LocalProof proof) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(proof.yHex)) {
|
||||
_selectedIds.remove(proof.yHex);
|
||||
} else {
|
||||
_selectedIds.add(proof.yHex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Seleccionar todos.
|
||||
void _selectAll() {
|
||||
setState(() {
|
||||
_selectedIds = _availableProofs.map((p) => p.yHex).toSet();
|
||||
});
|
||||
}
|
||||
|
||||
/// Deseleccionar todos.
|
||||
void _clearSelection() {
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Envio Offline',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// Botón seleccionar/deseleccionar todos
|
||||
if (_availableProofs.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_selectedIds.length == _availableProofs.length
|
||||
? LucideIcons.checkSquare
|
||||
: LucideIcons.square,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
onPressed: _selectedIds.length == _availableProofs.length
|
||||
? _clearSelection
|
||||
: _selectAll,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: _isLoading
|
||||
? _buildLoading()
|
||||
: _errorMessage != null
|
||||
? _buildError()
|
||||
: _buildContent(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.alertCircle,
|
||||
size: 48,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Total seleccionado
|
||||
_buildTotalDisplay(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Instrucciones
|
||||
Text(
|
||||
'Selecciona las notas que deseas enviar:',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Selector de proofs
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: ProofSelector(
|
||||
proofs: _availableProofs,
|
||||
selectedIds: _selectedIds,
|
||||
unit: widget.unit,
|
||||
onToggle: _toggleProof,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Memo opcional
|
||||
_buildMemoSection(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Botón crear token
|
||||
PrimaryButton(
|
||||
text: _isCreating ? 'Creando...' : 'Crear token',
|
||||
onPressed: _selectedIds.isNotEmpty && !_isCreating
|
||||
? _createOfflineToken
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTotalDisplay() {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Total a enviar',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ProofTotalDisplay(
|
||||
total: _selectedTotal,
|
||||
unit: widget.unit,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${_selectedIds.length} notas seleccionadas',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemoSection() {
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingSmall),
|
||||
child: TextField(
|
||||
controller: _memoController,
|
||||
maxLines: 1,
|
||||
maxLength: 100,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Memo (opcional)',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
counterStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createOfflineToken() async {
|
||||
setState(() {
|
||||
_isCreating = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final selectedProofs = _selectedProofs;
|
||||
|
||||
// Marcar proofs como PENDING_SPENT
|
||||
await _proofService.markProofsPendingSpent(selectedProofs);
|
||||
|
||||
// Crear token V3
|
||||
final memo = _memoController.text.isNotEmpty ? _memoController.text : null;
|
||||
final token = _proofService.createTokenV3(
|
||||
mintUrl: widget.mintUrl,
|
||||
proofs: selectedProofs,
|
||||
memo: memo,
|
||||
unit: widget.unit,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ShareTokenScreen(
|
||||
token: token,
|
||||
amount: _selectedTotal,
|
||||
unit: widget.unit,
|
||||
memo: memo,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Error creando token: $e';
|
||||
_isCreating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
@@ -9,6 +10,7 @@ import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import 'share_token_screen.dart';
|
||||
import 'offline_send_screen.dart';
|
||||
|
||||
/// Pantalla para enviar tokens Cashu
|
||||
class SendScreen extends StatefulWidget {
|
||||
@@ -79,6 +81,14 @@ class _SendScreenState extends State<SendScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// Botón para modo offline (selección manual de proofs)
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.coins, color: AppColors.primaryAction),
|
||||
tooltip: 'Seleccionar notas manualmente',
|
||||
onPressed: _goToOfflineMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
@@ -313,7 +323,60 @@ class _SendScreenState extends State<SendScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _showConfirmation() {
|
||||
/// Navegar al modo offline para seleccionar proofs manualmente.
|
||||
void _goToOfflineMode() {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final mintUrl = walletProvider.activeMintUrl;
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => OfflineSendScreen(
|
||||
mintUrl: mintUrl,
|
||||
unit: _activeUnit,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConfirmation() async {
|
||||
// Verificar conectividad antes de mostrar confirmación
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final mintUrl = walletProvider.activeMintUrl;
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
});
|
||||
|
||||
final isOnline = await _checkConnectivity(mintUrl);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
|
||||
if (!isOnline) {
|
||||
// Offline: ir directo a selección de monedas
|
||||
_goToOfflineModeWithMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Online: mostrar modal de confirmación normal
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -330,6 +393,18 @@ class _SendScreenState extends State<SendScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifica conectividad haciendo petición HTTP real al mint.
|
||||
Future<bool> _checkConnectivity(String mintUrl) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$mintUrl/v1/info'),
|
||||
).timeout(const Duration(seconds: 3));
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createToken() async {
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
@@ -358,8 +433,20 @@ class _SendScreenState extends State<SendScreen> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
|
||||
// Detectar errores de red y redirigir a modo offline
|
||||
if (_isNetworkError(errorStr)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
_goToOfflineModeWithMessage();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
_errorMessage = 'Balance insuficiente';
|
||||
} else {
|
||||
@@ -374,6 +461,51 @@ class _SendScreenState extends State<SendScreen> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detecta si el error es de conexión/red.
|
||||
bool _isNetworkError(String errorStr) {
|
||||
return errorStr.contains('transport error') ||
|
||||
errorStr.contains('network') ||
|
||||
errorStr.contains('connection') ||
|
||||
errorStr.contains('socket') ||
|
||||
errorStr.contains('timeout') ||
|
||||
errorStr.contains('unreachable') ||
|
||||
errorStr.contains('no route') ||
|
||||
errorStr.contains('error sending request');
|
||||
}
|
||||
|
||||
/// Navegar al modo offline mostrando mensaje informativo.
|
||||
void _goToOfflineModeWithMessage() {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final mintUrl = walletProvider.activeMintUrl;
|
||||
|
||||
if (mintUrl == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No hay mint activo';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Mostrar snackbar informativo
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Sin conexion. Usando modo offline...'),
|
||||
backgroundColor: AppColors.primaryAction,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Navegar a modo offline
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => OfflineSendScreen(
|
||||
mintUrl: mintUrl,
|
||||
unit: _activeUnit,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modal de confirmacion
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/models/proof.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
|
||||
/// Widget para seleccionar proofs (notas de ecash) manualmente.
|
||||
/// Cada proof se muestra como un chip que cambia de color al seleccionar.
|
||||
class ProofSelector extends StatelessWidget {
|
||||
/// Lista de proofs disponibles.
|
||||
final List<LocalProof> proofs;
|
||||
|
||||
/// Set de proofs actualmente seleccionados (por yHex).
|
||||
final Set<String> selectedIds;
|
||||
|
||||
/// Unidad para formatear (sat, usd, eur).
|
||||
final String unit;
|
||||
|
||||
/// Callback cuando se selecciona/deselecciona un proof.
|
||||
final void Function(LocalProof proof) onToggle;
|
||||
|
||||
const ProofSelector({
|
||||
super.key,
|
||||
required this.proofs,
|
||||
required this.selectedIds,
|
||||
required this.unit,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (proofs.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: proofs.map((proof) {
|
||||
final isSelected = selectedIds.contains(proof.yHex);
|
||||
return _ProofChip(
|
||||
proof: proof,
|
||||
unit: unit,
|
||||
isSelected: isSelected,
|
||||
onTap: () => onToggle(proof),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_balance_wallet_outlined,
|
||||
size: 48,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No hay notas disponibles',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip individual para un proof.
|
||||
class _ProofChip extends StatelessWidget {
|
||||
final LocalProof proof;
|
||||
final String unit;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ProofChip({
|
||||
required this.proof,
|
||||
required this.unit,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Formatear el monto según la unidad
|
||||
final displayAmount = _formatAmount(proof.amount, unit);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
// Color de fondo: naranja si seleccionado, glass si no
|
||||
color: isSelected
|
||||
? AppColors.primaryAction
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppColors.primaryAction
|
||||
: Colors.white.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
// Sombra sutil cuando seleccionado
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryAction.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
displayAmount,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Formatea el monto según la unidad.
|
||||
/// sat: "512", usd/eur: "5.12"
|
||||
String _formatAmount(BigInt amount, String unit) {
|
||||
final multiplier = UnitFormatter.getMultiplier(unit);
|
||||
|
||||
if (multiplier == 100) {
|
||||
// USD/EUR: mostrar como decimal
|
||||
final value = amount.toDouble() / 100;
|
||||
return value.toStringAsFixed(2);
|
||||
} else {
|
||||
// SAT: mostrar como entero
|
||||
return amount.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget que muestra el total seleccionado.
|
||||
class ProofTotalDisplay extends StatelessWidget {
|
||||
final BigInt total;
|
||||
final String unit;
|
||||
|
||||
const ProofTotalDisplay({
|
||||
super.key,
|
||||
required this.total,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formattedTotal = UnitFormatter.formatBalance(total, unit);
|
||||
final unitLabel = UnitFormatter.getUnitLabel(unit);
|
||||
|
||||
return Text(
|
||||
'$formattedTotal $unitLabel',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -555,6 +555,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4+6"
|
||||
sqflite_common_ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.4+4"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1+1"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -579,6 +635,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.0+3"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -27,6 +27,8 @@ dependencies:
|
||||
shared_preferences: ^2.2.0
|
||||
flutter_secure_storage: ^9.0.0
|
||||
path_provider: ^2.1.0
|
||||
sqflite: ^2.3.0
|
||||
sqflite_common_ffi: ^2.3.0
|
||||
|
||||
# Icons
|
||||
lucide_icons: ^0.257.0
|
||||
|
||||
Reference in New Issue
Block a user