Skip to content
186 changes: 113 additions & 73 deletions lib/features/trades/screens/trade_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<TradeDetailScreen> {
Timer? _countdownTimer;
Expand Down Expand Up @@ -183,7 +183,10 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
],
),
);
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);
Expand All @@ -197,6 +200,20 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.cancelRequestFailed)),
);
rethrow;
}
}

Future<void> _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;
}
}

Expand Down Expand Up @@ -226,14 +243,17 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).openDisputeFailed)),
);
rethrow;
Comment thread
BraCR10 marked this conversation as resolved.
}
}

/// 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<void> _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;
Expand All @@ -248,6 +268,7 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).releaseFailed)),
);
rethrow;
}
}

Expand Down Expand Up @@ -460,7 +481,7 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
onPressed: () =>
context.canPop() ? context.pop() : context.go(AppRoute.home),
),
actions: [_buildOverflowMenu(status, isBuyer, colors)],
actions: [_buildOverflowMenu()],
),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.lg),
Expand All @@ -479,6 +500,11 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
// 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),
Expand Down Expand Up @@ -532,13 +558,44 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
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<Widget> _buildSecondaryActionRow(
TradeStatus status,
bool isBuyer,
AppColors? colors,
) {
final canCancel = const {
TradeStatus.pending,
TradeStatus.waitingInvoice,
Expand All @@ -552,49 +609,53 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
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<void> 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(
Comment thread
BraCR10 marked this conversation as resolved.
children: [
for (var i = 0; i < buttons.length; i++) ...[
if (i > 0) const SizedBox(width: AppSpacing.sm),
buttons[i],
],
],
),
];
}

// ── State strip ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -798,37 +859,16 @@ class _TradeDetailScreenState extends ConsumerState<TradeDetailScreen> {
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, _):
Expand Down
17 changes: 11 additions & 6 deletions lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"appName": "Mostro",
"loading": "Laden…",
"error": "Fehler",
"actionFailedAnnouncement": "Aktion fehlgeschlagen",
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"done": "Fertig",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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…",
Expand Down
Loading
Loading