From d37da23e16d90b0a870a1f293c100ac151f09ba3 Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 15 Aug 2026 16:48:12 -0300 Subject: [PATCH 01/12] feat: Mortsom automation contract and test-environment entry point Add stable semantic identifiers (AutomationIds + AutomationId widget) to onboarding, navigation, key management, settings, node selection, relays, NWC, order creation, take order, trade actions, invoices and payments; document them in docs/automation-contract.md with contract widget tests. Add the Mortsom test-environment build: lib/main_mortsom.dart arms TestEnvironment (enabled only together with --dart-define MORTSOM_TEST_ENV=true), seeds MORTSOM_RELAYS as user relays on first launch, disables the public bootstrap-relay fallback, accepts ws:// local relays and shows a visible environment banner. Production main.dart never arms it and behaves as before. --- docs/automation-contract.md | 111 ++++ lib/core/app.dart | 16 +- lib/core/app_bootstrap.dart | 210 ++++++++ lib/core/automation/automation_id.dart | 50 ++ lib/core/automation/automation_ids.dart | 118 +++++ lib/core/config.dart | 10 + lib/core/test_environment.dart | 76 +++ .../screens/community_selector_screen.dart | 122 ++--- .../community/widgets/community_card.dart | 304 +++++------ .../home/widgets/order_list_item.dart | 411 +++++++-------- .../key_manager/import_mnemonic_dialog.dart | 193 +++---- .../key_manager/key_management_screen.dart | 283 ++++++----- .../widgets/add_custom_node_dialog.dart | 286 +++++------ .../mostro/widgets/mostro_node_selector.dart | 237 ++++----- .../order/screens/add_order_screen.dart | 98 ++-- .../screens/order_confirmation_screen.dart | 13 +- .../screens/pay_lightning_invoice_screen.dart | 38 +- .../order/screens/take_order_screen.dart | 478 +++++++++--------- .../order/widgets/action_buttons.dart | 55 +- .../order/widgets/amount_section.dart | 62 ++- .../order/widgets/currency_section.dart | 61 ++- .../widgets/payment_methods_section.dart | 77 +-- .../order/widgets/price_type_section.dart | 13 +- lib/features/relays/relays_notifier.dart | 16 +- .../relays/widgets/relay_selector.dart | 436 ++++++++-------- lib/features/settings/settings_screen.dart | 136 ++--- .../trades/screens/trade_detail_screen.dart | 387 +++++++------- .../widgets/mostro_message_detail_widget.dart | 22 +- .../trades/widgets/trades_list_item.dart | 275 +++++----- .../screens/walkthrough_screen.dart | 20 +- .../wallet/screens/connect_wallet_screen.dart | 118 ++--- .../screens/wallet_settings_screen.dart | 56 +- .../wallet/widgets/wallet_status_card.dart | 120 +++-- lib/main.dart | 189 +------ lib/main_mortsom.dart | 26 + lib/services/nostr_service.dart | 6 +- .../widgets/add_lightning_invoice_widget.dart | 103 ++-- lib/shared/widgets/add_order_button.dart | 125 ++--- lib/shared/widgets/bottom_nav_bar.dart | 9 + .../widgets/currency_selection_dialog.dart | 45 +- lib/shared/widgets/custom_drawer_overlay.dart | 42 +- lib/shared/widgets/mostro_app_bar.dart | 55 +- lib/shared/widgets/nwc_payment_widget.dart | 40 +- lib/shared/widgets/order_cards.dart | 25 +- .../widgets/test_environment_banner.dart | 56 ++ .../automation/automation_contract_test.dart | 247 +++++++++ 46 files changed, 3439 insertions(+), 2437 deletions(-) create mode 100644 docs/automation-contract.md create mode 100644 lib/core/app_bootstrap.dart create mode 100644 lib/core/automation/automation_id.dart create mode 100644 lib/core/automation/automation_ids.dart create mode 100644 lib/core/test_environment.dart create mode 100644 lib/main_mortsom.dart create mode 100644 lib/shared/widgets/test_environment_banner.dart create mode 100644 test/core/automation/automation_contract_test.dart diff --git a/docs/automation-contract.md b/docs/automation-contract.md new file mode 100644 index 000000000..f92e5fa41 --- /dev/null +++ b/docs/automation-contract.md @@ -0,0 +1,111 @@ +# 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 +`AutomationId` widget (`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. + +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.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` | account | Generate a new identity. | +| `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. | +| `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.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. | +| `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.discoveryRelays`); a disconnected local relay produces a test + failure, not public-network traffic; +- 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` and the widget, 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..0798dcde3 --- /dev/null +++ b/lib/core/app_bootstrap.dart @@ -0,0 +1,210 @@ +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 + 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; + } +} diff --git a/lib/core/automation/automation_id.dart b/lib/core/automation/automation_id.dart new file mode 100644 index 000000000..28456b5b8 --- /dev/null +++ b/lib/core/automation/automation_id.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +/// Attaches a stable automation identifier to a control. +/// +/// 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; + } +} diff --git a/lib/core/automation/automation_ids.dart b/lib/core/automation/automation_ids.dart new file mode 100644 index 000000000..c16c5dadd --- /dev/null +++ b/lib/core/automation/automation_ids.dart @@ -0,0 +1,118 @@ +/// 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 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 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.$url'; + static const String settingsRelayDelete = 'settings.relays.item.delete'; + + // 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 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 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 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..31198bfa6 100644 --- a/lib/features/community/screens/community_selector_screen.dart +++ b/lib/features/community/screens/community_selector_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -153,70 +155,74 @@ class _CommunitySelectorScreenState ), const SizedBox(height: 12), // Use custom node - TextButton( - onPressed: - _isSelecting ? null : () => _onUseCustomNode(context), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.settings, - size: 18, - color: AppTheme.textSecondary, - ), - const SizedBox(width: 8), - Text( - S.of(context)!.useCustomNode, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 14, - ), + AutomationId(AutomationIds.communityCustomNode, + child: TextButton( + onPressed: _isSelecting + ? null + : () => _onUseCustomNode(context), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.settings, + size: 18, + color: AppTheme.textSecondary, + ), + const SizedBox(width: 8), + Text( + S.of(context)!.useCustomNode, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + ], ), - ], - ), - ), + )), const SizedBox(height: 8), // Confirm button if (_selectedPubkey != null) - ElevatedButton( - onPressed: - _isSelecting ? null : () => _onConfirm(context), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric(vertical: 14), - ), - child: _isSelecting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.black, - ), - ) - : Text( - S.of(context)!.done, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + AutomationId(AutomationIds.communityDone, + child: ElevatedButton( + onPressed: + _isSelecting ? null : () => _onConfirm(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), ), - ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _isSelecting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black, + ), + ) + : Text( + S.of(context)!.done, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + )), // Skip button - TextButton( - onPressed: _isSelecting ? null : () => _onSkip(context), - child: Text( - S.of(context)!.skipForNow, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 14, - ), - ), - ), + AutomationId(AutomationIds.communitySkip, + child: TextButton( + onPressed: _isSelecting ? null : () => _onSkip(context), + child: Text( + S.of(context)!.skipForNow, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + )), 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..826d7b116 100644 --- a/lib/features/community/widgets/community_card.dart +++ b/lib/features/community/widgets/community_card.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; @@ -20,167 +22,169 @@ class CommunityCard extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: isSelected - ? AppTheme.activeColor.withValues(alpha: 0.1) - : AppTheme.backgroundCard, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? AppTheme.activeColor.withValues(alpha: 0.5) - : Colors.white.withValues(alpha: 0.1), - width: isSelected ? 2 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header: avatar + name + region - Row( + return AutomationId(AutomationIds.communityCard(community.pubkey), + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.activeColor.withValues(alpha: 0.1) + : AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppTheme.activeColor.withValues(alpha: 0.5) + : Colors.white.withValues(alpha: 0.1), + width: isSelected ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildAvatar(), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - community.displayName, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), + // Header: avatar + name + region + Row( + children: [ + _buildAvatar(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + community.displayName, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + community.region, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), + ), + ], ), - const SizedBox(height: 2), - Text( - community.region, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - ), + ), + if (isSelected) + const Icon( + Icons.check_circle, + color: AppTheme.activeColor, + size: 24, ), - ], - ), + ], ), - if (isSelected) - const Icon( - Icons.check_circle, - color: AppTheme.activeColor, - size: 24, + + // About text + if (community.about != null && community.about!.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + community.about!, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + height: 1.4, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - ], - ), + ], - // About text - if (community.about != null && community.about!.isNotEmpty) ...[ - const SizedBox(height: 10), - Text( - community.about!, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - height: 1.4, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], + // Currencies + if (community.currencies.isNotEmpty || + (community.hasTradeInfo && + community.currencies.isEmpty)) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 4, + children: community.currencies.isNotEmpty + ? community.currencies.map((currency) { + return _buildCurrencyTag(currency); + }).toList() + : [ + _buildCurrencyTag( + S.of(context)!.communityAllCurrencies, + ), + ], + ), + ], - // Currencies - if (community.currencies.isNotEmpty || - (community.hasTradeInfo && community.currencies.isEmpty)) ...[ - const SizedBox(height: 10), - Wrap( - spacing: 6, - runSpacing: 4, - children: community.currencies.isNotEmpty - ? community.currencies.map((currency) { - return _buildCurrencyTag(currency); - }).toList() - : [ - _buildCurrencyTag( - S.of(context)!.communityAllCurrencies, + // Stats row: fee + range + if (community.fee != null || + community.minAmount != null || + community.maxAmount != null) ...[ + const SizedBox(height: 10), + Row( + children: [ + if (community.fee != null) ...[ + Icon( + Icons.percent, + size: 14, + color: AppTheme.textSecondary.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Text( + '${S.of(context)!.communityFee} ${(community.fee! * 100).toStringAsFixed(1)}%', + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), ), ], - ), - ], - - // Stats row: fee + range - if (community.fee != null || - community.minAmount != null || - community.maxAmount != null) ...[ - const SizedBox(height: 10), - Row( - children: [ - if (community.fee != null) ...[ - Icon( - Icons.percent, - size: 14, - color: AppTheme.textSecondary.withValues(alpha: 0.7), - ), - const SizedBox(width: 4), - Text( - '${S.of(context)!.communityFee} ${(community.fee! * 100).toStringAsFixed(1)}%', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - if (community.fee != null && - (community.minAmount != null || - community.maxAmount != null)) - const SizedBox(width: 16), - if (community.minAmount != null || - community.maxAmount != null) ...[ - Icon( - Icons.bar_chart, - size: 14, - color: AppTheme.textSecondary.withValues(alpha: 0.7), - ), - const SizedBox(width: 4), - Text( - _formatRange(context), - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], + if (community.fee != null && + (community.minAmount != null || + community.maxAmount != null)) + const SizedBox(width: 16), + if (community.minAmount != null || + community.maxAmount != null) ...[ + Icon( + Icons.bar_chart, + size: 14, + color: AppTheme.textSecondary.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Text( + _formatRange(context), + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + ], + ), ], - ), - ], - // Social links - if (community.social.isNotEmpty) ...[ - const SizedBox(height: 10), - Row( - children: community.social.map((link) { - return Padding( - padding: const EdgeInsets.only(right: 12), - child: GestureDetector( - onTap: () => _launchUrl(link.url), - child: Icon( - _socialIcon(link.type), - size: 18, - color: AppTheme.textSecondary, - ), - ), - ); - }).toList(), - ), - ], - ], - ), - ), - ); + // Social links + if (community.social.isNotEmpty) ...[ + const SizedBox(height: 10), + Row( + children: community.social.map((link) { + return Padding( + padding: const EdgeInsets.only(right: 12), + child: GestureDetector( + onTap: () => _launchUrl(link.url), + child: Icon( + _socialIcon(link.type), + size: 18, + color: AppTheme.textSecondary, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + )); } Widget _buildCurrencyTag(String label) { diff --git a/lib/features/home/widgets/order_list_item.dart b/lib/features/home/widgets/order_list_item.dart index 45c12f769..36b2ab665 100644 --- a/lib/features/home/widgets/order_list_item.dart +++ b/lib/features/home/widgets/order_list_item.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:collection/collection.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; @@ -63,241 +65,246 @@ class OrderListItem extends ConsumerWidget { child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(20), - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () { - final sessions = ref.watch(sessionNotifierProvider); - final session = sessions.firstWhereOrNull((s) => s.orderId == order.orderId); - if (session != null && session.role != null) { - context.push('/trade_detail/${session.orderId}'); - return; - } - order.orderType == OrderType.buy - ? context.push('/take_buy/${order.orderId}') - : context.push('/take_sell/${order.orderId}'); - }, - highlightColor: Colors.white.withValues(alpha: 0.05), - splashColor: Colors.white.withValues(alpha: 0.03), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // First row: "SELLING" label and timestamp - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // SELLING/BUYING label with more contrast - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.backgroundCard, - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.6), - blurRadius: 4, - offset: const Offset(0, 2), - spreadRadius: -1, - ), - BoxShadow( - color: Colors.white.withValues(alpha: 0.08), - blurRadius: 1, - offset: const Offset(0, -1), - spreadRadius: 0, - ), - ], - ), - child: Text( - orderLabel, - style: const TextStyle( - color: Colors.white70, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - - // Timestamp - Text( - order.timeAgoWithLocale( - Localizations.localeOf(context).languageCode) ?? - S.of(context)!.hoursAgo('9'), - style: const TextStyle( - color: Colors.white60, - fontSize: 14, - ), - ), - ], - ), - ), - - // Second row: Amount and currency with flag and percentage (if not fixed) - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, + child: AutomationId(AutomationIds.orderBookItem(order.orderId ?? ''), + merge: false, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + final sessions = ref.watch(sessionNotifierProvider); + final session = sessions + .firstWhereOrNull((s) => s.orderId == order.orderId); + if (session != null && session.role != null) { + context.push('/trade_detail/${session.orderId}'); + return; + } + order.orderType == OrderType.buy + ? context.push('/take_buy/${order.orderId}') + : context.push('/take_sell/${order.orderId}'); + }, + highlightColor: Colors.white.withValues(alpha: 0.05), + splashColor: Colors.white.withValues(alpha: 0.03), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // First row: "SELLING" label and timestamp + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Large amount with more contrast - Text( - order.fiatAmount.toString(), - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Colors.white, - height: 1.1, + // SELLING/BUYING label with more contrast + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.6), + blurRadius: 4, + offset: const Offset(0, 2), + spreadRadius: -1, + ), + BoxShadow( + color: Colors.white.withValues(alpha: 0.08), + blurRadius: 1, + offset: const Offset(0, -1), + spreadRadius: 0, + ), + ], + ), + child: Text( + orderLabel, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), ), - const SizedBox(width: 8), - // Currency code and flag + // Timestamp Text( - '${order.currency ?? "CUP"} ', + order.timeAgoWithLocale( + Localizations.localeOf(context) + .languageCode) ?? + S.of(context)!.hoursAgo('9'), style: const TextStyle( - fontSize: 18, - color: Colors.white, + color: Colors.white60, + fontSize: 14, ), ), - Text( - () { - final String currencyCode = order.currency ?? 'CUP'; - return CurrencyUtils.getFlagFromCurrencyData( - currencyCode, currencyData); - }(), - style: const TextStyle(fontSize: 18), - ), - const SizedBox(width: 4), ], ), + ), + + // Second row: Amount and currency with flag and percentage (if not fixed) + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + // Large amount with more contrast + Text( + order.fiatAmount.toString(), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1.1, + ), + ), + const SizedBox(width: 8), - // Display sats amount for all orders (simplified) - Padding( - padding: const EdgeInsets.only(top: 4), - child: isFixedOrder - ? Text( - S.of(context)!.forSats(order.amount!), - style: TextStyle( - fontSize: 14, - color: Colors.white70, - fontWeight: FontWeight.w500, + // Currency code and flag + Text( + '${order.currency ?? "CUP"} ', + style: const TextStyle( + fontSize: 18, + color: Colors.white, ), - ) - : premiumValue == 0 + ), + Text( + () { + final String currencyCode = + order.currency ?? 'CUP'; + return CurrencyUtils.getFlagFromCurrencyData( + currencyCode, currencyData); + }(), + style: const TextStyle(fontSize: 18), + ), + const SizedBox(width: 4), + ], + ), + + // Display sats amount for all orders (simplified) + Padding( + padding: const EdgeInsets.only(top: 4), + child: isFixedOrder ? Text( - S.of(context)!.marketPrice, + S.of(context)!.forSats(order.amount!), style: TextStyle( fontSize: 14, color: Colors.white70, fontWeight: FontWeight.w500, ), ) - : Row( - children: [ - Text( - '${S.of(context)!.marketPrice} ', + : premiumValue == 0 + ? Text( + S.of(context)!.marketPrice, style: TextStyle( fontSize: 14, color: Colors.white70, fontWeight: FontWeight.w500, ), + ) + : Row( + children: [ + Text( + '${S.of(context)!.marketPrice} ', + style: TextStyle( + fontSize: 14, + color: Colors.white70, + fontWeight: FontWeight.w500, + ), + ), + Text( + premiumText, + style: TextStyle( + fontSize: 14, + color: premiumColor, + fontWeight: FontWeight.w500, + ), + ), + ], ), - Text( - premiumText, - style: TextStyle( - fontSize: 14, - color: premiumColor, - fontWeight: FontWeight.w500, - ), - ), - ], - ), + ), + ], ), - ], - ), - ), + ), - // Third row: Payment method - Container( - margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), - width: double.infinity, - padding: - const EdgeInsets.symmetric(vertical: 12, horizontal: 14), - decoration: BoxDecoration( - color: AppTheme.backgroundCard, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.7), - blurRadius: 6, - offset: const Offset(0, 3), - spreadRadius: -2, - ), - BoxShadow( - color: Colors.white.withValues(alpha: 0.08), - blurRadius: 1, - offset: const Offset(0, -1), - spreadRadius: 0, - ), - ], - ), - child: Row( - children: [ - // Emoji for payment method - const Text( - '💳 ', // Default emoji - style: TextStyle(fontSize: 16), + // Third row: Payment method + Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 12, horizontal: 14), + decoration: BoxDecoration( + color: AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.7), + blurRadius: 6, + offset: const Offset(0, 3), + spreadRadius: -2, + ), + BoxShadow( + color: Colors.white.withValues(alpha: 0.08), + blurRadius: 1, + offset: const Offset(0, -1), + spreadRadius: 0, + ), + ], ), - Expanded( - child: Text( - order.paymentMethods.isNotEmpty - ? order.paymentMethods.join(', ') - : 'tm', - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, + child: Row( + children: [ + // Emoji for payment method + const Text( + '💳 ', // Default emoji + style: TextStyle(fontSize: 16), ), - overflow: TextOverflow.ellipsis, - ), + Expanded( + child: Text( + order.paymentMethods.isNotEmpty + ? order.paymentMethods.join(', ') + : 'tm', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - ], - ), - ), + ), - // Fourth row: Rating with stars - Container( - margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), - padding: - const EdgeInsets.symmetric(vertical: 12, horizontal: 14), - decoration: BoxDecoration( - color: AppTheme.backgroundCard, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.7), - blurRadius: 6, - offset: const Offset(0, 3), - spreadRadius: -2, - ), - BoxShadow( - color: Colors.white.withValues(alpha: 0.08), - blurRadius: 1, - offset: const Offset(0, -1), - spreadRadius: 0, + // Fourth row: Rating with stars + Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + padding: const EdgeInsets.symmetric( + vertical: 12, horizontal: 14), + decoration: BoxDecoration( + color: AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.7), + blurRadius: 6, + offset: const Offset(0, 3), + spreadRadius: -2, + ), + BoxShadow( + color: Colors.white.withValues(alpha: 0.08), + blurRadius: 1, + offset: const Offset(0, -1), + spreadRadius: 0, + ), + ], ), - ], - ), - child: _buildRatingRow(context, order), + child: _buildRatingRow(context, order), + ), + ], ), - ], - ), - ), + )), ), ); } diff --git a/lib/features/key_manager/import_mnemonic_dialog.dart b/lib/features/key_manager/import_mnemonic_dialog.dart index bb42f0d12..1a54a3bd4 100644 --- a/lib/features/key_manager/import_mnemonic_dialog.dart +++ b/lib/features/key_manager/import_mnemonic_dialog.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -88,106 +90,109 @@ class _ImportMnemonicDialogState extends State { ), ), const SizedBox(height: 8), - TextField( - controller: _mnemonicController, - minLines: 4, - maxLines: 6, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 14, - ), - decoration: InputDecoration( - hintText: S.of(context)!.secretWordsPlaceholder, - hintStyle: TextStyle( - color: AppTheme.textSecondary.withValues(alpha: 0.5), - fontSize: 14, - ), - filled: true, - fillColor: AppTheme.backgroundInput, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Colors.white.withValues(alpha: 0.1), - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Colors.white.withValues(alpha: 0.1), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide( - color: AppTheme.activeColor, - width: 2, - ), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide( - color: Colors.red, - width: 2, - ), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide( - color: Colors.red, - width: 2, + AutomationId(AutomationIds.keysImportMnemonic, + child: TextField( + controller: _mnemonicController, + minLines: 4, + maxLines: 6, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, ), - ), - errorText: _errorMessage, - errorStyle: const TextStyle( - color: Colors.red, - fontSize: 12, - ), - ), - onChanged: (_) { - if (_errorMessage != null) { - setState(() { - _errorMessage = null; - }); - } - }, - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - S.of(context)!.cancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, + decoration: InputDecoration( + hintText: S.of(context)!.secretWordsPlaceholder, + hintStyle: TextStyle( + color: AppTheme.textSecondary.withValues(alpha: 0.5), + fontSize: 14, ), - ), - ), - const SizedBox(width: 12), - ElevatedButton( - onPressed: _handleImport, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( + filled: true, + fillColor: AppTheme.backgroundInput, + border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + ), ), - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + ), ), - ), - child: Text( - S.of(context)!.importMostroUser, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: AppTheme.activeColor, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Colors.red, + width: 2, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Colors.red, + width: 2, + ), + ), + errorText: _errorMessage, + errorStyle: const TextStyle( + color: Colors.red, + fontSize: 12, ), ), - ), + onChanged: (_) { + if (_errorMessage != null) { + setState(() { + _errorMessage = null; + }); + } + }, + )), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AutomationId(AutomationIds.keysImportCancel, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + )), + const SizedBox(width: 12), + AutomationId(AutomationIds.keysImportConfirm, + child: ElevatedButton( + onPressed: _handleImport, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.importMostroUser, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + )), ], ), ], @@ -224,4 +229,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..9bffe5564 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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(); @@ -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,41 @@ 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: AutomationId( + AutomationIds.keysPublicKey, + child: SelectableText( + npub, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + ), + ], + ), + ), + ); + } + Widget _buildSecretWordsCard(BuildContext context) { return Container( decoration: BoxDecoration( @@ -269,50 +318,54 @@ class _KeyManagementScreenState extends ConsumerState { ), child: Column( children: [ - SelectableText( - _showSecretWords - ? _mnemonic ?? '' - : _mnemonic != null - ? _maskSeedPhrase(_mnemonic!) - : '', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 14, - fontFamily: 'monospace', - ), - ), + AutomationId(AutomationIds.keysSeedText, + child: SelectableText( + _showSecretWords + ? _mnemonic ?? '' + : _mnemonic != null + ? _maskSeedPhrase(_mnemonic!) + : '', + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, + fontFamily: 'monospace', + ), + )), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton.icon( - onPressed: () { - setState(() { - _showSecretWords = !_showSecretWords; - }); - // Dismiss backup reminder when user views seed phrase - if (_showSecretWords) { - ref.read(backupReminderProvider.notifier).dismissBackupReminder(); - } - }, - icon: Icon( - _showSecretWords - ? LucideIcons.eyeOff - : LucideIcons.eye, - size: 16, - color: AppTheme.activeColor, - ), - label: Text( - _showSecretWords - ? S.of(context)!.hide - : S.of(context)!.show, - style: const TextStyle( - color: AppTheme.activeColor, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ), + AutomationId(AutomationIds.keysSeedReveal, + child: TextButton.icon( + onPressed: () { + setState(() { + _showSecretWords = !_showSecretWords; + }); + // Dismiss backup reminder when user views seed phrase + if (_showSecretWords) { + ref + .read(backupReminderProvider.notifier) + .dismissBackupReminder(); + } + }, + icon: Icon( + _showSecretWords + ? LucideIcons.eyeOff + : LucideIcons.eye, + size: 16, + color: AppTheme.activeColor, + ), + label: Text( + _showSecretWords + ? S.of(context)!.hide + : S.of(context)!.show, + style: const TextStyle( + color: AppTheme.activeColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + )), ], ), ], @@ -385,7 +438,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 +447,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), ), ], ), @@ -497,75 +552,77 @@ class _KeyManagementScreenState extends ConsumerState { Widget _buildGenerateNewUserButton(BuildContext context) { return SizedBox( width: double.infinity, - child: ElevatedButton( - onPressed: () => _showGenerateNewUserDialog(context), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - LucideIcons.userPlus, - size: 20, + child: AutomationId(AutomationIds.keysGenerate, + child: ElevatedButton( + onPressed: () => _showGenerateNewUserDialog(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), ), - const SizedBox(width: 8), - Flexible( - child: Text( - S.of(context)!.generateNewUser, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.userPlus, + size: 20, ), - overflow: TextOverflow.visible, - softWrap: true, - ), + const SizedBox(width: 8), + Flexible( + child: Text( + S.of(context)!.generateNewUser, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.visible, + softWrap: true, + ), + ), + ], ), - ], - ), - ), + )), ); } Widget _buildImportUserButton(BuildContext context) { - return OutlinedButton( - onPressed: () => _showImportMnemonicDialog(context), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: AppTheme.activeColor), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - LucideIcons.download, - size: 20, - color: AppTheme.activeColor, + return AutomationId(AutomationIds.keysImport, + child: OutlinedButton( + onPressed: () => _showImportMnemonicDialog(context), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: AppTheme.activeColor), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 16), ), - const SizedBox(width: 8), - Flexible( - child: Text( - S.of(context)!.importMostroUser, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + LucideIcons.download, + size: 20, color: AppTheme.activeColor, ), - overflow: TextOverflow.ellipsis, - ), + const SizedBox(width: 8), + Flexible( + child: Text( + S.of(context)!.importMostroUser, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppTheme.activeColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - ], - ), - ); + )); } Widget _buildRefreshUserButton(BuildContext context) { @@ -587,14 +644,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 +695,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, ), @@ -829,7 +886,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 +904,6 @@ class _KeyManagementScreenState extends ConsumerState { } Future _showImportMnemonicDialog(BuildContext context) async { - final mnemonic = await showDialog( context: context, builder: (BuildContext dialogContext) { @@ -859,4 +916,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..726e04473 100644 --- a/lib/features/mostro/widgets/add_custom_node_dialog.dart +++ b/lib/features/mostro/widgets/add_custom_node_dialog.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'dart:async'; import 'package:flutter/material.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, @@ -112,29 +112,26 @@ class _AddCustomNodeDialogContentState color: Colors.white.withValues(alpha: 0.1), ), ), - child: TextField( - controller: pubkeyController, - style: - const TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - 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), - errorText: errorMessage, - errorStyle: - const TextStyle(color: Colors.red), - ), - autofocus: true, - ), + child: AutomationId(AutomationIds.nodeCustomPubkey, + child: TextField( + controller: pubkeyController, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: InputDecoration( + 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), + errorText: errorMessage, + errorStyle: const TextStyle(color: Colors.red), + ), + autofocus: true, + )), ), const SizedBox(height: 12), // Name input @@ -150,141 +147,136 @@ class _AddCustomNodeDialogContentState color: Colors.white.withValues(alpha: 0.1), ), ), - child: TextField( - controller: nameController, - style: - const TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - 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), - ), - ), + child: AutomationId(AutomationIds.nodeCustomName, + child: TextField( + controller: nameController, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: InputDecoration( + 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), + ), + )), ), ], ), ), actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - S.of(context)!.cancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), + AutomationId(AutomationIds.nodeCustomCancel, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + )), const SizedBox(width: 12), - ElevatedButton( - onPressed: () async { - final input = pubkeyController.text.trim(); - final name = nameController.text.trim(); - final localizations = S.of(context)!; - final navigator = Navigator.of(context); + AutomationId(AutomationIds.nodeCustomConfirm, + child: ElevatedButton( + onPressed: () async { + final input = pubkeyController.text.trim(); + final name = nameController.text.trim(); + final localizations = S.of(context)!; + final navigator = Navigator.of(context); - if (input.isEmpty) { - setState(() { - errorMessage = localizations.pubkeyRequired; - }); - return; - } + if (input.isEmpty) { + setState(() { + errorMessage = localizations.pubkeyRequired; + }); + return; + } - // Reject nsec private keys - if (input.startsWith('nsec')) { - setState(() { - errorMessage = - localizations.invalidPubkeyFormat; - }); - return; - } + // Reject nsec private keys + if (input.startsWith('nsec')) { + setState(() { + errorMessage = localizations.invalidPubkeyFormat; + }); + return; + } - // Convert npub to hex if needed, then normalize - String hexPubkey; - try { - hexPubkey = AddCustomNodeDialog._convertToHex(input).toLowerCase(); - } catch (_) { - setState(() { - errorMessage = - localizations.invalidPubkeyFormat; - }); - return; - } + // Convert npub to hex if needed, then normalize + String hexPubkey; + try { + hexPubkey = + AddCustomNodeDialog._convertToHex(input).toLowerCase(); + } catch (_) { + setState(() { + errorMessage = localizations.invalidPubkeyFormat; + }); + return; + } - // Validate hex format - if (!MostroNode.isValidHexPubkey(hexPubkey)) { - setState(() { - errorMessage = - localizations.invalidPubkeyFormat; - }); - return; - } + // Validate hex format + if (!MostroNode.isValidHexPubkey(hexPubkey)) { + setState(() { + errorMessage = localizations.invalidPubkeyFormat; + }); + return; + } - // Check duplicates - final nodes = widget.ref.read(mostroNodesProvider); - if (nodes.any((n) => n.pubkey == hexPubkey)) { - setState(() { - errorMessage = - localizations.nodeAlreadyExists; - }); - return; - } + // Check duplicates + final nodes = widget.ref.read(mostroNodesProvider); + if (nodes.any((n) => n.pubkey == hexPubkey)) { + setState(() { + errorMessage = localizations.nodeAlreadyExists; + }); + return; + } - final notifier = - widget.ref.read(mostroNodesProvider.notifier); - final added = await notifier.addCustomNode( - hexPubkey, - name: name.isEmpty ? null : name, - ); + final notifier = widget.ref.read(mostroNodesProvider.notifier); + final added = await notifier.addCustomNode( + hexPubkey, + name: name.isEmpty ? null : name, + ); - if (added) { - // Fire-and-forget: errors are logged inside fetchNodeMetadata - unawaited(notifier.fetchNodeMetadata(hexPubkey)); + if (added) { + // Fire-and-forget: errors are logged inside fetchNodeMetadata + unawaited(notifier.fetchNodeMetadata(hexPubkey)); - navigator.pop(); - SnackBarHelper.showTopSnackBarAsync( - messenger: widget.parentMessenger, - screenHeight: widget.screenHeight, - statusBarHeight: widget.statusBarPadding, - message: localizations.nodeAddedSuccess, - backgroundColor: Colors.green, - ); - } else { - setState(() { - errorMessage = - localizations.nodeAlreadyExists; - }); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - ), - child: Text( - S.of(context)!.addCustomNode, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), + navigator.pop(); + SnackBarHelper.showTopSnackBarAsync( + messenger: widget.parentMessenger, + screenHeight: widget.screenHeight, + statusBarHeight: widget.statusBarPadding, + message: localizations.nodeAddedSuccess, + backgroundColor: Colors.green, + ); + } else { + setState(() { + errorMessage = localizations.nodeAlreadyExists; + }); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.addCustomNode, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + )), ], ); } diff --git a/lib/features/mostro/widgets/mostro_node_selector.dart b/lib/features/mostro/widgets/mostro_node_selector.dart index 117881564..5173d2a6d 100644 --- a/lib/features/mostro/widgets/mostro_node_selector.dart +++ b/lib/features/mostro/widgets/mostro_node_selector.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; @@ -123,29 +125,30 @@ class _MostroNodeSelectorState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - ElevatedButton( - onPressed: _isSwitching - ? null - : () => AddCustomNodeDialog.show(context, ref), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - child: Text( - S.of(context)!.addCustomNode, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - ), + AutomationId(AutomationIds.nodeAddCustom, + child: ElevatedButton( + onPressed: _isSwitching + ? null + : () => AddCustomNodeDialog.show(context, ref), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.addCustomNode, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + )), ], ), SizedBox( @@ -184,111 +187,115 @@ class _MostroNodeSelectorState extends ConsumerState { margin: const EdgeInsets.only(bottom: 8), child: Material( color: Colors.transparent, - child: InkWell( - onTap: (_isSwitching || isSelected) ? null : () => _onNodeTap(node), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: isSelected - ? AppTheme.activeColor.withValues(alpha: 0.1) - : AppTheme.backgroundCard, + child: AutomationId(AutomationIds.nodeItem(node.pubkey), + merge: false, + child: InkWell( + onTap: + (_isSwitching || isSelected) ? null : () => _onNodeTap(node), borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? AppTheme.activeColor.withValues(alpha: 0.4) - : Colors.white.withValues(alpha: 0.1), - ), - ), - child: Row( - children: [ - // Avatar - MostroNodeAvatar(node: node), - const SizedBox(width: 12), - // Name + pubkey - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.activeColor.withValues(alpha: 0.1) + : AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppTheme.activeColor.withValues(alpha: 0.4) + : Colors.white.withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + // Avatar + MostroNodeAvatar(node: node), + const SizedBox(width: 12), + // Name + pubkey + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible( - child: Text( - node.displayName, + Row( + children: [ + Flexible( + child: Text( + node.displayName, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (node.isTrusted) ...[ + const SizedBox(width: 8), + const TrustedBadge(), + ], + ], + ), + const SizedBox(height: 2), + Text( + node.pubkey, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (node.about != null && node.about!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + node.about!.length > 128 + ? '${node.about!.substring(0, 128)}...' + : node.about!, style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + fontSize: 12, ), + maxLines: 3, overflow: TextOverflow.ellipsis, ), - ), - if (node.isTrusted) ...[ - const SizedBox(width: 8), - const TrustedBadge(), ], ], ), - const SizedBox(height: 2), - Text( - node.pubkey, - style: const TextStyle( + ), + // Trailing: checkmark, loading, or delete + if (isLoading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(AppTheme.cream1), + ), + ) + else if (isSelected) + const Icon( + Icons.check_circle, + color: AppTheme.activeColor, + size: 22, + ) + else if (isCustom) + IconButton( + onPressed: + _isSwitching ? null : () => _onDeleteNode(node), + tooltip: S.of(context)!.deleteCustomNodeTitle, + icon: const Icon( + Icons.delete_outline, color: AppTheme.textSecondary, - fontSize: 12, + size: 20, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(4), ), - if (node.about != null && node.about!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - node.about!.length > 128 - ? '${node.about!.substring(0, 128)}...' - : node.about!, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), + ], ), - // Trailing: checkmark, loading, or delete - if (isLoading) - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(AppTheme.cream1), - ), - ) - else if (isSelected) - const Icon( - Icons.check_circle, - color: AppTheme.activeColor, - size: 22, - ) - else if (isCustom) - IconButton( - onPressed: _isSwitching ? null : () => _onDeleteNode(node), - tooltip: S.of(context)!.deleteCustomNodeTitle, - icon: const Icon( - Icons.delete_outline, - color: AppTheme.textSecondary, - size: 20, - ), - constraints: const BoxConstraints(), - padding: const EdgeInsets.all(4), - ), - ], - ), - ), - ), + ), + )), ), ); } diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 0a3295dec..63de07f80 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'dart:async'; import 'package:flutter/material.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(), + ); } } @@ -373,31 +376,38 @@ class _AddOrderScreenState extends ConsumerState { color: AppTheme.yellow, size: 18), iconBackgroundColor: AppTheme.yellow.withValues(alpha: 0.3), - child: TextFormField( - controller: _satsAmountController, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - border: InputBorder.none, - hintText: S.of(context)!.enterSatsAmount, - hintStyle: const TextStyle(color: Colors.grey), - ), - keyboardType: TextInputType.number, - validator: (value) { - if (!_marketRate && - (value == null || value.isEmpty)) { - return S.of(context)!.pleaseEnterSatsAmount; - } - if (!_marketRate && - !RegExp(r'^[0-9]+$').hasMatch(value!)) { - return S.of(context)!.pleaseEnterNumbersOnly; - } - return null; - }, - // Restricting input to numbers only - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - ), + child: AutomationId( + AutomationIds.orderCreateSatsAmount, + child: TextFormField( + controller: _satsAmountController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + border: InputBorder.none, + hintText: S.of(context)!.enterSatsAmount, + hintStyle: + const TextStyle(color: Colors.grey), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (!_marketRate && + (value == null || value.isEmpty)) { + return S + .of(context)! + .pleaseEnterSatsAmount; + } + if (!_marketRate && + !RegExp(r'^[0-9]+$').hasMatch(value!)) { + return S + .of(context)! + .pleaseEnterNumbersOnly; + } + return null; + }, + // Restricting input to numbers only + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + )), ), ], 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..7edb17db8 100644 --- a/lib/features/order/screens/order_confirmation_screen.dart +++ b/lib/features/order/screens/order_confirmation_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -32,11 +34,12 @@ class OrderConfirmationScreen extends ConsumerWidget { textAlign: TextAlign.center, ), const SizedBox(height: 16), - ElevatedButton( - key: const Key('homeButton'), - onPressed: () => context.go('/'), - child: Text(S.of(context)!.backToHome), - ), + AutomationId(AutomationIds.orderConfirmHome, + child: ElevatedButton( + key: const Key('homeButton'), + onPressed: () => context.go('/'), + child: Text(S.of(context)!.backToHome), + )), ], ), ), diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index cfdbd00da..d22c59af4 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.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,11 +58,11 @@ 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, ), @@ -80,17 +83,18 @@ class _PayLightningInvoiceScreenState Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( - onPressed: () async { - context.go('/'); - await orderNotifier.cancelOrder(); - }, - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.red, - ), - child: Text(S.of(context)!.cancel), - ), + AutomationId(AutomationIds.payCancel, + child: ElevatedButton( + onPressed: () async { + context.go('/'); + await orderNotifier.cancelOrder(); + }, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red, + ), + child: Text(S.of(context)!.cancel), + )), ], ), ] else ...[ diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 2fa5d0e49..ebe0978f4 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 ?? '', ), @@ -258,244 +256,264 @@ class _TakeOrderScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( - child: OutlinedButton( - onPressed: () => context.pop(), - style: AppTheme.theme.outlinedButtonTheme.style, - child: Text(S.of(context)!.close), - ), + child: AutomationId(AutomationIds.orderTakeClose, + child: OutlinedButton( + onPressed: () => context.pop(), + style: AppTheme.theme.outlinedButtonTheme.style, + child: Text(S.of(context)!.close), + )), ), const SizedBox(width: 16), Expanded( - child: ElevatedButton( - onPressed: _isSubmitting - ? null - : () async { - setState(() { - _isSubmitting = true; - }); - // Check if this is a range order - if (order.fiatAmount.maximum != null && - order.fiatAmount.minimum != order.fiatAmount.maximum) { - // Show dialog to get the amount - String? errorText; - final enteredAmount = await showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - return AlertDialog( - backgroundColor: AppTheme.backgroundCard, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: Colors.white.withValues(alpha: 0.1), - ), - ), - title: Text( - S.of(context)!.enterAmount, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - content: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: AppTheme.backgroundInput, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.white.withValues( - alpha: 0.1, - ), - ), - ), - child: TextField( - controller: widget._fiatAmountController, - keyboardType: TextInputType.number, - style: const TextStyle( - color: AppTheme.textPrimary, - ), - decoration: InputDecoration( - hintText: S - .of(context)! - .enterAmountBetween( - order.fiatAmount.minimum.toString(), - order.fiatAmount.maximum.toString(), - order.currency ?? '', - ), - hintStyle: const TextStyle( - color: AppTheme.textSecondary, - ), - errorText: errorText, - errorStyle: const TextStyle( - color: AppTheme.statusError, + child: AutomationId(AutomationIds.orderTakeConfirm, + child: ElevatedButton( + onPressed: _isSubmitting + ? null + : () async { + setState(() { + _isSubmitting = true; + }); + // Check if this is a range order + if (order.fiatAmount.maximum != null && + order.fiatAmount.minimum != + order.fiatAmount.maximum) { + // Show dialog to get the amount + String? errorText; + final enteredAmount = await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + backgroundColor: AppTheme.backgroundCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: + Colors.white.withValues(alpha: 0.1), ), - border: InputBorder.none, - contentPadding: - const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), ), - ), - ), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text( - S.of(context)!.cancel, + title: Text( + S.of(context)!.enterAmount, style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, ), - textAlign: TextAlign.center, ), - ), - const SizedBox(width: 12), - ElevatedButton( - key: const Key('submitAmountButton'), - onPressed: () { - final inputAmount = int.tryParse( - widget._fiatAmountController.text - .trim(), - ); - if (inputAmount == null) { - setState(() { - errorText = S - .of(context)! - .pleaseEnterValidNumber; - }); - } else if (inputAmount < - order.fiatAmount.minimum || - (order.fiatAmount.maximum != null && - inputAmount > - order.fiatAmount.maximum!)) { - setState(() { - errorText = S - .of(context)! - .amountMustBeBetween( - order.fiatAmount.minimum - .toString(), - order.fiatAmount.maximum - .toString(), - ); - }); - } else { - context.pop(inputAmount); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + content: Container( padding: const EdgeInsets.symmetric( horizontal: 12, - vertical: 12, + vertical: 8, ), - ), - child: Text( - S.of(context)!.submit, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.white.withValues( + alpha: 0.1, + ), + ), ), + child: AutomationId( + AutomationIds.orderTakeAmount, + child: TextField( + controller: + widget._fiatAmountController, + keyboardType: TextInputType.number, + style: const TextStyle( + color: AppTheme.textPrimary, + ), + decoration: InputDecoration( + hintText: S + .of(context)! + .enterAmountBetween( + order.fiatAmount.minimum + .toString(), + order.fiatAmount.maximum + .toString(), + order.currency ?? '', + ), + hintStyle: const TextStyle( + color: AppTheme.textSecondary, + ), + errorText: errorText, + errorStyle: const TextStyle( + color: AppTheme.statusError, + ), + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + )), ), - ), - ], + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + AutomationId( + AutomationIds.orderTakeAmountConfirm, + child: ElevatedButton( + key: + const Key('submitAmountButton'), + onPressed: () { + final inputAmount = int.tryParse( + widget + ._fiatAmountController.text + .trim(), + ); + if (inputAmount == null) { + setState(() { + errorText = S + .of(context)! + .pleaseEnterValidNumber; + }); + } else if (inputAmount < + order + .fiatAmount.minimum || + (order.fiatAmount.maximum != + null && + inputAmount > + order.fiatAmount + .maximum!)) { + setState(() { + errorText = S + .of(context)! + .amountMustBeBetween( + order.fiatAmount.minimum + .toString(), + order.fiatAmount.maximum + .toString(), + ); + }); + } else { + context.pop(inputAmount); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.submit, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + )), + ], + ); + }, ); }, ); - }, - ); - - if (enteredAmount != null) { - try { - if (widget.orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder( - order.orderId!, - enteredAmount, - ); + + if (enteredAmount != null) { + try { + if (widget.orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, + enteredAmount, + ); + } else { + final lndAddress = + widget._lndAddressController.text.trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + enteredAmount, + lndAddress.isEmpty ? null : lndAddress, + ); + } + } catch (e, stackTrace) { + logger.e( + 'Failed to take order', + error: e, + stackTrace: stackTrace, + ); + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } } else { - final lndAddress = widget._lndAddressController.text - .trim(); - await orderDetailsNotifier.takeSellOrder( - order.orderId!, - enteredAmount, - lndAddress.isEmpty ? null : lndAddress, - ); + // Dialog was dismissed without entering amount, reset loading state + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); } - } catch (e, stackTrace) { - logger.e( - 'Failed to take order', - error: e, - stackTrace: stackTrace, - ); - if (!mounted) return; - setState(() { - _isSubmitting = false; - }); - } - } else { - // Dialog was dismissed without entering amount, reset loading state - if (!mounted) return; - setState(() { - _isSubmitting = false; - }); - } - } else { - // Not a range order – use the existing logic. - final fiatAmount = int.tryParse( - widget._fiatAmountController.text.trim(), - ); - try { - if (widget.orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder( - order.orderId!, - fiatAmount, - ); } else { - final lndAddress = widget._lndAddressController.text - .trim(); - await orderDetailsNotifier.takeSellOrder( - order.orderId!, - fiatAmount, - lndAddress.isEmpty ? null : lndAddress, + // Not a range order – use the existing logic. + final fiatAmount = int.tryParse( + widget._fiatAmountController.text.trim(), ); + try { + if (widget.orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, + fiatAmount, + ); + } else { + final lndAddress = + widget._lndAddressController.text.trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + fiatAmount, + lndAddress.isEmpty ? null : lndAddress, + ); + } + } catch (e, stackTrace) { + logger.e( + 'Failed to take order', + error: e, + stackTrace: stackTrace, + ); + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } } - } catch (e, stackTrace) { - logger.e( - 'Failed to take order', - error: e, - stackTrace: stackTrace, - ); - if (!mounted) return; - setState(() { - _isSubmitting = false; - }); - } - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: _isSubmitting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : Text(buttonText), - ), + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text(buttonText), + )), ), ], ); diff --git a/lib/features/order/widgets/action_buttons.dart b/lib/features/order/widgets/action_buttons.dart index 8ffab55aa..d83356a9c 100644 --- a/lib/features/order/widgets/action_buttons.dart +++ b/lib/features/order/widgets/action_buttons.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as nostr_action; @@ -24,37 +26,40 @@ class ActionButtons extends StatelessWidget { Expanded( child: SizedBox( height: 48, - child: ElevatedButton( - onPressed: onCancel, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.backgroundCard, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 0), - ), - child: Text(S.of(context)!.cancel), - ), + child: AutomationId(AutomationIds.orderCreateCancel, + child: ElevatedButton( + onPressed: onCancel, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.backgroundCard, + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric(vertical: 12, horizontal: 0), + ), + child: Text(S.of(context)!.cancel), + )), ), ), const SizedBox(width: 12), Expanded( child: SizedBox( height: 48, - child: MostroReactiveButton( - key: const Key('submitOrderButton'), - label: S.of(context)!.submit, - buttonStyle: ButtonStyleType.raised, - orderId: currentRequestId?.toString() ?? '', - action: nostr_action.Action.newOrder, - onPressed: onSubmit, - timeout: const Duration(seconds: 10), - showSuccessIndicator: - onSubmit != null, // Only show success indicator when enabled - backgroundColor: onSubmit != null - ? AppTheme.purpleButton - : AppTheme.backgroundInactive, - foregroundColor: - onSubmit != null ? Colors.white : AppTheme.textInactive, - ), + child: AutomationId(AutomationIds.orderCreateSubmit, + child: MostroReactiveButton( + key: const Key('submitOrderButton'), + label: S.of(context)!.submit, + buttonStyle: ButtonStyleType.raised, + orderId: currentRequestId?.toString() ?? '', + action: nostr_action.Action.newOrder, + onPressed: onSubmit, + timeout: const Duration(seconds: 10), + showSuccessIndicator: onSubmit != + null, // Only show success indicator when enabled + backgroundColor: onSubmit != null + ? AppTheme.purpleButton + : AppTheme.backgroundInactive, + foregroundColor: + onSubmit != null ? Colors.white : AppTheme.textInactive, + )), ), ), ], diff --git a/lib/features/order/widgets/amount_section.dart b/lib/features/order/widgets/amount_section.dart index 7ed13aded..1fd6248ce 100644 --- a/lib/features/order/widgets/amount_section.dart +++ b/lib/features/order/widgets/amount_section.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mostro_mobile/core/app_theme.dart'; @@ -210,20 +212,21 @@ class _AmountSectionState extends State { // Min amount input Expanded( flex: _showSecondInput ? 2 : 1, - child: TextFormField( - key: const Key('minAmountField'), - controller: _minAmountController, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - border: InputBorder.none, - hintText: S.of(context)!.enterAmountHint, - hintStyle: const TextStyle(color: Colors.grey), - ), - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - validator: _validateMinAmount, - onChanged: (_) => _notifyAmountChanged(), - ), + child: AutomationId(AutomationIds.orderCreateFiatAmount, + child: TextFormField( + key: const Key('minAmountField'), + controller: _minAmountController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + border: InputBorder.none, + hintText: S.of(context)!.enterAmountHint, + hintStyle: const TextStyle(color: Colors.grey), + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + validator: _validateMinAmount, + onChanged: (_) => _notifyAmountChanged(), + )), ), // "to" label and max amount input (shown after first digit) @@ -237,20 +240,23 @@ class _AmountSectionState extends State { ), Expanded( flex: 2, - child: TextFormField( - key: const Key('maxAmountField'), - controller: _maxAmountController, - focusNode: _maxAmountFocusNode, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - border: InputBorder.none, - hintText: S.of(context)!.maxAmount, - hintStyle: const TextStyle(color: Colors.grey), - ), - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - validator: _validateMaxAmount, - ), + child: AutomationId(AutomationIds.orderCreateFiatAmountMax, + child: TextFormField( + key: const Key('maxAmountField'), + controller: _maxAmountController, + focusNode: _maxAmountFocusNode, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + border: InputBorder.none, + hintText: S.of(context)!.maxAmount, + hintStyle: const TextStyle(color: Colors.grey), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + validator: _validateMaxAmount, + )), ), ], ], diff --git a/lib/features/order/widgets/currency_section.dart b/lib/features/order/widgets/currency_section.dart index 940ad0cd8..4dd111629 100644 --- a/lib/features/order/widgets/currency_section.dart +++ b/lib/features/order/widgets/currency_section.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; @@ -45,40 +47,43 @@ class CurrencySection extends ConsumerWidget { } } - return InkWell( - key: const Key('fiatCodeDropdown'), - onTap: () async { - final selectedCode = await CurrencySelectionDialog.show( - context, - ref, - currentSelection: selectedFiatCode, - ); - if (selectedCode != null) { - ref.read(selectedFiatCodeProvider.notifier).state = selectedCode; - onCurrencySelected(); - } - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - key: Key('currency_${selectedFiatCode ?? 'none'}'), + return AutomationId(AutomationIds.orderCreateCurrency, + child: InkWell( + key: const Key('fiatCodeDropdown'), + onTap: () async { + final selectedCode = await CurrencySelectionDialog.show( + context, + ref, + currentSelection: selectedFiatCode, + ); + if (selectedCode != null) { + ref.read(selectedFiatCodeProvider.notifier).state = + selectedCode; + onCurrencySelected(); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(flag, style: const TextStyle(fontSize: 18)), - const SizedBox(width: 8), - Text( - displayCode.isNotEmpty ? '$displayCode - $name' : name, - style: const TextStyle(color: Colors.white), + Row( + key: Key('currency_${selectedFiatCode ?? 'none'}'), + children: [ + Text(flag, style: const TextStyle(fontSize: 18)), + const SizedBox(width: 8), + Text( + displayCode.isNotEmpty + ? '$displayCode - $name' + : name, + style: const TextStyle(color: Colors.white), + ), + ], ), + const Icon(Icons.keyboard_arrow_down, color: Colors.white), ], ), - const Icon(Icons.keyboard_arrow_down, color: Colors.white), - ], - ), - ); + )); }, ), ); } - } diff --git a/lib/features/order/widgets/payment_methods_section.dart b/lib/features/order/widgets/payment_methods_section.dart index 09d36a580..371eb2fa3 100644 --- a/lib/features/order/widgets/payment_methods_section.dart +++ b/lib/features/order/widgets/payment_methods_section.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/features/order/providers/payment_methods_provider.dart'; @@ -40,21 +42,23 @@ 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: AutomationId(AutomationIds.orderCreatePaymentMethod, + 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), + ), + )), + ), child: paymentMethodsData.when( loading: () => Text(S.of(context)!.loadingPaymentMethods, style: const TextStyle(color: Colors.white)), @@ -72,7 +76,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 +122,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 +148,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 +190,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..1a5a1ddae 100644 --- a/lib/features/order/widgets/price_type_section.dart +++ b/lib/features/order/widgets/price_type_section.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/order/widgets/form_section.dart'; @@ -55,11 +57,12 @@ class PriceTypeSection extends StatelessWidget { fontSize: 14, ), ), - MostroSwitch( - key: const Key('fixedSwitch'), - value: isMarketRate, - onChanged: onToggle, - ), + AutomationId(AutomationIds.orderCreatePriceType, + child: MostroSwitch( + key: const Key('fixedSwitch'), + value: isMarketRate, + onChanged: onToggle, + )), ], ), ], 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..c0de2a232 100644 --- a/lib/features/relays/widgets/relay_selector.dart +++ b/lib/features/relays/widgets/relay_selector.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; @@ -27,7 +29,7 @@ class RelaySelector extends ConsumerWidget { ), ), const SizedBox(height: 24), - + // Relay list if (mostroRelays.isEmpty) Container( @@ -60,110 +62,118 @@ 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, children: [ - ElevatedButton( - onPressed: () async { - await showAddDialog(context, ref); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - child: Text( - S.of(context)!.addRelay, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - ), + AutomationId(AutomationIds.settingsRelaysAdd, + child: ElevatedButton( + onPressed: () async { + await showAddDialog(context, ref); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.addRelay, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + )), ], ), ], ); } - Widget _buildRelayItem(BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - decoration: BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white.withValues(alpha: 0.1)), - ), - child: Row( - children: [ - // Status dot - green if active, grey if inactive - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: relayInfo.isActive ? AppTheme.activeColor : Colors.grey, - borderRadius: BorderRadius.circular(4), - ), + Widget _buildRelayItem( + BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) { + return AutomationId(AutomationIds.settingsRelayItem(relayInfo.url), + merge: false, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: AppTheme.dark1, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), ), - const SizedBox(width: 12), - - // Relay URL - Expanded( - child: Text( - relayInfo.url, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, + child: Row( + children: [ + // Status dot - green if active, grey if inactive + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: + relayInfo.isActive ? AppTheme.activeColor : Colors.grey, + borderRadius: BorderRadius.circular(4), + ), ), - ), + const SizedBox(width: 12), + + // Relay URL + Expanded( + child: Text( + relayInfo.url, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + + 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), + ], ), - - 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), - ], - ), - ); + )); } - 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), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - GestureDetector( - onTap: () async { - await _showDeleteUserRelayDialog(context, ref, relayInfo); - }, - child: const Icon( - Icons.delete, - color: Colors.white, - size: 24, - ), - ), + AutomationId(AutomationIds.settingsRelayDelete, + child: GestureDetector( + onTap: () async { + await _showDeleteUserRelayDialog(context, ref, relayInfo); + }, + child: const Icon( + Icons.delete, + color: Colors.white, + size: 24, + ), + )), ], ), ); } - 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 +183,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 +214,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 +256,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 +305,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,134 +349,155 @@ 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, + child: AutomationId(AutomationIds.settingsRelaysAddUrl, + 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, + )), ), - ), - if (isLoading) ...[ - const SizedBox(height: 16), - Row( - children: [ - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(AppTheme.cream1), + 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), - ), - ], - ), + const SizedBox(width: 12), + Text( + S.of(context)!.addRelayDialogTesting, + style: + const TextStyle(color: AppTheme.textSecondary), + ), + ], + ), + ], ], - ], ), ), actions: [ if (!isLoading) ...[ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text( - S.of(context)!.addRelayDialogCancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), + AutomationId(AutomationIds.settingsRelaysAddCancel, + child: TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text( + S.of(context)!.addRelayDialogCancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + )), const SizedBox(width: 12), ], - ElevatedButton( - onPressed: isLoading - ? null - : () async { - final input = textController.text.trim(); - if (input.isEmpty) return; + AutomationId(AutomationIds.settingsRelaysAddConfirm, + child: ElevatedButton( + onPressed: isLoading + ? null + : () async { + final input = textController.text.trim(); + if (input.isEmpty) return; - // Capture context values before async operations - final localizations = S.of(context)!; - final navigator = Navigator.of(dialogContext); + // Capture context values before async operations + final localizations = S.of(context)!; + final navigator = Navigator.of(dialogContext); - setState(() { - isLoading = true; - errorMessage = null; - }); - - try { - final result = await relaysNotifier.addRelayWithSmartValidation( - input, - errorOnlySecure: localizations.addRelayErrorOnlySecure, - errorNoHttp: localizations.addRelayErrorNoHttp, - errorInvalidDomain: localizations.addRelayErrorInvalidDomain, - errorAlreadyExists: localizations.addRelayErrorAlreadyExists, - errorNotValid: localizations.addRelayErrorNotValid, - ); + setState(() { + isLoading = true; + errorMessage = null; + }); - if (result.success) { - navigator.pop(); - if (context.mounted) { - SnackBarHelper.showTopSnackBar( - context, - localizations.addRelaySuccessMessage(result.normalizedUrl!), - backgroundColor: Colors.green, + try { + final result = await relaysNotifier + .addRelayWithSmartValidation( + input, + errorOnlySecure: + localizations.addRelayErrorOnlySecure, + errorNoHttp: + localizations.addRelayErrorNoHttp, + errorInvalidDomain: + localizations.addRelayErrorInvalidDomain, + errorAlreadyExists: + localizations.addRelayErrorAlreadyExists, + errorNotValid: + localizations.addRelayErrorNotValid, ); + + if (result.success) { + navigator.pop(); + if (context.mounted) { + SnackBarHelper.showTopSnackBar( + context, + localizations.addRelaySuccessMessage( + result.normalizedUrl!), + backgroundColor: Colors.green, + ); + } + } else { + setState(() { + errorMessage = result.error; + isLoading = false; + }); + } + } catch (e) { + setState(() { + errorMessage = + localizations.addRelayErrorGeneric; + isLoading = false; + }); } - } else { - setState(() { - errorMessage = result.error; - isLoading = false; - }); - } - } catch (e) { - setState(() { - errorMessage = localizations.addRelayErrorGeneric; - isLoading = false; - }); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - child: Text( - S.of(context)!.addRelayDialogAdd, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 12), + ), + child: Text( + S.of(context)!.addRelayDialogAdd, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + )), ], ); }, @@ -471,4 +505,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..37852c4b3 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -722,85 +724,89 @@ class _SettingsScreenState extends ConsumerState { const SizedBox(height: 16), Material( color: Colors.transparent, - child: InkWell( - onTap: () => MostroNodeSelector.show(context), - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.backgroundInput, + child: AutomationId(AutomationIds.settingsMostroNode, + merge: false, + child: InkWell( + onTap: () => MostroNodeSelector.show(context), borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.white.withValues(alpha: 0.1), - ), - ), - child: Row( - children: [ - if (selectedNode != null) ...[ - MostroNodeAvatar(node: selectedNode), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + if (selectedNode != null) ...[ + MostroNodeAvatar(node: selectedNode), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible( - child: Text( - selectedNode.displayName, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w500, + Row( + children: [ + Flexible( + child: Text( + selectedNode.displayName, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), ), - overflow: TextOverflow.ellipsis, + if (selectedNode.isTrusted) ...[ + const SizedBox(width: 8), + const TrustedBadge(), + ], + ], + ), + const SizedBox(height: 2), + AutomationId( + AutomationIds.settingsMostroNodePubkey, + child: Text( + selectedNode.pubkey, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + )), + const SizedBox(height: 2), + Text( + S.of(context)!.tapToSelectNode, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 11, ), ), - if (selectedNode.isTrusted) ...[ - const SizedBox(width: 8), - const TrustedBadge(), - ], ], ), - const SizedBox(height: 2), - Text( - selectedNode.pubkey, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - S.of(context)!.tapToSelectNode, + ), + ] else + Expanded( + child: Text( + S.of(context)!.selectMostroNode, style: const TextStyle( color: AppTheme.textSecondary, - fontSize: 11, + fontSize: 15, ), ), - ], - ), - ), - ] else - Expanded( - child: Text( - S.of(context)!.selectMostroNode, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 15, ), + const Icon( + Icons.arrow_drop_down, + color: AppTheme.textSecondary, ), - ), - const Icon( - Icons.arrow_drop_down, - color: AppTheme.textSecondary, + ], ), - ], - ), - ), - ), + ), + )), ), 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..787da4c66 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:circular_countdown/circular_countdown.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; @@ -455,28 +457,29 @@ class TradeDetailScreen extends ConsumerWidget { ), ), ), - ElevatedButton( - onPressed: () { - Navigator.of(dialogContext).pop(true); - ref - .read(orderNotifierProvider(orderId).notifier) - .releaseOrder(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: Text( - S.of(context)!.yes, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), + AutomationId(AutomationIds.tradeReleaseConfirm, + child: ElevatedButton( + onPressed: () { + Navigator.of(dialogContext).pop(true); + ref + .read(orderNotifierProvider(orderId).notifier) + .releaseOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + S.of(context)!.yes, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + )), ], ), ); @@ -605,18 +608,19 @@ class TradeDetailScreen extends ConsumerWidget { Key? key, MostroReactiveButtonController? controller, }) { - return MostroReactiveButton( - key: key, - label: label, - buttonStyle: ButtonStyleType.raised, - orderId: orderId, - action: action, - backgroundColor: backgroundColor, - onPressed: onPressed ?? () {}, // Provide empty function when null - showSuccessIndicator: true, - timeout: const Duration(seconds: 10), - controller: controller, - ); + return AutomationId(AutomationIds.tradeAction(action.name), + child: MostroReactiveButton( + key: key, + label: label, + buttonStyle: ButtonStyleType.raised, + orderId: orderId, + action: action, + backgroundColor: backgroundColor, + onPressed: onPressed ?? () {}, // Provide empty function when null + showSuccessIndicator: true, + timeout: const Duration(seconds: 10), + controller: controller, + )); } Widget _buildCancelButton( @@ -626,183 +630,190 @@ class TradeDetailScreen extends ConsumerWidget { String cancelMessage, actions.Action action, ) { - return ElevatedButton( - onPressed: () async { - final result = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: AppTheme.backgroundCard, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), - ), - title: Text( - S.of(context)!.cancelTradeDialogTitle, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - content: Text( - cancelMessage, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 14, - height: 1.5, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text( - S.of(context)!.no, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, + return AutomationId(AutomationIds.tradeCancel, + child: ElevatedButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: AppTheme.backgroundCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), ), - ), - const SizedBox(width: 12), - ElevatedButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + title: Text( + S.of(context)!.cancelTradeDialogTitle, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, ), - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 12), ), - child: Text( - S.of(context)!.yes, + content: Text( + cancelMessage, style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + fontSize: 14, + height: 1.5, ), - textAlign: TextAlign.center, ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text( + S.of(context)!.no, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + AutomationId(AutomationIds.tradeCancelConfirm, + child: AutomationId(AutomationIds.tradeDisputeConfirm, + child: ElevatedButton( + onPressed: () => + Navigator.of(dialogContext).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 12), + ), + child: Text( + S.of(context)!.yes, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ))), + ], ), - ], - ), - ); + ); - // Only proceed with cancellation if user confirmed - if (result == true) { - ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - foregroundColor: Colors.white, - ), - child: Text(buttonText), - ); + // Only proceed with cancellation if user confirmed + if (result == true) { + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.red1, + foregroundColor: Colors.white, + ), + child: Text(buttonText), + )); } Widget _buildDisputeButton( BuildContext context, WidgetRef ref, ) { - return ElevatedButton( - onPressed: () async { - final result = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: AppTheme.backgroundCard, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), - ), - title: Text( - S.of(context)!.disputeTradeDialogTitle, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - content: Text( - S.of(context)!.disputeTradeDialogContent, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 14, - height: 1.5, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text( - S.of(context)!.no, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), + return AutomationId(AutomationIds.tradeDispute, + child: ElevatedButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: AppTheme.backgroundCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), ), - ), - ElevatedButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + title: Text( + S.of(context)!.disputeTradeDialogTitle, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, ), ), - child: Text( - S.of(context)!.yes, + content: Text( + S.of(context)!.disputeTradeDialogContent, style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, + color: AppTheme.textSecondary, + fontSize: 14, + height: 1.5, ), ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text( + S.of(context)!.no, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + S.of(context)!.yes, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), - ], - ), - ); + ); - // Only proceed with dispute if user confirmed - if (result == true) { - try { - // Create dispute using the repository - final repository = ref.read(disputeRepositoryProvider); - final success = await repository.createDispute(orderId); - - if (success && context.mounted) { - SnackBarHelper.showTopSnackBar( - context, - S.of(context)!.disputeCreatedSuccessfully, - backgroundColor: AppTheme.mostroGreen, - ); - } else if (context.mounted) { - SnackBarHelper.showTopSnackBar( - context, - S.of(context)!.disputeCreationFailed, - backgroundColor: AppTheme.red1, - ); - } - } catch (e) { - if (context.mounted) { - SnackBarHelper.showTopSnackBar( - context, - S.of(context)!.disputeCreationErrorWithMessage(e.toString()), - backgroundColor: AppTheme.red1, - ); + // Only proceed with dispute if user confirmed + if (result == true) { + try { + // Create dispute using the repository + final repository = ref.read(disputeRepositoryProvider); + final success = await repository.createDispute(orderId); + + if (success && context.mounted) { + SnackBarHelper.showTopSnackBar( + context, + S.of(context)!.disputeCreatedSuccessfully, + backgroundColor: AppTheme.mostroGreen, + ); + } else if (context.mounted) { + SnackBarHelper.showTopSnackBar( + context, + S.of(context)!.disputeCreationFailed, + backgroundColor: AppTheme.red1, + ); + } + } catch (e) { + if (context.mounted) { + SnackBarHelper.showTopSnackBar( + context, + S + .of(context)! + .disputeCreationErrorWithMessage(e.toString()), + backgroundColor: AppTheme.red1, + ); + } + } } - } - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - foregroundColor: Colors.white, - ), - child: Text(S.of(context)!.disputeButton), - ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.red1, + foregroundColor: Colors.white, + ), + child: Text(S.of(context)!.disputeButton), + )); } 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..5e661a238 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/enums.dart'; @@ -43,13 +45,16 @@ class MostroMessageDetail extends ConsumerWidget { text: formatTextWithBoldUsernames(actionText, context), ), const SizedBox(height: 16), - Text( - _getLocalizedStatus(context, orderState.status), - style: TextStyle( - color: Colors.grey[400], - fontSize: 12, - ), - ), + AutomationId(AutomationIds.orderStatus, + merge: false, + label: orderState.status.value, + child: Text( + _getLocalizedStatus(context, orderState.status), + style: TextStyle( + color: Colors.grey[400], + fontSize: 12, + ), + )), ], ), ), @@ -77,8 +82,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..e4dc6adcf 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -53,145 +55,143 @@ class TradesListItem extends ConsumerWidget { ? trade.orderType == OrderType.buy : trade.orderType == OrderType.sell; - return GestureDetector( - onTap: () { - context.push('/trade_detail/${trade.orderId}'); - }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - decoration: BoxDecoration( - - color: AppTheme.dark1, - - borderRadius: BorderRadius.circular(12.0), - ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Left side - Trade info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // First row: Buy/Sell Bitcoin text (full width) - Text( - isBuying - ? S.of(context)!.buyingBitcoin - : S.of(context)!.sellingBitcoin, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - // Second row: Status and role chips + Premium/Discount - Row( - children: [ - _buildStatusChip( - context, - orderState.status, - bondBadgeLabel: bondBadgeLabel, - ), - const SizedBox(width: 8), - _buildRoleChip(context, isCreator), - const Spacer(), - // Show premium/discount if different from zero - if (trade.premium != null && trade.premium != '0') - Container( - 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, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${double.tryParse(trade.premium!) != null && double.parse(trade.premium!) > 0 ? '+' : ''}${trade.premium}%', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - // Third row: Flag + Amount and currency - Row( + return AutomationId(AutomationIds.tradesItem(trade.orderId ?? ''), + merge: false, + child: GestureDetector( + onTap: () { + context.push('/trade_detail/${trade.orderId}'); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + decoration: BoxDecoration( + color: AppTheme.dark1, + borderRadius: BorderRadius.circular(12.0), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Left side - Trade info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // First row: Buy/Sell Bitcoin text (full width) Text( - CurrencyUtils.getFlagFromCurrencyData( - trade.currency ?? '', currencyData), + isBuying + ? S.of(context)!.buyingBitcoin + : S.of(context)!.sellingBitcoin, style: const TextStyle( + color: AppTheme.textPrimary, fontSize: 16, + fontWeight: FontWeight.w600, ), ), - const SizedBox(width: 4), - Flexible( - child: Text( - trade.fiatAmount.maximum != null && - trade.fiatAmount.maximum != - trade.fiatAmount.minimum - ? '${trade.fiatAmount.minimum} - ${trade.fiatAmount.maximum} ${trade.currency ?? ''}' - : '${trade.fiatAmount.minimum} ${trade.currency ?? ''}', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 16, - fontWeight: FontWeight.bold, + const SizedBox(height: 8), + // Second row: Status and role chips + Premium/Discount + Row( + children: [ + _buildStatusChip( + context, + orderState.status, + bondBadgeLabel: bondBadgeLabel, ), - overflow: TextOverflow.ellipsis, - ), + const SizedBox(width: 8), + _buildRoleChip(context, isCreator), + const Spacer(), + // Show premium/discount if different from zero + if (trade.premium != null && trade.premium != '0') + Container( + 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, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${double.tryParse(trade.premium!) != null && double.parse(trade.premium!) > 0 ? '+' : ''}${trade.premium}%', + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], ), - ], - ), - const SizedBox(height: 4), - - trade.paymentMethods.isNotEmpty - ? Text( - trade.paymentMethods.join(', '), - style: const TextStyle( - color: AppTheme.secondaryText, - fontSize: 14, + const SizedBox(height: 8), + // Third row: Flag + Amount and currency + Row( + children: [ + Text( + CurrencyUtils.getFlagFromCurrencyData( + trade.currency ?? '', currencyData), + style: const TextStyle( + fontSize: 16, + ), ), - ) - : Text( - S.of(context)!.bankTransfer, - style: const TextStyle( - color: AppTheme.secondaryText, - fontSize: 14, + const SizedBox(width: 4), + Flexible( + child: Text( + trade.fiatAmount.maximum != null && + trade.fiatAmount.maximum != + trade.fiatAmount.minimum + ? '${trade.fiatAmount.minimum} - ${trade.fiatAmount.maximum} ${trade.currency ?? ''}' + : '${trade.fiatAmount.minimum} ${trade.currency ?? ''}', + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), ), - ), - ], - ), - ), - // Right side - Arrow icon - const Icon( - Icons.chevron_right, - color: AppTheme.textPrimary, - size: 24, + ], + ), + const SizedBox(height: 4), + + trade.paymentMethods.isNotEmpty + ? Text( + trade.paymentMethods.join(', '), + style: const TextStyle( + color: AppTheme.secondaryText, + fontSize: 14, + ), + ) + : Text( + S.of(context)!.bankTransfer, + style: const TextStyle( + color: AppTheme.secondaryText, + fontSize: 14, + ), + ), + ], + ), + ), + // Right side - Arrow icon + const Icon( + Icons.chevron_right, + color: AppTheme.textPrimary, + size: 24, + ), + ], ), - ], + ), ), - ), - ), - ); + )); } 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 +214,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 +236,6 @@ class TradesListItem extends ConsumerWidget { switch (status) { case Status.active: - backgroundColor = AppTheme.statusActiveBackground.withValues(alpha: 0.3); textColor = AppTheme.statusActiveText; @@ -337,20 +335,23 @@ class TradesListItem extends ConsumerWidget { break; } - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - label, - style: TextStyle( - color: textColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ); + return AutomationId(AutomationIds.tradesItemStatus, + merge: false, + label: status.value, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: TextStyle( + color: textColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + )); } } diff --git a/lib/features/walkthrough/screens/walkthrough_screen.dart b/lib/features/walkthrough/screens/walkthrough_screen.dart index 81d8d3ca7..673b4a280 100644 --- a/lib/features/walkthrough/screens/walkthrough_screen.dart +++ b/lib/features/walkthrough/screens/walkthrough_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:introduction_screen/introduction_screen.dart'; import 'package:go_router/go_router.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), - done: Text(S.of(context)!.done, - style: const TextStyle(fontWeight: FontWeight.w600)), + back: const AutomationId(AutomationIds.onboardingBack, + child: Icon(Icons.arrow_back)), + skip: AutomationId(AutomationIds.onboardingSkip, + child: Text(S.of(context)!.skip)), + next: const AutomationId(AutomationIds.onboardingNext, + child: Icon(Icons.arrow_forward)), + done: AutomationId(AutomationIds.onboardingDone, + child: Text(S.of(context)!.done, + style: const TextStyle(fontWeight: FontWeight.w600))), 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..f1001c16f 100644 --- a/lib/features/wallet/screens/connect_wallet_screen.dart +++ b/lib/features/wallet/screens/connect_wallet_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -132,38 +134,39 @@ class _ConnectWalletScreenState extends ConsumerState { : Colors.white.withValues(alpha: 0.1), ), ), - child: TextFormField( - controller: _uriController, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 14, - ), - maxLines: 3, - onChanged: (_) { - if (_validationError != null) { - setState(() => _validationError = null); - } - }, - decoration: InputDecoration( - border: InputBorder.none, - hintText: 'nostr+walletconnect://...', - hintStyle: const TextStyle( - color: AppTheme.textSecondary, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - suffixIcon: IconButton( - icon: const Icon( - LucideIcons.scanLine, - color: AppTheme.activeColor, + child: AutomationId(AutomationIds.walletNwcUri, + child: TextFormField( + controller: _uriController, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, ), - onPressed: () => _openQrScanner(context), - tooltip: S.of(context)!.scanQrCode, - ), - ), - ), + maxLines: 3, + onChanged: (_) { + if (_validationError != null) { + setState(() => _validationError = null); + } + }, + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'nostr+walletconnect://...', + hintStyle: const TextStyle( + color: AppTheme.textSecondary, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + suffixIcon: IconButton( + icon: const Icon( + LucideIcons.scanLine, + color: AppTheme.activeColor, + ), + onPressed: () => _openQrScanner(context), + tooltip: S.of(context)!.scanQrCode, + ), + ), + )), ), // Validation error @@ -183,32 +186,33 @@ class _ConnectWalletScreenState extends ConsumerState { // Connect button SizedBox( width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isConnecting ? null : _onConnect, - icon: _isConnecting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(LucideIcons.plug, size: 18), - label: Text( - _isConnecting - ? S.of(context)!.connecting - : S.of(context)!.connectWallet, - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), + child: AutomationId(AutomationIds.walletNwcConnect, + child: ElevatedButton.icon( + onPressed: _isConnecting ? null : _onConnect, + icon: _isConnecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(LucideIcons.plug, size: 18), + label: Text( + _isConnecting + ? S.of(context)!.connecting + : S.of(context)!.connectWallet, + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + )), ), ], ), diff --git a/lib/features/wallet/screens/wallet_settings_screen.dart b/lib/features/wallet/screens/wallet_settings_screen.dart index 54eca3675..5ecb3bf8f 100644 --- a/lib/features/wallet/screens/wallet_settings_screen.dart +++ b/lib/features/wallet/screens/wallet_settings_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -174,19 +176,20 @@ class WalletSettingsScreen extends ConsumerWidget { // Disconnect Button SizedBox( width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => _showDisconnectConfirm(context, ref), - icon: const Icon(LucideIcons.unplug, size: 18), - label: Text(S.of(context)!.disconnectWallet), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.redAccent, - side: const BorderSide(color: Colors.redAccent), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), + child: AutomationId(AutomationIds.walletSettingsDisconnect, + child: OutlinedButton.icon( + onPressed: () => _showDisconnectConfirm(context, ref), + icon: const Icon(LucideIcons.unplug, size: 18), + label: Text(S.of(context)!.disconnectWallet), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.redAccent, + side: const BorderSide(color: Colors.redAccent), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + )), ), ], ); @@ -289,19 +292,20 @@ class WalletSettingsScreen extends ConsumerWidget { const SizedBox(height: 20), SizedBox( width: double.infinity, - child: ElevatedButton.icon( - onPressed: () => context.push('/connect_wallet'), - icon: const Icon(LucideIcons.plug, size: 18), - label: Text(S.of(context)!.connectWallet), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), + child: AutomationId(AutomationIds.walletSettingsConnect, + child: ElevatedButton.icon( + onPressed: () => context.push('/connect_wallet'), + icon: const Icon(LucideIcons.plug, size: 18), + label: Text(S.of(context)!.connectWallet), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + )), ), ], ), diff --git a/lib/features/wallet/widgets/wallet_status_card.dart b/lib/features/wallet/widgets/wallet_status_card.dart index 09321b964..b22912665 100644 --- a/lib/features/wallet/widgets/wallet_status_card.dart +++ b/lib/features/wallet/widgets/wallet_status_card.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -61,65 +63,73 @@ class WalletStatusCard extends ConsumerWidget { const SizedBox(height: 16), Material( color: Colors.transparent, - child: InkWell( - onTap: () => context.push('/wallet_settings'), - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.backgroundInput, + child: AutomationId(AutomationIds.settingsWallet, + merge: false, + child: InkWell( + onTap: () => context.push('/wallet_settings'), borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.white.withValues(alpha: 0.1), - ), - ), - child: Row( - children: [ - Icon( - isConnected ? LucideIcons.check : LucideIcons.plug, - color: - isConnected ? Colors.green : AppTheme.textSecondary, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isConnected - ? (nwcState.walletAlias ?? - S.of(context)!.walletConnected) - : S.of(context)!.walletDisconnected, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - if (isConnected && - nwcState.balanceSats != null) ...[ - const SizedBox(height: 4), - Text( - '⚡ ${_formatSats(nwcState.balanceSats!)} ${S.of(context)!.sats}', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - ), - ), - ], - ], + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), ), ), - const Icon( - Icons.chevron_right, - color: AppTheme.textSecondary, - size: 20, + child: Row( + children: [ + Icon( + isConnected ? LucideIcons.check : LucideIcons.plug, + color: isConnected + ? Colors.green + : AppTheme.textSecondary, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutomationId(AutomationIds.walletConnection, + merge: false, + label: isConnected + ? 'connected' + : 'disconnected', + child: Text( + isConnected + ? (nwcState.walletAlias ?? + S.of(context)!.walletConnected) + : S.of(context)!.walletDisconnected, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + )), + if (isConnected && + nwcState.balanceSats != null) ...[ + const SizedBox(height: 4), + Text( + '⚡ ${_formatSats(nwcState.balanceSats!)} ${S.of(context)!.sats}', + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), + ), + ], + ], + ), + ), + const Icon( + Icons.chevron_right, + color: AppTheme.textSecondary, + size: 20, + ), + ], ), - ], - ), - ), - ), + ), + )), ), ], ), 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..f62176276 --- /dev/null +++ b/lib/main_mortsom.dart @@ -0,0 +1,26 @@ +// 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(); + // 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..4e0c713c4 100644 --- a/lib/shared/widgets/add_lightning_invoice_widget.dart +++ b/lib/shared/widgets/add_lightning_invoice_widget.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/generated/l10n.dart'; @@ -55,63 +57,66 @@ class _AddLightningInvoiceWidgetState extends State { borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.white.withValues(alpha: 0.1)), ), - child: TextFormField( - key: const Key('invoiceTextField'), - controller: widget.controller, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - labelText: S.of(context)!.lightningInvoice, - labelStyle: const TextStyle(color: AppTheme.textSecondary), - hintText: S.of(context)!.enterInvoiceHere, - hintStyle: const TextStyle(color: AppTheme.textSecondary), - border: InputBorder.none, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - alignLabelWithHint: true, - ), - maxLines: 6, - ), + child: AutomationId(AutomationIds.invoiceText, + child: TextFormField( + key: const Key('invoiceTextField'), + controller: widget.controller, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: InputDecoration( + labelText: S.of(context)!.lightningInvoice, + labelStyle: const TextStyle(color: AppTheme.textSecondary), + hintText: S.of(context)!.enterInvoiceHere, + hintStyle: const TextStyle(color: AppTheme.textSecondary), + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + alignLabelWithHint: true, + ), + maxLines: 6, + )), ), const SizedBox(height: 16), Row( children: [ Expanded( - child: TextButton( - key: const Key('cancelInvoiceButton'), - onPressed: widget.onCancel, - child: Text( - S.of(context)!.cancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), + child: AutomationId(AutomationIds.invoiceCancel, + child: TextButton( + key: const Key('cancelInvoiceButton'), + onPressed: widget.onCancel, + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + )), ), const SizedBox(width: 12), Expanded( - child: ElevatedButton( - key: const Key('submitInvoiceButton'), - onPressed: widget.onSubmit, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 12), - ), - child: Text( - S.of(context)!.submit, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), + child: AutomationId(AutomationIds.invoiceSubmit, + child: ElevatedButton( + key: const Key('submitInvoiceButton'), + onPressed: widget.onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 12), + ), + child: Text( + S.of(context)!.submit, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + )), ), ], ), diff --git a/lib/shared/widgets/add_order_button.dart b/lib/shared/widgets/add_order_button.dart index e59318a39..d47a6dac1 100644 --- a/lib/shared/widgets/add_order_button.dart +++ b/lib/shared/widgets/add_order_button.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/core/app_theme.dart'; @@ -76,25 +78,27 @@ class _AddOrderButtonState extends State child: Stack( alignment: Alignment.centerLeft, children: [ - ElevatedButton.icon( - key: const Key('buyButton'), - onPressed: _isMenuOpen - ? () => _navigateToCreateOrder(context, 'buy') - : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.buyColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - ), - icon: const SizedBox(width: 16, height: 16), - label: Text(S.of(context)!.buy, - style: - const TextStyle(fontWeight: FontWeight.bold)), - ), + AutomationId(AutomationIds.orderAddBuy, + child: ElevatedButton.icon( + key: const Key('buyButton'), + onPressed: _isMenuOpen + ? () => + _navigateToCreateOrder(context, 'buy') + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.buyColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + icon: const SizedBox(width: 16, height: 16), + label: Text(S.of(context)!.buy, + style: const TextStyle( + fontWeight: FontWeight.bold)), + )), if (_isMenuOpen) const Positioned( left: 12, @@ -109,25 +113,27 @@ class _AddOrderButtonState extends State child: Stack( alignment: Alignment.centerLeft, children: [ - ElevatedButton.icon( - key: const Key('sellButton'), - onPressed: _isMenuOpen - ? () => _navigateToCreateOrder(context, 'sell') - : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.sellColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - ), - icon: const SizedBox(width: 16, height: 16), - label: Text(S.of(context)!.sell, - style: - const TextStyle(fontWeight: FontWeight.bold)), - ), + AutomationId(AutomationIds.orderAddSell, + child: ElevatedButton.icon( + key: const Key('sellButton'), + onPressed: _isMenuOpen + ? () => + _navigateToCreateOrder(context, 'sell') + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.sellColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + icon: const SizedBox(width: 16, height: 16), + label: Text(S.of(context)!.sell, + style: const TextStyle( + fontWeight: FontWeight.bold)), + )), if (_isMenuOpen) const Positioned( left: 12, @@ -142,27 +148,28 @@ class _AddOrderButtonState extends State ), ), ), - FloatingActionButton( - key: const Key('addOrderButton'), - onPressed: _toggleMenu, - backgroundColor: - _isMenuOpen ? Colors.grey.shade700 : AppTheme.activeColor, - elevation: 6, - shape: const CircleBorder(), - child: AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Transform.rotate( - angle: _animationController.value * 0.785 * 2, - child: Icon( - _isMenuOpen ? Icons.close : Icons.add, - color: Colors.black, - size: 24, - ), - ); - }, - ), - ), + AutomationId(AutomationIds.orderAddFab, + child: FloatingActionButton( + key: const Key('addOrderButton'), + onPressed: _toggleMenu, + backgroundColor: + _isMenuOpen ? Colors.grey.shade700 : AppTheme.activeColor, + elevation: 6, + shape: const CircleBorder(), + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.rotate( + angle: _animationController.value * 0.785 * 2, + child: Icon( + _isMenuOpen ? Icons.close : Icons.add, + color: Colors.black, + size: 24, + ), + ); + }, + ), + )), ], ), ); diff --git a/lib/shared/widgets/bottom_nav_bar.dart b/lib/shared/widgets/bottom_nav_bar.dart index ac56c8d18..7bec951da 100644 --- a/lib/shared/widgets/bottom_nav_bar.dart +++ b/lib/shared/widgets/bottom_nav_bar.dart @@ -1,3 +1,4 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -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..381c81953 100644 --- a/lib/shared/widgets/currency_selection_dialog.dart +++ b/lib/shared/widgets/currency_selection_dialog.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; @@ -137,26 +139,29 @@ class _CurrencySelectionDialogWidgetState final isSelected = code == widget.currentSelection; - return ListTile( - key: Key('currency_$code'), - leading: Text( - currency.emoji.isNotEmpty - ? currency.emoji - : '🏳️', - style: const TextStyle(fontSize: 20), - ), - title: Text( - '$code - ${currency.name}', - style: const TextStyle(color: Colors.white), - ), - trailing: isSelected - ? const Icon(Icons.check, - color: AppTheme.mostroGreen) - : null, - onTap: () { - Navigator.of(context).pop(code); - }, - ); + return AutomationId( + AutomationIds.orderCreateCurrencyOption(code), + child: ListTile( + key: Key('currency_$code'), + leading: Text( + currency.emoji.isNotEmpty + ? currency.emoji + : '🏳️', + style: const TextStyle(fontSize: 20), + ), + title: Text( + '$code - ${currency.name}', + style: + const TextStyle(color: Colors.white), + ), + trailing: isSelected + ? const Icon(Icons.check, + color: AppTheme.mostroGreen) + : null, + onTap: () { + Navigator.of(context).pop(code); + }, + )); }, ); }, diff --git a/lib/shared/widgets/custom_drawer_overlay.dart b/lib/shared/widgets/custom_drawer_overlay.dart index 52f94ee99..eb89998d7 100644 --- a/lib/shared/widgets/custom_drawer_overlay.dart +++ b/lib/shared/widgets/custom_drawer_overlay.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.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, { @@ -134,24 +143,27 @@ class CustomDrawerOverlay extends ConsumerWidget { required String title, required String route, }) { - return ListTile( - dense: true, - leading: Icon( - icon, - color: AppTheme.cream1, - size: 22, - ), - title: Text( - title, - style: AppTheme.theme.textTheme.bodyLarge?.copyWith( + return AutomationId( + _drawerIdentifier(route), + child: ListTile( + dense: true, + leading: Icon( + icon, color: AppTheme.cream1, - fontWeight: FontWeight.w500, + size: 22, + ), + title: Text( + title, + style: AppTheme.theme.textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + fontWeight: FontWeight.w500, + ), ), + onTap: () { + ref.read(drawerProvider.notifier).closeDrawer(); + context.push(route); + }, ), - onTap: () { - ref.read(drawerProvider.notifier).closeDrawer(); - context.push(route); - }, ); } } diff --git a/lib/shared/widgets/mostro_app_bar.dart b/lib/shared/widgets/mostro_app_bar.dart index 0896175c1..29422b6e5 100644 --- a/lib/shared/widgets/mostro_app_bar.dart +++ b/lib/shared/widgets/mostro_app_bar.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -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, @@ -84,37 +86,40 @@ class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { if (showBackButton) { leading = Padding( padding: const EdgeInsets.only(left: 16.0), - child: IconButton( - icon: const HeroIcon( - HeroIcons.arrowLeft, - style: HeroIconStyle.outline, - color: AppTheme.cream1, - size: 28, - ), - onPressed: () => context.pop(), - ), + child: AutomationId(AutomationIds.appBarBack, + child: IconButton( + icon: const HeroIcon( + HeroIcons.arrowLeft, + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 28, + ), + onPressed: () => context.pop(), + )), ); } else if (showDrawerButton) { leading = Padding( padding: const EdgeInsets.only(left: 16.0), - child: IconButton( - icon: const HeroIcon( - HeroIcons.bars3, - style: HeroIconStyle.outline, - color: AppTheme.cream1, - size: 28, - ), - onPressed: () => ref.read(drawerProvider.notifier).toggleDrawer(), - ), + child: AutomationId(AutomationIds.appBarDrawer, + child: IconButton( + icon: const HeroIcon( + HeroIcons.bars3, + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 28, + ), + onPressed: () => ref.read(drawerProvider.notifier).toggleDrawer(), + )), ); } - + // 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_payment_widget.dart b/lib/shared/widgets/nwc_payment_widget.dart index e1f405185..d735ca007 100644 --- a/lib/shared/widgets/nwc_payment_widget.dart +++ b/lib/shared/widgets/nwc_payment_widget.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.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: [ @@ -268,22 +270,24 @@ class _NwcPaymentWidgetState extends ConsumerState { SizedBox( width: double.infinity, height: 56, - child: ElevatedButton.icon( - onPressed: hasEnoughBalance ? _payWithWallet : null, - icon: const Icon(LucideIcons.wallet, size: 20), - label: Text( - S.of(context)!.payWithWallet, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - foregroundColor: Colors.black, - disabledBackgroundColor: Colors.grey.shade700, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), + child: AutomationId(AutomationIds.payNwc, + child: ElevatedButton.icon( + onPressed: hasEnoughBalance ? _payWithWallet : null, + icon: const Icon(LucideIcons.wallet, size: 20), + label: Text( + S.of(context)!.payWithWallet, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + foregroundColor: Colors.black, + disabledBackgroundColor: Colors.grey.shade700, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + )), ), 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..bbae73ed8 100644 --- a/lib/shared/widgets/order_cards.dart +++ b/lib/shared/widgets/order_cards.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.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, @@ -215,13 +215,14 @@ class OrderIdCard extends StatelessWidget { Row( children: [ Expanded( - child: Text( - orderId, - style: const TextStyle( - color: AppTheme.mostroGreen, - fontSize: 14, - ), - ), + child: AutomationId(AutomationIds.orderId, + child: Text( + orderId, + style: const TextStyle( + color: AppTheme.mostroGreen, + fontSize: 14, + ), + )), ), 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..21122fefb --- /dev/null +++ b/lib/shared/widgets/test_environment_banner.dart @@ -0,0 +1,56 @@ +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; + } + return Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + child, + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + 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..9f17ccc07 --- /dev/null +++ b/test/core/automation/automation_contract_test.dart @@ -0,0 +1,247 @@ +// 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.settingsRelayDelete, + AutomationIds.nodeAddCustom, + AutomationIds.nodeCustomPubkey, + AutomationIds.nodeCustomName, + AutomationIds.nodeCustomConfirm, + AutomationIds.nodeCustomCancel, + AutomationIds.walletNwcUri, + AutomationIds.walletNwcConnect, + AutomationIds.walletConnection, + AutomationIds.walletSettingsConnect, + AutomationIds.walletSettingsDisconnect, + 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.tradeFiatSent, + AutomationIds.tradeRelease, + AutomationIds.tradeReleaseConfirm, + AutomationIds.tradeCancel, + AutomationIds.tradeCancelConfirm, + AutomationIds.tradeDispute, + AutomationIds.tradeDisputeConfirm, + AutomationIds.invoiceText, + AutomationIds.invoiceSubmit, + AutomationIds.invoiceCancel, + 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'); + 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.orderCreateCurrencyOption('USD'), 'order.create.currency.USD'); + }); + }); + + group('TestEnvironment', () { + tearDown(TestEnvironment.disarm); + + test('is disabled unless armed and compiled with the define', () { + expect(TestEnvironment.enabled, isFalse); + TestEnvironment.arm(); + // Tests never carry MORTSOM_TEST_ENV, so arming alone must not enable it. + expect(TestEnvironment.enabled, TestEnvironment.defineEnabled); + expect(TestEnvironment.defineEnabled, isFalse); + expect(TestEnvironment.seedRelays, isEmpty); + expect(TestEnvironment.disableBootstrapFallback, isFalse); + expect(TestEnvironment.allowInsecureRelays, isFalse); + }); + + 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( + AutomationId('demo.control', child: ElevatedButton(onPressed: () {}, child: const Text('Go'))), + )); + + final data = tester.getSemantics(find.bySemanticsIdentifier('demo.control')).getSemanticsData(); + expect(data.label, 'Go'); + expect(data.flagsCollection.isButton, isTrue); + }); + + testWidgets('merged text fields stay editable through accessibility', (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget(harness( + AutomationId('demo.field', child: TextField(controller: controller)), + )); + + // 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')); + final data = node.getSemanticsData(); + expect(data.flagsCollection.isTextField, isTrue); + expect(data.flagsCollection.isFocused.toString(), contains('isTrue')); + expect(data.hasAction(SemanticsAction.setText), isTrue); + + tester.binding.rootPipelineOwner.semanticsOwner! + .performAction(node.id, SemanticsAction.setText, '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 AutomationId('demo.state', merge: false, label: 'connected', child: Text('Wallet')), + )); + + 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); + }); + }); +} From 8d5f8fbae553f135ace0639938f66894a393b899 Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 15 Aug 2026 16:48:53 -0300 Subject: [PATCH 02/12] test: use the semantics owner that hosts the widget tree in the contract test --- test/core/automation/automation_contract_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/core/automation/automation_contract_test.dart b/test/core/automation/automation_contract_test.dart index 9f17ccc07..acafe5532 100644 --- a/test/core/automation/automation_contract_test.dart +++ b/test/core/automation/automation_contract_test.dart @@ -176,7 +176,8 @@ void main() { expect(data.flagsCollection.isFocused.toString(), contains('isTrue')); expect(data.hasAction(SemanticsAction.setText), isTrue); - tester.binding.rootPipelineOwner.semanticsOwner! + // ignore: deprecated_member_use + tester.binding.pipelineOwner.semanticsOwner! .performAction(node.id, SemanticsAction.setText, 'ws://10.0.2.2:7000'); await tester.pump(); expect(controller.text, 'ws://10.0.2.2:7000'); From 23c6901dbe020ed578b85edee1ecdb2a58123b5e Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 15 Aug 2026 16:53:58 -0300 Subject: [PATCH 03/12] feat: pin trade action identifiers used by automation as constants --- lib/core/automation/automation_ids.dart | 4 ++++ test/core/automation/automation_contract_test.dart | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/lib/core/automation/automation_ids.dart b/lib/core/automation/automation_ids.dart index c16c5dadd..a6993bd49 100644 --- a/lib/core/automation/automation_ids.dart +++ b/lib/core/automation/automation_ids.dart @@ -101,6 +101,10 @@ class AutomationIds { 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'; diff --git a/test/core/automation/automation_contract_test.dart b/test/core/automation/automation_contract_test.dart index acafe5532..fea11a878 100644 --- a/test/core/automation/automation_contract_test.dart +++ b/test/core/automation/automation_contract_test.dart @@ -82,6 +82,10 @@ const List staticIds = [ AutomationIds.orderId, AutomationIds.orderStatus, AutomationIds.tradesItemStatus, + AutomationIds.tradePayInvoice, + AutomationIds.tradeAddInvoice, + AutomationIds.tradeTakeSell, + AutomationIds.tradeTakeBuy, AutomationIds.tradeFiatSent, AutomationIds.tradeRelease, AutomationIds.tradeReleaseConfirm, @@ -125,6 +129,8 @@ void main() { 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'); }); }); From eaf9c393a767a2c9d93979a8745d921e436e9194 Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 15 Aug 2026 17:23:16 -0300 Subject: [PATCH 04/12] feat: identifiers for order-book tabs and NWC invoice generation --- docs/automation-contract.md | 2 + lib/core/automation/automation_ids.dart | 4 + lib/features/home/screens/home_screen.dart | 59 ++++++++------ lib/shared/widgets/nwc_invoice_widget.dart | 76 ++++++++++--------- .../automation/automation_contract_test.dart | 4 + 5 files changed, 85 insertions(+), 60 deletions(-) diff --git a/docs/automation-contract.md b/docs/automation-contract.md index f92e5fa41..f0729b74a 100644 --- a/docs/automation-contract.md +++ b/docs/automation-contract.md @@ -52,6 +52,7 @@ listed below disappears, is renamed, or stops being namespaced. | `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. | @@ -65,6 +66,7 @@ listed below disappears, is renamed, or stops being namespaced. | `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. | | `pay.nwc` | payment | Pay the displayed invoice with the connected wallet. | | `pay.cancel` | payment | Cancel the order from the pay-invoice screen. | diff --git a/lib/core/automation/automation_ids.dart b/lib/core/automation/automation_ids.dart index a6993bd49..f0eec368d 100644 --- a/lib/core/automation/automation_ids.dart +++ b/lib/core/automation/automation_ids.dart @@ -75,6 +75,8 @@ class AutomationIds { 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'; @@ -117,6 +119,8 @@ class AutomationIds { 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 payNwc = 'pay.nwc'; static const String payCancel = 'pay.cancel'; } diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 46eda9b65..5a71cf825 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:heroicons/heroicons.dart'; @@ -160,31 +162,35 @@ class HomeScreen extends ConsumerWidget { Color activeColor, ) { return Expanded( - child: InkWell( - onTap: () => ref.read(homeOrderTypeProvider.notifier).state = type, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: isActive ? activeColor : Colors.transparent, - width: 3.0, // Thicker line + child: AutomationId( + type == OrderType.buy + ? AutomationIds.orderBookTabBuy + : AutomationIds.orderBookTabSell, + child: InkWell( + onTap: () => ref.read(homeOrderTypeProvider.notifier).state = type, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isActive ? activeColor : Colors.transparent, + width: 3.0, // Thicker line + ), + ), + ), + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + color: isActive ? activeColor : AppTheme.textInactive, + fontWeight: FontWeight.w600, // Semi-bold + fontSize: 15, + letterSpacing: 0.5, // Letter spacing + fontFamily: 'Roboto', // Assuming Roboto as font + ), ), ), - ), - child: Text( - text, - textAlign: TextAlign.center, - style: TextStyle( - color: isActive ? activeColor : AppTheme.textInactive, - fontWeight: FontWeight.w600, // Semi-bold - fontSize: 15, - letterSpacing: 0.5, // Letter spacing - fontFamily: 'Roboto', // Assuming Roboto as font - ), - ), - ), - ), + )), ); } @@ -227,7 +233,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 +261,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/shared/widgets/nwc_invoice_widget.dart b/lib/shared/widgets/nwc_invoice_widget.dart index 0eb77b9c5..3c9b5b87b 100644 --- a/lib/shared/widgets/nwc_invoice_widget.dart +++ b/lib/shared/widgets/nwc_invoice_widget.dart @@ -1,3 +1,5 @@ +import 'package:mostro_mobile/core/automation/automation_ids.dart'; +import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; @@ -5,7 +7,8 @@ import 'package:mostro_mobile/core/app_theme.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 { @@ -163,21 +166,23 @@ class _NwcInvoiceWidgetState extends ConsumerState { SizedBox( width: double.infinity, height: 56, - child: ElevatedButton.icon( - onPressed: _generateInvoice, - icon: const Icon(LucideIcons.wallet, size: 20), - label: Text( - S.of(context)!.nwcGenerateWithWallet, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), + child: AutomationId(AutomationIds.invoiceNwcGenerate, + child: ElevatedButton.icon( + onPressed: _generateInvoice, + icon: const Icon(LucideIcons.wallet, size: 20), + label: Text( + S.of(context)!.nwcGenerateWithWallet, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + )), ), const SizedBox(height: 8), Text( @@ -277,25 +282,26 @@ class _NwcInvoiceWidgetState extends ConsumerState { SizedBox( width: double.infinity, height: 48, - child: ElevatedButton( - onPressed: _generatedInvoice != null - ? () => widget.onInvoiceConfirmed(_generatedInvoice!) - : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - S.of(context)!.nwcConfirmInvoice, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), + child: AutomationId(AutomationIds.invoiceNwcConfirm, + child: ElevatedButton( + onPressed: _generatedInvoice != null + ? () => widget.onInvoiceConfirmed(_generatedInvoice!) + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + S.of(context)!.nwcConfirmInvoice, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + )), ), ], ), diff --git a/test/core/automation/automation_contract_test.dart b/test/core/automation/automation_contract_test.dart index fea11a878..ebed9e3d7 100644 --- a/test/core/automation/automation_contract_test.dart +++ b/test/core/automation/automation_contract_test.dart @@ -63,6 +63,8 @@ const List staticIds = [ AutomationIds.walletConnection, AutomationIds.walletSettingsConnect, AutomationIds.walletSettingsDisconnect, + AutomationIds.orderBookTabBuy, + AutomationIds.orderBookTabSell, AutomationIds.orderAddFab, AutomationIds.orderAddBuy, AutomationIds.orderAddSell, @@ -96,6 +98,8 @@ const List staticIds = [ AutomationIds.invoiceText, AutomationIds.invoiceSubmit, AutomationIds.invoiceCancel, + AutomationIds.invoiceNwcGenerate, + AutomationIds.invoiceNwcConfirm, AutomationIds.payNwc, AutomationIds.payCancel, ]; From f3ee582d519613c595a0731632dc0628c613b7db Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 15 Aug 2026 17:24:42 -0300 Subject: [PATCH 05/12] feat: invoice readouts for payment correlation --- docs/automation-contract.md | 2 ++ lib/core/automation/automation_ids.dart | 2 ++ .../screens/pay_lightning_invoice_screen.dart | 9 ++++++++ lib/shared/widgets/nwc_invoice_widget.dart | 21 +++++++++++-------- .../automation/automation_contract_test.dart | 2 ++ 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/automation-contract.md b/docs/automation-contract.md index f0729b74a..744e010b4 100644 --- a/docs/automation-contract.md +++ b/docs/automation-contract.md @@ -67,6 +67,8 @@ listed below disappears, is renamed, or stops being namespaced. | `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. | diff --git a/lib/core/automation/automation_ids.dart b/lib/core/automation/automation_ids.dart index f0eec368d..9f24111d0 100644 --- a/lib/core/automation/automation_ids.dart +++ b/lib/core/automation/automation_ids.dart @@ -121,6 +121,8 @@ class AutomationIds { 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/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index d22c59af4..d38680c82 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -67,6 +67,15 @@ class _PayLightningInvoiceScreenState 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. + AutomationId( + AutomationIds.payInvoiceText, + merge: false, + label: lnInvoice, + child: const SizedBox(width: 1, height: 1), + ), NwcPaymentWidget( lnInvoice: lnInvoice, sats: sats, diff --git a/lib/shared/widgets/nwc_invoice_widget.dart b/lib/shared/widgets/nwc_invoice_widget.dart index 3c9b5b87b..74e80606e 100644 --- a/lib/shared/widgets/nwc_invoice_widget.dart +++ b/lib/shared/widgets/nwc_invoice_widget.dart @@ -269,15 +269,18 @@ class _NwcInvoiceWidgetState extends ConsumerState { ), ), const SizedBox(height: 4), - Text( - _truncateInvoice(_generatedInvoice), - style: TextStyle( - color: AppTheme.cream1.withAlpha(102), - fontSize: 11, - fontFamily: 'monospace', - ), - textAlign: TextAlign.center, - ), + AutomationId(AutomationIds.invoiceNwcText, + merge: false, + label: _generatedInvoice ?? '', + child: Text( + _truncateInvoice(_generatedInvoice), + style: TextStyle( + color: AppTheme.cream1.withAlpha(102), + fontSize: 11, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, + )), const SizedBox(height: 16), SizedBox( width: double.infinity, diff --git a/test/core/automation/automation_contract_test.dart b/test/core/automation/automation_contract_test.dart index ebed9e3d7..a9f87e88a 100644 --- a/test/core/automation/automation_contract_test.dart +++ b/test/core/automation/automation_contract_test.dart @@ -100,6 +100,8 @@ const List staticIds = [ AutomationIds.invoiceCancel, AutomationIds.invoiceNwcGenerate, AutomationIds.invoiceNwcConfirm, + AutomationIds.invoiceNwcText, + AutomationIds.payInvoiceText, AutomationIds.payNwc, AutomationIds.payCancel, ]; From 239b7583ce97a884dfde64340f19d12c68068539 Mon Sep 17 00:00:00 2001 From: grunch Date: Sun, 16 Aug 2026 18:33:03 -0300 Subject: [PATCH 06/12] feat: automation ids for the dialogs that block onboarding Two dialogs stand between a fresh install and a usable app, and neither could be reached by an automated driver. The community screen raises a legal notice over itself on first launch, and generating a user asks for confirmation; both cover everything behind them, so a harness waits for a screen the app is not showing and times out with no way to say why. The notice's Accept button and the generate dialog's Continue and Cancel buttons now carry automation ids. keys.generate.confirm already existed as a constant and was attached to nothing; keys.generate.cancel is new. No behaviour changes: the ids only make the existing controls addressable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012zKL9yUmiHAkR2xrYJeE2a --- lib/core/automation/automation_ids.dart | 3 +++ .../screens/community_selector_screen.dart | 9 ++++--- .../key_manager/key_management_screen.dart | 26 ++++++++++++------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/core/automation/automation_ids.dart b/lib/core/automation/automation_ids.dart index 9f24111d0..8c07c6b15 100644 --- a/lib/core/automation/automation_ids.dart +++ b/lib/core/automation/automation_ids.dart @@ -31,6 +31,8 @@ class AutomationIds { 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'; @@ -40,6 +42,7 @@ class AutomationIds { // 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'; diff --git a/lib/features/community/screens/community_selector_screen.dart b/lib/features/community/screens/community_selector_screen.dart index 31198bfa6..b67bd738f 100644 --- a/lib/features/community/screens/community_selector_screen.dart +++ b/lib/features/community/screens/community_selector_screen.dart @@ -46,9 +46,12 @@ class _CommunitySelectorScreenState child: Text(S.of(ctx)!.communityDisclaimerBody), ), actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: Text(S.of(ctx)!.communityDisclaimerAccept), + AutomationId( + AutomationIds.communityNoticeAccept, + child: TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(S.of(ctx)!.communityDisclaimerAccept), + ), ), ], ), diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index 9bffe5564..a6a61dbbf 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -791,20 +791,25 @@ class _KeyManagementScreenState extends ConsumerState { ), ), actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text( - S.of(context)!.cancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, + AutomationId( + AutomationIds.keysGenerateCancel, + child: TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), ), const SizedBox(width: 12), - ElevatedButton( + AutomationId( + AutomationIds.keysGenerateConfirm, + child: ElevatedButton( onPressed: () { Navigator.of(dialogContext).pop(); _generateNewMasterKey(); @@ -826,6 +831,7 @@ class _KeyManagementScreenState extends ConsumerState { ), textAlign: TextAlign.center, ), + ), ), ], ); From 2bcda9fc953ef28109d6ebbc132c8276a0706b07 Mon Sep 17 00:00:00 2001 From: grunch Date: Sun, 16 Aug 2026 18:35:46 -0300 Subject: [PATCH 07/12] fix: the account screen's back button carries the appbar id Every other screen goes through MostroAppBar, which tags its back button; the account screen builds its own AppBar and its arrow was unreachable, so an automated driver could enter key management and never leave. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012zKL9yUmiHAkR2xrYJeE2a --- lib/features/key_manager/key_management_screen.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index a6a61dbbf..28c6be227 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -137,12 +137,15 @@ class _KeyManagementScreenState extends ConsumerState { appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, - leading: IconButton( - icon: const HeroIcon( - HeroIcons.arrowLeft, - color: AppTheme.textPrimary, + leading: AutomationId( + AutomationIds.appBarBack, + child: IconButton( + icon: const HeroIcon( + HeroIcons.arrowLeft, + color: AppTheme.textPrimary, + ), + onPressed: () => context.pop(), ), - onPressed: () => context.pop(), ), title: Text( S.of(context)!.account, From 954fdf1f7ad7a6cd3f915221ba57d0e0633e492c Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 17 Aug 2026 11:52:13 -0300 Subject: [PATCH 08/12] refactor: attach automation ids through a .withAutomationId() extension Wrapping a control in the AutomationId widget re-indented its whole subtree: naming one control in community_card.dart showed up as 150 changed lines, and the same noise ran through every screen the contract touches. An identifier is a property of a control, not a level of the widget tree, and it now reads as one. The extension builds the same AutomationId widget underneath, so the semantics are unchanged: same Semantics/MergeSemantics, same merge and label arguments. All 84 call sites are converted, and the imports sit in the sorted position these files already followed. --- docs/automation-contract.md | 20 +- lib/core/automation/automation_id.dart | 28 ++ .../screens/community_selector_screen.dart | 135 +++-- .../community/widgets/community_card.dart | 306 ++++++------ lib/features/home/screens/home_screen.dart | 56 +-- .../home/widgets/order_list_item.dart | 415 ++++++++-------- .../key_manager/import_mnemonic_dialog.dart | 193 ++++---- .../key_manager/key_management_screen.dart | 270 +++++----- .../widgets/add_custom_node_dialog.dart | 270 +++++----- .../mostro/widgets/mostro_node_selector.dart | 239 +++++---- .../order/screens/add_order_screen.dart | 62 ++- .../screens/order_confirmation_screen.dart | 15 +- .../screens/pay_lightning_invoice_screen.dart | 37 +- .../order/screens/take_order_screen.dart | 460 +++++++++--------- .../order/widgets/action_buttons.dart | 58 ++- .../order/widgets/amount_section.dart | 64 ++- .../order/widgets/currency_section.dart | 63 ++- .../widgets/payment_methods_section.dart | 25 +- .../order/widgets/price_type_section.dart | 15 +- .../relays/widgets/relay_selector.dart | 354 +++++++------- lib/features/settings/settings_screen.dart | 140 +++--- .../trades/screens/trade_detail_screen.dart | 393 ++++++++------- .../widgets/mostro_message_detail_widget.dart | 22 +- .../trades/widgets/trades_list_item.dart | 270 +++++----- .../screens/walkthrough_screen.dart | 22 +- .../wallet/screens/connect_wallet_screen.dart | 120 +++-- .../screens/wallet_settings_screen.dart | 58 ++- .../wallet/widgets/wallet_status_card.dart | 125 +++-- .../widgets/add_lightning_invoice_widget.dart | 105 ++-- lib/shared/widgets/add_order_button.dart | 127 +++-- lib/shared/widgets/bottom_nav_bar.dart | 2 +- .../widgets/currency_selection_dialog.dart | 49 +- lib/shared/widgets/custom_drawer_overlay.dart | 39 +- lib/shared/widgets/mostro_app_bar.dart | 42 +- lib/shared/widgets/nwc_invoice_widget.dart | 97 ++-- lib/shared/widgets/nwc_payment_widget.dart | 38 +- lib/shared/widgets/order_cards.dart | 19 +- 37 files changed, 2328 insertions(+), 2425 deletions(-) diff --git a/docs/automation-contract.md b/docs/automation-contract.md index 744e010b4..deeb3609c 100644 --- a/docs/automation-contract.md +++ b/docs/automation-contract.md @@ -12,12 +12,26 @@ owners. Every actionable control and business-critical state carries a stable identifier from `lib/core/automation/automation_ids.dart`, attached with the -`AutomationId` widget (`lib/core/automation/automation_id.dart`). Flutter -exposes it as `Semantics.identifier`, which Android surfaces as 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 @@ -108,7 +122,7 @@ automation, never compiled in and never logged. ## 3. Changing the contract -1. Update `AutomationIds` and the widget, and this table, in one change. +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 diff --git a/lib/core/automation/automation_id.dart b/lib/core/automation/automation_id.dart index 28456b5b8..4ea68f9d3 100644 --- a/lib/core/automation/automation_id.dart +++ b/lib/core/automation/automation_id.dart @@ -2,6 +2,10 @@ 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 @@ -48,3 +52,27 @@ class AutomationId extends StatelessWidget { 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/features/community/screens/community_selector_screen.dart b/lib/features/community/screens/community_selector_screen.dart index b67bd738f..4362c8f59 100644 --- a/lib/features/community/screens/community_selector_screen.dart +++ b/lib/features/community/screens/community_selector_screen.dart @@ -1,10 +1,10 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; 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'; @@ -46,13 +46,10 @@ class _CommunitySelectorScreenState child: Text(S.of(ctx)!.communityDisclaimerBody), ), actions: [ - AutomationId( - AutomationIds.communityNoticeAccept, - child: TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: Text(S.of(ctx)!.communityDisclaimerAccept), - ), - ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(S.of(ctx)!.communityDisclaimerAccept), + ).withAutomationId(AutomationIds.communityNoticeAccept), ], ), ); @@ -158,74 +155,70 @@ class _CommunitySelectorScreenState ), const SizedBox(height: 12), // Use custom node - AutomationId(AutomationIds.communityCustomNode, - child: TextButton( - onPressed: _isSelecting - ? null - : () => _onUseCustomNode(context), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.settings, - size: 18, - color: AppTheme.textSecondary, - ), - const SizedBox(width: 8), - Text( - S.of(context)!.useCustomNode, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 14, - ), - ), - ], + TextButton( + onPressed: + _isSelecting ? null : () => _onUseCustomNode(context), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.settings, + size: 18, + color: AppTheme.textSecondary, ), - )), - const SizedBox(height: 8), - // Confirm button - if (_selectedPubkey != null) - AutomationId(AutomationIds.communityDone, - child: ElevatedButton( - onPressed: - _isSelecting ? null : () => _onConfirm(context), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric(vertical: 14), - ), - child: _isSelecting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.black, - ), - ) - : Text( - S.of(context)!.done, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - )), - // Skip button - AutomationId(AutomationIds.communitySkip, - child: TextButton( - onPressed: _isSelecting ? null : () => _onSkip(context), - child: Text( - S.of(context)!.skipForNow, + const SizedBox(width: 8), + Text( + S.of(context)!.useCustomNode, style: const TextStyle( color: AppTheme.textSecondary, fontSize: 14, ), ), - )), + ], + ), + ).withAutomationId(AutomationIds.communityCustomNode), + const SizedBox(height: 8), + // Confirm button + if (_selectedPubkey != null) + ElevatedButton( + onPressed: + _isSelecting ? null : () => _onConfirm(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _isSelecting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black, + ), + ) + : Text( + S.of(context)!.done, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ).withAutomationId(AutomationIds.communityDone), + // Skip button + TextButton( + onPressed: _isSelecting ? null : () => _onSkip(context), + child: Text( + S.of(context)!.skipForNow, + style: const TextStyle( + color: AppTheme.textSecondary, + 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 826d7b116..877c32862 100644 --- a/lib/features/community/widgets/community_card.dart +++ b/lib/features/community/widgets/community_card.dart @@ -1,8 +1,8 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -22,169 +22,167 @@ class CommunityCard extends StatelessWidget { @override Widget build(BuildContext context) { - return AutomationId(AutomationIds.communityCard(community.pubkey), - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: isSelected - ? AppTheme.activeColor.withValues(alpha: 0.1) - : AppTheme.backgroundCard, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? AppTheme.activeColor.withValues(alpha: 0.5) - : Colors.white.withValues(alpha: 0.1), - width: isSelected ? 2 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.activeColor.withValues(alpha: 0.1) + : AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppTheme.activeColor.withValues(alpha: 0.5) + : Colors.white.withValues(alpha: 0.1), + width: isSelected ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: avatar + name + region + Row( children: [ - // Header: avatar + name + region - Row( - children: [ - _buildAvatar(), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - community.displayName, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - community.region, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - ), - ), - ], + _buildAvatar(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + community.displayName, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), - ), - if (isSelected) - const Icon( - Icons.check_circle, - color: AppTheme.activeColor, - size: 24, + const SizedBox(height: 2), + Text( + community.region, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), ), - ], + ], + ), ), - - // About text - if (community.about != null && community.about!.isNotEmpty) ...[ - const SizedBox(height: 10), - Text( - community.about!, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - height: 1.4, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, + if (isSelected) + const Icon( + Icons.check_circle, + color: AppTheme.activeColor, + size: 24, ), - ], + ], + ), - // Currencies - if (community.currencies.isNotEmpty || - (community.hasTradeInfo && - community.currencies.isEmpty)) ...[ - const SizedBox(height: 10), - Wrap( - spacing: 6, - runSpacing: 4, - children: community.currencies.isNotEmpty - ? community.currencies.map((currency) { - return _buildCurrencyTag(currency); - }).toList() - : [ - _buildCurrencyTag( - S.of(context)!.communityAllCurrencies, - ), - ], - ), - ], + // About text + if (community.about != null && community.about!.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + community.about!, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + height: 1.4, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], - // Stats row: fee + range - if (community.fee != null || - community.minAmount != null || - community.maxAmount != null) ...[ - const SizedBox(height: 10), - Row( - children: [ - if (community.fee != null) ...[ - Icon( - Icons.percent, - size: 14, - color: AppTheme.textSecondary.withValues(alpha: 0.7), - ), - const SizedBox(width: 4), - Text( - '${S.of(context)!.communityFee} ${(community.fee! * 100).toStringAsFixed(1)}%', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), + // Currencies + if (community.currencies.isNotEmpty || + (community.hasTradeInfo && community.currencies.isEmpty)) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 4, + children: community.currencies.isNotEmpty + ? community.currencies.map((currency) { + return _buildCurrencyTag(currency); + }).toList() + : [ + _buildCurrencyTag( + S.of(context)!.communityAllCurrencies, ), ], - if (community.fee != null && - (community.minAmount != null || - community.maxAmount != null)) - const SizedBox(width: 16), - if (community.minAmount != null || - community.maxAmount != null) ...[ - Icon( - Icons.bar_chart, - size: 14, - color: AppTheme.textSecondary.withValues(alpha: 0.7), - ), - const SizedBox(width: 4), - Text( - _formatRange(context), - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - ], - ), - ], + ), + ], - // Social links - if (community.social.isNotEmpty) ...[ - const SizedBox(height: 10), - Row( - children: community.social.map((link) { - return Padding( - padding: const EdgeInsets.only(right: 12), - child: GestureDetector( - onTap: () => _launchUrl(link.url), - child: Icon( - _socialIcon(link.type), - size: 18, - color: AppTheme.textSecondary, - ), - ), - ); - }).toList(), - ), + // Stats row: fee + range + if (community.fee != null || + community.minAmount != null || + community.maxAmount != null) ...[ + const SizedBox(height: 10), + Row( + children: [ + if (community.fee != null) ...[ + Icon( + Icons.percent, + size: 14, + color: AppTheme.textSecondary.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Text( + '${S.of(context)!.communityFee} ${(community.fee! * 100).toStringAsFixed(1)}%', + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + if (community.fee != null && + (community.minAmount != null || + community.maxAmount != null)) + const SizedBox(width: 16), + if (community.minAmount != null || + community.maxAmount != null) ...[ + Icon( + Icons.bar_chart, + size: 14, + color: AppTheme.textSecondary.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Text( + _formatRange(context), + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], ], - ], - ), - ), - )); + ), + ], + + // Social links + if (community.social.isNotEmpty) ...[ + const SizedBox(height: 10), + Row( + children: community.social.map((link) { + return Padding( + padding: const EdgeInsets.only(right: 12), + child: GestureDetector( + onTap: () => _launchUrl(link.url), + child: Icon( + _socialIcon(link.type), + size: 18, + color: AppTheme.textSecondary, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ).withAutomationId(AutomationIds.communityCard(community.pubkey)); } Widget _buildCurrencyTag(String label) { diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 5a71cf825..e6858e0ed 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -1,9 +1,9 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -162,35 +162,33 @@ class HomeScreen extends ConsumerWidget { Color activeColor, ) { return Expanded( - child: AutomationId( - type == OrderType.buy - ? AutomationIds.orderBookTabBuy - : AutomationIds.orderBookTabSell, - child: InkWell( - onTap: () => ref.read(homeOrderTypeProvider.notifier).state = type, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: isActive ? activeColor : Colors.transparent, - width: 3.0, // Thicker line - ), - ), - ), - child: Text( - text, - textAlign: TextAlign.center, - style: TextStyle( - color: isActive ? activeColor : AppTheme.textInactive, - fontWeight: FontWeight.w600, // Semi-bold - fontSize: 15, - letterSpacing: 0.5, // Letter spacing - fontFamily: 'Roboto', // Assuming Roboto as font - ), + child: InkWell( + onTap: () => ref.read(homeOrderTypeProvider.notifier).state = type, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isActive ? activeColor : Colors.transparent, + width: 3.0, // Thicker line ), ), - )), + ), + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + color: isActive ? activeColor : AppTheme.textInactive, + fontWeight: FontWeight.w600, // Semi-bold + fontSize: 15, + letterSpacing: 0.5, // Letter spacing + fontFamily: 'Roboto', // Assuming Roboto as font + ), + ), + ), + ).withAutomationId(type == OrderType.buy + ? AutomationIds.orderBookTabBuy + : AutomationIds.orderBookTabSell), ); } diff --git a/lib/features/home/widgets/order_list_item.dart b/lib/features/home/widgets/order_list_item.dart index 36b2ab665..78c27d2d3 100644 --- a/lib/features/home/widgets/order_list_item.dart +++ b/lib/features/home/widgets/order_list_item.dart @@ -1,11 +1,11 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:collection/collection.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; 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'; @@ -65,246 +65,243 @@ class OrderListItem extends ConsumerWidget { child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(20), - child: AutomationId(AutomationIds.orderBookItem(order.orderId ?? ''), - merge: false, - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () { - final sessions = ref.watch(sessionNotifierProvider); - final session = sessions - .firstWhereOrNull((s) => s.orderId == order.orderId); - if (session != null && session.role != null) { - context.push('/trade_detail/${session.orderId}'); - return; - } - order.orderType == OrderType.buy - ? context.push('/take_buy/${order.orderId}') - : context.push('/take_sell/${order.orderId}'); - }, - highlightColor: Colors.white.withValues(alpha: 0.05), - splashColor: Colors.white.withValues(alpha: 0.03), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // First row: "SELLING" label and timestamp - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // SELLING/BUYING label with more contrast - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.backgroundCard, - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.6), - blurRadius: 4, - offset: const Offset(0, 2), - spreadRadius: -1, - ), - BoxShadow( - color: Colors.white.withValues(alpha: 0.08), - blurRadius: 1, - offset: const Offset(0, -1), - spreadRadius: 0, - ), - ], + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + final sessions = ref.watch(sessionNotifierProvider); + final session = + sessions.firstWhereOrNull((s) => s.orderId == order.orderId); + if (session != null && session.role != null) { + context.push('/trade_detail/${session.orderId}'); + return; + } + order.orderType == OrderType.buy + ? context.push('/take_buy/${order.orderId}') + : context.push('/take_sell/${order.orderId}'); + }, + highlightColor: Colors.white.withValues(alpha: 0.05), + splashColor: Colors.white.withValues(alpha: 0.03), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // First row: "SELLING" label and timestamp + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // SELLING/BUYING label with more contrast + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.6), + blurRadius: 4, + offset: const Offset(0, 2), + spreadRadius: -1, + ), + BoxShadow( + color: Colors.white.withValues(alpha: 0.08), + blurRadius: 1, + offset: const Offset(0, -1), + spreadRadius: 0, ), - child: Text( - orderLabel, - style: const TextStyle( - color: Colors.white70, - fontSize: 12, - fontWeight: FontWeight.w500, - ), + ], + ), + child: Text( + orderLabel, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + + // Timestamp + Text( + order.timeAgoWithLocale( + Localizations.localeOf(context).languageCode) ?? + S.of(context)!.hoursAgo('9'), + style: const TextStyle( + color: Colors.white60, + fontSize: 14, + ), + ), + ], + ), + ), + + // Second row: Amount and currency with flag and percentage (if not fixed) + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + // Large amount with more contrast + Text( + order.fiatAmount.toString(), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1.1, ), ), + const SizedBox(width: 8), - // Timestamp + // Currency code and flag Text( - order.timeAgoWithLocale( - Localizations.localeOf(context) - .languageCode) ?? - S.of(context)!.hoursAgo('9'), + '${order.currency ?? "CUP"} ', style: const TextStyle( - color: Colors.white60, - fontSize: 14, + fontSize: 18, + color: Colors.white, ), ), + Text( + () { + final String currencyCode = order.currency ?? 'CUP'; + return CurrencyUtils.getFlagFromCurrencyData( + currencyCode, currencyData); + }(), + style: const TextStyle(fontSize: 18), + ), + const SizedBox(width: 4), ], ), - ), - - // Second row: Amount and currency with flag and percentage (if not fixed) - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - // Large amount with more contrast - Text( - order.fiatAmount.toString(), - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Colors.white, - height: 1.1, - ), - ), - const SizedBox(width: 8), - // Currency code and flag - Text( - '${order.currency ?? "CUP"} ', - style: const TextStyle( - fontSize: 18, - color: Colors.white, + // Display sats amount for all orders (simplified) + Padding( + padding: const EdgeInsets.only(top: 4), + child: isFixedOrder + ? Text( + S.of(context)!.forSats(order.amount!), + style: TextStyle( + fontSize: 14, + color: Colors.white70, + fontWeight: FontWeight.w500, ), - ), - Text( - () { - final String currencyCode = - order.currency ?? 'CUP'; - return CurrencyUtils.getFlagFromCurrencyData( - currencyCode, currencyData); - }(), - style: const TextStyle(fontSize: 18), - ), - const SizedBox(width: 4), - ], - ), - - // Display sats amount for all orders (simplified) - Padding( - padding: const EdgeInsets.only(top: 4), - child: isFixedOrder + ) + : premiumValue == 0 ? Text( - S.of(context)!.forSats(order.amount!), + S.of(context)!.marketPrice, style: TextStyle( fontSize: 14, color: Colors.white70, fontWeight: FontWeight.w500, ), ) - : premiumValue == 0 - ? Text( - S.of(context)!.marketPrice, + : Row( + children: [ + Text( + '${S.of(context)!.marketPrice} ', style: TextStyle( fontSize: 14, color: Colors.white70, fontWeight: FontWeight.w500, ), - ) - : Row( - children: [ - Text( - '${S.of(context)!.marketPrice} ', - style: TextStyle( - fontSize: 14, - color: Colors.white70, - fontWeight: FontWeight.w500, - ), - ), - Text( - premiumText, - style: TextStyle( - fontSize: 14, - color: premiumColor, - fontWeight: FontWeight.w500, - ), - ), - ], ), - ), - ], + Text( + premiumText, + style: TextStyle( + fontSize: 14, + color: premiumColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), - ), + ], + ), + ), - // Third row: Payment method - Container( - margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), - width: double.infinity, - padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 14), - decoration: BoxDecoration( - color: AppTheme.backgroundCard, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.7), - blurRadius: 6, - offset: const Offset(0, 3), - spreadRadius: -2, - ), - BoxShadow( - color: Colors.white.withValues(alpha: 0.08), - blurRadius: 1, - offset: const Offset(0, -1), - spreadRadius: 0, - ), - ], + // Third row: Payment method + Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), + width: double.infinity, + padding: + const EdgeInsets.symmetric(vertical: 12, horizontal: 14), + decoration: BoxDecoration( + color: AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.7), + blurRadius: 6, + offset: const Offset(0, 3), + spreadRadius: -2, ), - child: Row( - children: [ - // Emoji for payment method - const Text( - '💳 ', // Default emoji - style: TextStyle(fontSize: 16), - ), - Expanded( - child: Text( - order.paymentMethods.isNotEmpty - ? order.paymentMethods.join(', ') - : 'tm', - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), + BoxShadow( + color: Colors.white.withValues(alpha: 0.08), + blurRadius: 1, + offset: const Offset(0, -1), + spreadRadius: 0, + ), + ], + ), + child: Row( + children: [ + // Emoji for payment method + const Text( + '💳 ', // Default emoji + style: TextStyle(fontSize: 16), + ), + Expanded( + child: Text( + order.paymentMethods.isNotEmpty + ? order.paymentMethods.join(', ') + : 'tm', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, ), - ], + overflow: TextOverflow.ellipsis, + ), ), - ), + ], + ), + ), - // Fourth row: Rating with stars - Container( - margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), - padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 14), - decoration: BoxDecoration( - color: AppTheme.backgroundCard, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.7), - blurRadius: 6, - offset: const Offset(0, 3), - spreadRadius: -2, - ), - BoxShadow( - color: Colors.white.withValues(alpha: 0.08), - blurRadius: 1, - offset: const Offset(0, -1), - spreadRadius: 0, - ), - ], + // Fourth row: Rating with stars + Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + padding: + const EdgeInsets.symmetric(vertical: 12, horizontal: 14), + decoration: BoxDecoration( + color: AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.7), + blurRadius: 6, + offset: const Offset(0, 3), + spreadRadius: -2, ), - child: _buildRatingRow(context, order), - ), - ], + BoxShadow( + color: Colors.white.withValues(alpha: 0.08), + blurRadius: 1, + offset: const Offset(0, -1), + spreadRadius: 0, + ), + ], + ), + child: _buildRatingRow(context, order), ), - )), + ], + ), + ).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 1a54a3bd4..f9ff91fb4 100644 --- a/lib/features/key_manager/import_mnemonic_dialog.dart +++ b/lib/features/key_manager/import_mnemonic_dialog.dart @@ -1,7 +1,7 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -90,109 +90,106 @@ class _ImportMnemonicDialogState extends State { ), ), const SizedBox(height: 8), - AutomationId(AutomationIds.keysImportMnemonic, - child: TextField( - controller: _mnemonicController, - minLines: 4, - maxLines: 6, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 14, + TextField( + controller: _mnemonicController, + minLines: 4, + maxLines: 6, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, + ), + decoration: InputDecoration( + hintText: S.of(context)!.secretWordsPlaceholder, + hintStyle: TextStyle( + color: AppTheme.textSecondary.withValues(alpha: 0.5), + fontSize: 14, + ), + filled: true, + fillColor: AppTheme.backgroundInput, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.white.withValues(alpha: 0.1), ), - decoration: InputDecoration( - hintText: S.of(context)!.secretWordsPlaceholder, - hintStyle: TextStyle( - color: AppTheme.textSecondary.withValues(alpha: 0.5), - fontSize: 14, - ), - filled: true, - fillColor: AppTheme.backgroundInput, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Colors.white.withValues(alpha: 0.1), - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Colors.white.withValues(alpha: 0.1), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide( - color: AppTheme.activeColor, - width: 2, - ), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide( - color: Colors.red, - width: 2, - ), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide( - color: Colors.red, - width: 2, - ), - ), - errorText: _errorMessage, - errorStyle: const TextStyle( - color: Colors.red, - fontSize: 12, - ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Colors.white.withValues(alpha: 0.1), ), - onChanged: (_) { - if (_errorMessage != null) { - setState(() { - _errorMessage = null; - }); - } - }, - )), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: AppTheme.activeColor, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Colors.red, + width: 2, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Colors.red, + width: 2, + ), + ), + errorText: _errorMessage, + errorStyle: const TextStyle( + color: Colors.red, + fontSize: 12, + ), + ), + onChanged: (_) { + if (_errorMessage != null) { + setState(() { + _errorMessage = null; + }); + } + }, + ).withAutomationId(AutomationIds.keysImportMnemonic), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - AutomationId(AutomationIds.keysImportCancel, - child: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - S.of(context)!.cancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - )), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ).withAutomationId(AutomationIds.keysImportCancel), const SizedBox(width: 12), - AutomationId(AutomationIds.keysImportConfirm, - child: ElevatedButton( - onPressed: _handleImport, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), - ), - child: Text( - S.of(context)!.importMostroUser, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - )), + ElevatedButton( + onPressed: _handleImport, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.importMostroUser, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ).withAutomationId(AutomationIds.keysImportConfirm), ], ), ], diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index 28c6be227..fd549b94d 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -1,5 +1,3 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,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'; @@ -137,16 +137,13 @@ class _KeyManagementScreenState extends ConsumerState { appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, - leading: AutomationId( - AutomationIds.appBarBack, - child: IconButton( - icon: const HeroIcon( - HeroIcons.arrowLeft, - color: AppTheme.textPrimary, - ), - onPressed: () => context.pop(), + leading: IconButton( + icon: const HeroIcon( + HeroIcons.arrowLeft, + color: AppTheme.textPrimary, ), - ), + onPressed: () => context.pop(), + ).withAutomationId(AutomationIds.appBarBack), title: Text( S.of(context)!.account, style: const TextStyle( @@ -237,17 +234,14 @@ class _KeyManagementScreenState extends ConsumerState { const Icon(LucideIcons.user, color: AppTheme.activeColor, size: 20), const SizedBox(width: 8), Expanded( - child: AutomationId( - AutomationIds.keysPublicKey, - child: SelectableText( - npub, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 12, - fontFamily: 'monospace', - ), + child: SelectableText( + npub, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 12, + fontFamily: 'monospace', ), - ), + ).withAutomationId(AutomationIds.keysPublicKey), ), ], ), @@ -321,54 +315,52 @@ class _KeyManagementScreenState extends ConsumerState { ), child: Column( children: [ - AutomationId(AutomationIds.keysSeedText, - child: SelectableText( - _showSecretWords - ? _mnemonic ?? '' - : _mnemonic != null - ? _maskSeedPhrase(_mnemonic!) - : '', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 14, - fontFamily: 'monospace', - ), - )), + SelectableText( + _showSecretWords + ? _mnemonic ?? '' + : _mnemonic != null + ? _maskSeedPhrase(_mnemonic!) + : '', + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, + fontFamily: 'monospace', + ), + ).withAutomationId(AutomationIds.keysSeedText), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - AutomationId(AutomationIds.keysSeedReveal, - child: TextButton.icon( - onPressed: () { - setState(() { - _showSecretWords = !_showSecretWords; - }); - // Dismiss backup reminder when user views seed phrase - if (_showSecretWords) { - ref - .read(backupReminderProvider.notifier) - .dismissBackupReminder(); - } - }, - icon: Icon( - _showSecretWords - ? LucideIcons.eyeOff - : LucideIcons.eye, - size: 16, - color: AppTheme.activeColor, - ), - label: Text( - _showSecretWords - ? S.of(context)!.hide - : S.of(context)!.show, - style: const TextStyle( - color: AppTheme.activeColor, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - )), + TextButton.icon( + onPressed: () { + setState(() { + _showSecretWords = !_showSecretWords; + }); + // Dismiss backup reminder when user views seed phrase + if (_showSecretWords) { + ref + .read(backupReminderProvider.notifier) + .dismissBackupReminder(); + } + }, + icon: Icon( + _showSecretWords + ? LucideIcons.eyeOff + : LucideIcons.eye, + size: 16, + color: AppTheme.activeColor, + ), + label: Text( + _showSecretWords + ? S.of(context)!.hide + : S.of(context)!.show, + style: const TextStyle( + color: AppTheme.activeColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ).withAutomationId(AutomationIds.keysSeedReveal), ], ), ], @@ -555,77 +547,75 @@ class _KeyManagementScreenState extends ConsumerState { Widget _buildGenerateNewUserButton(BuildContext context) { return SizedBox( width: double.infinity, - child: AutomationId(AutomationIds.keysGenerate, - child: ElevatedButton( - onPressed: () => _showGenerateNewUserDialog(context), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + child: ElevatedButton( + onPressed: () => _showGenerateNewUserDialog(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.userPlus, + size: 20, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - LucideIcons.userPlus, - size: 20, - ), - const SizedBox(width: 8), - Flexible( - child: Text( - S.of(context)!.generateNewUser, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.visible, - softWrap: true, - ), + const SizedBox(width: 8), + Flexible( + child: Text( + S.of(context)!.generateNewUser, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, ), - ], + overflow: TextOverflow.visible, + softWrap: true, + ), ), - )), + ], + ), + ).withAutomationId(AutomationIds.keysGenerate), ); } Widget _buildImportUserButton(BuildContext context) { - return AutomationId(AutomationIds.keysImport, - child: OutlinedButton( - onPressed: () => _showImportMnemonicDialog(context), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: AppTheme.activeColor), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 16), + return OutlinedButton( + onPressed: () => _showImportMnemonicDialog(context), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: AppTheme.activeColor), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + LucideIcons.download, + size: 20, + color: AppTheme.activeColor, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - LucideIcons.download, - size: 20, + const SizedBox(width: 8), + Flexible( + child: Text( + S.of(context)!.importMostroUser, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, color: AppTheme.activeColor, ), - const SizedBox(width: 8), - Flexible( - child: Text( - S.of(context)!.importMostroUser, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: AppTheme.activeColor, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], + overflow: TextOverflow.ellipsis, + ), ), - )); + ], + ), + ).withAutomationId(AutomationIds.keysImport); } Widget _buildRefreshUserButton(BuildContext context) { @@ -794,25 +784,20 @@ class _KeyManagementScreenState extends ConsumerState { ), ), actions: [ - AutomationId( - AutomationIds.keysGenerateCancel, - child: TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text( - S.of(context)!.cancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, ), + textAlign: TextAlign.center, ), - ), + ).withAutomationId(AutomationIds.keysGenerateCancel), const SizedBox(width: 12), - AutomationId( - AutomationIds.keysGenerateConfirm, - child: ElevatedButton( + ElevatedButton( onPressed: () { Navigator.of(dialogContext).pop(); _generateNewMasterKey(); @@ -834,8 +819,7 @@ class _KeyManagementScreenState extends ConsumerState { ), textAlign: TextAlign.center, ), - ), - ), + ).withAutomationId(AutomationIds.keysGenerateConfirm), ], ); }, diff --git a/lib/features/mostro/widgets/add_custom_node_dialog.dart b/lib/features/mostro/widgets/add_custom_node_dialog.dart index 726e04473..0f5472bc5 100644 --- a/lib/features/mostro/widgets/add_custom_node_dialog.dart +++ b/lib/features/mostro/widgets/add_custom_node_dialog.dart @@ -1,10 +1,10 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -112,26 +112,24 @@ class _AddCustomNodeDialogContentState color: Colors.white.withValues(alpha: 0.1), ), ), - child: AutomationId(AutomationIds.nodeCustomPubkey, - child: TextField( - controller: pubkeyController, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - 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), - errorText: errorMessage, - errorStyle: const TextStyle(color: Colors.red), - ), - autofocus: true, - )), + child: TextField( + controller: pubkeyController, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: InputDecoration( + 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), + errorText: errorMessage, + errorStyle: const TextStyle(color: Colors.red), + ), + autofocus: true, + ).withAutomationId(AutomationIds.nodeCustomPubkey), ), const SizedBox(height: 12), // Name input @@ -147,136 +145,132 @@ class _AddCustomNodeDialogContentState color: Colors.white.withValues(alpha: 0.1), ), ), - child: AutomationId(AutomationIds.nodeCustomName, - child: TextField( - controller: nameController, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - 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), - ), - )), + child: TextField( + controller: nameController, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: InputDecoration( + 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), + ), + ).withAutomationId(AutomationIds.nodeCustomName), ), ], ), ), actions: [ - AutomationId(AutomationIds.nodeCustomCancel, - child: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - S.of(context)!.cancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - )), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ).withAutomationId(AutomationIds.nodeCustomCancel), const SizedBox(width: 12), - AutomationId(AutomationIds.nodeCustomConfirm, - child: ElevatedButton( - onPressed: () async { - final input = pubkeyController.text.trim(); - final name = nameController.text.trim(); - final localizations = S.of(context)!; - final navigator = Navigator.of(context); + ElevatedButton( + onPressed: () async { + final input = pubkeyController.text.trim(); + final name = nameController.text.trim(); + final localizations = S.of(context)!; + final navigator = Navigator.of(context); - if (input.isEmpty) { - setState(() { - errorMessage = localizations.pubkeyRequired; - }); - return; - } + if (input.isEmpty) { + setState(() { + errorMessage = localizations.pubkeyRequired; + }); + return; + } - // Reject nsec private keys - if (input.startsWith('nsec')) { - setState(() { - errorMessage = localizations.invalidPubkeyFormat; - }); - return; - } + // Reject nsec private keys + if (input.startsWith('nsec')) { + setState(() { + errorMessage = localizations.invalidPubkeyFormat; + }); + return; + } - // Convert npub to hex if needed, then normalize - String hexPubkey; - try { - hexPubkey = - AddCustomNodeDialog._convertToHex(input).toLowerCase(); - } catch (_) { - setState(() { - errorMessage = localizations.invalidPubkeyFormat; - }); - return; - } + // Convert npub to hex if needed, then normalize + String hexPubkey; + try { + hexPubkey = + AddCustomNodeDialog._convertToHex(input).toLowerCase(); + } catch (_) { + setState(() { + errorMessage = localizations.invalidPubkeyFormat; + }); + return; + } - // Validate hex format - if (!MostroNode.isValidHexPubkey(hexPubkey)) { - setState(() { - errorMessage = localizations.invalidPubkeyFormat; - }); - return; - } + // Validate hex format + if (!MostroNode.isValidHexPubkey(hexPubkey)) { + setState(() { + errorMessage = localizations.invalidPubkeyFormat; + }); + return; + } - // Check duplicates - final nodes = widget.ref.read(mostroNodesProvider); - if (nodes.any((n) => n.pubkey == hexPubkey)) { - setState(() { - errorMessage = localizations.nodeAlreadyExists; - }); - return; - } + // Check duplicates + final nodes = widget.ref.read(mostroNodesProvider); + if (nodes.any((n) => n.pubkey == hexPubkey)) { + setState(() { + errorMessage = localizations.nodeAlreadyExists; + }); + return; + } - final notifier = widget.ref.read(mostroNodesProvider.notifier); - final added = await notifier.addCustomNode( - hexPubkey, - name: name.isEmpty ? null : name, - ); + final notifier = widget.ref.read(mostroNodesProvider.notifier); + final added = await notifier.addCustomNode( + hexPubkey, + name: name.isEmpty ? null : name, + ); - if (added) { - // Fire-and-forget: errors are logged inside fetchNodeMetadata - unawaited(notifier.fetchNodeMetadata(hexPubkey)); + if (added) { + // Fire-and-forget: errors are logged inside fetchNodeMetadata + unawaited(notifier.fetchNodeMetadata(hexPubkey)); - navigator.pop(); - SnackBarHelper.showTopSnackBarAsync( - messenger: widget.parentMessenger, - screenHeight: widget.screenHeight, - statusBarHeight: widget.statusBarPadding, - message: localizations.nodeAddedSuccess, - backgroundColor: Colors.green, - ); - } else { - setState(() { - errorMessage = localizations.nodeAlreadyExists; - }); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - ), - child: Text( - S.of(context)!.addCustomNode, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - )), + navigator.pop(); + SnackBarHelper.showTopSnackBarAsync( + messenger: widget.parentMessenger, + screenHeight: widget.screenHeight, + statusBarHeight: widget.statusBarPadding, + message: localizations.nodeAddedSuccess, + backgroundColor: Colors.green, + ); + } else { + setState(() { + errorMessage = localizations.nodeAlreadyExists; + }); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.addCustomNode, + style: const TextStyle( + fontSize: 16, + 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 5173d2a6d..b247671b6 100644 --- a/lib/features/mostro/widgets/mostro_node_selector.dart +++ b/lib/features/mostro/widgets/mostro_node_selector.dart @@ -1,8 +1,8 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -125,30 +125,29 @@ class _MostroNodeSelectorState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - AutomationId(AutomationIds.nodeAddCustom, - child: ElevatedButton( - onPressed: _isSwitching - ? null - : () => AddCustomNodeDialog.show(context, ref), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - child: Text( - S.of(context)!.addCustomNode, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - )), + ElevatedButton( + onPressed: _isSwitching + ? null + : () => AddCustomNodeDialog.show(context, ref), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.addCustomNode, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ).withAutomationId(AutomationIds.nodeAddCustom), ], ), SizedBox( @@ -187,115 +186,111 @@ class _MostroNodeSelectorState extends ConsumerState { margin: const EdgeInsets.only(bottom: 8), child: Material( color: Colors.transparent, - child: AutomationId(AutomationIds.nodeItem(node.pubkey), - merge: false, - child: InkWell( - onTap: - (_isSwitching || isSelected) ? null : () => _onNodeTap(node), + child: InkWell( + onTap: (_isSwitching || isSelected) ? null : () => _onNodeTap(node), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.activeColor.withValues(alpha: 0.1) + : AppTheme.backgroundCard, borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: isSelected - ? AppTheme.activeColor.withValues(alpha: 0.1) - : AppTheme.backgroundCard, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? AppTheme.activeColor.withValues(alpha: 0.4) - : Colors.white.withValues(alpha: 0.1), - ), - ), - child: Row( - children: [ - // Avatar - MostroNodeAvatar(node: node), - const SizedBox(width: 12), - // Name + pubkey - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + border: Border.all( + color: isSelected + ? AppTheme.activeColor.withValues(alpha: 0.4) + : Colors.white.withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + // Avatar + MostroNodeAvatar(node: node), + const SizedBox(width: 12), + // Name + pubkey + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Row( - children: [ - Flexible( - child: Text( - node.displayName, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (node.isTrusted) ...[ - const SizedBox(width: 8), - const TrustedBadge(), - ], - ], - ), - const SizedBox(height: 2), - Text( - node.pubkey, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (node.about != null && node.about!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - node.about!.length > 128 - ? '${node.about!.substring(0, 128)}...' - : node.about!, + Flexible( + child: Text( + node.displayName, style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, + color: AppTheme.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, ), - maxLines: 3, overflow: TextOverflow.ellipsis, ), + ), + if (node.isTrusted) ...[ + const SizedBox(width: 8), + const TrustedBadge(), ], ], ), - ), - // Trailing: checkmark, loading, or delete - if (isLoading) - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(AppTheme.cream1), - ), - ) - else if (isSelected) - const Icon( - Icons.check_circle, - color: AppTheme.activeColor, - size: 22, - ) - else if (isCustom) - IconButton( - onPressed: - _isSwitching ? null : () => _onDeleteNode(node), - tooltip: S.of(context)!.deleteCustomNodeTitle, - icon: const Icon( - Icons.delete_outline, + const SizedBox(height: 2), + Text( + node.pubkey, + style: const TextStyle( color: AppTheme.textSecondary, - size: 20, + fontSize: 12, ), - constraints: const BoxConstraints(), - padding: const EdgeInsets.all(4), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], + if (node.about != null && node.about!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + node.about!.length > 128 + ? '${node.about!.substring(0, 128)}...' + : node.about!, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), ), - ), - )), + // Trailing: checkmark, loading, or delete + if (isLoading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(AppTheme.cream1), + ), + ) + else if (isSelected) + const Icon( + Icons.check_circle, + color: AppTheme.activeColor, + size: 22, + ) + else if (isCustom) + IconButton( + onPressed: _isSwitching ? null : () => _onDeleteNode(node), + tooltip: S.of(context)!.deleteCustomNodeTitle, + icon: const Icon( + Icons.delete_outline, + color: AppTheme.textSecondary, + size: 20, + ), + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(4), + ), + ], + ), + ), + ).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 63de07f80..aeda024f9 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -1,5 +1,3 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -7,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'; @@ -376,38 +376,32 @@ class _AddOrderScreenState extends ConsumerState { color: AppTheme.yellow, size: 18), iconBackgroundColor: AppTheme.yellow.withValues(alpha: 0.3), - child: AutomationId( - AutomationIds.orderCreateSatsAmount, - child: TextFormField( - controller: _satsAmountController, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - border: InputBorder.none, - hintText: S.of(context)!.enterSatsAmount, - hintStyle: - const TextStyle(color: Colors.grey), - ), - keyboardType: TextInputType.number, - validator: (value) { - if (!_marketRate && - (value == null || value.isEmpty)) { - return S - .of(context)! - .pleaseEnterSatsAmount; - } - if (!_marketRate && - !RegExp(r'^[0-9]+$').hasMatch(value!)) { - return S - .of(context)! - .pleaseEnterNumbersOnly; - } - return null; - }, - // Restricting input to numbers only - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - )), + child: TextFormField( + controller: _satsAmountController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + border: InputBorder.none, + hintText: S.of(context)!.enterSatsAmount, + hintStyle: const TextStyle(color: Colors.grey), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (!_marketRate && + (value == null || value.isEmpty)) { + return S.of(context)!.pleaseEnterSatsAmount; + } + if (!_marketRate && + !RegExp(r'^[0-9]+$').hasMatch(value!)) { + return S.of(context)!.pleaseEnterNumbersOnly; + } + return null; + }, + // Restricting input to numbers only + 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 7edb17db8..847ff27cd 100644 --- a/lib/features/order/screens/order_confirmation_screen.dart +++ b/lib/features/order/screens/order_confirmation_screen.dart @@ -1,9 +1,9 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -34,12 +34,11 @@ class OrderConfirmationScreen extends ConsumerWidget { textAlign: TextAlign.center, ), const SizedBox(height: 16), - AutomationId(AutomationIds.orderConfirmHome, - child: ElevatedButton( - key: const Key('homeButton'), - onPressed: () => context.go('/'), - child: Text(S.of(context)!.backToHome), - )), + ElevatedButton( + 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 d38680c82..305f36bca 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -1,9 +1,9 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -70,12 +70,10 @@ class _PayLightningInvoiceScreenState // 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. - AutomationId( - AutomationIds.payInvoiceText, - merge: false, - label: lnInvoice, - child: const SizedBox(width: 1, height: 1), - ), + const SizedBox(width: 1, height: 1).withAutomationId( + AutomationIds.payInvoiceText, + merge: false, + label: lnInvoice), NwcPaymentWidget( lnInvoice: lnInvoice, sats: sats, @@ -92,18 +90,17 @@ class _PayLightningInvoiceScreenState Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - AutomationId(AutomationIds.payCancel, - child: ElevatedButton( - onPressed: () async { - context.go('/'); - await orderNotifier.cancelOrder(); - }, - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.red, - ), - child: Text(S.of(context)!.cancel), - )), + ElevatedButton( + onPressed: () async { + context.go('/'); + await orderNotifier.cancelOrder(); + }, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + 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 ebe0978f4..0c76629f3 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -1,11 +1,11 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; 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'; @@ -256,264 +256,246 @@ class _TakeOrderScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( - child: AutomationId(AutomationIds.orderTakeClose, - child: OutlinedButton( - onPressed: () => context.pop(), - style: AppTheme.theme.outlinedButtonTheme.style, - child: Text(S.of(context)!.close), - )), + child: OutlinedButton( + onPressed: () => context.pop(), + style: AppTheme.theme.outlinedButtonTheme.style, + child: Text(S.of(context)!.close), + ).withAutomationId(AutomationIds.orderTakeClose), ), const SizedBox(width: 16), Expanded( - child: AutomationId(AutomationIds.orderTakeConfirm, - child: ElevatedButton( - onPressed: _isSubmitting - ? null - : () async { - setState(() { - _isSubmitting = true; - }); - // Check if this is a range order - if (order.fiatAmount.maximum != null && - order.fiatAmount.minimum != - order.fiatAmount.maximum) { - // Show dialog to get the amount - String? errorText; - final enteredAmount = await showDialog( - context: context, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - return AlertDialog( - backgroundColor: AppTheme.backgroundCard, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide( - color: - Colors.white.withValues(alpha: 0.1), + child: ElevatedButton( + onPressed: _isSubmitting + ? null + : () async { + setState(() { + _isSubmitting = true; + }); + // Check if this is a range order + if (order.fiatAmount.maximum != null && + order.fiatAmount.minimum != order.fiatAmount.maximum) { + // Show dialog to get the amount + String? errorText; + final enteredAmount = await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + backgroundColor: AppTheme.backgroundCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + ), + ), + title: Text( + S.of(context)!.enterAmount, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + content: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.white.withValues( + alpha: 0.1, ), ), - title: Text( - S.of(context)!.enterAmount, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), + ), + child: TextField( + controller: widget._fiatAmountController, + keyboardType: TextInputType.number, + style: const TextStyle( + color: AppTheme.textPrimary, ), - content: Container( - padding: const EdgeInsets.symmetric( + decoration: InputDecoration( + hintText: S + .of(context)! + .enterAmountBetween( + order.fiatAmount.minimum.toString(), + order.fiatAmount.maximum.toString(), + order.currency ?? '', + ), + hintStyle: const TextStyle( + color: AppTheme.textSecondary, + ), + errorText: errorText, + errorStyle: const TextStyle( + color: AppTheme.statusError, + ), + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), - decoration: BoxDecoration( - color: AppTheme.backgroundInput, + ), + ).withAutomationId( + AutomationIds.orderTakeAmount), + ), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + ElevatedButton( + key: const Key('submitAmountButton'), + onPressed: () { + final inputAmount = int.tryParse( + widget._fiatAmountController.text + .trim(), + ); + if (inputAmount == null) { + setState(() { + errorText = S + .of(context)! + .pleaseEnterValidNumber; + }); + } else if (inputAmount < + order.fiatAmount.minimum || + (order.fiatAmount.maximum != null && + inputAmount > + order.fiatAmount.maximum!)) { + setState(() { + errorText = S + .of(context)! + .amountMustBeBetween( + order.fiatAmount.minimum + .toString(), + order.fiatAmount.maximum + .toString(), + ); + }); + } else { + context.pop(inputAmount); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.white.withValues( - alpha: 0.1, - ), - ), ), - child: AutomationId( - AutomationIds.orderTakeAmount, - child: TextField( - controller: - widget._fiatAmountController, - keyboardType: TextInputType.number, - style: const TextStyle( - color: AppTheme.textPrimary, - ), - decoration: InputDecoration( - hintText: S - .of(context)! - .enterAmountBetween( - order.fiatAmount.minimum - .toString(), - order.fiatAmount.maximum - .toString(), - order.currency ?? '', - ), - hintStyle: const TextStyle( - color: AppTheme.textSecondary, - ), - errorText: errorText, - errorStyle: const TextStyle( - color: AppTheme.statusError, - ), - border: InputBorder.none, - contentPadding: - const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - )), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), ), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text( - S.of(context)!.cancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), + child: Text( + S.of(context)!.submit, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 12), - AutomationId( - AutomationIds.orderTakeAmountConfirm, - child: ElevatedButton( - key: - const Key('submitAmountButton'), - onPressed: () { - final inputAmount = int.tryParse( - widget - ._fiatAmountController.text - .trim(), - ); - if (inputAmount == null) { - setState(() { - errorText = S - .of(context)! - .pleaseEnterValidNumber; - }); - } else if (inputAmount < - order - .fiatAmount.minimum || - (order.fiatAmount.maximum != - null && - inputAmount > - order.fiatAmount - .maximum!)) { - setState(() { - errorText = S - .of(context)! - .amountMustBeBetween( - order.fiatAmount.minimum - .toString(), - order.fiatAmount.maximum - .toString(), - ); - }); - } else { - context.pop(inputAmount); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: - AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(8), - ), - padding: - const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - ), - child: Text( - S.of(context)!.submit, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - )), - ], - ); - }, + ), + ).withAutomationId( + AutomationIds.orderTakeAmountConfirm), + ], ); }, ); - - if (enteredAmount != null) { - try { - if (widget.orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder( - order.orderId!, - enteredAmount, - ); - } else { - final lndAddress = - widget._lndAddressController.text.trim(); - await orderDetailsNotifier.takeSellOrder( - order.orderId!, - enteredAmount, - lndAddress.isEmpty ? null : lndAddress, - ); - } - } catch (e, stackTrace) { - logger.e( - 'Failed to take order', - error: e, - stackTrace: stackTrace, - ); - if (!mounted) return; - setState(() { - _isSubmitting = false; - }); - } + }, + ); + + if (enteredAmount != null) { + try { + if (widget.orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, + enteredAmount, + ); } else { - // Dialog was dismissed without entering amount, reset loading state - if (!mounted) return; - setState(() { - _isSubmitting = false; - }); + final lndAddress = + widget._lndAddressController.text.trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + enteredAmount, + lndAddress.isEmpty ? null : lndAddress, + ); } + } catch (e, stackTrace) { + logger.e( + 'Failed to take order', + error: e, + stackTrace: stackTrace, + ); + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } + } else { + // Dialog was dismissed without entering amount, reset loading state + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } + } else { + // Not a range order – use the existing logic. + final fiatAmount = int.tryParse( + widget._fiatAmountController.text.trim(), + ); + try { + if (widget.orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, + fiatAmount, + ); } else { - // Not a range order – use the existing logic. - final fiatAmount = int.tryParse( - widget._fiatAmountController.text.trim(), + final lndAddress = + widget._lndAddressController.text.trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + fiatAmount, + lndAddress.isEmpty ? null : lndAddress, ); - try { - if (widget.orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder( - order.orderId!, - fiatAmount, - ); - } else { - final lndAddress = - widget._lndAddressController.text.trim(); - await orderDetailsNotifier.takeSellOrder( - order.orderId!, - fiatAmount, - lndAddress.isEmpty ? null : lndAddress, - ); - } - } catch (e, stackTrace) { - logger.e( - 'Failed to take order', - error: e, - stackTrace: stackTrace, - ); - if (!mounted) return; - setState(() { - _isSubmitting = false; - }); - } } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: _isSubmitting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(Colors.white), - ), - ) - : Text(buttonText), - )), + } catch (e, stackTrace) { + logger.e( + 'Failed to take order', + error: e, + stackTrace: stackTrace, + ); + if (!mounted) return; + setState(() { + _isSubmitting = false; + }); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text(buttonText), + ).withAutomationId(AutomationIds.orderTakeConfirm), ), ], ); diff --git a/lib/features/order/widgets/action_buttons.dart b/lib/features/order/widgets/action_buttons.dart index d83356a9c..1fb6b624d 100644 --- a/lib/features/order/widgets/action_buttons.dart +++ b/lib/features/order/widgets/action_buttons.dart @@ -1,7 +1,7 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -26,40 +26,38 @@ class ActionButtons extends StatelessWidget { Expanded( child: SizedBox( height: 48, - child: AutomationId(AutomationIds.orderCreateCancel, - child: ElevatedButton( - onPressed: onCancel, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.backgroundCard, - foregroundColor: Colors.white, - padding: - const EdgeInsets.symmetric(vertical: 12, horizontal: 0), - ), - child: Text(S.of(context)!.cancel), - )), + child: ElevatedButton( + onPressed: onCancel, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.backgroundCard, + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric(vertical: 12, horizontal: 0), + ), + child: Text(S.of(context)!.cancel), + ).withAutomationId(AutomationIds.orderCreateCancel), ), ), const SizedBox(width: 12), Expanded( child: SizedBox( height: 48, - child: AutomationId(AutomationIds.orderCreateSubmit, - child: MostroReactiveButton( - key: const Key('submitOrderButton'), - label: S.of(context)!.submit, - buttonStyle: ButtonStyleType.raised, - orderId: currentRequestId?.toString() ?? '', - action: nostr_action.Action.newOrder, - onPressed: onSubmit, - timeout: const Duration(seconds: 10), - showSuccessIndicator: onSubmit != - null, // Only show success indicator when enabled - backgroundColor: onSubmit != null - ? AppTheme.purpleButton - : AppTheme.backgroundInactive, - foregroundColor: - onSubmit != null ? Colors.white : AppTheme.textInactive, - )), + child: MostroReactiveButton( + key: const Key('submitOrderButton'), + label: S.of(context)!.submit, + buttonStyle: ButtonStyleType.raised, + orderId: currentRequestId?.toString() ?? '', + action: nostr_action.Action.newOrder, + onPressed: onSubmit, + timeout: const Duration(seconds: 10), + showSuccessIndicator: + onSubmit != null, // Only show success indicator when enabled + backgroundColor: onSubmit != null + ? AppTheme.purpleButton + : 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 1fd6248ce..98298bfa5 100644 --- a/lib/features/order/widgets/amount_section.dart +++ b/lib/features/order/widgets/amount_section.dart @@ -1,8 +1,8 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -212,21 +212,20 @@ class _AmountSectionState extends State { // Min amount input Expanded( flex: _showSecondInput ? 2 : 1, - child: AutomationId(AutomationIds.orderCreateFiatAmount, - child: TextFormField( - key: const Key('minAmountField'), - controller: _minAmountController, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - border: InputBorder.none, - hintText: S.of(context)!.enterAmountHint, - hintStyle: const TextStyle(color: Colors.grey), - ), - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - validator: _validateMinAmount, - onChanged: (_) => _notifyAmountChanged(), - )), + child: TextFormField( + key: const Key('minAmountField'), + controller: _minAmountController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + border: InputBorder.none, + hintText: S.of(context)!.enterAmountHint, + hintStyle: const TextStyle(color: Colors.grey), + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + validator: _validateMinAmount, + onChanged: (_) => _notifyAmountChanged(), + ).withAutomationId(AutomationIds.orderCreateFiatAmount), ), // "to" label and max amount input (shown after first digit) @@ -240,23 +239,20 @@ class _AmountSectionState extends State { ), Expanded( flex: 2, - child: AutomationId(AutomationIds.orderCreateFiatAmountMax, - child: TextFormField( - key: const Key('maxAmountField'), - controller: _maxAmountController, - focusNode: _maxAmountFocusNode, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - border: InputBorder.none, - hintText: S.of(context)!.maxAmount, - hintStyle: const TextStyle(color: Colors.grey), - ), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly - ], - validator: _validateMaxAmount, - )), + child: TextFormField( + key: const Key('maxAmountField'), + controller: _maxAmountController, + focusNode: _maxAmountFocusNode, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + border: InputBorder.none, + hintText: S.of(context)!.maxAmount, + hintStyle: const TextStyle(color: Colors.grey), + ), + 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 4dd111629..f569e69b2 100644 --- a/lib/features/order/widgets/currency_section.dart +++ b/lib/features/order/widgets/currency_section.dart @@ -1,8 +1,8 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -47,41 +47,38 @@ class CurrencySection extends ConsumerWidget { } } - return AutomationId(AutomationIds.orderCreateCurrency, - child: InkWell( - key: const Key('fiatCodeDropdown'), - onTap: () async { - final selectedCode = await CurrencySelectionDialog.show( - context, - ref, - currentSelection: selectedFiatCode, - ); - if (selectedCode != null) { - ref.read(selectedFiatCodeProvider.notifier).state = - selectedCode; - onCurrencySelected(); - } - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return InkWell( + key: const Key('fiatCodeDropdown'), + onTap: () async { + final selectedCode = await CurrencySelectionDialog.show( + context, + ref, + currentSelection: selectedFiatCode, + ); + if (selectedCode != null) { + ref.read(selectedFiatCodeProvider.notifier).state = + selectedCode; + onCurrencySelected(); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + key: Key('currency_${selectedFiatCode ?? 'none'}'), children: [ - Row( - key: Key('currency_${selectedFiatCode ?? 'none'}'), - children: [ - Text(flag, style: const TextStyle(fontSize: 18)), - const SizedBox(width: 8), - Text( - displayCode.isNotEmpty - ? '$displayCode - $name' - : name, - style: const TextStyle(color: Colors.white), - ), - ], + Text(flag, style: const TextStyle(fontSize: 18)), + const SizedBox(width: 8), + Text( + displayCode.isNotEmpty ? '$displayCode - $name' : name, + style: const TextStyle(color: Colors.white), ), - const Icon(Icons.keyboard_arrow_down, color: Colors.white), ], ), - )); + 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 371eb2fa3..0b179dfc9 100644 --- a/lib/features/order/widgets/payment_methods_section.dart +++ b/lib/features/order/widgets/payment_methods_section.dart @@ -1,7 +1,7 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -47,17 +47,16 @@ class PaymentMethodsSection extends ConsumerWidget { iconBackgroundColor: AppTheme.mostroGreen.withValues(alpha: 0.3), extraContent: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: AutomationId(AutomationIds.orderCreatePaymentMethod, - 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), - ), - )), + 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, diff --git a/lib/features/order/widgets/price_type_section.dart b/lib/features/order/widgets/price_type_section.dart index 1a5a1ddae..e48b26179 100644 --- a/lib/features/order/widgets/price_type_section.dart +++ b/lib/features/order/widgets/price_type_section.dart @@ -1,7 +1,7 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -57,12 +57,11 @@ class PriceTypeSection extends StatelessWidget { fontSize: 14, ), ), - AutomationId(AutomationIds.orderCreatePriceType, - child: MostroSwitch( - key: const Key('fixedSwitch'), - value: isMarketRate, - onChanged: onToggle, - )), + MostroSwitch( + key: const Key('fixedSwitch'), + value: isMarketRate, + onChanged: onToggle, + ).withAutomationId(AutomationIds.orderCreatePriceType), ], ), ], diff --git a/lib/features/relays/widgets/relay_selector.dart b/lib/features/relays/widgets/relay_selector.dart index c0de2a232..6169d1848 100644 --- a/lib/features/relays/widgets/relay_selector.dart +++ b/lib/features/relays/widgets/relay_selector.dart @@ -1,8 +1,8 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -69,30 +69,29 @@ class RelaySelector extends ConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - AutomationId(AutomationIds.settingsRelaysAdd, - child: ElevatedButton( - onPressed: () async { - await showAddDialog(context, ref); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - child: Text( - S.of(context)!.addRelay, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - )), + ElevatedButton( + onPressed: () async { + await showAddDialog(context, ref); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + child: Text( + S.of(context)!.addRelay, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ).withAutomationId(AutomationIds.settingsRelaysAdd), ], ), ], @@ -101,51 +100,49 @@ class RelaySelector extends ConsumerWidget { Widget _buildRelayItem( BuildContext context, WidgetRef ref, MostroRelayInfo relayInfo) { - return AutomationId(AutomationIds.settingsRelayItem(relayInfo.url), - merge: false, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - decoration: BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: AppTheme.dark1, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + ), + child: Row( + children: [ + // Status dot - green if active, grey if inactive + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: relayInfo.isActive ? AppTheme.activeColor : Colors.grey, + borderRadius: BorderRadius.circular(4), + ), ), - child: Row( - children: [ - // Status dot - green if active, grey if inactive - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: - relayInfo.isActive ? AppTheme.activeColor : Colors.grey, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(width: 12), + const SizedBox(width: 12), - // Relay URL - Expanded( - child: Text( - relayInfo.url, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), + // Relay URL + Expanded( + child: Text( + relayInfo.url, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, ), + ), + ), - const SizedBox(width: 12), + 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), - ], - ), - )); + // 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( @@ -156,17 +153,16 @@ class RelaySelector extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - AutomationId(AutomationIds.settingsRelayDelete, - child: GestureDetector( - onTap: () async { - await _showDeleteUserRelayDialog(context, ref, relayInfo); - }, - child: const Icon( - Icons.delete, - color: Colors.white, - size: 24, - ), - )), + GestureDetector( + onTap: () async { + await _showDeleteUserRelayDialog(context, ref, relayInfo); + }, + child: const Icon( + Icons.delete, + color: Colors.white, + size: 24, + ), + ).withAutomationId(AutomationIds.settingsRelayDelete), ], ), ); @@ -358,28 +354,26 @@ class RelaySelector extends ConsumerWidget { border: Border.all( color: Colors.white.withValues(alpha: 0.1)), ), - child: AutomationId(AutomationIds.settingsRelaysAddUrl, - 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, - )), + 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), @@ -408,96 +402,92 @@ class RelaySelector extends ConsumerWidget { ), actions: [ if (!isLoading) ...[ - AutomationId(AutomationIds.settingsRelaysAddCancel, - child: TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text( - S.of(context)!.addRelayDialogCancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - )), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text( + S.of(context)!.addRelayDialogCancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ).withAutomationId(AutomationIds.settingsRelaysAddCancel), const SizedBox(width: 12), ], - AutomationId(AutomationIds.settingsRelaysAddConfirm, - child: ElevatedButton( - onPressed: isLoading - ? null - : () async { - final input = textController.text.trim(); - if (input.isEmpty) return; + ElevatedButton( + onPressed: isLoading + ? null + : () async { + final input = textController.text.trim(); + if (input.isEmpty) return; - // Capture context values before async operations - final localizations = S.of(context)!; - final navigator = Navigator.of(dialogContext); + // Capture context values before async operations + final localizations = S.of(context)!; + final navigator = Navigator.of(dialogContext); - setState(() { - isLoading = true; - errorMessage = null; - }); + setState(() { + isLoading = true; + errorMessage = null; + }); - try { - final result = await relaysNotifier - .addRelayWithSmartValidation( - input, - errorOnlySecure: - localizations.addRelayErrorOnlySecure, - errorNoHttp: - localizations.addRelayErrorNoHttp, - errorInvalidDomain: - localizations.addRelayErrorInvalidDomain, - errorAlreadyExists: - localizations.addRelayErrorAlreadyExists, - errorNotValid: - localizations.addRelayErrorNotValid, - ); + try { + final result = await relaysNotifier + .addRelayWithSmartValidation( + input, + errorOnlySecure: + localizations.addRelayErrorOnlySecure, + errorNoHttp: localizations.addRelayErrorNoHttp, + errorInvalidDomain: + localizations.addRelayErrorInvalidDomain, + errorAlreadyExists: + localizations.addRelayErrorAlreadyExists, + errorNotValid: + localizations.addRelayErrorNotValid, + ); - if (result.success) { - navigator.pop(); - if (context.mounted) { - SnackBarHelper.showTopSnackBar( - context, - localizations.addRelaySuccessMessage( - result.normalizedUrl!), - backgroundColor: Colors.green, - ); - } - } else { - setState(() { - errorMessage = result.error; - isLoading = false; - }); - } - } catch (e) { - setState(() { - errorMessage = - localizations.addRelayErrorGeneric; - isLoading = false; - }); + if (result.success) { + navigator.pop(); + if (context.mounted) { + SnackBarHelper.showTopSnackBar( + context, + localizations.addRelaySuccessMessage( + result.normalizedUrl!), + backgroundColor: Colors.green, + ); } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 12), - ), - child: Text( - S.of(context)!.addRelayDialogAdd, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - )), + } else { + setState(() { + errorMessage = result.error; + isLoading = false; + }); + } + } catch (e) { + setState(() { + errorMessage = localizations.addRelayErrorGeneric; + isLoading = false; + }); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 12), + ), + child: Text( + S.of(context)!.addRelayDialogAdd, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ).withAutomationId(AutomationIds.settingsRelaysAddConfirm), ], ); }, diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 37852c4b3..6d94b770c 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1,11 +1,11 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; 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'; @@ -724,89 +724,87 @@ class _SettingsScreenState extends ConsumerState { const SizedBox(height: 16), Material( color: Colors.transparent, - child: AutomationId(AutomationIds.settingsMostroNode, - merge: false, - child: InkWell( - onTap: () => MostroNodeSelector.show(context), + child: InkWell( + onTap: () => MostroNodeSelector.show(context), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.backgroundInput, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.white.withValues(alpha: 0.1), - ), - ), - child: Row( - children: [ - if (selectedNode != null) ...[ - MostroNodeAvatar(node: selectedNode), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + if (selectedNode != null) ...[ + MostroNodeAvatar(node: selectedNode), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Row( - children: [ - Flexible( - child: Text( - selectedNode.displayName, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), + Flexible( + child: Text( + selectedNode.displayName, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, ), - if (selectedNode.isTrusted) ...[ - const SizedBox(width: 8), - const TrustedBadge(), - ], - ], - ), - const SizedBox(height: 2), - AutomationId( - AutomationIds.settingsMostroNodePubkey, - child: Text( - selectedNode.pubkey, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - )), - const SizedBox(height: 2), - Text( - S.of(context)!.tapToSelectNode, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 11, + overflow: TextOverflow.ellipsis, ), ), + if (selectedNode.isTrusted) ...[ + const SizedBox(width: 8), + const TrustedBadge(), + ], ], ), - ), - ] else - Expanded( - child: Text( - S.of(context)!.selectMostroNode, + const SizedBox(height: 2), + Text( + selectedNode.pubkey, style: const TextStyle( color: AppTheme.textSecondary, - fontSize: 15, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ).withAutomationId( + AutomationIds.settingsMostroNodePubkey), + const SizedBox(height: 2), + Text( + S.of(context)!.tapToSelectNode, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 11, ), ), + ], + ), + ), + ] else + Expanded( + child: Text( + S.of(context)!.selectMostroNode, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 15, ), - const Icon( - Icons.arrow_drop_down, - color: AppTheme.textSecondary, ), - ], + ), + const Icon( + Icons.arrow_drop_down, + color: AppTheme.textSecondary, ), - ), - )), + ], + ), + ), + ).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 787da4c66..9e0650276 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -1,5 +1,3 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:circular_countdown/circular_countdown.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; @@ -7,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'; @@ -457,29 +457,28 @@ class TradeDetailScreen extends ConsumerWidget { ), ), ), - AutomationId(AutomationIds.tradeReleaseConfirm, - child: ElevatedButton( - onPressed: () { - Navigator.of(dialogContext).pop(true); - ref - .read(orderNotifierProvider(orderId).notifier) - .releaseOrder(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: Text( - S.of(context)!.yes, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - )), + ElevatedButton( + onPressed: () { + Navigator.of(dialogContext).pop(true); + ref + .read(orderNotifierProvider(orderId).notifier) + .releaseOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + S.of(context)!.yes, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ).withAutomationId(AutomationIds.tradeReleaseConfirm), ], ), ); @@ -608,19 +607,18 @@ class TradeDetailScreen extends ConsumerWidget { Key? key, MostroReactiveButtonController? controller, }) { - return AutomationId(AutomationIds.tradeAction(action.name), - child: MostroReactiveButton( - key: key, - label: label, - buttonStyle: ButtonStyleType.raised, - orderId: orderId, - action: action, - backgroundColor: backgroundColor, - onPressed: onPressed ?? () {}, // Provide empty function when null - showSuccessIndicator: true, - timeout: const Duration(seconds: 10), - controller: controller, - )); + return MostroReactiveButton( + key: key, + label: label, + buttonStyle: ButtonStyleType.raised, + orderId: orderId, + action: action, + backgroundColor: backgroundColor, + onPressed: onPressed ?? () {}, // Provide empty function when null + showSuccessIndicator: true, + timeout: const Duration(seconds: 10), + controller: controller, + ).withAutomationId(AutomationIds.tradeAction(action.name)); } Widget _buildCancelButton( @@ -630,190 +628,185 @@ class TradeDetailScreen extends ConsumerWidget { String cancelMessage, actions.Action action, ) { - return AutomationId(AutomationIds.tradeCancel, - child: ElevatedButton( - onPressed: () async { - final result = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: AppTheme.backgroundCard, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), - ), - title: Text( - S.of(context)!.cancelTradeDialogTitle, + return ElevatedButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: AppTheme.backgroundCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + title: Text( + S.of(context)!.cancelTradeDialogTitle, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + content: Text( + cancelMessage, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + height: 1.5, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text( + S.of(context)!.no, style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, ), + textAlign: TextAlign.center, ), - content: Text( - cancelMessage, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 14, - height: 1.5, + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 12), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text( - S.of(context)!.no, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), + child: Text( + S.of(context)!.yes, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 12), - AutomationId(AutomationIds.tradeCancelConfirm, - child: AutomationId(AutomationIds.tradeDisputeConfirm, - child: ElevatedButton( - onPressed: () => - Navigator.of(dialogContext).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 12), - ), - child: Text( - S.of(context)!.yes, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ))), - ], - ), - ); - - // Only proceed with cancellation if user confirmed - if (result == true) { - ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - foregroundColor: Colors.white, + textAlign: TextAlign.center, + ), + ) + .withAutomationId(AutomationIds.tradeDisputeConfirm) + .withAutomationId(AutomationIds.tradeCancelConfirm), + ], ), - child: Text(buttonText), - )); + ); + + // Only proceed with cancellation if user confirmed + if (result == true) { + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.red1, + foregroundColor: Colors.white, + ), + child: Text(buttonText), + ).withAutomationId(AutomationIds.tradeCancel); } Widget _buildDisputeButton( BuildContext context, WidgetRef ref, ) { - return AutomationId(AutomationIds.tradeDispute, - child: ElevatedButton( - onPressed: () async { - final result = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: AppTheme.backgroundCard, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), - ), - title: Text( - S.of(context)!.disputeTradeDialogTitle, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - content: Text( - S.of(context)!.disputeTradeDialogContent, + return ElevatedButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: AppTheme.backgroundCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + title: Text( + S.of(context)!.disputeTradeDialogTitle, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + content: Text( + S.of(context)!.disputeTradeDialogContent, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + height: 1.5, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text( + S.of(context)!.no, style: const TextStyle( color: AppTheme.textSecondary, - fontSize: 14, - height: 1.5, + fontSize: 16, + fontWeight: FontWeight.w500, ), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text( - S.of(context)!.no, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), + ), + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - ElevatedButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: Text( - S.of(context)!.yes, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), + ), + child: Text( + S.of(context)!.yes, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, ), - ], + ), ), - ); + ], + ), + ); - // Only proceed with dispute if user confirmed - if (result == true) { - try { - // Create dispute using the repository - final repository = ref.read(disputeRepositoryProvider); - final success = await repository.createDispute(orderId); - - if (success && context.mounted) { - SnackBarHelper.showTopSnackBar( - context, - S.of(context)!.disputeCreatedSuccessfully, - backgroundColor: AppTheme.mostroGreen, - ); - } else if (context.mounted) { - SnackBarHelper.showTopSnackBar( - context, - S.of(context)!.disputeCreationFailed, - backgroundColor: AppTheme.red1, - ); - } - } catch (e) { - if (context.mounted) { - SnackBarHelper.showTopSnackBar( - context, - S - .of(context)! - .disputeCreationErrorWithMessage(e.toString()), - backgroundColor: AppTheme.red1, - ); - } - } + // Only proceed with dispute if user confirmed + if (result == true) { + try { + // Create dispute using the repository + final repository = ref.read(disputeRepositoryProvider); + final success = await repository.createDispute(orderId); + + if (success && context.mounted) { + SnackBarHelper.showTopSnackBar( + context, + S.of(context)!.disputeCreatedSuccessfully, + backgroundColor: AppTheme.mostroGreen, + ); + } else if (context.mounted) { + SnackBarHelper.showTopSnackBar( + context, + S.of(context)!.disputeCreationFailed, + backgroundColor: AppTheme.red1, + ); } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - foregroundColor: Colors.white, - ), - child: Text(S.of(context)!.disputeButton), - )); + } catch (e) { + if (context.mounted) { + SnackBarHelper.showTopSnackBar( + context, + S.of(context)!.disputeCreationErrorWithMessage(e.toString()), + backgroundColor: AppTheme.red1, + ); + } + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.red1, + 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 5e661a238..101c10c32 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -1,7 +1,7 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -45,16 +45,14 @@ class MostroMessageDetail extends ConsumerWidget { text: formatTextWithBoldUsernames(actionText, context), ), const SizedBox(height: 16), - AutomationId(AutomationIds.orderStatus, - merge: false, - label: orderState.status.value, - child: Text( - _getLocalizedStatus(context, orderState.status), - style: TextStyle( - color: Colors.grey[400], - fontSize: 12, - ), - )), + Text( + _getLocalizedStatus(context, orderState.status), + style: TextStyle( + color: Colors.grey[400], + fontSize: 12, + ), + ).withAutomationId(AutomationIds.orderStatus, + merge: false, label: orderState.status.value), ], ), ), diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index e4dc6adcf..d16dcab51 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -1,10 +1,10 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; 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'; @@ -55,135 +55,133 @@ class TradesListItem extends ConsumerWidget { ? trade.orderType == OrderType.buy : trade.orderType == OrderType.sell; - return AutomationId(AutomationIds.tradesItem(trade.orderId ?? ''), - merge: false, - child: GestureDetector( - onTap: () { - context.push('/trade_detail/${trade.orderId}'); - }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - decoration: BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.circular(12.0), - ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Left side - Trade info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return GestureDetector( + onTap: () { + context.push('/trade_detail/${trade.orderId}'); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + decoration: BoxDecoration( + color: AppTheme.dark1, + borderRadius: BorderRadius.circular(12.0), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Left side - Trade info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // First row: Buy/Sell Bitcoin text (full width) + Text( + isBuying + ? S.of(context)!.buyingBitcoin + : S.of(context)!.sellingBitcoin, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + // Second row: Status and role chips + Premium/Discount + Row( children: [ - // First row: Buy/Sell Bitcoin text (full width) - Text( - isBuying - ? S.of(context)!.buyingBitcoin - : S.of(context)!.sellingBitcoin, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), + _buildStatusChip( + context, + orderState.status, + bondBadgeLabel: bondBadgeLabel, ), - const SizedBox(height: 8), - // Second row: Status and role chips + Premium/Discount - Row( - children: [ - _buildStatusChip( - context, - orderState.status, - bondBadgeLabel: bondBadgeLabel, + const SizedBox(width: 8), + _buildRoleChip(context, isCreator), + const Spacer(), + // Show premium/discount if different from zero + if (trade.premium != null && trade.premium != '0') + Container( + 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, + borderRadius: BorderRadius.circular(8), ), - const SizedBox(width: 8), - _buildRoleChip(context, isCreator), - const Spacer(), - // Show premium/discount if different from zero - if (trade.premium != null && trade.premium != '0') - Container( - 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, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${double.tryParse(trade.premium!) != null && double.parse(trade.premium!) > 0 ? '+' : ''}${trade.premium}%', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - // Third row: Flag + Amount and currency - Row( - children: [ - Text( - CurrencyUtils.getFlagFromCurrencyData( - trade.currency ?? '', currencyData), + child: Text( + '${double.tryParse(trade.premium!) != null && double.parse(trade.premium!) > 0 ? '+' : ''}${trade.premium}%', style: const TextStyle( - fontSize: 16, + color: AppTheme.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w500, ), ), - const SizedBox(width: 4), - Flexible( - child: Text( - trade.fiatAmount.maximum != null && - trade.fiatAmount.maximum != - trade.fiatAmount.minimum - ? '${trade.fiatAmount.minimum} - ${trade.fiatAmount.maximum} ${trade.currency ?? ''}' - : '${trade.fiatAmount.minimum} ${trade.currency ?? ''}', - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, - ), + ), + ], + ), + const SizedBox(height: 8), + // Third row: Flag + Amount and currency + Row( + children: [ + Text( + CurrencyUtils.getFlagFromCurrencyData( + trade.currency ?? '', currencyData), + style: const TextStyle( + fontSize: 16, + ), + ), + const SizedBox(width: 4), + Flexible( + child: Text( + trade.fiatAmount.maximum != null && + trade.fiatAmount.maximum != + trade.fiatAmount.minimum + ? '${trade.fiatAmount.minimum} - ${trade.fiatAmount.maximum} ${trade.currency ?? ''}' + : '${trade.fiatAmount.minimum} ${trade.currency ?? ''}', + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.bold, ), - ], + overflow: TextOverflow.ellipsis, + ), ), - const SizedBox(height: 4), - - trade.paymentMethods.isNotEmpty - ? Text( - trade.paymentMethods.join(', '), - style: const TextStyle( - color: AppTheme.secondaryText, - fontSize: 14, - ), - ) - : Text( - S.of(context)!.bankTransfer, - style: const TextStyle( - color: AppTheme.secondaryText, - fontSize: 14, - ), - ), ], ), - ), - // Right side - Arrow icon - const Icon( - Icons.chevron_right, - color: AppTheme.textPrimary, - size: 24, - ), - ], + const SizedBox(height: 4), + + trade.paymentMethods.isNotEmpty + ? Text( + trade.paymentMethods.join(', '), + style: const TextStyle( + color: AppTheme.secondaryText, + fontSize: 14, + ), + ) + : Text( + S.of(context)!.bankTransfer, + style: const TextStyle( + color: AppTheme.secondaryText, + fontSize: 14, + ), + ), + ], + ), + ), + // Right side - Arrow icon + const Icon( + Icons.chevron_right, + color: AppTheme.textPrimary, + size: 24, ), - ), + ], ), - )); + ), + ), + ).withAutomationId(AutomationIds.tradesItem(trade.orderId ?? ''), + merge: false); } Widget _buildRoleChip(BuildContext context, bool isCreator) { @@ -335,23 +333,21 @@ class TradesListItem extends ConsumerWidget { break; } - return AutomationId(AutomationIds.tradesItemStatus, - merge: false, - label: status.value, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - label, - style: TextStyle( - color: textColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - )); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: TextStyle( + color: textColor, + fontSize: 12, + 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 673b4a280..392667791 100644 --- a/lib/features/walkthrough/screens/walkthrough_screen.dart +++ b/lib/features/walkthrough/screens/walkthrough_screen.dart @@ -1,10 +1,10 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; 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'; @@ -202,15 +202,15 @@ class _WalkthroughScreenState extends ConsumerState { onSkip: () => _onIntroEnd(context), showSkipButton: true, showBackButton: true, - back: const AutomationId(AutomationIds.onboardingBack, - child: Icon(Icons.arrow_back)), - skip: AutomationId(AutomationIds.onboardingSkip, - child: Text(S.of(context)!.skip)), - next: const AutomationId(AutomationIds.onboardingNext, - child: Icon(Icons.arrow_forward)), - done: AutomationId(AutomationIds.onboardingDone, - child: Text(S.of(context)!.done, - style: const TextStyle(fontWeight: FontWeight.w600))), + 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)) + .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 f1001c16f..c419741b9 100644 --- a/lib/features/wallet/screens/connect_wallet_screen.dart +++ b/lib/features/wallet/screens/connect_wallet_screen.dart @@ -1,11 +1,11 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; 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'; @@ -134,39 +134,38 @@ class _ConnectWalletScreenState extends ConsumerState { : Colors.white.withValues(alpha: 0.1), ), ), - child: AutomationId(AutomationIds.walletNwcUri, - child: TextFormField( - controller: _uriController, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 14, - ), - maxLines: 3, - onChanged: (_) { - if (_validationError != null) { - setState(() => _validationError = null); - } - }, - decoration: InputDecoration( - border: InputBorder.none, - hintText: 'nostr+walletconnect://...', - hintStyle: const TextStyle( - color: AppTheme.textSecondary, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - suffixIcon: IconButton( - icon: const Icon( - LucideIcons.scanLine, - color: AppTheme.activeColor, - ), - onPressed: () => _openQrScanner(context), - tooltip: S.of(context)!.scanQrCode, - ), + child: TextFormField( + controller: _uriController, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, + ), + maxLines: 3, + onChanged: (_) { + if (_validationError != null) { + setState(() => _validationError = null); + } + }, + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'nostr+walletconnect://...', + hintStyle: const TextStyle( + color: AppTheme.textSecondary, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + suffixIcon: IconButton( + icon: const Icon( + LucideIcons.scanLine, + color: AppTheme.activeColor, ), - )), + onPressed: () => _openQrScanner(context), + tooltip: S.of(context)!.scanQrCode, + ), + ), + ).withAutomationId(AutomationIds.walletNwcUri), ), // Validation error @@ -186,33 +185,32 @@ class _ConnectWalletScreenState extends ConsumerState { // Connect button SizedBox( width: double.infinity, - child: AutomationId(AutomationIds.walletNwcConnect, - child: ElevatedButton.icon( - onPressed: _isConnecting ? null : _onConnect, - icon: _isConnecting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(LucideIcons.plug, size: 18), - label: Text( - _isConnecting - ? S.of(context)!.connecting - : S.of(context)!.connectWallet, - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - )), + child: ElevatedButton.icon( + onPressed: _isConnecting ? null : _onConnect, + icon: _isConnecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(LucideIcons.plug, size: 18), + label: Text( + _isConnecting + ? S.of(context)!.connecting + : S.of(context)!.connectWallet, + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + 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 5ecb3bf8f..d7f35f8c0 100644 --- a/lib/features/wallet/screens/wallet_settings_screen.dart +++ b/lib/features/wallet/screens/wallet_settings_screen.dart @@ -1,11 +1,11 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; 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'; @@ -176,20 +176,19 @@ class WalletSettingsScreen extends ConsumerWidget { // Disconnect Button SizedBox( width: double.infinity, - child: AutomationId(AutomationIds.walletSettingsDisconnect, - child: OutlinedButton.icon( - onPressed: () => _showDisconnectConfirm(context, ref), - icon: const Icon(LucideIcons.unplug, size: 18), - label: Text(S.of(context)!.disconnectWallet), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.redAccent, - side: const BorderSide(color: Colors.redAccent), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - )), + child: OutlinedButton.icon( + onPressed: () => _showDisconnectConfirm(context, ref), + icon: const Icon(LucideIcons.unplug, size: 18), + label: Text(S.of(context)!.disconnectWallet), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.redAccent, + side: const BorderSide(color: Colors.redAccent), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ).withAutomationId(AutomationIds.walletSettingsDisconnect), ), ], ); @@ -292,20 +291,19 @@ class WalletSettingsScreen extends ConsumerWidget { const SizedBox(height: 20), SizedBox( width: double.infinity, - child: AutomationId(AutomationIds.walletSettingsConnect, - child: ElevatedButton.icon( - onPressed: () => context.push('/connect_wallet'), - icon: const Icon(LucideIcons.plug, size: 18), - label: Text(S.of(context)!.connectWallet), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - )), + child: ElevatedButton.icon( + onPressed: () => context.push('/connect_wallet'), + icon: const Icon(LucideIcons.plug, size: 18), + label: Text(S.of(context)!.connectWallet), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + 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 b22912665..5984e8f94 100644 --- a/lib/features/wallet/widgets/wallet_status_card.dart +++ b/lib/features/wallet/widgets/wallet_status_card.dart @@ -1,10 +1,10 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; 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'; @@ -63,73 +63,68 @@ class WalletStatusCard extends ConsumerWidget { const SizedBox(height: 16), Material( color: Colors.transparent, - child: AutomationId(AutomationIds.settingsWallet, - merge: false, - child: InkWell( - onTap: () => context.push('/wallet_settings'), + child: InkWell( + onTap: () => context.push('/wallet_settings'), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.backgroundInput, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.white.withValues(alpha: 0.1), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + Icon( + isConnected ? LucideIcons.check : LucideIcons.plug, + color: + isConnected ? Colors.green : AppTheme.textSecondary, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isConnected + ? (nwcState.walletAlias ?? + S.of(context)!.walletConnected) + : S.of(context)!.walletDisconnected, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ).withAutomationId(AutomationIds.walletConnection, + merge: false, + label: + isConnected ? 'connected' : 'disconnected'), + if (isConnected && + nwcState.balanceSats != null) ...[ + const SizedBox(height: 4), + Text( + '⚡ ${_formatSats(nwcState.balanceSats!)} ${S.of(context)!.sats}', + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), + ), + ], + ], ), ), - child: Row( - children: [ - Icon( - isConnected ? LucideIcons.check : LucideIcons.plug, - color: isConnected - ? Colors.green - : AppTheme.textSecondary, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AutomationId(AutomationIds.walletConnection, - merge: false, - label: isConnected - ? 'connected' - : 'disconnected', - child: Text( - isConnected - ? (nwcState.walletAlias ?? - S.of(context)!.walletConnected) - : S.of(context)!.walletDisconnected, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - )), - if (isConnected && - nwcState.balanceSats != null) ...[ - const SizedBox(height: 4), - Text( - '⚡ ${_formatSats(nwcState.balanceSats!)} ${S.of(context)!.sats}', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - ), - ), - ], - ], - ), - ), - const Icon( - Icons.chevron_right, - color: AppTheme.textSecondary, - size: 20, - ), - ], + const Icon( + Icons.chevron_right, + color: AppTheme.textSecondary, + size: 20, ), - ), - )), + ], + ), + ), + ).withAutomationId(AutomationIds.settingsWallet, merge: false), ), ], ), diff --git a/lib/shared/widgets/add_lightning_invoice_widget.dart b/lib/shared/widgets/add_lightning_invoice_widget.dart index 4e0c713c4..7618376f2 100644 --- a/lib/shared/widgets/add_lightning_invoice_widget.dart +++ b/lib/shared/widgets/add_lightning_invoice_widget.dart @@ -1,7 +1,7 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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 { @@ -57,66 +57,63 @@ class _AddLightningInvoiceWidgetState extends State { borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.white.withValues(alpha: 0.1)), ), - child: AutomationId(AutomationIds.invoiceText, - child: TextFormField( - key: const Key('invoiceTextField'), - controller: widget.controller, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - labelText: S.of(context)!.lightningInvoice, - labelStyle: const TextStyle(color: AppTheme.textSecondary), - hintText: S.of(context)!.enterInvoiceHere, - hintStyle: const TextStyle(color: AppTheme.textSecondary), - border: InputBorder.none, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - alignLabelWithHint: true, - ), - maxLines: 6, - )), + child: TextFormField( + key: const Key('invoiceTextField'), + controller: widget.controller, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: InputDecoration( + labelText: S.of(context)!.lightningInvoice, + labelStyle: const TextStyle(color: AppTheme.textSecondary), + hintText: S.of(context)!.enterInvoiceHere, + hintStyle: const TextStyle(color: AppTheme.textSecondary), + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + alignLabelWithHint: true, + ), + maxLines: 6, + ).withAutomationId(AutomationIds.invoiceText), ), const SizedBox(height: 16), Row( children: [ Expanded( - child: AutomationId(AutomationIds.invoiceCancel, - child: TextButton( - key: const Key('cancelInvoiceButton'), - onPressed: widget.onCancel, - child: Text( - S.of(context)!.cancel, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - )), + child: TextButton( + key: const Key('cancelInvoiceButton'), + onPressed: widget.onCancel, + child: Text( + S.of(context)!.cancel, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ).withAutomationId(AutomationIds.invoiceCancel), ), const SizedBox(width: 12), Expanded( - child: AutomationId(AutomationIds.invoiceSubmit, - child: ElevatedButton( - key: const Key('submitInvoiceButton'), - onPressed: widget.onSubmit, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.activeColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 12), - ), - child: Text( - S.of(context)!.submit, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - )), + child: ElevatedButton( + key: const Key('submitInvoiceButton'), + onPressed: widget.onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 12), + ), + child: Text( + S.of(context)!.submit, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ).withAutomationId(AutomationIds.invoiceSubmit), ), ], ), diff --git a/lib/shared/widgets/add_order_button.dart b/lib/shared/widgets/add_order_button.dart index d47a6dac1..e5fb5755f 100644 --- a/lib/shared/widgets/add_order_button.dart +++ b/lib/shared/widgets/add_order_button.dart @@ -1,8 +1,8 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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 { @@ -78,27 +78,25 @@ class _AddOrderButtonState extends State child: Stack( alignment: Alignment.centerLeft, children: [ - AutomationId(AutomationIds.orderAddBuy, - child: ElevatedButton.icon( - key: const Key('buyButton'), - onPressed: _isMenuOpen - ? () => - _navigateToCreateOrder(context, 'buy') - : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.buyColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - ), - icon: const SizedBox(width: 16, height: 16), - label: Text(S.of(context)!.buy, - style: const TextStyle( - fontWeight: FontWeight.bold)), - )), + ElevatedButton.icon( + key: const Key('buyButton'), + onPressed: _isMenuOpen + ? () => _navigateToCreateOrder(context, 'buy') + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.buyColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + icon: const SizedBox(width: 16, height: 16), + label: Text(S.of(context)!.buy, + style: const TextStyle( + fontWeight: FontWeight.bold)), + ).withAutomationId(AutomationIds.orderAddBuy), if (_isMenuOpen) const Positioned( left: 12, @@ -113,27 +111,25 @@ class _AddOrderButtonState extends State child: Stack( alignment: Alignment.centerLeft, children: [ - AutomationId(AutomationIds.orderAddSell, - child: ElevatedButton.icon( - key: const Key('sellButton'), - onPressed: _isMenuOpen - ? () => - _navigateToCreateOrder(context, 'sell') - : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.sellColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - ), - icon: const SizedBox(width: 16, height: 16), - label: Text(S.of(context)!.sell, - style: const TextStyle( - fontWeight: FontWeight.bold)), - )), + ElevatedButton.icon( + key: const Key('sellButton'), + onPressed: _isMenuOpen + ? () => _navigateToCreateOrder(context, 'sell') + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.sellColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + icon: const SizedBox(width: 16, height: 16), + label: Text(S.of(context)!.sell, + style: const TextStyle( + fontWeight: FontWeight.bold)), + ).withAutomationId(AutomationIds.orderAddSell), if (_isMenuOpen) const Positioned( left: 12, @@ -148,28 +144,27 @@ class _AddOrderButtonState extends State ), ), ), - AutomationId(AutomationIds.orderAddFab, - child: FloatingActionButton( - key: const Key('addOrderButton'), - onPressed: _toggleMenu, - backgroundColor: - _isMenuOpen ? Colors.grey.shade700 : AppTheme.activeColor, - elevation: 6, - shape: const CircleBorder(), - child: AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Transform.rotate( - angle: _animationController.value * 0.785 * 2, - child: Icon( - _isMenuOpen ? Icons.close : Icons.add, - color: Colors.black, - size: 24, - ), - ); - }, - ), - )), + FloatingActionButton( + key: const Key('addOrderButton'), + onPressed: _toggleMenu, + backgroundColor: + _isMenuOpen ? Colors.grey.shade700 : AppTheme.activeColor, + elevation: 6, + shape: const CircleBorder(), + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.rotate( + angle: _animationController.value * 0.785 * 2, + child: Icon( + _isMenuOpen ? Icons.close : Icons.add, + color: Colors.black, + size: 24, + ), + ); + }, + ), + ).withAutomationId(AutomationIds.orderAddFab), ], ), ); diff --git a/lib/shared/widgets/bottom_nav_bar.dart b/lib/shared/widgets/bottom_nav_bar.dart index 7bec951da..fd4ffa77f 100644 --- a/lib/shared/widgets/bottom_nav_bar.dart +++ b/lib/shared/widgets/bottom_nav_bar.dart @@ -1,9 +1,9 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; import 'package:flutter/material.dart'; 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); diff --git a/lib/shared/widgets/currency_selection_dialog.dart b/lib/shared/widgets/currency_selection_dialog.dart index 381c81953..d5c0f6323 100644 --- a/lib/shared/widgets/currency_selection_dialog.dart +++ b/lib/shared/widgets/currency_selection_dialog.dart @@ -1,8 +1,8 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -139,29 +139,28 @@ class _CurrencySelectionDialogWidgetState final isSelected = code == widget.currentSelection; - return AutomationId( - AutomationIds.orderCreateCurrencyOption(code), - child: ListTile( - key: Key('currency_$code'), - leading: Text( - currency.emoji.isNotEmpty - ? currency.emoji - : '🏳️', - style: const TextStyle(fontSize: 20), - ), - title: Text( - '$code - ${currency.name}', - style: - const TextStyle(color: Colors.white), - ), - trailing: isSelected - ? const Icon(Icons.check, - color: AppTheme.mostroGreen) - : null, - onTap: () { - Navigator.of(context).pop(code); - }, - )); + return ListTile( + key: Key('currency_$code'), + leading: Text( + currency.emoji.isNotEmpty + ? currency.emoji + : '🏳️', + style: const TextStyle(fontSize: 20), + ), + title: Text( + '$code - ${currency.name}', + style: const TextStyle(color: Colors.white), + ), + trailing: isSelected + ? const Icon(Icons.check, + color: AppTheme.mostroGreen) + : null, + 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 eb89998d7..6dbb6fd7c 100644 --- a/lib/shared/widgets/custom_drawer_overlay.dart +++ b/lib/shared/widgets/custom_drawer_overlay.dart @@ -1,10 +1,10 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; 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'; @@ -143,27 +143,24 @@ class CustomDrawerOverlay extends ConsumerWidget { required String title, required String route, }) { - return AutomationId( - _drawerIdentifier(route), - child: ListTile( - dense: true, - leading: Icon( - icon, + return ListTile( + dense: true, + leading: Icon( + icon, + color: AppTheme.cream1, + size: 22, + ), + title: Text( + title, + style: AppTheme.theme.textTheme.bodyLarge?.copyWith( color: AppTheme.cream1, - size: 22, - ), - title: Text( - title, - style: AppTheme.theme.textTheme.bodyLarge?.copyWith( - color: AppTheme.cream1, - fontWeight: FontWeight.w500, - ), + fontWeight: FontWeight.w500, ), - onTap: () { - ref.read(drawerProvider.notifier).closeDrawer(); - context.push(route); - }, ), - ); + onTap: () { + 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 29422b6e5..0a68d74b0 100644 --- a/lib/shared/widgets/mostro_app_bar.dart +++ b/lib/shared/widgets/mostro_app_bar.dart @@ -1,10 +1,10 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; import 'package:flutter/material.dart'; 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'; @@ -86,30 +86,28 @@ class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { if (showBackButton) { leading = Padding( padding: const EdgeInsets.only(left: 16.0), - child: AutomationId(AutomationIds.appBarBack, - child: IconButton( - icon: const HeroIcon( - HeroIcons.arrowLeft, - style: HeroIconStyle.outline, - color: AppTheme.cream1, - size: 28, - ), - onPressed: () => context.pop(), - )), + child: IconButton( + icon: const HeroIcon( + HeroIcons.arrowLeft, + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 28, + ), + onPressed: () => context.pop(), + ).withAutomationId(AutomationIds.appBarBack), ); } else if (showDrawerButton) { leading = Padding( padding: const EdgeInsets.only(left: 16.0), - child: AutomationId(AutomationIds.appBarDrawer, - child: IconButton( - icon: const HeroIcon( - HeroIcons.bars3, - style: HeroIconStyle.outline, - color: AppTheme.cream1, - size: 28, - ), - onPressed: () => ref.read(drawerProvider.notifier).toggleDrawer(), - )), + child: IconButton( + icon: const HeroIcon( + HeroIcons.bars3, + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 28, + ), + onPressed: () => ref.read(drawerProvider.notifier).toggleDrawer(), + ).withAutomationId(AutomationIds.appBarDrawer), ); } diff --git a/lib/shared/widgets/nwc_invoice_widget.dart b/lib/shared/widgets/nwc_invoice_widget.dart index 74e80606e..2dba63c4f 100644 --- a/lib/shared/widgets/nwc_invoice_widget.dart +++ b/lib/shared/widgets/nwc_invoice_widget.dart @@ -1,9 +1,9 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -166,23 +166,21 @@ class _NwcInvoiceWidgetState extends ConsumerState { SizedBox( width: double.infinity, height: 56, - child: AutomationId(AutomationIds.invoiceNwcGenerate, - child: ElevatedButton.icon( - onPressed: _generateInvoice, - icon: const Icon(LucideIcons.wallet, size: 20), - label: Text( - S.of(context)!.nwcGenerateWithWallet, - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.w600), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - )), + child: ElevatedButton.icon( + onPressed: _generateInvoice, + icon: const Icon(LucideIcons.wallet, size: 20), + label: Text( + S.of(context)!.nwcGenerateWithWallet, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ).withAutomationId(AutomationIds.invoiceNwcGenerate), ), const SizedBox(height: 8), Text( @@ -269,42 +267,39 @@ class _NwcInvoiceWidgetState extends ConsumerState { ), ), const SizedBox(height: 4), - AutomationId(AutomationIds.invoiceNwcText, - merge: false, - label: _generatedInvoice ?? '', - child: Text( - _truncateInvoice(_generatedInvoice), - style: TextStyle( - color: AppTheme.cream1.withAlpha(102), - fontSize: 11, - fontFamily: 'monospace', - ), - textAlign: TextAlign.center, - )), + Text( + _truncateInvoice(_generatedInvoice), + style: TextStyle( + color: AppTheme.cream1.withAlpha(102), + fontSize: 11, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, + ).withAutomationId(AutomationIds.invoiceNwcText, + merge: false, label: _generatedInvoice ?? ''), const SizedBox(height: 16), SizedBox( width: double.infinity, height: 48, - child: AutomationId(AutomationIds.invoiceNwcConfirm, - child: ElevatedButton( - onPressed: _generatedInvoice != null - ? () => widget.onInvoiceConfirmed(_generatedInvoice!) - : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - S.of(context)!.nwcConfirmInvoice, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - )), + child: ElevatedButton( + onPressed: _generatedInvoice != null + ? () => widget.onInvoiceConfirmed(_generatedInvoice!) + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + S.of(context)!.nwcConfirmInvoice, + style: const TextStyle( + fontSize: 16, + 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 d735ca007..af534ba92 100644 --- a/lib/shared/widgets/nwc_payment_widget.dart +++ b/lib/shared/widgets/nwc_payment_widget.dart @@ -1,9 +1,9 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -270,24 +270,22 @@ class _NwcPaymentWidgetState extends ConsumerState { SizedBox( width: double.infinity, height: 56, - child: AutomationId(AutomationIds.payNwc, - child: ElevatedButton.icon( - onPressed: hasEnoughBalance ? _payWithWallet : null, - icon: const Icon(LucideIcons.wallet, size: 20), - label: Text( - S.of(context)!.payWithWallet, - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.w600), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - foregroundColor: Colors.black, - disabledBackgroundColor: Colors.grey.shade700, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - )), + child: ElevatedButton.icon( + onPressed: hasEnoughBalance ? _payWithWallet : null, + icon: const Icon(LucideIcons.wallet, size: 20), + label: Text( + S.of(context)!.payWithWallet, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + foregroundColor: Colors.black, + disabledBackgroundColor: Colors.grey.shade700, + shape: RoundedRectangleBorder( + 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 bbae73ed8..cc9a56060 100644 --- a/lib/shared/widgets/order_cards.dart +++ b/lib/shared/widgets/order_cards.dart @@ -1,9 +1,9 @@ -import 'package:mostro_mobile/core/automation/automation_ids.dart'; -import 'package:mostro_mobile/core/automation/automation_id.dart'; 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'; @@ -215,14 +215,13 @@ class OrderIdCard extends StatelessWidget { Row( children: [ Expanded( - child: AutomationId(AutomationIds.orderId, - child: Text( - orderId, - style: const TextStyle( - color: AppTheme.mostroGreen, - fontSize: 14, - ), - )), + child: Text( + orderId, + style: const TextStyle( + color: AppTheme.mostroGreen, + fontSize: 14, + ), + ).withAutomationId(AutomationIds.orderId), ), IconButton( icon: const Icon( From 12c63f96374a3957a472fe3b7abaca383da24c5a Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 17 Aug 2026 11:52:20 -0300 Subject: [PATCH 09/12] test: build the contract test on the Flutter version CI pins The test asserted semantic flags through SemanticsData.flagsCollection, which does not exist in Flutter 3.32.5, the version .github/workflows pins, so `flutter analyze` failed to compile the test and the build job never reached `flutter test`. containsSemantics is available both there and on current stable. The focus assertion also compared a Tristate's toString() against a substring; containsSemantics(isFocused: true) states it directly. --- .../automation/automation_contract_test.dart | 108 ++++++++++++------ 1 file changed, 72 insertions(+), 36 deletions(-) diff --git a/test/core/automation/automation_contract_test.dart b/test/core/automation/automation_contract_test.dart index a9f87e88a..09a73bba1 100644 --- a/test/core/automation/automation_contract_test.dart +++ b/test/core/automation/automation_contract_test.dart @@ -121,23 +121,30 @@ 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'); + 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.communityCard('abc'), 'onboarding.community.card.abc'); expect(AutomationIds.nodeItem('abc'), 'node.item.abc'); - expect(AutomationIds.settingsRelayItem('ws://x'), 'settings.relays.item.ws://x'); + expect(AutomationIds.settingsRelayItem('ws://x'), + 'settings.relays.item.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('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'); + expect(AutomationIds.tradeAction('payInvoice'), + AutomationIds.tradePayInvoice); + expect( + AutomationIds.tradeAction('takeSell'), AutomationIds.tradeTakeSell); + expect(AutomationIds.orderCreateCurrencyOption('USD'), + 'order.create.currency.USD'); }); }); @@ -165,42 +172,55 @@ void main() { group('AutomationId widget', () { testWidgets('exposes the identifier and merges the label', (tester) async { await tester.pumpWidget(harness( - AutomationId('demo.control', child: ElevatedButton(onPressed: () {}, child: const Text('Go'))), + ElevatedButton(onPressed: () {}, child: const Text('Go')) + .withAutomationId('demo.control'), )); - final data = tester.getSemantics(find.bySemanticsIdentifier('demo.control')).getSemanticsData(); - expect(data.label, 'Go'); - expect(data.flagsCollection.isButton, isTrue); + // `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 { + testWidgets('merged text fields stay editable through accessibility', + (tester) async { final controller = TextEditingController(); await tester.pumpWidget(harness( - AutomationId('demo.field', child: TextField(controller: controller)), + 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')); - final data = node.getSemanticsData(); - expect(data.flagsCollection.isTextField, isTrue); - expect(data.flagsCollection.isFocused.toString(), contains('isTrue')); - expect(data.hasAction(SemanticsAction.setText), isTrue); + final node = + tester.getSemantics(find.bySemanticsIdentifier('demo.field')); + expect( + node, + containsSemantics( + isTextField: true, + isFocused: true, + hasSetTextAction: true, + ), + ); // ignore: deprecated_member_use - tester.binding.pipelineOwner.semanticsOwner! - .performAction(node.id, SemanticsAction.setText, 'ws://10.0.2.2:7000'); + tester.binding.pipelineOwner.semanticsOwner!.performAction( + node.id, SemanticsAction.setText, '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 AutomationId('demo.state', merge: false, label: 'connected', child: Text('Wallet')), + const Text('Wallet') + .withAutomationId('demo.state', merge: false, label: 'connected'), )); - final semantics = tester.getSemantics(find.bySemanticsIdentifier('demo.state')); + final semantics = + tester.getSemantics(find.bySemanticsIdentifier('demo.state')); expect(semantics.label, 'connected'); expect(semantics.getSemanticsData().label, 'connected'); }); @@ -209,12 +229,15 @@ void main() { 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); + 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); + expect(find.bySemanticsIdentifier(AutomationIds.orderAddBuy), + findsOneWidget); + expect(find.bySemanticsIdentifier(AutomationIds.orderAddSell), + findsOneWidget); }); testWidgets('invoice entry', (tester) async { @@ -227,32 +250,45 @@ void main() { fiatCode: 'USD', orderId: 'order-1', ))); - expect(find.bySemanticsIdentifier(AutomationIds.invoiceText), findsOneWidget); - expect(find.bySemanticsIdentifier(AutomationIds.invoiceSubmit), findsOneWidget); - expect(find.bySemanticsIdentifier(AutomationIds.invoiceCancel), findsOneWidget); + 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); + 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); + 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(); + 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')))); + 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); }); From 44bc34b9bd91d8348f2352751982e78b86510473 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 17 Aug 2026 13:12:26 -0300 Subject: [PATCH 10/12] fix: point the automation identifiers at the controls they name Three identifiers named the wrong thing, which is the one failure mode the contract exists to prevent. `trade.dispute.confirm` sat on the cancel dialog's confirmation button, stacked on top of `trade.cancel.confirm`, while the dispute dialog's own button carried nothing. A driver confirming a dispute would have cancelled the order instead. The order-book tabs derived their identifier from `OrderType`, but the Buy BTC tab filters for sell orders, so the two came out swapped. Each call site now passes its identifier explicitly. Every relay delete control shared `settings.relays.item.delete`, so automation could not choose which relay to remove; the control is now keyed by relay URL. Relay identifiers also normalize trailing slashes, the way the relay list itself does, so one relay never carries two identifiers. --- lib/core/automation/automation_ids.dart | 13 +++++++++++-- lib/features/home/screens/home_screen.dart | 9 ++++++--- lib/features/relays/widgets/relay_selector.dart | 2 +- .../trades/screens/trade_detail_screen.dart | 6 ++---- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/core/automation/automation_ids.dart b/lib/core/automation/automation_ids.dart index 8c07c6b15..2861d3981 100644 --- a/lib/core/automation/automation_ids.dart +++ b/lib/core/automation/automation_ids.dart @@ -59,8 +59,17 @@ class AutomationIds { 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.$url'; - static const String settingsRelayDelete = 'settings.relays.item.delete'; + 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'; diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index e6858e0ed..0d8acb21d 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -139,6 +139,7 @@ class HomeScreen extends ConsumerWidget { orderType == OrderType.sell, OrderType.sell, AppTheme.buyColor, + AutomationIds.orderBookTabBuy, ), _buildTabButton( context, @@ -147,6 +148,7 @@ class HomeScreen extends ConsumerWidget { orderType == OrderType.buy, OrderType.buy, AppTheme.sellColor, + AutomationIds.orderBookTabSell, ), ], ), @@ -160,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( @@ -186,9 +191,7 @@ class HomeScreen extends ConsumerWidget { ), ), ), - ).withAutomationId(type == OrderType.buy - ? AutomationIds.orderBookTabBuy - : AutomationIds.orderBookTabSell), + ).withAutomationId(automationId), ); } diff --git a/lib/features/relays/widgets/relay_selector.dart b/lib/features/relays/widgets/relay_selector.dart index 6169d1848..2f2ce0ec8 100644 --- a/lib/features/relays/widgets/relay_selector.dart +++ b/lib/features/relays/widgets/relay_selector.dart @@ -162,7 +162,7 @@ class RelaySelector extends ConsumerWidget { color: Colors.white, size: 24, ), - ).withAutomationId(AutomationIds.settingsRelayDelete), + ).withAutomationId(AutomationIds.settingsRelayDelete(relayInfo.url)), ], ), ); diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 9e0650276..ee3c334ad 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -687,9 +687,7 @@ class TradeDetailScreen extends ConsumerWidget { ), textAlign: TextAlign.center, ), - ) - .withAutomationId(AutomationIds.tradeDisputeConfirm) - .withAutomationId(AutomationIds.tradeCancelConfirm), + ).withAutomationId(AutomationIds.tradeCancelConfirm), ], ), ); @@ -765,7 +763,7 @@ class TradeDetailScreen extends ConsumerWidget { fontWeight: FontWeight.w600, ), ), - ), + ).withAutomationId(AutomationIds.tradeDisputeConfirm), ], ), ); From 1dc0fbcb58f3e1fce8c46176036030c073707b5f Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 17 Aug 2026 13:12:29 -0300 Subject: [PATCH 11/12] fix: choose the semantics merge mode by how many controls a subtree holds Container mode was applied by shape rather than by content. A trade row and the wallet card each wrap a single tap target, so merging is what keeps the tap action on the node that carries the identifier. The community card wraps its own tap plus one per social link, so merging there would collapse independent actions into one node. State readouts with an explicit label keep container mode. The test-environment banner pinned `Directionality` around the whole stack, which forced the application under test into LTR. Only the banner is pinned now; the stack takes an explicit direction so `Positioned` still resolves. --- .../community/widgets/community_card.dart | 5 +++- .../trades/widgets/trades_list_item.dart | 3 +- .../wallet/widgets/wallet_status_card.dart | 2 +- .../widgets/test_environment_banner.dart | 29 +++++++++++-------- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/lib/features/community/widgets/community_card.dart b/lib/features/community/widgets/community_card.dart index 877c32862..01a62bb5e 100644 --- a/lib/features/community/widgets/community_card.dart +++ b/lib/features/community/widgets/community_card.dart @@ -182,7 +182,10 @@ class CommunityCard extends StatelessWidget { ], ), ), - ).withAutomationId(AutomationIds.communityCard(community.pubkey)); + // 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/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index d16dcab51..5e4c0a8c9 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -180,8 +180,7 @@ class TradesListItem extends ConsumerWidget { ), ), ), - ).withAutomationId(AutomationIds.tradesItem(trade.orderId ?? ''), - merge: false); + ).withAutomationId(AutomationIds.tradesItem(trade.orderId ?? '')); } Widget _buildRoleChip(BuildContext context, bool isCreator) { diff --git a/lib/features/wallet/widgets/wallet_status_card.dart b/lib/features/wallet/widgets/wallet_status_card.dart index 5984e8f94..3962f640a 100644 --- a/lib/features/wallet/widgets/wallet_status_card.dart +++ b/lib/features/wallet/widgets/wallet_status_card.dart @@ -124,7 +124,7 @@ class WalletStatusCard extends ConsumerWidget { ], ), ), - ).withAutomationId(AutomationIds.settingsWallet, merge: false), + ).withAutomationId(AutomationIds.settingsWallet), ), ], ), diff --git a/lib/shared/widgets/test_environment_banner.dart b/lib/shared/widgets/test_environment_banner.dart index 21122fefb..4560678e2 100644 --- a/lib/shared/widgets/test_environment_banner.dart +++ b/lib/shared/widgets/test_environment_banner.dart @@ -15,17 +15,22 @@ class TestEnvironmentBanner extends StatelessWidget { if (!TestEnvironment.enabled) { return child; } - return Directionality( + // 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, - child: Stack( - children: [ - child, - Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - bottom: false, + 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, @@ -49,8 +54,8 @@ class TestEnvironmentBanner extends StatelessWidget { ), ), ), - ], - ), + ), + ], ); } } From 46252b8a455f4b4c0899e7cd715be07c6feadbe0 Mon Sep 17 00:00:00 2001 From: grunch Date: Mon, 17 Aug 2026 13:12:30 -0300 Subject: [PATCH 12/12] chore: report startup failures, fail fast on a missing relay seed Bootstrap logged its error paths through `debugPrint`, which is silent in release builds, so a failure to start relay sync, push integration or FCM left no trace. These now go through the singleton logger. A Mortsom build without `MORTSOM_RELAYS` left `Config.discoveryRelays` empty and crashed on an assertion inside dart_nostr; it now fails at startup naming the missing define. The contract test stated the test-environment gate as a fixed false, which would break if the suite ever ran with the define; it is now stated against the define itself. Semantics actions go through the supported `tester.semantics.performAction` rather than the deprecated pipeline owner. The identifier table was missing `onboarding.community.notice.accept` and `keys.generate.cancel`, and named `Config.discoveryRelays` where it meant `Config.bootstrapRelays`. --- docs/automation-contract.md | 11 +++++--- lib/core/app_bootstrap.dart | 11 ++++---- lib/main_mortsom.dart | 9 ++++++ .../automation/automation_contract_test.dart | 28 +++++++++++++------ 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/docs/automation-contract.md b/docs/automation-contract.md index deeb3609c..4414a31b6 100644 --- a/docs/automation-contract.md +++ b/docs/automation-contract.md @@ -52,16 +52,17 @@ listed below disappears, is renamed, or stops being namespaced. | `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` | account | Generate a new identity. | +| `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. | +| `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`. | @@ -106,8 +107,10 @@ 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.discoveryRelays`); a disconnected local relay produces a test - failure, not public-network traffic; + (`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 diff --git a/lib/core/app_bootstrap.dart b/lib/core/app_bootstrap.dart index 0798dcde3..d264c7e63 100644 --- a/lib/core/app_bootstrap.dart +++ b/lib/core/app_bootstrap.dart @@ -112,7 +112,7 @@ void _initializeRelaySynchronization(ProviderContainer container) { container.read(relaysProvider); } catch (e) { // Log error but don't crash app if relay sync initialization fails - debugPrint('Failed to initialize relay synchronization: $e'); + logger.e('Failed to initialize relay synchronization', error: e); } } @@ -142,9 +142,9 @@ void _initializePushNotificationIntegration( return container.read(settingsProvider).mostroPublicKey; }; - debugPrint('Push notification integration initialized'); + logger.i('Push notification integration initialized'); } catch (e) { - debugPrint('Failed to initialize push notification integration: $e'); + logger.e('Failed to initialize push notification integration', error: e); } } @@ -183,8 +183,7 @@ Future<_PushServices?> _initializeFirebaseMessaging( try { // Skip Firebase initialization on Linux (not supported) if (!kIsWeb && Platform.isLinux) { - debugPrint( - 'Firebase not supported on Linux - skipping FCM initialization'); + logger.i('Firebase not supported on Linux - skipping FCM initialization'); return null; } @@ -204,7 +203,7 @@ Future<_PushServices?> _initializeFirebaseMessaging( 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'); + logger.e('Failed to initialize Firebase Cloud Messaging', error: e); return null; } } diff --git a/lib/main_mortsom.dart b/lib/main_mortsom.dart index f62176276..3f4599840 100644 --- a/lib/main_mortsom.dart +++ b/lib/main_mortsom.dart @@ -18,6 +18,15 @@ 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(); diff --git a/test/core/automation/automation_contract_test.dart b/test/core/automation/automation_contract_test.dart index 09a73bba1..136a28af1 100644 --- a/test/core/automation/automation_contract_test.dart +++ b/test/core/automation/automation_contract_test.dart @@ -52,7 +52,6 @@ const List staticIds = [ AutomationIds.settingsRelaysAddUrl, AutomationIds.settingsRelaysAddConfirm, AutomationIds.settingsRelaysAddCancel, - AutomationIds.settingsRelayDelete, AutomationIds.nodeAddCustom, AutomationIds.nodeCustomPubkey, AutomationIds.nodeCustomName, @@ -134,6 +133,14 @@ void main() { 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( @@ -152,14 +159,15 @@ void main() { 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(); - // Tests never carry MORTSOM_TEST_ENV, so arming alone must not enable it. expect(TestEnvironment.enabled, TestEnvironment.defineEnabled); - expect(TestEnvironment.defineEnabled, isFalse); - expect(TestEnvironment.seedRelays, isEmpty); - expect(TestEnvironment.disableBootstrapFallback, isFalse); - expect(TestEnvironment.allowInsecureRelays, isFalse); + expect(TestEnvironment.disableBootstrapFallback, + TestEnvironment.defineEnabled); + expect( + TestEnvironment.allowInsecureRelays, TestEnvironment.defineEnabled); }); test('parses relay lists', () { @@ -206,9 +214,11 @@ void main() { ), ); - // ignore: deprecated_member_use - tester.binding.pipelineOwner.semanticsOwner!.performAction( - node.id, SemanticsAction.setText, 'ws://10.0.2.2:7000'); + 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'); });