diff --git a/docs/DEEP_LINK_MOSTRO_SWITCH.md b/docs/DEEP_LINK_MOSTRO_SWITCH.md new file mode 100644 index 000000000..9f5082e54 --- /dev/null +++ b/docs/DEEP_LINK_MOSTRO_SWITCH.md @@ -0,0 +1,42 @@ +# Deep Link Mostro Instance Switch + +## Overview + +When a deep link contains a `mostro=` parameter identifying a different +Mostro instance than the currently connected one, the app shows a confirmation +dialog before switching. + +## Deep Link Format + +```text +mostro:?relays=,&mostro= +``` + +The `mostro` parameter is optional for backward compatibility. When absent, the +app assumes the order belongs to the currently selected Mostro instance. + +## Flow + +1. App receives `mostro:` deep link +2. `parseMostroUrl` extracts `orderId`, `relays`, and optional `mostroPubkey` +3. `DeepLinkHandler` compares `mostroPubkey` with `settings.mostroPublicKey` +4. If same (or absent) → navigate directly to order (existing behavior) +5. If different → show confirmation dialog +6. If user confirms → call `updateMostroInstance(newPubkey)` then navigate +7. If user cancels → do nothing + +## Files Changed + +| File | Change | +|------|--------| +| `lib/shared/utils/nostr_utils.dart` | Extract `mostro` query param in `parseMostroUrl` | +| `lib/services/deep_link_service.dart` | Add `mostroPubkey` field to `OrderInfo` | +| `lib/core/deep_link_handler.dart` | Pubkey comparison + switch dialog | +| `lib/l10n/intl_en.arb` | English strings for dialog | +| `lib/l10n/intl_es.arb` | Spanish strings for dialog | +| `test/shared/utils/deep_link_parsing_test.dart` | Unit tests | + +## References + +- [Issue #541](https://github.com/MostroP2P/mobile/issues/541) +- [Order Event Spec](https://mostro.network/protocol/order_event.html) diff --git a/lib/core/deep_link_handler.dart b/lib/core/deep_link_handler.dart index 67197d284..6f2b2011c 100644 --- a/lib/core/deep_link_handler.dart +++ b/lib/core/deep_link_handler.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/deep_link_service.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; @@ -12,6 +13,13 @@ class DeepLinkHandler { final Ref _ref; StreamSubscription? _subscription; + bool _isHandlingMostroDeepLink = false; + String? _lastHandledDeepLinkUrl; + DateTime? _lastHandledDeepLinkAt; + + BuildContext? _loadingDialogContext; + bool _isLoadingDialogVisible = false; + DeepLinkHandler(this._ref); /// Initializes deep link handling for the app @@ -37,10 +45,7 @@ class DeepLinkHandler { } /// Handles incoming deep links - Future _handleDeepLink( - Uri uri, - GoRouter router, - ) async { + Future _handleDeepLink(Uri uri, GoRouter router) async { try { logger.i('Handling deep link: $uri'); @@ -64,10 +69,22 @@ class DeepLinkHandler { } /// Handles mostro: scheme deep links - Future _handleMostroDeepLink( - String url, - GoRouter router, - ) async { + Future _handleMostroDeepLink(String url, GoRouter router) async { + final now = DateTime.now(); + final isDuplicateRecent = + _lastHandledDeepLinkUrl == url && + _lastHandledDeepLinkAt != null && + now.difference(_lastHandledDeepLinkAt!) < const Duration(seconds: 2); + + if (_isHandlingMostroDeepLink || isDuplicateRecent) { + logger.i('Ignoring duplicate/concurrent deep link handling for: $url'); + return; + } + + _isHandlingMostroDeepLink = true; + _lastHandledDeepLinkUrl = url; + _lastHandledDeepLinkAt = now; + BuildContext? context; try { // Show loading indicator @@ -81,68 +98,198 @@ class DeepLinkHandler { final deepLinkService = _ref.read(deepLinkServiceProvider); // Ensure we have a valid context for processing - final processingContext = context ?? router.routerDelegate.navigatorKey.currentContext; + final processingContext = + context ?? router.routerDelegate.navigatorKey.currentContext; if (processingContext == null || !processingContext.mounted) { logger.e('No valid context available for deep link processing'); return; } // Process the mostro link - final result = await deepLinkService.processMostroLink(url, nostrService, processingContext); - - // Get fresh context after async operation - final currentContext = router.routerDelegate.navigatorKey.currentContext; + final result = await deepLinkService.processMostroLink( + url, + nostrService, + processingContext, + ); - // Hide loading indicator - if (currentContext != null && currentContext.mounted) { - Navigator.of(currentContext).pop(); - } + _hideLoadingDialog(); if (result.isSuccess && result.orderInfo != null) { + final orderInfo = result.orderInfo!; + final currentContext = + router.routerDelegate.navigatorKey.currentContext; + + // Check if the deep link targets a different Mostro instance + if (orderInfo.mostroPubkey != null && + currentContext != null && + currentContext.mounted) { + final currentPubkey = _ref.read(settingsProvider).mostroPublicKey; + if (orderInfo.mostroPubkey != currentPubkey) { + final shouldSwitch = await _showMostroSwitchDialog( + currentContext, + orderInfo.mostroPubkey!, + currentPubkey, + ); + if (shouldSwitch != true) { + logger.i('User declined Mostro switch for deep link'); + return; + } + // Switch Mostro instance + await _ref + .read(settingsProvider.notifier) + .updateMostroInstance(orderInfo.mostroPubkey!); + logger.i('Switched Mostro instance to: ${orderInfo.mostroPubkey}'); + } + } + // Navigate to the appropriate screen with proper timing WidgetsBinding.instance.addPostFrameCallback((_) { - deepLinkService.navigateToOrder(router, result.orderInfo!); + deepLinkService.navigateToOrder(router, orderInfo); }); - logger.i('Successfully navigated to order: ${result.orderInfo!.orderId} (${result.orderInfo!.orderType.value})'); + logger.i( + 'Successfully navigated to order: ${orderInfo.orderId} (${orderInfo.orderType.value})', + ); } else { final errorContext = router.routerDelegate.navigatorKey.currentContext; if (errorContext != null && errorContext.mounted) { - final errorMessage = result.error ?? S.of(errorContext)!.failedToLoadOrder; + final errorMessage = + result.error ?? S.of(errorContext)!.failedToLoadOrder; _showErrorSnackBar(errorContext, errorMessage); } logger.w('Failed to process mostro link: ${result.error}'); } } catch (e) { logger.e('Error processing mostro deep link: $e'); + _hideLoadingDialog(); + final errorContext = router.routerDelegate.navigatorKey.currentContext; if (errorContext != null && errorContext.mounted) { - Navigator.of(errorContext).pop(); // Hide loading if still showing _showErrorSnackBar(errorContext, S.of(errorContext)!.failedToOpenOrder); } + } finally { + _isHandlingMostroDeepLink = false; } } + /// Shows a confirmation dialog when a deep link targets a different Mostro instance. + /// + /// [targetName] and [currentName] are optional human-readable labels for the + /// Mostro instances. When empty, truncated pubkeys are shown instead. + Future _showMostroSwitchDialog( + BuildContext context, + String linkPubkey, + String currentPubkey, { + String targetName = '', + String currentName = '', + }) { + final completer = Completer(); + final s = S.of(context)!; + final truncatedLink = + '${linkPubkey.substring(0, 8)}...${linkPubkey.substring(linkPubkey.length - 8)}'; + final truncatedCurrent = + '${currentPubkey.substring(0, 8)}...${currentPubkey.substring(currentPubkey.length - 8)}'; + + final targetLabel = targetName.isNotEmpty ? targetName : truncatedLink; + final currentLabel = currentName.isNotEmpty + ? currentName + : truncatedCurrent; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!context.mounted) { + completer.complete(null); + return; + } + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.orange), + const SizedBox(width: 8), + Expanded(child: Text(s.deepLinkDifferentMostroTitle)), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(s.deepLinkDifferentMostroBody), + const SizedBox(height: 12), + Text( + '${s.deepLinkDifferentMostroFrom}\n$targetLabel', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + const SizedBox(height: 8), + Text( + '${s.deepLinkDifferentMostroCurrent}\n$currentLabel', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(s.cancel), + ), + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(s.deepLinkSwitchAndView), + ), + ], + ), + ); + completer.complete(result); + }); + + return completer.future; + } + /// Shows a loading dialog void _showLoadingDialog(BuildContext context) { + if (_isLoadingDialogVisible) { + return; + } + + _isLoadingDialogVisible = true; showDialog( context: context, barrierDismissible: false, - builder: (dialogContext) => Center( - child: Card( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text(S.of(dialogContext)!.loadingOrder), - ], + builder: (dialogContext) { + _loadingDialogContext = dialogContext; + return Center( + child: Card( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(S.of(dialogContext)!.loadingOrder), + ], + ), ), ), - ), - ), - ); + ); + }, + ).whenComplete(() { + _isLoadingDialogVisible = false; + _loadingDialogContext = null; + }); + } + + void _hideLoadingDialog() { + if (!_isLoadingDialogVisible) { + return; + } + + final dialogContext = _loadingDialogContext; + if (dialogContext != null && dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + + _isLoadingDialogVisible = false; + _loadingDialogContext = null; } /// Shows an error snack bar @@ -161,6 +308,7 @@ class DeepLinkHandler { void dispose() { _subscription?.cancel(); _subscription = null; + _hideLoadingDialog(); // DeepLinkService disposal is handled by Riverpod provider } } diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart index 38418e82b..a153bf014 100644 --- a/lib/data/models/enums/action.dart +++ b/lib/data/models/enums/action.dart @@ -51,7 +51,7 @@ enum Action { /// /// Throws an ArgumentError if the string doesn't match any Action value. static final _valueMap = { - for (var action in Action.values) action.value: action + for (var action in Action.values) action.value: action, }; static Action fromString(String value) { diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 065bd6ce6..a09b5fc92 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/services/logger_service.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as mostro_action; +import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; @@ -12,7 +14,6 @@ import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/shared/widgets/order_cards.dart'; - import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; @@ -41,76 +42,98 @@ class _TakeOrderScreenState extends ConsumerState { @override Widget build(BuildContext context) { final order = ref.watch(eventProvider(widget.orderId)); + final orderEventsAsync = ref.watch(orderEventsProvider); // Listen for messages to reset loading state on CantDo - ref.listen( - mostroMessageStreamProvider(widget.orderId), - (_, next) { - next.whenData((msg) { - if (msg == null || msg.action == _lastSeenAction) return; - _lastSeenAction = msg.action; - - // Reset loading state only on CantDo message - if (msg.action == actions.Action.cantDo && _isSubmitting) { - setState(() { - _isSubmitting = false; - }); - } - }); - }, - ); + ref.listen(mostroMessageStreamProvider(widget.orderId), (_, next) { + next.whenData((msg) { + if (msg == null || msg.action == _lastSeenAction) return; + _lastSeenAction = msg.action; + + // Reset loading state when Mostro rejects or cancels the take attempt. + // Only cantDo and canceled can arrive for an order in pending status + // per the Mostro protocol — cooperative/admin actions and later-stage + // outcomes (released, paymentFailed, etc.) cannot occur at this point. + if ((msg.action == mostro_action.Action.cantDo || + msg.action == mostro_action.Action.canceled) && + _isSubmitting) { + setState(() { + _isSubmitting = false; + }); + } + }); + }); return Scaffold( backgroundColor: AppTheme.backgroundDark, appBar: OrderAppBar( - title: widget.orderType == OrderType.buy - ? S.of(context)!.buyOrderDetailsTitle - : S.of(context)!.sellOrderDetailsTitle), - body: SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - 16.0, - 16.0, - 16.0, - 16.0 + MediaQuery.of(context).viewPadding.bottom, - ), - child: Column( - children: [ - const SizedBox(height: 16), - _buildSellerAmount(ref, order!), - const SizedBox(height: 16), - _buildPaymentMethod(context, order), - const SizedBox(height: 16), - _buildCreatedOn(order), - const SizedBox(height: 16), - _buildOrderId(context), - const SizedBox(height: 16), - _buildCreatorReputation(order), - const SizedBox(height: 24), - _CountdownWidget( - order: order, - ), - const SizedBox(height: 36), - _buildActionButtons(context, ref, order), - ], - ), + title: widget.orderType == OrderType.buy + ? S.of(context)!.buyOrderDetailsTitle + : S.of(context)!.sellOrderDetailsTitle, ), + body: order == null + ? Center( + // Show spinner until the stream has emitted at least once. + // isLoading is not reliable here because orderEventsProvider + // is a StreamProvider backed by a repository that updates in + // place on settings changes (e.g. updateMostroInstance), + // so the stream never re-enters loading after a Mostro switch. + // Using !hasValue ensures we wait for the first emission + // before showing the empty-state icon. + child: !orderEventsAsync.hasValue + ? const CircularProgressIndicator() + : const Icon( + Icons.search_off, + size: 48, + color: Colors.white38, + ), + ) + : SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 16.0, + 16.0, + 16.0, + 16.0 + MediaQuery.of(context).viewPadding.bottom, + ), + child: Column( + children: [ + const SizedBox(height: 16), + _buildSellerAmount(ref, order), + const SizedBox(height: 16), + _buildPaymentMethod(context, order), + const SizedBox(height: 16), + _buildCreatedOn(order), + const SizedBox(height: 16), + _buildOrderId(context), + const SizedBox(height: 16), + _buildCreatorReputation(order), + const SizedBox(height: 24), + if (order.status == Status.pending) + _CountdownWidget(order: order), + const SizedBox(height: 36), + _buildActionButtons(context, ref, order), + ], + ), + ), ); } Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { return Builder( builder: (context) { - final currencyData = ref.watch(currencyCodesProvider).asData?.value; final currencyFlag = CurrencyUtils.getFlagFromCurrencyData( - order.currency!, currencyData); + order.currency!, + currencyData, + ); final amountString = '${order.fiatAmount} ${order.currency} $currencyFlag'; String priceText = ''; if (order.amount == '0') { final premium = order.premium; - final premiumValue = - premium != null ? double.tryParse(premium) ?? 0.0 : 0.0; + final premiumValue = premium != null + ? double.tryParse(premium) ?? 0.0 + : 0.0; if (premiumValue == 0) { // No premium - show only market price @@ -118,16 +141,15 @@ class _TakeOrderScreenState extends ConsumerState { } else { // Has premium/discount - show market price with percentage final isPremiumPositive = premiumValue >= 0; - final premiumDisplay = - isPremiumPositive ? '(+$premiumValue%)' : '($premiumValue%)'; + final premiumDisplay = isPremiumPositive + ? '(+$premiumValue%)' + : '($premiumValue%)'; priceText = '${S.of(context)!.atMarketPrice} $premiumDisplay'; } } - final hasFixedSatsAmount = order.amount != '0'; - return CustomCard( padding: const EdgeInsets.all(16), child: Column( @@ -136,11 +158,11 @@ class _TakeOrderScreenState extends ConsumerState { Text( hasFixedSatsAmount ? (widget.orderType == OrderType.sell - ? "${S.of(context)!.someoneIsSellingTitle.replaceAll(' Sats', '')} ${order.amount} Sats" - : "${S.of(context)!.someoneIsBuyingTitle.replaceAll(' Sats', '')} ${order.amount} Sats") + ? "${S.of(context)!.someoneIsSellingTitle.replaceAll(' Sats', '')} ${order.amount} Sats" + : "${S.of(context)!.someoneIsBuyingTitle.replaceAll(' Sats', '')} ${order.amount} Sats") : (widget.orderType == OrderType.sell - ? S.of(context)!.someoneIsSellingTitle - : S.of(context)!.someoneIsBuyingTitle), + ? S.of(context)!.someoneIsSellingTitle + : S.of(context)!.someoneIsBuyingTitle), style: const TextStyle( color: Colors.white, fontSize: 18, @@ -150,11 +172,15 @@ class _TakeOrderScreenState extends ConsumerState { const SizedBox(height: 8), Row( children: [ - Flexible( child: RichText( text: TextSpan( - text: S.of(context)!.forAmountWithCurrency(amountString, order.currency ?? ''), + text: S + .of(context)! + .forAmountWithCurrency( + amountString, + order.currency ?? '', + ), style: const TextStyle( color: Colors.white70, fontSize: 16, @@ -169,7 +195,6 @@ class _TakeOrderScreenState extends ConsumerState { ), ), ], - ), softWrap: true, maxLines: 2, @@ -186,9 +211,7 @@ class _TakeOrderScreenState extends ConsumerState { } Widget _buildOrderId(BuildContext context) { - return OrderIdCard( - orderId: widget.orderId, - ); + return OrderIdCard(orderId: widget.orderId); } Widget _buildPaymentMethod(BuildContext context, NostrEvent order) { @@ -196,9 +219,7 @@ class _TakeOrderScreenState extends ConsumerState { ? order.paymentMethods.join(', ') : S.of(context)!.noPaymentMethod; - return PaymentMethodCard( - paymentMethod: methods, - ); + return PaymentMethodCard(paymentMethod: methods); } Widget _buildCreatedOn(NostrEvent order) { @@ -218,20 +239,21 @@ class _TakeOrderScreenState extends ConsumerState { final reviews = ratingInfo?.totalReviews ?? 0; final days = ratingInfo?.days ?? 0; - return CreatorReputationCard( - rating: rating, - reviews: reviews, - days: days, - ); + return CreatorReputationCard(rating: rating, reviews: reviews, days: days); } Widget _buildActionButtons( - BuildContext context, WidgetRef ref, NostrEvent order) { - final orderDetailsNotifier = - ref.read(orderNotifierProvider(widget.orderId).notifier); + BuildContext context, + WidgetRef ref, + NostrEvent order, + ) { + final orderDetailsNotifier = ref.read( + orderNotifierProvider(widget.orderId).notifier, + ); - final buttonText = - widget.orderType == OrderType.buy ? S.of(context)!.sell : S.of(context)!.buy; + final buttonText = widget.orderType == OrderType.buy + ? S.of(context)!.sell + : S.of(context)!.buy; return Row( mainAxisAlignment: MainAxisAlignment.center, @@ -246,157 +268,221 @@ class _TakeOrderScreenState extends ConsumerState { const SizedBox(width: 16), Expanded( child: ElevatedButton( - onPressed: _isSubmitting ? null : () async { - setState(() { - _isSubmitting = true; - }); - // Check if this is a range order - if (order.fiatAmount.maximum != null && - order.fiatAmount.minimum != order.fiatAmount.maximum) { - // Show dialog to get the amount - String? errorText; - final enteredAmount = await showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - return AlertDialog( - backgroundColor: AppTheme.backgroundCard, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), - ), - title: Text( - S.of(context)!.enterAmount, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - content: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.backgroundInput, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white.withValues(alpha: 0.1)), - ), - child: TextField( - controller: widget._fiatAmountController, - keyboardType: TextInputType.number, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - hintText: S.of(context)!.enterAmountBetween( - order.fiatAmount.minimum.toString(), - order.fiatAmount.maximum.toString(), - order.currency ?? ''), - hintStyle: const TextStyle(color: AppTheme.textSecondary), - errorText: errorText, - errorStyle: const TextStyle(color: AppTheme.statusError), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - ), - ), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text( - S.of(context)!.cancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(width: 12), - ElevatedButton( - key: const Key('submitAmountButton'), - onPressed: () { - final inputAmount = int.tryParse( - widget._fiatAmountController.text.trim()); - if (inputAmount == null) { - setState(() { - errorText = - S.of(context)!.pleaseEnterValidNumber; - }); - } else if (inputAmount < - order.fiatAmount.minimum || - (order.fiatAmount.maximum != null && - inputAmount > - order.fiatAmount.maximum!)) { - setState(() { - errorText = S - .of(context)! - .amountMustBeBetween( - order.fiatAmount.minimum.toString(), - order.fiatAmount.maximum - .toString()); - }); - } else { - context.pop(inputAmount); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, + onPressed: _isSubmitting + ? null + : () async { + setState(() { + _isSubmitting = true; + }); + // Check if this is a range order + if (order.fiatAmount.maximum != null && + order.fiatAmount.minimum != order.fiatAmount.maximum) { + // Show dialog to get the amount + String? errorText; + final enteredAmount = await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + backgroundColor: AppTheme.backgroundCard, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + ), ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - child: Text( - S.of(context)!.submit, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, + title: Text( + S.of(context)!.enterAmount, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), ), - ), - ), - ], + content: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.white.withValues( + alpha: 0.1, + ), + ), + ), + child: TextField( + controller: widget._fiatAmountController, + keyboardType: TextInputType.number, + style: const TextStyle( + color: AppTheme.textPrimary, + ), + decoration: InputDecoration( + hintText: S + .of(context)! + .enterAmountBetween( + order.fiatAmount.minimum.toString(), + order.fiatAmount.maximum.toString(), + order.currency ?? '', + ), + hintStyle: const TextStyle( + color: AppTheme.textSecondary, + ), + errorText: errorText, + errorStyle: const TextStyle( + color: AppTheme.statusError, + ), + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + ElevatedButton( + key: const Key('submitAmountButton'), + onPressed: () { + final inputAmount = int.tryParse( + widget._fiatAmountController.text + .trim(), + ); + if (inputAmount == null) { + setState(() { + errorText = S + .of(context)! + .pleaseEnterValidNumber; + }); + } else if (inputAmount < + order.fiatAmount.minimum || + (order.fiatAmount.maximum != null && + inputAmount > + order.fiatAmount.maximum!)) { + setState(() { + errorText = S + .of(context)! + .amountMustBeBetween( + order.fiatAmount.minimum + .toString(), + order.fiatAmount.maximum + .toString(), + ); + }); + } else { + context.pop(inputAmount); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.submit, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + }, + ); + }, + ); + + if (enteredAmount != null) { + try { + if (widget.orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, + enteredAmount, + ); + } else { + final lndAddress = widget._lndAddressController.text + .trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + enteredAmount, + lndAddress.isEmpty ? null : lndAddress, + ); + } + } catch (e, stackTrace) { + logger.e( + 'Failed to take order', + error: e, + stackTrace: stackTrace, + ); + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } + } else { + // Dialog was dismissed without entering amount, reset loading state + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } + } else { + // Not a range order – use the existing logic. + final fiatAmount = int.tryParse( + widget._fiatAmountController.text.trim(), + ); + try { + if (widget.orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, + fiatAmount, + ); + } else { + final lndAddress = widget._lndAddressController.text + .trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + fiatAmount, + lndAddress.isEmpty ? null : lndAddress, + ); + } + } catch (e, stackTrace) { + logger.e( + 'Failed to take order', + error: e, + stackTrace: stackTrace, ); - }, - ); + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } + } }, - ); - - if (enteredAmount != null) { - if (widget.orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder( - order.orderId!, enteredAmount); - } else { - final lndAddress = widget._lndAddressController.text.trim(); - await orderDetailsNotifier.takeSellOrder( - order.orderId!, - enteredAmount, - lndAddress.isEmpty ? null : lndAddress, - ); - } - } else { - // Dialog was dismissed without entering amount, reset loading state - setState(() { - _isSubmitting = false; - }); - } - } else { - // Not a range order – use the existing logic. - final fiatAmount = - int.tryParse(widget._fiatAmountController.text.trim()); - if (widget.orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder( - order.orderId!, fiatAmount); - } else { - final lndAddress = widget._lndAddressController.text.trim(); - await orderDetailsNotifier.takeSellOrder( - order.orderId!, - fiatAmount, - lndAddress.isEmpty ? null : lndAddress, - ); - } - } - }, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.mostroGreen, ), @@ -419,10 +505,12 @@ class _TakeOrderScreenState extends ConsumerState { String formatDateTime(DateTime dt, [BuildContext? context]) { if (context != null) { // Use internationalized date format - final dateFormatter = - DateFormat.yMMMd(Localizations.localeOf(context).languageCode); - final timeFormatter = - DateFormat.Hm(Localizations.localeOf(context).languageCode); + final dateFormatter = DateFormat.yMMMd( + Localizations.localeOf(context).languageCode, + ); + final timeFormatter = DateFormat.Hm( + Localizations.localeOf(context).languageCode, + ); final formattedDate = dateFormatter.format(dt); final formattedTime = timeFormatter.format(dt); @@ -444,9 +532,7 @@ class _TakeOrderScreenState extends ConsumerState { class _CountdownWidget extends ConsumerWidget { final NostrEvent order; - const _CountdownWidget({ - required this.order, - }); + const _CountdownWidget({required this.order}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -463,7 +549,10 @@ class _CountdownWidget extends ConsumerWidget { } Widget _buildCountDownTime( - BuildContext context, WidgetRef ref, NostrEvent order) { + BuildContext context, + WidgetRef ref, + NostrEvent order, + ) { // Use exact timestamps from expires_at if (order.expiresAt == null) { // No valid expiration timestamp available @@ -474,15 +563,14 @@ class _CountdownWidget extends ConsumerWidget { if (expiresAtSeconds == null || expiresAtSeconds <= 0) { return const SizedBox.shrink(); } - final expiration = DateTime.fromMillisecondsSinceEpoch(expiresAtSeconds * 1000); + final expiration = DateTime.fromMillisecondsSinceEpoch( + expiresAtSeconds * 1000, + ); final createdAt = order.createdAt; if (createdAt == null) { return const SizedBox.shrink(); } - return DynamicCountdownWidget( - expiration: expiration, - createdAt: createdAt, - ); + return DynamicCountdownWidget(expiration: expiration, createdAt: createdAt); } } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index bcace0a2d..d1aed7963 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -101,7 +101,7 @@ } } }, - "buyerInvoiceAccepted": "Die Rechnung wurde erfolgreich gespeichert.", + "buyerInvoiceAccepted": "Die Rechnung wurde erfolgreich gespeichert.", "holdInvoicePaymentAccepted": "Kontaktiere den Verkäufer {seller_name}, um zu vereinbaren, wie du {fiat_code} {fiat_amount} mit {payment_method} sendest. Sobald du das Fiat-Geld gesendet hast, benachrichtige mich durch Drücken der Schaltfläche 'Fiat gesendet'.", "buyerTookOrder": "Kontaktiere den Käufer {buyer_name}, um ihm mitzuteilen, wie er {fiat_code} {fiat_amount} über {payment_method} senden soll. Du wirst benachrichtigt, wenn der Käufer die Fiat-Zahlung bestätigt. Überprüfe danach, ob das Geld angekommen ist. Wenn der Käufer nicht antwortet, kannst du eine Stornierung oder einen Streitfall (Dispute) einleiten. Denke daran: Ein Administrator wird dich NIEMALS von sich aus kontaktieren, um deine Order zu klären, es sei denn, du eröffnest zuerst einen Streitfall.", "fiatSentOkBuyer": "Ich habe {seller_name} darüber informiert, dass du das Fiat-Geld gesendet hast. Wenn der Verkäufer den Erhalt bestätigt, wird er die Beträge freigeben. Wenn er sich weigert, kannst du einen Streitfall eröffnen.", @@ -198,7 +198,7 @@ "buyBtc": "BTC KAUFEN", "sellBtc": "BTC VERKAUFEN", "filter": "FILTER", - "statusFilter": "Status", + "statusFilter": "Status", "allStatuses": "Alle", "all": "Alle", "statusPending": "Ausstehend", @@ -298,7 +298,7 @@ "cancelPendingButton": "AUSSTEHENDE STORNIEREN", "acceptCancelButton": "ABBRECHEN", "disputeButton": "STREITFALL", - "fiatSentButton": "FIAT GESENDET", + "fiatSentButton": "FIAT GESENDET", "completePurchaseButton": "KAUF ABSCHLIESSEN", "paymentMethodLabel": "Zahlungsmethode", "createdOnLabel": "Erstellt am", @@ -498,7 +498,7 @@ "generateNewUser": "Neuen Nutzer generieren", "importMostroUser": "Nutzer importieren", "refreshUser": "Nutzer aktualisieren", - "keyImportedSuccessfully": "Schlüssel erfolgreich importiert", + "keyImportedSuccessfully": "Schlüssel erfolgreich importiert", "importFailed": "Import fehlgeschlagen: {error}", "@importFailed": { "placeholders": { @@ -698,7 +698,7 @@ } }, "failedToGenerateQR": "QR-Code konnte nicht generiert werden", - "invoiceCopiedToClipboard": "Rechnung in die Zwischenablage kopiert", + "invoiceCopiedToClipboard": "Rechnung in die Zwischenablage kopiert", "copiedToClipboard": "In die Zwischenablage kopiert", "copy": "Kopieren", "share": "Teilen", @@ -899,7 +899,7 @@ "usersDocumentationSpanish": "Benutzerdokumentation (Spanisch)", "technicalDocumentation": "Technische Dokumentation", "read": "Lesen", - "technicalDetails": "Technische Details", + "technicalDetails": "Technische Details", "mostroDaemonVersion": "Mostro-Version", "mostroCommitId": "Mostro Commit-ID", "orderExpiration": "Order-Ablaufzeit", @@ -1099,7 +1099,7 @@ "discount": "Rabatt", "premium": "Aufschlag", "clear": "Löschen", - "min": "Min", + "min": "Min", "max": "Max", "@_comment_timeout_messages": "Nachrichten für Zeitüberschreitungen", "orderTimeoutTaker": "Du hast nicht rechtzeitig geantwortet. Die Order wird neu veröffentlicht.", @@ -1299,7 +1299,7 @@ "@_comment_logging_ui": "Logging-UI Texte", "logCapture": "Log-Erfassung", "capturingLogs": "Logs werden erfasst", - "captureDisabled": "Erfassung deaktiviert", + "captureDisabled": "Erfassung deaktiviert", "performanceWarning": "Leistungswarnung", "performanceWarningMessage": "Das Aktivieren der Log-Erfassung kann die Anwendungsleistung beeinträchtigen. Aktiviere diese Funktion nur zum Debuggen oder zur Fehlersuche.", "enable": "Aktivieren", @@ -1504,5 +1504,10 @@ }, "cameraPermissionDenied": "Kamerazugriff wird benötigt, um QR-Codes zu scannen", "toggleTorch": "Taschenlampe umschalten", - "switchCamera": "Kamera wechseln" + "switchCamera": "Kamera wechseln", + "deepLinkDifferentMostroTitle": "Andere Mostro-Instanz", + "deepLinkDifferentMostroBody": "Diese Order wurde auf einem anderen Mostro-Knoten erstellt. Möchtest du zu diesem Mostro wechseln und die Order ansehen?", + "deepLinkDifferentMostroFrom": "Order von:", + "deepLinkDifferentMostroCurrent": "Aktuell verbunden mit:", + "deepLinkSwitchAndView": "Wechseln und ansehen" } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index e734309a9..d9668ebb6 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1504,5 +1504,10 @@ }, "cameraPermissionDenied": "Camera permission is required to scan QR codes", "toggleTorch": "Toggle flashlight", - "switchCamera": "Switch camera" -} + "switchCamera": "Switch camera", + "deepLinkDifferentMostroTitle": "Different Mostro Instance", + "deepLinkDifferentMostroBody": "This order was created on a different Mostro node. Do you want to switch to this Mostro and view the order?", + "deepLinkDifferentMostroFrom": "Order from:", + "deepLinkDifferentMostroCurrent": "Currently connected to:", + "deepLinkSwitchAndView": "Switch & View" +} \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 54e11537c..93479ae10 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1479,5 +1479,10 @@ }, "cameraPermissionDenied": "Se requiere permiso de cámara para escanear códigos QR", "toggleTorch": "Activar/desactivar linterna", - "switchCamera": "Cambiar cámara" -} + "switchCamera": "Cambiar cámara", + "deepLinkDifferentMostroTitle": "Instancia de Mostro diferente", + "deepLinkDifferentMostroBody": "Esta orden fue creada en un nodo Mostro diferente. ¿Deseas cambiar a este Mostro y ver la orden?", + "deepLinkDifferentMostroFrom": "Orden de:", + "deepLinkDifferentMostroCurrent": "Actualmente conectado a:", + "deepLinkSwitchAndView": "Cambiar y ver" +} \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 9b90b63e3..b498eaeb1 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1504,5 +1504,10 @@ }, "cameraPermissionDenied": "L'autorisation de la caméra est requise pour scanner les codes QR", "toggleTorch": "Activer/désactiver la lampe", - "switchCamera": "Changer de caméra" -} + "switchCamera": "Changer de caméra", + "deepLinkDifferentMostroTitle": "Instance Mostro différente", + "deepLinkDifferentMostroBody": "Cette commande a été créée sur un nœud Mostro différent. Voulez-vous basculer vers ce Mostro et voir la commande ?", + "deepLinkDifferentMostroFrom": "Commande de :", + "deepLinkDifferentMostroCurrent": "Actuellement connecté à :", + "deepLinkSwitchAndView": "Basculer et voir" +} \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index fa44782b8..ff66e801f 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1538,5 +1538,10 @@ }, "cameraPermissionDenied": "È necessaria l'autorizzazione della fotocamera per scansionare i codici QR", "toggleTorch": "Attiva/disattiva torcia", - "switchCamera": "Cambia fotocamera" -} + "switchCamera": "Cambia fotocamera", + "deepLinkDifferentMostroTitle": "Istanza Mostro diversa", + "deepLinkDifferentMostroBody": "Questo ordine è stato creato su un nodo Mostro diverso. Vuoi passare a questo Mostro e visualizzare l'ordine?", + "deepLinkDifferentMostroFrom": "Ordine da:", + "deepLinkDifferentMostroCurrent": "Attualmente connesso a:", + "deepLinkSwitchAndView": "Cambia e visualizza" +} \ No newline at end of file diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart index 5d309503b..e9a8e690c 100644 --- a/lib/services/deep_link_service.dart +++ b/lib/services/deep_link_service.dart @@ -15,14 +15,17 @@ class OrderInfo { final String orderId; final OrderType orderType; + /// Mostro instance pubkey from the deep link, if present. + final String? mostroPubkey; + const OrderInfo({ required this.orderId, required this.orderType, + this.mostroPubkey, }); } class DeepLinkService { - final AppLinks _appLinks = AppLinks(); // Stream controller for deep link events @@ -51,7 +54,7 @@ class DeepLinkService { // NOTE: We don't process the initial link here to avoid GoRouter conflicts // The initial link will be handled by the app initialization in app.dart - + _isInitialized = true; logger.i('DeepLinkService initialized successfully'); } catch (e) { @@ -87,6 +90,7 @@ class DeepLinkService { final orderId = orderInfo['orderId'] as String; final relays = orderInfo['relays'] as List; + final mostroPubkey = orderInfo['mostroPubkey'] as String?; // Validate order ID format (UUID-like string) if (orderId.isEmpty || orderId.length < 10) { @@ -105,6 +109,7 @@ class DeepLinkService { orderId, relays, nostrService, + mostroPubkey: mostroPubkey, ); if (fetchedOrderInfo == null) { @@ -126,14 +131,15 @@ class DeepLinkService { Future _fetchOrderInfoById( String orderId, List relays, - NostrService nostrService, - ) async { + NostrService nostrService, { + String? mostroPubkey, + }) async { try { // Create a filter to search for NIP-69 order events with the specific order ID final filter = NostrFilter( kinds: [38383], // NIP-69 order events additionalFilters: { - '#d': [orderId] + '#d': [orderId], }, // Order ID is stored in 'd' tag ); @@ -141,21 +147,58 @@ class DeepLinkService { // First try to fetch from specified relays if (relays.isNotEmpty) { - // Use the specific relays from the deep link URL - final orderEvents = await nostrService.fetchEvents(filter, specificRelays: relays); + final orderEvents = await nostrService.fetchEvents( + filter, + specificRelays: relays, + ); events.addAll(orderEvents); } - // If no events found and we have default relays, try those - if (events.isEmpty) { - logger.i('Order not found in specified relays, trying default relays'); + // Helper to build the candidate list from a set of raw events. + // When mostroPubkey is present only events authored by that node are + // accepted. isVerified() failures are logged but not treated as hard + // rejections due to a known dart_nostr limitation (consistent with + // how mostro_nodes_notifier.dart handles kind-0 events). + List buildCandidates(List raw) { + if (mostroPubkey == null) return raw; + return raw.where((e) { + if (!e.isVerified()) { + logger.w( + 'Event \${e.id} from pubkey \${e.pubkey} failed signature ' + 'verification — rejecting to prevent spoofing.', + ); + return false; + } + return e.pubkey == mostroPubkey; + }).toList(); + } + + var candidateEvents = buildCandidates(events); + + // If no matching candidate was found in the link relays, retry with the + // app's default relays. This covers the case where events from other + // Mostro nodes were returned by the link relays (events non-empty but + // candidateEvents empty), which previously skipped the fallback entirely. + if (candidateEvents.isEmpty) { + logger.i( + 'No matching event in specified relays, trying default relays', + ); final defaultEvents = await nostrService.fetchEvents(filter); events.addAll(defaultEvents); + candidateEvents = buildCandidates(events); + } + + if (candidateEvents.isEmpty && mostroPubkey != null) { + logger.w( + 'Order $orderId not found for Mostro pubkey $mostroPubkey ' + '(found ${events.length} event(s) from other nodes)', + ); + return null; } // Process the first matching event - if (events.isNotEmpty) { - final event = events.first; + if (candidateEvents.isNotEmpty) { + final event = candidateEvents.first; // Extract order type from 'k' tag final kTag = event.tags?.firstWhere( @@ -165,12 +208,20 @@ class DeepLinkService { if (kTag != null && kTag.length > 1) { final orderTypeValue = kTag[1]; - final orderType = - orderTypeValue == 'sell' ? OrderType.sell : OrderType.buy; + final OrderType? orderType; + if (orderTypeValue == 'sell') { + orderType = OrderType.sell; + } else if (orderTypeValue == 'buy') { + orderType = OrderType.buy; + } else { + logger.w('Unknown order type in k tag: $orderTypeValue'); + return null; + } return OrderInfo( orderId: orderId, orderType: orderType, + mostroPubkey: mostroPubkey, ); } } @@ -197,8 +248,9 @@ class DeepLinkService { void navigateToOrder(GoRouter router, OrderInfo orderInfo) { final route = getNavigationRoute(orderInfo); logger.i( - 'Navigating to: $route (Order: ${orderInfo.orderId}, Type: ${orderInfo.orderType.value})'); - + 'Navigating to: $route (Order: ${orderInfo.orderId}, Type: ${orderInfo.orderType.value})', + ); + // Use post-frame callback to ensure navigation happens after the current frame // This prevents GoRouter assertion failures during app lifecycle transitions WidgetsBinding.instance.addPostFrameCallback((_) { @@ -235,24 +287,14 @@ class DeepLinkResult { final OrderInfo? orderInfo; final String? error; - const DeepLinkResult._({ - required this.isSuccess, - this.orderInfo, - this.error, - }); + const DeepLinkResult._({required this.isSuccess, this.orderInfo, this.error}); factory DeepLinkResult.success(OrderInfo orderInfo) { - return DeepLinkResult._( - isSuccess: true, - orderInfo: orderInfo, - ); + return DeepLinkResult._(isSuccess: true, orderInfo: orderInfo); } factory DeepLinkResult.error(String error) { - return DeepLinkResult._( - isSuccess: false, - error: error, - ); + return DeepLinkResult._(isSuccess: false, error: error); } } diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 41d983f10..ca9c0f5e2 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -153,8 +153,8 @@ class NostrUtils { } /// Parses a mostro: URL and returns order information - /// Format: mostro:order-id&relays=wss://relay1,wss://relay2 - /// Returns a map with 'orderId' and 'relays' keys + /// Format: `mostro:order-id?relays=wss://relay1,wss://relay2&mostro=pubkey` + /// Returns a map with 'orderId', 'relays', and optionally 'mostroPubkey' keys static Map? parseMostroUrl(String url) { if (!isValidMostroUrl(url)) return null; @@ -172,7 +172,22 @@ class NostrUtils { .where((relay) => relay.isNotEmpty) .toList(); - return {'orderId': orderId, 'relays': relays}; + final result = {'orderId': orderId, 'relays': relays}; + + // Extract and validate optional Mostro instance pubkey (must be 64-char hex) + final rawMostroPubkey = uri.queryParameters['mostro']; + if (rawMostroPubkey != null && rawMostroPubkey.isNotEmpty) { + final normalized = rawMostroPubkey.trim().toLowerCase().replaceFirst( + '0x', + '', + ); + if (normalized.length == 64 && + RegExp(r'^[0-9a-f]{64}$').hasMatch(normalized)) { + result['mostroPubkey'] = normalized; + } + } + + return result; } catch (e) { return null; } diff --git a/test/shared/utils/deep_link_parsing_test.dart b/test/shared/utils/deep_link_parsing_test.dart new file mode 100644 index 000000000..cc381545b --- /dev/null +++ b/test/shared/utils/deep_link_parsing_test.dart @@ -0,0 +1,145 @@ +import 'package:test/test.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +void main() { + group('NostrUtils.parseMostroUrl — mostro pubkey extraction', () { + test('parses URL without mostro param (backward compatible)', () { + const url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['orderId'], 'e215c07e-b1f9-45b0-9640-0295067ee99a'); + expect(result['relays'], ['wss://relay.mostro.network']); + expect(result['mostroPubkey'], isNull); + }); + + test('parses URL with mostro pubkey param', () { + const pubkey = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + final url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network&mostro=$pubkey'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['orderId'], 'e215c07e-b1f9-45b0-9640-0295067ee99a'); + expect(result['relays'], ['wss://relay.mostro.network']); + expect(result['mostroPubkey'], pubkey); + }); + + test('parses URL with multiple relays and mostro pubkey', () { + const pubkey = + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678ab'; + final url = + 'mostro:order-id-123?relays=wss://relay1.example.com,wss://relay2.example.com&mostro=$pubkey'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['relays'], hasLength(2)); + expect(result['mostroPubkey'], pubkey); + }); + + test('ignores empty mostro param', () { + const url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network&mostro='; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'], isNull); + }); + + test('rejects malformed pubkey (too short)', () { + const url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network&mostro=abc123'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'], isNull); + }); + + test('rejects pubkey with non-hex characters', () { + const url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network&mostro=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'], isNull); + }); + + test('normalizes uppercase pubkey to lowercase', () { + const url = + 'mostro:e215c07e-b1f9-45b0-9640-0295067ee99a?relays=wss://relay.mostro.network&mostro=82FA8CB978B43C79B2156585BAC2C011176A21D2AEAD6D9F7C575C005BE88390'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect( + result!['mostroPubkey'], + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390', + ); + }); + + test('isValidMostroUrl accepts URL with mostro param', () { + const url = + 'mostro:e215c07e?relays=wss://relay.mostro.network&mostro=abc123'; + expect(NostrUtils.isValidMostroUrl(url), isTrue); + }); + + test('isValidMostroUrl still rejects URL without relays', () { + const url = 'mostro:e215c07e?mostro=abc123'; + expect(NostrUtils.isValidMostroUrl(url), isFalse); + }); + }); + + group('Mostro instance comparison via parseMostroUrl', () { + const currentPubkey = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + + test('same pubkey in link matches current instance', () { + final url = + 'mostro:order-123?relays=wss://relay.mostro.network&mostro=$currentPubkey'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'] == currentPubkey, isTrue); + }); + + test('different pubkey in link does not match current instance', () { + const otherPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final url = + 'mostro:order-123?relays=wss://relay.mostro.network&mostro=$otherPubkey'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'] == currentPubkey, isFalse); + expect(result['mostroPubkey'], otherPubkey); + }); + + test('mixed-case pubkey is normalized and matches lowercase', () { + final url = + 'mostro:order-123?relays=wss://relay.mostro.network&mostro=82FA8CB978B43C79B2156585BAC2C011176A21D2AEAD6D9F7C575C005BE88390'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'] == currentPubkey, isTrue); + }); + + test('absent mostro param means same instance (backward compatible)', () { + const url = 'mostro:order-123?relays=wss://relay.mostro.network'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + // null mostroPubkey → app treats as same instance (no switch dialog) + expect(result!['mostroPubkey'], isNull); + }); + + test('malformed pubkey is silently dropped (treated as same instance)', () { + const url = + 'mostro:order-123?relays=wss://relay.mostro.network&mostro=not-a-valid-key'; + final result = NostrUtils.parseMostroUrl(url); + + expect(result, isNotNull); + expect(result!['mostroPubkey'], isNull); + }); + }); +}