diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 39df71b3..7aae8516 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,6 +33,12 @@ jobs: run: melos exec --concurrency=1 -- "flutter clean" - name: Analyze run: melos exec --concurrency=1 -- "flutter analyze ." + - name: Create mock .env file to not fail the tests + run: touch mobile-app/.env + - name: Test Mobile App + run: melos exec --concurrency=1 --scope="resonance_network_wallet" -- "flutter test" + - name: Test SDK Feature Flags API Contract + run: melos exec --concurrency=1 --scope="quantus_sdk" -- "flutter test test/contract" # build_android: # name: Build Android diff --git a/mobile-app/lib/app.dart b/mobile-app/lib/app.dart index 623cbfd2..475e3998 100644 --- a/mobile-app/lib/app.dart +++ b/mobile-app/lib/app.dart @@ -1,10 +1,9 @@ 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/utils/feature_flags.dart'; import 'package:resonance_network_wallet/v2/screens/auth/auth_wrapper.dart'; import 'package:resonance_network_wallet/v2/theme/app_theme.dart'; -import 'package:resonance_network_wallet/services/firebase_messaging_service.dart'; import 'package:resonance_network_wallet/services/local_notifications_service.dart'; import 'package:resonance_network_wallet/services/notification_integration_service.dart'; import 'package:resonance_network_wallet/services/referral_service.dart'; @@ -12,9 +11,6 @@ import 'package:resonance_network_wallet/services/telemetry_navigator_observer.d import 'package:resonance_network_wallet/services/deep_link_service.dart'; import 'dart:io' show Platform; -// This ensures it's a single, persistent key for the entire app lifecycle. -final GlobalKey navigatorKey = GlobalKey(); - class ResonanceWalletApp extends ConsumerStatefulWidget { const ResonanceWalletApp({super.key}); @@ -35,10 +31,6 @@ class _ResonanceWalletAppState extends ConsumerState { ref.read(localNotificationsServiceProvider).setupNotificationsClickListener(navigatorKey); ref.read(localNotificationsServiceProvider).handleLaunchByNotification(navigatorKey); - if (FeatureFlags.enableRemoteNotifications) { - ref.read(firebaseMessagingServiceProvider).setupNotificationTapHandlers(navigatorKey); - } - if (Platform.isAndroid) _referralService.checkPlayStoreReferralCode(); }); } diff --git a/mobile-app/lib/app_initializer.dart b/mobile-app/lib/app_initializer.dart index dd1fefce..3217fd73 100644 --- a/mobile-app/lib/app_initializer.dart +++ b/mobile-app/lib/app_initializer.dart @@ -1,9 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:resonance_network_wallet/services/firebase_messaging_service.dart'; +import 'package:resonance_network_wallet/providers/feature_flags_provider.dart'; import 'package:resonance_network_wallet/services/history_polling_manager.dart'; import 'package:resonance_network_wallet/services/local_notifications_service.dart'; -import 'package:resonance_network_wallet/utils/feature_flags.dart'; /// Widget that initializes the polling services for the entire app. /// This should be placed high in the widget tree, typically in your main app @@ -21,19 +22,17 @@ class _AppInitializerState extends ConsumerState { @override void initState() { super.initState(); + _initialize(); } Future _initialize() async { try { + ref.read(featureFlagsProvider.notifier).registerRemoteRefreshListener(ref); + final notificationService = ref.read(localNotificationsServiceProvider); await notificationService.init(); - if (FeatureFlags.enableRemoteNotifications) { - final fcmService = ref.read(firebaseMessagingServiceProvider); - await fcmService.init(); - } - ref.read(historyPollingManagerProvider); } catch (e, stackTrace) { debugPrint('Initialization error: $e\n$stackTrace'); diff --git a/mobile-app/lib/app_lifecycle_manager.dart b/mobile-app/lib/app_lifecycle_manager.dart index 0d35eafb..ee1f0ed5 100644 --- a/mobile-app/lib/app_lifecycle_manager.dart +++ b/mobile-app/lib/app_lifecycle_manager.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/connectivity_provider.dart'; +import 'package:resonance_network_wallet/providers/feature_flags_provider.dart'; import 'package:resonance_network_wallet/providers/local_auth_provider.dart'; import 'package:resonance_network_wallet/services/history_polling_manager.dart'; @@ -99,6 +102,9 @@ class _AppLifecycleManagerState extends ConsumerState with // Initialize Taskmaster login if wallet exists _initializeTaskmasterLogin(); + + // Sync feature flags on background resume + unawaited(ref.read(featureFlagsProvider.notifier).syncFlags()); } } else { // Handle background states (inactive, paused, hidden, detached) diff --git a/mobile-app/lib/main.dart b/mobile-app/lib/main.dart index 26b28cfa..282cbaae 100644 --- a/mobile-app/lib/main.dart +++ b/mobile-app/lib/main.dart @@ -1,4 +1,3 @@ -import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -8,10 +7,8 @@ import 'package:resonance_network_wallet/app_initializer.dart'; import 'package:resonance_network_wallet/app_lifecycle_manager.dart'; import 'package:resonance_network_wallet/app.dart'; import 'package:resonance_network_wallet/utils/env_utils.dart'; -import 'package:resonance_network_wallet/utils/feature_flags.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:telemetrydecksdk/telemetrydecksdk.dart'; -import 'package:resonance_network_wallet/firebase_options.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -22,9 +19,6 @@ void main() async { // Initialize Supabase await Supabase.initialize(url: EnvUtils.supabaseUrl, anonKey: EnvUtils.supabaseKey); await QuantusSdk.init(); - if (FeatureFlags.enableRemoteNotifications) { - await Firebase.initializeApp(options: DefaultFirebaseOptions.getOptionsForEnvironment()); - } Telemetrydecksdk.start( const TelemetryManagerConfiguration( diff --git a/mobile-app/lib/providers/feature_flags_provider.dart b/mobile-app/lib/providers/feature_flags_provider.dart new file mode 100644 index 00000000..6b431460 --- /dev/null +++ b/mobile-app/lib/providers/feature_flags_provider.dart @@ -0,0 +1,80 @@ +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/feature_flags_service.dart'; +import 'package:resonance_network_wallet/services/firebase_messaging_service.dart'; +import 'package:resonance_network_wallet/shared/global_navigator_key.dart'; + +final featureFlagsServiceProvider = Provider((ref) { + return FeatureFlagsService(); +}); + +final featureFlagsProvider = StateNotifierProvider((ref) { + return FeatureFlagsNotifier(ref.read(featureFlagsServiceProvider)); +}); + +class FeatureFlagsNotifier extends StateNotifier { + final FeatureFlagsService _service; + bool _isRefreshingRemote = false; + bool _isEnablingRemoteNotifications = false; + + FeatureFlagsNotifier(this._service) : super(_service.readLocalFlags()) { + syncFlags(); + } + + Future syncFlags() async { + // Fetch remote in the background. This should not block startup feel. + if (_isRefreshingRemote) return; + _isRefreshingRemote = true; + + unawaited(() async { + try { + final remote = await _service.readRemoteFlags(); + if (remote == null) return; + + if (remote != state) { + _service.cacheFlags(remote.toCacheJson()); + state = remote; + } + } catch (e) { + // Keep using cached flags on failure. + print('Feature flags remote refresh failed: $e'); + } finally { + _isRefreshingRemote = false; + } + }()); + } + + void registerRemoteRefreshListener(WidgetRef ref) { + // using `listenManual` allows + // setting up the side-effect listener from `initState`/async code. + ref.listenManual(featureFlagsProvider, (previous, next) { + if (!next.enableRemoteNotifications) return; + unawaited(_enableRemoteNotificationsIfNeeded(ref)); + }); + } + + Future _enableRemoteNotificationsIfNeeded(WidgetRef ref) async { + if (_isEnablingRemoteNotifications) return; + _isEnablingRemoteNotifications = true; + + // If Firebase wasn't initialized at startup (because cached flags were false), + // do it now. + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(options: DefaultFirebaseOptions.getOptionsForEnvironment()); + } + + 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); + }); + + _isEnablingRemoteNotifications = false; + } +} diff --git a/mobile-app/lib/services/feature_flags_service.dart b/mobile-app/lib/services/feature_flags_service.dart new file mode 100644 index 00000000..70b39c62 --- /dev/null +++ b/mobile-app/lib/services/feature_flags_service.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +import 'package:quantus_sdk/quantus_sdk.dart'; + +const String featureFlagsCacheKey = 'feature_flags_cache_v1'; + +class FeatureFlagsService { + final TaskmasterService _taskmasterService = TaskmasterService(); + final SettingsService _settingsService = SettingsService(); + + Future readRemoteFlags() async { + try { + final remoteData = await _taskmasterService.getWalletFeatureFlags(); + return remoteData; + } catch (error) { + print('Feature flags remote read failed: $error'); + return null; + } + } + + FeatureFlagsModel readLocalFlags() { + final jsonString = _settingsService.getString(featureFlagsCacheKey); + + if (jsonString == null || jsonString.isEmpty) { + cacheFlags(FeatureFlagsModel.defaults.toCacheJson()); + return FeatureFlagsModel.defaults; + } + + final decoded = jsonDecode(jsonString); + return FeatureFlagsModel.fromJson(decoded); + } + + Future cacheFlags(Object json) async { + try { + await _settingsService.setString(featureFlagsCacheKey, jsonEncode(json)); + } catch (error) { + print('Feature flags local save failed: $error'); + } + } +} diff --git a/mobile-app/lib/services/firebase_messaging_service.dart b/mobile-app/lib/services/firebase_messaging_service.dart index 82cceeef..98056dab 100644 --- a/mobile-app/lib/services/firebase_messaging_service.dart +++ b/mobile-app/lib/services/firebase_messaging_service.dart @@ -22,6 +22,7 @@ class FirebaseMessagingService { final SenotiService _senotiService = SenotiService(); bool _isInitialized = false; + bool __hasRegisteredHandlers = false; String? _cachedToken; FirebaseMessagingService(this._ref); @@ -147,6 +148,9 @@ 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; + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { debugPrint('FCM notification tapped (background): ${message.messageId}'); _handleNotificationTap(message, navigatorKey); diff --git a/mobile-app/lib/shared/global_navigator_key.dart b/mobile-app/lib/shared/global_navigator_key.dart new file mode 100644 index 00000000..5691d874 --- /dev/null +++ b/mobile-app/lib/shared/global_navigator_key.dart @@ -0,0 +1,3 @@ +import 'package:flutter/material.dart'; + +final GlobalKey navigatorKey = GlobalKey(); diff --git a/mobile-app/lib/utils/feature_flags.dart b/mobile-app/lib/utils/feature_flags.dart deleted file mode 100644 index ae915bb6..00000000 --- a/mobile-app/lib/utils/feature_flags.dart +++ /dev/null @@ -1,8 +0,0 @@ -// Simple feature flags for things we want in the code but not yet in the production app -class FeatureFlags { - static const bool enableTestButtons = false; // Only show in debug mode - static const bool enableKeystoneHardwareWallet = false; // turn keystone hw wallet on and off - static const bool enableHighSecurity = true; // turn keystone hw wallet on and off - static const bool enableRemoteNotifications = true; // turn remote notifications on and off - static const bool enableSwap = true; -} diff --git a/mobile-app/lib/v2/screens/create/wallet_ready_screen.dart b/mobile-app/lib/v2/screens/create/wallet_ready_screen.dart index a2979ee4..78ed6c8d 100644 --- a/mobile-app/lib/v2/screens/create/wallet_ready_screen.dart +++ b/mobile-app/lib/v2/screens/create/wallet_ready_screen.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/feature_flags_provider.dart'; import 'package:resonance_network_wallet/services/firebase_messaging_service.dart'; import 'package:resonance_network_wallet/services/referral_service.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; import 'package:resonance_network_wallet/shared/extensions/toaster_extensions.dart'; -import 'package:resonance_network_wallet/utils/feature_flags.dart'; import 'package:resonance_network_wallet/v2/components/glass_button.dart'; import 'package:resonance_network_wallet/v2/components/glass_container.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; @@ -91,7 +91,7 @@ class _WalletReadyScreenV2State extends ConsumerState { ref.invalidate(accountsProvider); ref.invalidate(activeAccountProvider); - if (FeatureFlags.enableRemoteNotifications) { + if (ref.read(featureFlagsProvider).enableRemoteNotifications) { ref.read(firebaseMessagingServiceProvider).registerDeviceIfPossible(); } diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 0e3bd579..a10050c8 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -6,13 +6,13 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/button.dart'; import 'package:resonance_network_wallet/features/components/shared_address_action_sheet.dart'; import 'package:resonance_network_wallet/features/components/skeleton.dart'; +import 'package:resonance_network_wallet/providers/feature_flags_provider.dart'; import 'package:resonance_network_wallet/v2/components/glass_button.dart' hide ButtonVariant; import 'package:resonance_network_wallet/v2/components/glass_icon_button.dart'; import 'package:resonance_network_wallet/v2/screens/accounts/accounts_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/receive/receive_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/settings/settings_screen.dart'; -import 'package:resonance_network_wallet/utils/feature_flags.dart'; import 'package:resonance_network_wallet/v2/screens/pos/pos_amount_screen.dart'; import 'package:resonance_network_wallet/v2/screens/swap/swap_screen.dart'; import 'package:resonance_network_wallet/providers/account_id_list_cache.dart'; @@ -226,6 +226,8 @@ class _HomeScreenState extends ConsumerState { } Widget _buildActionButtons() { + final enableSwap = ref.watch(featureFlagsProvider).enableSwap; + final receiveCard = _actionCard( iconAsset: 'assets/v2/action_receive.svg', label: 'Receive', @@ -238,7 +240,7 @@ class _HomeScreenState extends ConsumerState { onTap: () => showSendSheetV2(context), ); - if (!FeatureFlags.enableSwap) { + if (!enableSwap) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index ba60ea5c..74150900 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/feature_flags_provider.dart'; import 'package:resonance_network_wallet/services/firebase_messaging_service.dart'; -import 'package:resonance_network_wallet/utils/feature_flags.dart'; import 'package:resonance_network_wallet/v2/components/glass_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; @@ -55,7 +55,7 @@ class _ImportWalletScreenV2State extends ConsumerState { _settingsService.setReferralCheckCompleted(); _settingsService.setExistingUserSeenPromoVideo(); - if (FeatureFlags.enableRemoteNotifications) { + if (ref.read(featureFlagsProvider).enableRemoteNotifications) { ref.read(firebaseMessagingServiceProvider).registerDeviceIfPossible(); } diff --git a/mobile-app/lib/v2/screens/settings/settings_screen.dart b/mobile-app/lib/v2/screens/settings/settings_screen.dart index 516d3c6a..57801e5b 100644 --- a/mobile-app/lib/v2/screens/settings/settings_screen.dart +++ b/mobile-app/lib/v2/screens/settings/settings_screen.dart @@ -2,8 +2,8 @@ import 'package:flutter/cupertino.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/providers/feature_flags_provider.dart'; import 'package:resonance_network_wallet/services/firebase_messaging_service.dart'; -import 'package:resonance_network_wallet/utils/feature_flags.dart'; import 'package:resonance_network_wallet/v2/components/glass_button.dart'; import 'package:resonance_network_wallet/v2/screens/settings/recovery_phrase_screen.dart'; import 'package:resonance_network_wallet/v2/screens/settings/reset_confirmation_sheet.dart'; @@ -66,7 +66,7 @@ class _SettingsScreenV2State extends ConsumerState { } Future _resetAndClearData() async { - if (FeatureFlags.enableRemoteNotifications) { + if (ref.read(featureFlagsProvider).enableRemoteNotifications) { ref.read(firebaseMessagingServiceProvider).unregisterDevice(); } diff --git a/mobile-app/test/unit/send_screen_logic_test.dart b/mobile-app/test/unit/send_screen_logic_test.dart index 996cebaf..f1c372ab 100644 --- a/mobile-app/test/unit/send_screen_logic_test.dart +++ b/mobile-app/test/unit/send_screen_logic_test.dart @@ -42,15 +42,6 @@ void main() { }); group('hasAmountError', () { - test('returns true for zero amount', () { - final result = SendScreenLogic.hasAmountError( - amount: BigInt.zero, - balance: BigInt.from(5000000000000), - networkFee: BigInt.from(100000000), - ); - expect(result, isTrue); - }); - test('returns true when amount + fee exceeds balance', () { final result = SendScreenLogic.hasAmountError( amount: BigInt.from(4999900000000), diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index e258c28a..85f0bb2e 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -23,6 +23,7 @@ export 'src/models/event_type.dart'; export 'src/models/extrinsic_data.dart'; export 'src/models/extrinsic_fee_data.dart'; export 'src/models/unsigned_transaction_data.dart'; +export 'src/models/feature_flags_model.dart'; export 'src/models/miner_reward_event.dart'; export 'src/models/miner_stats.dart'; export 'src/models/opted_in_position.dart'; diff --git a/quantus_sdk/lib/src/models/feature_flags_model.dart b/quantus_sdk/lib/src/models/feature_flags_model.dart new file mode 100644 index 00000000..fde15408 --- /dev/null +++ b/quantus_sdk/lib/src/models/feature_flags_model.dart @@ -0,0 +1,87 @@ +class FeatureFlagsModel { + final bool enableTestButtons; + final bool enableKeystoneHardwareWallet; + final bool enableHighSecurity; + final bool enableRemoteNotifications; + final bool enableSwap; + + const FeatureFlagsModel({ + required this.enableTestButtons, + required this.enableKeystoneHardwareWallet, + required this.enableHighSecurity, + required this.enableRemoteNotifications, + required this.enableSwap, + }); + + R match({ + required R Function( + bool enableTestButtons, + bool enableKeystoneHardwareWallet, + bool enableHighSecurity, + bool enableRemoteNotifications, + bool enableSwap, + ) + fn, + }) { + return fn( + enableTestButtons, + enableKeystoneHardwareWallet, + enableHighSecurity, + enableRemoteNotifications, + enableSwap, + ); + } + + static const FeatureFlagsModel defaults = FeatureFlagsModel( + enableTestButtons: false, + enableKeystoneHardwareWallet: false, + enableHighSecurity: true, + enableRemoteNotifications: true, + enableSwap: true, + ); + + Map toCacheJson() { + return match( + fn: (test, keystone, security, notifications, swap) => { + 'enableTestButtons': test, + 'enableKeystoneHardwareWallet': keystone, + 'enableHighSecurity': security, + 'enableRemoteNotifications': notifications, + 'enableSwap': swap, + }, + ); + } + + factory FeatureFlagsModel.fromJson(Map json) { + return FeatureFlagsModel( + enableTestButtons: json['enableTestButtons'] ?? defaults.enableTestButtons, + enableKeystoneHardwareWallet: json['enableKeystoneHardwareWallet'] ?? defaults.enableKeystoneHardwareWallet, + enableHighSecurity: json['enableHighSecurity'] ?? defaults.enableHighSecurity, + enableRemoteNotifications: json['enableRemoteNotifications'] ?? defaults.enableRemoteNotifications, + enableSwap: json['enableSwap'] ?? defaults.enableSwap, + ); + } + + bool compare(FeatureFlagsModel other) { + return match( + fn: (enableTestButtons, enableKeystoneHardwareWallet, enableHighSecurity, enableRemoteNotifications, enableSwap) { + return other.match( + fn: + ( + otherEnableTestButtons, + otherEnableKeystoneHardwareWallet, + otherEnableHighSecurity, + otherEnableRemoteNotifications, + otherEnableSwap, + ) { + return enableTestButtons == otherEnableTestButtons && + enableKeystoneHardwareWallet == otherEnableKeystoneHardwareWallet && + enableHighSecurity == otherEnableHighSecurity && + enableRemoteNotifications == otherEnableRemoteNotifications && + enableSwap == otherEnableSwap; + }, + ); + }, + ); + } +} diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index 715b84ee..6f1c9fff 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -301,7 +301,7 @@ class SettingsService { // --- Primitive Accessors for General Use --- /// Get a boolean value from SharedPreferences - Future getBool(String key) async { + bool? getBool(String key) { return _prefs.getBool(key); } @@ -311,10 +311,15 @@ class SettingsService { } /// Get a string value from SharedPreferences - Future getString(String key) async { + String? getString(String key) { return _prefs.getString(key); } + /// Set a string value from SharedPreferences + Future setString(String key, String value) async { + await _prefs.setString(key, value); + } + DateTime? getLastPausedTime() { final String? lastPausedString = _prefs.getString(_lastPausedTimeKey); if (lastPausedString == null) return null; diff --git a/quantus_sdk/lib/src/services/taskmaster_service.dart b/quantus_sdk/lib/src/services/taskmaster_service.dart index 9fac8836..b138b05d 100644 --- a/quantus_sdk/lib/src/services/taskmaster_service.dart +++ b/quantus_sdk/lib/src/services/taskmaster_service.dart @@ -492,6 +492,24 @@ class TaskmasterService { await ensureIsLoggedIn(); } + Future getWalletFeatureFlags() async { + final Uri uri = Uri.parse('${AppConstants.taskMasterEndpoint}/feature-flags/wallet'); + + final http.Response response = await http.get(uri, headers: {'Content-Type': 'application/json'}); + if (response.statusCode != 200) { + throw Exception('Feature flags request failed with status: ${response.statusCode}. Body: ${response.body}'); + } + + final Map? responseBody = jsonDecode(response.body); + final Map? data = responseBody?['data']; + + if (data == null) { + throw Exception('Feature flags request failed with status: ${response.statusCode}. Body: ${response.body}'); + } + + return FeatureFlagsModel.fromJson(data); + } + Future getMinerStats() async { final mainAccount = await getMainAccount(); final oldMiningAccountId = await getOldMiningAccountId(); diff --git a/quantus_sdk/test/contract/feature_flags_api_test.dart b/quantus_sdk/test/contract/feature_flags_api_test.dart new file mode 100644 index 00000000..1eecf60b --- /dev/null +++ b/quantus_sdk/test/contract/feature_flags_api_test.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:http/http.dart' as http; + +void main() { + group('API Contract Tests', () { + test('Remote Feature Flags API exactly matches FeatureFlagsModel properties', () async { + final Uri uri = Uri.parse('${AppConstants.taskMasterEndpoint}/feature-flags/wallet'); + final http.Response response = await http.get(uri, headers: {'Content-Type': 'application/json'}); + + if (response.statusCode != 200) { + fail('Feature flags request failed with status: ${response.statusCode}. Body: ${response.body}'); + } + + final Map? responseBody = jsonDecode(response.body); + final Map? data = responseBody?['data']; + + if (data == null) { + fail('Feature flags request failed: Data is null'); + } + + final expectedKeys = { + 'enableTestButtons', + 'enableKeystoneHardwareWallet', + 'enableHighSecurity', + 'enableRemoteNotifications', + 'enableSwap', + }; + + final actualKeys = data.keys.toSet(); + + // Check for MISSING keys (The backend removed or renamed a property) + final missingKeys = expectedKeys.difference(actualKeys); + expect(missingKeys, isEmpty, reason: 'CRITICAL: The API is missing properties your app relies on: $missingKeys'); + + // Check for NEW keys (The backend added properties your app ignores) + final newKeys = actualKeys.difference(expectedKeys); + expect(newKeys, isEmpty, reason: 'WARNING: The API sent new properties not handled in your app: $newKeys'); + + try { + FeatureFlagsModel.fromJson(data); + } catch (e) { + fail('Failed to parse feature flags model: $e'); + } + }); + }); +}