From b7e72b94dca2f1455e5f773764360d092c7370dd Mon Sep 17 00:00:00 2001 From: bracr10 Date: Fri, 17 Jul 2026 18:19:11 -0600 Subject: [PATCH 1/6] feat(l10n): add trade-detail action button labels Cancel, dispute, release, fiat-sent, and share-order button labels in the trade detail screen were hardcoded English strings. Add ARB keys in all 5 supported languages (en/es/fr/de/it) and regenerate AppLocalizations. Ref #134 --- lib/l10n/app_de.arb | 6 +++++ lib/l10n/app_en.arb | 12 ++++++++++ lib/l10n/app_es.arb | 6 +++++ lib/l10n/app_fr.arb | 6 +++++ lib/l10n/app_it.arb | 6 +++++ lib/l10n/app_localizations.dart | 36 ++++++++++++++++++++++++++++++ lib/l10n/app_localizations_de.dart | 18 +++++++++++++++ lib/l10n/app_localizations_en.dart | 18 +++++++++++++++ lib/l10n/app_localizations_es.dart | 18 +++++++++++++++ lib/l10n/app_localizations_fr.dart | 18 +++++++++++++++ lib/l10n/app_localizations_it.dart | 18 +++++++++++++++ 11 files changed, 162 insertions(+) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 751d220e..39f7fc7e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -211,6 +211,12 @@ "cancelRequestFailed": "Abbrechen fehlgeschlagen. Bitte erneut versuchen.", "fiatSentFailed": "Fiat-Zahlung konnte nicht bestätigt werden. Bitte erneut versuchen.", "releaseFailed": "Freigabe fehlgeschlagen. Bitte erneut versuchen.", + "cancelTradeButton": "Bestellung stornieren", + "openDisputeButton": "Streitfall eröffnen", + "releaseSatsButton": "Sats freigeben", + "markFiatSentButton": "Als gesendet markieren", + "confirmReleaseSatsButton": "Bestätigen und Sats freigeben", + "shareOrderButton": "Bestellung teilen", "orderPillYouAreSelling": "SIE VERKAUFEN", "orderPillYouAreBuying": "SIE KAUFEN", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8e70c598..c861163d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -445,6 +445,18 @@ "@fiatSentFailed": {"description": "Snackbar shown when the fiat-sent action fails"}, "releaseFailed": "Failed to release. Please try again.", "@releaseFailed": {"description": "Snackbar shown when the release-sats action fails"}, + "cancelTradeButton": "Cancel order", + "@cancelTradeButton": {"description": "Button label to cancel an in-progress trade (secondary action row)"}, + "openDisputeButton": "Open dispute", + "@openDisputeButton": {"description": "Button label to open a dispute on an in-progress trade"}, + "releaseSatsButton": "Release sats", + "@releaseSatsButton": {"description": "Button label for the seller to release sats during an active dispute (secondary action row)"}, + "markFiatSentButton": "Mark fiat sent", + "@markFiatSentButton": {"description": "Primary CTA button label for the buyer to mark fiat as sent"}, + "confirmReleaseSatsButton": "Confirm & release sats", + "@confirmReleaseSatsButton": {"description": "Primary CTA button label for the seller to confirm and release sats"}, + "shareOrderButton": "Share order", + "@shareOrderButton": {"description": "Menu item label to share the current order (not yet implemented — shows a coming-soon message)"}, "orderPillYouAreSelling": "YOU ARE SELLING", "@orderPillYouAreSelling": {"description": "Order card pill label when the current user is the maker of a sell order"}, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5acb3bc1..103d558e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -211,6 +211,12 @@ "cancelRequestFailed": "No se pudo cancelar. Por favor, inténtelo de nuevo.", "fiatSentFailed": "Error al marcar el fiat como enviado. Por favor, inténtelo de nuevo.", "releaseFailed": "Error al liberar. Por favor, inténtelo de nuevo.", + "cancelTradeButton": "Cancelar orden", + "openDisputeButton": "Abrir disputa", + "releaseSatsButton": "Liberar sats", + "markFiatSentButton": "Marcar fiat enviado", + "confirmReleaseSatsButton": "Confirmar y liberar sats", + "shareOrderButton": "Compartir orden", "orderPillYouAreSelling": "USTED ESTÁ VENDIENDO", "orderPillYouAreBuying": "USTED ESTÁ COMPRANDO", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0c263e00..47aa1d32 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -211,6 +211,12 @@ "cancelRequestFailed": "Échec de l'annulation. Veuillez réessayer.", "fiatSentFailed": "Échec de la confirmation du paiement fiat. Veuillez réessayer.", "releaseFailed": "Échec de la libération. Veuillez réessayer.", + "cancelTradeButton": "Annuler la commande", + "openDisputeButton": "Ouvrir un litige", + "releaseSatsButton": "Libérer les sats", + "markFiatSentButton": "Marquer comme envoyé", + "confirmReleaseSatsButton": "Confirmer et libérer les sats", + "shareOrderButton": "Partager la commande", "orderPillYouAreSelling": "VOUS VENDEZ", "orderPillYouAreBuying": "VOUS ACHETEZ", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index c74142fe..ab51bebf 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -211,6 +211,12 @@ "cancelRequestFailed": "Annullamento fallito. Riprovare.", "fiatSentFailed": "Impossibile contrassegnare il fiat come inviato. Riprovare.", "releaseFailed": "Rilascio fallito. Riprovare.", + "cancelTradeButton": "Annulla ordine", + "openDisputeButton": "Apri disputa", + "releaseSatsButton": "Rilascia sats", + "markFiatSentButton": "Segna come inviato", + "confirmReleaseSatsButton": "Conferma e rilascia sats", + "shareOrderButton": "Condividi ordine", "orderPillYouAreSelling": "STAI VENDENDO", "orderPillYouAreBuying": "STAI COMPRANDO", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 480d3506..6de337aa 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1274,6 +1274,42 @@ abstract class AppLocalizations { /// **'Failed to release. Please try again.'** String get releaseFailed; + /// Button label to cancel an in-progress trade (secondary action row) + /// + /// In en, this message translates to: + /// **'Cancel order'** + String get cancelTradeButton; + + /// Button label to open a dispute on an in-progress trade + /// + /// In en, this message translates to: + /// **'Open dispute'** + String get openDisputeButton; + + /// Button label for the seller to release sats during an active dispute (secondary action row) + /// + /// In en, this message translates to: + /// **'Release sats'** + String get releaseSatsButton; + + /// Primary CTA button label for the buyer to mark fiat as sent + /// + /// In en, this message translates to: + /// **'Mark fiat sent'** + String get markFiatSentButton; + + /// Primary CTA button label for the seller to confirm and release sats + /// + /// In en, this message translates to: + /// **'Confirm & release sats'** + String get confirmReleaseSatsButton; + + /// Menu item label to share the current order (not yet implemented — shows a coming-soon message) + /// + /// In en, this message translates to: + /// **'Share order'** + String get shareOrderButton; + /// Order card pill label when the current user is the maker of a sell order /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ebc42fd0..e897e57b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -652,6 +652,24 @@ class AppLocalizationsDe extends AppLocalizations { String get releaseFailed => 'Freigabe fehlgeschlagen. Bitte erneut versuchen.'; + @override + String get cancelTradeButton => 'Bestellung stornieren'; + + @override + String get openDisputeButton => 'Streitfall eröffnen'; + + @override + String get releaseSatsButton => 'Sats freigeben'; + + @override + String get markFiatSentButton => 'Als gesendet markieren'; + + @override + String get confirmReleaseSatsButton => 'Bestätigen und Sats freigeben'; + + @override + String get shareOrderButton => 'Bestellung teilen'; + @override String get orderPillYouAreSelling => 'SIE VERKAUFEN'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ff8451ca..9c922ae7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -641,6 +641,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get releaseFailed => 'Failed to release. Please try again.'; + @override + String get cancelTradeButton => 'Cancel order'; + + @override + String get openDisputeButton => 'Open dispute'; + + @override + String get releaseSatsButton => 'Release sats'; + + @override + String get markFiatSentButton => 'Mark fiat sent'; + + @override + String get confirmReleaseSatsButton => 'Confirm & release sats'; + + @override + String get shareOrderButton => 'Share order'; + @override String get orderPillYouAreSelling => 'YOU ARE SELLING'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 800f1854..6fb78693 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -652,6 +652,24 @@ class AppLocalizationsEs extends AppLocalizations { String get releaseFailed => 'Error al liberar. Por favor, inténtelo de nuevo.'; + @override + String get cancelTradeButton => 'Cancelar orden'; + + @override + String get openDisputeButton => 'Abrir disputa'; + + @override + String get releaseSatsButton => 'Liberar sats'; + + @override + String get markFiatSentButton => 'Marcar fiat enviado'; + + @override + String get confirmReleaseSatsButton => 'Confirmar y liberar sats'; + + @override + String get shareOrderButton => 'Compartir orden'; + @override String get orderPillYouAreSelling => 'USTED ESTÁ VENDIENDO'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c2618317..acf72feb 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -655,6 +655,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get releaseFailed => 'Échec de la libération. Veuillez réessayer.'; + @override + String get cancelTradeButton => 'Annuler la commande'; + + @override + String get openDisputeButton => 'Ouvrir un litige'; + + @override + String get releaseSatsButton => 'Libérer les sats'; + + @override + String get markFiatSentButton => 'Marquer comme envoyé'; + + @override + String get confirmReleaseSatsButton => 'Confirmer et libérer les sats'; + + @override + String get shareOrderButton => 'Partager la commande'; + @override String get orderPillYouAreSelling => 'VOUS VENDEZ'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 5763f346..6bbd71bb 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -650,6 +650,24 @@ class AppLocalizationsIt extends AppLocalizations { @override String get releaseFailed => 'Rilascio fallito. Riprovare.'; + @override + String get cancelTradeButton => 'Annulla ordine'; + + @override + String get openDisputeButton => 'Apri disputa'; + + @override + String get releaseSatsButton => 'Rilascia sats'; + + @override + String get markFiatSentButton => 'Segna come inviato'; + + @override + String get confirmReleaseSatsButton => 'Conferma e rilascia sats'; + + @override + String get shareOrderButton => 'Condividi ordine'; + @override String get orderPillYouAreSelling => 'STAI VENDENDO'; From da09219963b4ce559f6976babeb574af95dd9a36 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Fri, 17 Jul 2026 18:19:28 -0600 Subject: [PATCH 2/6] feat(trades): surface cancel/dispute/release as visible buttons Cancel, dispute, and release were only reachable through the app-bar overflow menu, making them hard to spot during an active trade. Move them into a secondary row below the primary CTA, using a new MostroReactiveButton outlined variant so loading/error feedback stays consistent with the rest of the screen. Also: - Fix MostroReactiveButton showing a false "success" state on failure: the trade-detail handlers were swallowing their own errors instead of letting the button's own catch block see them. - Remove the button's error-state color/icon change. The SnackBar already reports failures, so the button now just re-enables after a cooldown instead of flashing red. - Restore a minimal overflow menu scoped to a single "Share order" item, which shows a coming-soon message (the underlying share_order contract in contracts/orders.md is not implemented yet). - Sync specs/004 (spec.md, tasks.md) with the layout actually shipped. Closes #134 --- .../trades/screens/trade_detail_screen.dart | 158 ++++++++----- .../widgets/mostro_reactive_button.dart | 52 ++-- specs/004-mostro-p2p-client/spec.md | 12 +- specs/004-mostro-p2p-client/tasks.md | 8 +- .../trades/trade_detail_screen_test.dart | 222 ++++++++++++++++++ .../widgets/mostro_reactive_button_test.dart | 117 +++++++++ 6 files changed, 475 insertions(+), 94 deletions(-) create mode 100644 test/features/trades/trade_detail_screen_test.dart create mode 100644 test/shared/widgets/mostro_reactive_button_test.dart diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 0bdae5ec..9b87fb59 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -65,9 +65,6 @@ enum TradeStatus { final String label; } -/// Overflow-menu actions (cancel / dispute / release collapsed behind ⋮). -enum _MenuAction { cancel, dispute, release } - class _TradeDetailScreenState extends ConsumerState { Timer? _countdownTimer; Duration _remaining = const Duration(seconds: _kCountdownSeconds); @@ -180,6 +177,7 @@ class _TradeDetailScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.cancelRequestFailed)), ); + rethrow; } } @@ -209,11 +207,11 @@ class _TradeDetailScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context).openDisputeFailed)), ); + rethrow; } } - /// Confirm and release the sats (seller). Shared between the primary CTA - /// in the fiat-sent state and the overflow menu in the disputed state. + /// Shared by the fiat-sent primary CTA and the disputed secondary row. Future _releaseOrder() async { final confirmed = await showReleaseConfirmationDialog(context); if (confirmed != true || !mounted) return; @@ -231,6 +229,7 @@ class _TradeDetailScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context).releaseFailed)), ); + rethrow; } } @@ -355,7 +354,7 @@ class _TradeDetailScreenState extends ConsumerState { isBuyer ? 'Time for the seller to confirm receipt' : 'Time to confirm receipt and release', - 'If something looks wrong, open a dispute from the ⋮ menu.', + 'If something looks wrong, open a dispute using the button below.', ), _ => null, }; @@ -451,7 +450,7 @@ class _TradeDetailScreenState extends ConsumerState { onPressed: () => context.canPop() ? context.pop() : context.go(AppRoute.home), ), - actions: [_buildOverflowMenu(status, isBuyer, colors)], + actions: [_buildOverflowMenu(context)], ), body: ListView( padding: const EdgeInsets.all(AppSpacing.lg), @@ -470,6 +469,11 @@ class _TradeDetailScreenState extends ConsumerState { // Single primary CTA for the current state. ..._buildPrimaryAction(status, isBuyer, green, colors), + // Secondary row of outlined destructive actions (cancel / dispute / + // release), shown only when at least one applies to the current + // status + role. + ..._buildSecondaryActionRow(status, isBuyer, colors), + // Step timeline. if (_currentStep(status) >= 0) ...[ const SizedBox(height: AppSpacing.lg), @@ -523,12 +527,44 @@ class _TradeDetailScreenState extends ConsumerState { static String _shortId(String id) => id.length <= 14 ? id : '${id.substring(0, 8)}…${id.substring(id.length - 5)}'; - // ── Overflow menu (collapsed secondary/destructive actions) ────────────── + // ── Overflow menu (share order) ─────────────────────────────────────────── - Widget _buildOverflowMenu( - TradeStatus status, bool isBuyer, AppColors? colors) { - final red = colors?.destructiveRed ?? const Color(0xFFD84D4D); + /// Unconditional `⋮` menu — sharing an order is always a valid action, + /// unlike the status-gated Cancel/Dispute/Release row below. + Widget _buildOverflowMenu(BuildContext context) { + return PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (_) { + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.comingSoonMessage), + duration: const Duration(seconds: 2), + ), + ); + }, + itemBuilder: (ctx) => [ + PopupMenuItem( + value: 0, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.share, size: 18), + title: Text(AppLocalizations.of(context).shareOrderButton), + dense: true, + ), + ), + ], + ); + } + // ── Secondary action row (visible cancel / dispute / release) ──────────── + + /// Empty list when no action applies. + List _buildSecondaryActionRow( + TradeStatus status, + bool isBuyer, + AppColors? colors, + ) { final canCancel = const { TradeStatus.pending, TradeStatus.waitingInvoice, @@ -542,49 +578,53 @@ class _TradeDetailScreenState extends ConsumerState { final canRelease = status == TradeStatus.disputed && !isBuyer; if (!canCancel && !canDispute && !canRelease) { - return const SizedBox.shrink(); + return const []; } - return PopupMenuButton<_MenuAction>( - icon: const Icon(Icons.more_vert), - onSelected: (action) => switch (action) { - _MenuAction.cancel => _cancelOrder(), - _MenuAction.dispute => _openDispute(), - _MenuAction.release => _releaseOrder(), - }, - itemBuilder: (ctx) => [ - if (canRelease) - const PopupMenuItem( - value: _MenuAction.release, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.lock_open, size: 18), - title: Text('Release sats'), - dense: true, - ), - ), - if (canCancel) - PopupMenuItem( - value: _MenuAction.cancel, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.cancel_outlined, size: 18, color: red), - title: Text('Cancel order', style: TextStyle(color: red)), - dense: true, - ), - ), - if (canDispute) - PopupMenuItem( - value: _MenuAction.dispute, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.gavel, size: 18, color: red), - title: Text('Open dispute', style: TextStyle(color: red)), - dense: true, - ), + final l10n = AppLocalizations.of(context); + + Widget destructiveButton({ + required String label, + required Future Function() onPressed, + }) => + Expanded( + child: MostroReactiveButton( + outlined: true, + label: label, + variant: MostroButtonVariant.destructive, + onPressed: onPressed, ), - ], - ); + ); + + final buttons = [ + if (canRelease) + destructiveButton( + label: l10n.releaseSatsButton, + onPressed: _releaseOrder, + ), + if (canCancel) + destructiveButton( + label: l10n.cancelTradeButton, + onPressed: _cancelOrder, + ), + if (canDispute) + destructiveButton( + label: l10n.openDisputeButton, + onPressed: _openDispute, + ), + ]; + + return [ + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + for (var i = 0; i < buttons.length; i++) ...[ + if (i > 0) const SizedBox(width: AppSpacing.sm), + buttons[i], + ], + ], + ), + ]; } // ── State strip ────────────────────────────────────────────────────────── @@ -739,6 +779,7 @@ class _TradeDetailScreenState extends ConsumerState { AppColors? colors, ) { final red = colors?.destructiveRed ?? const Color(0xFFD84D4D); + final l10n = AppLocalizations.of(context); FilledButton bigButton({ required String label, @@ -785,8 +826,7 @@ class _TradeDetailScreenState extends ConsumerState { case (TradeStatus.active, true): return [ MostroReactiveButton( - label: 'Mark fiat sent', - backgroundColor: green, + label: l10n.markFiatSentButton, icon: Icons.check, onPressed: () async { await orders_api.sendFiatSent(orderId: widget.orderId); @@ -805,18 +845,8 @@ class _TradeDetailScreenState extends ConsumerState { case (TradeStatus.fiatSent, false): return [ MostroReactiveButton( - label: 'Confirm & release sats', - backgroundColor: green, - icon: Icons.lock_open, + label: l10n.confirmReleaseSatsButton, onPressed: _releaseOrder, - onError: (e) { - debugPrint('[TradeDetailScreen] releaseOrder onError: $e'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context).releaseFailed)), - ); - }, ), ]; case (TradeStatus.disputed, _): diff --git a/lib/shared/widgets/mostro_reactive_button.dart b/lib/shared/widgets/mostro_reactive_button.dart index c7ef7573..37878631 100644 --- a/lib/shared/widgets/mostro_reactive_button.dart +++ b/lib/shared/widgets/mostro_reactive_button.dart @@ -2,25 +2,27 @@ import 'package:flutter/material.dart'; import 'package:mostro/core/app_theme.dart'; -/// Button that shows a spinner while waiting for a Mostro response, -/// a success check on completion, and an error state on failure. +enum MostroButtonVariant { primary, destructive } + +/// Button that shows a spinner while waiting, then a success check. class MostroReactiveButton extends StatefulWidget { const MostroReactiveButton({ super.key, required this.label, required this.onPressed, - this.backgroundColor, - this.foregroundColor, + this.variant = MostroButtonVariant.primary, this.icon, this.onError, + this.outlined = false, }); final String label; final Future Function() onPressed; - final Color? backgroundColor; - final Color? foregroundColor; + + final MostroButtonVariant variant; final IconData? icon; final void Function(Object error)? onError; + final bool outlined; @override State createState() => _MostroReactiveButtonState(); @@ -47,7 +49,7 @@ class _MostroReactiveButtonState extends State { if (!mounted) return; setState(() => _state = _ButtonState.error); - await Future.delayed(const Duration(milliseconds: 2000)); + await Future.delayed(const Duration(seconds: 4)); if (mounted) setState(() => _state = _ButtonState.idle); } } @@ -56,16 +58,32 @@ class _MostroReactiveButtonState extends State { Widget build(BuildContext context) { final colors = Theme.of(context).extension(); final green = colors?.mostroGreen ?? const Color(0xFF8CC63F); - final bg = widget.backgroundColor ?? green; - final fg = widget.foregroundColor ?? Colors.black; + final destructiveRed = colors?.destructiveRed ?? const Color(0xFFD84D4D); + final accent = switch (widget.variant) { + MostroButtonVariant.primary => green, + MostroButtonVariant.destructive => destructiveRed, + }; + + if (widget.outlined) { + return OutlinedButton( + onPressed: _state == _ButtonState.idle ? _handlePress : null, + style: OutlinedButton.styleFrom( + side: BorderSide(color: accent), + foregroundColor: accent, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: _buildChild(), + ); + } return FilledButton( onPressed: _state == _ButtonState.idle ? _handlePress : null, style: FilledButton.styleFrom( - backgroundColor: _state == _ButtonState.error - ? colors?.destructiveRed ?? const Color(0xFFD84D4D) - : bg, - foregroundColor: fg, + backgroundColor: accent, + foregroundColor: Colors.black, minimumSize: const Size(0, 48), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.button), @@ -93,13 +111,7 @@ class _MostroReactiveButtonState extends State { liveRegion: true, child: const Icon(Icons.check, size: 20), ); - case _ButtonState.error: - return Semantics( - label: 'Error', - liveRegion: true, - child: const Icon(Icons.error_outline, size: 20), - ); - case _ButtonState.idle: + case _ButtonState.idle || _ButtonState.error: if (widget.icon != null) { return Row( mainAxisSize: MainAxisSize.min, diff --git a/specs/004-mostro-p2p-client/spec.md b/specs/004-mostro-p2p-client/spec.md index 7d7613a1..791bbaca 100644 --- a/specs/004-mostro-p2p-client/spec.md +++ b/specs/004-mostro-p2p-client/spec.md @@ -116,7 +116,7 @@ A buyer (taker of a sell order) completes a trade. Without NWC, they manually en 1. **Given** a buyer has taken a sell order and NWC is NOT configured, **When** the app prompts for a Lightning invoice, **Then** the buyer sees an input screen with the sats and fiat amounts, and can enter an invoice or Lightning address. 2. **Given** a buyer has taken a sell order and NWC IS configured, **When** the order is accepted, **Then** the invoice step is skipped entirely and the buyer proceeds to the active trade view. -3. **Given** the trade is in "active" status, **When** the buyer views Trade Detail, **Then** they see: trade summary, payment method, order ID, instructions to contact the seller, and buttons for Fiat Sent, Cancel, Dispute, and Contact. +3. **Given** the trade is in "active" status, **When** the buyer views Trade Detail, **Then** they see: trade summary, payment method, order ID, instructions to contact the seller, a "Fiat Sent" primary CTA, a secondary row with outlined Cancel and Dispute buttons, and a persistent chat chip for Contact. 4. **Given** the buyer has sent fiat payment, **When** they tap "Fiat Sent", **Then** the order status changes to "Fiat sent" and the seller sees instructions to verify and release. 5. **Given** the seller releases sats, **When** the buyer receives the Lightning payment, **Then** both parties are prompted to rate each other. @@ -137,7 +137,7 @@ A seller (taker of a buy order) completes a trade. They must pay a hold Lightnin 2. **Given** a seller takes a buy order and NWC IS configured, **When** the hold invoice is ready, **Then** a simplified screen appears with a "Pay with Wallet" button that auto-pays via the connected wallet. If NWC payment fails, the screen falls back to the QR view of scenario 1. 2a. **Given** the seller has paid the hold invoice (QR or NWC path), **When** mostrod confirms the HTLC and broadcasts the order update as Active, **Then** the app MUST auto-navigate from the pay-invoice screen to Trade Detail without any further user action; the navigation is driven by the live order status stream, not by the local wallet success callback. 2b. **Given** the seller is still on the pay-invoice screen, **When** mostrod broadcasts a terminal cancellation (canceled / cooperativelyCanceled / canceledByAdmin / expired), **Then** the app MUST leave the pay-invoice screen and surface a cancellation notice so the user is not stranded on a dead invoice. -3. **Given** the trade is active, **When** the seller views Trade Detail, **Then** they see instructions to contact the buyer with payment details and buttons: Close, Cancel, Dispute, Contact. +3. **Given** the trade is active, **When** the seller views Trade Detail, **Then** they see instructions to contact the buyer with payment details, a disabled "waiting for the buyer" primary state, a secondary row with outlined Cancel and Dispute buttons, and a persistent chat chip for Contact. 4. **Given** the buyer confirms "Fiat Sent", **When** the seller views Trade Detail, **Then** the status changes to "Fiat Sent" and a "Release" button becomes available. 5. **Given** the seller taps "Release", **When** the confirmation modal appears, **Then** tapping "Yes" releases the sats and transitions to the success/rating screen. @@ -349,10 +349,10 @@ Users manage their cryptographic identity from the Account screen: view their 12 - **FR-030**: When NWC is configured for a seller, the system MUST present a "Pay with Wallet" button that auto-pays the hold invoice. On NWC failure, the system MUST fall back to the manual QR flow defined in FR-029. - **FR-030a**: The pay-invoice screen MUST subscribe to the live order-status stream (`tradeStatusProvider`) and auto-navigate to Trade Detail on `Active` (or any later non-cancel status) regardless of the local wallet's success callback. This guarantees that the navigation is driven by mostrod's confirmation of the HTLC, not by the seller's wallet reporting a local send, so both QR and NWC paths converge on the same source of truth. - **FR-030b**: While the seller remains on the pay-invoice screen, terminal cancellation statuses (`canceled`, `cooperativelyCanceled`, `canceledByAdmin`, `expired`) MUST trigger navigation away from the dead invoice with a user-visible cancellation notice. -- **FR-031**: The Trade Detail screen MUST display role-appropriate action buttons based on the current order status and the user's role (buyer or seller). -- **FR-032**: The buyer MUST have a "Fiat Sent" button available when the trade is in "active" status. -- **FR-033**: The seller MUST have a "Release" button available when the trade is in "fiat-sent" status; tapping it MUST show a confirmation modal before executing. -- **FR-034**: Both parties MUST have "Cancel" (cooperative) and "Dispute" buttons available during active trades. +- **FR-031**: The Trade Detail screen MUST display a role-appropriate primary CTA button, plus a secondary row of outlined destructive-style buttons (Cancel, Dispute, and — while disputed — Release) below it, based on the current order status and the user's role (buyer or seller). Contact is provided separately via a persistent chat chip, not a dedicated button. +- **FR-032**: The buyer MUST have a "Fiat Sent" primary CTA button available when the trade is in "active" status. +- **FR-033**: The seller MUST have a "Release" primary CTA button available when the trade is in "fiat-sent" status, and again as a secondary-row button while the trade is disputed; tapping it MUST show a confirmation modal before executing. +- **FR-034**: Both parties MUST have "Cancel" (cooperative) and "Dispute" actions available during active trades, presented as outlined destructive-style buttons in the secondary row below the primary CTA. **P2P Chat** diff --git a/specs/004-mostro-p2p-client/tasks.md b/specs/004-mostro-p2p-client/tasks.md index 8b56fde0..4457f7db 100644 --- a/specs/004-mostro-p2p-client/tasks.md +++ b/specs/004-mostro-p2p-client/tasks.md @@ -201,9 +201,9 @@ configuration. - [x] T055 Implement invoice submission action in `rust/src/api/orders.rs`: add `send_invoice(order_id, invoice_or_address, amount_sats)` — sends `AddInvoice` `MostroMessage` to Mostro. On failure: `on_payment_failed` stream event. - [x] T056 Implement trade detail screen in `lib/features/trades/screens/trade_detail_screen.dart`: AppBar "ORDER DETAILS". 5 cards: (1) trade summary "You are buying [sats] sats for [fiat] [currency] [flag]", (2) payment method, (3) creation date, (4) order ID + copy, (5) instructions + status label. Countdown widget (color-coded as time elapses). Action button rows derived from `OrderState.getActions(role)`. Watches `orderNotifierProvider(orderId)` for live state. - [x] T057 [P] Implement trade info cards widget in `lib/features/trades/widgets/trade_info_cards.dart`: reusable components for the 5 info cards used in trade detail. `OrderIdCard` with copy-to-clipboard. `InstructionsCard` with green lightning bolt icon + instructional text based on role + status. -- [x] T058 [P] Implement Mostro reactive button in `lib/shared/widgets/mostro_reactive_button.dart`: button that shows spinner while waiting for Mostro response, success check on completion, error state on failure. Listens to `mostroMessageStreamProvider` for its specific action. +- [x] T058 [P] Implement Mostro reactive button in `lib/shared/widgets/mostro_reactive_button.dart`: button that shows spinner while waiting for Mostro response, success check on completion. On failure, the caller reports the error via SnackBar (`onError`) and the button stays disabled for 4s (matching the SnackBar's default duration) with no visual change, then returns to idle. - [x] T059 Implement fiat-sent action in `rust/src/api/orders.rs`: add `send_fiat_sent(order_id)` — sends `FiatSent` `MostroMessage`. Updates local `Trade.current_step` to `FiatSent`. Streams: `on_trade_updated(order_id)` emits new `TradeInfo`. -- [x] T060 Wire buyer trade detail buttons per FSM: active state → show FIAT SENT (green) + CANCEL (red) + DISPUTE (red) + CONTACT (green). Fiat Sent tap → `send_fiat_sent()` → reactive button flow. CONTACT → `/chat_room/:orderId`. +- [x] T060 Wire buyer trade detail buttons per FSM: active state → primary CTA FIAT SENT (green filled) + a secondary row of outlined destructive buttons CANCEL (red) and DISPUTE (red) below it. Contact is provided by the persistent chat chip, not a dedicated button. Fiat Sent tap → `send_fiat_sent()` → reactive button flow. Chat chip → `/chat_room/:orderId`. AppBar also carries a `⋮` overflow menu, scoped to a single "Share order" (coming soon) item — separate from the Cancel/Dispute/Release secondary row. **Checkpoint**: Full buyer flow: add invoice → active trade detail → Fiat Sent → status changes to "Fiat sent". Cancel and dispute buttons visible. @@ -222,10 +222,10 @@ configuration. - [x] T061b Add live status listener in `pay_lightning_invoice_screen.dart`: `ref.listen>(tradeStatusProvider(orderId))` with a one-shot `_navigated` guard. On `active | fiatSent | settledHoldInvoice | success | dispute` → `context.go(/trade_detail/:orderId)`. On `canceled | cooperativelyCanceled | canceledByAdmin | expired` → SnackBar + `context.go(/home)`. Simplify `_onPaymentDetected` (NWC success callback) to only flip `_waiting = true` so both QR and NWC paths converge on the mostrod-confirmed status transition instead of the local wallet's success reply. Satisfies FR-030a / FR-030b. - [x] T062 [P] Implement NWC payment widget in `lib/shared/widgets/nwc_payment_widget.dart`: single "Pay with Wallet" button (large green, wallet icon). Shows loading spinner during payment. `onPaymentSuccess` fires the shared status-listener-driven navigation (T061b); `onFallbackToManual` flips the screen into manual QR mode. - [x] T063 [P] Implement pay invoice widget (manual QR mode) in `lib/shared/widgets/pay_lightning_invoice_widget.dart`: QR code display using `qr_flutter`, copy button, share button (wired via share_plus). `onSubmit` (user confirms manual payment), `onCancel` callbacks. -- [x] T064 Extend trade detail screen in `lib/features/trades/screens/trade_detail_screen.dart` for seller fiat-sent view: Card 5 instruction text becomes "The buyer [handle] has confirmed they sent you [fiat] [currency] using [method]. Once you verify, release the sats." Status label: "Fiat sent". Action buttons: CLOSE (green outline) + RELEASE (green filled) + CANCEL (red) + DISPUTE (red) in one row, CONTACT (green full-width) below. +- [x] T064 Extend trade detail screen in `lib/features/trades/screens/trade_detail_screen.dart` for seller fiat-sent view: Card 5 instruction text becomes "The buyer [handle] has confirmed they sent you [fiat] [currency] using [method]. Once you verify, release the sats." Status label: "Fiat sent". Primary CTA: RELEASE (green filled, opens the confirmation modal). Secondary row below it: outlined CANCEL (red) and DISPUTE (red). Contact is provided by the persistent chat chip, not a dedicated button (there is no CLOSE button in this state). AppBar also carries a `⋮` overflow menu, scoped to a single "Share order" (coming soon) item — separate from this secondary row. - [x] T065 Implement release confirmation dialog in `lib/features/trades/widgets/release_confirmation_dialog.dart`: centered modal on dark overlay. Large gray info icon. Title "Release Bitcoin". Body "Are you sure you want to release the Satoshis to the buyer?" No (gray) + Yes (green) buttons. - [x] T066 Implement release order action in `rust/src/api/orders.rs`: `release_order(order_id)` validates FiatSent status, builds Release MostroMessage via NIP-59 gift wrap and publishes to relay pool via `publish_event_json()`. Also added `fiat_sent`, `release`, `cancel`, `add_invoice` action builders to `mostro/actions.rs`. `send_fiat_sent`, `send_invoice`, and `take_order` are wired to dispatch (fire-and-forget with optimistic local return). `create_order` instead waits for the daemon's confirmation and returns an error on no response (no optimistic local persist). -- [x] T067 Wire seller active view in trade detail: active status + seller role → show CLOSE + CANCEL + DISPUTE + CONTACT (no RELEASE, no FIAT SENT). Seller card 5 instruction: "Contact the buyer [handle] with payment instructions." Status: "Active order". Role and status derive from reactive providers (`tradeRoleProvider`/`tradeRoleFromDbProvider`, `tradeStatusProvider`) — no local `_isBuyer`/`_status`. Real trade state arrives via incoming kind-14 routing (`dispatch_mostro_message` → in-memory order book + trade DB), which the providers poll. +- [x] T067 Wire seller active view in trade detail: active status + seller role → primary area shows a disabled "waiting for the buyer" state (no RELEASE, no FIAT SENT), with a secondary row of outlined CANCEL (red) and DISPUTE (red) below it; Contact is provided by the persistent chat chip (no CLOSE button in this state). Seller card 5 instruction: "Contact the buyer [handle] with payment instructions." Status: "Active order". Role and status derive from reactive providers (`tradeRoleProvider`/`tradeRoleFromDbProvider`, `tradeStatusProvider`) — no local `_isBuyer`/`_status`. Real trade state arrives via incoming kind-14 routing (`dispatch_mostro_message` → in-memory order book + trade DB), which the providers poll. AppBar also carries a `⋮` overflow menu, scoped to a single "Share order" (coming soon) item — separate from the secondary row. - [x] T068 Wire seller release flow: RELEASE tap → confirmation dialog → Yes → `orders_api.releaseOrder(orderId)` via Rust bridge → on success → navigate to rate screen `/rate_user/:orderId`. Buyer FIAT SENT button also wired to `orders_api.sendFiatSent(orderId)`. Buyer add-invoice screen wired to `orders_api.sendInvoice()`. All `Future.delayed` stubs replaced. **Checkpoint**: Full seller flow: pay hold invoice (both QR and NWC paths) → active → fiat sent by buyer → Release confirmation → trade completes and navigates to rating. diff --git a/test/features/trades/trade_detail_screen_test.dart b/test/features/trades/trade_detail_screen_test.dart new file mode 100644 index 00000000..472e7b3d --- /dev/null +++ b/test/features/trades/trade_detail_screen_test.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/home/providers/home_order_providers.dart'; +import 'package:mostro/features/order/providers/trade_state_provider.dart'; +import 'package:mostro/features/trades/screens/trade_detail_screen.dart'; +import 'package:mostro/l10n/app_localizations.dart'; + +import '../../support/provider_harness.dart'; + +/// Pumps [TradeDetailScreen] for [orderId] with the role and live order +/// status overridden, matching this repo's Riverpod-override testing +/// convention (see `test/support/order_book_harness.dart`). +/// +/// The order book itself is overridden to an empty stream — the screen's own +/// `_loadExpiresAt`/Rust-bridge calls fail silently without `RustLib.init()` +/// (the same as `test/widget_test.dart`'s smoke test), which is fine since +/// none of the assertions here depend on live order details. +Future _pumpTradeDetail( + WidgetTester tester, { + required String orderId, + required bool isBuyer, + required OrderStatus status, +}) async { + final container = createContainer(overrides: [ + tradeRoleProvider.overrideWith((ref) => {orderId: isBuyer}), + tradeStatusProvider(orderId).overrideWith((ref) => Stream.value(status)), + orderBookProvider.overrideWith((ref) => Stream.value(const [])), + ]); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildDarkTheme(), + locale: const Locale('en'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: TradeDetailScreen(orderId: orderId), + ), + ), + ); + + // One frame for the initial build, then a frame to flush the + // fire-and-forget `_loadExpiresAt` future and the stream-provider + // emissions above. Deliberately not `pumpAndSettle()`: the screen starts a + // real 1s-period countdown `Timer.periodic` that keeps scheduling frames + // for the full 15-minute default duration, which would make + // `pumpAndSettle()` time out. + await tester.pump(); + await tester.pump(); +} + +/// Matches an outlined secondary-row button by its visible label text. +Finder _outlinedButtonWithText(String label) => find.ancestor( + of: find.text(label), + matching: find.byType(OutlinedButton), + ); + +/// Matches any `PopupMenuButton`, regardless of its generic type argument. +/// +/// The AppBar overflow menu is unconditional (Share order only — see +/// `_buildOverflowMenu`), so it is always present regardless of trade status. +Finder _anyPopupMenuButton() => + find.byWidgetPredicate((widget) => widget is PopupMenuButton); + +/// Matches any `PopupMenuItem`, regardless of its generic type argument — +/// used to assert the restored overflow menu contains exactly one entry. +Finder _anyPopupMenuItem() => + find.byWidgetPredicate((widget) => widget is PopupMenuItem); + +void main() { + group('TradeDetailScreen secondary action row', () { + testWidgets('buyer + active: Fiat Sent CTA, Cancel + Dispute, no Release', + (tester) async { + await _pumpTradeDetail( + tester, + orderId: 'order-1', + isBuyer: true, + status: OrderStatus.active, + ); + + expect(find.text('Mark fiat sent'), findsOneWidget); + expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Open dispute'), findsOneWidget); + expect(_outlinedButtonWithText('Release sats'), findsNothing); + expect(_anyPopupMenuButton(), findsOneWidget); + }); + + testWidgets('buyer + fiatSent: Cancel + Dispute, no Release', + (tester) async { + await _pumpTradeDetail( + tester, + orderId: 'order-2', + isBuyer: true, + status: OrderStatus.fiatSent, + ); + + expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Open dispute'), findsOneWidget); + expect(_outlinedButtonWithText('Release sats'), findsNothing); + expect(_anyPopupMenuButton(), findsOneWidget); + }); + + testWidgets('seller + active: Cancel + Dispute, no Release', + (tester) async { + await _pumpTradeDetail( + tester, + orderId: 'order-3', + isBuyer: false, + status: OrderStatus.active, + ); + + expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Open dispute'), findsOneWidget); + expect(_outlinedButtonWithText('Release sats'), findsNothing); + expect(_anyPopupMenuButton(), findsOneWidget); + }); + + testWidgets( + 'seller + fiatSent: Confirm & release CTA, Cancel + Dispute, no secondary Release', + (tester) async { + await _pumpTradeDetail( + tester, + orderId: 'order-4', + isBuyer: false, + status: OrderStatus.fiatSent, + ); + + expect(find.text('Confirm & release sats'), findsOneWidget); + expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Open dispute'), findsOneWidget); + expect(_outlinedButtonWithText('Release sats'), findsNothing); + expect(_anyPopupMenuButton(), findsOneWidget); + }); + + testWidgets( + 'seller + disputed: View dispute CTA, Release + Cancel, no Dispute', + (tester) async { + await _pumpTradeDetail( + tester, + orderId: 'order-5', + isBuyer: false, + status: OrderStatus.dispute, + ); + + expect(find.text('View dispute'), findsOneWidget); + expect(_outlinedButtonWithText('Release sats'), findsOneWidget); + expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + // canDispute is false once already disputed — no "Open dispute" button. + expect(_outlinedButtonWithText('Open dispute'), findsNothing); + expect(_anyPopupMenuButton(), findsOneWidget); + }); + + testWidgets('buyer + disputed: no secondary row at all', (tester) async { + await _pumpTradeDetail( + tester, + orderId: 'order-6', + isBuyer: true, + status: OrderStatus.dispute, + ); + + expect(find.text('View dispute'), findsOneWidget); + // Per the existing gating rules, canCancel/canDispute/canRelease are + // all false for buyer + disputed — see gating logic in + // trade_detail_screen.dart (`_buildSecondaryActionRow`). + expect(_outlinedButtonWithText('Release sats'), findsNothing); + expect(_outlinedButtonWithText('Cancel order'), findsNothing); + expect(_outlinedButtonWithText('Open dispute'), findsNothing); + expect(_anyPopupMenuButton(), findsOneWidget); + }); + }); + + group('TradeDetailScreen overflow menu (Share order)', () { + testWidgets( + 'contains only Share order; tapping it shows the coming-soon ' + 'SnackBar; Cancel/Dispute/Release are not duplicated into it', + (tester) async { + await _pumpTradeDetail( + tester, + orderId: 'order-7', + isBuyer: true, + status: OrderStatus.active, + ); + + // Secondary row is visible for this status/role, with its own + // Cancel/Dispute buttons — the menu must not duplicate them. + expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Open dispute'), findsOneWidget); + expect(_anyPopupMenuItem(), findsNothing); + + await tester.tap(find.byIcon(Icons.more_vert)); + // The popup menu's opening route animates in — a zero-duration pump() + // leaves it mid-transition. pumpAndSettle() is unsafe here: the screen's + // 1s countdown Timer.periodic keeps scheduling frames for its full + // 15-minute duration, so it never reports "settled". + await tester.pump(const Duration(milliseconds: 350)); + + expect(_anyPopupMenuItem(), findsOneWidget); + expect(find.text('Share order'), findsOneWidget); + + // Selecting the item via a real tap gesture is timing-fragile in a + // widget test (the popup's own closing-route animation delays when + // `onSelected` actually fires). Invoke the already-wired callback + // directly instead — this still exercises the real selection → SnackBar + // logic without depending on that animation's exact timing. + final popupButton = + tester.widget>(find.byType(PopupMenuButton)); + popupButton.onSelected!(0); + await tester.pump(); + + expect(find.text('Coming soon'), findsOneWidget); + }); + }); +} diff --git a/test/shared/widgets/mostro_reactive_button_test.dart b/test/shared/widgets/mostro_reactive_button_test.dart new file mode 100644 index 00000000..7ad4afb3 --- /dev/null +++ b/test/shared/widgets/mostro_reactive_button_test.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/shared/widgets/mostro_reactive_button.dart'; + +Future _pump( + WidgetTester tester, { + required Future Function() onPressed, + MostroButtonVariant variant = MostroButtonVariant.primary, + bool outlined = false, + void Function(Object error)? onError, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: buildDarkTheme(), + home: Scaffold( + body: MostroReactiveButton( + label: 'Do it', + variant: variant, + outlined: outlined, + onPressed: onPressed, + onError: onError, + ), + ), + ), + ); +} + +void main() { + group('MostroReactiveButton', () { + testWidgets('idle renders the label', (tester) async { + await _pump(tester, onPressed: () async {}); + expect(find.text('Do it'), findsOneWidget); + }); + + testWidgets('outlined: false renders a FilledButton', (tester) async { + await _pump(tester, onPressed: () async {}); + expect(find.byType(FilledButton), findsOneWidget); + expect(find.byType(OutlinedButton), findsNothing); + }); + + testWidgets('outlined: true renders an OutlinedButton', (tester) async { + await _pump(tester, onPressed: () async {}, outlined: true); + expect(find.byType(OutlinedButton), findsOneWidget); + expect(find.byType(FilledButton), findsNothing); + }); + + testWidgets('tap shows a spinner, then success, then the label again', + (tester) async { + final completer = Completer(); + await _pump(tester, onPressed: () => completer.future); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete(); + await tester.pump(); + expect(find.byIcon(Icons.check), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1500)); + expect(find.text('Do it'), findsOneWidget); + }); + + testWidgets('disabled while pending, so a second tap does not re-enter', + (tester) async { + var callCount = 0; + final completer = Completer(); + await _pump( + tester, + onPressed: () { + callCount++; + return completer.future; + }, + ); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(callCount, 1); + + completer.complete(); + await tester.pump(const Duration(milliseconds: 1500)); + }); + + testWidgets( + 'on failure: reports onError, shows no error icon or color, ' + 'stays disabled for 4s', (tester) async { + Object? reportedError; + await _pump( + tester, + onPressed: () async => throw Exception('boom'), + onError: (e) => reportedError = e, + ); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(reportedError, isNotNull); + expect(find.text('Do it'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsNothing); + + final duringCooldown = + tester.widget(find.byType(FilledButton)); + expect(duringCooldown.onPressed, isNull); + + await tester.pump(const Duration(seconds: 4)); + final afterCooldown = + tester.widget(find.byType(FilledButton)); + expect(afterCooldown.onPressed, isNotNull); + }); + }); +} From be9a31d839c49d96c9da4b8dc47ad2d7b7ea5b6c Mon Sep 17 00:00:00 2001 From: bracr10 Date: Sun, 19 Jul 2026 23:31:12 -0600 Subject: [PATCH 3/6] fix(trades): throw when cancel or release confirmation is declined MostroReactiveButton treats any normal return from onPressed as success. Cancel and release confirmation dialogs returned normally when the user picked No, so declining showed a false success checkmark. Both now throw in that case, keeping the existing mounted check and confirmed dialog behavior unchanged. test(trades): cover MostroReactiveButton destructive variant No test exercised MostroButtonVariant.destructive, the outlined style used by the cancel and dispute buttons. Add a test asserting the resolved accent color differs from the primary green, without changing existing primary variant coverage. docs(specs): align T064 primary CTA label with the shipped button T064 specified RELEASE as the seller fiat sent primary CTA, but the implementation uses confirmReleaseSatsButton, Confirm & release sats, already covered by an existing test. Update the task to match the shipped label instead of changing tested UI copy. --- .../trades/screens/trade_detail_screen.dart | 10 +++++++-- specs/004-mostro-p2p-client/tasks.md | 2 +- .../widgets/mostro_reactive_button_test.dart | 22 +++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 9b87fb59..551d2b24 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -163,7 +163,10 @@ class _TradeDetailScreenState extends ConsumerState { ], ), ); - if (confirmed != true || !mounted) return; + if (!mounted) return; + if (confirmed != true) { + throw StateError('Cancel declined by user'); + } try { await orders_api.cancelOrder(orderId: widget.orderId); ref.invalidate(rawTradesProvider); @@ -214,7 +217,10 @@ class _TradeDetailScreenState extends ConsumerState { /// Shared by the fiat-sent primary CTA and the disputed secondary row. Future _releaseOrder() async { final confirmed = await showReleaseConfirmationDialog(context); - if (confirmed != true || !mounted) return; + if (!mounted) return; + if (confirmed != true) { + throw StateError('Release declined by user'); + } try { await orders_api.releaseOrder(orderId: widget.orderId); if (!mounted) return; diff --git a/specs/004-mostro-p2p-client/tasks.md b/specs/004-mostro-p2p-client/tasks.md index 4457f7db..6560b19b 100644 --- a/specs/004-mostro-p2p-client/tasks.md +++ b/specs/004-mostro-p2p-client/tasks.md @@ -222,7 +222,7 @@ configuration. - [x] T061b Add live status listener in `pay_lightning_invoice_screen.dart`: `ref.listen>(tradeStatusProvider(orderId))` with a one-shot `_navigated` guard. On `active | fiatSent | settledHoldInvoice | success | dispute` → `context.go(/trade_detail/:orderId)`. On `canceled | cooperativelyCanceled | canceledByAdmin | expired` → SnackBar + `context.go(/home)`. Simplify `_onPaymentDetected` (NWC success callback) to only flip `_waiting = true` so both QR and NWC paths converge on the mostrod-confirmed status transition instead of the local wallet's success reply. Satisfies FR-030a / FR-030b. - [x] T062 [P] Implement NWC payment widget in `lib/shared/widgets/nwc_payment_widget.dart`: single "Pay with Wallet" button (large green, wallet icon). Shows loading spinner during payment. `onPaymentSuccess` fires the shared status-listener-driven navigation (T061b); `onFallbackToManual` flips the screen into manual QR mode. - [x] T063 [P] Implement pay invoice widget (manual QR mode) in `lib/shared/widgets/pay_lightning_invoice_widget.dart`: QR code display using `qr_flutter`, copy button, share button (wired via share_plus). `onSubmit` (user confirms manual payment), `onCancel` callbacks. -- [x] T064 Extend trade detail screen in `lib/features/trades/screens/trade_detail_screen.dart` for seller fiat-sent view: Card 5 instruction text becomes "The buyer [handle] has confirmed they sent you [fiat] [currency] using [method]. Once you verify, release the sats." Status label: "Fiat sent". Primary CTA: RELEASE (green filled, opens the confirmation modal). Secondary row below it: outlined CANCEL (red) and DISPUTE (red). Contact is provided by the persistent chat chip, not a dedicated button (there is no CLOSE button in this state). AppBar also carries a `⋮` overflow menu, scoped to a single "Share order" (coming soon) item — separate from this secondary row. +- [x] T064 Extend trade detail screen in `lib/features/trades/screens/trade_detail_screen.dart` for seller fiat-sent view: Card 5 instruction text becomes "The buyer [handle] has confirmed they sent you [fiat] [currency] using [method]. Once you verify, release the sats." Status label: "Fiat sent". Primary CTA: Confirm & release sats (green filled, opens the confirmation modal). Secondary row below it: outlined CANCEL (red) and DISPUTE (red). Contact is provided by the persistent chat chip, not a dedicated button (there is no CLOSE button in this state). AppBar also carries a `⋮` overflow menu, scoped to a single "Share order" (coming soon) item — separate from this secondary row. - [x] T065 Implement release confirmation dialog in `lib/features/trades/widgets/release_confirmation_dialog.dart`: centered modal on dark overlay. Large gray info icon. Title "Release Bitcoin". Body "Are you sure you want to release the Satoshis to the buyer?" No (gray) + Yes (green) buttons. - [x] T066 Implement release order action in `rust/src/api/orders.rs`: `release_order(order_id)` validates FiatSent status, builds Release MostroMessage via NIP-59 gift wrap and publishes to relay pool via `publish_event_json()`. Also added `fiat_sent`, `release`, `cancel`, `add_invoice` action builders to `mostro/actions.rs`. `send_fiat_sent`, `send_invoice`, and `take_order` are wired to dispatch (fire-and-forget with optimistic local return). `create_order` instead waits for the daemon's confirmation and returns an error on no response (no optimistic local persist). - [x] T067 Wire seller active view in trade detail: active status + seller role → primary area shows a disabled "waiting for the buyer" state (no RELEASE, no FIAT SENT), with a secondary row of outlined CANCEL (red) and DISPUTE (red) below it; Contact is provided by the persistent chat chip (no CLOSE button in this state). Seller card 5 instruction: "Contact the buyer [handle] with payment instructions." Status: "Active order". Role and status derive from reactive providers (`tradeRoleProvider`/`tradeRoleFromDbProvider`, `tradeStatusProvider`) — no local `_isBuyer`/`_status`. Real trade state arrives via incoming kind-14 routing (`dispatch_mostro_message` → in-memory order book + trade DB), which the providers poll. AppBar also carries a `⋮` overflow menu, scoped to a single "Share order" (coming soon) item — separate from the secondary row. diff --git a/test/shared/widgets/mostro_reactive_button_test.dart b/test/shared/widgets/mostro_reactive_button_test.dart index 7ad4afb3..49d5c8e1 100644 --- a/test/shared/widgets/mostro_reactive_button_test.dart +++ b/test/shared/widgets/mostro_reactive_button_test.dart @@ -113,5 +113,27 @@ void main() { tester.widget(find.byType(FilledButton)); expect(afterCooldown.onPressed, isNotNull); }); + + testWidgets( + 'destructive variant renders outlined with the destructive accent, ' + 'not the primary green', (tester) async { + await _pump( + tester, + onPressed: () async {}, + variant: MostroButtonVariant.destructive, + outlined: true, + ); + + expect(find.byType(OutlinedButton), findsOneWidget); + expect(find.byType(FilledButton), findsNothing); + + final button = + tester.widget(find.byType(OutlinedButton)); + final accent = button.style?.side?.resolve({})?.color; + final green = buildDarkTheme().extension()!.mostroGreen; + + expect(accent, isNotNull); + expect(accent, isNot(equals(green))); + }); }); } From 5abdfa05f0567b29007ac5e796b3da4c633e8f68 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Tue, 21 Jul 2026 18:23:57 -0600 Subject: [PATCH 4/6] fix(trades): resolve blocking feedback from review round 2 Grunch flagged that throwing StateError on a declined confirmation traded the false success checkmark for a worse bug: onError is null on the secondary row buttons, so the button silently disabled itself for 4 seconds with no visual feedback on a normal decline. Add MostroActionAborted, a marker exception for user intent rather than failure. MostroReactiveButton catches it before the generic error path and returns straight to idle, no checkmark, no cooldown. Cancel and release now throw it when the user picks No. Also wrap secondary action button labels instead of letting them overflow. Two Expanded outlined buttons at roughly 140dp cannot fit German or French labels at one line. Add a layout test at 320dp with Locale de that pumps the secondary row and asserts no layout exception. --- .../trades/screens/trade_detail_screen.dart | 4 ++-- .../widgets/mostro_reactive_button.dart | 24 +++++++++++++++++-- .../trades/trade_detail_screen_test.dart | 24 ++++++++++++++++++- .../widgets/mostro_reactive_button_test.dart | 22 +++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 551d2b24..8573c0db 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -165,7 +165,7 @@ class _TradeDetailScreenState extends ConsumerState { ); if (!mounted) return; if (confirmed != true) { - throw StateError('Cancel declined by user'); + throw const MostroActionAborted(); } try { await orders_api.cancelOrder(orderId: widget.orderId); @@ -219,7 +219,7 @@ class _TradeDetailScreenState extends ConsumerState { final confirmed = await showReleaseConfirmationDialog(context); if (!mounted) return; if (confirmed != true) { - throw StateError('Release declined by user'); + throw const MostroActionAborted(); } try { await orders_api.releaseOrder(orderId: widget.orderId); diff --git a/lib/shared/widgets/mostro_reactive_button.dart b/lib/shared/widgets/mostro_reactive_button.dart index 37878631..af1b7dec 100644 --- a/lib/shared/widgets/mostro_reactive_button.dart +++ b/lib/shared/widgets/mostro_reactive_button.dart @@ -4,6 +4,12 @@ import 'package:mostro/core/app_theme.dart'; enum MostroButtonVariant { primary, destructive } +/// Thrown by an onPressed handler when the user aborts before any work +/// starts, for example declining a confirmation dialog. Not a failure. +class MostroActionAborted implements Exception { + const MostroActionAborted(); +} + /// Button that shows a spinner while waiting, then a success check. class MostroReactiveButton extends StatefulWidget { const MostroReactiveButton({ @@ -44,6 +50,8 @@ class _MostroReactiveButtonState extends State { await Future.delayed(const Duration(milliseconds: 1500)); if (mounted) setState(() => _state = _ButtonState.idle); + } on MostroActionAborted { + if (mounted) setState(() => _state = _ButtonState.idle); } catch (e) { widget.onError?.call(e); if (!mounted) return; @@ -118,11 +126,23 @@ class _MostroReactiveButtonState extends State { children: [ Icon(widget.icon, size: 18), const SizedBox(width: AppSpacing.sm), - Text(widget.label), + Flexible( + child: Text( + widget.label, + maxLines: 2, + textAlign: TextAlign.center, + softWrap: true, + ), + ), ], ); } - return Text(widget.label); + return Text( + widget.label, + maxLines: 2, + textAlign: TextAlign.center, + softWrap: true, + ); } } } diff --git a/test/features/trades/trade_detail_screen_test.dart b/test/features/trades/trade_detail_screen_test.dart index 472e7b3d..fd9220b7 100644 --- a/test/features/trades/trade_detail_screen_test.dart +++ b/test/features/trades/trade_detail_screen_test.dart @@ -23,6 +23,7 @@ Future _pumpTradeDetail( required String orderId, required bool isBuyer, required OrderStatus status, + Locale locale = const Locale('en'), }) async { final container = createContainer(overrides: [ tradeRoleProvider.overrideWith((ref) => {orderId: isBuyer}), @@ -35,7 +36,7 @@ Future _pumpTradeDetail( container: container, child: MaterialApp( theme: buildDarkTheme(), - locale: const Locale('en'), + locale: locale, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, @@ -219,4 +220,25 @@ void main() { expect(find.text('Coming soon'), findsOneWidget); }); }); + + group('TradeDetailScreen secondary action row layout', () { + testWidgets( + 'German labels on a 320dp width do not overflow the secondary row', + (tester) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpTradeDetail( + tester, + orderId: 'order-8', + isBuyer: true, + status: OrderStatus.active, + locale: const Locale('de'), + ); + + expect(tester.takeException(), isNull); + }); + }); } diff --git a/test/shared/widgets/mostro_reactive_button_test.dart b/test/shared/widgets/mostro_reactive_button_test.dart index 49d5c8e1..cbe9810e 100644 --- a/test/shared/widgets/mostro_reactive_button_test.dart +++ b/test/shared/widgets/mostro_reactive_button_test.dart @@ -135,5 +135,27 @@ void main() { expect(accent, isNotNull); expect(accent, isNot(equals(green))); }); + + testWidgets( + 'on MostroActionAborted: no success checkmark, no error report, ' + 'button immediately re-enabled', (tester) async { + Object? reportedError; + await _pump( + tester, + onPressed: () async => throw const MostroActionAborted(), + onError: (e) => reportedError = e, + ); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(reportedError, isNull); + expect(find.byIcon(Icons.check), findsNothing); + expect(find.text('Do it'), findsOneWidget); + + final afterAbort = + tester.widget(find.byType(FilledButton)); + expect(afterAbort.onPressed, isNotNull); + }); }); } From 6341691b643c150f1369919b99381e6572d51257 Mon Sep 17 00:00:00 2001 From: bracr10 Date: Tue, 21 Jul 2026 19:24:22 -0600 Subject: [PATCH 5/6] fix(trades): address non-blocking review round 2 feedback Localization - cancelTradeButton duplicated cancelOrderButton in English while diverging in German, since both meant Cancel order. Reworded it to Cancel trade in all 5 locales, matching cancelTradeDialogTitle's existing wording for the same flow. - French shareOrderButton used commande, the retail register, while the screen itself uses ordre for order-book concepts. Changed to Partager l'ordre. - Wired the two remaining hardcoded English CTAs, View dispute and Pay hold invoice, to l10n. View dispute reuses the previously orphaned viewDisputeButtonLabel key, recased from all caps to match the shown text. Pay hold invoice is a new key across all 5 locales. MostroReactiveButton - Restored the Icons.lock_open icon on the seller release CTA, lost without mention when it moved off the old popup menu. - The removed error styling left screen readers with no signal at all during the 4s cooldown. Add a Semantics liveRegion announcement, localized, scoped to the cooldown state only. - Renamed _ButtonState.error to cooldown, since it now renders identically to idle and the old name was misleading. - Extracted the success display and error cooldown durations into named constants. Error reporting convention - markFiatSentButton was the only handler using the onError callback; the rest show their own SnackBar and rethrow. Migrated it to the same pattern via a new _markFiatSent method, so one convention applies across the whole switch. Overflow menu - Restored a typed _OverflowAction enum instead of a magic PopupMenuButton with value 0, matching the enum this PR had removed. Dropped the redundant context parameter and duplicate AppLocalizations.of(context) calls. Tests - The Share order popup test invoked onSelected directly, which cannot catch a wrong PopupMenuItem value. Switched to a real tap; needed a fully settled opening animation first, or the tap misses the item's actual position. - Added coverage for the three rethrows in cancel, open dispute, and release: with no RustLib.init() in this harness, the bridge calls fail for real, exercising the actual SnackBar and rethrow path. - mostro_reactive_button_test.dart's MaterialApp had no localization delegates, needed once the widget started reading AppLocalizations. --- .../trades/screens/trade_detail_screen.dart | 48 ++++--- lib/l10n/app_de.arb | 6 +- lib/l10n/app_en.arb | 8 +- lib/l10n/app_es.arb | 6 +- lib/l10n/app_fr.arb | 8 +- lib/l10n/app_it.arb | 6 +- lib/l10n/app_localizations.dart | 16 ++- lib/l10n/app_localizations_de.dart | 10 +- lib/l10n/app_localizations_en.dart | 10 +- lib/l10n/app_localizations_es.dart | 10 +- lib/l10n/app_localizations_fr.dart | 12 +- lib/l10n/app_localizations_it.dart | 10 +- .../widgets/mostro_reactive_button.dart | 75 ++++++---- .../trades/trade_detail_screen_test.dart | 132 +++++++++++++++--- .../widgets/mostro_reactive_button_test.dart | 9 ++ 15 files changed, 274 insertions(+), 92 deletions(-) diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 8573c0db..49314ff1 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -65,6 +65,9 @@ enum TradeStatus { final String label; } +/// Overflow-menu actions (currently just sharing the order). +enum _OverflowAction { shareOrder } + class _TradeDetailScreenState extends ConsumerState { Timer? _countdownTimer; Duration _remaining = const Duration(seconds: _kCountdownSeconds); @@ -184,6 +187,19 @@ class _TradeDetailScreenState extends ConsumerState { } } + Future _markFiatSent() async { + try { + await orders_api.sendFiatSent(orderId: widget.orderId); + } catch (e, st) { + debugPrint('[TradeDetailScreen] sendFiatSent error: $e\n$st'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).fiatSentFailed)), + ); + rethrow; + } + } + /// Open a dispute for this trade, upsert into the local dispute notifier, /// and navigate to the dispute chat. Future _openDispute() async { @@ -456,7 +472,7 @@ class _TradeDetailScreenState extends ConsumerState { onPressed: () => context.canPop() ? context.pop() : context.go(AppRoute.home), ), - actions: [_buildOverflowMenu(context)], + actions: [_buildOverflowMenu()], ), body: ListView( padding: const EdgeInsets.all(AppSpacing.lg), @@ -537,11 +553,11 @@ class _TradeDetailScreenState extends ConsumerState { /// Unconditional `⋮` menu — sharing an order is always a valid action, /// unlike the status-gated Cancel/Dispute/Release row below. - Widget _buildOverflowMenu(BuildContext context) { - return PopupMenuButton( + Widget _buildOverflowMenu() { + final l10n = AppLocalizations.of(context); + return PopupMenuButton<_OverflowAction>( icon: const Icon(Icons.more_vert), onSelected: (_) { - final l10n = AppLocalizations.of(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(l10n.comingSoonMessage), @@ -549,13 +565,13 @@ class _TradeDetailScreenState extends ConsumerState { ), ); }, - itemBuilder: (ctx) => [ + itemBuilder: (_) => [ PopupMenuItem( - value: 0, + value: _OverflowAction.shareOrder, child: ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.share, size: 18), - title: Text(AppLocalizations.of(context).shareOrderButton), + title: Text(l10n.shareOrderButton), dense: true, ), ), @@ -823,7 +839,7 @@ class _TradeDetailScreenState extends ConsumerState { case (TradeStatus.waitingPayment, false): return [ bigButton( - label: 'Pay hold invoice', + label: l10n.payHoldInvoiceButton, icon: Icons.bolt, onPressed: () => context.push(AppRoute.payInvoicePath(widget.orderId)), @@ -834,31 +850,21 @@ class _TradeDetailScreenState extends ConsumerState { MostroReactiveButton( label: l10n.markFiatSentButton, icon: Icons.check, - onPressed: () async { - await orders_api.sendFiatSent(orderId: widget.orderId); - }, - onError: (e) { - debugPrint('[TradeDetailScreen] sendFiatSent onError: $e'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text(AppLocalizations.of(context).fiatSentFailed)), - ); - }, + onPressed: _markFiatSent, ), ]; case (TradeStatus.fiatSent, false): return [ MostroReactiveButton( label: l10n.confirmReleaseSatsButton, + icon: Icons.lock_open, onPressed: _releaseOrder, ), ]; case (TradeStatus.disputed, _): return [ bigButton( - label: 'View dispute', + label: l10n.viewDisputeButtonLabel, icon: Icons.gavel, background: red, foreground: Colors.white, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 39f7fc7e..aa0115e9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -5,6 +5,7 @@ "appName": "Mostro", "loading": "Laden…", "error": "Fehler", + "actionFailedAnnouncement": "Aktion fehlgeschlagen", "cancel": "Abbrechen", "confirm": "Bestätigen", "done": "Fertig", @@ -80,7 +81,7 @@ "disputeButtonLabel": "STREITFALL", "contactButtonLabel": "KONTAKT", "rateButtonLabel": "BEWERTEN", - "viewDisputeButtonLabel": "STREITFALL ANZEIGEN", + "viewDisputeButtonLabel": "Streitfall anzeigen", "comingSoonMessage": "Demnächst verfügbar", "tradeStatusActive": "Aktiv", "tradeStatusFiatSent": "Fiat gesendet", @@ -211,7 +212,8 @@ "cancelRequestFailed": "Abbrechen fehlgeschlagen. Bitte erneut versuchen.", "fiatSentFailed": "Fiat-Zahlung konnte nicht bestätigt werden. Bitte erneut versuchen.", "releaseFailed": "Freigabe fehlgeschlagen. Bitte erneut versuchen.", - "cancelTradeButton": "Bestellung stornieren", + "cancelTradeButton": "Handel abbrechen", + "payHoldInvoiceButton": "Hold-Rechnung bezahlen", "openDisputeButton": "Streitfall eröffnen", "releaseSatsButton": "Sats freigeben", "markFiatSentButton": "Als gesendet markieren", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c861163d..94dcc47e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8,6 +8,8 @@ "@loading": {"description": "Generic loading label"}, "error": "Error", "@error": {"description": "Generic error label"}, + "actionFailedAnnouncement": "Action failed", + "@actionFailedAnnouncement": {"description": "Screen-reader-only announcement when a button action fails and the button enters its cooldown state"}, "cancel": "Cancel", "@cancel": {"description": "Cancel action"}, "confirm": "Confirm", @@ -179,7 +181,7 @@ "@contactButtonLabel": {"description": "Button label to open the trade chat"}, "rateButtonLabel": "RATE", "@rateButtonLabel": {"description": "Button label to rate the trading counterpart"}, - "viewDisputeButtonLabel": "VIEW DISPUTE", + "viewDisputeButtonLabel": "View dispute", "@viewDisputeButtonLabel": {"description": "Button label to view an active dispute"}, "comingSoonMessage": "Coming soon", "@comingSoonMessage": {"description": "Generic coming-soon placeholder message"}, @@ -445,8 +447,10 @@ "@fiatSentFailed": {"description": "Snackbar shown when the fiat-sent action fails"}, "releaseFailed": "Failed to release. Please try again.", "@releaseFailed": {"description": "Snackbar shown when the release-sats action fails"}, - "cancelTradeButton": "Cancel order", + "cancelTradeButton": "Cancel trade", "@cancelTradeButton": {"description": "Button label to cancel an in-progress trade (secondary action row)"}, + "payHoldInvoiceButton": "Pay hold invoice", + "@payHoldInvoiceButton": {"description": "Primary CTA for the seller to open the pay hold invoice screen"}, "openDisputeButton": "Open dispute", "@openDisputeButton": {"description": "Button label to open a dispute on an in-progress trade"}, "releaseSatsButton": "Release sats", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 103d558e..696fb47f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -5,6 +5,7 @@ "appName": "Mostro", "loading": "Cargando…", "error": "Error", + "actionFailedAnnouncement": "Acción fallida", "cancel": "Cancelar", "confirm": "Confirmar", "done": "Listo", @@ -80,7 +81,7 @@ "disputeButtonLabel": "DISPUTAR", "contactButtonLabel": "CONTACTAR", "rateButtonLabel": "VALORAR", - "viewDisputeButtonLabel": "VER DISPUTA", + "viewDisputeButtonLabel": "Ver disputa", "comingSoonMessage": "Próximamente", "tradeStatusActive": "Activo", "tradeStatusFiatSent": "Fiat enviado", @@ -211,7 +212,8 @@ "cancelRequestFailed": "No se pudo cancelar. Por favor, inténtelo de nuevo.", "fiatSentFailed": "Error al marcar el fiat como enviado. Por favor, inténtelo de nuevo.", "releaseFailed": "Error al liberar. Por favor, inténtelo de nuevo.", - "cancelTradeButton": "Cancelar orden", + "cancelTradeButton": "Cancelar intercambio", + "payHoldInvoiceButton": "Pagar factura hold", "openDisputeButton": "Abrir disputa", "releaseSatsButton": "Liberar sats", "markFiatSentButton": "Marcar fiat enviado", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 47aa1d32..44249c81 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -5,6 +5,7 @@ "appName": "Mostro", "loading": "Chargement…", "error": "Erreur", + "actionFailedAnnouncement": "Action échouée", "cancel": "Annuler", "confirm": "Confirmer", "done": "Terminé", @@ -80,7 +81,7 @@ "disputeButtonLabel": "LITIGE", "contactButtonLabel": "CONTACTER", "rateButtonLabel": "NOTER", - "viewDisputeButtonLabel": "VOIR LE LITIGE", + "viewDisputeButtonLabel": "Voir le litige", "comingSoonMessage": "Bientôt disponible", "tradeStatusActive": "Actif", "tradeStatusFiatSent": "Fiat envoyé", @@ -211,12 +212,13 @@ "cancelRequestFailed": "Échec de l'annulation. Veuillez réessayer.", "fiatSentFailed": "Échec de la confirmation du paiement fiat. Veuillez réessayer.", "releaseFailed": "Échec de la libération. Veuillez réessayer.", - "cancelTradeButton": "Annuler la commande", + "cancelTradeButton": "Annuler l'échange", + "payHoldInvoiceButton": "Payer la facture hold", "openDisputeButton": "Ouvrir un litige", "releaseSatsButton": "Libérer les sats", "markFiatSentButton": "Marquer comme envoyé", "confirmReleaseSatsButton": "Confirmer et libérer les sats", - "shareOrderButton": "Partager la commande", + "shareOrderButton": "Partager l'ordre", "orderPillYouAreSelling": "VOUS VENDEZ", "orderPillYouAreBuying": "VOUS ACHETEZ", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ab51bebf..bd39986d 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -5,6 +5,7 @@ "appName": "Mostro", "loading": "Caricamento…", "error": "Errore", + "actionFailedAnnouncement": "Azione non riuscita", "cancel": "Annulla", "confirm": "Conferma", "done": "Fine", @@ -80,7 +81,7 @@ "disputeButtonLabel": "DISPUTA", "contactButtonLabel": "CONTATTA", "rateButtonLabel": "VALUTA", - "viewDisputeButtonLabel": "VEDI DISPUTA", + "viewDisputeButtonLabel": "Vedi disputa", "comingSoonMessage": "Prossimamente", "tradeStatusActive": "Attivo", "tradeStatusFiatSent": "Fiat inviato", @@ -211,7 +212,8 @@ "cancelRequestFailed": "Annullamento fallito. Riprovare.", "fiatSentFailed": "Impossibile contrassegnare il fiat come inviato. Riprovare.", "releaseFailed": "Rilascio fallito. Riprovare.", - "cancelTradeButton": "Annulla ordine", + "cancelTradeButton": "Annulla scambio", + "payHoldInvoiceButton": "Paga fattura hold", "openDisputeButton": "Apri disputa", "releaseSatsButton": "Rilascia sats", "markFiatSentButton": "Segna come inviato", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6de337aa..54d2d9f2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -122,6 +122,12 @@ abstract class AppLocalizations { /// **'Error'** String get error; + /// Screen-reader-only announcement when a button action fails and the button enters its cooldown state + /// + /// In en, this message translates to: + /// **'Action failed'** + String get actionFailedAnnouncement; + /// Cancel action /// /// In en, this message translates to: @@ -539,7 +545,7 @@ abstract class AppLocalizations { /// Button label to view an active dispute /// /// In en, this message translates to: - /// **'VIEW DISPUTE'** + /// **'View dispute'** String get viewDisputeButtonLabel; /// Generic coming-soon placeholder message @@ -1277,9 +1283,15 @@ abstract class AppLocalizations { /// Button label to cancel an in-progress trade (secondary action row) /// /// In en, this message translates to: - /// **'Cancel order'** + /// **'Cancel trade'** String get cancelTradeButton; + /// Primary CTA for the seller to open the pay hold invoice screen + /// + /// In en, this message translates to: + /// **'Pay hold invoice'** + String get payHoldInvoiceButton; + /// Button label to open a dispute on an in-progress trade /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e897e57b..48dc6fbb 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -17,6 +17,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get error => 'Fehler'; + @override + String get actionFailedAnnouncement => 'Aktion fehlgeschlagen'; + @override String get cancel => 'Abbrechen'; @@ -264,7 +267,7 @@ class AppLocalizationsDe extends AppLocalizations { String get rateButtonLabel => 'BEWERTEN'; @override - String get viewDisputeButtonLabel => 'STREITFALL ANZEIGEN'; + String get viewDisputeButtonLabel => 'Streitfall anzeigen'; @override String get comingSoonMessage => 'Demnächst verfügbar'; @@ -653,7 +656,10 @@ class AppLocalizationsDe extends AppLocalizations { 'Freigabe fehlgeschlagen. Bitte erneut versuchen.'; @override - String get cancelTradeButton => 'Bestellung stornieren'; + String get cancelTradeButton => 'Handel abbrechen'; + + @override + String get payHoldInvoiceButton => 'Hold-Rechnung bezahlen'; @override String get openDisputeButton => 'Streitfall eröffnen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9c922ae7..4b4f6971 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -17,6 +17,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get error => 'Error'; + @override + String get actionFailedAnnouncement => 'Action failed'; + @override String get cancel => 'Cancel'; @@ -261,7 +264,7 @@ class AppLocalizationsEn extends AppLocalizations { String get rateButtonLabel => 'RATE'; @override - String get viewDisputeButtonLabel => 'VIEW DISPUTE'; + String get viewDisputeButtonLabel => 'View dispute'; @override String get comingSoonMessage => 'Coming soon'; @@ -642,7 +645,10 @@ class AppLocalizationsEn extends AppLocalizations { String get releaseFailed => 'Failed to release. Please try again.'; @override - String get cancelTradeButton => 'Cancel order'; + String get cancelTradeButton => 'Cancel trade'; + + @override + String get payHoldInvoiceButton => 'Pay hold invoice'; @override String get openDisputeButton => 'Open dispute'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 6fb78693..fcb9d34a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -17,6 +17,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get error => 'Error'; + @override + String get actionFailedAnnouncement => 'Acción fallida'; + @override String get cancel => 'Cancelar'; @@ -264,7 +267,7 @@ class AppLocalizationsEs extends AppLocalizations { String get rateButtonLabel => 'VALORAR'; @override - String get viewDisputeButtonLabel => 'VER DISPUTA'; + String get viewDisputeButtonLabel => 'Ver disputa'; @override String get comingSoonMessage => 'Próximamente'; @@ -653,7 +656,10 @@ class AppLocalizationsEs extends AppLocalizations { 'Error al liberar. Por favor, inténtelo de nuevo.'; @override - String get cancelTradeButton => 'Cancelar orden'; + String get cancelTradeButton => 'Cancelar intercambio'; + + @override + String get payHoldInvoiceButton => 'Pagar factura hold'; @override String get openDisputeButton => 'Abrir disputa'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index acf72feb..f1d81074 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -17,6 +17,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get error => 'Erreur'; + @override + String get actionFailedAnnouncement => 'Action échouée'; + @override String get cancel => 'Annuler'; @@ -266,7 +269,7 @@ class AppLocalizationsFr extends AppLocalizations { String get rateButtonLabel => 'NOTER'; @override - String get viewDisputeButtonLabel => 'VOIR LE LITIGE'; + String get viewDisputeButtonLabel => 'Voir le litige'; @override String get comingSoonMessage => 'Bientôt disponible'; @@ -656,7 +659,10 @@ class AppLocalizationsFr extends AppLocalizations { String get releaseFailed => 'Échec de la libération. Veuillez réessayer.'; @override - String get cancelTradeButton => 'Annuler la commande'; + String get cancelTradeButton => 'Annuler l\'échange'; + + @override + String get payHoldInvoiceButton => 'Payer la facture hold'; @override String get openDisputeButton => 'Ouvrir un litige'; @@ -671,7 +677,7 @@ class AppLocalizationsFr extends AppLocalizations { String get confirmReleaseSatsButton => 'Confirmer et libérer les sats'; @override - String get shareOrderButton => 'Partager la commande'; + String get shareOrderButton => 'Partager l\'ordre'; @override String get orderPillYouAreSelling => 'VOUS VENDEZ'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 6bbd71bb..0adb00f3 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -17,6 +17,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get error => 'Errore'; + @override + String get actionFailedAnnouncement => 'Azione non riuscita'; + @override String get cancel => 'Annulla'; @@ -264,7 +267,7 @@ class AppLocalizationsIt extends AppLocalizations { String get rateButtonLabel => 'VALUTA'; @override - String get viewDisputeButtonLabel => 'VEDI DISPUTA'; + String get viewDisputeButtonLabel => 'Vedi disputa'; @override String get comingSoonMessage => 'Prossimamente'; @@ -651,7 +654,10 @@ class AppLocalizationsIt extends AppLocalizations { String get releaseFailed => 'Rilascio fallito. Riprovare.'; @override - String get cancelTradeButton => 'Annulla ordine'; + String get cancelTradeButton => 'Annulla scambio'; + + @override + String get payHoldInvoiceButton => 'Paga fattura hold'; @override String get openDisputeButton => 'Apri disputa'; diff --git a/lib/shared/widgets/mostro_reactive_button.dart b/lib/shared/widgets/mostro_reactive_button.dart index af1b7dec..5c75d8c7 100644 --- a/lib/shared/widgets/mostro_reactive_button.dart +++ b/lib/shared/widgets/mostro_reactive_button.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/l10n/app_localizations.dart'; enum MostroButtonVariant { primary, destructive } @@ -34,11 +35,17 @@ class MostroReactiveButton extends StatefulWidget { State createState() => _MostroReactiveButtonState(); } -enum _ButtonState { idle, loading, success, error } +enum _ButtonState { idle, loading, success, cooldown } class _MostroReactiveButtonState extends State { _ButtonState _state = _ButtonState.idle; + static const _kSuccessDisplay = Duration(milliseconds: 1500); + + /// Matches SnackBar's default display duration, so the button re-enables + /// as the failure message disappears. + static const _kErrorCooldown = Duration(seconds: 4); + Future _handlePress() async { if (_state != _ButtonState.idle) return; setState(() => _state = _ButtonState.loading); @@ -48,16 +55,16 @@ class _MostroReactiveButtonState extends State { if (!mounted) return; setState(() => _state = _ButtonState.success); - await Future.delayed(const Duration(milliseconds: 1500)); + await Future.delayed(_kSuccessDisplay); if (mounted) setState(() => _state = _ButtonState.idle); } on MostroActionAborted { if (mounted) setState(() => _state = _ButtonState.idle); } catch (e) { widget.onError?.call(e); if (!mounted) return; - setState(() => _state = _ButtonState.error); + setState(() => _state = _ButtonState.cooldown); - await Future.delayed(const Duration(seconds: 4)); + await Future.delayed(_kErrorCooldown); if (mounted) setState(() => _state = _ButtonState.idle); } } @@ -119,30 +126,44 @@ class _MostroReactiveButtonState extends State { liveRegion: true, child: const Icon(Icons.check, size: 20), ); - case _ButtonState.idle || _ButtonState.error: - if (widget.icon != null) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(widget.icon, size: 18), - const SizedBox(width: AppSpacing.sm), - Flexible( - child: Text( - widget.label, - maxLines: 2, - textAlign: TextAlign.center, - softWrap: true, - ), - ), - ], - ); - } - return Text( - widget.label, - maxLines: 2, - textAlign: TextAlign.center, - softWrap: true, + case _ButtonState.idle: + return _buildLabel(); + case _ButtonState.cooldown: + // No visual error styling — the SnackBar already reported the + // failure. This announcement keeps the state change perceivable to + // screen-reader users, who would otherwise get no signal at all + // while the button sits disabled for the cooldown. + return Semantics( + liveRegion: true, + label: AppLocalizations.of(context).actionFailedAnnouncement, + child: _buildLabel(), ); } } + + Widget _buildLabel() { + if (widget.icon != null) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, size: 18), + const SizedBox(width: AppSpacing.sm), + Flexible( + child: Text( + widget.label, + maxLines: 2, + textAlign: TextAlign.center, + softWrap: true, + ), + ), + ], + ); + } + return Text( + widget.label, + maxLines: 2, + textAlign: TextAlign.center, + softWrap: true, + ); + } } diff --git a/test/features/trades/trade_detail_screen_test.dart b/test/features/trades/trade_detail_screen_test.dart index fd9220b7..b4616331 100644 --- a/test/features/trades/trade_detail_screen_test.dart +++ b/test/features/trades/trade_detail_screen_test.dart @@ -89,7 +89,7 @@ void main() { ); expect(find.text('Mark fiat sent'), findsOneWidget); - expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Cancel trade'), findsOneWidget); expect(_outlinedButtonWithText('Open dispute'), findsOneWidget); expect(_outlinedButtonWithText('Release sats'), findsNothing); expect(_anyPopupMenuButton(), findsOneWidget); @@ -104,7 +104,7 @@ void main() { status: OrderStatus.fiatSent, ); - expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Cancel trade'), findsOneWidget); expect(_outlinedButtonWithText('Open dispute'), findsOneWidget); expect(_outlinedButtonWithText('Release sats'), findsNothing); expect(_anyPopupMenuButton(), findsOneWidget); @@ -119,7 +119,7 @@ void main() { status: OrderStatus.active, ); - expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Cancel trade'), findsOneWidget); expect(_outlinedButtonWithText('Open dispute'), findsOneWidget); expect(_outlinedButtonWithText('Release sats'), findsNothing); expect(_anyPopupMenuButton(), findsOneWidget); @@ -136,7 +136,7 @@ void main() { ); expect(find.text('Confirm & release sats'), findsOneWidget); - expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Cancel trade'), findsOneWidget); expect(_outlinedButtonWithText('Open dispute'), findsOneWidget); expect(_outlinedButtonWithText('Release sats'), findsNothing); expect(_anyPopupMenuButton(), findsOneWidget); @@ -154,7 +154,7 @@ void main() { expect(find.text('View dispute'), findsOneWidget); expect(_outlinedButtonWithText('Release sats'), findsOneWidget); - expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Cancel trade'), findsOneWidget); // canDispute is false once already disputed — no "Open dispute" button. expect(_outlinedButtonWithText('Open dispute'), findsNothing); expect(_anyPopupMenuButton(), findsOneWidget); @@ -173,7 +173,7 @@ void main() { // all false for buyer + disputed — see gating logic in // trade_detail_screen.dart (`_buildSecondaryActionRow`). expect(_outlinedButtonWithText('Release sats'), findsNothing); - expect(_outlinedButtonWithText('Cancel order'), findsNothing); + expect(_outlinedButtonWithText('Cancel trade'), findsNothing); expect(_outlinedButtonWithText('Open dispute'), findsNothing); expect(_anyPopupMenuButton(), findsOneWidget); }); @@ -193,34 +193,126 @@ void main() { // Secondary row is visible for this status/role, with its own // Cancel/Dispute buttons — the menu must not duplicate them. - expect(_outlinedButtonWithText('Cancel order'), findsOneWidget); + expect(_outlinedButtonWithText('Cancel trade'), findsOneWidget); expect(_outlinedButtonWithText('Open dispute'), findsOneWidget); expect(_anyPopupMenuItem(), findsNothing); await tester.tap(find.byIcon(Icons.more_vert)); - // The popup menu's opening route animates in — a zero-duration pump() - // leaves it mid-transition. pumpAndSettle() is unsafe here: the screen's - // 1s countdown Timer.periodic keeps scheduling frames for its full - // 15-minute duration, so it never reports "settled". + // The popup menu's opening route animates in — pumpAndSettle() is + // unsafe here: the screen's 1s countdown Timer.periodic keeps + // scheduling frames for its full 15-minute duration, so it never + // reports "settled". Two pumps let the open transition fully finish; + // tapping mid-transition hits the wrong on-screen position and misses + // the item. + await tester.pump(const Duration(milliseconds: 350)); await tester.pump(const Duration(milliseconds: 350)); expect(_anyPopupMenuItem(), findsOneWidget); expect(find.text('Share order'), findsOneWidget); - // Selecting the item via a real tap gesture is timing-fragile in a - // widget test (the popup's own closing-route animation delays when - // `onSelected` actually fires). Invoke the already-wired callback - // directly instead — this still exercises the real selection → SnackBar - // logic without depending on that animation's exact timing. - final popupButton = - tester.widget>(find.byType(PopupMenuButton)); - popupButton.onSelected!(0); - await tester.pump(); + // A real tap gesture exercises the actual value wired to onSelected, + // catching a wrong PopupMenuItem value that a direct callback + // invocation would not — `_OverflowAction` is private to the screen, + // so the test cannot construct one to invoke onSelected directly + // anyway. Two more pumps: one for the closing-route animation onSelected + // waits on, one for the SnackBar's own entrance animation. + await tester.tap(_anyPopupMenuItem()); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pump(const Duration(milliseconds: 350)); expect(find.text('Coming soon'), findsOneWidget); }); }); + group('TradeDetailScreen secondary action failures propagate to the button', + () { + // No RustLib.init() in this harness (see _pumpTradeDetail's doc comment), + // so every orders_api / disputes_api call below fails for real — + // exercising the actual rethrow path instead of a mocked one. + testWidgets( + 'cancel: bridge failure shows the SnackBar and does not crash', + (tester) async { + await _pumpTradeDetail( + tester, + orderId: 'order-9', + isBuyer: true, + status: OrderStatus.active, + ); + + await tester.tap(_outlinedButtonWithText('Cancel trade')); + await tester.pump(); + + expect(find.text('Yes, cancel'), findsOneWidget); + await tester.tap(find.text('Yes, cancel')); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect( + find.text('Failed to cancel. Please try again.'), + findsOneWidget, + ); + + // Flush the button's own 4s error cooldown timer so it does not + // outlive this test. + await tester.pump(const Duration(seconds: 4)); + }); + + testWidgets( + 'open dispute: bridge failure shows the SnackBar and does not crash', + (tester) async { + await _pumpTradeDetail( + tester, + orderId: 'order-10', + isBuyer: true, + status: OrderStatus.active, + ); + + await tester.tap(_outlinedButtonWithText('Open dispute')); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect( + find.text('Could not open dispute. Please try again.'), + findsOneWidget, + ); + + // Flush the button's own 4s error cooldown timer so it does not + // outlive this test. + await tester.pump(const Duration(seconds: 4)); + }); + + testWidgets( + 'release: bridge failure shows the SnackBar and does not crash', + (tester) async { + await _pumpTradeDetail( + tester, + orderId: 'order-11', + isBuyer: false, + status: OrderStatus.fiatSent, + ); + + await tester.tap(find.text('Confirm & release sats')); + await tester.pump(); + + expect(find.text('Yes'), findsOneWidget); + await tester.tap(find.text('Yes')); + await tester.pump(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect( + find.text('Failed to release. Please try again.'), + findsOneWidget, + ); + + // Flush the button's own 4s error cooldown timer so it does not + // outlive this test. + await tester.pump(const Duration(seconds: 4)); + }); + }); + group('TradeDetailScreen secondary action row layout', () { testWidgets( 'German labels on a 320dp width do not overflow the secondary row', diff --git a/test/shared/widgets/mostro_reactive_button_test.dart b/test/shared/widgets/mostro_reactive_button_test.dart index cbe9810e..4db7e02a 100644 --- a/test/shared/widgets/mostro_reactive_button_test.dart +++ b/test/shared/widgets/mostro_reactive_button_test.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/l10n/app_localizations.dart'; import 'package:mostro/shared/widgets/mostro_reactive_button.dart'; Future _pump( @@ -15,6 +17,13 @@ Future _pump( await tester.pumpWidget( MaterialApp( theme: buildDarkTheme(), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: MostroReactiveButton( label: 'Do it', From b36281fb1f532639efc8f16928240d8d462f656e Mon Sep 17 00:00:00 2001 From: bracr10 Date: Tue, 21 Jul 2026 20:35:33 -0600 Subject: [PATCH 6/6] fix(trades): localize the release confirmation dialog buttons The Yes/No buttons in ReleaseConfirmationDialog were hardcoded English, the one confirmation dialog in this flow that was not going through AppLocalizations. Added yesButtonLabel across all 5 locales and reused the existing noButtonLabel. The test asserted and tapped find.text('Yes') directly. Switched it to read AppLocalizationsEn().yesButtonLabel, so it verifies the actual localized label instead of coincidentally matching a hardcoded string. --- .../trades/widgets/release_confirmation_dialog.dart | 6 ++++-- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_localizations.dart | 6 ++++++ lib/l10n/app_localizations_de.dart | 3 +++ lib/l10n/app_localizations_en.dart | 3 +++ lib/l10n/app_localizations_es.dart | 3 +++ lib/l10n/app_localizations_fr.dart | 3 +++ lib/l10n/app_localizations_it.dart | 3 +++ test/features/trades/trade_detail_screen_test.dart | 6 ++++-- 13 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/features/trades/widgets/release_confirmation_dialog.dart b/lib/features/trades/widgets/release_confirmation_dialog.dart index 08669fe5..18f3e9be 100644 --- a/lib/features/trades/widgets/release_confirmation_dialog.dart +++ b/lib/features/trades/widgets/release_confirmation_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/l10n/app_localizations.dart'; /// Shows the release confirmation dialog. /// @@ -21,6 +22,7 @@ class _ReleaseConfirmationDialog extends StatelessWidget { final colors = Theme.of(context).extension(); final green = colors?.mostroGreen ?? const Color(0xFF8CC63F); final cardBg = colors?.backgroundCard ?? const Color(0xFF1E2230); + final l10n = AppLocalizations.of(context); return Dialog( backgroundColor: cardBg, @@ -64,7 +66,7 @@ class _ReleaseConfirmationDialog extends StatelessWidget { borderRadius: BorderRadius.circular(AppRadius.button), ), ), - child: const Text('No'), + child: Text(l10n.noButtonLabel), ), ), const SizedBox(width: AppSpacing.md), @@ -79,7 +81,7 @@ class _ReleaseConfirmationDialog extends StatelessWidget { borderRadius: BorderRadius.circular(AppRadius.button), ), ), - child: const Text('Yes'), + child: Text(l10n.yesButtonLabel), ), ), ], diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index aa0115e9..9467b395 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -207,6 +207,7 @@ "cancelTradeDialogTitle": "Handel abbrechen?", "cancelTradeDialogContent": "Kooperativen Abbruch angefragt. Die andere Partei muss ebenfalls zustimmen, damit der Handel vollständig abgebrochen wird.", "noButtonLabel": "Nein", + "yesButtonLabel": "Ja", "yesCancelButtonLabel": "Ja, abbrechen", "cancelRequestSent": "Abbruchanfrage gesendet", "cancelRequestFailed": "Abbrechen fehlgeschlagen. Bitte erneut versuchen.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 94dcc47e..00b40450 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -436,6 +436,7 @@ "cancelTradeDialogContent": "Requesting a cooperative cancel. The other party must also agree for the trade to be fully cancelled.", "@cancelTradeDialogContent": {"description": "Body text for the cancel-trade confirmation dialog"}, "noButtonLabel": "No", + "yesButtonLabel": "Yes", "@noButtonLabel": {"description": "Negative button label in a confirmation dialog"}, "yesCancelButtonLabel": "Yes, cancel", "@yesCancelButtonLabel": {"description": "Affirmative cancel button label in the cancel-trade dialog"}, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 696fb47f..4e86ebe3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -207,6 +207,7 @@ "cancelTradeDialogTitle": "¿Cancelar intercambio?", "cancelTradeDialogContent": "Se solicita una cancelación cooperativa. La otra parte también debe aceptar para que el intercambio quede cancelado.", "noButtonLabel": "No", + "yesButtonLabel": "Sí", "yesCancelButtonLabel": "Sí, cancelar", "cancelRequestSent": "Solicitud de cancelación enviada", "cancelRequestFailed": "No se pudo cancelar. Por favor, inténtelo de nuevo.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 44249c81..7b88b4d8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -207,6 +207,7 @@ "cancelTradeDialogTitle": "Annuler l'échange ?", "cancelTradeDialogContent": "Annulation coopérative demandée. L'autre partie doit également accepter pour que l'échange soit entièrement annulé.", "noButtonLabel": "Non", + "yesButtonLabel": "Oui", "yesCancelButtonLabel": "Oui, annuler", "cancelRequestSent": "Demande d'annulation envoyée", "cancelRequestFailed": "Échec de l'annulation. Veuillez réessayer.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index bd39986d..c82dbd3c 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -207,6 +207,7 @@ "cancelTradeDialogTitle": "Annullare lo scambio?", "cancelTradeDialogContent": "Annullamento cooperativo richiesto. Anche l'altra parte deve accettare affinché lo scambio venga annullato.", "noButtonLabel": "No", + "yesButtonLabel": "Sì", "yesCancelButtonLabel": "Sì, annulla", "cancelRequestSent": "Richiesta di annullamento inviata", "cancelRequestFailed": "Annullamento fallito. Riprovare.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 54d2d9f2..4757f313 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1250,6 +1250,12 @@ abstract class AppLocalizations { /// **'No'** String get noButtonLabel; + /// No description provided for @yesButtonLabel. + /// + /// In en, this message translates to: + /// **'Yes'** + String get yesButtonLabel; + /// Affirmative cancel button label in the cancel-trade dialog /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 48dc6fbb..f24c618a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -637,6 +637,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get noButtonLabel => 'Nein'; + @override + String get yesButtonLabel => 'Ja'; + @override String get yesCancelButtonLabel => 'Ja, abbrechen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4b4f6971..6cd4d23c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -629,6 +629,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noButtonLabel => 'No'; + @override + String get yesButtonLabel => 'Yes'; + @override String get yesCancelButtonLabel => 'Yes, cancel'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index fcb9d34a..05ca922e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -637,6 +637,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get noButtonLabel => 'No'; + @override + String get yesButtonLabel => 'Sí'; + @override String get yesCancelButtonLabel => 'Sí, cancelar'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f1d81074..97278ed9 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -641,6 +641,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get noButtonLabel => 'Non'; + @override + String get yesButtonLabel => 'Oui'; + @override String get yesCancelButtonLabel => 'Oui, annuler'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 0adb00f3..4cc6c431 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -637,6 +637,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get noButtonLabel => 'No'; + @override + String get yesButtonLabel => 'Sì'; + @override String get yesCancelButtonLabel => 'Sì, annulla'; diff --git a/test/features/trades/trade_detail_screen_test.dart b/test/features/trades/trade_detail_screen_test.dart index b4616331..130323ee 100644 --- a/test/features/trades/trade_detail_screen_test.dart +++ b/test/features/trades/trade_detail_screen_test.dart @@ -7,6 +7,7 @@ import 'package:mostro/features/home/providers/home_order_providers.dart'; import 'package:mostro/features/order/providers/trade_state_provider.dart'; import 'package:mostro/features/trades/screens/trade_detail_screen.dart'; import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/l10n/app_localizations_en.dart'; import '../../support/provider_harness.dart'; @@ -296,8 +297,9 @@ void main() { await tester.tap(find.text('Confirm & release sats')); await tester.pump(); - expect(find.text('Yes'), findsOneWidget); - await tester.tap(find.text('Yes')); + final confirmLabel = AppLocalizationsEn().yesButtonLabel; + expect(find.text(confirmLabel), findsOneWidget); + await tester.tap(find.text(confirmLabel)); await tester.pump(); await tester.pump();