diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 5ce20d5..ed23b55 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -38,6 +38,8 @@ class _AddLightningInvoiceScreenState extends ConsumerState { final _invoiceController = TextEditingController(); bool _submitting = false; + /// `true` while a protocol cancel is in flight — blocks re-entry and submit. + bool _canceling = false; /// `true` when NWC is connected but generation failed → show manual form. bool _manualMode = false; /// One-shot guard so we don't navigate twice as further updates stream in. @@ -66,8 +68,57 @@ class _AddLightningInvoiceScreenState return true; } + /// Cancel button = cancel the trade itself (confirmed via dialog), not + /// just leave the screen — going back is what lands on trade detail (#268). + Future _cancelOrder() async { + // Serialize state-changing requests: no cancel while a submit or another + // cancel is in flight (review round 1). + if (_submitting || _canceling) return; + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.cancelTradeDialogTitle), + content: Text(l10n.cancelTradeDialogContent), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l10n.noButtonLabel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l10n.yesCancelButtonLabel), + ), + ], + ), + ); + if (!mounted || confirmed != true) return; + setState(() => _canceling = true); + try { + await orders_api.cancelOrder(orderId: widget.orderId); + if (!mounted) return; + _navigated = true; + refreshTrades(ref); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.cancelRequestSent)), + ); + context.go(AppRoute.home); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + localizedDaemonError(l10n, e, fallback: l10n.cancelRequestFailed), + ), + ), + ); + } finally { + if (mounted) setState(() => _canceling = false); + } + } + Future _submit(WidgetRef ref) async { - if (_submitting) return; + if (_submitting || _canceling) return; final input = _invoiceController.text.trim(); // For Lightning Addresses, the sats amount must be resolved before sending — // the Rust side uses it to resolve the address. Bolt11 invoices encode @@ -271,7 +322,8 @@ class _AddLightningInvoiceScreenState children: [ Expanded( child: TextButton( - onPressed: () => context.pop(), + onPressed: + (_submitting || _canceling) ? null : _cancelOrder, child: Text( l10n.cancel, style: TextStyle(color: colors?.textSecondary), @@ -281,7 +333,9 @@ class _AddLightningInvoiceScreenState const SizedBox(width: AppSpacing.md), Expanded( child: FilledButton( - onPressed: _isValid(ref) ? () => _submit(ref) : null, + onPressed: (!_canceling && _isValid(ref)) + ? () => _submit(ref) + : null, style: FilledButton.styleFrom( backgroundColor: green, foregroundColor: Colors.black, diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index d4de5e4..c119070 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -8,11 +8,13 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/core/daemon_errors.dart'; import 'package:mostro/features/order/providers/trade_state_provider.dart'; import 'package:mostro/features/settings/providers/nwc_provider.dart'; import 'package:mostro/features/trades/providers/trades_providers.dart' show refreshTrades; import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/src/rust/api/orders.dart' as orders_api; import 'package:mostro/src/rust/api/types.dart' show OrderStatus, TradeUpdate; import 'package:mostro/shared/widgets/nwc_payment_widget.dart'; @@ -33,6 +35,8 @@ class PayLightningInvoiceScreen extends ConsumerStatefulWidget { class _PayLightningInvoiceScreenState extends ConsumerState { bool _waiting = false; + /// `true` while a protocol cancel is in flight — blocks re-entry. + bool _canceling = false; /// `true` when NWC is connected but payment failed → show QR fallback. bool _manualMode = false; /// One-shot guard so we don't navigate twice as further statuses stream in. @@ -46,6 +50,54 @@ class _PayLightningInvoiceScreenState setState(() => _waiting = true); } + /// Cancel button = cancel the trade itself (confirmed via dialog), not + /// just leave the screen — going back is what lands on trade detail (#268). + Future _cancelOrder() async { + // Serialize state-changing requests: one cancel at a time (review round 1). + if (_canceling) return; + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.cancelTradeDialogTitle), + content: Text(l10n.cancelTradeDialogContent), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l10n.noButtonLabel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l10n.yesCancelButtonLabel), + ), + ], + ), + ); + if (!mounted || confirmed != true) return; + setState(() => _canceling = true); + try { + await orders_api.cancelOrder(orderId: widget.orderId); + if (!mounted) return; + _navigated = true; + refreshTrades(ref); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.cancelRequestSent)), + ); + context.go(AppRoute.home); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + localizedDaemonError(l10n, e, fallback: l10n.cancelRequestFailed), + ), + ), + ); + } finally { + if (mounted) setState(() => _canceling = false); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -364,7 +416,7 @@ class _PayLightningInvoiceScreenState SizedBox( width: double.infinity, child: OutlinedButton( - onPressed: () => context.pop(), + onPressed: _canceling ? null : _cancelOrder, style: OutlinedButton.styleFrom( foregroundColor: colors?.destructiveRed ?? const Color(0xFFD84D4D), diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 3e957ba..c2e984e 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -49,6 +49,10 @@ class _TakeOrderScreenState extends ConsumerState { @override void initState() { super.initState(); + // Defense in depth (#268): if the user already participates in this + // order (deep link, stale book entry, back navigation), Take Order + // must not offer to take it again — land on the trade instead. + _redirectIfParticipant(); // Try immediately in case the provider already has data. _tryStartCountdown(); // If the provider is still loading, listen for the first value. @@ -58,6 +62,12 @@ class _TakeOrderScreenState extends ConsumerState { }); } + Future _redirectIfParticipant() async { + final role = await orders_api.getTradeRole(orderId: widget.orderId); + if (!mounted || role == null) return; + context.go(AppRoute.tradeDetailPath(widget.orderId)); + } + void _tryStartCountdown() { if (_countdownTimer != null) return; // already running _startCountdown(); @@ -101,6 +111,16 @@ class _TakeOrderScreenState extends ConsumerState { final order = orders.where((o) => o.id == widget.orderId).firstOrNull; if (order == null || _submitting) return; + // Serialize with the async initState redirect: a participant racing the + // role lookup must never dispatch a second take (which the daemon would + // reject and strand them on home instead of their trade). + final role = await orders_api.getTradeRole(orderId: widget.orderId); + if (!mounted) return; + if (role != null) { + context.go(AppRoute.tradeDetailPath(widget.orderId)); + return; + } + // Range orders: show amount modal first. if (order.isRange) { final amount = await showRangeAmountModal( @@ -142,9 +162,15 @@ class _TakeOrderScreenState extends ConsumerState { // LN address was included in take-sell payload — go straight to trade. context.go(AppRoute.tradeDetailPath(widget.orderId)); } else { + // Rebuild the stack with trade detail as the base so back/close + // from add-invoice lands on the trade, never back on Take Order + // offering to take an already-taken order (#268). + context.go(AppRoute.tradeDetailPath(widget.orderId)); context.push(AppRoute.addInvoicePath(widget.orderId)); } } else { + // Same stack shape for the seller's pay-invoice screen (#268). + context.go(AppRoute.tradeDetailPath(widget.orderId)); context.push(AppRoute.payInvoicePath(widget.orderId)); } } catch (e) {