diff --git a/mobile-app/lib/app.dart b/mobile-app/lib/app.dart index 475e3998..08ca8f05 100644 --- a/mobile-app/lib/app.dart +++ b/mobile-app/lib/app.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:resonance_network_wallet/shared/global_navigator_key.dart'; import 'package:resonance_network_wallet/wallet_initializer.dart'; import 'package:resonance_network_wallet/v2/screens/auth/auth_wrapper.dart'; import 'package:resonance_network_wallet/v2/theme/app_theme.dart'; @@ -25,14 +24,13 @@ class _ResonanceWalletAppState extends ConsumerState { void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(notificationIntegrationServiceProvider); - ref.read(deepLinkServiceProvider).init(navigatorKey); - ref.read(localNotificationsServiceProvider).setupNotificationsClickListener(navigatorKey); - ref.read(localNotificationsServiceProvider).handleLaunchByNotification(navigatorKey); + ref.read(notificationIntegrationServiceProvider); + ref.read(deepLinkServiceProvider).init(); + final localNotifications = ref.read(localNotificationsServiceProvider); + localNotifications.setupNotificationsClickListener(); + localNotifications.handleLaunchByNotification(); - if (Platform.isAndroid) _referralService.checkPlayStoreReferralCode(); - }); + if (Platform.isAndroid) _referralService.checkPlayStoreReferralCode(); } @override @@ -45,15 +43,9 @@ class _ResonanceWalletAppState extends ConsumerState { Widget build(BuildContext context) { return MaterialApp( title: 'Quantus Wallet', - navigatorKey: navigatorKey, navigatorObservers: [TelemetryNavigatorObserver()], initialRoute: '/', - routes: { - '/': (context) => const WalletInitializer(), - // These routes are for deep linking, each will carry an intent - '/account': (context) => const WalletInitializer(), - '/transactions': (context) => const WalletInitializer(), - }, + routes: {'/': (context) => const WalletInitializer()}, theme: AppTheme.darkTheme(context), darkTheme: AppTheme.darkTheme(context), themeMode: ThemeMode.dark, diff --git a/mobile-app/lib/providers/remote_config_provider.dart b/mobile-app/lib/providers/remote_config_provider.dart index 4afaf985..93994e4d 100644 --- a/mobile-app/lib/providers/remote_config_provider.dart +++ b/mobile-app/lib/providers/remote_config_provider.dart @@ -1,12 +1,10 @@ import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:async'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/firebase_options.dart'; import 'package:resonance_network_wallet/services/remote_config_service.dart'; import 'package:resonance_network_wallet/services/firebase_messaging_service.dart'; -import 'package:resonance_network_wallet/shared/global_navigator_key.dart'; final remoteConfigServiceProvider = Provider((ref) { return RemoteConfigService(); @@ -68,11 +66,7 @@ class RemoteConfigNotifier extends StateNotifier { final fcmService = ref.read(firebaseMessagingServiceProvider); await fcmService.init(); // This requests notification permission. - - // Ensure navigatorKey.currentState is attached before handling any initial message. - WidgetsBinding.instance.addPostFrameCallback((_) { - fcmService.setupNotificationTapHandlers(navigatorKey); - }); + fcmService.setupNotificationTapHandlers(); _isEnablingRemoteNotifications = false; } diff --git a/mobile-app/lib/services/deep_link_service.dart b/mobile-app/lib/services/deep_link_service.dart index da57877a..e25c2c73 100644 --- a/mobile-app/lib/services/deep_link_service.dart +++ b/mobile-app/lib/services/deep_link_service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:flutter/material.dart'; import 'package:app_links/app_links.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonance_network_wallet/providers/account_associations_providers.dart'; @@ -17,22 +16,22 @@ class DeepLinkService { final _appLinks = AppLinks(); StreamSubscription? _linkSubscription; - Future init(GlobalKey navigatorKey) async { + Future init() async { // Handle links when the app is already open (warm state) _linkSubscription = _appLinks.uriLinkStream.listen((uri) { print('Received link while app is open: $uri'); - _handleLink(uri, navigatorKey); + _handleLink(uri); }); // Handle the link that opened the app (cold state) final initialUri = await _appLinks.getInitialLink(); if (initialUri != null) { print('Received initial link: $initialUri'); - _handleLink(initialUri, navigatorKey); + _handleLink(initialUri); } } - void _handleLink(Uri uri, GlobalKey navigatorKey) { + void _handleLink(Uri uri) { if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'account') { String? accountId; diff --git a/mobile-app/lib/services/firebase_messaging_service.dart b/mobile-app/lib/services/firebase_messaging_service.dart index 98056dab..ed495db1 100644 --- a/mobile-app/lib/services/firebase_messaging_service.dart +++ b/mobile-app/lib/services/firebase_messaging_service.dart @@ -22,7 +22,7 @@ class FirebaseMessagingService { final SenotiService _senotiService = SenotiService(); bool _isInitialized = false; - bool __hasRegisteredHandlers = false; + bool _hasRegisteredHandlers = false; String? _cachedToken; FirebaseMessagingService(this._ref); @@ -106,6 +106,7 @@ class FirebaseMessagingService { } try { await _senotiService.unregisterDevice(token, _platform); + _cachedToken = null; } catch (e) { debugPrint('Failed to unregister device: $e'); } @@ -146,33 +147,33 @@ class FirebaseMessagingService { } /// Handle the user tapping on an FCM notification that launched/resumed the app. - /// Call this after the navigator key is available. - void setupNotificationTapHandlers(GlobalKey navigatorKey) { - if (__hasRegisteredHandlers) return; - __hasRegisteredHandlers = true; + void setupNotificationTapHandlers() { + if (_hasRegisteredHandlers) return; + _hasRegisteredHandlers = true; FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { debugPrint('FCM notification tapped (background): ${message.messageId}'); - _handleNotificationTap(message, navigatorKey); + _handleNotificationTap(message); }); - _handleInitialMessage(navigatorKey); + _handleInitialMessage(); } - Future _handleInitialMessage(GlobalKey navigatorKey) async { + Future _handleInitialMessage() async { final initialMessage = await _messaging.getInitialMessage(); if (initialMessage != null) { debugPrint('FCM initial message (terminated): ${initialMessage.messageId}'); - _handleNotificationTap(initialMessage, navigatorKey); + _handleNotificationTap(initialMessage); } } - void _handleNotificationTap(RemoteMessage message, GlobalKey navigatorKey) { + void _handleNotificationTap(RemoteMessage message) { final data = message.data; + if (data.isEmpty) return; final txService = _ref.read(transactionServiceProvider); - txService.navigateToTransactionFromPayloadIfPossible(data, navigatorKey); + txService.navigateToTransactionFromPayloadIfPossible(data); } NotificationData? _remoteMessageToNotificationData(RemoteMessage message) { diff --git a/mobile-app/lib/services/local_notifications_service.dart b/mobile-app/lib/services/local_notifications_service.dart index 6617aa21..cebcb6d6 100644 --- a/mobile-app/lib/services/local_notifications_service.dart +++ b/mobile-app/lib/services/local_notifications_service.dart @@ -1,10 +1,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonance_network_wallet/models/notification_models.dart'; +import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/services/transaction_service.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/data/latest.dart' as tz; @@ -69,7 +70,7 @@ class LocalNotificationsService { } // This is for handling when app is in terminated state then launched by tapping notification. - Future handleLaunchByNotification(GlobalKey navigatorKey) async { + Future handleLaunchByNotification() async { final notificationAppLaunchDetails = await _notificationPlugin.getNotificationAppLaunchDetails(); if (notificationAppLaunchDetails?.didNotificationLaunchApp != true) return; @@ -77,9 +78,17 @@ class LocalNotificationsService { if (payload == null || payload.isEmpty) return; final txService = _ref.read(transactionServiceProvider); - final json = jsonDecode(payload); - - txService.navigateToTransactionFromPayloadIfPossible(json, navigatorKey); + try { + final json = jsonDecode(payload); + txService.navigateToTransactionFromPayloadIfPossible(json); + } catch (e) { + debugPrint('Error decoding payload handle launch by notification: $e'); + TelemetryService().sendError( + 'Error decoding notification launch payload', + error: e.runtimeType.toString(), + stackTrace: StackTrace.current, + ); + } } Future _showNotification(NotificationData notification) async { @@ -125,14 +134,23 @@ class LocalNotificationsService { } } - void setupNotificationsClickListener(GlobalKey navigatorKey) { + void setupNotificationsClickListener() { _onNotificationClick.stream.listen((payload) { if (payload == null || payload.isEmpty) return; final txService = _ref.read(transactionServiceProvider); - final json = jsonDecode(payload); - txService.navigateToTransactionFromPayloadIfPossible(json, navigatorKey); + try { + final json = jsonDecode(payload); + txService.navigateToTransactionFromPayloadIfPossible(json); + } catch (e) { + debugPrint('Error decoding payload setup notifications click listener: $e'); + TelemetryService().sendError( + 'Error decoding notification click payload', + error: e.runtimeType.toString(), + stackTrace: StackTrace.current, + ); + } }); } diff --git a/mobile-app/lib/services/transaction_service.dart b/mobile-app/lib/services/transaction_service.dart index c4f8ea02..43158378 100644 --- a/mobile-app/lib/services/transaction_service.dart +++ b/mobile-app/lib/services/transaction_service.dart @@ -1,9 +1,10 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/models/transaction_role.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; +import 'package:resonance_network_wallet/services/telemetry_service.dart'; final transactionServiceProvider = Provider((ref) { return TransactionService(ref); @@ -89,12 +90,11 @@ class TransactionService { } } - void navigateToTransactionFromPayloadIfPossible(Map? json, GlobalKey navigatorKey) { + void navigateToTransactionFromPayloadIfPossible(Map json) { final event = deserializeTxEventFromJsonIfPossible(json); if (event != null) { _ref.read(transactionIntentProvider.notifier).state = event; - navigatorKey.currentState?.pushNamed('/transactions'); } } @@ -111,7 +111,12 @@ class TransactionService { event = PendingTransactionEvent.fromJson(json); } } catch (e) { - print('Failed deserializing event: $e'); + debugPrint('Failed deserializing $txType event: $e'); + TelemetryService().sendError( + 'Failed deserializing $txType event', + error: e.runtimeType.toString(), + stackTrace: StackTrace.current, + ); } return event; diff --git a/mobile-app/lib/shared/global_navigator_key.dart b/mobile-app/lib/shared/global_navigator_key.dart deleted file mode 100644 index 5691d874..00000000 --- a/mobile-app/lib/shared/global_navigator_key.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:flutter/material.dart'; - -final GlobalKey navigatorKey = GlobalKey(); diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 5f2cd01a..28a649fb 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -13,6 +13,7 @@ import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/quantus_icon_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/screens/accounts/open_accounts_management_button.dart'; +import 'package:resonance_network_wallet/v2/screens/activity/transaction_detail_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/receive/receive_screen.dart'; import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; import 'package:resonance_network_wallet/v2/screens/send/select_recipient_screen.dart'; @@ -40,6 +41,53 @@ class HomeScreen extends ConsumerStatefulWidget { } class _HomeScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + + ref.listenManual(transactionIntentProvider, _onTransactionIntent); + ref.listenManual(paymentIntentProvider, _onPaymentIntent); + ref.listenManual(sharedAccountIntentProvider, _onSharedIntent); + ref.listenManual>(activeAccountProvider, (_, async) { + if (async.value == null) return; + _onTransactionIntent(null, ref.read(transactionIntentProvider)); + }); + + Future.microtask(_drainPendingIntents); + } + + void _drainPendingIntents() { + if (!mounted) return; + _onTransactionIntent(null, ref.read(transactionIntentProvider)); + _onPaymentIntent(null, ref.read(paymentIntentProvider)); + _onSharedIntent(null, ref.read(sharedAccountIntentProvider)); + } + + void _onTransactionIntent(TransactionEvent? _, TransactionEvent? transaction) { + if (transaction == null || !mounted) return; + final active = ref.read(activeAccountProvider).value; + if (active == null) return; + ref.read(transactionIntentProvider.notifier).state = null; + showTransactionDetailSheet(context, transaction, active.account.accountId); + } + + void _onPaymentIntent(PaymentIntent? _, PaymentIntent? payment) { + if (payment == null || !mounted) return; + ref.read(paymentIntentProvider.notifier).state = null; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => InputAmountScreen(recipientAddress: payment.to, initialAmount: payment.amount, isPayMode: true), + ), + ); + } + + void _onSharedIntent(String? _, String? shared) { + if (shared == null || !mounted) return; + ref.read(sharedAccountIntentProvider.notifier).state = null; + showSharedAddressActionSheet(context, shared); + } + Future _refresh() async { final active = ref.read(activeAccountProvider).value; ref.invalidate(balanceProviderFamily); @@ -70,24 +118,6 @@ class _HomeScreenState extends ConsumerState { @override Widget build(BuildContext context) { - ref.listen(paymentIntentProvider, (_, payment) { - if (payment == null) return; - ref.read(paymentIntentProvider.notifier).state = null; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - InputAmountScreen(recipientAddress: payment.to, initialAmount: payment.amount, isPayMode: true), - ), - ); - }); - - ref.listen(sharedAccountIntentProvider, (_, shared) { - if (shared == null) return; - ref.read(sharedAccountIntentProvider.notifier).state = null; - showSharedAddressActionSheet(context, shared); - }); - final accountAsync = ref.watch(activeAccountProvider); final txAsync = ref.watch(activeAccountTransactionsProvider(TransactionFilter.all)); final colors = context.colors; diff --git a/mobile-app/test/unit/transaction_service_test.dart b/mobile-app/test/unit/transaction_service_test.dart new file mode 100644 index 00000000..5c93dcb4 --- /dev/null +++ b/mobile-app/test/unit/transaction_service_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/services/transaction_service.dart'; + +void main() { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + group('TransactionService.deserializeTxEventFromJsonIfPossible', () { + /// Typical REST shape (nested maps, amount/fee as decimal strings). + Map transferPayloadFromSample() => { + 'amount': '1000000000', + 'sender': {'id': 'qzjij4Tiow9jtse9d7L1T3NEZuxgFW8JdUbaTLsfgubF7ZQAC'}, + 'id': '0000197242-9c90c-000002', + 'receiver': {'id': 'qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEda'}, + 'block': {'hash': '0x9c90cf5b0d7348e49c7ed427b42f5cc7cfe37e11bd7f4e3254c7fc4c7acbbf62', 'height': 197242}, + 'extrinsic': {'id': '0x60db7af926aa917d0e15c02fa4ddf54ed759ae564b380b8e73b63570000924e7'}, + 'fee': '8189972000', + 'type': 'TRANSFER', + 'timestamp': '2026-05-12T12:39:51.706Z', + }; + + test('returns TransferEvent for TRANSFER type with sample payload', () { + final service = container.read(transactionServiceProvider); + final json = transferPayloadFromSample(); + + final event = service.deserializeTxEventFromJsonIfPossible(json); + + expect(event, isA()); + final transfer = event! as TransferEvent; + expect(transfer.id, '0000197242-9c90c-000002'); + expect(transfer.from, 'qzjij4Tiow9jtse9d7L1T3NEZuxgFW8JdUbaTLsfgubF7ZQAC'); + expect(transfer.to, 'qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEda'); + expect(transfer.amount, BigInt.parse('1000000000')); + expect(transfer.fee, BigInt.parse('8189972000')); + expect(transfer.blockNumber, 197242); + expect(transfer.blockHash, '0x9c90cf5b0d7348e49c7ed427b42f5cc7cfe37e11bd7f4e3254c7fc4c7acbbf62'); + expect(transfer.extrinsicHash, '0x60db7af926aa917d0e15c02fa4ddf54ed759ae564b380b8e73b63570000924e7'); + expect(transfer.timestamp.toUtc(), DateTime.parse('2026-05-12T12:39:51.706Z').toUtc()); + }); + + test('returns null when type is not supported', () { + final service = container.read(transactionServiceProvider); + final json = {...transferPayloadFromSample(), 'type': 'UNKNOWN'}; + + expect(service.deserializeTxEventFromJsonIfPossible(json), isNull); + }); + + test('parses FCM-style TRANSFER (JSON string block/senders, int amounts, ' + 'top-level extrinsicHash)', () { + final service = container.read(transactionServiceProvider); + final json = { + 'amount': 1000000000, + 'fee': 8189972000, + 'sender': '{"id":"qzjij4Tiow9jtse9d7L1T3NEZuxgFW8JdUbaTLsfgubF7ZQAC"}', + 'id': '0000197378-4f5d2-000002', + 'receiver': '{"id":"qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEda"}', + 'block': '{"hash":"0x4f5d27e7b1c679e64292606c9560432f610f7a6fccab992f5ab6326f5171f90c","height":197378}', + 'extrinsicHash': '0xc399a24c2cd9b3a85f2b10cdb6a0bb98ff8f02eb8185c23959f9b7e7d943a9b7', + 'type': 'TRANSFER', + 'timestamp': '2026-05-12T13:08:21.846Z', + }; + + final event = service.deserializeTxEventFromJsonIfPossible(json); + + expect(event, isA()); + final transfer = event! as TransferEvent; + expect(transfer.blockNumber, 197378); + expect(transfer.extrinsicHash, '0xc399a24c2cd9b3a85f2b10cdb6a0bb98ff8f02eb8185c23959f9b7e7d943a9b7'); + }); + }); +} diff --git a/quantus_sdk/lib/src/models/json_dynamic_parse.dart b/quantus_sdk/lib/src/models/json_dynamic_parse.dart new file mode 100644 index 00000000..27c4ddb1 --- /dev/null +++ b/quantus_sdk/lib/src/models/json_dynamic_parse.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; + +/// Parses values produced by APIs that sometimes nest objects and sometimes +/// JSON-encode them as strings (e.g. FCM [RemoteMessage.data]). +Map? jsonMapOrNull(dynamic value) { + if (value == null) return null; + if (value is Map) return value; + if (value is Map) return Map.from(value); + if (value is String) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + final decoded = jsonDecode(trimmed); + if (decoded is Map) return decoded; + if (decoded is Map) return Map.from(decoded); + throw FormatException('JSON string must decode to an object, got ${decoded.runtimeType}'); + } + throw FormatException('Expected Map or JSON object string, got ${value.runtimeType}'); +} + +Map jsonMapRequired(dynamic value, String fieldName) { + final m = jsonMapOrNull(value); + if (m == null) { + throw FormatException('Missing or empty map for $fieldName'); + } + return m; +} + +BigInt bigIntFromJson(dynamic value) { + if (value is BigInt) return value; + if (value is int) return BigInt.from(value); + if (value is String) return BigInt.parse(value); + throw FormatException('Cannot parse BigInt from ${value.runtimeType}: $value'); +} + +DateTime dateTimeFromJson(dynamic value) { + if (value is DateTime) return value; + if (value is String) return DateTime.parse(value); + throw FormatException('Cannot parse DateTime from ${value.runtimeType}: $value'); +} + +String stringFromJson(dynamic value) { + if (value is String) return value; + throw FormatException('Expected String, got ${value.runtimeType}: $value'); +} + +/// Resolves `{"id":"..."}`, a JSON string of that object, or a bare address +/// string. +String nestedAccountId(dynamic holder) { + if (holder == null) return ''; + if (holder is String) { + final s = holder.trim(); + if (s.startsWith('{')) { + final m = jsonMapOrNull(s); + final id = m?['id']; + if (id is String) return id; + if (id != null) return id.toString(); + return ''; + } + return s; + } + final m = jsonMapOrNull(holder); + if (m != null) { + final id = m['id']; + if (id is String) return id; + if (id != null) return id.toString(); + } + return ''; +} + +String? optionalExtrinsicHash(Map json) { + final nested = jsonMapOrNull(json['extrinsic'])?['id']; + if (nested is String) return nested; + final direct = json['extrinsicHash']; + if (direct is String) return direct; + return null; +} + +int blockHeightFromJsonMap(Map? block) { + if (block == null) return 0; + final h = block['height']; + if (h == null) return 0; + if (h is int) return h; + if (h is num) return h.toInt(); + if (h is String) return int.parse(h); + throw FormatException('Invalid block height: $h (${h.runtimeType})'); +} + +String blockHashFromJsonMap(Map? block) { + if (block == null) return ''; + final hash = block['hash']; + if (hash == null) return ''; + if (hash is String) return hash; + return hash.toString(); +} diff --git a/quantus_sdk/lib/src/models/transaction_event.dart b/quantus_sdk/lib/src/models/transaction_event.dart index c844c194..3061ab1d 100644 --- a/quantus_sdk/lib/src/models/transaction_event.dart +++ b/quantus_sdk/lib/src/models/transaction_event.dart @@ -1,5 +1,7 @@ import 'package:quantus_sdk/quantus_sdk.dart'; +import 'json_dynamic_parse.dart'; + // Base class for different transaction types abstract class TransactionEvent { final String id; @@ -45,17 +47,17 @@ class TransferEvent extends TransactionEvent { }); factory TransferEvent.fromJson(Map json) { - final block = json['block'] as Map?; - final blockHeight = block?['height'] as int? ?? 0; - final blockHash = block?['hash'] as String? ?? ''; + final block = jsonMapOrNull(json['block']); + final blockHeight = blockHeightFromJsonMap(block); + final blockHash = blockHashFromJsonMap(block); return TransferEvent( - id: json['id'] as String, - from: json['from']?['id'] as String? ?? '', - to: json['to']?['id'] as String? ?? '', - amount: BigInt.parse(json['amount'] as String), - timestamp: DateTime.parse(json['timestamp'] as String), - fee: json['fee'] != null ? BigInt.parse(json['fee'] as String) : BigInt.zero, - extrinsicHash: json['extrinsic']?['id'] as String?, + id: stringFromJson(json['id']), + from: nestedAccountId(json['sender'] ?? json['from']), + to: nestedAccountId(json['receiver'] ?? json['to']), + amount: bigIntFromJson(json['amount']), + timestamp: dateTimeFromJson(json['timestamp']), + fee: json['fee'] != null ? bigIntFromJson(json['fee']) : BigInt.zero, + extrinsicHash: optionalExtrinsicHash(json), blockNumber: blockHeight, blockHash: blockHash, ); @@ -104,20 +106,20 @@ class ReversibleTransferEvent extends TransactionEvent { // Create a ReversibleTransferEvent with a known status factory ReversibleTransferEvent.fromJson(Map json, {required ReversibleTransferStatus status}) { - final block = json['block'] as Map; - final transfer = json['scheduledTransfer'] as Map? ?? json; + final block = jsonMapRequired(json['block'], 'block'); + final transfer = jsonMapOrNull(json['scheduledTransfer']) ?? json; return ReversibleTransferEvent( - id: json['id'] as String, - from: transfer['from']?['id'] as String? ?? '', - to: transfer['to']?['id'] as String? ?? '', - amount: BigInt.parse(transfer['amount'] as String), - timestamp: DateTime.parse(json['timestamp'] as String), - txId: json['txId'] as String, + id: stringFromJson(json['id']), + from: nestedAccountId(transfer['sender'] ?? transfer['from']), + to: nestedAccountId(transfer['receiver'] ?? transfer['to']), + amount: bigIntFromJson(transfer['amount']), + timestamp: dateTimeFromJson(json['timestamp']), + txId: stringFromJson(json['txId']), status: status, - scheduledAt: DateTime.parse(transfer['scheduledAt'] as String), - extrinsicHash: json['extrinsic']?['id'] as String?, - blockNumber: block['height'] as int, - blockHash: block['hash'] as String? ?? '', + scheduledAt: dateTimeFromJson(transfer['scheduledAt']), + extrinsicHash: optionalExtrinsicHash(json), + blockNumber: blockHeightFromJsonMap(block), + blockHash: blockHashFromJsonMap(block), ); } diff --git a/quantus_sdk/test/models/transfer_event_from_json_test.dart b/quantus_sdk/test/models/transfer_event_from_json_test.dart new file mode 100644 index 00000000..d626786d --- /dev/null +++ b/quantus_sdk/test/models/transfer_event_from_json_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/src/models/transaction_event.dart'; + +void main() { + group('TransferEvent.fromJson', () { + test('parses nested maps from typical REST JSON', () { + final json = { + 'amount': '1000000000', + 'sender': {'id': 'qzjij4Tiow9jtse9d7L1T3NEZuxgFW8JdUbaTLsfgubF7ZQAC'}, + 'id': '0000197242-9c90c-000002', + 'receiver': {'id': 'qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEda'}, + 'block': { + 'hash': '0x9c90cf5b0d7348e49c7ed427b42f5cc7cfe37e11bd7f4e3254c7fc4c7acbbf62', + 'height': 197242, + }, + 'extrinsic': {'id': '0x60db7af926aa917d0e15c02fa4ddf54ed759ae564b380b8e73b63570000924e7'}, + 'fee': '8189972000', + 'timestamp': '2026-05-12T12:39:51.706Z', + }; + + final event = TransferEvent.fromJson(json); + + expect(event.blockNumber, 197242); + expect(event.amount, BigInt.parse('1000000000')); + }); + + test('parses FCM-style string-encoded nested objects and int amounts', () { + final json = { + 'amount': 1000000000, + 'fee': 8189972000, + 'sender': '{"id":"qzjij4Tiow9jtse9d7L1T3NEZuxgFW8JdUbaTLsfgubF7ZQAC"}', + 'id': '0000197378-4f5d2-000002', + 'receiver': '{"id":"qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEda"}', + 'block': '{"hash":"0x4f5d27e7b1c679e64292606c9560432f610f7a6fccab992f5ab6326f5171f90c","height":197378}', + 'extrinsicHash': '0xc399a24c2cd9b3a85f2b10cdb6a0bb98ff8f02eb8185c23959f9b7e7d943a9b7', + 'timestamp': '2026-05-12T13:08:21.846Z', + }; + + final event = TransferEvent.fromJson(json); + + expect(event.blockNumber, 197378); + expect(event.extrinsicHash, '0xc399a24c2cd9b3a85f2b10cdb6a0bb98ff8f02eb8185c23959f9b7e7d943a9b7'); + expect(event.amount, BigInt.from(1000000000)); + expect(event.fee, BigInt.from(8189972000)); + }); + }); +}