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..80558371 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,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", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f3f7c3d2..8cce82d4 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,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", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 3ff880d7..6c56f748 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,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", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 8397ac81..2e72fc17 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,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", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 6d36f343..d7f4a6f5 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,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", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index af08de7e..fb201cf4 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,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", 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..06535069 100644 --- a/lib/shared/widgets/order_cards.dart +++ b/lib/shared/widgets/order_cards.dart @@ -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'; @@ -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 _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; 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..cd1b37ba 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,136 @@ 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(find.byIcon(Icons.share), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('hands the link to the share sheet wrapped in a sentence', + (tester) async { + const channel = MethodChannel('dev.fluttercommunity.plus/share'); + String? sharedText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + if (call.method == 'share') { + sharedText = (call.arguments as Map)['text'] as String?; + } + return 'dev.fluttercommunity.plus/share/success'; + }, + ); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null)); + + await pumpCard( + tester, + const OrderShareLinkCard(orderId: 'order-1234'), + settings: settingsWith(const ['wss://relay.mostro.network']), + ); + + await tester.tap(find.byIcon(Icons.share)); + await tester.pumpAndSettle(); + + expect(sharedText, isNotNull); + expect( + sharedText, + contains( + 'mostro:order-1234?relays=wss://relay.mostro.network&mostro=$pubkey', + ), + ); + // The link travels with an explanation, not on its own. + expect(sharedText!.trim(), isNot(startsWith('mostro:'))); + }); + + // Two 48dp buttons now sit next to a link long enough to wrap. This is the + // shape that produced the overflow in #654, so it is pinned here. + testWidgets('lays out both actions beside a long link without overflowing', + (tester) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await pumpCard( + tester, + const OrderShareLinkCard(orderId: 'order-1234'), + settings: settingsWith(const [ + 'wss://relay.mostro.network', + 'wss://a-considerably-longer-relay-domain.example', + 'wss://another-quite-long-relay-domain.example', + ]), + ); + + expect(find.byIcon(Icons.copy), findsOneWidget); + expect(find.byIcon(Icons.share), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + group('CreatorReputationCard', () { testWidgets('renders rating, review count and account age', (tester) async { await pumpCard(