diff --git a/docs/automation-contract.md b/docs/automation-contract.md new file mode 100644 index 000000000..4414a31b6 --- /dev/null +++ b/docs/automation-contract.md @@ -0,0 +1,132 @@ +# Automation contract + +This document is the product contract between the mobile app and black-box +UI automation (Mortsom, `../mortsom`). It covers stable semantic identifiers, +the test-environment build, and the rules for changing either. Everything +here is product code: changes go through normal review, and a change to an +identifier, a visible business state or the test-environment behaviour is a +**contract change** that requires coordinated review with the automation +owners. + +## 1. Semantic identifiers + +Every actionable control and business-critical state carries a stable +identifier from `lib/core/automation/automation_ids.dart`, attached with the +`.withAutomationId()` extension (`lib/core/automation/automation_id.dart`). +Flutter exposes it as `Semantics.identifier`, which Android surfaces as the +accessibility `resource-id`; drivers locate it with a UiAutomator +`resourceId("")` selector. Identifiers are namespaced +`..` and never localized. + +The extension applies at the end of the expression rather than wrapping it, +so naming a control neither re-indents its subtree nor adds a level of +nesting to `build()`: + +```dart +ElevatedButton( + onPressed: _submit, + child: Text(S.of(context)!.confirm), +).withAutomationId(AutomationIds.orderConfirm) +``` + +It builds the `AutomationId` widget underneath; reach for that widget +directly only where an extension call cannot be expressed. + +Two modes exist: + +- **merged** (default): the wrapped subtree collapses into one node, so the + identifier travels with the visible label, the enabled flag and the tap + action. Use it for buttons, text fields and single-purpose controls. +- **container** (`merge: false`): the identifier names a row or card that + contains several independent controls; an explicit `label` may describe a + business state (`connected` / `disconnected`, the wire status of an order). + +`test/core/automation/automation_contract_test.dart` fails when an identifier +listed below disappears, is renamed, or stops being namespaced. + +| Identifier | Owner | Behavioural contract | +|---|---|---| +| `env.marker` | app shell | Present on every screen only in the test environment; label `TEST ENVIRONMENT · Mortsom`. | +| `appbar.drawer`, `appbar.back` | shell | Open the drawer / go back. | +| `nav.order_book`, `nav.trades`, `nav.chat` | shell | Bottom navigation tabs. | +| `drawer.account`, `drawer.settings`, `drawer.about` | shell | Drawer destinations. | +| `onboarding.walkthrough.{back,skip,next,done}` | onboarding | Walkthrough controls; `done`/`skip` mark first run complete. | +| `onboarding.community.card.` | onboarding | Selects that community/node. | +| `onboarding.community.notice.accept` | onboarding | Accepts the legal notice raised on first launch. | +| `onboarding.community.custom_node` | onboarding | Opens the custom-node dialog. | +| `onboarding.community.done`, `onboarding.community.skip` | onboarding | Confirm selection / skip. | +| `keys.public_key` | account | Read-only npub of the current account (label = npub). | +| `keys.generate`, `keys.generate.confirm`, `keys.generate.cancel` | account | Generate a new identity; confirm or dismiss the dialog. | +| `keys.import`, `keys.import.mnemonic`, `keys.import.confirm`, `keys.import.cancel` | account | Import a mnemonic; the field is a secret input and is never logged. | +| `keys.seed.reveal`, `keys.seed.text` | account | Reveal / display the seed phrase (sensitive; automation never records it). | +| `settings.mostro_node`, `settings.mostro_node.pubkey` | settings | Opens the node selector; read-only pubkey of the selected node. | +| `settings.wallet` | settings | Wallet status card; opens wallet settings. | +| `settings.relays.add`, `settings.relays.add.url`, `settings.relays.add.confirm`, `settings.relays.add.cancel` | relays | Add a relay through the dialog. | +| `settings.relays.item.`, `settings.relays.item..delete` | relays | Relay row and its delete control. `` is the relay URL with trailing slashes removed, so one relay never carries two identifiers. | +| `node.add_custom`, `node.custom.pubkey`, `node.custom.name`, `node.custom.confirm`, `node.custom.cancel`, `node.item.` | mostro node | Custom node dialog and node rows. | +| `wallet.nwc.uri`, `wallet.nwc.connect` | wallet | NWC URI input (secret) and connect action. | +| `wallet.connection` | wallet | State readout; label is `connected` or `disconnected`. | +| `wallet.settings.connect`, `wallet.settings.disconnect` | wallet | Open the connect screen / disconnect the wallet from wallet settings. | +| `order.book.tab.buy`, `order.book.tab.sell` | order book | Switch the book between buy and sell offers. | +| `order.add.fab`, `order.add.buy`, `order.add.sell` | order book | Open the create-order menu and pick a side. | +| `order.book.item.` | order book | Opens the order (take screen or trade detail). | +| `order.create.currency`, `order.create.currency.` | create order | Currency picker and its options. | +| `order.create.fiat_amount`, `order.create.fiat_amount_max` | create order | Fiat amount (and range maximum). | +| `order.create.payment_method`, `order.create.price_type`, `order.create.sats_amount` | create order | Payment method, market/fixed toggle, sats amount (fixed price). | +| `order.create.submit`, `order.create.cancel`, `order.confirm.home` | create order | Submit / cancel; back to home from the confirmation screen. | +| `order.take.confirm`, `order.take.close`, `order.take.amount`, `order.take.amount.confirm` | take order | Take the order; range amount dialog. | +| `order.id` | trade | Read-only order id (label = id). | +| `order.status` | trade | Read-only order status; label is the wire status (`pending`, `waiting-payment`, `active`, `fiat-sent`, `success`, `canceled`, ...). | +| `trades.item.`, `trades.item.status` | trades | Trade row; status chip whose label is the wire status. | +| `trade.` (`trade.payInvoice`, `trade.addInvoice`, `trade.fiatSent`, `trade.release`, `trade.takeSell`, `trade.takeBuy`, `trade.rate`, `trade.cancel`, `trade.dispute`, ...) | trade | Trade action buttons named after the protocol action. | +| `trade.release.confirm`, `trade.cancel.confirm`, `trade.dispute.confirm` | trade | Confirmation dialogs. | +| `invoice.text`, `invoice.submit`, `invoice.cancel` | invoice | Buyer invoice entry. | +| `invoice.nwc.generate`, `invoice.nwc.confirm` | invoice | Generate the buyer invoice with the connected wallet and confirm it. | +| `invoice.nwc.text` | invoice | Read-only generated buyer invoice (label = bolt11). | +| `pay.invoice.text` | payment | Read-only invoice being paid (label = bolt11); invisible readout for correlation. | +| `pay.nwc` | payment | Pay the displayed invoice with the connected wallet. | +| `pay.cancel` | payment | Cancel the order from the pay-invoice screen. | + +## 2. Test-environment build + +The Mortsom build is the same application with a different Dart entry point: + +```sh +flutter build apk -t lib/main_mortsom.dart \ + --dart-define=MORTSOM_TEST_ENV=true \ + --dart-define=MOSTRO_PUB_KEY= \ + --dart-define=MORTSOM_RELAYS=ws://10.0.2.2:7000 +``` + +`lib/core/test_environment.dart` enables the test environment only when +**both** the entry point armed it and `MORTSOM_TEST_ENV=true` was compiled +in. `lib/main.dart` never arms it, and the release pipeline never passes the +define, so a release build cannot enter the test environment by accident. +When enabled: + +- the local relay seed list (`MORTSOM_RELAYS`) becomes the user relays on the + first launch of a fresh install, before any subscription starts; +- relay discovery never falls back to the public bootstrap relays + (`Config.bootstrapRelays`): `Config.discoveryRelays` resolves to the seed + list instead, so a disconnected local relay produces a test failure, not + public-network traffic. A build that arms the test environment without + `MORTSOM_RELAYS` fails at startup rather than starting with no relay; +- plain `ws://` relays on private addresses are accepted by the add-relay + validation; +- a red `TEST ENVIRONMENT · Mortsom` banner (`env.marker`) is shown on every + screen. + +The banner copy is deliberately not localized: it is test-only tooling and +follows Mortsom's English-only policy. + +Only non-secret values travel through Dart defines (daemon pubkey, relay +endpoints). Mnemonics and NWC URIs are entered through the UI by the +automation, never compiled in and never logged. + +## 3. Changing the contract + +1. Update `AutomationIds`, the call site, and this table, in one change. +2. Keep `flutter test test/core/automation` green; add the identifier to the + contract test list. +3. Notify the automation owners; Mortsom's adapter contract tests pin the + same identifiers. diff --git a/lib/core/app.dart b/lib/core/app.dart index 9f6a57475..1f92e3387 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -20,6 +20,7 @@ import 'package:mostro_mobile/features/community/providers/community_selector_pr import 'package:mostro_mobile/features/walkthrough/providers/first_run_provider.dart'; import 'package:mostro_mobile/features/restore/restore_overlay.dart'; import 'package:mostro_mobile/shared/widgets/nwc_notification_listener.dart'; +import 'package:mostro_mobile/shared/widgets/test_environment_banner.dart'; class MostroApp extends ConsumerStatefulWidget { const MostroApp({super.key}); @@ -175,7 +176,8 @@ class _MostroAppState extends ConsumerState { if (!mounted) return; if (payload != null && payload.isNotEmpty) { final route = resolveNotificationRoute(payload); - debugPrint('App launched from notification tap, navigating to: $route'); + debugPrint( + 'App launched from notification tap, navigating to: $route'); _router!.push(route); } }); @@ -188,11 +190,13 @@ class _MostroAppState extends ConsumerState { routerConfig: _router!, builder: (context, child) { return NwcNotificationListener( - child: Stack( - children: [ - if (child != null) child, - const RestoreOverlay(), - ], + child: TestEnvironmentBanner( + child: Stack( + children: [ + if (child != null) child, + const RestoreOverlay(), + ], + ), ), ); }, diff --git a/lib/core/app_bootstrap.dart b/lib/core/app_bootstrap.dart new file mode 100644 index 000000000..d264c7e63 --- /dev/null +++ b/lib/core/app_bootstrap.dart @@ -0,0 +1,209 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mostro_mobile/core/app.dart'; +import 'package:mostro_mobile/features/relays/relay.dart'; +import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; +import 'package:mostro_mobile/features/relays/relays_provider.dart'; +import 'package:mostro_mobile/features/settings/settings_notifier.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/background/background_service.dart'; +import 'package:mostro_mobile/features/notifications/services/background_notification_service.dart'; +import 'package:mostro_mobile/services/fcm_service.dart'; +import 'package:mostro_mobile/services/push_notification_service.dart'; +import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/providers.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; +import 'package:mostro_mobile/shared/utils/notification_permission_helper.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:timeago/timeago.dart' as timeago; + +/// Builds every dependency, seeds optional test relays and runs the app. +/// +/// `seedRelays` is non-empty only for the Mortsom test entry point +/// (`lib/main_mortsom.dart`): on the first launch of a fresh install the +/// list becomes the user relays, so relay discovery starts from the local +/// test relay instead of the public bootstrap relays. +Future bootstrapAndRun({List seedRelays = const []}) async { + WidgetsFlutterBinding.ensureInitialized(); + + initIsolateLogReceiver(); + + await requestNotificationPermissionIfNeeded(); + + final biometricsHelper = BiometricsHelper(); + final sharedPreferences = SharedPreferencesAsync(); + final secureStorage = const FlutterSecureStorage(); + + final mostroDatabase = await openMostroDatabase('mostro.db'); + final eventsDatabase = await openMostroDatabase('events.db'); + + final settings = SettingsNotifier(sharedPreferences); + await settings.init(); + await seedTestRelays(settings, seedRelays); + + await initializeNotifications(); + + _initializeTimeAgoLocalization(); + + final backgroundService = createBackgroundService(settings.settings); + await backgroundService.init(); + + // Initialize FCM (skip on Linux) + final pushServices = await _initializeFirebaseMessaging(sharedPreferences); + + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith((b) => settings), + backgroundServiceProvider.overrideWithValue(backgroundService), + biometricsHelperProvider.overrideWithValue(biometricsHelper), + sharedPreferencesProvider.overrideWithValue(sharedPreferences), + secureStorageProvider.overrideWithValue(secureStorage), + mostroDatabaseProvider.overrideWithValue(mostroDatabase), + eventDatabaseProvider.overrideWithValue(eventsDatabase), + if (pushServices != null) ...[ + fcmServiceProvider.overrideWithValue(pushServices.fcmService), + pushNotificationServiceProvider + .overrideWithValue(pushServices.pushService), + ], + ], + ); + + // Initialize relay sync on app start + _initializeRelaySynchronization(container); + + // Connect push notification service with session notifier and settings + if (pushServices != null) { + _initializePushNotificationIntegration(container, pushServices); + } + + runApp( + UncontrolledProviderScope( + container: container, + child: const MostroApp(), + ), + ); +} + +/// Seeds `relays` as user relays when the settings hold no relay yet. +/// Idempotent: a second launch keeps whatever the user configured. +Future seedTestRelays( + SettingsNotifier settings, List relays) async { + if (relays.isEmpty || settings.settings.relays.isNotEmpty) { + return; + } + await settings.updateRelays(relays); + await settings.updateUserRelays( + relays + .map((url) => Relay(url: url, source: RelaySource.user).toJson()) + .toList(), + ); +} + +/// Initialize relay synchronization on app startup +void _initializeRelaySynchronization(ProviderContainer container) { + try { + // Read the relays provider to trigger initialization of RelaysNotifier + // This will automatically start sync with the configured Mostro instance + container.read(relaysProvider); + } catch (e) { + // Log error but don't crash app if relay sync initialization fails + logger.e('Failed to initialize relay synchronization', error: e); + } +} + +/// Initialize push notification integration with session notifier and settings +void _initializePushNotificationIntegration( + ProviderContainer container, + _PushServices pushServices, +) { + try { + // Connect push service with session notifier for automatic token registration + container + .read(sessionNotifierProvider.notifier) + .setPushNotificationService(pushServices.pushService); + + // Connect push services with settings notifier for unregistration on disable + container + .read(settingsProvider.notifier) + .setPushServices(pushServices.pushService, pushServices.fcmService); + + // Set up settings check callback + pushServices.pushService.isPushEnabledInSettings = () { + return container.read(settingsProvider).pushNotificationsEnabled; + }; + + // Provide the active Mostro instance pubkey for /api/register + pushServices.pushService.getMostroPubkey = () { + return container.read(settingsProvider).mostroPublicKey; + }; + + logger.i('Push notification integration initialized'); + } catch (e) { + logger.e('Failed to initialize push notification integration', error: e); + } +} + +/// Initialize timeago localization for supported languages +void _initializeTimeAgoLocalization() { + // Set Spanish locale for timeago + timeago.setLocaleMessages('es', timeago.EsMessages()); + + // Set Italian locale for timeago + timeago.setLocaleMessages('it', timeago.ItMessages()); + + // Set German locale for timeago + timeago.setLocaleMessages('de', timeago.DeMessages()); + + // Set French locale for timeago + timeago.setLocaleMessages('fr', timeago.FrMessages()); + + // Set Portuguese locale for timeago + timeago.setLocaleMessages('pt', timeago.PtBrMessages()); + + // English is already the default, no need to set it +} + +/// Result of Firebase/push notification initialization +class _PushServices { + final FCMService fcmService; + final PushNotificationService pushService; + + _PushServices({required this.fcmService, required this.pushService}); +} + +/// Initialize Firebase Cloud Messaging and Push Notification Service +/// Returns the initialized services, or null if not supported/failed +Future<_PushServices?> _initializeFirebaseMessaging( + SharedPreferencesAsync prefs) async { + try { + // Skip Firebase initialization on Linux (not supported) + if (!kIsWeb && Platform.isLinux) { + logger.i('Firebase not supported on Linux - skipping FCM initialization'); + return null; + } + + final fcmService = FCMService(prefs); + await fcmService.initialize(); + + // Initialize Push Notification Service (for encrypted token registration) + final pushService = PushNotificationService(fcmService: fcmService); + await pushService.initialize(); + + // Wire up token refresh to re-register all trade pubkeys + fcmService.onTokenRefresh = (_) => pushService.reRegisterAllTokens(); + + // Note: isPushEnabledInSettings callback will be set after ProviderContainer is created + // This is done in the app initialization to have access to settings provider + + return _PushServices(fcmService: fcmService, pushService: pushService); + } catch (e) { + // Log error but don't crash app if FCM initialization fails + logger.e('Failed to initialize Firebase Cloud Messaging', error: e); + return null; + } +} diff --git a/lib/core/automation/automation_id.dart b/lib/core/automation/automation_id.dart new file mode 100644 index 000000000..4ea68f9d3 --- /dev/null +++ b/lib/core/automation/automation_id.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +/// Attaches a stable automation identifier to a control. +/// +/// Prefer the [AutomationIdExtension.withAutomationId] extension over using +/// this widget directly: it applies at the end of the expression instead of +/// wrapping it, so adding an identifier does not re-indent the subtree. +/// +/// The identifier is exposed as the Android accessibility `resource-id` +/// (Flutter `Semantics.identifier`), which black-box drivers locate without +/// depending on localized labels. By default the wrapped subtree is merged +/// into one semantics node, so the identifier, the visible label, the +/// enabled flag and the tap action travel together. Use `merge: false` for +/// composite rows that contain several independent controls; the identifier +/// then names the row container only. +class AutomationId extends StatelessWidget { + const AutomationId( + this.id, { + super.key, + required this.child, + this.merge = true, + this.label, + }); + + /// One of the `AutomationIds` constants or helpers. + final String id; + + /// The control. + final Widget child; + + /// Whether descendants are merged into a single semantics node. + final bool merge; + + /// Optional explicit accessibility label (used for state readouts whose + /// visible text is not a plain `Text`). + final String? label; + + @override + Widget build(BuildContext context) { + // `container: true` always introduces one node carrying the identifier. + // In merge mode `MergeSemantics` folds every descendant into that node, + // so label, flags and actions travel with the id (what the platform + // accessibility bridge exposes). Otherwise descendants keep their own + // nodes and only the explicit `label` describes the container. + final node = Semantics( + identifier: id, + label: label, + container: true, + explicitChildNodes: !merge, + child: child, + ); + return merge ? MergeSemantics(child: node) : node; + } +} + +/// Attaches an automation identifier without wrapping the expression. +/// +/// This is the preferred way to name a control: +/// +/// ```dart +/// ElevatedButton( +/// onPressed: _submit, +/// child: Text(S.of(context)!.confirm), +/// ).withAutomationId(AutomationIds.orderConfirm) +/// ``` +/// +/// The semantics are identical to building an [AutomationId] around the +/// widget; the difference is that the identifier reads as a property of the +/// control rather than as an extra level of nesting. +extension AutomationIdExtension on Widget { + /// Names this control with [id] for black-box UI automation. + /// + /// Pass `merge: false` for a row or card holding several independent + /// controls, in which case [label] may carry the business state the + /// harness asserts on. See `docs/automation-contract.md`. + Widget withAutomationId(String id, {bool merge = true, String? label}) => + AutomationId(id, merge: merge, label: label, child: this); +} diff --git a/lib/core/automation/automation_ids.dart b/lib/core/automation/automation_ids.dart new file mode 100644 index 000000000..2861d3981 --- /dev/null +++ b/lib/core/automation/automation_ids.dart @@ -0,0 +1,140 @@ +/// Stable semantic identifiers for UI automation (Mortsom automation +/// contract). Every actionable control and business-critical state carries +/// one of these through `Semantics(identifier: ...)`; on Android they surface +/// as the accessibility `resource-id`, so black-box drivers can locate them +/// without depending on localized text or widget hierarchy. +/// +/// Rules (see `docs/automation-contract.md`): +/// * identifiers are namespaced `..`; +/// * an identifier is a product contract: renaming or removing one requires +/// coordinated review with the automation owners; +/// * dynamic identifiers use the `withKey` helpers so their shape is +/// documented in one place. +class AutomationIds { + AutomationIds._(); + + // Environment + static const String envMarker = 'env.marker'; + + // App bar / navigation + static const String appBarDrawer = 'appbar.drawer'; + static const String appBarBack = 'appbar.back'; + static const String navOrderBook = 'nav.order_book'; + static const String navTrades = 'nav.trades'; + static const String navChat = 'nav.chat'; + static const String drawerAccount = 'drawer.account'; + static const String drawerSettings = 'drawer.settings'; + static const String drawerAbout = 'drawer.about'; + + // Onboarding + static const String onboardingBack = 'onboarding.walkthrough.back'; + static const String onboardingSkip = 'onboarding.walkthrough.skip'; + static const String onboardingNext = 'onboarding.walkthrough.next'; + static const String onboardingDone = 'onboarding.walkthrough.done'; + static const String communityNoticeAccept = + 'onboarding.community.notice.accept'; + static const String communityCustomNode = 'onboarding.community.custom_node'; + static const String communityDone = 'onboarding.community.done'; + static const String communitySkip = 'onboarding.community.skip'; + static String communityCard(String pubkey) => + 'onboarding.community.card.$pubkey'; + + // Key management + static const String keysGenerate = 'keys.generate'; + static const String keysGenerateConfirm = 'keys.generate.confirm'; + static const String keysGenerateCancel = 'keys.generate.cancel'; + static const String keysImport = 'keys.import'; + static const String keysImportMnemonic = 'keys.import.mnemonic'; + static const String keysImportConfirm = 'keys.import.confirm'; + static const String keysImportCancel = 'keys.import.cancel'; + static const String keysSeedReveal = 'keys.seed.reveal'; + static const String keysSeedText = 'keys.seed.text'; + static const String keysPublicKey = 'keys.public_key'; + + // Settings + static const String settingsMostroNode = 'settings.mostro_node'; + static const String settingsMostroNodePubkey = 'settings.mostro_node.pubkey'; + static const String settingsWallet = 'settings.wallet'; + static const String settingsRelaysAdd = 'settings.relays.add'; + static const String settingsRelaysAddUrl = 'settings.relays.add.url'; + static const String settingsRelaysAddConfirm = 'settings.relays.add.confirm'; + static const String settingsRelaysAddCancel = 'settings.relays.add.cancel'; + static String settingsRelayItem(String url) => + 'settings.relays.item.${_normalizeRelayUrl(url)}'; + static String settingsRelayDelete(String url) => + 'settings.relays.item.${_normalizeRelayUrl(url)}.delete'; + + /// A relay URL reaches the UI with and without a trailing slash and both + /// name the same relay, so the identifier normalizes it the way the relay + /// list itself does. Without a URL key every delete control would share one + /// identifier and automation could not pick a relay to remove. + static String _normalizeRelayUrl(String url) => + url.trim().replaceAll(RegExp(r'/+$'), ''); + + // Mostro node selector + static const String nodeAddCustom = 'node.add_custom'; + static const String nodeCustomPubkey = 'node.custom.pubkey'; + static const String nodeCustomName = 'node.custom.name'; + static const String nodeCustomConfirm = 'node.custom.confirm'; + static const String nodeCustomCancel = 'node.custom.cancel'; + static String nodeItem(String pubkey) => 'node.item.$pubkey'; + + // Wallet / NWC + static const String walletNwcUri = 'wallet.nwc.uri'; + static const String walletNwcConnect = 'wallet.nwc.connect'; + static const String walletConnection = 'wallet.connection'; + static const String walletSettingsConnect = 'wallet.settings.connect'; + static const String walletSettingsDisconnect = 'wallet.settings.disconnect'; + + // Order book and creation + static const String orderBookTabBuy = 'order.book.tab.buy'; + static const String orderBookTabSell = 'order.book.tab.sell'; + static const String orderAddFab = 'order.add.fab'; + static const String orderAddBuy = 'order.add.buy'; + static const String orderAddSell = 'order.add.sell'; + static String orderBookItem(String orderId) => 'order.book.item.$orderId'; + static const String orderCreateCurrency = 'order.create.currency'; + static String orderCreateCurrencyOption(String code) => + 'order.create.currency.$code'; + static const String orderCreateFiatAmount = 'order.create.fiat_amount'; + static const String orderCreateFiatAmountMax = 'order.create.fiat_amount_max'; + static const String orderCreatePaymentMethod = 'order.create.payment_method'; + static const String orderCreatePriceType = 'order.create.price_type'; + static const String orderCreateSatsAmount = 'order.create.sats_amount'; + static const String orderCreateSubmit = 'order.create.submit'; + static const String orderCreateCancel = 'order.create.cancel'; + static const String orderConfirmHome = 'order.confirm.home'; + + // Take order and trade detail + static const String orderTakeConfirm = 'order.take.confirm'; + static const String orderTakeClose = 'order.take.close'; + static const String orderTakeAmount = 'order.take.amount'; + static const String orderTakeAmountConfirm = 'order.take.amount.confirm'; + static const String orderId = 'order.id'; + static const String orderStatus = 'order.status'; + static String tradesItem(String orderId) => 'trades.item.$orderId'; + static const String tradesItemStatus = 'trades.item.status'; + static String tradeAction(String action) => 'trade.$action'; + static const String tradePayInvoice = 'trade.payInvoice'; + static const String tradeAddInvoice = 'trade.addInvoice'; + static const String tradeTakeSell = 'trade.takeSell'; + static const String tradeTakeBuy = 'trade.takeBuy'; + static const String tradeFiatSent = 'trade.fiatSent'; + static const String tradeRelease = 'trade.release'; + static const String tradeReleaseConfirm = 'trade.release.confirm'; + static const String tradeCancel = 'trade.cancel'; + static const String tradeCancelConfirm = 'trade.cancel.confirm'; + static const String tradeDispute = 'trade.dispute'; + static const String tradeDisputeConfirm = 'trade.dispute.confirm'; + + // Invoices and payments + static const String invoiceText = 'invoice.text'; + static const String invoiceSubmit = 'invoice.submit'; + static const String invoiceCancel = 'invoice.cancel'; + static const String invoiceNwcGenerate = 'invoice.nwc.generate'; + static const String invoiceNwcConfirm = 'invoice.nwc.confirm'; + static const String invoiceNwcText = 'invoice.nwc.text'; + static const String payInvoiceText = 'pay.invoice.text'; + static const String payNwc = 'pay.nwc'; + static const String payCancel = 'pay.cancel'; +} diff --git a/lib/core/config.dart b/lib/core/config.dart index a6b4d9d4c..3a658e3ea 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:mostro_mobile/core/test_environment.dart'; import 'package:mostro_mobile/core/config/communities.dart'; class Config { @@ -20,6 +21,15 @@ class Config { 'wss://relay.damus.io', ]; + /// Relays used for discovery when nothing else is configured. In the + /// Mortsom test environment this is the seeded local relay list, never + /// the public bootstrap relays: a disconnected local relay must produce a + /// test failure, not public-network traffic (automation contract §3). + static List get discoveryRelays => + TestEnvironment.disableBootstrapFallback + ? TestEnvironment.seedRelays + : bootstrapRelays; + // Derived from trustedCommunities to maintain single source of truth static final List> trustedMostroNodes = trustedCommunities diff --git a/lib/core/test_environment.dart b/lib/core/test_environment.dart new file mode 100644 index 000000000..eb4dccfdb --- /dev/null +++ b/lib/core/test_environment.dart @@ -0,0 +1,76 @@ +import 'package:flutter/foundation.dart'; + +/// Mortsom test-environment switch (see `docs/automation-contract.md`). +/// +/// The test environment is enabled only when BOTH conditions hold: +/// 1. the app was started through the `lib/main_mortsom.dart` entry point, +/// which is the only caller of [arm]; and +/// 2. the build carried `--dart-define=MORTSOM_TEST_ENV=true`. +/// +/// The production entry point (`lib/main.dart`) never arms it and the +/// release pipeline never passes the define, so a release build cannot enter +/// the test environment by accident. +/// +/// Values passed as Dart defines are visible to build tooling, so only +/// non-secret data travels this way: the daemon public key (already +/// `MOSTRO_PUB_KEY`) and the local relay seed list (`MORTSOM_RELAYS`). +/// Secrets such as mnemonics or NWC URIs are entered through the UI. +class TestEnvironment { + TestEnvironment._(); + + static const bool _defineEnabled = + bool.fromEnvironment('MORTSOM_TEST_ENV', defaultValue: false); + static const String _relaysDefine = + String.fromEnvironment('MORTSOM_RELAYS', defaultValue: ''); + + static bool _armed = false; + + /// Marks the process as started through the Mortsom entry point. + /// Only `lib/main_mortsom.dart` may call this. In release mode arming + /// without the compile-time define is a build mistake and fails loudly in + /// debug/profile builds through the assertion. + static void arm() { + assert( + !kReleaseMode || _defineEnabled, + 'TestEnvironment.arm() called from a release build without MORTSOM_TEST_ENV', + ); + _armed = true; + } + + /// Test-only: clears the armed state between tests. + @visibleForTesting + static void disarm() { + _armed = false; + } + + /// True when the app runs in the Mortsom test environment. + static bool get enabled => _armed && _defineEnabled; + + /// Whether the compile-time define is present (regardless of arming). + @visibleForTesting + static bool get defineEnabled => _defineEnabled; + + /// Local relay seed list, in the order given by `MORTSOM_RELAYS` + /// (comma separated). Empty outside the test environment. + static List get seedRelays => + enabled ? parseRelays(_relaysDefine) : const []; + + /// Parses a comma-separated relay list, trimming and dropping blanks. + @visibleForTesting + static List parseRelays(String csv) => csv + .split(',') + .map((r) => r.trim()) + .where((r) => r.isNotEmpty) + .toList(growable: false); + + /// In the test environment the app must never fall back to public + /// bootstrap relays: a disconnected local relay must fail the test. + static bool get disableBootstrapFallback => enabled; + + /// Local test relays are plain `ws://` on a private address; that is + /// only acceptable inside the test environment. + static bool get allowInsecureRelays => enabled; + + /// Copy of the visible environment marker. + static const String markerLabel = 'TEST ENVIRONMENT · Mortsom'; +} diff --git a/lib/features/community/screens/community_selector_screen.dart b/lib/features/community/screens/community_selector_screen.dart index 2580f0d59..4362c8f59 100644 --- a/lib/features/community/screens/community_selector_screen.dart +++ b/lib/features/community/screens/community_selector_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.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/core/config/communities.dart'; import 'package:mostro_mobile/features/community/community.dart'; import 'package:mostro_mobile/features/community/providers/community_selector_provider.dart'; @@ -47,7 +49,7 @@ class _CommunitySelectorScreenState TextButton( onPressed: () => Navigator.of(ctx).pop(), child: Text(S.of(ctx)!.communityDisclaimerAccept), - ), + ).withAutomationId(AutomationIds.communityNoticeAccept), ], ), ); @@ -174,7 +176,7 @@ class _CommunitySelectorScreenState ), ], ), - ), + ).withAutomationId(AutomationIds.communityCustomNode), const SizedBox(height: 8), // Confirm button if (_selectedPubkey != null) @@ -205,7 +207,7 @@ class _CommunitySelectorScreenState fontWeight: FontWeight.w600, ), ), - ), + ).withAutomationId(AutomationIds.communityDone), // Skip button TextButton( onPressed: _isSelecting ? null : () => _onSkip(context), @@ -216,7 +218,7 @@ class _CommunitySelectorScreenState fontSize: 14, ), ), - ), + ).withAutomationId(AutomationIds.communitySkip), SizedBox( height: MediaQuery.of(context).viewPadding.bottom + 8, ), diff --git a/lib/features/community/widgets/community_card.dart b/lib/features/community/widgets/community_card.dart index 123a06716..01a62bb5e 100644 --- a/lib/features/community/widgets/community_card.dart +++ b/lib/features/community/widgets/community_card.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.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/features/community/community.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; @@ -180,7 +182,10 @@ class CommunityCard extends StatelessWidget { ], ), ), - ); + // Container mode: the card holds its own tap plus one per social link, + // and merging would collapse those independent actions into one node. + ).withAutomationId(AutomationIds.communityCard(community.pubkey), + merge: false); } Widget _buildCurrencyTag(String label) { diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 46eda9b65..0d8acb21d 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:heroicons/heroicons.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/enums/order_type.dart'; import 'package:mostro_mobile/features/home/providers/home_order_providers.dart'; import 'package:mostro_mobile/features/home/widgets/order_list_item.dart'; @@ -137,6 +139,7 @@ class HomeScreen extends ConsumerWidget { orderType == OrderType.sell, OrderType.sell, AppTheme.buyColor, + AutomationIds.orderBookTabBuy, ), _buildTabButton( context, @@ -145,6 +148,7 @@ class HomeScreen extends ConsumerWidget { orderType == OrderType.buy, OrderType.buy, AppTheme.sellColor, + AutomationIds.orderBookTabSell, ), ], ), @@ -158,6 +162,9 @@ class HomeScreen extends ConsumerWidget { bool isActive, OrderType type, Color activeColor, + // Named by the visible tab, not by `type`: the Buy BTC tab filters for + // sell orders, so deriving the id from `type` would swap the two. + String automationId, ) { return Expanded( child: InkWell( @@ -184,7 +191,7 @@ class HomeScreen extends ConsumerWidget { ), ), ), - ), + ).withAutomationId(automationId), ); } @@ -227,7 +234,8 @@ class HomeScreen extends ConsumerWidget { splashColor: AppTheme.activeColor.withValues(alpha: 0.3), highlightColor: AppTheme.activeColor.withValues(alpha: 0.15), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -254,7 +262,9 @@ class HomeScreen extends ConsumerWidget { color: Colors.white.withValues(alpha: 0.2), ), Text( - S.of(context)!.offersCount(filteredOrders.length.toString()), + S + .of(context)! + .offersCount(filteredOrders.length.toString()), style: const TextStyle( color: Colors.grey, fontSize: 12, diff --git a/lib/features/home/widgets/order_list_item.dart b/lib/features/home/widgets/order_list_item.dart index 45c12f769..78c27d2d3 100644 --- a/lib/features/home/widgets/order_list_item.dart +++ b/lib/features/home/widgets/order_list_item.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.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/enums.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; @@ -67,7 +69,8 @@ class OrderListItem extends ConsumerWidget { borderRadius: BorderRadius.circular(20), onTap: () { final sessions = ref.watch(sessionNotifierProvider); - final session = sessions.firstWhereOrNull((s) => s.orderId == order.orderId); + final session = + sessions.firstWhereOrNull((s) => s.orderId == order.orderId); if (session != null && session.role != null) { context.push('/trade_detail/${session.orderId}'); return; @@ -297,7 +300,8 @@ class OrderListItem extends ConsumerWidget { ), ], ), - ), + ).withAutomationId(AutomationIds.orderBookItem(order.orderId ?? ''), + merge: false), ), ); } diff --git a/lib/features/key_manager/import_mnemonic_dialog.dart b/lib/features/key_manager/import_mnemonic_dialog.dart index bb42f0d12..f9ff91fb4 100644 --- a/lib/features/key_manager/import_mnemonic_dialog.dart +++ b/lib/features/key_manager/import_mnemonic_dialog.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.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/generated/l10n.dart'; import 'package:mostro_mobile/shared/utils/mnemonic_validator.dart'; @@ -150,7 +152,7 @@ class _ImportMnemonicDialogState extends State { }); } }, - ), + ).withAutomationId(AutomationIds.keysImportMnemonic), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.end, @@ -165,7 +167,7 @@ class _ImportMnemonicDialogState extends State { fontWeight: FontWeight.w500, ), ), - ), + ).withAutomationId(AutomationIds.keysImportCancel), const SizedBox(width: 12), ElevatedButton( onPressed: _handleImport, @@ -187,7 +189,7 @@ class _ImportMnemonicDialogState extends State { fontWeight: FontWeight.w500, ), ), - ), + ).withAutomationId(AutomationIds.keysImportConfirm), ], ), ], @@ -224,4 +226,4 @@ class _ImportMnemonicDialogState extends State { ], ); } -} \ No newline at end of file +} diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index 6ecb6a0c8..fd549b94d 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:lucide_icons/lucide_icons.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/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/key_manager/import_mnemonic_dialog.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; @@ -13,6 +15,7 @@ import 'package:mostro_mobile/features/notifications/providers/backup_reminder_p import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/providers/notifications_history_repository_provider.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart'; class KeyManagementScreen extends ConsumerStatefulWidget { @@ -36,6 +39,9 @@ class _KeyManagementScreenState extends ConsumerState { _loadKeys(); } + /// Master public key (npub) for display; never the private material. + String? _publicKey; + Future _loadKeys() async { setState(() { _loading = true; @@ -46,6 +52,10 @@ class _KeyManagementScreenState extends ConsumerState { if (hasMaster) { _mnemonic = await keyManager.getMnemonic(); _tradeKeyIndex = await keyManager.getCurrentKeyIndex(); + final publicHex = keyManager.masterKeyPair?.public; + _publicKey = publicHex == null + ? null + : NostrUtils.encodePublicKeyToNpub(publicHex); } else { if (mounted) _mnemonic = S.of(context)!.noMnemonicFound; _tradeKeyIndex = 0; @@ -72,7 +82,7 @@ class _KeyManagementScreenState extends ConsumerState { await eventStorage.deleteAll(); await ref.read(notificationsRepositoryProvider).clearAll(); - + final keyManager = ref.read(keyManagerProvider); await keyManager.generateAndStoreMasterKey(); @@ -133,7 +143,7 @@ class _KeyManagementScreenState extends ConsumerState { color: AppTheme.textPrimary, ), onPressed: () => context.pop(), - ), + ).withAutomationId(AutomationIds.appBarBack), title: Text( S.of(context)!.account, style: const TextStyle( @@ -160,6 +170,10 @@ class _KeyManagementScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Public key readout (identity, safe to show) + _buildPublicKeyCard(context), + const SizedBox(height: 16), + // Secret Words Card _buildSecretWordsCard(context), const SizedBox(height: 16), @@ -203,6 +217,38 @@ class _KeyManagementScreenState extends ConsumerState { ); } + /// Compact card with the account public key (npub). It is the identity + /// readout automation and users rely on to tell accounts apart. + Widget _buildPublicKeyCard(BuildContext context) { + final npub = _publicKey ?? ''; + return Container( + decoration: BoxDecoration( + color: AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + const Icon(LucideIcons.user, color: AppTheme.activeColor, size: 20), + const SizedBox(width: 8), + Expanded( + child: SelectableText( + npub, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 12, + fontFamily: 'monospace', + ), + ).withAutomationId(AutomationIds.keysPublicKey), + ), + ], + ), + ), + ); + } + Widget _buildSecretWordsCard(BuildContext context) { return Container( decoration: BoxDecoration( @@ -280,7 +326,7 @@ class _KeyManagementScreenState extends ConsumerState { fontSize: 14, fontFamily: 'monospace', ), - ), + ).withAutomationId(AutomationIds.keysSeedText), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, @@ -292,7 +338,9 @@ class _KeyManagementScreenState extends ConsumerState { }); // Dismiss backup reminder when user views seed phrase if (_showSecretWords) { - ref.read(backupReminderProvider.notifier).dismissBackupReminder(); + ref + .read(backupReminderProvider.notifier) + .dismissBackupReminder(); } }, icon: Icon( @@ -312,7 +360,7 @@ class _KeyManagementScreenState extends ConsumerState { fontWeight: FontWeight.w500, ), ), - ), + ).withAutomationId(AutomationIds.keysSeedReveal), ], ), ], @@ -385,7 +433,8 @@ class _KeyManagementScreenState extends ConsumerState { title: S.of(context)!.reputationMode, description: S.of(context)!.standardPrivacyWithReputation, isSelected: !settings.fullPrivacyMode, - onTap: () => ref.read(settingsProvider.notifier).updatePrivacyMode(false), + onTap: () => + ref.read(settingsProvider.notifier).updatePrivacyMode(false), ), const SizedBox(height: 8), _buildPrivacyOption( @@ -393,7 +442,8 @@ class _KeyManagementScreenState extends ConsumerState { title: S.of(context)!.fullPrivacyMode, description: S.of(context)!.maximumAnonymity, isSelected: settings.fullPrivacyMode, - onTap: () => ref.read(settingsProvider.notifier).updatePrivacyMode(true), + onTap: () => + ref.read(settingsProvider.notifier).updatePrivacyMode(true), ), ], ), @@ -529,7 +579,7 @@ class _KeyManagementScreenState extends ConsumerState { ), ], ), - ), + ).withAutomationId(AutomationIds.keysGenerate), ); } @@ -565,7 +615,7 @@ class _KeyManagementScreenState extends ConsumerState { ), ], ), - ); + ).withAutomationId(AutomationIds.keysImport); } Widget _buildRefreshUserButton(BuildContext context) { @@ -587,14 +637,12 @@ class _KeyManagementScreenState extends ConsumerState { } Widget _buildPrivacyOption( - BuildContext context, - { - required String title, - required String description, - required bool isSelected, - required VoidCallback onTap, - } - ) { + BuildContext context, { + required String title, + required String description, + required bool isSelected, + required VoidCallback onTap, + }) { return Material( color: Colors.transparent, child: InkWell( @@ -640,7 +688,9 @@ class _KeyManagementScreenState extends ConsumerState { Text( title, style: TextStyle( - color: isSelected ? AppTheme.textPrimary : AppTheme.textInactive, + color: isSelected + ? AppTheme.textPrimary + : AppTheme.textInactive, fontSize: 15, fontWeight: FontWeight.w600, ), @@ -745,7 +795,7 @@ class _KeyManagementScreenState extends ConsumerState { ), textAlign: TextAlign.center, ), - ), + ).withAutomationId(AutomationIds.keysGenerateCancel), const SizedBox(width: 12), ElevatedButton( onPressed: () { @@ -769,7 +819,7 @@ class _KeyManagementScreenState extends ConsumerState { ), textAlign: TextAlign.center, ), - ), + ).withAutomationId(AutomationIds.keysGenerateConfirm), ], ); }, @@ -829,7 +879,8 @@ class _KeyManagementScreenState extends ConsumerState { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), + padding: + const EdgeInsets.symmetric(vertical: 12, horizontal: 24), ), child: Text( S.of(context)!.refresh, @@ -846,7 +897,6 @@ class _KeyManagementScreenState extends ConsumerState { } Future _showImportMnemonicDialog(BuildContext context) async { - final mnemonic = await showDialog( context: context, builder: (BuildContext dialogContext) { @@ -859,4 +909,4 @@ class _KeyManagementScreenState extends ConsumerState { await restoreService.importMnemonicAndRestore(mnemonic); } } -} \ No newline at end of file +} diff --git a/lib/features/mostro/widgets/add_custom_node_dialog.dart b/lib/features/mostro/widgets/add_custom_node_dialog.dart index ac11009e0..0f5472bc5 100644 --- a/lib/features/mostro/widgets/add_custom_node_dialog.dart +++ b/lib/features/mostro/widgets/add_custom_node_dialog.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/features/mostro/mostro_node.dart'; import 'package:mostro_mobile/features/mostro/mostro_nodes_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -10,7 +12,6 @@ import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart'; class AddCustomNodeDialog { - static Future show(BuildContext context, WidgetRef ref) async { // Capture parent context values before entering dialog final parentMessenger = ScaffoldMessenger.of(context); @@ -82,8 +83,7 @@ class _AddCustomNodeDialogContentState backgroundColor: AppTheme.backgroundCard, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: Colors.white.withValues(alpha: 0.1)), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), ), title: Text( S.of(context)!.addCustomNodeTitle, @@ -114,27 +114,22 @@ class _AddCustomNodeDialogContentState ), child: TextField( controller: pubkeyController, - style: - const TextStyle(color: AppTheme.textPrimary), + style: const TextStyle(color: AppTheme.textPrimary), decoration: InputDecoration( - labelText: - S.of(context)!.enterNodePubkey, - labelStyle: const TextStyle( - color: AppTheme.textSecondary), + labelText: S.of(context)!.enterNodePubkey, + labelStyle: const TextStyle(color: AppTheme.textSecondary), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), hintText: S.of(context)!.pubkeyHint, - hintStyle: const TextStyle( - color: AppTheme.textSecondary), + hintStyle: const TextStyle(color: AppTheme.textSecondary), errorText: errorMessage, - errorStyle: - const TextStyle(color: Colors.red), + errorStyle: const TextStyle(color: Colors.red), ), autofocus: true, - ), + ).withAutomationId(AutomationIds.nodeCustomPubkey), ), const SizedBox(height: 12), // Name input @@ -152,23 +147,19 @@ class _AddCustomNodeDialogContentState ), child: TextField( controller: nameController, - style: - const TextStyle(color: AppTheme.textPrimary), + style: const TextStyle(color: AppTheme.textPrimary), decoration: InputDecoration( - labelText: - S.of(context)!.enterNodeName, - labelStyle: const TextStyle( - color: AppTheme.textSecondary), + labelText: S.of(context)!.enterNodeName, + labelStyle: const TextStyle(color: AppTheme.textSecondary), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), hintText: S.of(context)!.nodeNameHint, - hintStyle: const TextStyle( - color: AppTheme.textSecondary), + hintStyle: const TextStyle(color: AppTheme.textSecondary), ), - ), + ).withAutomationId(AutomationIds.nodeCustomName), ), ], ), @@ -184,7 +175,7 @@ class _AddCustomNodeDialogContentState fontWeight: FontWeight.w500, ), ), - ), + ).withAutomationId(AutomationIds.nodeCustomCancel), const SizedBox(width: 12), ElevatedButton( onPressed: () async { @@ -203,8 +194,7 @@ class _AddCustomNodeDialogContentState // Reject nsec private keys if (input.startsWith('nsec')) { setState(() { - errorMessage = - localizations.invalidPubkeyFormat; + errorMessage = localizations.invalidPubkeyFormat; }); return; } @@ -212,11 +202,11 @@ class _AddCustomNodeDialogContentState // Convert npub to hex if needed, then normalize String hexPubkey; try { - hexPubkey = AddCustomNodeDialog._convertToHex(input).toLowerCase(); + hexPubkey = + AddCustomNodeDialog._convertToHex(input).toLowerCase(); } catch (_) { setState(() { - errorMessage = - localizations.invalidPubkeyFormat; + errorMessage = localizations.invalidPubkeyFormat; }); return; } @@ -224,8 +214,7 @@ class _AddCustomNodeDialogContentState // Validate hex format if (!MostroNode.isValidHexPubkey(hexPubkey)) { setState(() { - errorMessage = - localizations.invalidPubkeyFormat; + errorMessage = localizations.invalidPubkeyFormat; }); return; } @@ -234,14 +223,12 @@ class _AddCustomNodeDialogContentState final nodes = widget.ref.read(mostroNodesProvider); if (nodes.any((n) => n.pubkey == hexPubkey)) { setState(() { - errorMessage = - localizations.nodeAlreadyExists; + errorMessage = localizations.nodeAlreadyExists; }); return; } - final notifier = - widget.ref.read(mostroNodesProvider.notifier); + final notifier = widget.ref.read(mostroNodesProvider.notifier); final added = await notifier.addCustomNode( hexPubkey, name: name.isEmpty ? null : name, @@ -261,8 +248,7 @@ class _AddCustomNodeDialogContentState ); } else { setState(() { - errorMessage = - localizations.nodeAlreadyExists; + errorMessage = localizations.nodeAlreadyExists; }); } }, @@ -284,7 +270,7 @@ class _AddCustomNodeDialogContentState fontWeight: FontWeight.w500, ), ), - ), + ).withAutomationId(AutomationIds.nodeCustomConfirm), ], ); } diff --git a/lib/features/mostro/widgets/mostro_node_selector.dart b/lib/features/mostro/widgets/mostro_node_selector.dart index 117881564..b247671b6 100644 --- a/lib/features/mostro/widgets/mostro_node_selector.dart +++ b/lib/features/mostro/widgets/mostro_node_selector.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/features/mostro/mostro_node.dart'; import 'package:mostro_mobile/features/mostro/mostro_nodes_provider.dart'; import 'package:mostro_mobile/features/mostro/widgets/add_custom_node_dialog.dart'; @@ -145,7 +147,7 @@ class _MostroNodeSelectorState extends ConsumerState { fontSize: 14, ), ), - ), + ).withAutomationId(AutomationIds.nodeAddCustom), ], ), SizedBox( @@ -288,7 +290,7 @@ class _MostroNodeSelectorState extends ConsumerState { ], ), ), - ), + ).withAutomationId(AutomationIds.nodeItem(node.pubkey), merge: false), ), ); } diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 0a3295dec..aeda024f9 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -5,6 +5,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.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/enums/order_type.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; @@ -90,7 +92,8 @@ class _AddOrderScreenState extends ConsumerState { _fixedPriceRangeErrorTimer?.cancel(); _scrollController.dispose(); _lightningAddressController.dispose(); - _customPaymentMethodController.removeListener(_onCustomPaymentMethodChanged); + _customPaymentMethodController + .removeListener(_onCustomPaymentMethodChanged); _customPaymentMethodController.dispose(); _satsAmountController.dispose(); super.dispose(); @@ -199,29 +202,29 @@ class _AddOrderScreenState extends ConsumerState { if (satsAmount < minAllowed) { return fiatLimits.isDisplayable ? S.of(context)!.fiatAmountTooLowRange( - fiatLimits.minFiat.toString(), - fiatLimits.maxFiat.toString(), - selectedFiatCode, - minAllowed.toString(), - maxAllowed.toString(), - ) + fiatLimits.minFiat.toString(), + fiatLimits.maxFiat.toString(), + selectedFiatCode, + minAllowed.toString(), + maxAllowed.toString(), + ) : S.of(context)!.fiatAmountTooLow( - minAllowed.toString(), - maxAllowed.toString(), - ); + minAllowed.toString(), + maxAllowed.toString(), + ); } else { return fiatLimits.isDisplayable ? S.of(context)!.fiatAmountTooHighRange( - fiatLimits.minFiat.toString(), - fiatLimits.maxFiat.toString(), - selectedFiatCode, - minAllowed.toString(), - maxAllowed.toString(), - ) + fiatLimits.minFiat.toString(), + fiatLimits.maxFiat.toString(), + selectedFiatCode, + minAllowed.toString(), + maxAllowed.toString(), + ) : S.of(context)!.fiatAmountTooHigh( - minAllowed.toString(), - maxAllowed.toString(), - ); + minAllowed.toString(), + maxAllowed.toString(), + ); } } @@ -397,7 +400,8 @@ class _AddOrderScreenState extends ConsumerState { inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], - ), + ).withAutomationId( + AutomationIds.orderCreateSatsAmount), ), ], const SizedBox(height: 16), diff --git a/lib/features/order/screens/order_confirmation_screen.dart b/lib/features/order/screens/order_confirmation_screen.dart index c26540d50..847ff27cd 100644 --- a/lib/features/order/screens/order_confirmation_screen.dart +++ b/lib/features/order/screens/order_confirmation_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.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/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; @@ -36,7 +38,7 @@ class OrderConfirmationScreen extends ConsumerWidget { key: const Key('homeButton'), onPressed: () => context.go('/'), child: Text(S.of(context)!.backToHome), - ), + ).withAutomationId(AutomationIds.orderConfirmHome), ], ), ), diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index cfdbd00da..305f36bca 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.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/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/features/wallet/providers/nwc_provider.dart'; @@ -36,7 +38,8 @@ class _PayLightningInvoiceScreenState final nwcState = ref.watch(nwcProvider); final isNwcConnected = nwcState.status == NwcStatus.connected; - final showNwcPayment = isNwcConnected && !_manualMode && lnInvoice.isNotEmpty; + final showNwcPayment = + isNwcConnected && !_manualMode && lnInvoice.isNotEmpty; return Scaffold( backgroundColor: AppTheme.dark1, @@ -55,15 +58,22 @@ class _PayLightningInvoiceScreenState // NWC auto-payment flow Text( S.of(context)!.payInvoiceToContinue( - sats.toString(), - fiatCode, - fiatAmount, - widget.orderId, - ), + sats.toString(), + fiatCode, + fiatAmount, + widget.orderId, + ), style: const TextStyle(color: AppTheme.cream1, fontSize: 18), textAlign: TextAlign.center, ), const SizedBox(height: 24), + // Automation readout: the invoice being paid, so a black-box + // driver can correlate the payment by hash without reading the + // QR code. Invisible; screen readers get the invoice string. + const SizedBox(width: 1, height: 1).withAutomationId( + AutomationIds.payInvoiceText, + merge: false, + label: lnInvoice), NwcPaymentWidget( lnInvoice: lnInvoice, sats: sats, @@ -90,7 +100,7 @@ class _PayLightningInvoiceScreenState backgroundColor: Colors.red, ), child: Text(S.of(context)!.cancel), - ), + ).withAutomationId(AutomationIds.payCancel), ], ), ] else ...[ diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 2fa5d0e49..0c76629f3 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -4,6 +4,8 @@ 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/core/automation/automation_id.dart'; +import 'package:mostro_mobile/core/automation/automation_ids.dart'; 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'; @@ -130,9 +132,8 @@ class _TakeOrderScreenState extends ConsumerState { 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 @@ -140,9 +141,8 @@ 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'; } } @@ -157,11 +157,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, @@ -174,9 +174,7 @@ class _TakeOrderScreenState extends ConsumerState { Flexible( child: RichText( text: TextSpan( - text: S - .of(context)! - .forAmountWithCurrency( + text: S.of(context)!.forAmountWithCurrency( amountString, order.currency ?? '', ), @@ -262,7 +260,7 @@ class _TakeOrderScreenState extends ConsumerState { onPressed: () => context.pop(), style: AppTheme.theme.outlinedButtonTheme.style, child: Text(S.of(context)!.close), - ), + ).withAutomationId(AutomationIds.orderTakeClose), ), const SizedBox(width: 16), Expanded( @@ -337,11 +335,12 @@ class _TakeOrderScreenState extends ConsumerState { border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), + horizontal: 12, + vertical: 8, + ), ), - ), + ).withAutomationId( + AutomationIds.orderTakeAmount), ), actions: [ TextButton( @@ -407,7 +406,8 @@ class _TakeOrderScreenState extends ConsumerState { fontWeight: FontWeight.w500, ), ), - ), + ).withAutomationId( + AutomationIds.orderTakeAmountConfirm), ], ); }, @@ -423,8 +423,8 @@ class _TakeOrderScreenState extends ConsumerState { enteredAmount, ); } else { - final lndAddress = widget._lndAddressController.text - .trim(); + final lndAddress = + widget._lndAddressController.text.trim(); await orderDetailsNotifier.takeSellOrder( order.orderId!, enteredAmount, @@ -461,8 +461,8 @@ class _TakeOrderScreenState extends ConsumerState { fiatAmount, ); } else { - final lndAddress = widget._lndAddressController.text - .trim(); + final lndAddress = + widget._lndAddressController.text.trim(); await orderDetailsNotifier.takeSellOrder( order.orderId!, fiatAmount, @@ -495,7 +495,7 @@ class _TakeOrderScreenState extends ConsumerState { ), ) : Text(buttonText), - ), + ).withAutomationId(AutomationIds.orderTakeConfirm), ), ], ); diff --git a/lib/features/order/widgets/action_buttons.dart b/lib/features/order/widgets/action_buttons.dart index 8ffab55aa..1fb6b624d 100644 --- a/lib/features/order/widgets/action_buttons.dart +++ b/lib/features/order/widgets/action_buttons.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.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/enums/action.dart' as nostr_action; import 'package:mostro_mobile/shared/widgets/mostro_reactive_button.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -29,10 +31,11 @@ class ActionButtons extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: AppTheme.backgroundCard, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 0), + padding: + const EdgeInsets.symmetric(vertical: 12, horizontal: 0), ), child: Text(S.of(context)!.cancel), - ), + ).withAutomationId(AutomationIds.orderCreateCancel), ), ), const SizedBox(width: 12), @@ -54,7 +57,7 @@ class ActionButtons extends StatelessWidget { : AppTheme.backgroundInactive, foregroundColor: onSubmit != null ? Colors.white : AppTheme.textInactive, - ), + ).withAutomationId(AutomationIds.orderCreateSubmit), ), ), ], diff --git a/lib/features/order/widgets/amount_section.dart b/lib/features/order/widgets/amount_section.dart index 7ed13aded..98298bfa5 100644 --- a/lib/features/order/widgets/amount_section.dart +++ b/lib/features/order/widgets/amount_section.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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/enums/order_type.dart'; import 'package:mostro_mobile/features/order/widgets/form_section.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -223,7 +225,7 @@ class _AmountSectionState extends State { inputFormatters: [FilteringTextInputFormatter.digitsOnly], validator: _validateMinAmount, onChanged: (_) => _notifyAmountChanged(), - ), + ).withAutomationId(AutomationIds.orderCreateFiatAmount), ), // "to" label and max amount input (shown after first digit) @@ -250,7 +252,7 @@ class _AmountSectionState extends State { keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], validator: _validateMaxAmount, - ), + ).withAutomationId(AutomationIds.orderCreateFiatAmountMax), ), ], ], diff --git a/lib/features/order/widgets/currency_section.dart b/lib/features/order/widgets/currency_section.dart index 940ad0cd8..f569e69b2 100644 --- a/lib/features/order/widgets/currency_section.dart +++ b/lib/features/order/widgets/currency_section.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/enums/order_type.dart'; import 'package:mostro_mobile/features/order/widgets/form_section.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; @@ -54,7 +56,8 @@ class CurrencySection extends ConsumerWidget { currentSelection: selectedFiatCode, ); if (selectedCode != null) { - ref.read(selectedFiatCodeProvider.notifier).state = selectedCode; + ref.read(selectedFiatCodeProvider.notifier).state = + selectedCode; onCurrencySelected(); } }, @@ -75,10 +78,9 @@ class CurrencySection extends ConsumerWidget { const Icon(Icons.keyboard_arrow_down, color: Colors.white), ], ), - ); + ).withAutomationId(AutomationIds.orderCreateCurrency); }, ), ); } - } diff --git a/lib/features/order/widgets/payment_methods_section.dart b/lib/features/order/widgets/payment_methods_section.dart index 09d36a580..0b179dfc9 100644 --- a/lib/features/order/widgets/payment_methods_section.dart +++ b/lib/features/order/widgets/payment_methods_section.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; +import 'package:mostro_mobile/core/automation/automation_ids.dart'; import 'package:mostro_mobile/features/order/providers/payment_methods_provider.dart'; import 'package:mostro_mobile/features/order/widgets/form_section.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; @@ -40,21 +42,22 @@ class PaymentMethodsSection extends ConsumerWidget { return FormSection( title: S.of(context)!.paymentMethodsForCurrency(selectedFiatCode ?? ''), - icon: const Icon(Icons.credit_card, color: AppTheme.mostroGreen, size: 18), + icon: + const Icon(Icons.credit_card, color: AppTheme.mostroGreen, size: 18), iconBackgroundColor: AppTheme.mostroGreen.withValues(alpha: 0.3), extraContent: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: TextField( - key: const Key('paymentMethodField'), - controller: customController, - style: const TextStyle(color: Colors.white, fontSize: 15), - decoration: InputDecoration( - border: InputBorder.none, - hintText: S.of(context)!.enterCustomPaymentMethod, - hintStyle: TextStyle(color: Colors.grey, fontSize: 13), - ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: TextField( + key: const Key('paymentMethodField'), + controller: customController, + style: const TextStyle(color: Colors.white, fontSize: 15), + decoration: InputDecoration( + border: InputBorder.none, + hintText: S.of(context)!.enterCustomPaymentMethod, + hintStyle: TextStyle(color: Colors.grey, fontSize: 13), ), - ), + ).withAutomationId(AutomationIds.orderCreatePaymentMethod), + ), child: paymentMethodsData.when( loading: () => Text(S.of(context)!.loadingPaymentMethods, style: const TextStyle(color: Colors.white)), @@ -72,7 +75,8 @@ class PaymentMethodsSection extends ConsumerWidget { .map((method) => _translatePaymentMethod(method, context)) .toList(); } else { - availableMethods = List.from(data['default'] ?? ['Bank Transfer', 'Cash in person', 'Other']) + availableMethods = List.from(data['default'] ?? + ['Bank Transfer', 'Cash in person', 'Other']) .map((method) => _translatePaymentMethod(method, context)) .toList(); } @@ -117,9 +121,8 @@ class PaymentMethodsSection extends ConsumerWidget { ) { // Remove "Other" from available methods since custom field is always visible final translatedOther = _translatePaymentMethod('Other', context); - availableMethods = availableMethods - .where((m) => m != translatedOther) - .toList(); + availableMethods = + availableMethods.where((m) => m != translatedOther).toList(); // Normalize to current locale so checkbox states align with localized labels final localizedSelected = selectedMethods @@ -144,23 +147,25 @@ class PaymentMethodsSection extends ConsumerWidget { child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, - children: availableMethods.map((method) => CheckboxListTile( - title: Text(method, - style: const TextStyle(color: Colors.white)), - value: dialogSelectedMethods.contains(method), - activeColor: AppTheme.mostroGreen, - checkColor: Colors.black, - contentPadding: EdgeInsets.zero, - onChanged: (selected) { - setDialogState(() { - if (selected == true) { - dialogSelectedMethods.add(method); - } else { - dialogSelectedMethods.remove(method); - } - }); - }, - )).toList(), + children: availableMethods + .map((method) => CheckboxListTile( + title: Text(method, + style: const TextStyle(color: Colors.white)), + value: dialogSelectedMethods.contains(method), + activeColor: AppTheme.mostroGreen, + checkColor: Colors.black, + contentPadding: EdgeInsets.zero, + onChanged: (selected) { + setDialogState(() { + if (selected == true) { + dialogSelectedMethods.add(method); + } else { + dialogSelectedMethods.remove(method); + } + }); + }, + )) + .toList(), ), ), ), @@ -184,7 +189,8 @@ class PaymentMethodsSection extends ConsumerWidget { style: ElevatedButton.styleFrom( backgroundColor: AppTheme.activeColor, foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + padding: const EdgeInsets.symmetric( + vertical: 16, horizontal: 24), minimumSize: const Size(0, 52), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), diff --git a/lib/features/order/widgets/price_type_section.dart b/lib/features/order/widgets/price_type_section.dart index d8449f1a9..e48b26179 100644 --- a/lib/features/order/widgets/price_type_section.dart +++ b/lib/features/order/widgets/price_type_section.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.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/features/order/widgets/form_section.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/widgets/mostro_switch.dart'; @@ -59,7 +61,7 @@ class PriceTypeSection extends StatelessWidget { key: const Key('fixedSwitch'), value: isMarketRate, onChanged: onToggle, - ), + ).withAutomationId(AutomationIds.orderCreatePriceType), ], ), ], diff --git a/lib/features/relays/relays_notifier.dart b/lib/features/relays/relays_notifier.dart index b233f6a80..3052b0700 100644 --- a/lib/features/relays/relays_notifier.dart +++ b/lib/features/relays/relays_notifier.dart @@ -4,6 +4,7 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:dart_nostr/nostr/model/ease.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/core/test_environment.dart'; import 'package:mostro_mobile/core/models/relay_list_event.dart'; import 'package:mostro_mobile/features/settings/settings_notifier.dart'; import 'package:mostro_mobile/features/subscriptions/subscription_manager.dart'; @@ -123,7 +124,11 @@ class RelaysNotifier extends StateNotifier> { if (input.startsWith('wss://')) { return input; // Already properly formatted - } else if (input.startsWith('ws://') || input.startsWith('http')) { + } else if (input.startsWith('ws://')) { + // Plain WebSocket relays are accepted only inside the Mortsom test + // environment, where the relay is a local process on a private address. + return TestEnvironment.allowInsecureRelays ? input : null; + } else if (input.startsWith('http')) { return null; // Reject non-secure protocols } else { return 'wss://$input'; // Auto-add wss:// prefix @@ -143,7 +148,12 @@ class RelaysNotifier extends StateNotifier> { input = input.substring(8); } - // Reject IP addresses (basic check for numbers and dots only) + // Reject IP addresses (basic check for numbers and dots only), except + // the emulator's host address family inside the test environment. + if (TestEnvironment.allowInsecureRelays && + RegExp(r'^[\d.]+(:\d+)?$').hasMatch(input)) { + return true; + } if (RegExp(r'^[\d.]+$').hasMatch(input)) { return false; } @@ -560,7 +570,7 @@ class RelaysNotifier extends StateNotifier> { logger.i('Discovered ${normalizedRelays.length} relays from Mostro 10002: ' '$normalizedRelays'); final normalizedBootstrap = - Config.bootstrapRelays.map(_normalizeRelayUrl).toSet(); + Config.discoveryRelays.map(_normalizeRelayUrl).toSet(); final retiredBootstrap = normalizedBootstrap.where((b) => !normalizedRelays.contains(b)).toList(); if (retiredBootstrap.isNotEmpty) { diff --git a/lib/features/relays/widgets/relay_selector.dart b/lib/features/relays/widgets/relay_selector.dart index 12cb75293..2f2ce0ec8 100644 --- a/lib/features/relays/widgets/relay_selector.dart +++ b/lib/features/relays/widgets/relay_selector.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/features/relays/relay.dart'; import 'package:mostro_mobile/features/relays/relays_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -27,7 +29,7 @@ class RelaySelector extends ConsumerWidget { ), ), const SizedBox(height: 24), - + // Relay list if (mostroRelays.isEmpty) Container( @@ -60,9 +62,9 @@ class RelaySelector extends ConsumerWidget { ...mostroRelays.map((relayInfo) { return _buildRelayItem(context, ref, relayInfo); }), - + const SizedBox(height: 24), - + // Add relay button - aligned to the right Row( mainAxisAlignment: MainAxisAlignment.end, @@ -89,14 +91,15 @@ class RelaySelector extends ConsumerWidget { fontSize: 14, ), ), - ), + ).withAutomationId(AutomationIds.settingsRelaysAdd), ], ), ], ); } - Widget _buildRelayItem(BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) { + Widget _buildRelayItem( + BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) { return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), @@ -117,7 +120,7 @@ class RelaySelector extends ConsumerWidget { ), ), const SizedBox(width: 12), - + // Relay URL Expanded( child: Text( @@ -129,19 +132,21 @@ class RelaySelector extends ConsumerWidget { ), ), ), - + const SizedBox(width: 12), - + // Control - Switch for Mostro/default relays, Delete button for user relays relayInfo.source == RelaySource.user ? _buildDeleteButton(context, ref, relayInfo) : _buildRelaySwitch(context, ref, relayInfo), ], ), - ); + ).withAutomationId(AutomationIds.settingsRelayItem(relayInfo.url), + merge: false); } - Widget _buildDeleteButton(BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) { + Widget _buildDeleteButton( + BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) { return Container( width: 140, padding: const EdgeInsets.only(right: 16), @@ -157,13 +162,14 @@ class RelaySelector extends ConsumerWidget { color: Colors.white, size: 24, ), - ), + ).withAutomationId(AutomationIds.settingsRelayDelete(relayInfo.url)), ], ), ); } - Widget _buildRelaySwitch(BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) { + Widget _buildRelaySwitch( + BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) { return MostroSwitch( value: relayInfo.isActive, onChanged: (value) async { @@ -173,9 +179,10 @@ class RelaySelector extends ConsumerWidget { } /// Show confirmation dialog for deleting user relay - Future _showDeleteUserRelayDialog(BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) async { + Future _showDeleteUserRelayDialog( + BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) async { final relaysNotifier = ref.read(relaysProvider.notifier); - + // Check if this would leave no active relays if (relaysNotifier.wouldLeaveNoActiveRelays(relayInfo.url)) { await showDialog( @@ -203,7 +210,7 @@ class RelaySelector extends ConsumerWidget { ); return; // Exit early - don't proceed with deletion } - + // If not the last relay, show confirmation dialog final shouldDelete = await showDialog( context: context, @@ -245,24 +252,26 @@ class RelaySelector extends ConsumerWidget { } /// Handle relay toggle with safety checks and confirmation dialogs - Future _handleRelayToggle(BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) async { + Future _handleRelayToggle( + BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) async { final isCurrentlyBlacklisted = !relayInfo.isActive; final relaysNotifier = ref.read(relaysProvider.notifier); // Detect relay type (user vs mostro) final currentRelays = ref.read(relaysProvider); final relay = currentRelays.firstWhere( - (r) => r.url == relayInfo.url, + (r) => r.url == relayInfo.url, orElse: () => Relay(url: ''), // Empty relay if not found ); - final isUserRelay = relay.url.isNotEmpty && relay.source == RelaySource.user; - + final isUserRelay = + relay.url.isNotEmpty && relay.source == RelaySource.user; + // If removing from blacklist, proceed directly if (isCurrentlyBlacklisted) { await relaysNotifier.toggleMostroRelayBlacklist(relayInfo.url); return; } - + // Check if this would be the last active relay - BLOCK the action if (relaysNotifier.wouldLeaveNoActiveRelays(relayInfo.url)) { await showDialog( @@ -292,7 +301,7 @@ class RelaySelector extends ConsumerWidget { ); return; // Block the action - do NOT proceed } - + // Handle deactivation based on relay type if (isUserRelay) { // User relay: Delete completely (no blacklisting needed) @@ -336,51 +345,59 @@ class RelaySelector extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - 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: textController, - enabled: !isLoading, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - labelText: S.of(context)!.addRelayDialogPlaceholder, - labelStyle: const TextStyle(color: AppTheme.textSecondary), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - hintText: 'relay.example.com or wss://relay.example.com', - hintStyle: const TextStyle(color: AppTheme.textSecondary), - errorText: errorMessage, - errorStyle: const TextStyle(color: Colors.red), + 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)), ), - autofocus: true, - ), - ), - if (isLoading) ...[ - const SizedBox(height: 16), - Row( - children: [ - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(AppTheme.cream1), - ), - ), - const SizedBox(width: 12), - Text( - S.of(context)!.addRelayDialogTesting, - style: const TextStyle(color: AppTheme.textSecondary), + child: TextField( + controller: textController, + enabled: !isLoading, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: InputDecoration( + labelText: S.of(context)!.addRelayDialogPlaceholder, + labelStyle: + const TextStyle(color: AppTheme.textSecondary), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + hintText: + 'relay.example.com or wss://relay.example.com', + hintStyle: + const TextStyle(color: AppTheme.textSecondary), + errorText: errorMessage, + errorStyle: const TextStyle(color: Colors.red), ), - ], + autofocus: true, + ).withAutomationId(AutomationIds.settingsRelaysAddUrl), ), + if (isLoading) ...[ + const SizedBox(height: 16), + Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppTheme.cream1), + ), + ), + const SizedBox(width: 12), + Text( + S.of(context)!.addRelayDialogTesting, + style: + const TextStyle(color: AppTheme.textSecondary), + ), + ], + ), + ], ], - ], ), ), actions: [ @@ -396,7 +413,7 @@ class RelaySelector extends ConsumerWidget { ), textAlign: TextAlign.center, ), - ), + ).withAutomationId(AutomationIds.settingsRelaysAddCancel), const SizedBox(width: 12), ], ElevatedButton( @@ -416,13 +433,18 @@ class RelaySelector extends ConsumerWidget { }); try { - final result = await relaysNotifier.addRelayWithSmartValidation( + final result = await relaysNotifier + .addRelayWithSmartValidation( input, - errorOnlySecure: localizations.addRelayErrorOnlySecure, + errorOnlySecure: + localizations.addRelayErrorOnlySecure, errorNoHttp: localizations.addRelayErrorNoHttp, - errorInvalidDomain: localizations.addRelayErrorInvalidDomain, - errorAlreadyExists: localizations.addRelayErrorAlreadyExists, - errorNotValid: localizations.addRelayErrorNotValid, + errorInvalidDomain: + localizations.addRelayErrorInvalidDomain, + errorAlreadyExists: + localizations.addRelayErrorAlreadyExists, + errorNotValid: + localizations.addRelayErrorNotValid, ); if (result.success) { @@ -430,7 +452,8 @@ class RelaySelector extends ConsumerWidget { if (context.mounted) { SnackBarHelper.showTopSnackBar( context, - localizations.addRelaySuccessMessage(result.normalizedUrl!), + localizations.addRelaySuccessMessage( + result.normalizedUrl!), backgroundColor: Colors.green, ); } @@ -453,7 +476,8 @@ class RelaySelector extends ConsumerWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 12), ), child: Text( S.of(context)!.addRelayDialogAdd, @@ -463,7 +487,7 @@ class RelaySelector extends ConsumerWidget { ), textAlign: TextAlign.center, ), - ), + ).withAutomationId(AutomationIds.settingsRelaysAddConfirm), ], ); }, @@ -471,4 +495,4 @@ class RelaySelector extends ConsumerWidget { }, ); } -} \ No newline at end of file +} diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 31dcc85f0..6d94b770c 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:lucide_icons/lucide_icons.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/features/mostro/mostro_nodes_provider.dart'; import 'package:mostro_mobile/features/mostro/widgets/mostro_node_avatar.dart'; import 'package:mostro_mobile/features/mostro/widgets/mostro_node_selector.dart'; @@ -771,7 +773,8 @@ class _SettingsScreenState extends ConsumerState { ), maxLines: 1, overflow: TextOverflow.ellipsis, - ), + ).withAutomationId( + AutomationIds.settingsMostroNodePubkey), const SizedBox(height: 2), Text( S.of(context)!.tapToSelectNode, @@ -800,7 +803,8 @@ class _SettingsScreenState extends ConsumerState { ], ), ), - ), + ).withAutomationId(AutomationIds.settingsMostroNode, + merge: false), ), Container( margin: const EdgeInsets.only(top: 16), diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 2850c4e4e..ee3c334ad 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -5,6 +5,8 @@ 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/core/automation/automation_id.dart'; +import 'package:mostro_mobile/core/automation/automation_ids.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; @@ -476,7 +478,7 @@ class TradeDetailScreen extends ConsumerWidget { fontWeight: FontWeight.w600, ), ), - ), + ).withAutomationId(AutomationIds.tradeReleaseConfirm), ], ), ); @@ -616,7 +618,7 @@ class TradeDetailScreen extends ConsumerWidget { showSuccessIndicator: true, timeout: const Duration(seconds: 10), controller: controller, - ); + ).withAutomationId(AutomationIds.tradeAction(action.name)); } Widget _buildCancelButton( @@ -685,7 +687,7 @@ class TradeDetailScreen extends ConsumerWidget { ), textAlign: TextAlign.center, ), - ), + ).withAutomationId(AutomationIds.tradeCancelConfirm), ], ), ); @@ -700,7 +702,7 @@ class TradeDetailScreen extends ConsumerWidget { foregroundColor: Colors.white, ), child: Text(buttonText), - ); + ).withAutomationId(AutomationIds.tradeCancel); } Widget _buildDisputeButton( @@ -761,7 +763,7 @@ class TradeDetailScreen extends ConsumerWidget { fontWeight: FontWeight.w600, ), ), - ), + ).withAutomationId(AutomationIds.tradeDisputeConfirm), ], ), ); @@ -802,7 +804,7 @@ class TradeDetailScreen extends ConsumerWidget { foregroundColor: Colors.white, ), child: Text(S.of(context)!.disputeButton), - ); + ).withAutomationId(AutomationIds.tradeDispute); } Widget _buildContactButton(BuildContext context) { diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 79fafee14..101c10c32 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; +import 'package:mostro_mobile/core/automation/automation_ids.dart'; import 'package:mostro_mobile/data/enums.dart'; import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/features/order/models/order_state.dart'; @@ -49,7 +51,8 @@ class MostroMessageDetail extends ConsumerWidget { color: Colors.grey[400], fontSize: 12, ), - ), + ).withAutomationId(AutomationIds.orderStatus, + merge: false, label: orderState.status.value), ], ), ), @@ -77,8 +80,7 @@ class MostroMessageDetail extends ConsumerWidget { if (bondPhase == BondPayoutPhase.completed) { final prev = _previousNonBondAction(messages) ?? tradeState.action; final base = _renderActionMessage(context, ref, tradeState, prev); - final canRate = - messages.any((m) => m.action == actions.Action.rate); + final canRate = messages.any((m) => m.action == actions.Action.rate); final extension = canRate ? S.of(context)!.bondPayoutCompletedWithRating : S.of(context)!.bondPayoutCompletedMessage; diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index 7a637d2a7..5e4c0a8c9 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.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/enums/role.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; @@ -60,9 +62,7 @@ class TradesListItem extends ConsumerWidget { child: Container( margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), decoration: BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.circular(12.0), ), child: Padding( @@ -104,11 +104,10 @@ class TradesListItem extends ConsumerWidget { padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: - double.tryParse(trade.premium!) != null && - double.parse(trade.premium!) > 0 - ? AppTheme.premiumPositiveChip - : AppTheme.premiumNegativeChip, + color: double.tryParse(trade.premium!) != null && + double.parse(trade.premium!) > 0 + ? AppTheme.premiumPositiveChip + : AppTheme.premiumNegativeChip, borderRadius: BorderRadius.circular(8), ), child: Text( @@ -181,17 +180,15 @@ class TradesListItem extends ConsumerWidget { ), ), ), - ); + ).withAutomationId(AutomationIds.tradesItem(trade.orderId ?? '')); } Widget _buildRoleChip(BuildContext context, bool isCreator) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: isCreator ? AppTheme.createdByYouChip : AppTheme.takenByYouChip, borderRadius: BorderRadius.circular(12), - ), child: Text( isCreator ? S.of(context)!.createdByYou : S.of(context)!.takenByYou, @@ -214,8 +211,7 @@ class TradesListItem extends ConsumerWidget { String label; if (bondBadgeLabel != null) { - backgroundColor = - AppTheme.statusPendingBackground.withValues(alpha: 0.6); + backgroundColor = AppTheme.statusPendingBackground.withValues(alpha: 0.6); textColor = AppTheme.statusPendingText; label = bondBadgeLabel; return Container( @@ -237,7 +233,6 @@ class TradesListItem extends ConsumerWidget { switch (status) { case Status.active: - backgroundColor = AppTheme.statusActiveBackground.withValues(alpha: 0.3); textColor = AppTheme.statusActiveText; @@ -351,6 +346,7 @@ class TradesListItem extends ConsumerWidget { fontWeight: FontWeight.w500, ), ), - ); + ).withAutomationId(AutomationIds.tradesItemStatus, + merge: false, label: status.value); } } diff --git a/lib/features/walkthrough/screens/walkthrough_screen.dart b/lib/features/walkthrough/screens/walkthrough_screen.dart index 81d8d3ca7..392667791 100644 --- a/lib/features/walkthrough/screens/walkthrough_screen.dart +++ b/lib/features/walkthrough/screens/walkthrough_screen.dart @@ -3,6 +3,8 @@ import 'package:introduction_screen/introduction_screen.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/generated/l10n.dart'; import 'package:mostro_mobile/features/walkthrough/providers/first_run_provider.dart'; import 'package:mostro_mobile/features/notifications/providers/backup_reminder_provider.dart'; @@ -178,7 +180,7 @@ class _WalkthroughScreenState extends ConsumerState { @override Widget build(BuildContext context) { final firstRunState = ref.watch(firstRunProvider); - + return firstRunState.when( data: (isFirstRun) { // If this is not the first run, redirect to home @@ -190,7 +192,7 @@ class _WalkthroughScreenState extends ConsumerState { }); return const SizedBox.shrink(); } - + // Show walkthrough for first run final theme = Theme.of(context); return SafeArea( @@ -200,11 +202,15 @@ class _WalkthroughScreenState extends ConsumerState { onSkip: () => _onIntroEnd(context), showSkipButton: true, showBackButton: true, - back: const Icon(Icons.arrow_back), - skip: Text(S.of(context)!.skip), - next: const Icon(Icons.arrow_forward), + back: const Icon(Icons.arrow_back) + .withAutomationId(AutomationIds.onboardingBack), + skip: Text(S.of(context)!.skip) + .withAutomationId(AutomationIds.onboardingSkip), + next: const Icon(Icons.arrow_forward) + .withAutomationId(AutomationIds.onboardingNext), done: Text(S.of(context)!.done, - style: const TextStyle(fontWeight: FontWeight.w600)), + style: const TextStyle(fontWeight: FontWeight.w600)) + .withAutomationId(AutomationIds.onboardingDone), dotsDecorator: DotsDecorator( activeColor: theme.primaryColor, size: const Size(8, 8), diff --git a/lib/features/wallet/screens/connect_wallet_screen.dart b/lib/features/wallet/screens/connect_wallet_screen.dart index 6cb5208b6..c419741b9 100644 --- a/lib/features/wallet/screens/connect_wallet_screen.dart +++ b/lib/features/wallet/screens/connect_wallet_screen.dart @@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:lucide_icons/lucide_icons.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/features/wallet/providers/nwc_provider.dart'; import 'package:mostro_mobile/services/nwc/nwc_connection.dart'; import 'package:mostro_mobile/services/nwc/nwc_exceptions.dart'; @@ -163,7 +165,7 @@ class _ConnectWalletScreenState extends ConsumerState { tooltip: S.of(context)!.scanQrCode, ), ), - ), + ).withAutomationId(AutomationIds.walletNwcUri), ), // Validation error @@ -208,7 +210,7 @@ class _ConnectWalletScreenState extends ConsumerState { borderRadius: BorderRadius.circular(8), ), ), - ), + ).withAutomationId(AutomationIds.walletNwcConnect), ), ], ), diff --git a/lib/features/wallet/screens/wallet_settings_screen.dart b/lib/features/wallet/screens/wallet_settings_screen.dart index 54eca3675..d7f35f8c0 100644 --- a/lib/features/wallet/screens/wallet_settings_screen.dart +++ b/lib/features/wallet/screens/wallet_settings_screen.dart @@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:lucide_icons/lucide_icons.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/features/wallet/providers/nwc_provider.dart'; import 'package:mostro_mobile/features/wallet/widgets/wallet_balance_widget.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -186,7 +188,7 @@ class WalletSettingsScreen extends ConsumerWidget { borderRadius: BorderRadius.circular(8), ), ), - ), + ).withAutomationId(AutomationIds.walletSettingsDisconnect), ), ], ); @@ -301,7 +303,7 @@ class WalletSettingsScreen extends ConsumerWidget { borderRadius: BorderRadius.circular(8), ), ), - ), + ).withAutomationId(AutomationIds.walletSettingsConnect), ), ], ), diff --git a/lib/features/wallet/widgets/wallet_status_card.dart b/lib/features/wallet/widgets/wallet_status_card.dart index 09321b964..3962f640a 100644 --- a/lib/features/wallet/widgets/wallet_status_card.dart +++ b/lib/features/wallet/widgets/wallet_status_card.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.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/features/wallet/providers/nwc_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -96,7 +98,10 @@ class WalletStatusCard extends ConsumerWidget { fontSize: 15, fontWeight: FontWeight.w500, ), - ), + ).withAutomationId(AutomationIds.walletConnection, + merge: false, + label: + isConnected ? 'connected' : 'disconnected'), if (isConnected && nwcState.balanceSats != null) ...[ const SizedBox(height: 4), @@ -119,7 +124,7 @@ class WalletStatusCard extends ConsumerWidget { ], ), ), - ), + ).withAutomationId(AutomationIds.settingsWallet), ), ], ), diff --git a/lib/main.dart b/lib/main.dart index 2238d9b64..c90213192 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,187 +1,4 @@ -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:mostro_mobile/core/app.dart'; -import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; -import 'package:mostro_mobile/features/relays/relays_provider.dart'; -import 'package:mostro_mobile/features/settings/settings_notifier.dart'; -import 'package:mostro_mobile/features/settings/settings_provider.dart'; -import 'package:mostro_mobile/background/background_service.dart'; -import 'package:mostro_mobile/features/notifications/services/background_notification_service.dart'; -import 'package:mostro_mobile/services/fcm_service.dart'; -import 'package:mostro_mobile/services/push_notification_service.dart'; -import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; -import 'package:mostro_mobile/shared/providers/providers.dart'; -import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; -import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; -import 'package:mostro_mobile/shared/utils/notification_permission_helper.dart'; -import 'package:mostro_mobile/services/logger_service.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:timeago/timeago.dart' as timeago; +import 'package:mostro_mobile/core/app_bootstrap.dart'; -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - - initIsolateLogReceiver(); - - await requestNotificationPermissionIfNeeded(); - - final biometricsHelper = BiometricsHelper(); - final sharedPreferences = SharedPreferencesAsync(); - final secureStorage = const FlutterSecureStorage(); - - final mostroDatabase = await openMostroDatabase('mostro.db'); - final eventsDatabase = await openMostroDatabase('events.db'); - - final settings = SettingsNotifier(sharedPreferences); - await settings.init(); - - await initializeNotifications(); - - _initializeTimeAgoLocalization(); - - final backgroundService = createBackgroundService(settings.settings); - await backgroundService.init(); - - // Initialize FCM (skip on Linux) - final pushServices = await _initializeFirebaseMessaging(sharedPreferences); - - final container = ProviderContainer( - overrides: [ - settingsProvider.overrideWith((b) => settings), - backgroundServiceProvider.overrideWithValue(backgroundService), - biometricsHelperProvider.overrideWithValue(biometricsHelper), - sharedPreferencesProvider.overrideWithValue(sharedPreferences), - secureStorageProvider.overrideWithValue(secureStorage), - mostroDatabaseProvider.overrideWithValue(mostroDatabase), - eventDatabaseProvider.overrideWithValue(eventsDatabase), - if (pushServices != null) ...[ - fcmServiceProvider.overrideWithValue(pushServices.fcmService), - pushNotificationServiceProvider - .overrideWithValue(pushServices.pushService), - ], - ], - ); - - // Initialize relay sync on app start - _initializeRelaySynchronization(container); - - // Connect push notification service with session notifier and settings - if (pushServices != null) { - _initializePushNotificationIntegration(container, pushServices); - } - - runApp( - UncontrolledProviderScope( - container: container, - child: const MostroApp(), - ), - ); -} - -/// Initialize relay synchronization on app startup -void _initializeRelaySynchronization(ProviderContainer container) { - try { - // Read the relays provider to trigger initialization of RelaysNotifier - // This will automatically start sync with the configured Mostro instance - container.read(relaysProvider); - } catch (e) { - // Log error but don't crash app if relay sync initialization fails - debugPrint('Failed to initialize relay synchronization: $e'); - } -} - -/// Initialize push notification integration with session notifier and settings -void _initializePushNotificationIntegration( - ProviderContainer container, - _PushServices pushServices, -) { - try { - // Connect push service with session notifier for automatic token registration - container - .read(sessionNotifierProvider.notifier) - .setPushNotificationService(pushServices.pushService); - - // Connect push services with settings notifier for unregistration on disable - container - .read(settingsProvider.notifier) - .setPushServices(pushServices.pushService, pushServices.fcmService); - - // Set up settings check callback - pushServices.pushService.isPushEnabledInSettings = () { - return container.read(settingsProvider).pushNotificationsEnabled; - }; - - // Provide the active Mostro instance pubkey for /api/register - pushServices.pushService.getMostroPubkey = () { - return container.read(settingsProvider).mostroPublicKey; - }; - - debugPrint('Push notification integration initialized'); - } catch (e) { - debugPrint('Failed to initialize push notification integration: $e'); - } -} - -/// Initialize timeago localization for supported languages -void _initializeTimeAgoLocalization() { - // Set Spanish locale for timeago - timeago.setLocaleMessages('es', timeago.EsMessages()); - - // Set Italian locale for timeago - timeago.setLocaleMessages('it', timeago.ItMessages()); - - // Set German locale for timeago - timeago.setLocaleMessages('de', timeago.DeMessages()); - - // Set French locale for timeago - timeago.setLocaleMessages('fr', timeago.FrMessages()); - - // Set Portuguese locale for timeago - timeago.setLocaleMessages('pt', timeago.PtBrMessages()); - - // English is already the default, no need to set it -} - -/// Result of Firebase/push notification initialization -class _PushServices { - final FCMService fcmService; - final PushNotificationService pushService; - - _PushServices({required this.fcmService, required this.pushService}); -} - -/// Initialize Firebase Cloud Messaging and Push Notification Service -/// Returns the initialized services, or null if not supported/failed -Future<_PushServices?> _initializeFirebaseMessaging( - SharedPreferencesAsync prefs) async { - try { - // Skip Firebase initialization on Linux (not supported) - if (!kIsWeb && Platform.isLinux) { - debugPrint( - 'Firebase not supported on Linux - skipping FCM initialization'); - return null; - } - - final fcmService = FCMService(prefs); - await fcmService.initialize(); - - // Initialize Push Notification Service (for encrypted token registration) - final pushService = PushNotificationService(fcmService: fcmService); - await pushService.initialize(); - - // Wire up token refresh to re-register all trade pubkeys - fcmService.onTokenRefresh = (_) => pushService.reRegisterAllTokens(); - - // Note: isPushEnabledInSettings callback will be set after ProviderContainer is created - // This is done in the app initialization to have access to settings provider - - return _PushServices(fcmService: fcmService, pushService: pushService); - } catch (e) { - // Log error but don't crash app if FCM initialization fails - debugPrint('Failed to initialize Firebase Cloud Messaging: $e'); - return null; - } -} +/// Production entry point. Never arms the Mortsom test environment. +Future main() => bootstrapAndRun(); diff --git a/lib/main_mortsom.dart b/lib/main_mortsom.dart new file mode 100644 index 000000000..3f4599840 --- /dev/null +++ b/lib/main_mortsom.dart @@ -0,0 +1,35 @@ +// Mortsom test-environment entry point. +// +// Build with: +// flutter build apk -t lib/main_mortsom.dart \ +// --dart-define=MORTSOM_TEST_ENV=true \ +// --dart-define=MOSTRO_PUB_KEY= \ +// --dart-define=MORTSOM_RELAYS=ws://10.0.2.2:7000 +// +// This entry point arms `TestEnvironment` and seeds the local relay list on +// first launch so subscriptions never touch public bootstrap relays. It is +// the ONLY place that arms the test environment; `lib/main.dart` (the +// production entry point) never does. + +import 'package:flutter/semantics.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mostro_mobile/core/app_bootstrap.dart'; +import 'package:mostro_mobile/core/test_environment.dart'; + +Future main() async { + TestEnvironment.arm(); + // Without a seed list `Config.discoveryRelays` is empty and relay init + // fails on an assertion deep inside dart_nostr, which reads as an opaque + // startup crash rather than the build mistake it is. + if (TestEnvironment.seedRelays.isEmpty) { + throw StateError( + 'The Mortsom build requires --dart-define=MORTSOM_TEST_ENV=true and ' + '--dart-define=MORTSOM_RELAYS=[,...]', + ); + } + // UiAutomator2 acts as an accessibility service, but make the semantics + // tree unconditional so identifiers exist from the first frame. + WidgetsFlutterBinding.ensureInitialized(); + SemanticsBinding.instance.ensureSemantics(); + await bootstrapAndRun(seedRelays: TestEnvironment.seedRelays); +} diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 85a045350..354b53cb7 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -40,7 +40,7 @@ class NostrService { /// 10002 discovery), so init never fails on an empty list. @visibleForTesting static List effectiveRelays(List configured) => - configured.isEmpty ? Config.bootstrapRelays : configured; + configured.isEmpty ? Config.discoveryRelays : configured; Future init(Settings settings) async { final relays = effectiveRelays(settings.relays); @@ -223,7 +223,7 @@ class NostrService { /// discovery possible when no discovered relay is reachable. Future ensureBootstrapConnectivity() async { final current = settings.relays; - final merged = {...current, ...Config.bootstrapRelays}.toList(); + final merged = {...current, ...Config.discoveryRelays}.toList(); if (ListEquality().equals(current, merged)) { logger.d('Bootstrap relays already part of the active relay set'); @@ -231,7 +231,7 @@ class NostrService { } logger.i( - 'Bootstrap: connecting to defensive relays: ${Config.bootstrapRelays}', + 'Bootstrap: connecting to defensive relays: ${Config.discoveryRelays}', ); await updateSettings(settings.copyWith(relays: merged)); } diff --git a/lib/shared/widgets/add_lightning_invoice_widget.dart b/lib/shared/widgets/add_lightning_invoice_widget.dart index f0e5a0e9c..7618376f2 100644 --- a/lib/shared/widgets/add_lightning_invoice_widget.dart +++ b/lib/shared/widgets/add_lightning_invoice_widget.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.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/generated/l10n.dart'; class AddLightningInvoiceWidget extends StatefulWidget { @@ -70,7 +72,7 @@ class _AddLightningInvoiceWidgetState extends State { alignLabelWithHint: true, ), maxLines: 6, - ), + ).withAutomationId(AutomationIds.invoiceText), ), const SizedBox(height: 16), Row( @@ -88,7 +90,7 @@ class _AddLightningInvoiceWidgetState extends State { ), textAlign: TextAlign.center, ), - ), + ).withAutomationId(AutomationIds.invoiceCancel), ), const SizedBox(width: 12), Expanded( @@ -111,7 +113,7 @@ class _AddLightningInvoiceWidgetState extends State { fontWeight: FontWeight.w500, ), ), - ), + ).withAutomationId(AutomationIds.invoiceSubmit), ), ], ), diff --git a/lib/shared/widgets/add_order_button.dart b/lib/shared/widgets/add_order_button.dart index e59318a39..e5fb5755f 100644 --- a/lib/shared/widgets/add_order_button.dart +++ b/lib/shared/widgets/add_order_button.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.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/generated/l10n.dart'; class AddOrderButton extends StatefulWidget { @@ -92,9 +94,9 @@ class _AddOrderButtonState extends State ), icon: const SizedBox(width: 16, height: 16), label: Text(S.of(context)!.buy, - style: - const TextStyle(fontWeight: FontWeight.bold)), - ), + style: const TextStyle( + fontWeight: FontWeight.bold)), + ).withAutomationId(AutomationIds.orderAddBuy), if (_isMenuOpen) const Positioned( left: 12, @@ -125,9 +127,9 @@ class _AddOrderButtonState extends State ), icon: const SizedBox(width: 16, height: 16), label: Text(S.of(context)!.sell, - style: - const TextStyle(fontWeight: FontWeight.bold)), - ), + style: const TextStyle( + fontWeight: FontWeight.bold)), + ).withAutomationId(AutomationIds.orderAddSell), if (_isMenuOpen) const Positioned( left: 12, @@ -162,7 +164,7 @@ class _AddOrderButtonState extends State ); }, ), - ), + ).withAutomationId(AutomationIds.orderAddFab), ], ), ); diff --git a/lib/shared/widgets/bottom_nav_bar.dart b/lib/shared/widgets/bottom_nav_bar.dart index ac56c8d18..fd4ffa77f 100644 --- a/lib/shared/widgets/bottom_nav_bar.dart +++ b/lib/shared/widgets/bottom_nav_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/core/automation/automation_ids.dart'; import 'package:mostro_mobile/generated/l10n.dart'; final chatCountProvider = StateProvider((ref) => 0); @@ -62,6 +63,13 @@ class BottomNavBar extends ConsumerWidget { ); } + /// Stable automation identifier per tab (see `AutomationIds`). + static String _navIdentifier(int index) => switch (index) { + 0 => AutomationIds.navOrderBook, + 1 => AutomationIds.navTrades, + _ => AutomationIds.navChat, + }; + Widget _buildNavItem( BuildContext context, IconData icon, String label, int index, {int? notificationCount}) { @@ -72,6 +80,7 @@ class BottomNavBar extends ConsumerWidget { return Expanded( child: Semantics( + identifier: _navIdentifier(index), button: true, enabled: true, label: S.of(context)!.navigateToLabel(label), diff --git a/lib/shared/widgets/currency_selection_dialog.dart b/lib/shared/widgets/currency_selection_dialog.dart index d2ac65db8..d5c0f6323 100644 --- a/lib/shared/widgets/currency_selection_dialog.dart +++ b/lib/shared/widgets/currency_selection_dialog.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -156,7 +158,9 @@ class _CurrencySelectionDialogWidgetState onTap: () { Navigator.of(context).pop(code); }, - ); + ).withAutomationId( + AutomationIds.orderCreateCurrencyOption( + code)); }, ); }, diff --git a/lib/shared/widgets/custom_drawer_overlay.dart b/lib/shared/widgets/custom_drawer_overlay.dart index 52f94ee99..6dbb6fd7c 100644 --- a/lib/shared/widgets/custom_drawer_overlay.dart +++ b/lib/shared/widgets/custom_drawer_overlay.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.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/shared/providers/drawer_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -127,6 +129,13 @@ class CustomDrawerOverlay extends ConsumerWidget { ); } + /// Stable automation identifier per drawer destination. + static String _drawerIdentifier(String route) => switch (route) { + '/key_management' => AutomationIds.drawerAccount, + '/settings' => AutomationIds.drawerSettings, + _ => AutomationIds.drawerAbout, + }; + Widget _buildMenuItem( BuildContext context, WidgetRef ref, { @@ -152,6 +161,6 @@ class CustomDrawerOverlay extends ConsumerWidget { ref.read(drawerProvider.notifier).closeDrawer(); context.push(route); }, - ); + ).withAutomationId(_drawerIdentifier(route)); } } diff --git a/lib/shared/widgets/mostro_app_bar.dart b/lib/shared/widgets/mostro_app_bar.dart index 0896175c1..0a68d74b0 100644 --- a/lib/shared/widgets/mostro_app_bar.dart +++ b/lib/shared/widgets/mostro_app_bar.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.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/shared/providers/drawer_provider.dart'; import 'package:mostro_mobile/shared/widgets/notification_history_bell_widget.dart'; import 'dart:async'; @@ -68,7 +70,7 @@ class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { final bool showBackButton; final bool showDrawerButton; final List? actions; - + const MostroAppBar({ super.key, this.title, @@ -92,7 +94,7 @@ class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { size: 28, ), onPressed: () => context.pop(), - ), + ).withAutomationId(AutomationIds.appBarBack), ); } else if (showDrawerButton) { leading = Padding( @@ -105,16 +107,17 @@ class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { size: 28, ), onPressed: () => ref.read(drawerProvider.notifier).toggleDrawer(), - ), + ).withAutomationId(AutomationIds.appBarDrawer), ); } - + // Use provided actions or default to just notification bell - List appBarActions = actions ?? [ - const NotificationBellWidget(), - const SizedBox(width: 16), - ]; - + List appBarActions = actions ?? + [ + const NotificationBellWidget(), + const SizedBox(width: 16), + ]; + return AppBar( backgroundColor: AppTheme.backgroundDark, elevation: 0, diff --git a/lib/shared/widgets/nwc_invoice_widget.dart b/lib/shared/widgets/nwc_invoice_widget.dart index 0eb77b9c5..2dba63c4f 100644 --- a/lib/shared/widgets/nwc_invoice_widget.dart +++ b/lib/shared/widgets/nwc_invoice_widget.dart @@ -2,10 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.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/features/wallet/providers/nwc_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/services/logger_service.dart'; -import 'package:mostro_mobile/services/nwc/nwc_exceptions.dart' show NwcResponseException, NwcTimeoutException, NwcErrorCode; +import 'package:mostro_mobile/services/nwc/nwc_exceptions.dart' + show NwcResponseException, NwcTimeoutException, NwcErrorCode; /// Invoice generation status for the NWC auto-invoice flow. enum NwcInvoiceStatus { @@ -177,7 +180,7 @@ class _NwcInvoiceWidgetState extends ConsumerState { borderRadius: BorderRadius.circular(12), ), ), - ), + ).withAutomationId(AutomationIds.invoiceNwcGenerate), ), const SizedBox(height: 8), Text( @@ -272,7 +275,8 @@ class _NwcInvoiceWidgetState extends ConsumerState { fontFamily: 'monospace', ), textAlign: TextAlign.center, - ), + ).withAutomationId(AutomationIds.invoiceNwcText, + merge: false, label: _generatedInvoice ?? ''), const SizedBox(height: 16), SizedBox( width: double.infinity, @@ -295,7 +299,7 @@ class _NwcInvoiceWidgetState extends ConsumerState { fontWeight: FontWeight.w600, ), ), - ), + ).withAutomationId(AutomationIds.invoiceNwcConfirm), ), ], ), diff --git a/lib/shared/widgets/nwc_payment_widget.dart b/lib/shared/widgets/nwc_payment_widget.dart index e1f405185..af534ba92 100644 --- a/lib/shared/widgets/nwc_payment_widget.dart +++ b/lib/shared/widgets/nwc_payment_widget.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.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/features/wallet/providers/nwc_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/services/logger_service.dart'; @@ -176,8 +178,8 @@ class _NwcPaymentWidgetState extends ConsumerState { // Show the "Show invoice" fallback whenever the user is not actively // paying or already done, so they can always pay from another wallet // (e.g. when the NWC wallet has insufficient balance). - final showFallback = _status == NwcPaymentStatus.idle || - _status == NwcPaymentStatus.failed; + final showFallback = + _status == NwcPaymentStatus.idle || _status == NwcPaymentStatus.failed; return Column( children: [ @@ -283,7 +285,7 @@ class _NwcPaymentWidgetState extends ConsumerState { borderRadius: BorderRadius.circular(12), ), ), - ), + ).withAutomationId(AutomationIds.payNwc), ), if (balanceSats != null) ...[ const SizedBox(height: 8), diff --git a/lib/shared/widgets/order_cards.dart b/lib/shared/widgets/order_cards.dart index ccb5b1608..cc9a56060 100644 --- a/lib/shared/widgets/order_cards.dart +++ b/lib/shared/widgets/order_cards.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; @@ -28,14 +30,12 @@ class OrderAmountCard extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { final currencyData = ref.watch(currencyCodesProvider).asData?.value; final currencyFlag = CurrencyUtils.getFlagFromCurrencyData(currency, currencyData); final amountString = '$amount $currencyFlag'; - return CustomCard( padding: const EdgeInsets.all(16), child: Column( @@ -52,11 +52,12 @@ class OrderAmountCard extends ConsumerWidget { const SizedBox(height: 8), Row( children: [ - Flexible( child: RichText( text: TextSpan( - text: S.of(context)!.forAmountWithCurrency(amountString, currency), + text: S + .of(context)! + .forAmountWithCurrency(amountString, currency), style: const TextStyle( color: Colors.white70, fontSize: 16, @@ -71,7 +72,6 @@ class OrderAmountCard extends ConsumerWidget { ), ), ], - ), softWrap: true, maxLines: 2, @@ -221,7 +221,7 @@ class OrderIdCard extends StatelessWidget { color: AppTheme.mostroGreen, fontSize: 14, ), - ), + ).withAutomationId(AutomationIds.orderId), ), IconButton( icon: const Icon( diff --git a/lib/shared/widgets/test_environment_banner.dart b/lib/shared/widgets/test_environment_banner.dart new file mode 100644 index 000000000..4560678e2 --- /dev/null +++ b/lib/shared/widgets/test_environment_banner.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/test_environment.dart'; + +/// Visible marker shown on every screen while the app runs in the Mortsom +/// test environment. It makes a test build impossible to confuse with a +/// production one and gives automation a stable element to assert on. +class TestEnvironmentBanner extends StatelessWidget { + const TestEnvironmentBanner({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + if (!TestEnvironment.enabled) { + return child; + } + // Only the banner is pinned to LTR. Wrapping the stack would force the + // application under test into LTR as well, so an RTL locale would not + // exercise the layout it ships with. The stack still needs an explicit + // direction for `Positioned` to resolve left/right. + return Stack( + textDirection: TextDirection.ltr, + children: [ + child, + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: Directionality( + textDirection: TextDirection.ltr, + child: Semantics( + identifier: AutomationIds.envMarker, + label: TestEnvironment.markerLabel, + container: true, + child: IgnorePointer( + child: Container( + color: const Color(0xCCB71C1C), + padding: const EdgeInsets.symmetric(vertical: 2), + alignment: Alignment.center, + child: const Text( + TestEnvironment.markerLabel, + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + decoration: TextDecoration.none, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/test/core/automation/automation_contract_test.dart b/test/core/automation/automation_contract_test.dart new file mode 100644 index 000000000..136a28af1 --- /dev/null +++ b/test/core/automation/automation_contract_test.dart @@ -0,0 +1,306 @@ +// Automation contract tests: every semantic identifier Mortsom relies on +// must stay present, unique and namespaced. Removing or renaming one is a +// contract change (see docs/automation-contract.md) and must fail here. + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/test_environment.dart'; +import 'package:mostro_mobile/features/community/community.dart'; +import 'package:mostro_mobile/features/community/widgets/community_card.dart'; +import 'package:mostro_mobile/features/key_manager/import_mnemonic_dialog.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; +import 'package:mostro_mobile/shared/widgets/add_order_button.dart'; +import 'package:mostro_mobile/shared/widgets/order_cards.dart'; +import 'package:mostro_mobile/shared/widgets/test_environment_banner.dart'; + +/// Every static identifier declared on [AutomationIds]. +const List staticIds = [ + AutomationIds.envMarker, + AutomationIds.appBarDrawer, + AutomationIds.appBarBack, + AutomationIds.navOrderBook, + AutomationIds.navTrades, + AutomationIds.navChat, + AutomationIds.drawerAccount, + AutomationIds.drawerSettings, + AutomationIds.drawerAbout, + AutomationIds.onboardingBack, + AutomationIds.onboardingSkip, + AutomationIds.onboardingNext, + AutomationIds.onboardingDone, + AutomationIds.communityCustomNode, + AutomationIds.communityDone, + AutomationIds.communitySkip, + AutomationIds.keysGenerate, + AutomationIds.keysGenerateConfirm, + AutomationIds.keysImport, + AutomationIds.keysImportMnemonic, + AutomationIds.keysImportConfirm, + AutomationIds.keysImportCancel, + AutomationIds.keysSeedReveal, + AutomationIds.keysSeedText, + AutomationIds.keysPublicKey, + AutomationIds.settingsMostroNode, + AutomationIds.settingsMostroNodePubkey, + AutomationIds.settingsWallet, + AutomationIds.settingsRelaysAdd, + AutomationIds.settingsRelaysAddUrl, + AutomationIds.settingsRelaysAddConfirm, + AutomationIds.settingsRelaysAddCancel, + AutomationIds.nodeAddCustom, + AutomationIds.nodeCustomPubkey, + AutomationIds.nodeCustomName, + AutomationIds.nodeCustomConfirm, + AutomationIds.nodeCustomCancel, + AutomationIds.walletNwcUri, + AutomationIds.walletNwcConnect, + AutomationIds.walletConnection, + AutomationIds.walletSettingsConnect, + AutomationIds.walletSettingsDisconnect, + AutomationIds.orderBookTabBuy, + AutomationIds.orderBookTabSell, + AutomationIds.orderAddFab, + AutomationIds.orderAddBuy, + AutomationIds.orderAddSell, + AutomationIds.orderCreateCurrency, + AutomationIds.orderCreateFiatAmount, + AutomationIds.orderCreateFiatAmountMax, + AutomationIds.orderCreatePaymentMethod, + AutomationIds.orderCreatePriceType, + AutomationIds.orderCreateSatsAmount, + AutomationIds.orderCreateSubmit, + AutomationIds.orderCreateCancel, + AutomationIds.orderConfirmHome, + AutomationIds.orderTakeConfirm, + AutomationIds.orderTakeClose, + AutomationIds.orderTakeAmount, + AutomationIds.orderTakeAmountConfirm, + AutomationIds.orderId, + AutomationIds.orderStatus, + AutomationIds.tradesItemStatus, + AutomationIds.tradePayInvoice, + AutomationIds.tradeAddInvoice, + AutomationIds.tradeTakeSell, + AutomationIds.tradeTakeBuy, + AutomationIds.tradeFiatSent, + AutomationIds.tradeRelease, + AutomationIds.tradeReleaseConfirm, + AutomationIds.tradeCancel, + AutomationIds.tradeCancelConfirm, + AutomationIds.tradeDispute, + AutomationIds.tradeDisputeConfirm, + AutomationIds.invoiceText, + AutomationIds.invoiceSubmit, + AutomationIds.invoiceCancel, + AutomationIds.invoiceNwcGenerate, + AutomationIds.invoiceNwcConfirm, + AutomationIds.invoiceNwcText, + AutomationIds.payInvoiceText, + AutomationIds.payNwc, + AutomationIds.payCancel, +]; + +Widget harness(Widget child) => MaterialApp( + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en')], + home: Scaffold(body: child), + ); + +void main() { + group('AutomationIds', () { + test('are unique and namespaced', () { + final pattern = RegExp(r'^[a-z][a-zA-Z0-9_]*(\.[a-z][a-zA-Z0-9_]*)+$'); + expect(staticIds.toSet().length, staticIds.length, + reason: 'duplicate id'); + for (final id in staticIds) { + expect(pattern.hasMatch(id), isTrue, reason: '$id is not namespaced'); + } + }); + + test('dynamic helpers keep their documented prefixes', () { + expect( + AutomationIds.communityCard('abc'), 'onboarding.community.card.abc'); + expect(AutomationIds.nodeItem('abc'), 'node.item.abc'); + expect(AutomationIds.settingsRelayItem('ws://x'), + 'settings.relays.item.ws://x'); + // One relay, one identifier: the trailing slash must not fork it, and + // each delete control is keyed by its own relay. + expect(AutomationIds.settingsRelayItem('ws://x/'), + AutomationIds.settingsRelayItem('ws://x')); + expect(AutomationIds.settingsRelayDelete('ws://x/'), + 'settings.relays.item.ws://x.delete'); + expect(AutomationIds.settingsRelayDelete('ws://y'), + isNot(AutomationIds.settingsRelayDelete('ws://x'))); + expect(AutomationIds.orderBookItem('o1'), 'order.book.item.o1'); + expect(AutomationIds.tradesItem('o1'), 'trades.item.o1'); + expect( + AutomationIds.tradeAction('fiatSent'), AutomationIds.tradeFiatSent); + expect(AutomationIds.tradeAction('release'), AutomationIds.tradeRelease); + expect(AutomationIds.tradeAction('payInvoice'), + AutomationIds.tradePayInvoice); + expect( + AutomationIds.tradeAction('takeSell'), AutomationIds.tradeTakeSell); + expect(AutomationIds.orderCreateCurrencyOption('USD'), + 'order.create.currency.USD'); + }); + }); + + group('TestEnvironment', () { + tearDown(TestEnvironment.disarm); + + test('is disabled unless armed and compiled with the define', () { + // Stated against the compile-time define rather than a fixed false, so + // the suite is correct whether or not it carries MORTSOM_TEST_ENV. + expect(TestEnvironment.enabled, isFalse); + TestEnvironment.arm(); + expect(TestEnvironment.enabled, TestEnvironment.defineEnabled); + expect(TestEnvironment.disableBootstrapFallback, + TestEnvironment.defineEnabled); + expect( + TestEnvironment.allowInsecureRelays, TestEnvironment.defineEnabled); + }); + + test('parses relay lists', () { + expect(TestEnvironment.parseRelays(' ws://10.0.2.2:7000, ,wss://x '), + ['ws://10.0.2.2:7000', 'wss://x']); + expect(TestEnvironment.parseRelays(''), isEmpty); + }); + }); + + group('AutomationId widget', () { + testWidgets('exposes the identifier and merges the label', (tester) async { + await tester.pumpWidget(harness( + ElevatedButton(onPressed: () {}, child: const Text('Go')) + .withAutomationId('demo.control'), + )); + + // `containsSemantics` (rather than `SemanticsData.flagsCollection`) + // keeps this readable on the Flutter version CI pins; migrate to + // `isSemantics` when the pin moves past 3.40. + expect( + tester.getSemantics(find.bySemanticsIdentifier('demo.control')), + containsSemantics(label: 'Go', isButton: true), + ); + }); + + testWidgets('merged text fields stay editable through accessibility', + (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget(harness( + TextField(controller: controller).withAutomationId('demo.field'), + )); + + // Tapping the merged node focuses the field, as a driver's tap would. + await tester.tap(find.bySemanticsIdentifier('demo.field')); + await tester.pump(); + final node = + tester.getSemantics(find.bySemanticsIdentifier('demo.field')); + expect( + node, + containsSemantics( + isTextField: true, + isFocused: true, + hasSetTextAction: true, + ), + ); + + tester.semantics.performAction( + find.semantics.byPredicate((n) => n.identifier == 'demo.field'), + SemanticsAction.setText, + args: 'ws://10.0.2.2:7000', + ); + await tester.pump(); + expect(controller.text, 'ws://10.0.2.2:7000'); + }); + + testWidgets('container mode keeps an explicit state label', (tester) async { + await tester.pumpWidget(harness( + const Text('Wallet') + .withAutomationId('demo.state', merge: false, label: 'connected'), + )); + + final semantics = + tester.getSemantics(find.bySemanticsIdentifier('demo.state')); + expect(semantics.label, 'connected'); + expect(semantics.getSemanticsData().label, 'connected'); + }); + }); + + group('contract identifiers on real widgets', () { + testWidgets('order creation entry points', (tester) async { + await tester.pumpWidget(harness(const AddOrderButton())); + expect(find.bySemanticsIdentifier(AutomationIds.orderAddFab), + findsOneWidget); + // Buy/sell are hidden (opacity 0) until the menu opens. + await tester.tap(find.bySemanticsIdentifier(AutomationIds.orderAddFab)); + await tester.pumpAndSettle(); + expect(find.bySemanticsIdentifier(AutomationIds.orderAddBuy), + findsOneWidget); + expect(find.bySemanticsIdentifier(AutomationIds.orderAddSell), + findsOneWidget); + }); + + testWidgets('invoice entry', (tester) async { + await tester.pumpWidget(harness(AddLightningInvoiceWidget( + controller: TextEditingController(), + onSubmit: () {}, + onCancel: () {}, + amount: 1000, + fiatAmount: '10', + fiatCode: 'USD', + orderId: 'order-1', + ))); + expect(find.bySemanticsIdentifier(AutomationIds.invoiceText), + findsOneWidget); + expect(find.bySemanticsIdentifier(AutomationIds.invoiceSubmit), + findsOneWidget); + expect(find.bySemanticsIdentifier(AutomationIds.invoiceCancel), + findsOneWidget); + }); + + testWidgets('mnemonic import dialog', (tester) async { + await tester.pumpWidget(harness(const ImportMnemonicDialog())); + expect(find.bySemanticsIdentifier(AutomationIds.keysImportMnemonic), + findsOneWidget); + expect(find.bySemanticsIdentifier(AutomationIds.keysImportConfirm), + findsOneWidget); + expect(find.bySemanticsIdentifier(AutomationIds.keysImportCancel), + findsOneWidget); + }); + + testWidgets('community card carries its pubkey', (tester) async { + const community = Community(pubkey: 'deadbeef', region: 'Test'); + await tester.pumpWidget(harness(CommunityCard( + community: community, isSelected: false, onTap: () {}))); + expect( + find.bySemanticsIdentifier(AutomationIds.communityCard('deadbeef')), + findsOneWidget); + }); + + testWidgets('order id readout', (tester) async { + await tester.pumpWidget(harness(const OrderIdCard(orderId: 'order-123'))); + final data = tester + .getSemantics(find.bySemanticsIdentifier(AutomationIds.orderId)) + .getSemanticsData(); + expect(data.label, 'order-123'); + }); + + testWidgets('environment banner is absent outside the test environment', + (tester) async { + await tester + .pumpWidget(harness(const TestEnvironmentBanner(child: Text('app')))); + expect(find.bySemanticsIdentifier(AutomationIds.envMarker), findsNothing); + expect(find.text('app'), findsOneWidget); + }); + }); +}