diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index b1b00704..9651df43 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -82,8 +82,8 @@ extension TradeStatusL10n on TradeStatus { }; } -/// Overflow-menu actions (cancel / dispute / release collapsed behind ⋮). -enum _MenuAction { cancel, dispute, release } +/// Overflow-menu actions (currently just sharing the order). +enum _OverflowAction { shareOrder } class _TradeDetailScreenState extends ConsumerState { Timer? _countdownTimer; @@ -183,7 +183,10 @@ class _TradeDetailScreenState extends ConsumerState { ], ), ); - if (confirmed != true || !mounted) return; + if (!mounted) return; + if (confirmed != true) { + throw const MostroActionAborted(); + } try { await orders_api.cancelOrder(orderId: widget.orderId); ref.invalidate(rawTradesProvider); @@ -197,6 +200,20 @@ class _TradeDetailScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.cancelRequestFailed)), ); + rethrow; + } + } + + 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; } } @@ -226,14 +243,17 @@ 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; + if (!mounted) return; + if (confirmed != true) { + throw const MostroActionAborted(); + } try { await orders_api.releaseOrder(orderId: widget.orderId); if (!mounted) return; @@ -248,6 +268,7 @@ class _TradeDetailScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context).releaseFailed)), ); + rethrow; } } @@ -460,7 +481,7 @@ class _TradeDetailScreenState extends ConsumerState { onPressed: () => context.canPop() ? context.pop() : context.go(AppRoute.home), ), - actions: [_buildOverflowMenu(status, isBuyer, colors)], + actions: [_buildOverflowMenu()], ), body: ListView( padding: const EdgeInsets.all(AppSpacing.lg), @@ -479,6 +500,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), @@ -532,13 +558,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() { final l10n = AppLocalizations.of(context); + return PopupMenuButton<_OverflowAction>( + icon: const Icon(Icons.more_vert), + onSelected: (_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.comingSoonMessage), + duration: const Duration(seconds: 2), + ), + ); + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: _OverflowAction.shareOrder, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.share, size: 18), + title: Text(l10n.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, @@ -552,49 +609,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) - PopupMenuItem( - value: _MenuAction.release, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.lock_open, size: 18), - title: Text(l10n.releaseSatsMenuItem), - dense: true, - ), - ), - if (canCancel) - PopupMenuItem( - value: _MenuAction.cancel, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.cancel_outlined, size: 18, color: red), - title: Text(l10n.cancelOrderMenuItem, 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(l10n.openDisputeMenuItem, 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 ────────────────────────────────────────────────────────── @@ -798,37 +859,16 @@ class _TradeDetailScreenState extends ConsumerState { return [ MostroReactiveButton( label: l10n.markFiatSentButton, - backgroundColor: green, 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, - backgroundColor: green, icon: Icons.lock_open, 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/l10n/app_de.arb b/lib/l10n/app_de.arb index 63aca7a8..3b8d0ce3 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", @@ -206,11 +207,19 @@ "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.", "fiatSentFailed": "Fiat-Zahlung konnte nicht bestätigt werden. Bitte erneut versuchen.", "releaseFailed": "Freigabe fehlgeschlagen. Bitte erneut versuchen.", + "cancelTradeButton": "Handel abbrechen", + "payHoldInvoiceButton": "Hold-Rechnung bezahlen", + "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", @@ -418,7 +427,6 @@ "couldNotLoadTradesMessage": "Trades konnten nicht geladen werden", "releaseBitcoinTitle": "Bitcoin freigeben", "releaseBitcoinConfirmation": "Möchtest du die Satoshis wirklich an den Käufer freigeben?", - "yesButtonLabel": "Ja", "sellingBitcoin": "Bitcoin verkaufen", "buyingBitcoin": "Bitcoin kaufen", "createdByYou": "Von dir erstellt", @@ -467,7 +475,7 @@ "tradeTimerActiveConsequence": "Bei Ablauf kann der Trade storniert werden. Stimmt euch im Chat ab, wenn mehr Zeit nötig ist.", "tradeTimerFiatSentLabelBuyer": "Zeit für den Verkäufer, den Empfang zu bestätigen", "tradeTimerFiatSentLabelSeller": "Zeit, den Empfang zu bestätigen und freizugeben", - "tradeTimerFiatSentConsequence": "Wenn etwas nicht stimmt, öffne einen Streitfall über das ⋮-Menü.", + "tradeTimerFiatSentConsequence": "Wenn etwas nicht stimmt, öffne einen Streitfall über die Schaltfläche unten.", "tradeStepOrderTaken": "Bestellung angenommen", "tradeStepInvoiceBuyer": "Du teilst eine Rechnung · der Verkäufer sperrt die Sats", "tradeStepInvoiceSeller": "Der Käufer teilt eine Rechnung · du sperrst die Sats", @@ -485,9 +493,6 @@ "stepDoneLabel": "FERTIG", "stepIndicator": "SCHRITT {current} VON {total}", "addLightningInvoiceButton": "Lightning-Rechnung hinzufügen", - "payHoldInvoiceButton": "Hold-Invoice bezahlen", - "markFiatSentButton": "Fiat als gesendet markieren", - "confirmReleaseSatsButton": "Bestätigen und Sats freigeben", "viewDisputeButton": "Streitfall ansehen", "waitingForBuyer": "Warte auf den Käufer…", "waitingForSeller": "Warte auf den Verkäufer…", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1949715f..d3b0e598 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"}, @@ -434,7 +436,9 @@ "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"}, + "@yesButtonLabel": {"description": "Generic Yes button label"}, "yesCancelButtonLabel": "Yes, cancel", "@yesCancelButtonLabel": {"description": "Affirmative cancel button label in the cancel-trade dialog"}, "cancelRequestSent": "Cancel request sent", @@ -445,6 +449,20 @@ "@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 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", + "@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"}, @@ -860,8 +878,6 @@ "@releaseBitcoinTitle": {"description": "Title of the release confirmation dialog"}, "releaseBitcoinConfirmation": "Are you sure you want to release the Satoshis to the buyer?", "@releaseBitcoinConfirmation": {"description": "Body of the release confirmation dialog"}, - "yesButtonLabel": "Yes", - "@yesButtonLabel": {"description": "Generic Yes button label"}, "sellingBitcoin": "Selling Bitcoin", "@sellingBitcoin": {"description": "Trade card title when the user is selling Bitcoin"}, "buyingBitcoin": "Buying Bitcoin", @@ -976,7 +992,7 @@ "@tradeTimerFiatSentLabelBuyer": {"description": "Timer label for buyer waiting for confirmation"}, "tradeTimerFiatSentLabelSeller": "Time to confirm receipt and release", "@tradeTimerFiatSentLabelSeller": {"description": "Timer label for seller to confirm and release"}, - "tradeTimerFiatSentConsequence": "If something looks wrong, open a dispute from the ⋮ menu.", + "tradeTimerFiatSentConsequence": "If something looks wrong, open a dispute using the button below.", "@tradeTimerFiatSentConsequence": {"description": "Timer consequence after fiat is marked sent"}, "tradeStepOrderTaken": "Order taken", "@tradeStepOrderTaken": {"description": "Timeline step: order taken"}, @@ -1021,12 +1037,6 @@ }, "addLightningInvoiceButton": "Add Lightning invoice", "@addLightningInvoiceButton": {"description": "Primary CTA: add Lightning invoice"}, - "payHoldInvoiceButton": "Pay hold invoice", - "@payHoldInvoiceButton": {"description": "Primary CTA: pay hold invoice"}, - "markFiatSentButton": "Mark fiat sent", - "@markFiatSentButton": {"description": "Primary CTA: mark fiat sent"}, - "confirmReleaseSatsButton": "Confirm & release sats", - "@confirmReleaseSatsButton": {"description": "Primary CTA: confirm and release sats"}, "viewDisputeButton": "View dispute", "@viewDisputeButton": {"description": "Primary CTA: view dispute"}, "waitingForBuyer": "Waiting for the buyer…", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 88a06ef4..40ab6f12 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", @@ -206,11 +207,19 @@ "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.", "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 intercambio", + "payHoldInvoiceButton": "Pagar factura hold", + "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", @@ -418,7 +427,6 @@ "couldNotLoadTradesMessage": "No se pudieron cargar las operaciones", "releaseBitcoinTitle": "Liberar Bitcoin", "releaseBitcoinConfirmation": "¿Seguro que quieres liberar los Satoshis al comprador?", - "yesButtonLabel": "Sí", "sellingBitcoin": "Vendiendo Bitcoin", "buyingBitcoin": "Comprando Bitcoin", "createdByYou": "Creada por ti", @@ -467,7 +475,7 @@ "tradeTimerActiveConsequence": "Si expira, la operación puede cancelarse. Coordina en el chat si se necesita más tiempo.", "tradeTimerFiatSentLabelBuyer": "Tiempo para que el vendedor confirme la recepción", "tradeTimerFiatSentLabelSeller": "Tiempo para confirmar la recepción y liberar", - "tradeTimerFiatSentConsequence": "Si algo parece mal, abre una disputa desde el menú ⋮.", + "tradeTimerFiatSentConsequence": "Si algo parece mal, abre una disputa con el botón de abajo.", "tradeStepOrderTaken": "Orden tomada", "tradeStepInvoiceBuyer": "Compartes una factura · el vendedor bloquea los sats", "tradeStepInvoiceSeller": "El comprador comparte una factura · tú bloqueas los sats", @@ -485,9 +493,6 @@ "stepDoneLabel": "LISTO", "stepIndicator": "PASO {current} DE {total}", "addLightningInvoiceButton": "Agregar factura Lightning", - "payHoldInvoiceButton": "Pagar hold invoice", - "markFiatSentButton": "Marcar fiat enviado", - "confirmReleaseSatsButton": "Confirmar y liberar sats", "viewDisputeButton": "Ver disputa", "waitingForBuyer": "Esperando al comprador…", "waitingForSeller": "Esperando al vendedor…", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9806590a..4703d469 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é", @@ -206,11 +207,19 @@ "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.", "fiatSentFailed": "Échec de la confirmation du paiement fiat. Veuillez réessayer.", "releaseFailed": "Échec de la libération. Veuillez réessayer.", + "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 l'ordre", "orderPillYouAreSelling": "VOUS VENDEZ", "orderPillYouAreBuying": "VOUS ACHETEZ", @@ -418,7 +427,6 @@ "couldNotLoadTradesMessage": "Impossible de charger les transactions", "releaseBitcoinTitle": "Libérer les Bitcoin", "releaseBitcoinConfirmation": "Êtes-vous sûr de vouloir libérer les Satoshis à l'acheteur ?", - "yesButtonLabel": "Oui", "sellingBitcoin": "Vente de Bitcoin", "buyingBitcoin": "Achat de Bitcoin", "createdByYou": "Créé par vous", @@ -467,7 +475,7 @@ "tradeTimerActiveConsequence": "S'il expire, la transaction peut être annulée. Coordonnez-vous dans le chat si plus de temps est nécessaire.", "tradeTimerFiatSentLabelBuyer": "Temps pour que le vendeur confirme la réception", "tradeTimerFiatSentLabelSeller": "Temps pour confirmer la réception et libérer", - "tradeTimerFiatSentConsequence": "Si quelque chose semble anormal, ouvrez un litige depuis le menu ⋮.", + "tradeTimerFiatSentConsequence": "Si quelque chose semble anormal, ouvrez un litige avec le bouton ci-dessous.", "tradeStepOrderTaken": "Ordre pris", "tradeStepInvoiceBuyer": "Vous partagez une facture · le vendeur verrouille les sats", "tradeStepInvoiceSeller": "L'acheteur partage une facture · vous verrouillez les sats", @@ -485,9 +493,6 @@ "stepDoneLabel": "TERMINÉ", "stepIndicator": "ÉTAPE {current} SUR {total}", "addLightningInvoiceButton": "Ajouter une facture Lightning", - "payHoldInvoiceButton": "Payer la facture de retenue", - "markFiatSentButton": "Marquer le fiat envoyé", - "confirmReleaseSatsButton": "Confirmer et libérer les sats", "viewDisputeButton": "Voir le litige", "waitingForBuyer": "En attente de l'acheteur…", "waitingForSeller": "En attente du vendeur…", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index e9225501..f2dfe850 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", @@ -206,11 +207,19 @@ "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.", "fiatSentFailed": "Impossibile contrassegnare il fiat come inviato. Riprovare.", "releaseFailed": "Rilascio fallito. Riprovare.", + "cancelTradeButton": "Annulla scambio", + "payHoldInvoiceButton": "Paga fattura hold", + "openDisputeButton": "Apri disputa", + "releaseSatsButton": "Rilascia sats", + "markFiatSentButton": "Segna come inviato", + "confirmReleaseSatsButton": "Conferma e rilascia sats", + "shareOrderButton": "Condividi ordine", "orderPillYouAreSelling": "STAI VENDENDO", "orderPillYouAreBuying": "STAI COMPRANDO", @@ -418,7 +427,6 @@ "couldNotLoadTradesMessage": "Impossibile caricare le operazioni", "releaseBitcoinTitle": "Rilascia Bitcoin", "releaseBitcoinConfirmation": "Sei sicuro di voler rilasciare i Satoshi all'acquirente?", - "yesButtonLabel": "Sì", "sellingBitcoin": "Vendita di Bitcoin", "buyingBitcoin": "Acquisto di Bitcoin", "createdByYou": "Creata da te", @@ -467,7 +475,7 @@ "tradeTimerActiveConsequence": "Se scade, l'operazione può essere annullata. Coordinatevi nella chat se serve più tempo.", "tradeTimerFiatSentLabelBuyer": "Tempo perché il venditore confermi la ricezione", "tradeTimerFiatSentLabelSeller": "Tempo per confermare la ricezione e rilasciare", - "tradeTimerFiatSentConsequence": "Se qualcosa non va, apri una disputa dal menu ⋮.", + "tradeTimerFiatSentConsequence": "Se qualcosa non va, apri una disputa con il pulsante qui sotto.", "tradeStepOrderTaken": "Ordine preso", "tradeStepInvoiceBuyer": "Condividi una fattura · il venditore blocca i sats", "tradeStepInvoiceSeller": "L'acquirente condivide una fattura · tu blocchi i sats", @@ -485,9 +493,6 @@ "stepDoneLabel": "FATTO", "stepIndicator": "PASSO {current} DI {total}", "addLightningInvoiceButton": "Aggiungi fattura Lightning", - "payHoldInvoiceButton": "Paga hold invoice", - "markFiatSentButton": "Segna fiat inviato", - "confirmReleaseSatsButton": "Conferma e rilascia sats", "viewDisputeButton": "Vedi disputa", "waitingForBuyer": "In attesa dell'acquirente…", "waitingForSeller": "In attesa del venditore…", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bbdf969c..5ff69357 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 @@ -1244,6 +1250,12 @@ abstract class AppLocalizations { /// **'No'** String get noButtonLabel; + /// Generic Yes button label + /// + /// 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: @@ -1274,6 +1286,48 @@ 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 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: + /// **'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: @@ -2474,12 +2528,6 @@ abstract class AppLocalizations { /// **'Are you sure you want to release the Satoshis to the buyer?'** String get releaseBitcoinConfirmation; - /// Generic Yes button label - /// - /// In en, this message translates to: - /// **'Yes'** - String get yesButtonLabel; - /// Trade card title when the user is selling Bitcoin /// /// In en, this message translates to: @@ -2771,7 +2819,7 @@ abstract class AppLocalizations { /// Timer consequence after fiat is marked sent /// /// In en, this message translates to: - /// **'If something looks wrong, open a dispute from the ⋮ menu.'** + /// **'If something looks wrong, open a dispute using the button below.'** String get tradeTimerFiatSentConsequence; /// Timeline step: order taken @@ -2876,24 +2924,6 @@ abstract class AppLocalizations { /// **'Add Lightning invoice'** String get addLightningInvoiceButton; - /// Primary CTA: pay hold invoice - /// - /// In en, this message translates to: - /// **'Pay hold invoice'** - String get payHoldInvoiceButton; - - /// Primary CTA: mark fiat sent - /// - /// In en, this message translates to: - /// **'Mark fiat sent'** - String get markFiatSentButton; - - /// Primary CTA: confirm and release sats - /// - /// In en, this message translates to: - /// **'Confirm & release sats'** - String get confirmReleaseSatsButton; - /// Primary CTA: view dispute /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5c86161a..7c4ccb76 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'; @@ -634,6 +637,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get noButtonLabel => 'Nein'; + @override + String get yesButtonLabel => 'Ja'; + @override String get yesCancelButtonLabel => 'Ja, abbrechen'; @@ -652,6 +658,27 @@ class AppLocalizationsDe extends AppLocalizations { String get releaseFailed => 'Freigabe fehlgeschlagen. Bitte erneut versuchen.'; + @override + String get cancelTradeButton => 'Handel abbrechen'; + + @override + String get payHoldInvoiceButton => 'Hold-Rechnung bezahlen'; + + @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'; @@ -1325,9 +1352,6 @@ class AppLocalizationsDe extends AppLocalizations { String get releaseBitcoinConfirmation => 'Möchtest du die Satoshis wirklich an den Käufer freigeben?'; - @override - String get yesButtonLabel => 'Ja'; - @override String get sellingBitcoin => 'Bitcoin verkaufen'; @@ -1511,7 +1535,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get tradeTimerFiatSentConsequence => - 'Wenn etwas nicht stimmt, öffne einen Streitfall über das ⋮-Menü.'; + 'Wenn etwas nicht stimmt, öffne einen Streitfall über die Schaltfläche unten.'; @override String get tradeStepOrderTaken => 'Bestellung angenommen'; @@ -1574,15 +1598,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get addLightningInvoiceButton => 'Lightning-Rechnung hinzufügen'; - @override - String get payHoldInvoiceButton => 'Hold-Invoice bezahlen'; - - @override - String get markFiatSentButton => 'Fiat als gesendet markieren'; - - @override - String get confirmReleaseSatsButton => 'Bestätigen und Sats freigeben'; - @override String get viewDisputeButton => 'Streitfall ansehen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6db613d5..2107b7ad 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'; @@ -626,6 +629,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noButtonLabel => 'No'; + @override + String get yesButtonLabel => 'Yes'; + @override String get yesCancelButtonLabel => 'Yes, cancel'; @@ -641,6 +647,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get releaseFailed => 'Failed to release. Please try again.'; + @override + String get cancelTradeButton => 'Cancel trade'; + + @override + String get payHoldInvoiceButton => 'Pay hold invoice'; + + @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'; @@ -1303,9 +1330,6 @@ class AppLocalizationsEn extends AppLocalizations { String get releaseBitcoinConfirmation => 'Are you sure you want to release the Satoshis to the buyer?'; - @override - String get yesButtonLabel => 'Yes'; - @override String get sellingBitcoin => 'Selling Bitcoin'; @@ -1487,7 +1511,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tradeTimerFiatSentConsequence => - 'If something looks wrong, open a dispute from the ⋮ menu.'; + 'If something looks wrong, open a dispute using the button below.'; @override String get tradeStepOrderTaken => 'Order taken'; @@ -1549,15 +1573,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addLightningInvoiceButton => 'Add Lightning invoice'; - @override - String get payHoldInvoiceButton => 'Pay hold invoice'; - - @override - String get markFiatSentButton => 'Mark fiat sent'; - - @override - String get confirmReleaseSatsButton => 'Confirm & release sats'; - @override String get viewDisputeButton => 'View dispute'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 4f11a64f..f883e42c 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'; @@ -634,6 +637,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get noButtonLabel => 'No'; + @override + String get yesButtonLabel => 'Sí'; + @override String get yesCancelButtonLabel => 'Sí, cancelar'; @@ -652,6 +658,27 @@ class AppLocalizationsEs extends AppLocalizations { String get releaseFailed => 'Error al liberar. Por favor, inténtelo de nuevo.'; + @override + String get cancelTradeButton => 'Cancelar intercambio'; + + @override + String get payHoldInvoiceButton => 'Pagar factura hold'; + + @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'; @@ -1320,9 +1347,6 @@ class AppLocalizationsEs extends AppLocalizations { String get releaseBitcoinConfirmation => '¿Seguro que quieres liberar los Satoshis al comprador?'; - @override - String get yesButtonLabel => 'Sí'; - @override String get sellingBitcoin => 'Vendiendo Bitcoin'; @@ -1505,7 +1529,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tradeTimerFiatSentConsequence => - 'Si algo parece mal, abre una disputa desde el menú ⋮.'; + 'Si algo parece mal, abre una disputa con el botón de abajo.'; @override String get tradeStepOrderTaken => 'Orden tomada'; @@ -1567,15 +1591,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get addLightningInvoiceButton => 'Agregar factura Lightning'; - @override - String get payHoldInvoiceButton => 'Pagar hold invoice'; - - @override - String get markFiatSentButton => 'Marcar fiat enviado'; - - @override - String get confirmReleaseSatsButton => 'Confirmar y liberar sats'; - @override String get viewDisputeButton => 'Ver disputa'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c9db948d..5c490883 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'; @@ -638,6 +641,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get noButtonLabel => 'Non'; + @override + String get yesButtonLabel => 'Oui'; + @override String get yesCancelButtonLabel => 'Oui, annuler'; @@ -655,6 +661,27 @@ class AppLocalizationsFr extends AppLocalizations { @override String get releaseFailed => 'Échec de la libération. Veuillez réessayer.'; + @override + String get cancelTradeButton => 'Annuler l\'échange'; + + @override + String get payHoldInvoiceButton => 'Payer la facture hold'; + + @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 l\'ordre'; + @override String get orderPillYouAreSelling => 'VOUS VENDEZ'; @@ -1326,9 +1353,6 @@ class AppLocalizationsFr extends AppLocalizations { String get releaseBitcoinConfirmation => 'Êtes-vous sûr de vouloir libérer les Satoshis à l\'acheteur ?'; - @override - String get yesButtonLabel => 'Oui'; - @override String get sellingBitcoin => 'Vente de Bitcoin'; @@ -1513,7 +1537,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get tradeTimerFiatSentConsequence => - 'Si quelque chose semble anormal, ouvrez un litige depuis le menu ⋮.'; + 'Si quelque chose semble anormal, ouvrez un litige avec le bouton ci-dessous.'; @override String get tradeStepOrderTaken => 'Ordre pris'; @@ -1575,15 +1599,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get addLightningInvoiceButton => 'Ajouter une facture Lightning'; - @override - String get payHoldInvoiceButton => 'Payer la facture de retenue'; - - @override - String get markFiatSentButton => 'Marquer le fiat envoyé'; - - @override - String get confirmReleaseSatsButton => 'Confirmer et libérer les sats'; - @override String get viewDisputeButton => 'Voir le litige'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 855091a4..6c0bb457 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'; @@ -634,6 +637,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get noButtonLabel => 'No'; + @override + String get yesButtonLabel => 'Sì'; + @override String get yesCancelButtonLabel => 'Sì, annulla'; @@ -650,6 +656,27 @@ class AppLocalizationsIt extends AppLocalizations { @override String get releaseFailed => 'Rilascio fallito. Riprovare.'; + @override + String get cancelTradeButton => 'Annulla scambio'; + + @override + String get payHoldInvoiceButton => 'Paga fattura hold'; + + @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'; @@ -1317,9 +1344,6 @@ class AppLocalizationsIt extends AppLocalizations { String get releaseBitcoinConfirmation => 'Sei sicuro di voler rilasciare i Satoshi all\'acquirente?'; - @override - String get yesButtonLabel => 'Sì'; - @override String get sellingBitcoin => 'Vendita di Bitcoin'; @@ -1504,7 +1528,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get tradeTimerFiatSentConsequence => - 'Se qualcosa non va, apri una disputa dal menu ⋮.'; + 'Se qualcosa non va, apri una disputa con il pulsante qui sotto.'; @override String get tradeStepOrderTaken => 'Ordine preso'; @@ -1566,15 +1590,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get addLightningInvoiceButton => 'Aggiungi fattura Lightning'; - @override - String get payHoldInvoiceButton => 'Paga hold invoice'; - - @override - String get markFiatSentButton => 'Segna fiat inviato'; - - @override - String get confirmReleaseSatsButton => 'Conferma e rilascia sats'; - @override String get viewDisputeButton => 'Vedi disputa'; diff --git a/lib/shared/widgets/mostro_reactive_button.dart b/lib/shared/widgets/mostro_reactive_button.dart index 2c119b25..02a6d39f 100644 --- a/lib/shared/widgets/mostro_reactive_button.dart +++ b/lib/shared/widgets/mostro_reactive_button.dart @@ -3,35 +3,49 @@ import 'package:flutter/material.dart'; import 'package:mostro/core/app_theme.dart'; import 'package:mostro/l10n/app_localizations.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 } + +/// 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({ 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(); } -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); @@ -41,14 +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(milliseconds: 2000)); + await Future.delayed(_kErrorCooldown); if (mounted) setState(() => _state = _ButtonState.idle); } } @@ -57,16 +73,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), @@ -94,24 +126,44 @@ class _MostroReactiveButtonState extends State { liveRegion: true, child: const Icon(Icons.check, size: 20), ); - case _ButtonState.error: + 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( - label: AppLocalizations.of(context).error, liveRegion: true, - child: const Icon(Icons.error_outline, size: 20), + label: AppLocalizations.of(context).actionFailedAnnouncement, + child: _buildLabel(), ); - case _ButtonState.idle: - if (widget.icon != null) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(widget.icon, size: 18), - const SizedBox(width: AppSpacing.sm), - Text(widget.label), - ], - ); - } - return Text(widget.label); } } + + 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/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..6560b19b 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: 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 → 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..43ef6a03 --- /dev/null +++ b/test/features/trades/trade_detail_screen_test.dart @@ -0,0 +1,345 @@ +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 'package:mostro/l10n/app_localizations_en.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, + Locale locale = const Locale('en'), +}) 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: locale, + 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 trade'), 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 trade'), 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 trade'), 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 trade'), 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 trade'), 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 trade'), 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 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 — 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); + + // 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(); + + final confirmLabel = AppLocalizationsEn().yesButtonLabel; + expect(find.text(confirmLabel), findsOneWidget); + await tester.tap(find.text(confirmLabel)); + 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 360dp width do not overflow the secondary row', + (tester) async { + // 360dp, not 320dp: at 320dp the unrelated step/status pill row + // (trade_detail_screen.dart, around the _Pill row above the + // instruction text) also overflows in German. That row has no + // Expanded/Flexible protection and predates this PR; it is a + // separate, pre-existing issue, not the secondary action row this + // test targets. 360dp is still narrow enough to stress the + // secondary row's wrapping while staying clear of that other bug. + tester.view.physicalSize = const Size(360, 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 new file mode 100644 index 00000000..4db7e02a --- /dev/null +++ b/test/shared/widgets/mostro_reactive_button_test.dart @@ -0,0 +1,170 @@ +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( + 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(), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + 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); + }); + + 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))); + }); + + 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); + }); + }); +}