Compare commits

..
Author SHA1 Message Date
Forte11Cuba f0236df966 feat: add offline send with manual proof selection 2026-02-06 03:43:43 -06:00
Forte11andGitHub 6b1cdabf14 Merge pull request #6 from Forte11Cuba/feat/mint-persistence-and-ordering
feat: persist mints and prioritize Cuba Bitcoin
2026-02-06 02:30:13 -06:00
7 changed files with 1058 additions and 2 deletions
+148
View File
@@ -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';
}
}
}
+181
View File
@@ -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;
}
}
+350
View File
@@ -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;
});
}
}
}
+134 -2
View File
@@ -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
+179
View File
@@ -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,
),
);
}
}
+64
View File
@@ -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:
+2
View File
@@ -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