feat: add dedicated history screen with dynamic QR codes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user