diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 0c76629f..4708216f 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -108,6 +108,8 @@ class _TakeOrderScreenState extends ConsumerState { 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) @@ -211,6 +213,10 @@ class _TakeOrderScreenState extends ConsumerState { 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(', ') diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index c8066fa6..ebe1caba 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -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 diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 146e8d6b..e7cd18d6 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -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", @@ -369,6 +370,7 @@ "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", "disputeTradeDialogTitle": "Streitfall starten", "disputeTradeDialogContent": "Du bist dabei, einen Streitfall mit deinem Handelspartner zu eröffnen. Möchtest du fortfahren?", "disputeCreatedSuccessfully": "Streitfall erfolgreich erstellt", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f3f7c3d2..e1866b24 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -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", @@ -369,6 +370,7 @@ "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", "disputeTradeDialogTitle": "Start Dispute", "disputeTradeDialogContent": "You are about to start a dispute with your counterparty. Do you want to continue?", "disputeCreatedSuccessfully": "Dispute created successfully", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 3ff880d7..971073da 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -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", @@ -805,6 +806,7 @@ "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", "disputeTradeDialogTitle": "Iniciar Disputa", "disputeTradeDialogContent": "Estás a punto de iniciar una disputa con tu contraparte. ¿Deseas continuar?", "disputeCreatedSuccessfully": "Disputa creada exitosamente", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 8397ac81..368e997a 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -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", @@ -369,6 +370,7 @@ "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", "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", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 6d36f343..7dd41aaf 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -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", @@ -878,6 +879,7 @@ "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", "disputeTradeDialogTitle": "Inizia Disputa", "disputeTradeDialogContent": "Stai per iniziare una disputa con la tua controparte. Vuoi continuare?", "disputeCreatedSuccessfully": "Disputa creata con successo", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index af08de7e..50343356 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -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", @@ -369,6 +370,7 @@ "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", "disputeTradeDialogTitle": "Iniciar Disputa", "disputeTradeDialogContent": "Você está prestes a iniciar uma disputa com sua contraparte. Deseja continuar?", "disputeCreatedSuccessfully": "Disputa criada com sucesso", diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 36951c9c..cac4c7dd 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -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 relays, + String? mostroPubkey, + int maxRelays = 3, + }) { + final trimmedOrderId = orderId.trim(); + if (trimmedOrderId.isEmpty) return null; + + final usableRelays = []; + 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 diff --git a/lib/shared/widgets/order_cards.dart b/lib/shared/widgets/order_cards.dart index f66e8203..fd8279d5 100644 --- a/lib/shared/widgets/order_cards.dart +++ b/lib/shared/widgets/order_cards.dart @@ -5,6 +5,8 @@ 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/shared/utils/nostr_utils.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; @@ -246,6 +248,78 @@ 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, + ), + onPressed: () { + Clipboard.setData(ClipboardData(text: link)); + SnackBarHelper.showTopSnackBar( + context, + S.of(context)!.orderLinkCopiedMessage, + ); + }, + ), + ], + ), + ], + ), + ); + } +} + /// Card that displays the creator's reputation class CreatorReputationCard extends StatelessWidget { final double rating; diff --git a/test/shared/utils/deep_link_parsing_test.dart b/test/shared/utils/deep_link_parsing_test.dart index cc381545..6b761daa 100644 --- a/test/shared/utils/deep_link_parsing_test.dart +++ b/test/shared/utils/deep_link_parsing_test.dart @@ -142,4 +142,114 @@ void main() { expect(result!['mostroPubkey'], isNull); }); }); + + group('NostrUtils.buildMostroUrl', () { + const orderId = 'e215c07e-b1f9-45b0-9640-0295067ee99a'; + const pubkey = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + + test('builds a link its own parser reads back unchanged', () { + final url = NostrUtils.buildMostroUrl( + orderId: orderId, + relays: const ['wss://relay.mostro.network', 'wss://relay.damus.io'], + mostroPubkey: pubkey, + ); + + expect(url, isNotNull); + expect(NostrUtils.isValidMostroUrl(url!), isTrue); + + final parsed = NostrUtils.parseMostroUrl(url); + expect(parsed, isNotNull); + expect(parsed!['orderId'], orderId); + expect(parsed['relays'], + ['wss://relay.mostro.network', 'wss://relay.damus.io']); + expect(parsed['mostroPubkey'], pubkey); + }); + + test('omits the mostro param when no pubkey is given', () { + final url = NostrUtils.buildMostroUrl( + orderId: orderId, + relays: const ['wss://relay.mostro.network'], + ); + + expect(url, isNot(contains('mostro='))); + expect(NostrUtils.parseMostroUrl(url!)!['mostroPubkey'], isNull); + }); + + test('drops a pubkey the parser would reject rather than emitting it', () { + final url = NostrUtils.buildMostroUrl( + orderId: orderId, + relays: const ['wss://relay.mostro.network'], + mostroPubkey: 'not-a-pubkey', + ); + + expect(url, isNot(contains('mostro='))); + }); + + test('accepts a pubkey in upper case or 0x-prefixed', () { + final url = NostrUtils.buildMostroUrl( + orderId: orderId, + relays: const ['wss://relay.mostro.network'], + mostroPubkey: '0x${pubkey.toUpperCase()}', + ); + + expect(NostrUtils.parseMostroUrl(url!)!['mostroPubkey'], pubkey); + }); + + test('keeps only relays carrying a WebSocket scheme', () { + final url = NostrUtils.buildMostroUrl( + orderId: orderId, + relays: const [ + 'https://relay.example.com', + ' wss://relay.mostro.network ', + 'relay.example.org', + 'ws://localhost:7000', + ], + ); + + expect(NostrUtils.parseMostroUrl(url!)!['relays'], + ['wss://relay.mostro.network', 'ws://localhost:7000']); + }); + + test('removes duplicates and caps the relay count', () { + final url = NostrUtils.buildMostroUrl( + orderId: orderId, + relays: const [ + 'wss://a.example', + 'wss://a.example', + 'wss://b.example', + 'wss://c.example', + 'wss://d.example', + ], + maxRelays: 3, + ); + + expect(NostrUtils.parseMostroUrl(url!)!['relays'], + ['wss://a.example', 'wss://b.example', 'wss://c.example']); + }); + + test('returns null when no usable relay is left', () { + expect( + NostrUtils.buildMostroUrl( + orderId: orderId, + relays: const ['https://relay.example.com'], + ), + isNull, + ); + expect( + NostrUtils.buildMostroUrl(orderId: orderId, relays: const []), + isNull, + ); + }); + + test('returns null without an order id', () { + expect( + NostrUtils.buildMostroUrl( + orderId: ' ', + relays: const ['wss://relay.mostro.network'], + ), + isNull, + ); + }); + }); } diff --git a/test/shared/widgets/order_cards_test.dart b/test/shared/widgets/order_cards_test.dart index 99a4b211..9b85380a 100644 --- a/test/shared/widgets/order_cards_test.dart +++ b/test/shared/widgets/order_cards_test.dart @@ -1,10 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mostro_mobile/data/models/currency.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_notifier.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/widgets/order_cards.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; final _currencies = { 'USD': Currency( @@ -19,18 +26,30 @@ final _currencies = { ), }; +/// Holds a fixed [Settings] so cards reading `settingsProvider` can be pumped +/// without touching storage. Nothing calls `init()`, so the preferences +/// instance is never used. +class _StubSettingsNotifier extends SettingsNotifier { + _StubSettingsNotifier(Settings settings) : super(SharedPreferencesAsync()) { + state = settings; + } +} + /// Pumps [child] with the currency catalogue stubbed out, so cards that read /// `currencyCodesProvider` resolve without hitting the exchange service. Future pumpCard( WidgetTester tester, Widget child, { Map? currencies, + Settings? settings, }) async { await tester.pumpWidget( ProviderScope( overrides: [ currencyCodesProvider .overrideWith((ref) async => currencies ?? _currencies), + if (settings != null) + settingsProvider.overrideWith((ref) => _StubSettingsNotifier(settings)), ], child: MaterialApp( localizationsDelegates: S.localizationsDelegates, @@ -44,6 +63,11 @@ Future pumpCard( } void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + group('OrderAmountCard', () { testWidgets('renders the title, amount and currency', (tester) async { await pumpCard( @@ -161,6 +185,76 @@ void main() { }); }); + group('OrderShareLinkCard', () { + const pubkey = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + + Settings settingsWith(List relays) => Settings( + relays: relays, + fullPrivacyMode: false, + mostroPublicKey: pubkey, + ); + + testWidgets('renders a link carrying the order, relays and instance', + (tester) async { + await pumpCard( + tester, + const OrderShareLinkCard(orderId: 'order-1234'), + settings: settingsWith(const ['wss://relay.mostro.network']), + ); + + expect( + find.text( + 'mostro:order-1234?relays=wss://relay.mostro.network&mostro=$pubkey', + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('copies that link to the clipboard', (tester) async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null)); + + await pumpCard( + tester, + const OrderShareLinkCard(orderId: 'order-1234'), + settings: settingsWith(const ['wss://relay.mostro.network']), + ); + + await tester.tap(find.byIcon(Icons.copy)); + await tester.pump(); + + expect( + copied, + 'mostro:order-1234?relays=wss://relay.mostro.network&mostro=$pubkey', + ); + }); + + testWidgets('renders nothing when no relay can resolve the order', + (tester) async { + await pumpCard( + tester, + const OrderShareLinkCard(orderId: 'order-1234'), + settings: settingsWith(const []), + ); + + expect(find.textContaining('mostro:'), findsNothing); + expect(find.byIcon(Icons.copy), findsNothing); + expect(tester.takeException(), isNull); + }); + }); + group('CreatorReputationCard', () { testWidgets('renders rating, review count and account age', (tester) async { await pumpCard(