adaptable_remote_config 0.4.0
adaptable_remote_config: ^0.4.0 copied to clipboard
Firebase, HTTP, or custom remote config for Flutter as plain key/value pairs, with typed reads, a cached last-good snapshot, and optional/required app-update gates.
adaptable_remote_config #
Remote settings and app-update gating for Flutter, built on one idea:
A provider returns key/value pairs.
UpdateKeyssays which keys mean "minimum version", "latest version", "store link" and "force". The controller turns that into a decision, andUpdateGateshows it.
The same code works with Firebase Remote Config, an HTTP JSON file, or any function that returns a map. There is no dependency on Bloc, Provider, GetIt or a router. Supports Android and iOS.
Quick start: two commands #
flutter pub add adaptable_remote_config
dart run adaptable_remote_config
The second command is an interactive setup. Use the arrow keys, space and Enter to answer a few questions (where the values live, force and/or optional updates, platforms, key names). It then:
- writes
lib/app_update.dartwith a ready controller, - wraps your
MaterialAppinUpdateGateand initializes Firebase inmain()(it shows what it will change and asks first), - runs
flutter pub add firebase_coreif you chose Firebase and it is missing, - creates the parameters in your Firebase project for you (it shows what it will add, asks first, and never changes existing ones), or prints the JSON to host.
Your app already works with the defaults it writes; the remote values let you change behavior later without a release. Run it from the app's root folder.
No questions at all:
dart run adaptable_remote_config --yes
Firebase, force + optional, Android + iOS, standard key names. Flags change the defaults without prompts:
dart run adaptable_remote_config --yes --source http --url https://example.com/app.json
dart run adaptable_remote_config --yes --mode force # both | force | optional
For Firebase, run flutterfire configure once so the app can reach your
project. The same login lets the wizard create the Remote Config parameters
(it needs the Firebase CLI: npm i -g firebase-tools, then firebase login). With --yes it only writes to your
Firebase project when you add --publish:
dart run adaptable_remote_config --yes --publish # do everything
dart run adaptable_remote_config --yes --no-publish # just print the parameters
dart run adaptable_remote_config --publish --project my-id # pick the project
If the wizard cannot safely edit a file (for example your MaterialApp
already has a builder:), it leaves it alone and prints the few lines to add.
Manual setup #
If you prefer to do it by hand, or want to understand what the wizard writes:
dependencies:
adaptable_remote_config: ^0.3.0
import 'package:adaptable_remote_config/adaptable_remote_config.dart';
The package depends on firebase_remote_config even when you use another
provider, so Firebase code is only compiled in, never started, unless you use
FirebaseConfigProvider.
Three steps #
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(); // only for FirebaseConfigProvider
// 1. A controller: a source + an id for the cache.
final updates = RemoteConfigController(
appId: 'my_app',
provider: FirebaseConfigProvider(),
);
runApp(MyApp(updates: updates));
}
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.updates});
final RemoteConfigController updates;
@override
Widget build(BuildContext context) => MaterialApp.router(
routerConfig: router,
// 2. Wrap the app. Put it in the builder, above every route.
builder: (context, child) =>
UpdateGate(controller: updates, child: child!),
);
}
// 3. Call updates.dispose() when its owner ends.
Without Firebase, use a JSON file you host instead:
provider: HttpConfigProvider(url: Uri.parse('https://example.com/my_app.json')),
keys: const UpdateKeys.nested(),
{
"android": {"minimum_version": "1.2.0+4", "latest_version": "1.3.0+1",
"force_update": true,
"store_url": "https://play.google.com/store/apps/details?id=com.example.app"},
"ios": {"minimum_version": "1.2.0+4", "force_update": true,
"store_url": "https://apps.apple.com/app/id123456789"},
"base_url": "https://api.example.com"
}
With Firebase and the default keys, create these parameters in the console:
android_version, ios_version, android_store_link, ios_store_link,
force_update (a boolean for both platforms), and optionally
android_latest_version and ios_latest_version for soft updates. Versions look
like 1.2.0 or 1.2.0+4 (version plus build number).
Choose where the values come from #
| Provider | Use |
|---|---|
FirebaseConfigProvider() |
Firebase Remote Config. Pass defaults: for every key the app reads. |
HttpConfigProvider(url: …) |
A JSON object. root: 'data.app' if the response wraps it. Also method, headers, body, client. |
CustomConfigProvider(() async => map) |
Anything else: an existing API client, a database… |
Subclass RemoteConfigProvider for more. Providers just throw on failure; the
controller falls back to its cache. Each event on provider.changes triggers a
refetch when subscribeToChanges: true (Firebase real-time updates use this).
Choose the key names #
UpdateKeys values are keys or dotted paths. {platform} becomes android or
ios.
const UpdateKeys() // android_version, ios_version, force_update, …
const UpdateKeys.prefixed('shop') // shop_android_version, shop_force_update, …
const UpdateKeys.nested() // {"android": {"minimum_version": …}} JSON
const UpdateKeys( // anything
minimumVersion: 'min.{platform}',
forceUpdate: 'must_update',
storeUrl: 'links.{platform}',
)
Pass it as RemoteConfigController(keys: …).
Read other settings (feature flags, base URL…) #
Every key in the snapshot is available, not only the update ones:
final values = updates.values; // defaults + last good snapshot
values.string('base_url', 'https://fallback'); // trimmed, blank -> fallback
values.boolean('new_checkout'); // true/false/'true'/'1'
values.number('request_limit', 10);
values.json('appearance', {'style': 'default'}); // object, list or JSON string
values.string('theme.mode'); // dotted path into nested JSON
Missing, blank or wrong-type values return the fallback. Local defaults: on the
controller sit underneath the remote values. The controller notifies listeners
when the snapshot changes, so ListenableBuilder(listenable: updates, …)
rebuilds on new settings. Keep the config endpoint separate from your API
base_url, and call await updates.initialize() before creating the API client.
How the decision is made #
| Result | When | UI |
|---|---|---|
required |
force_update is true and the installed version is below the minimum |
Blocking page |
optional |
The installed version is below the latest | Dialog: Update / Later / Skip this version |
none |
Otherwise | Your app |
- Versions compare as dotted numbers (missing parts are zero), then
+build. Passcomparator:for other formats; it must throw on malformed input. - A snapshot with malformed versions, a latest below the minimum, or no usable store link when an update is offered is rejected as a whole; the last good snapshot stays in force. With nothing cached, the user is let in.
- Store links accept
https,http,market://details?id=…anditms-apps://…. Android derives the Play Store page from the package name if no link is configured; iOS needs one. - Later hides the prompt for
remindAfter(one day); Skip hides that version for good. Both persist. A required update ignores them.
Customize the UI #
UpdateGate(
controller: updates,
loadingBuilder: (context) => const MySplash(),
requiredBuilder: (context, controller) => MyWall(controller: controller),
optionalBuilder: (context, controller) => MyDialog(controller: controller),
texts: const UpdateTexts(update: 'Get it'), // Arabic is automatic for `ar`
showOptional: true, checkOnResume: true, allowSkip: true,
)
A custom wall only needs the controller:
ListenableBuilder(
listenable: controller,
builder: (_, _) => FilledButton(
onPressed: controller.isOpeningUpdate ? null : controller.openUpdate,
child: Text(controller.actionError == null ? 'Update' : 'Retry'),
),
)
Building blocks: RequiredUpdatePage, OptionalUpdateDialog, UpdateButton.
While a required update applies the app stays mounted but hidden, without
focus, input or accessibility access, and an isolated navigator keeps back from
reaching it.
Controller reference #
| Member | Meaning |
|---|---|
initialize() |
Waits for the first check, at most startupWait (4 s). |
refresh({force}) |
Fetches again; force: false respects refreshInterval (1 h). |
check() |
Re-evaluates the current values, no network. |
openUpdate() |
Opens the store (openStore: replaces the default launcher). |
dismissOptional({skipVersion}) |
Later / Skip. |
values, update, shouldPrompt, isReady, error, actionError |
Observable state. |
onError |
Called for every provider, validation, storage or launch error. |
appInfo: and platform: can be injected (tests, other environments).
storage: swaps the SharedPreferences cache (MemoryConfigStorage for tests).
Contributing #
Issues and pull requests are welcome at github.com/Abdelrahman1atef/adaptable_remote_config. Run the checks from the package directory:
flutter pub get
flutter analyze
flutter test
Native Firebase fetches and real store launches need an Android/iOS device check; the unit tests use fakes.
License #
MIT. See LICENSE.