From b399c8665e029b79bbdf309602909b24de7db4d9 Mon Sep 17 00:00:00 2001 From: "@Delagado74" Date: Tue, 12 May 2026 11:34:18 -0400 Subject: [PATCH 1/5] fix: clear invoice now removes from history instead of leaving pending transactions When a user generates a Bolt11 invoice from the receive screen and presses 'Clear invoice', the invoice remained visible in the transaction history because _clearInvoice() only cleared local UI state without cancelling the pending invoice on the LNBits server. Add cancelInvoice() method to InvoiceService that attempts DELETE on LNBits API endpoints. If the server does not support cancellation, the payment hash is stored in a shared set and filtered from the history screen locally. --- lib/screens/7history_screen.dart | 13 +++++++--- lib/screens/9receive_screen.dart | 32 +++++++++++++++++++++++ lib/services/invoice_service.dart | 43 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/lib/screens/7history_screen.dart b/lib/screens/7history_screen.dart index ef05df8..54fb5df 100644 --- a/lib/screens/7history_screen.dart +++ b/lib/screens/7history_screen.dart @@ -10,6 +10,7 @@ import '../services/wallet_service.dart'; import '../models/transaction_info.dart'; import '../l10n/generated/app_localizations.dart'; import '../theme/app_tokens.dart'; +import '9receive_screen.dart'; class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @@ -165,14 +166,20 @@ class _HistoryScreenState extends State with TickerProviderStateM } List get _filteredTransactions { + var filtered = _transactions.where((tx) => + !(tx.isPending && + tx.paymentHash != null && + clearedInvoiceHashes.contains(tx.paymentHash)) + ).toList(); + switch (_currentFilter) { case TransactionFilter.incoming: - return _transactions.where((tx) => tx.isIncoming).toList(); + return filtered.where((tx) => tx.isIncoming).toList(); case TransactionFilter.outgoing: - return _transactions.where((tx) => tx.isOutgoing).toList(); + return filtered.where((tx) => tx.isOutgoing).toList(); case TransactionFilter.all: default: - return _transactions; + return filtered; } } diff --git a/lib/screens/9receive_screen.dart b/lib/screens/9receive_screen.dart index a7e2bf3..62a2f96 100644 --- a/lib/screens/9receive_screen.dart +++ b/lib/screens/9receive_screen.dart @@ -20,6 +20,8 @@ import '../theme/app_tokens.dart'; import '7ln_address_screen.dart'; import 'voucher_scan_screen.dart'; +final Set clearedInvoiceHashes = {}; + class ReceiveScreen extends StatefulWidget { const ReceiveScreen({super.key}); @@ -28,6 +30,7 @@ class ReceiveScreen extends StatefulWidget { } class _ReceiveScreenState extends State { + final _amountController = TextEditingController(); final _noteController = TextEditingController(); String _selectedCurrency = 'sats'; @@ -782,6 +785,12 @@ class _ReceiveScreenState extends State { void _clearInvoice() { _invoicePaymentTimer?.cancel(); _invoicePaymentTimeoutTimer?.cancel(); + + if (_generatedInvoice != null) { + final hash = _generatedInvoice!.paymentHash; + unawaited(_tryCancelInvoiceOnServer(hash)); + } + setState(() { _generatedInvoice = null; }); @@ -791,6 +800,29 @@ class _ReceiveScreenState extends State { ); } + Future _tryCancelInvoiceOnServer(String paymentHash) async { + try { + final walletProvider = context.read(); + final authProvider = context.read(); + final serverUrl = authProvider.sessionData?.serverUrl; + final wallet = walletProvider.primaryWallet; + + if (serverUrl == null || wallet == null) return; + + final cancelled = await _invoiceService.cancelInvoice( + serverUrl: serverUrl, + adminKey: wallet.inKey, + paymentHash: paymentHash, + ); + + if (!cancelled) { + clearedInvoiceHashes.add(paymentHash); + } + } catch (e) { + clearedInvoiceHashes.add(paymentHash); + } + } + void _showCopySheet(LNAddress? defaultAddress) { final hasInvoice = _generatedInvoice != null; final lnurl = defaultAddress?.lnurl; diff --git a/lib/services/invoice_service.dart b/lib/services/invoice_service.dart index 3ed677c..5baaa3c 100644 --- a/lib/services/invoice_service.dart +++ b/lib/services/invoice_service.dart @@ -1757,6 +1757,49 @@ class InvoiceService { }; } + Future cancelInvoice({ + required String serverUrl, + required String adminKey, + required String paymentHash, + }) async { + try { + String baseUrl = serverUrl; + if (!baseUrl.startsWith('http')) { + baseUrl = 'https://$baseUrl'; + } + + final headers = {'X-API-KEY': adminKey}; + + final endpoints = [ + '$baseUrl/api/v1/payments/$paymentHash', + '$baseUrl/api/v1/wallet/payment/$paymentHash', + ]; + + for (final endpoint in endpoints) { + try { + _debugLog('[INVOICE_SERVICE] Attempting to cancel invoice: $paymentHash'); + final response = await _dio.delete( + endpoint, + options: Options(headers: headers), + ); + if (response.statusCode == 200 || response.statusCode == 204) { + _debugLog('[INVOICE_SERVICE] Invoice cancelled successfully: $paymentHash'); + return true; + } + } catch (e) { + _debugLog('[INVOICE_SERVICE] Cancel failed at $endpoint: $e'); + continue; + } + } + + _debugLog('[INVOICE_SERVICE] No endpoint succeeded for invoice cancellation: $paymentHash'); + return false; + } catch (e) { + _debugLog('[INVOICE_SERVICE] Error cancelling invoice: $e'); + return false; + } + } + void dispose() { _dio.close(); } From 5646db67c4047fcb8d430cb379fb09ce97cd08f2 Mon Sep 17 00:00:00 2001 From: "@Delagado74" Date: Tue, 12 May 2026 13:41:05 -0400 Subject: [PATCH 2/5] fix: prevent cleared Bolt11 invoices from appearing in transaction history When a user generated a Bolt11 invoice on the receive screen and then pressed 'Clear invoice', or when the 10-minute monitoring timeout expired, or when navigating away, the invoice remained visible in the transaction history. This happened because the invoice was only removed from local UI state but was never cancelled on the LNBits server, and no local record of the cleared invoice was kept. Key changes: - Add cancelInvoice() to InvoiceService: attempts DELETE on LNBits API endpoints to cancel the pending invoice server-side - Add ClearedInvoiceStore: persists cleared invoice payment hashes to SharedPreferences as JSON, surviving app restarts and crashes - Add _discardInvoice() helper: used by both clear and timeout paths to ensure the hash is always recorded before async cancellation - Update HistoryScreen filter: excludes pending transactions whose payment hash is in the cleared set All discard paths (_clearInvoice, timeout, dispose) now record the hash synchronously before async cancellation is attempted, eliminating race conditions between clearing and history navigation. --- lib/main.dart | 4 +++ lib/screens/7history_screen.dart | 4 +-- lib/screens/9receive_screen.dart | 32 ++++++++++++-------- lib/services/cleared_invoice_store.dart | 40 +++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 lib/services/cleared_invoice_store.dart diff --git a/lib/main.dart b/lib/main.dart index 9aab4f8..c74131b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'services/wallet_service.dart'; import 'services/ln_address_service.dart'; import 'services/app_info_service.dart'; import 'services/deep_link_service.dart'; +import 'services/cleared_invoice_store.dart'; import 'screens/auth_checker.dart'; import 'screens/10send_screen.dart'; import 'l10n/generated/app_localizations.dart'; @@ -25,6 +26,9 @@ void main() async { // Initialize deep link service await DeepLinkService().initialize(); + // Load persisted cleared invoice hashes + await ClearedInvoiceStore.instance.load(); + runApp(const LaChispaApp()); } diff --git a/lib/screens/7history_screen.dart b/lib/screens/7history_screen.dart index 54fb5df..e1f130e 100644 --- a/lib/screens/7history_screen.dart +++ b/lib/screens/7history_screen.dart @@ -10,7 +10,7 @@ import '../services/wallet_service.dart'; import '../models/transaction_info.dart'; import '../l10n/generated/app_localizations.dart'; import '../theme/app_tokens.dart'; -import '9receive_screen.dart'; +import '../services/cleared_invoice_store.dart'; class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @@ -169,7 +169,7 @@ class _HistoryScreenState extends State with TickerProviderStateM var filtered = _transactions.where((tx) => !(tx.isPending && tx.paymentHash != null && - clearedInvoiceHashes.contains(tx.paymentHash)) + ClearedInvoiceStore.instance.contains(tx.paymentHash)) ).toList(); switch (_currentFilter) { diff --git a/lib/screens/9receive_screen.dart b/lib/screens/9receive_screen.dart index 62a2f96..a94b760 100644 --- a/lib/screens/9receive_screen.dart +++ b/lib/screens/9receive_screen.dart @@ -13,6 +13,7 @@ import '../services/invoice_service.dart'; import '../services/yadio_service.dart'; import '../services/transaction_detector.dart'; import '../services/nfc_charge_service.dart'; +import '../services/cleared_invoice_store.dart'; import '../models/lightning_invoice.dart'; import '../models/wallet_info.dart'; import '../l10n/generated/app_localizations.dart'; @@ -20,8 +21,6 @@ import '../theme/app_tokens.dart'; import '7ln_address_screen.dart'; import 'voucher_scan_screen.dart'; -final Set clearedInvoiceHashes = {}; - class ReceiveScreen extends StatefulWidget { const ReceiveScreen({super.key}); @@ -127,6 +126,9 @@ class _ReceiveScreenState extends State { _yadioService.dispose(); _invoicePaymentTimer?.cancel(); _invoicePaymentTimeoutTimer?.cancel(); + if (_generatedInvoice != null) { + ClearedInvoiceStore.instance.add(_generatedInvoice!.paymentHash); + } super.dispose(); } @@ -788,6 +790,7 @@ class _ReceiveScreenState extends State { if (_generatedInvoice != null) { final hash = _generatedInvoice!.paymentHash; + ClearedInvoiceStore.instance.add(hash); unawaited(_tryCancelInvoiceOnServer(hash)); } @@ -800,6 +803,17 @@ class _ReceiveScreenState extends State { ); } + void _discardInvoice() { + _invoicePaymentTimer?.cancel(); + _invoicePaymentTimeoutTimer?.cancel(); + if (_generatedInvoice != null) { + ClearedInvoiceStore.instance.add(_generatedInvoice!.paymentHash); + } + setState(() { + _generatedInvoice = null; + }); + } + Future _tryCancelInvoiceOnServer(String paymentHash) async { try { final walletProvider = context.read(); @@ -809,18 +823,12 @@ class _ReceiveScreenState extends State { if (serverUrl == null || wallet == null) return; - final cancelled = await _invoiceService.cancelInvoice( + await _invoiceService.cancelInvoice( serverUrl: serverUrl, adminKey: wallet.inKey, paymentHash: paymentHash, ); - - if (!cancelled) { - clearedInvoiceHashes.add(paymentHash); - } - } catch (e) { - clearedInvoiceHashes.add(paymentHash); - } + } catch (_) {} } void _showCopySheet(LNAddress? defaultAddress) { @@ -1468,9 +1476,7 @@ class _ReceiveScreenState extends State { _invoicePaymentTimeoutTimer = Timer(const Duration(minutes: 10), () { _invoicePaymentTimer?.cancel(); if (!mounted) return; - setState(() { - _generatedInvoice = null; - }); + _discardInvoice(); _showInfoSnackBar( AppLocalizations.of(context)!.invoice_monitoring_timeout_message, ); diff --git a/lib/services/cleared_invoice_store.dart b/lib/services/cleared_invoice_store.dart new file mode 100644 index 0000000..9c17339 --- /dev/null +++ b/lib/services/cleared_invoice_store.dart @@ -0,0 +1,40 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; + +class ClearedInvoiceStore { + static final ClearedInvoiceStore _instance = ClearedInvoiceStore._(); + static ClearedInvoiceStore get instance => _instance; + ClearedInvoiceStore._(); + + static const String _storageKey = 'cleared_invoice_hashes'; + Set _hashes = {}; + + Future load() async { + try { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(_storageKey); + if (stored != null && stored.isNotEmpty) { + final List decoded = jsonDecode(stored); + _hashes = decoded.cast().toSet(); + } + } catch (_) { + _hashes = {}; + } + } + + Future add(String hash) async { + _hashes.add(hash); + await _persist(); + } + + bool contains(String? hash) => hash != null && _hashes.contains(hash); + + Set get all => Set.unmodifiable(_hashes); + + Future _persist() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_storageKey, jsonEncode(_hashes.toList())); + } catch (_) {} + } +} From ca6589067cfbe625588cdd90aca839f70dc86224 Mon Sep 17 00:00:00 2001 From: "@Delagado74" Date: Tue, 12 May 2026 14:10:43 -0400 Subject: [PATCH 3/5] fix: also attempt server cancellation from discard and dispose paths CodeRabbit review found that _discardInvoice() and dispose() only recorded the cleared invoice hash locally but did not fire a best-effort server cancellation. This could leave pending invoices active on LNBits even when the user navigated away or the monitoring timeout fired. Add unawaited(_tryCancelInvoiceOnServer(hash)) to both paths so the DELETE attempt is always made regardless of how the invoice is discarded. --- lib/screens/9receive_screen.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/screens/9receive_screen.dart b/lib/screens/9receive_screen.dart index a94b760..4bf0b04 100644 --- a/lib/screens/9receive_screen.dart +++ b/lib/screens/9receive_screen.dart @@ -127,7 +127,9 @@ class _ReceiveScreenState extends State { _invoicePaymentTimer?.cancel(); _invoicePaymentTimeoutTimer?.cancel(); if (_generatedInvoice != null) { - ClearedInvoiceStore.instance.add(_generatedInvoice!.paymentHash); + final hash = _generatedInvoice!.paymentHash; + ClearedInvoiceStore.instance.add(hash); + unawaited(_tryCancelInvoiceOnServer(hash)); } super.dispose(); } @@ -807,7 +809,9 @@ class _ReceiveScreenState extends State { _invoicePaymentTimer?.cancel(); _invoicePaymentTimeoutTimer?.cancel(); if (_generatedInvoice != null) { - ClearedInvoiceStore.instance.add(_generatedInvoice!.paymentHash); + final hash = _generatedInvoice!.paymentHash; + ClearedInvoiceStore.instance.add(hash); + unawaited(_tryCancelInvoiceOnServer(hash)); } setState(() { _generatedInvoice = null; From 5b8c8b03a2a9855c2f060e3a2db7a2be10059a0b Mon Sep 17 00:00:00 2001 From: "@Delagado74" Date: Mon, 18 May 2026 18:52:02 -0400 Subject: [PATCH 4/5] feat: allow clearing pending invoices from history screen - Add 'Clear from history' button in transaction detail bottom sheet - Add swipe-to-dismiss on pending transaction cards - Add confirmation dialog before clearing - Update all locale files (de, en, es, fr, it, pt, ru) with new strings - Integrate with ClearedInvoiceStore and InvoiceService.cancelInvoice() Closes #112 --- lib/l10n/app_de.arb | 7 +- lib/l10n/app_en.arb | 7 +- lib/l10n/app_es.arb | 7 +- lib/l10n/app_fr.arb | 7 +- lib/l10n/app_it.arb | 7 +- lib/l10n/app_pt.arb | 7 +- lib/l10n/app_ru.arb | 7 +- lib/l10n/generated/app_localizations.dart | 24 ++++ lib/l10n/generated/app_localizations_de.dart | 13 ++ lib/l10n/generated/app_localizations_en.dart | 13 ++ lib/l10n/generated/app_localizations_es.dart | 13 ++ lib/l10n/generated/app_localizations_fr.dart | 14 ++ lib/l10n/generated/app_localizations_it.dart | 13 ++ lib/l10n/generated/app_localizations_pt.dart | 13 ++ lib/l10n/generated/app_localizations_ru.dart | 13 ++ lib/screens/7history_screen.dart | 143 ++++++++++++++++++- 16 files changed, 296 insertions(+), 12 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1bb8733..1b72770 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -377,5 +377,10 @@ "share_ready_message": "Bereit zum Teilen", "lnurl_copied_message": "LNURL in die Zwischenablage kopiert", "qr_scanner_title": "QR scannen", - "qr_scanner_instructions": "Kamera auf den QR-Code richten\num Rechnung oder Adresse zu scannen" + "qr_scanner_instructions": "Kamera auf den QR-Code richten\num Rechnung oder Adresse zu scannen", + + "clear_pending_invoice": "Aus Verlauf entfernen", + "clear_pending_confirm_title": "Ausstehende Rechnung entfernen?", + "clear_pending_confirm_message": "Diese ausstehende Transaktion wird aus dem Verlauf entfernt. Die Rechnung bleibt bis zum Ablauf auf LNBits bestehen.", + "invoice_cleared_from_history": "Rechnung aus Verlauf entfernt" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f25cd45..deeb973 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -392,5 +392,10 @@ "share_ready_message": "Ready to share", "lnurl_copied_message": "LNURL copied to clipboard", "qr_scanner_title": "Scan QR", - "qr_scanner_instructions": "Point the camera at the QR code\nto scan the invoice or address" + "qr_scanner_instructions": "Point the camera at the QR code\nto scan the invoice or address", + + "clear_pending_invoice": "Clear from history", + "clear_pending_confirm_title": "Clear pending invoice?", + "clear_pending_confirm_message": "This pending transaction will be removed from history. The invoice will remain on LNBits until it expires.", + "invoice_cleared_from_history": "Invoice removed from history" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 6d36fa9..eb32c68 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -377,5 +377,10 @@ "share_ready_message": "Listo para compartir", "lnurl_copied_message": "LNURL copiado al portapapeles", "qr_scanner_title": "Escanear QR", - "qr_scanner_instructions": "Apunta la cámara al código QR\npara escanear la factura o dirección" + "qr_scanner_instructions": "Apunta la cámara al código QR\npara escanear la factura o dirección", + + "clear_pending_invoice": "Limpiar del historial", + "clear_pending_confirm_title": "¿Limpiar factura pendiente?", + "clear_pending_confirm_message": "Esta transacción pendiente se eliminará del historial. La factura seguirá existiendo en LNBits hasta su expiración.", + "invoice_cleared_from_history": "Factura eliminada del historial" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6064bfe..46f070a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -377,5 +377,10 @@ "share_ready_message": "Prêt à partager", "lnurl_copied_message": "LNURL copié dans le presse-papiers", "qr_scanner_title": "Scanner QR", - "qr_scanner_instructions": "Pointez la caméra vers le code QR\npour scanner la facture ou l'adresse" + "qr_scanner_instructions": "Pointez la caméra vers le code QR\npour scanner la facture ou l'adresse", + + "clear_pending_invoice": "Effacer de l'historique", + "clear_pending_confirm_title": "Effacer la facture en attente ?", + "clear_pending_confirm_message": "Cette transaction en attente sera supprimée de l'historique. La facture restera sur LNBits jusqu'à son expiration.", + "invoice_cleared_from_history": "Facture supprimée de l'historique" } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 280ce6f..63b74cb 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -377,5 +377,10 @@ "share_ready_message": "Pronto da condividere", "lnurl_copied_message": "LNURL copiato negli appunti", "qr_scanner_title": "Scansiona QR", - "qr_scanner_instructions": "Punta la fotocamera sul codice QR\nper scansionare la fattura o l'indirizzo" + "qr_scanner_instructions": "Punta la fotocamera sul codice QR\nper scansionare la fattura o l'indirizzo", + + "clear_pending_invoice": "Cancella dalla cronologia", + "clear_pending_confirm_title": "Cancellare fattura in sospeso?", + "clear_pending_confirm_message": "Questa transazione in sospeso verrà rimossa dalla cronologia. La fattura rimarrà su LNBits fino alla scadenza.", + "invoice_cleared_from_history": "Fattura rimossa dalla cronologia" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index a42538f..131be53 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -377,5 +377,10 @@ "share_ready_message": "Pronto para compartilhar", "lnurl_copied_message": "LNURL copiado para a área de transferência", "qr_scanner_title": "Escanear QR", - "qr_scanner_instructions": "Aponte a câmera para o código QR\npara escanear a fatura ou endereço" + "qr_scanner_instructions": "Aponte a câmera para o código QR\npara escanear a fatura ou endereço", + + "clear_pending_invoice": "Limpar do histórico", + "clear_pending_confirm_title": "Limpar fatura pendente?", + "clear_pending_confirm_message": "Esta transação pendente será removida do histórico. A fatura continuará existindo no LNBits até expirar.", + "invoice_cleared_from_history": "Fatura removida do histórico" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c3ecd9e..c12ca68 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -377,5 +377,10 @@ "share_ready_message": "Готово к отправке", "lnurl_copied_message": "LNURL скопирован в буфер обмена", "qr_scanner_title": "Сканировать QR", - "qr_scanner_instructions": "Наведите камеру на QR-код\nчтобы сканировать счёт или адрес" + "qr_scanner_instructions": "Наведите камеру на QR-код\nчтобы сканировать счёт или адрес", + + "clear_pending_invoice": "Удалить из истории", + "clear_pending_confirm_title": "Удалить ожидающий счёт?", + "clear_pending_confirm_message": "Эта ожидающая транзакция будет удалена из истории. Счёт останется на LNBits до истечения срока.", + "invoice_cleared_from_history": "Счёт удалён из истории" } \ No newline at end of file diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 6940bd6..cabf5e4 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1925,6 +1925,30 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Apunta la cámara al código QR\npara escanear la factura o dirección'** String get qr_scanner_instructions; + + /// No description provided for @clear_pending_invoice. + /// + /// In es, this message translates to: + /// **'Limpiar del historial'** + String get clear_pending_invoice; + + /// No description provided for @clear_pending_confirm_title. + /// + /// In es, this message translates to: + /// **'¿Limpiar factura pendiente?'** + String get clear_pending_confirm_title; + + /// No description provided for @clear_pending_confirm_message. + /// + /// In es, this message translates to: + /// **'Esta transacción pendiente se eliminará del historial. La factura seguirá existiendo en LNBits hasta su expiración.'** + String get clear_pending_confirm_message; + + /// No description provided for @invoice_cleared_from_history. + /// + /// In es, this message translates to: + /// **'Factura eliminada del historial'** + String get invoice_cleared_from_history; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 9b80964..6c50c76 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1014,4 +1014,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String get qr_scanner_instructions => 'Kamera auf den QR-Code richten\num Rechnung oder Adresse zu scannen'; + + @override + String get clear_pending_invoice => 'Aus Verlauf entfernen'; + + @override + String get clear_pending_confirm_title => 'Ausstehende Rechnung entfernen?'; + + @override + String get clear_pending_confirm_message => + 'Diese ausstehende Transaktion wird aus dem Verlauf entfernt. Die Rechnung bleibt bis zum Ablauf auf LNBits bestehen.'; + + @override + String get invoice_cleared_from_history => 'Rechnung aus Verlauf entfernt'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 9ae3142..0b53ae5 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -989,4 +989,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get qr_scanner_instructions => 'Point the camera at the QR code\nto scan the invoice or address'; + + @override + String get clear_pending_invoice => 'Clear from history'; + + @override + String get clear_pending_confirm_title => 'Clear pending invoice?'; + + @override + String get clear_pending_confirm_message => + 'This pending transaction will be removed from history. The invoice will remain on LNBits until it expires.'; + + @override + String get invoice_cleared_from_history => 'Invoice removed from history'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index fd1d578..12706e2 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1003,4 +1003,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String get qr_scanner_instructions => 'Apunta la cámara al código QR\npara escanear la factura o dirección'; + + @override + String get clear_pending_invoice => 'Limpiar del historial'; + + @override + String get clear_pending_confirm_title => '¿Limpiar factura pendiente?'; + + @override + String get clear_pending_confirm_message => + 'Esta transacción pendiente se eliminará del historial. La factura seguirá existiendo en LNBits hasta su expiración.'; + + @override + String get invoice_cleared_from_history => 'Factura eliminada del historial'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 5738fa1..e85e423 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1020,4 +1020,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get qr_scanner_instructions => 'Pointez la caméra vers le code QR\npour scanner la facture ou l\'adresse'; + + @override + String get clear_pending_invoice => 'Effacer de l\'historique'; + + @override + String get clear_pending_confirm_title => 'Effacer la facture en attente ?'; + + @override + String get clear_pending_confirm_message => + 'Cette transaction en attente sera supprimée de l\'historique. La facture restera sur LNBits jusqu\'à son expiration.'; + + @override + String get invoice_cleared_from_history => + 'Facture supprimée de l\'historique'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index e3af45f..33b8d86 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1017,4 +1017,17 @@ class AppLocalizationsIt extends AppLocalizations { @override String get qr_scanner_instructions => 'Punta la fotocamera sul codice QR\nper scansionare la fattura o l\'indirizzo'; + + @override + String get clear_pending_invoice => 'Cancella dalla cronologia'; + + @override + String get clear_pending_confirm_title => 'Cancellare fattura in sospeso?'; + + @override + String get clear_pending_confirm_message => + 'Questa transazione in sospeso verrà rimossa dalla cronologia. La fattura rimarrà su LNBits fino alla scadenza.'; + + @override + String get invoice_cleared_from_history => 'Fattura rimossa dalla cronologia'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 485050a..ca26673 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1003,4 +1003,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String get qr_scanner_instructions => 'Aponte a câmera para o código QR\npara escanear a fatura ou endereço'; + + @override + String get clear_pending_invoice => 'Limpar do histórico'; + + @override + String get clear_pending_confirm_title => 'Limpar fatura pendente?'; + + @override + String get clear_pending_confirm_message => + 'Esta transação pendente será removida do histórico. A fatura continuará existindo no LNBits até expirar.'; + + @override + String get invoice_cleared_from_history => 'Fatura removida do histórico'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index c5dc852..2987b94 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -993,4 +993,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String get qr_scanner_instructions => 'Наведите камеру на QR-код\nчтобы сканировать счёт или адрес'; + + @override + String get clear_pending_invoice => 'Удалить из истории'; + + @override + String get clear_pending_confirm_title => 'Удалить ожидающий счёт?'; + + @override + String get clear_pending_confirm_message => + 'Эта ожидающая транзакция будет удалена из истории. Счёт останется на LNBits до истечения срока.'; + + @override + String get invoice_cleared_from_history => 'Счёт удалён из истории'; } diff --git a/lib/screens/7history_screen.dart b/lib/screens/7history_screen.dart index e1f130e..e65d467 100644 --- a/lib/screens/7history_screen.dart +++ b/lib/screens/7history_screen.dart @@ -11,6 +11,7 @@ import '../models/transaction_info.dart'; import '../l10n/generated/app_localizations.dart'; import '../theme/app_tokens.dart'; import '../services/cleared_invoice_store.dart'; +import '../services/invoice_service.dart'; class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @@ -33,6 +34,7 @@ class _HistoryScreenState extends State with TickerProviderStateM TransactionFilter _currentFilter = TransactionFilter.all; final ScrollController _scrollController = ScrollController(); + final InvoiceService _invoiceService = InvoiceService(); @override void initState() { @@ -624,7 +626,7 @@ class _HistoryScreenState extends State with TickerProviderStateM Widget _buildTransactionCard(TransactionInfo transaction, int index, AppTokens t) { final iconColor = _getTransactionIconColor(transaction, t); - return Container( + final card = Container( margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: t.surface, @@ -640,7 +642,6 @@ class _HistoryScreenState extends State with TickerProviderStateM padding: const EdgeInsets.all(16), child: Row( children: [ - // Leading icon Container( width: 48, height: 48, @@ -657,7 +658,6 @@ class _HistoryScreenState extends State with TickerProviderStateM const SizedBox(width: 16), - // Content Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -707,12 +707,11 @@ class _HistoryScreenState extends State with TickerProviderStateM ), ), - // Amount column Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - transaction.displayAmount.split('\n').first, // Show sats amount + transaction.displayAmount.split('\n').first, style: TextStyle( color: iconColor, fontSize: 16, @@ -744,6 +743,60 @@ class _HistoryScreenState extends State with TickerProviderStateM ), ), ); + + if (transaction.isPending && transaction.paymentHash != null) { + return Dismissible( + key: ValueKey('pending_${transaction.paymentHash}'), + direction: DismissDirection.horizontal, + confirmDismiss: (direction) async { + final l = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l.clear_pending_confirm_title), + content: Text(l.clear_pending_confirm_message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l.cancel_button), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l.clear_pending_invoice), + ), + ], + ), + ); + if (confirmed == true) { + _doClearPendingTransaction(transaction); + } + return false; + }, + background: Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: t.statusWarning.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + child: Icon(Icons.delete_outline, color: t.statusWarning, size: 28), + ), + secondaryBackground: Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: t.statusWarning.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + ), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 24), + child: Icon(Icons.delete_outline, color: t.statusWarning, size: 28), + ), + child: card, + ); + } + + return card; } void _showTransactionDetails(TransactionInfo transaction) { @@ -862,6 +915,29 @@ class _HistoryScreenState extends State with TickerProviderStateM if (transaction.fee != null) _buildDetailRow(t, 'Fee', '${(transaction.fee! / 1000).toStringAsFixed(3)} sats'), _buildDetailRow(t, AppLocalizations.of(context)!.invoice_status_label, _getTransactionStatus(transaction)), + + if (transaction.isPending && transaction.paymentHash != null) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + _clearPendingTransaction(transaction); + }, + icon: const Icon(Icons.delete_outline, size: 18), + label: Text(AppLocalizations.of(context)!.clear_pending_invoice), + style: ElevatedButton.styleFrom( + backgroundColor: t.statusWarning.withValues(alpha: 0.2), + foregroundColor: t.statusWarning, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], ], ), ), @@ -871,6 +947,63 @@ class _HistoryScreenState extends State with TickerProviderStateM ); } + void _doClearPendingTransaction(TransactionInfo transaction) { + if (transaction.paymentHash == null) return; + + ClearedInvoiceStore.instance.add(transaction.paymentHash!); + unawaited(_tryCancelInvoiceOnServer(transaction.paymentHash!)); + + setState(() {}); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.invoice_cleared_from_history)), + ); + } + + Future _clearPendingTransaction(TransactionInfo transaction) async { + if (transaction.paymentHash == null) return; + + final l = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l.clear_pending_confirm_title), + content: Text(l.clear_pending_confirm_message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l.cancel_button), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l.clear_pending_invoice), + ), + ], + ), + ); + + if (confirmed != true) return; + _doClearPendingTransaction(transaction); + } + + Future _tryCancelInvoiceOnServer(String paymentHash) async { + try { + final walletProvider = context.read(); + final authProvider = context.read(); + final serverUrl = authProvider.sessionData?.serverUrl; + final wallet = walletProvider.primaryWallet; + + if (serverUrl == null || wallet == null) return; + + await _invoiceService.cancelInvoice( + serverUrl: serverUrl, + adminKey: wallet.inKey, + paymentHash: paymentHash, + ); + } catch (_) {} + } + Widget _buildInvoiceQRSection(TransactionInfo transaction, AppTokens t) { final invoice = transaction.invoice!; final l = AppLocalizations.of(context)!; From 36149d8dcaf0f1ec31a85f74674da19ae4195910 Mon Sep 17 00:00:00 2001 From: "@Delagado74" Date: Mon, 18 May 2026 19:07:44 -0400 Subject: [PATCH 5/5] fix: apply coderabbit suggestions - Use wallet.adminKey instead of wallet.inKey for cancelInvoice calls - Wrap ClearedInvoiceStore.instance.add() with unawaited() - Check mounted before setState in _doClearPendingTransaction - Cache serverUrl and WalletInfo at invoice generation for safe dispose() - Refactor _tryCancelInvoiceOnServer to use cached fields - Eliminate duplicate cleanup logic between _clearInvoice and _discardInvoice --- lib/screens/7history_screen.dart | 7 +++---- lib/screens/9receive_screen.dart | 35 ++++++++++++-------------------- 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/lib/screens/7history_screen.dart b/lib/screens/7history_screen.dart index e65d467..a73722b 100644 --- a/lib/screens/7history_screen.dart +++ b/lib/screens/7history_screen.dart @@ -950,12 +950,11 @@ class _HistoryScreenState extends State with TickerProviderStateM void _doClearPendingTransaction(TransactionInfo transaction) { if (transaction.paymentHash == null) return; - ClearedInvoiceStore.instance.add(transaction.paymentHash!); + unawaited(ClearedInvoiceStore.instance.add(transaction.paymentHash!)); unawaited(_tryCancelInvoiceOnServer(transaction.paymentHash!)); - setState(() {}); - if (!mounted) return; + setState(() {}); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.invoice_cleared_from_history)), ); @@ -998,7 +997,7 @@ class _HistoryScreenState extends State with TickerProviderStateM await _invoiceService.cancelInvoice( serverUrl: serverUrl, - adminKey: wallet.inKey, + adminKey: wallet.adminKey, paymentHash: paymentHash, ); } catch (_) {} diff --git a/lib/screens/9receive_screen.dart b/lib/screens/9receive_screen.dart index adc7290..e7a7a0e 100644 --- a/lib/screens/9receive_screen.dart +++ b/lib/screens/9receive_screen.dart @@ -48,6 +48,9 @@ class _ReceiveScreenState extends State { bool _nfcAvailable = false; bool _nfcChecked = false; + String? _cachedServerUrl; + WalletInfo? _cachedWallet; + @override void initState() { super.initState(); @@ -129,7 +132,7 @@ class _ReceiveScreenState extends State { _invoicePaymentTimeoutTimer?.cancel(); if (_generatedInvoice != null) { final hash = _generatedInvoice!.paymentHash; - ClearedInvoiceStore.instance.add(hash); + unawaited(ClearedInvoiceStore.instance.add(hash)); unawaited(_tryCancelInvoiceOnServer(hash)); } super.dispose(); @@ -788,18 +791,7 @@ class _ReceiveScreenState extends State { } void _clearInvoice() { - _invoicePaymentTimer?.cancel(); - _invoicePaymentTimeoutTimer?.cancel(); - - if (_generatedInvoice != null) { - final hash = _generatedInvoice!.paymentHash; - ClearedInvoiceStore.instance.add(hash); - unawaited(_tryCancelInvoiceOnServer(hash)); - } - - setState(() { - _generatedInvoice = null; - }); + _discardInvoice(); _showAccentSnackBar( icon: Icons.check_circle, message: AppLocalizations.of(context)!.invoice_cleared_message, @@ -811,7 +803,7 @@ class _ReceiveScreenState extends State { _invoicePaymentTimeoutTimer?.cancel(); if (_generatedInvoice != null) { final hash = _generatedInvoice!.paymentHash; - ClearedInvoiceStore.instance.add(hash); + unawaited(ClearedInvoiceStore.instance.add(hash)); unawaited(_tryCancelInvoiceOnServer(hash)); } setState(() { @@ -820,17 +812,13 @@ class _ReceiveScreenState extends State { } Future _tryCancelInvoiceOnServer(String paymentHash) async { + final serverUrl = _cachedServerUrl; + final wallet = _cachedWallet; + if (serverUrl == null || wallet == null) return; try { - final walletProvider = context.read(); - final authProvider = context.read(); - final serverUrl = authProvider.sessionData?.serverUrl; - final wallet = walletProvider.primaryWallet; - - if (serverUrl == null || wallet == null) return; - await _invoiceService.cancelInvoice( serverUrl: serverUrl, - adminKey: wallet.inKey, + adminKey: wallet.adminKey, paymentHash: paymentHash, ); } catch (_) {} @@ -1366,6 +1354,9 @@ class _ReceiveScreenState extends State { final wallet = walletProvider.primaryWallet; final serverUrl = authProvider.sessionData?.serverUrl; + _cachedWallet = wallet; + _cachedServerUrl = serverUrl; + if (wallet == null || serverUrl == null) { throw Exception(AppLocalizations.of(context)!.no_wallet_error); }