From b0b1f1ab4e0514d530d969678cfd127ac8790042 Mon Sep 17 00:00:00 2001 From: 21Mill Date: Sat, 22 Aug 2026 00:27:11 +0200 Subject: [PATCH 1/2] feat: show a shareable link on takeable orders The app could already open order links but never produced one, so sharing an order meant reading its id aloud and having the other side look it up by hand. The `mostro:` scheme is registered in the manifest, NostrUtils parses `mostro:?relays=...&mostro=`, and DeepLinkService resolves the order from those relays, switching Mostro instance when the link points at a different one. Only the encoder was missing. NostrUtils.buildMostroUrl now writes that same format, next to the two functions that read it. It keeps only relays carrying a WebSocket scheme, drops duplicates, caps the list so the link stays pasteable, and emits the instance pubkey only when it passes the check the parser applies, so a link this builds is always one the parser accepts. OrderShareLinkCard shows the link with a copy button, following the shape of the OrderIdCard beside it, and renders nothing when no relay can resolve the order. It appears on the order book's detail screen and, in My Trades, only while the order is still pending: a link to a trade already under way is of no use to whoever opens it. Adds orderLinkLabel and orderLinkCopiedMessage to the six locales. --- .../order/screens/take_order_screen.dart | 6 + .../trades/screens/trade_detail_screen.dart | 6 + lib/l10n/intl_de.arb | 2 + lib/l10n/intl_en.arb | 2 + lib/l10n/intl_es.arb | 2 + lib/l10n/intl_fr.arb | 2 + lib/l10n/intl_it.arb | 2 + lib/l10n/intl_pt.arb | 2 + lib/shared/utils/nostr_utils.dart | 45 +++++++ lib/shared/widgets/order_cards.dart | 74 ++++++++++++ test/shared/utils/deep_link_parsing_test.dart | 110 ++++++++++++++++++ test/shared/widgets/order_cards_test.dart | 94 +++++++++++++++ 12 files changed, 347 insertions(+) diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 0c76629f3..4708216fa 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 c8066fa64..ebe1caba7 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 146e8d6ba..e7cd18d6b 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 f3f7c3d2b..e1866b242 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 3ff880d78..971073daa 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 8397ac818..368e997a0 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 6d36f3432..7dd41aaff 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 af08de7ec..503433566 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 36951c9c9..cac4c7dd7 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 f66e8203d..fd8279d5d 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 cc381545b..6b761daa2 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 99a4b2111..9b85380a3 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( From 99fc2e62288349b3882e12a2c3ca1f288737059c Mon Sep 17 00:00:00 2001 From: 21Mill Date: Sat, 22 Aug 2026 01:03:23 +0200 Subject: [PATCH 2/2] feat: share an order through the system share sheet Copying the order link means copy, leave the app, open the chat, paste. The share sheet takes the same link straight to Telegram, Signal, a note or anything else installed, in one gesture. It also allows something the clipboard cannot: sending the link with a sentence around it. A bare mostro:8927bb1d-...?relays=... lands in a chat as an unexplained string, and most apps only linkify http(s), so the recipient sees neither a tappable link nor a hint of what it is. It now travels as "Take this order on Mostro: ". Both actions stay: copying is still faster when the destination is not an app, such as a password manager or a QR generator. The share call follows the pattern the invoice widget already uses, taking everything it needs from the context before the await -- this card is a ConsumerWidget, so there is no mounted to check afterwards -- and reporting a failure through the snackbar rather than letting it escape. It passes sharePositionOrigin, which iPads need to anchor the sheet. Adds orderShareMessage and failedToShareOrder to the six locales. The card now holds two 48dp buttons next to a link long enough to wrap, which is the shape that produced the overflow in #654, so a layout test pins it. Confirmed that test fails against a card whose link is not flexible. --- lib/l10n/intl_de.arb | 9 ++++ lib/l10n/intl_en.arb | 9 ++++ lib/l10n/intl_es.arb | 9 ++++ lib/l10n/intl_fr.arb | 9 ++++ lib/l10n/intl_it.arb | 9 ++++ lib/l10n/intl_pt.arb | 9 ++++ lib/shared/widgets/order_cards.dart | 44 +++++++++++++++++ test/shared/widgets/order_cards_test.dart | 60 +++++++++++++++++++++++ 8 files changed, 158 insertions(+) diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index e7cd18d6b..80558371b 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -371,6 +371,15 @@ "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 e1866b242..8cce82d4b 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -371,6 +371,15 @@ "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 971073daa..6c56f7488 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -807,6 +807,15 @@ "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 368e997a0..2e72fc179 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -371,6 +371,15 @@ "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 7dd41aaff..d7f4a6f58 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -880,6 +880,15 @@ "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 503433566..fb201cf45 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -371,6 +371,15 @@ "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/widgets/order_cards.dart b/lib/shared/widgets/order_cards.dart index fd8279d5d..06535069c 100644 --- a/lib/shared/widgets/order_cards.dart +++ b/lib/shared/widgets/order_cards.dart @@ -1,11 +1,13 @@ 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'; @@ -304,6 +306,7 @@ class OrderShareLinkCard extends ConsumerWidget { color: Colors.white70, size: 20, ), + visualDensity: VisualDensity.compact, onPressed: () { Clipboard.setData(ClipboardData(text: link)); SnackBarHelper.showTopSnackBar( @@ -312,12 +315,53 @@ class OrderShareLinkCard extends ConsumerWidget { ); }, ), + 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 diff --git a/test/shared/widgets/order_cards_test.dart b/test/shared/widgets/order_cards_test.dart index 9b85380a3..cd1b37bad 100644 --- a/test/shared/widgets/order_cards_test.dart +++ b/test/shared/widgets/order_cards_test.dart @@ -251,6 +251,66 @@ void main() { 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); }); });