feat: add dedicated history screen with dynamic QR codes

This commit is contained in:
Forte11Cuba
2026-02-06 14:22:25 -06:00
parent aeaebee37b
commit 9ee020ccb2
4 changed files with 1371 additions and 295 deletions
+166
View File
@@ -0,0 +1,166 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// Tipos de transacción
enum TransactionType {
cashu,
lightning,
}
/// Metadata adicional de una transacción.
/// Se usa para guardar datos que el CDK no persiste (token, invoice, tipo).
class TransactionMeta {
final TransactionType type;
final String? token; // Solo para Cashu send
final String? invoice; // Solo para Lightning (mint/melt)
final DateTime createdAt;
TransactionMeta({
required this.type,
this.token,
this.invoice,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
Map<String, dynamic> toJson() => {
'type': type.name,
'token': token,
'invoice': invoice,
'createdAt': createdAt.toIso8601String(),
};
factory TransactionMeta.fromJson(Map<String, dynamic> json) {
return TransactionMeta(
type: TransactionType.values.firstWhere(
(t) => t.name == json['type'],
orElse: () => TransactionType.cashu,
),
token: json['token'] as String?,
invoice: json['invoice'] as String?,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
: DateTime.now(),
);
}
}
/// Storage para metadata de transacciones.
/// Complementa el historial del CDK con datos adicionales.
///
/// Uso:
/// ```dart
/// final storage = TransactionMetaStorage();
/// await storage.init();
///
/// // Guardar metadata
/// await storage.save('tx_123', TransactionMeta(type: TransactionType.cashu, token: 'cashuA...'));
///
/// // Obtener metadata
/// final meta = storage.get('tx_123');
/// ```
class TransactionMetaStorage {
static const _storageKey = 'transaction_meta';
SharedPreferences? _prefs;
final Map<String, TransactionMeta> _cache = {};
/// Singleton
static final TransactionMetaStorage _instance = TransactionMetaStorage._internal();
factory TransactionMetaStorage() => _instance;
TransactionMetaStorage._internal();
/// Inicializa el storage. Llamar antes de usar.
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
await _loadFromDisk();
}
/// Carga datos desde SharedPreferences al cache en memoria.
Future<void> _loadFromDisk() async {
final jsonStr = _prefs?.getString(_storageKey);
if (jsonStr == null || jsonStr.isEmpty) return;
try {
final decoded = jsonDecode(jsonStr) as Map<String, dynamic>;
_cache.clear();
for (final entry in decoded.entries) {
_cache[entry.key] = TransactionMeta.fromJson(
entry.value as Map<String, dynamic>,
);
}
} catch (e) {
// Si hay error de parsing, empezar limpio
_cache.clear();
}
}
/// Guarda el cache en SharedPreferences.
Future<void> _saveToDisk() async {
final jsonMap = <String, dynamic>{};
for (final entry in _cache.entries) {
jsonMap[entry.key] = entry.value.toJson();
}
await _prefs?.setString(_storageKey, jsonEncode(jsonMap));
}
/// Guarda metadata para una transacción.
Future<void> save(String transactionId, TransactionMeta meta) async {
_cache[transactionId] = meta;
await _saveToDisk();
}
/// Obtiene metadata de una transacción.
TransactionMeta? get(String transactionId) {
return _cache[transactionId];
}
/// Verifica si existe metadata para una transacción.
bool has(String transactionId) {
return _cache.containsKey(transactionId);
}
/// Elimina metadata de una transacción.
Future<void> remove(String transactionId) async {
_cache.remove(transactionId);
await _saveToDisk();
}
/// Elimina todas las metadata.
Future<void> clear() async {
_cache.clear();
await _prefs?.remove(_storageKey);
}
/// Obtiene el tipo de una transacción.
/// Primero busca en metadata del CDK, luego en storage local.
TransactionType getType(String transactionId, Map<String, String>? cdkMetadata) {
// 1. Buscar en metadata del CDK
final cdkType = cdkMetadata?['type'];
if (cdkType == 'cashu') return TransactionType.cashu;
if (cdkType == 'lightning') return TransactionType.lightning;
// 2. Buscar en storage local
final localMeta = get(transactionId);
if (localMeta != null) return localMeta.type;
// 3. Default: cashu (la mayoría de transacciones son cashu)
return TransactionType.cashu;
}
/// Limpia entradas huérfanas (IDs que ya no existen en el CDK).
/// Llamar periódicamente para no acumular basura.
Future<int> cleanupOrphans(Set<String> validIds) async {
final orphans = _cache.keys.where((id) => !validIds.contains(id)).toList();
for (final id in orphans) {
_cache.remove(id);
}
if (orphans.isNotEmpty) {
await _saveToDisk();
}
return orphans.length;
}
}
+164 -1
View File
@@ -6,6 +6,7 @@ import 'package:cdk_flutter/cdk_flutter.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../data/transaction_meta_storage.dart';
/// Helper class para info de token parseado
class TokenInfo {
@@ -29,6 +30,9 @@ class WalletProvider extends ChangeNotifier {
WalletDatabase? _db;
String? _mnemonic;
/// Storage para metadata de transacciones (tipo, token, invoice)
final TransactionMetaStorage _txMetaStorage = TransactionMetaStorage();
/// Mints conocidos con sus unidades soportadas.
/// Ejemplo: {'mint.cubabitcoin.org': ['sat', 'usd']}
final Map<String, List<String>> _mintUnits = {};
@@ -210,6 +214,9 @@ class WalletProvider extends ChangeNotifier {
Future<void> initialize(String mnemonic) async {
_mnemonic = mnemonic;
// Inicializar storage de metadata de transacciones
await _txMetaStorage.init();
// Obtener directorio de documentos (path absoluto requerido)
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
@@ -720,6 +727,7 @@ class WalletProvider extends ChangeNotifier {
}
/// Recibe un token con una unidad específica.
/// Guarda metadata type=cashu para identificar en historial.
Future<BigInt> _receiveWithUnit(
String encodedToken,
String mintUrl,
@@ -729,11 +737,38 @@ class WalletProvider extends ChangeNotifier {
final token = Token.parse(encoded: encodedToken);
final amount = await wallet.receive(token: token);
// Guardar metadata para la transacción recién creada
await _saveMetaForRecentReceive(wallet, encodedToken);
debugPrint('Token recibido: $amount $unit en $mintUrl');
notifyListeners();
return amount;
}
/// Guarda metadata para la transacción de receive más reciente.
Future<void> _saveMetaForRecentReceive(Wallet wallet, String tokenEncoded) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.incoming,
);
if (txs.isNotEmpty) {
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.cashu,
token: tokenEncoded,
),
);
debugPrint('Receive metadata guardada para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando receive metadata: $e');
}
}
/// Reclama un token P2PK (bloqueado a una clave pública).
Future<BigInt> receiveP2pkToken(
String encodedToken,
@@ -789,6 +824,7 @@ class WalletProvider extends ChangeNotifier {
}
/// Confirma un envío preparado y retorna el token encoded.
/// Guarda metadata type=cashu para identificar en historial.
Future<String> confirmSend(PreparedSend prepared, String? memo) async {
final wallet = await getActiveWallet();
@@ -798,10 +834,41 @@ class WalletProvider extends ChangeNotifier {
includeMemo: memo != null && memo.isNotEmpty,
);
// Guardar token en storage local para mostrar en detalles del historial.
// Usamos hash del token como key temporal; después buscaremos la transacción.
await _saveTokenForRecentTransaction(token.encoded);
notifyListeners();
return token.encoded;
}
/// Guarda el token para la transacción más reciente de tipo send.
Future<void> _saveTokenForRecentTransaction(String tokenEncoded) async {
try {
// Obtener transacciones outgoing más recientes
final wallet = await getActiveWallet();
final txs = await wallet.listTransactions(
direction: TransactionDirection.outgoing,
);
if (txs.isNotEmpty) {
// La más reciente debería ser la que acabamos de crear
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.cashu,
token: tokenEncoded,
),
);
debugPrint('Token guardado para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando token metadata: $e');
}
}
/// Cancela un envío preparado (libera proofs reservados).
Future<void> cancelSend(PreparedSend prepared) async {
final wallet = await getActiveWallet();
@@ -830,36 +897,113 @@ class WalletProvider extends ChangeNotifier {
/// Inicia un depósito via Lightning.
/// Retorna Stream con estados: unpaid -> paid -> issued.
/// Guarda metadata type=lightning cuando se completa.
Stream<MintQuote> mintTokens(BigInt amount, String? description) {
final wallet = activeWallet;
if (wallet == null) {
throw Exception('No hay wallet activo');
}
String? invoiceBolt11;
// Wrapper del stream para capturar el invoice y guardar metadata
return wallet.mint(
amount: amount,
description: description,
);
).map((quote) {
// Capturar el invoice cuando está en estado unpaid
if (quote.state == MintQuoteState.unpaid) {
invoiceBolt11 = quote.request;
}
// Cuando se completa, guardar metadata
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
_saveMintMetadata(wallet, invoiceBolt11!);
}
return quote;
});
}
/// Guarda metadata para una transacción de mint (Lightning deposit).
Future<void> _saveMintMetadata(Wallet wallet, String invoice) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.incoming,
);
if (txs.isNotEmpty) {
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.lightning,
invoice: invoice,
),
);
debugPrint('Mint metadata guardada para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando mint metadata: $e');
}
}
// ============================================================
// MELT (Retirar a Lightning)
// ============================================================
/// Invoice temporal para guardar metadata después de melt
String? _pendingMeltInvoice;
/// Obtiene quote para pagar un invoice BOLT11.
/// Guarda el invoice temporalmente para asociarlo a la transacción después.
Future<MeltQuote> getMeltQuote(String bolt11Invoice) async {
_pendingMeltInvoice = bolt11Invoice;
final wallet = await getActiveWallet();
return await wallet.meltQuote(request: bolt11Invoice);
}
/// Ejecuta el pago del invoice.
/// Guarda metadata type=lightning para identificar en historial.
Future<BigInt> melt(MeltQuote quote) async {
final wallet = await getActiveWallet();
final totalPaid = await wallet.melt(quote: quote);
// Guardar metadata con el invoice
if (_pendingMeltInvoice != null) {
await _saveMeltMetadata(wallet, _pendingMeltInvoice!);
_pendingMeltInvoice = null;
}
notifyListeners();
return totalPaid;
}
/// Guarda metadata para una transacción de melt (Lightning withdrawal).
Future<void> _saveMeltMetadata(Wallet wallet, String invoice) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.outgoing,
);
if (txs.isNotEmpty) {
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.lightning,
invoice: invoice,
),
);
debugPrint('Melt metadata guardada para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando melt metadata: $e');
}
}
// ============================================================
// HISTORIAL
// ============================================================
@@ -894,6 +1038,22 @@ class WalletProvider extends ChangeNotifier {
return allTransactions;
}
/// Obtiene el tipo de una transacción (cashu o lightning).
/// Busca primero en metadata del CDK, luego en storage local.
TransactionType getTransactionType(Transaction tx) {
return _txMetaStorage.getType(tx.id, tx.metadata);
}
/// Obtiene metadata adicional de una transacción.
TransactionMeta? getTransactionMeta(String transactionId) {
return _txMetaStorage.get(transactionId);
}
/// Verifica si una transacción tiene metadata guardada.
bool hasTransactionMeta(String transactionId) {
return _txMetaStorage.has(transactionId);
}
// ============================================================
// VERIFICACION DE PROOFS
// ============================================================
@@ -1060,6 +1220,9 @@ class WalletProvider extends ChangeNotifier {
_mnemonic = null;
_db = null;
// Limpiar metadata de transacciones
await _txMetaStorage.clear();
// Borrar archivo
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
+7 -294
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
@@ -17,6 +16,7 @@ import '../6_mint/mint_screen.dart';
import '../7_melt/melt_screen.dart';
import '../8_settings/settings_screen.dart';
import '../8_settings/mints_screen.dart';
import '../9_history/history_screen.dart';
/// Pantalla principal - Home
/// Muestra balance, acciones principales e historial
@@ -401,302 +401,15 @@ class _HomeScreenState extends State<HomeScreen> {
type: ButtonType.navigation,
icon: LucideIcons.history,
showIcon: true,
onTap: _showHistoryModal,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const HistoryScreen()),
);
},
),
);
}
void _showHistoryModal() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => const _HistoryModal(),
);
}
}
/// Modal del historial de transacciones
class _HistoryModal extends StatelessWidget {
const _HistoryModal();
@override
Widget build(BuildContext context) {
final walletProvider = context.watch<WalletProvider>();
return Container(
height: MediaQuery.of(context).size.height * 0.7,
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Column(
children: [
// Handle
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
// Título
const Padding(
padding: EdgeInsets.all(AppDimensions.paddingMedium),
child: Text(
'Historial',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
// Lista de transacciones
Expanded(
child: FutureBuilder<List<Transaction>>(
future: walletProvider.getAllTransactions(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
),
);
}
final transactions = snapshot.data ?? [];
if (transactions.isEmpty) {
return _buildEmptyHistory();
}
return ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: AppDimensions.paddingMedium,
),
itemCount: transactions.length,
itemBuilder: (context, index) {
final tx = transactions[index];
return _TransactionTile(transaction: tx);
},
);
},
),
),
],
),
);
}
Widget _buildEmptyHistory() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.history,
size: 48,
color: AppColors.textSecondary.withValues(alpha: 0.3),
),
const SizedBox(height: AppDimensions.paddingMedium),
Text(
'Sin transacciones aún',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
),
const SizedBox(height: AppDimensions.paddingSmall),
Text(
'Recibe tokens Cashu para empezar',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.4),
),
),
],
),
);
}
}
/// Tile para mostrar una transacción
class _TransactionTile extends StatelessWidget {
final Transaction transaction;
const _TransactionTile({required this.transaction});
@override
Widget build(BuildContext context) {
final isIncoming = transaction.direction == TransactionDirection.incoming;
final amount = transaction.amount.toInt();
final fee = transaction.fee.toInt();
// Convertir timestamp (BigInt unix) a DateTime
final timestamp = DateTime.fromMillisecondsSinceEpoch(
transaction.timestamp.toInt() * 1000,
);
// Formatear fecha
final dateStr = _formatDate(timestamp);
// Estado (pending o settled)
final isPending = transaction.status == TransactionStatus.pending;
return Container(
margin: const EdgeInsets.only(bottom: AppDimensions.paddingSmall),
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
// Icono de dirección
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: (isIncoming ? AppColors.success : AppColors.primaryAction)
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
isIncoming ? LucideIcons.arrowDownLeft : LucideIcons.arrowUpRight,
color: isIncoming ? AppColors.success : AppColors.primaryAction,
size: 20,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
// Info de la transacción
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
isIncoming ? 'Recibido' : 'Enviado',
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
if (isPending) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Pendiente',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.warning,
),
),
),
],
],
),
const SizedBox(height: 2),
Text(
dateStr,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.textSecondary.withValues(alpha: 0.6),
),
),
if (transaction.memo != null && transaction.memo!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
transaction.memo!,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontStyle: FontStyle.italic,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Monto
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isIncoming ? '+' : '-'}$amount',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.bold,
color: isIncoming ? AppColors.success : AppColors.primaryAction,
),
),
if (fee > 0)
Text(
'fee: $fee',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
color: AppColors.textSecondary.withValues(alpha: 0.5),
),
),
],
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inMinutes < 1) {
return 'Ahora';
} else if (diff.inHours < 1) {
return 'Hace ${diff.inMinutes} min';
} else if (diff.inDays < 1) {
return 'Hace ${diff.inHours} h';
} else if (diff.inDays < 7) {
return 'Hace ${diff.inDays} días';
} else {
return '${date.day}/${date.month}/${date.year}';
}
}
}
/// Modelo para opciones del selector
File diff suppressed because it is too large Load Diff