diff --git a/modules/ensemble/lib/framework/data_context.dart b/modules/ensemble/lib/framework/data_context.dart index 2a9b982eb..332b71557 100644 --- a/modules/ensemble/lib/framework/data_context.dart +++ b/modules/ensemble/lib/framework/data_context.dart @@ -28,6 +28,7 @@ import 'package:ensemble/framework/secrets.dart'; import 'package:ensemble/framework/stub/auth_context_manager.dart'; import 'package:ensemble/framework/stub/oauth_controller.dart'; import 'package:ensemble/framework/stub/token_manager.dart'; +import 'package:ensemble/framework/stub/remote_config.dart'; import 'package:ensemble/framework/storage_manager.dart'; import 'package:ensemble/framework/widget/view_util.dart'; import 'package:ensemble/host_platform_manager.dart'; @@ -465,6 +466,7 @@ class NativeInvokable extends ActionInvokable { Map getters() { return { 'storage': () => storage, + 'remoteConfig': () => RemoteConfigInvokable(), 'user': () => UserInfo(), 'formatter': () => Formatter(), 'utils': () => _EnsembleUtils(), @@ -690,6 +692,53 @@ class EnsembleSocketInvokable with Invokable { } } +/// Invokable for Remote Config +class RemoteConfigInvokable with Invokable { + static RemoteConfig get _provider => + GetIt.instance.isRegistered() + ? GetIt.instance() + : RemoteConfigStub(); + + @override + dynamic getProperty(dynamic prop) => + _provider.getValue(prop?.toString() ?? '', null); + + @override + Map getters() => {}; + + @override + Map methods() { + return { + 'get': (dynamic key, [dynamic defaultValue]) { + final k = key?.toString() ?? ''; + final def = defaultValue ?? ''; + return _provider.getValue(k, def); + }, + 'getBool': (dynamic key, [bool defaultValue = false]) => + _provider.getValue(key?.toString() ?? '', defaultValue) as bool, + 'getInt': (dynamic key, [int defaultValue = 0]) => + _provider.getValue(key?.toString() ?? '', defaultValue) as int, + 'getDouble': (dynamic key, [double defaultValue = 0.0]) => + _provider.getValue(key?.toString() ?? '', defaultValue) as double, + 'getString': (dynamic key, [String defaultValue = '']) => + _provider.getValue(key?.toString() ?? '', defaultValue) as String, + 'all': () => _provider.getAllValues(), + 'info': () => _provider.getInfo(), + 'refresh': () => _provider.refresh(), + 'setDefaults': (dynamic defaults) { + final map = Utils.getMap(defaults); + if (map == null) { + return null; + } + return _provider.setDefaults(Map.from(map)); + }, + }; + } + + @override + Map setters() => {}; +} + /// Singleton handling user storage class EnsembleStorage with Invokable { static final EnsembleStorage _instance = EnsembleStorage._internal(); diff --git a/modules/ensemble/lib/framework/stub/remote_config.dart b/modules/ensemble/lib/framework/stub/remote_config.dart new file mode 100644 index 000000000..6cf1cd144 --- /dev/null +++ b/modules/ensemble/lib/framework/stub/remote_config.dart @@ -0,0 +1,52 @@ +/// Abstract provider for Remote Config values. +/// +/// The real implementation is provided by the [ensemble_remote_config] module +/// when enabled via [EnsembleModules] in the starter; otherwise a stub is used. +abstract class RemoteConfig { + dynamic getValue(String key, dynamic defaultValue); + + /// Return all currently known values, using the same typing rules as + /// [getValue] with a `null` default (i.e. minimal inference). + Map getAllValues(); + + /// Metadata about the current Remote Config state (debugging only). + Map getInfo(); + + /// Manually trigger a refresh/fetch of Remote Config values. + /// + /// This is intended for developer tools or long‑lived sessions, and is + /// typically called from expressions as `ensemble.remoteConfig.refresh()`. + Future refresh(); + + /// Optionally register a map of default values. + /// + /// This is primarily intended for use from Dart (e.g. starter wiring), but + /// is also exposed in expressions as `ensemble.remoteConfig.setDefaults({...})` + /// for advanced scenarios. + Future setDefaults(Map defaults); +} + +/// Stub used when the ensemble_remote_config module is not enabled. +/// Always returns [defaultValue] so expressions like ensemble.remoteConfig.my_key or ensemble.remoteConfig.get('key', 'default') +/// work without throwing. +class RemoteConfigStub implements RemoteConfig { + @override + dynamic getValue(String key, dynamic defaultValue) => defaultValue; + + @override + Map getAllValues() => const {}; + + @override + Map getInfo() => + const {'initialized': false, 'source': 'stub'}; + + @override + Future refresh() async { + // no-op in stub + } + + @override + Future setDefaults(Map defaults) async { + // no-op in stub + } +} diff --git a/modules/ensemble_remote_config/README.md b/modules/ensemble_remote_config/README.md new file mode 100644 index 000000000..4037b721e --- /dev/null +++ b/modules/ensemble_remote_config/README.md @@ -0,0 +1,96 @@ +# Ensemble Remote Config + +Ensemble module for Firebase Remote Config. Use it for A/B testing, feature flags, and runtime configuration in screens and expressions. + +## What it does + +- Fetches and activates Remote Config in the background when the module is registered. +- Exposes Remote Config on the ensemble object like storage: `ensemble.remoteConfig.my_key` (or `ensemble.remoteConfig.get('key', 'default')` for a custom default). + +## Enabling the module + +Remote Config is **off** by default. To turn it on: + +1. **Starter `pubspec.yaml`** + Uncomment the dependency: + + ```yaml + ensemble_remote_config: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: + path: modules/ensemble_remote_config + ``` + +2. **Starter `lib/generated/ensemble_modules.dart`** + - Set `useRemoteConfig = true`. + - Uncomment: `import 'package:ensemble_remote_config/remote_config.dart';` + - Uncomment: `GetIt.I.registerSingleton(RemoteConfigImpl());` + +3. **Firebase** + Ensure Firebase is set up (e.g. `GoogleService-Info.plist` / `google-services.json`). With `useRemoteConfig = true`, `Firebase.initializeApp()` is already triggered by the starter’s init. + +### Optional environment variables + +You can tune Remote Config fetch behavior via environment variables: + +- `remote_config_fetch_timeout` — fetch timeout in seconds (default `10`). +- `remote_config_minimum_fetch_interval` — minimum fetch interval in seconds (default `3600`, i.e. 1 hour). + +Example: + +```yaml +environmentVariables: + remote_config_fetch_timeout: 10 + remote_config_minimum_fetch_interval: 3600 +``` + +Notes: + +- These values are applied when Remote Config is initialized **and** before each + subsequent `fetchAndActivate()` / `ensemble.remoteConfig.refresh()`. +- On the very first app start, Ensemble config may not be loaded yet when RC + initializes, so the defaults can be used for the first fetch; any later call + to `ensemble.remoteConfig.refresh()` re-reads and reapplies the env values. +- For local debugging you can temporarily set `remote_config_minimum_fetch_interval: 0` to avoid throttling, but you should use a higher value in production. + +## Usage in YAML + +In any screen or expression, use the `ensemble` object + +- **Property access** — `ensemble.remoteConfig.my_key` returns a minimally typed value + - `"true"/"false"` → `bool` + - numeric string → `num` + - otherwise a `String` (or `null` if the key is missing/empty). +- **Custom default and type** — `ensemble.remoteConfig.get('my_key', false)` uses the + default's type (here `bool`) and falls back to that default on errors. +- **Typed helpers** — these are just wrappers around `get` with a typed default: + - `ensemble.remoteConfig.getBool('flag', false)` + - `ensemble.remoteConfig.getInt('max_items', 10)` + - `ensemble.remoteConfig.getDouble('ratio', 0.5)` + - `ensemble.remoteConfig.getString('cta_text', 'Try it now')` + +### Defaults + +- `ensemble.remoteConfig.setDefaults({ flag: true, max_items: 10 })` registers app‑side + defaults with Firebase Remote Config. +- **Intended use**: call once during app startup before the screens that depend on these keys are built. +- Changing defaults at runtime does **not automatically update existing widgets**; you may need to rebuild the screen or navigate away/back to see the new default take effect. + +### Debug helpers + +For debugging and introspection (e.g. a developer screen): + +- `ensemble.remoteConfig.all()` → map of all keys to their current values. +- `ensemble.remoteConfig.info()` → metadata such as `initialized`, `lastFetchStatus`, + `lastFetchTime`, and fetch/interval settings. +- `ensemble.remoteConfig.refresh()` → manually trigger a re‑fetch/activate of + Remote Config values (using Firebase's built‑in throttling and current env + settings for timeout/interval). + +## Debug logging + +In debug builds, the module logs to `debugPrint` when: + +- Fetch/activate fails (with exception and stack). +- `getValue` returns the default because the service isn’t initialized, the key is empty, or an error occurred. diff --git a/modules/ensemble_remote_config/lib/remote_config.dart b/modules/ensemble_remote_config/lib/remote_config.dart new file mode 100644 index 000000000..4ec63b7da --- /dev/null +++ b/modules/ensemble_remote_config/lib/remote_config.dart @@ -0,0 +1,219 @@ +library ensemble_remote_config; + +import 'package:ensemble/framework/config.dart'; +import 'package:ensemble/framework/stub/remote_config.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:flutter/foundation.dart'; + +/// Remote Config service used by Ensemble. +/// +/// This module is responsible for: +/// - Fetching and activating Remote Config +/// - Providing `getValue`, `getAllValues`, `getInfo`, and `refresh` methods for the core Ensemble runtime +class RemoteConfigService { + static final RemoteConfigService _instance = RemoteConfigService._internal(); + RemoteConfigService._internal(); + factory RemoteConfigService() => _instance; + + FirebaseRemoteConfig? _remoteConfig; + + /// Apply settings from environment variables (if present), falling back + /// to sensible defaults. This can be called multiple times; it will + /// re-read the env on each invocation. + Future _applySettingsFromEnv(FirebaseRemoteConfig config) async { + // Environment variables: + // - remote_config_fetch_timeout (seconds) + // - remote_config_minimum_fetch_interval (seconds) + Duration _durationFromEnv(String key, Duration fallback) { + final dynamic value = EnvConfig().getProperty(key); + if (value == null || (value is String && value.isEmpty)) { + return fallback; + } + if (value is num) { + return Duration(seconds: value.toInt()); + } + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) { + return Duration(seconds: parsed); + } + } + return fallback; + } + + final fetchTimeout = _durationFromEnv( + 'remote_config_fetch_timeout', const Duration(seconds: 10)); + final minimumFetchInterval = _durationFromEnv( + 'remote_config_minimum_fetch_interval', const Duration(hours: 1)); + + await config.setConfigSettings( + RemoteConfigSettings( + fetchTimeout: fetchTimeout, + minimumFetchInterval: minimumFetchInterval, + ), + ); + } + + /// Initialize Remote Config + Future ensureInitialized() async { + if (_remoteConfig != null) return; + + final config = FirebaseRemoteConfig.instance; + await _applySettingsFromEnv(config); + _remoteConfig = config; + } + + /// Optionally register a map of default values with Firebase Remote Config. + /// + /// This is entirely optional – most apps will rely on per‑call defaults in + /// expressions – but it can be useful for centralizing defaults in Dart. + Future setDefaults(Map defaults) async { + await ensureInitialized(); + await _remoteConfig?.setDefaults(defaults); + } + + /// Fetch and activate latest Remote Config values + /// + /// Errors are swallowed; if anything fails we simply keep using defaults. + Future fetchAndActivate() async { + await ensureInitialized(); + if (_remoteConfig == null) return; + try { + // Re-apply settings on each fetch so that environment overrides + // (which may load after first init) are honored. + await _applySettingsFromEnv(_remoteConfig!); + await _remoteConfig!.fetchAndActivate(); + if (kDebugMode) { + debugPrint('[RemoteConfig] Initialized'); + } + } catch (e, stack) { + if (kDebugMode) { + debugPrint('[RemoteConfig] fetchAndActivate failed: $e\n$stack'); + } + } + } + + /// Get a Remote Config value + dynamic getValue(String key, dynamic defaultValue) { + if (_remoteConfig == null) { + if (kDebugMode) { + debugPrint( + '[RemoteConfig] getValue("$key"): not initialized, using default'); + } + return defaultValue; + } + try { + // When the caller provides a typed default, prefer the Firebase typed + // getters so we respect the parameter's declared type in RC. + if (defaultValue is bool) { + final v = _remoteConfig!.getBool(key); + return v; + } + if (defaultValue is int) { + final v = _remoteConfig!.getInt(key); + return v; + } + if (defaultValue is double || defaultValue is num) { + final v = _remoteConfig!.getDouble(key); + return v; + } + + // Fallback: work with the raw string value. + final raw = _remoteConfig!.getString(key); + if (raw.isEmpty) { + if (kDebugMode) { + debugPrint('[RemoteConfig] getValue("$key"): empty, using default'); + } + return defaultValue; + } + + dynamic converted = raw; + + // If no explicit default is provided, infer minimally from the string. + if (defaultValue == null) { + // No explicit default type: infer minimally from the string. + final trimmed = raw.trim(); + final lower = trimmed.toLowerCase(); + + if (lower == 'true') { + converted = true; + } else if (lower == 'false') { + converted = false; + } else { + final asNum = num.tryParse(trimmed); + if (asNum != null) { + converted = asNum; + } + } + } + return converted; + } catch (e, stack) { + if (kDebugMode) { + debugPrint('[RemoteConfig] getValue("$key") failed: $e\n$stack'); + } + return defaultValue; + } + } +} + +extension RemoteConfigServiceDebug on RemoteConfigService { + /// Snapshot of all RC values, using the same typing rules as [getValue] + /// with a `null` default. + Map getAllValues() { + final config = _remoteConfig; + if (config == null) return const {}; + + final all = config.getAll(); + final result = {}; + all.forEach((key, _) { + result[key] = getValue(key, null); + }); + return result; + } + + /// Debug information about the current RC state. + Map getInfo() { + final config = _remoteConfig; + if (config == null) { + return const { + 'initialized': false, + }; + } + return { + 'initialized': true, + 'lastFetchStatus': config.lastFetchStatus.toString().split('.').last, + 'lastFetchTime': config.lastFetchTime.toIso8601String(), + 'minimumFetchIntervalSeconds': + config.settings.minimumFetchInterval.inSeconds, + 'fetchTimeoutSeconds': config.settings.fetchTimeout.inSeconds, + }; + } +} + +/// Implementation of [RemoteConfig] for use when the module is enabled +/// via [EnsembleModules]. Register with GetIt in the starter's init. +/// +/// When registered, initializes and fetches Remote Config in the background; +/// [getValue] uses cached values (or defaults until fetch completes). +class RemoteConfigImpl implements RemoteConfig { + RemoteConfigImpl() { + RemoteConfigService().fetchAndActivate(); + } + + @override + dynamic getValue(String key, dynamic defaultValue) => + RemoteConfigService().getValue(key, defaultValue); + + @override + Map getAllValues() => RemoteConfigService().getAllValues(); + + @override + Map getInfo() => RemoteConfigService().getInfo(); + + @override + Future refresh() => RemoteConfigService().fetchAndActivate(); + + @override + Future setDefaults(Map defaults) => + RemoteConfigService().setDefaults(defaults); +} diff --git a/modules/ensemble_remote_config/pubspec.yaml b/modules/ensemble_remote_config/pubspec.yaml new file mode 100644 index 000000000..af3cea3a3 --- /dev/null +++ b/modules/ensemble_remote_config/pubspec.yaml @@ -0,0 +1,31 @@ +name: ensemble_remote_config +description: "Ensemble Firebase Remote Config integration." +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.5.0 <4.0.0' + flutter: '>=3.24.0' + +dependencies: + flutter: + sdk: flutter + + firebase_core: + firebase_remote_config: ^6.1.4 + + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.33 + path: modules/ensemble + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.3 + +flutter: + uses-material-design: true + diff --git a/starter/lib/generated/ensemble_modules.dart b/starter/lib/generated/ensemble_modules.dart index 250e76edc..61f61c1e6 100644 --- a/starter/lib/generated/ensemble_modules.dart +++ b/starter/lib/generated/ensemble_modules.dart @@ -16,6 +16,7 @@ import 'package:ensemble/framework/stub/plaid_link_manager.dart'; import 'package:ensemble/module/auth_module.dart'; import 'package:ensemble/module/location_module.dart'; import 'package:ensemble/framework/stub/moengage_manager.dart'; +import 'package:ensemble/framework/stub/remote_config.dart'; import 'package:ensemble/module/stripe_module.dart'; // import 'package:ensemble_stripe/ensemble_stripe.dart'; //import 'package:ensemble_bluetooth/ensemble_bluetooth.dart'; @@ -71,6 +72,9 @@ import 'package:get_it/get_it.dart'; // Uncomment to enable Adobe Analytics // import 'package:ensemble_adobe_analytics/adobe_analytics.dart'; +// Uncomment to enable Firebase Remote Config (A/B testing, feature flags) +// import 'package:ensemble_remote_config/remote_config.dart'; + /// TODO: This class should be generated to enable selected Services class EnsembleModules { static final EnsembleModules _instance = EnsembleModules._internal(); @@ -97,6 +101,7 @@ class EnsembleModules { static const useBluetooth = false; static const useStripe = false; + static const useRemoteConfig = false; // widgets static const enableChat = false; @@ -107,7 +112,11 @@ class EnsembleModules { Future init() async { // Note that notifications is not a module - if (useMoEngage || useNotifications || useFirebaseAnalytics || useAuth) { + if (useMoEngage || + useNotifications || + useFirebaseAnalytics || + useAuth || + useRemoteConfig) { // if payload is not passed, Firebase configuration files // are required to be added manually to iOS and Android try { @@ -241,5 +250,12 @@ class EnsembleModules { } else { GetIt.I.registerSingleton(StripeModuleStub()); } + + if (useRemoteConfig) { + // Uncomment to enable Remote Config + // GetIt.I.registerSingleton(RemoteConfigImpl()); + } else { + GetIt.I.registerSingleton(RemoteConfigStub()); + } } } diff --git a/starter/package.json b/starter/package.json index 84ca765c7..6c03c9ae0 100644 --- a/starter/package.json +++ b/starter/package.json @@ -29,7 +29,8 @@ "hasGoogleMaps": "ts-node src/dart_runner.ts google_maps", "generate_keystore": "ts-node src/dart_runner.ts generateKeystore", "hasAdobeAnalytics": "ts-node src/dart_runner.ts adobe_analytics", - "hasStripe": "ts-node src/dart_runner.ts stripe" + "hasStripe": "ts-node src/dart_runner.ts stripe", + "hasRemoteConfig": "ts-node src/dart_runner.ts remote_config" }, "keywords": [], "author": "", diff --git a/starter/pubspec.yaml b/starter/pubspec.yaml index e9ab8c1bc..f3a0389e5 100644 --- a/starter/pubspec.yaml +++ b/starter/pubspec.yaml @@ -150,6 +150,13 @@ dependencies: # ref: main # path: modules/adobe_analytics + # Uncomment to enable Firebase Remote Config (A/B testing, feature flags) + # ensemble_remote_config: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/ensemble_remote_config + # ensemble_stripe: # git: # url: https://github.com/EnsembleUI/ensemble.git diff --git a/starter/scripts/modules/enable_remote_config.dart b/starter/scripts/modules/enable_remote_config.dart new file mode 100644 index 000000000..3507b7283 --- /dev/null +++ b/starter/scripts/modules/enable_remote_config.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import '../utils.dart'; +import '../utils/firebase_utils.dart'; + +Future main(List arguments) async { + List platforms = getPlatforms(arguments); + String? ensembleVersion = getArgumentValue(arguments, 'ensemble_version'); + + final statements = { + 'moduleStatements': [ + "import 'package:ensemble_remote_config/remote_config.dart';", + "GetIt.I.registerSingleton(RemoteConfigImpl());", + ], + 'useStatements': [ + 'static const useRemoteConfig = true;', + ], + }; + + final pubspecDependencies = [ + { + 'statement': ''' +ensemble_remote_config: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ${await packageVersion(version: ensembleVersion)} + path: modules/ensemble_remote_config''', + 'regex': + r'#\s*ensemble_remote_config:\s*\n\s*#\s*git:\s*\n\s*#\s*url:\s*https:\/\/github\.com\/EnsembleUI\/ensemble\.git\s*\n\s*#\s*ref:\s*main\s*\n\s*#\s*path:\s*modules\/ensemble_remote_config', + } + ]; + + try { + // Update the ensemble_modules.dart file + updateEnsembleModules( + statements['moduleStatements'], + statements['useStatements'], + ); + + // Generate Firebase configuration based on platform (Remote Config uses Firebase) + updateFirebaseInitialization(platforms, arguments); + updateFirebaseConfig(platforms, arguments); + + if (platforms.contains('android')) { + addClasspathDependency( + "classpath 'com.google.gms:google-services:4.3.15'"); + addPluginDependency("id 'com.google.gms.google-services'"); + addImplementationDependency( + "implementation platform('com.google.firebase:firebase-bom:32.7.0')"); + addSettingsPluginDependency( + 'id "com.google.gms.google-services" version "4.3.15" apply false'); + } + + // Update the pubspec.yaml file + updatePubspec(pubspecDependencies); + + print( + 'Firebase Remote Config module enabled successfully for ${platforms.join(', ')}! 🎉'); + exit(0); + } catch (e) { + print('Starter Error: $e'); + exit(1); + } +} diff --git a/starter/src/modules_scripts.ts b/starter/src/modules_scripts.ts index 2cd33a1ac..0a77b9e9a 100644 --- a/starter/src/modules_scripts.ts +++ b/starter/src/modules_scripts.ts @@ -390,4 +390,13 @@ export const modules: Script[] = [ }, ], }, + { + name: 'remote_config', + path: 'scripts/modules/enable_remote_config.dart', + parameters: [ + ...firebaseAndroidParameters, + ...firebaseIOSParameters, + ...firebaseWebParameters, + ], + }, ];