Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/features/order/screens/take_order_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ class _TakeOrderScreenState extends ConsumerState<TakeOrderScreen> {
const SizedBox(height: 16),
_buildOrderId(context),
const SizedBox(height: 16),
_buildOrderLink(context),
const SizedBox(height: 16),
_buildCreatorReputation(order),
const SizedBox(height: 24),
if (order.status == Status.pending)
Expand Down Expand Up @@ -211,6 +213,10 @@ class _TakeOrderScreenState extends ConsumerState<TakeOrderScreen> {
return OrderIdCard(orderId: widget.orderId);
}

Widget _buildOrderLink(BuildContext context) {
return OrderShareLinkCard(orderId: widget.orderId);
}

Widget _buildPaymentMethod(BuildContext context, NostrEvent order) {
final methods = order.paymentMethods.isNotEmpty
? order.paymentMethods.join(', ')
Expand Down
6 changes: 6 additions & 0 deletions lib/features/trades/screens/trade_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ class TradeDetailScreen extends ConsumerWidget {
const SizedBox(height: 16),
_buildOrderId(context),
const SizedBox(height: 16),
// Only while the order can still be taken: sharing a link to a
// trade already under way is of no use to whoever opens it.
if (isPending) ...[
OrderShareLinkCard(orderId: orderId),
const SizedBox(height: 16),
],
// For pending orders created by the user, show creator's reputation
if (isPending && isCreator) ...[
// TODO: Change this to use `orderPayload` after Order model is updated
Expand Down
11 changes: 11 additions & 0 deletions lib/l10n/intl_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@
"paymentMethodLabel": "Zahlungsmethode",
"createdOnLabel": "Erstellt am",
"orderIdLabel": "Order-ID",
"orderLinkLabel": "Order-Link",
"creatorReputationLabel": "Reputation des Erstellers",
"buyerReputationLabel": "Reputation des Käufers",
"sellerReputationLabel": "Reputation des Verkäufers",
Expand Down Expand Up @@ -369,6 +370,16 @@
"cooperativeCancelDialogMessage": "Wenn du bestätigst, leitest du eine kooperative Stornierung mit deinem Handelspartner ein.",
"acceptCancelDialogMessage": "Wenn du bestätigst, akzeptierst du die von deinem Handelspartner eingeleitete kooperative Stornierung.",
"orderIdCopiedMessage": "Order-ID in die Zwischenablage kopiert",
"orderLinkCopiedMessage": "Order-Link in die Zwischenablage kopiert",
"orderShareMessage": "Nimm diese Order auf Mostro: {link}",
"@orderShareMessage": {
"placeholders": {
"link": {
"type": "String"
}
}
},
"failedToShareOrder": "Order konnte nicht geteilt werden. Versuche stattdessen zu kopieren.",
"disputeTradeDialogTitle": "Streitfall starten",
"disputeTradeDialogContent": "Du bist dabei, einen Streitfall mit deinem Handelspartner zu eröffnen. Möchtest du fortfahren?",
"disputeCreatedSuccessfully": "Streitfall erfolgreich erstellt",
Expand Down
11 changes: 11 additions & 0 deletions lib/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@
"paymentMethodLabel": "Payment Method",
"createdOnLabel": "Created On",
"orderIdLabel": "Order ID",
"orderLinkLabel": "Order link",
"creatorReputationLabel": "Creator's Reputation",
"buyerReputationLabel": "Buyer's Reputation",
"sellerReputationLabel": "Seller's Reputation",
Expand Down Expand Up @@ -369,6 +370,16 @@
"cooperativeCancelDialogMessage": "If you confirm, you will start a cooperative cancellation with your counterparty.",
"acceptCancelDialogMessage": "If you confirm, you will accept the cooperative cancellation initiated by your counterparty.",
"orderIdCopiedMessage": "Order ID copied to clipboard",
"orderLinkCopiedMessage": "Order link copied to clipboard",
"orderShareMessage": "Take this order on Mostro: {link}",
"@orderShareMessage": {
"placeholders": {
"link": {
"type": "String"
}
}
},
"failedToShareOrder": "Failed to share order. Please try copying instead.",
"disputeTradeDialogTitle": "Start Dispute",
"disputeTradeDialogContent": "You are about to start a dispute with your counterparty. Do you want to continue?",
"disputeCreatedSuccessfully": "Dispute created successfully",
Expand Down
11 changes: 11 additions & 0 deletions lib/l10n/intl_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@
"paymentMethodLabel": "Método de Pago",
"createdOnLabel": "Creado el",
"orderIdLabel": "ID de la orden",
"orderLinkLabel": "Enlace de la orden",
"creatorReputationLabel": "Reputación del Creador",
"buyerReputationLabel": "Reputación del Comprador",
"sellerReputationLabel": "Reputación del Vendedor",
Expand Down Expand Up @@ -805,6 +806,16 @@
"cooperativeCancelDialogMessage": "Si confirmas, iniciarás una cancelación cooperativa con tu contraparte.",
"acceptCancelDialogMessage": "Si confirmas, aceptarás la cancelación cooperativa iniciada por tu contraparte.",
"orderIdCopiedMessage": "ID de orden copiado al portapapeles",
"orderLinkCopiedMessage": "Enlace de la orden copiado al portapapeles",
"orderShareMessage": "Toma esta orden en Mostro: {link}",
"@orderShareMessage": {
"placeholders": {
"link": {
"type": "String"
}
}
},
"failedToShareOrder": "No se pudo compartir la orden. Prueba a copiarla.",
"disputeTradeDialogTitle": "Iniciar Disputa",
"disputeTradeDialogContent": "Estás a punto de iniciar una disputa con tu contraparte. ¿Deseas continuar?",
"disputeCreatedSuccessfully": "Disputa creada exitosamente",
Expand Down
11 changes: 11 additions & 0 deletions lib/l10n/intl_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@
"paymentMethodLabel": "Méthode de paiement",
"createdOnLabel": "Créé le",
"orderIdLabel": "ID de commande",
"orderLinkLabel": "Lien de commande",
"creatorReputationLabel": "Réputation du créateur",
"buyerReputationLabel": "Réputation de l'acheteur",
"sellerReputationLabel": "Réputation du vendeur",
Expand Down Expand Up @@ -369,6 +370,16 @@
"cooperativeCancelDialogMessage": "Si vous confirmez, vous allez initier une annulation coopérative avec votre contrepartie.",
"acceptCancelDialogMessage": "Si vous confirmez, vous allez accepter l'annulation coopérative initiée par votre contrepartie.",
"orderIdCopiedMessage": "ID de commande copié dans le presse-papiers",
"orderLinkCopiedMessage": "Lien de commande copié dans le presse-papiers",
"orderShareMessage": "Prends cet ordre sur Mostro : {link}",
"@orderShareMessage": {
"placeholders": {
"link": {
"type": "String"
}
}
},
"failedToShareOrder": "Impossible de partager l'ordre. Essaie de le copier.",
"disputeTradeDialogTitle": "Démarrer un différend",
"disputeTradeDialogContent": "Vous êtes sur le point de démarrer un différend avec votre contrepartie. Voulez-vous continuer ?",
"disputeCreatedSuccessfully": "Différend créé avec succès",
Expand Down
11 changes: 11 additions & 0 deletions lib/l10n/intl_it.arb
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,7 @@
"paymentMethodLabel": "Metodo di Pagamento",
"createdOnLabel": "Creato il",
"orderIdLabel": "ID Ordine",
"orderLinkLabel": "Link Ordine",
"creatorReputationLabel": "Reputazione del Creatore",
"buyerReputationLabel": "Reputazione dell'Acquirente",
"sellerReputationLabel": "Reputazione del Venditore",
Expand Down Expand Up @@ -878,6 +879,16 @@
"cooperativeCancelDialogMessage": "Se confermi, inizierai un annullamento cooperativo con la tua controparte.",
"acceptCancelDialogMessage": "Se confermi, accetterai l'annullamento cooperativo iniziato dalla tua controparte.",
"orderIdCopiedMessage": "ID ordine copiato negli appunti",
"orderLinkCopiedMessage": "Link ordine copiato negli appunti",
"orderShareMessage": "Prendi questo ordine su Mostro: {link}",
"@orderShareMessage": {
"placeholders": {
"link": {
"type": "String"
}
}
},
"failedToShareOrder": "Impossibile condividere l'ordine. Prova a copiarlo.",
"disputeTradeDialogTitle": "Inizia Disputa",
"disputeTradeDialogContent": "Stai per iniziare una disputa con la tua controparte. Vuoi continuare?",
"disputeCreatedSuccessfully": "Disputa creata con successo",
Expand Down
11 changes: 11 additions & 0 deletions lib/l10n/intl_pt.arb
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@
"paymentMethodLabel": "Método de Pagamento",
"createdOnLabel": "Criada Em",
"orderIdLabel": "ID da Ordem",
"orderLinkLabel": "Link da Ordem",
"creatorReputationLabel": "Reputação do Criador",
"buyerReputationLabel": "Reputação do Comprador",
"sellerReputationLabel": "Reputação do Vendedor",
Expand Down Expand Up @@ -369,6 +370,16 @@
"cooperativeCancelDialogMessage": "Se você confirmar, iniciará um cancelamento cooperativo com sua contraparte.",
"acceptCancelDialogMessage": "Se você confirmar, aceitará o cancelamento cooperativo iniciado pela sua contraparte.",
"orderIdCopiedMessage": "ID da ordem copiado para a área de transferência",
"orderLinkCopiedMessage": "Link da ordem copiado para a área de transferência",
"orderShareMessage": "Aceita esta ordem no Mostro: {link}",
"@orderShareMessage": {
"placeholders": {
"link": {
"type": "String"
}
}
},
"failedToShareOrder": "Não foi possível partilhar a ordem. Tenta copiá-la.",
"disputeTradeDialogTitle": "Iniciar Disputa",
"disputeTradeDialogContent": "Você está prestes a iniciar uma disputa com sua contraparte. Deseja continuar?",
"disputeCreatedSuccessfully": "Disputa criada com sucesso",
Expand Down
45 changes: 45 additions & 0 deletions lib/shared/utils/nostr_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,51 @@ class NostrUtils {
}
}

/// Builds a shareable `mostro:` link for [orderId], in the same format
/// [parseMostroUrl] reads back.
///
/// Relays that carry no WebSocket scheme are dropped and duplicates are
/// removed; at most [maxRelays] survive, so the link stays short enough to
/// paste into a chat. Returns null when none is left, since the format
/// requires at least one relay to resolve the order.
static String? buildMostroUrl({
required String orderId,
required List<String> relays,
String? mostroPubkey,
int maxRelays = 3,
}) {
final trimmedOrderId = orderId.trim();
if (trimmedOrderId.isEmpty) return null;

final usableRelays = <String>[];
for (final relay in relays) {
final trimmedRelay = relay.trim();
if (!trimmedRelay.startsWith('wss://') &&
!trimmedRelay.startsWith('ws://')) {
continue;
}
if (usableRelays.contains(trimmedRelay)) continue;
usableRelays.add(trimmedRelay);
if (usableRelays.length == maxRelays) break;
}
if (usableRelays.isEmpty) return null;

final buffer = StringBuffer('mostro:$trimmedOrderId?relays=')
..write(usableRelays.join(','));

// Same shape parseMostroUrl accepts, so a link we build never carries a
// pubkey the reader would silently discard.
final normalizedPubkey =
mostroPubkey?.trim().toLowerCase().replaceFirst('0x', '');
if (normalizedPubkey != null &&
normalizedPubkey.length == 64 &&
RegExp(r'^[0-9a-f]{64}$').hasMatch(normalizedPubkey)) {
buffer.write('&mostro=$normalizedPubkey');
}

return buffer.toString();
}

/// Parses a mostro: URL and returns order information
/// Format: `mostro:order-id?relays=wss://relay1,wss://relay2&mostro=pubkey`
/// Returns a map with 'orderId', 'relays', and optionally 'mostroPubkey' keys
Expand Down
118 changes: 118 additions & 0 deletions lib/shared/widgets/order_cards.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_plus/share_plus.dart';
import 'package:mostro_mobile/core/app_theme.dart';
import 'package:mostro_mobile/core/automation/automation_id.dart';
import 'package:mostro_mobile/core/automation/automation_ids.dart';
import 'package:mostro_mobile/data/models/user_info.dart';
import 'package:mostro_mobile/features/settings/settings_provider.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';
import 'package:mostro_mobile/shared/widgets/custom_card.dart';

import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart';
Expand Down Expand Up @@ -246,6 +250,120 @@ class OrderIdCard extends StatelessWidget {
}
}

/// Card that displays the order's shareable `mostro:` link with a copy button.
///
/// The link carries the relays the order can be resolved from and the Mostro
/// instance it lives on, so any client that understands the scheme can open the
/// order directly. It renders nothing when no usable relay is configured.
class OrderShareLinkCard extends ConsumerWidget {
final String orderId;

const OrderShareLinkCard({
super.key,
required this.orderId,
});

@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final link = NostrUtils.buildMostroUrl(
orderId: orderId,
relays: settings.relays,
mostroPubkey: settings.mostroPublicKey,
);

if (link == null) return const SizedBox.shrink();

return CustomCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.orderLinkLabel,
style: TextStyle(
color: Colors.white70,
fontSize: 12,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
link,
style: const TextStyle(
color: AppTheme.mostroGreen,
fontSize: 14,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(
Icons.copy,
color: Colors.white70,
size: 20,
),
visualDensity: VisualDensity.compact,
onPressed: () {
Clipboard.setData(ClipboardData(text: link));
SnackBarHelper.showTopSnackBar(
context,
S.of(context)!.orderLinkCopiedMessage,
);
},
),
IconButton(
icon: const Icon(
Icons.share,
color: Colors.white70,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: S.of(context)!.share,
onPressed: () => _share(context, link),
),
],
),
],
),
);
}

/// Hands the link to the system share sheet, wrapped in a sentence so it
/// arrives as something the recipient can make sense of.
///
/// Everything taken from [context] is read before the await: this is a
/// stateless widget, so there is no `mounted` to check afterwards.
Future<void> _share(BuildContext context, String link) async {
final messenger = ScaffoldMessenger.of(context);
final mediaQuery = MediaQuery.of(context);
final message = S.of(context)!.orderShareMessage(link);
final errorMessage = S.of(context)!.failedToShareOrder;
final box = context.findRenderObject() as RenderBox?;

try {
await Share.share(
message,
// iPads anchor the share sheet to the widget that raised it.
sharePositionOrigin: box == null
? null
: box.localToGlobal(Offset.zero) & box.size,
);
} catch (e, stack) {
logger.e('Failed to share order link', error: e, stackTrace: stack);
SnackBarHelper.showTopSnackBarAsync(
messenger: messenger,
screenHeight: mediaQuery.size.height,
statusBarHeight: mediaQuery.padding.top,
message: errorMessage,
);
}
}
}

/// Card that displays the creator's reputation
class CreatorReputationCard extends StatelessWidget {
final double rating;
Expand Down
Loading
Loading