update_checker_plus 1.0.0
update_checker_plus: ^1.0.0 copied to clipboard
The most powerful, developer-friendly app update checker for Flutter. Supports Play Store, App Store, Firebase Remote Config, Supabase, Appwrite, force updates, maintenance mode, beautiful adaptive di [...]
update_checker_plus #
The most powerful, developer-friendly app update checker for Flutter.
One-line integration. Remote-controlled from any backend. Beautiful Material 3 + Cupertino adaptive UI. Force update, maintenance mode, staged rollouts, and full i18n — all out of the box.
Why update_checker_plus? #
| Feature | update_checker_plus | upgrader | new_version | in_app_update |
|---|---|---|---|---|
| One-line API | ✅ | ❌ | ❌ | ❌ |
| Remote config (Firebase, Supabase, HTTP…) | ✅ | ❌ | ❌ | ❌ |
| Force update | ✅ | ⚠️ partial | ❌ | ❌ |
| Maintenance mode | ✅ | ❌ | ❌ | ❌ |
| Material 3 + Cupertino adaptive dialogs | ✅ | ❌ | ❌ | N/A |
| Bottom sheet UI | ✅ | ❌ | ❌ | N/A |
| Custom UI builder | ✅ | ❌ | ❌ | ❌ |
| Staged rollout support | ✅ | ❌ | ❌ | ❌ |
| "Don't ask again" persistence | ✅ | ⚠️ partial | ❌ | N/A |
| Offline caching | ✅ | ❌ | ❌ | N/A |
| Silent background check | ✅ | ❌ | ❌ | ❌ |
| Analytics hooks | ✅ | ❌ | ❌ | ❌ |
| Full i18n / custom strings | ✅ | ⚠️ partial | ❌ | N/A |
Table of Contents #
- Installation
- Quick Start
- How It Works
- Remote Config
- Remote Config JSON Schema
- Update Types
- UpdateResult Reference
- UI Styles
- Theming
- Strings & Localisation
- Callbacks & Analytics
- Silent Check
- Staged Rollouts
- Offline Support
- Skip Version
- Full UpdateConfig Reference
- Platform Setup
- Migrating from upgrader
- FAQ
Installation #
Add to your pubspec.yaml:
dependencies:
update_checker_plus: ^1.0.0
Then run:
flutter pub get
Quick Start #
Step 1 — Configure once at startup #
Call UpdateChecker.configure() in main() before runApp. This sets a global default that every subsequent check() call will use.
void main() {
UpdateChecker.configure(
UpdateConfig(
androidPackageName: 'com.example.myapp',
iosAppId: '123456789', // numeric App Store ID
debugMode: true, // prints logs in debug builds
),
);
runApp(const MyApp());
}
Step 2 — Check for updates #
Call this once after your first frame renders, for example in initState:
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
UpdateChecker.check(context: context);
});
}
That's it. The package handles everything — fetching the latest version, comparing it to the installed version, deciding whether to show a dialog, and launching the store if the user taps "Update Now".
How It Works #
UpdateChecker.check()
│
├─ Throttle check (default: once per 24 h)
│
├─ Online?
│ ├─ Yes → Fetch from RemoteConfigSource (or scrape store page)
│ └─ No → Use cached config → return UpdateResult.offline() if no cache
│
├─ Maintenance mode? → Show maintenance dialog → return maintenanceMode
│
├─ Update available?
│ ├─ No → return upToDate
│ └─ Yes →
│ ├─ User previously skipped this version? → return skipped
│ ├─ Staged rollout gate (device outside %)? → return upToDate
│ ├─ Fire onUpdateAvailable callback
│ ├─ Show dialog / bottom sheet / custom UI
│ │ ├─ User taps "Update Now" → launch store
│ │ ├─ User taps "Later" → fire onUpdateIgnored
│ │ └─ User taps "Don't ask" → persist skipped version
│ └─ return softUpdateAvailable | forceUpdateRequired
│
└─ Any error → fire onError → return UpdateResult.error (app continues normally)
Remote Config #
Remote config is the recommended approach. It lets you change update behaviour — force an update, activate maintenance mode, adjust messages — without shipping a new app release.
1. HTTP Endpoint #
Host a static JSON file anywhere (AWS S3, GitHub Gist, Cloudflare, your own API):
UpdateChecker.configure(
UpdateConfig(
remoteConfigSource: HttpRemoteConfigSource(
url: 'https://yourapi.com/update-config.json',
),
),
);
Optional: add authentication headers:
HttpRemoteConfigSource(
url: 'https://yourapi.com/update-config.json',
headers: {'Authorization': 'Bearer $token'},
timeout: const Duration(seconds: 8),
)
2. Firebase Remote Config #
Prerequisite: Add
firebase_remote_configto your app'spubspec.yamland complete the Firebase setup.update_checker_plusdoes not depend on it at compile time.
Step 1 — In the Firebase console, create a Remote Config parameter:
| Field | Value |
|---|---|
| Key | update_checker_plus |
| Data type | String |
| Value | (the JSON from the schema section) |
Step 2 — Configure fetch settings in main() and pass the instance to the source:
import 'package:firebase_remote_config/firebase_remote_config.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
// Configure fetch settings before handing the instance to UpdateChecker.
final remoteConfig = FirebaseRemoteConfig.instance;
await remoteConfig.setConfigSettings(RemoteConfigSettings(
fetchTimeout: const Duration(seconds: 15),
minimumFetchInterval: const Duration(hours: 1),
));
UpdateChecker.configure(
UpdateConfig(
remoteConfigSource: FirebaseRemoteConfigSource(
remoteConfig: remoteConfig,
configKey: 'update_checker_plus', // matches the key in Firebase console
),
),
);
runApp(const MyApp());
}
3. Supabase #
Option A — Database table:
-- Create a table with a JSONB column
CREATE TABLE app_config (
key TEXT PRIMARY KEY,
value JSONB
);
INSERT INTO app_config (key, value) VALUES (
'update_checker_plus',
'{"android": {...}, "ios": {...}, "maintenance": {"enabled": false}}'
);
UpdateChecker.configure(
UpdateConfig(
remoteConfigSource: SupabaseRemoteConfigSource.table(
supabaseUrl: 'https://xxxx.supabase.co',
anonKey: 'your-anon-key',
// Optional — table and column names default to 'app_config' / 'value'
),
),
);
Option B — Edge Function:
UpdateChecker.configure(
UpdateConfig(
remoteConfigSource: SupabaseRemoteConfigSource.edgeFunction(
supabaseUrl: 'https://xxxx.supabase.co',
anonKey: 'your-anon-key',
functionName: 'get-update-config',
),
),
);
4. Appwrite #
Option A — Database document:
UpdateChecker.configure(
UpdateConfig(
remoteConfigSource: AppwriteRemoteConfigSource.database(
endpoint: 'https://cloud.appwrite.io/v1',
projectId: 'your-project-id',
databaseId: 'your-database-id',
collectionId: 'app_config',
documentId: 'update_checker_plus',
configAttribute: 'config', // the document attribute holding the JSON
),
),
);
Option B — Function:
UpdateChecker.configure(
UpdateConfig(
remoteConfigSource: AppwriteRemoteConfigSource.function(
endpoint: 'https://cloud.appwrite.io/v1',
projectId: 'your-project-id',
functionId: 'get-update-config',
),
),
);
The function must return a JSON body matching the schema.
5. Custom Backend #
Implement RemoteConfigSource to connect any data source — GraphQL, REST, local file, a database, anything:
class MyCustomSource extends RemoteConfigSource {
@override
Future<RemoteConfigSchema> fetch() async {
final data = await myApi.getUpdateConfig();
return RemoteConfigSchema.fromJson(data);
}
// Optional: return a cached copy for offline support
@override
Future<RemoteConfigSchema?> fetchCached() async {
final cached = await myLocalCache.get('update_config');
if (cached == null) return null;
return RemoteConfigSchema.fromJson(cached);
}
}
UpdateChecker.configure(
UpdateConfig(remoteConfigSource: MyCustomSource()),
);
Remote Config JSON Schema #
All remote config sources must return JSON matching this shape:
{
"android": {
"latest_version": "2.5.0",
"min_required_version": "2.0.0",
"force_update": false,
"store_url": "https://play.google.com/store/apps/details?id=com.example.app",
"release_notes": "• New dashboard\n• Performance improvements\n• Bug fixes",
"rollout_percentage": 100,
"enabled": true
},
"ios": {
"latest_version": "2.5.0",
"min_required_version": "2.0.0",
"force_update": false,
"store_url": "https://apps.apple.com/app/id123456789",
"release_notes": "• New dashboard\n• Performance improvements\n• Bug fixes",
"rollout_percentage": 100,
"enabled": true
},
"maintenance": {
"enabled": false,
"message": "We'll be back shortly. Scheduled maintenance until 3:00 PM UTC."
},
"title": "Update Available",
"message": "A new version is ready with exciting features."
}
Field Reference #
android / ios block
| Field | Type | Default | Description |
|---|---|---|---|
latest_version |
string | required | The current version on the store |
min_required_version |
string | — | Versions below this trigger a force update |
force_update |
bool | false |
Force update regardless of version comparison |
store_url |
string | — | Deep link to the store listing |
release_notes |
string | — | Changelog shown in the dialog |
rollout_percentage |
int (0–100) | 100 |
Percentage of devices that see the prompt |
enabled |
bool | true |
Set to false to silently disable for this platform |
maintenance block
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | required | true activates maintenance mode |
message |
string | — | Message shown to users |
Root level
| Field | Type | Description |
|---|---|---|
title |
string | Overrides the dialog title for all update types |
message |
string | Overrides the dialog body for all update types |
Update Type Resolution Logic #
The package evaluates update type in this priority order:
- Maintenance mode — if
maintenance.enabled == true, always shown first - Force update — if
force_update == trueOR current version <min_required_version - Soft update — if current version <
latest_version - No update — otherwise
Update Types #
| Type | Behaviour |
|---|---|
UpdateType.none |
App is up to date — no UI shown |
UpdateType.soft |
Optional prompt with "Later" and "Don't ask again" buttons |
UpdateType.force |
Blocking dialog — back button and dismiss are disabled |
UpdateType.maintenance |
Non-dismissible maintenance message — no update button |
UpdateResult Reference #
UpdateChecker.check() always returns an UpdateResult. Inspect it to drive your own logic:
final result = await UpdateChecker.check(context: context);
switch (result.status) {
case UpdateStatus.upToDate:
// App is current — nothing to do.
break;
case UpdateStatus.softUpdateAvailable:
// Dialog was shown. result.updateInfo contains version details.
print('Update to ${result.updateInfo?.latestVersion} available');
break;
case UpdateStatus.forceUpdateRequired:
// Blocking dialog was shown.
break;
case UpdateStatus.maintenanceMode:
// Maintenance dialog was shown.
break;
case UpdateStatus.skipped:
// User previously tapped "Don't ask again" for this version.
break;
case UpdateStatus.offline:
// Device is offline and no cached config was found.
break;
case UpdateStatus.error:
// Something went wrong (network, parse, etc.). App continues normally.
print(result.error);
break;
}
UI Styles #
Default — Material 3 dialog (Android / Web / Desktop) #
Automatically uses Cupertino on iOS and macOS. No configuration needed.
await UpdateChecker.check(context: context);
Bottom sheet #
await UpdateChecker.check(
context: context,
config: UpdateConfig(
displayStyle: UpdateDisplayStyle.bottomSheet,
androidPackageName: 'com.example.app',
),
);
Or call it directly with your own UpdateInfo:
await showUpdateBottomSheet(
context: context,
info: myUpdateInfo,
strings: const UpdateStrings(),
);
Custom UI #
Replace the entire update UI with your own widget tree. The package still handles all the logic — version fetching, caching, skip persistence — and just calls your builder:
UpdateConfig(
customUiBuilder: UpdateUiBuilder(
builder: (context, info, onUpdate, onDismiss) {
return Dialog(
child: Column(
children: [
Text('Version ${info.latestVersion} is ready!'),
ElevatedButton(
onPressed: onUpdate, // launch store
child: const Text('Update'),
),
TextButton(
onPressed: () => onDismiss(null), // dismiss without skipping
child: const Text('Later'),
),
TextButton(
// Pass the version string to persist "don't ask again"
onPressed: () => onDismiss(info.latestVersion),
child: const Text("Don't ask again"),
),
],
),
);
},
),
)
Theming #
Presets #
// Dark theme
UpdateConfig(theme: UpdateTheme.dark())
// Branded — adapts to your app's primary colour
UpdateConfig(theme: UpdateTheme.branded(Colors.deepPurple))
Fully custom #
UpdateConfig(
theme: UpdateTheme(
backgroundColor: const Color(0xFF1A1A2E),
titleColor: Colors.white,
messageColor: const Color(0xFFB0B8C1),
borderRadius: 28,
icon: const Icon(Icons.rocket_launch_rounded, size: 52, color: Colors.white),
headerDecoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF6C63FF), Color(0xFF3B82F6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
primaryButtonStyle: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
),
secondaryButtonStyle: TextButton.styleFrom(
foregroundColor: const Color(0xFF6C63FF),
),
),
)
Strings & Localisation #
Override any displayed string to translate or customise the copy:
UpdateConfig(
strings: UpdateStrings(
softUpdateTitle: 'Nueva versión disponible',
softUpdateMessage: '¡Actualiza ahora para disfrutar de las últimas novedades!',
forceUpdateTitle: 'Actualización requerida',
forceUpdateMessage: 'Esta versión ya no está disponible. Actualiza para continuar.',
maintenanceTitle: 'En mantenimiento',
maintenanceMessage: 'Volvemos pronto. Gracias por tu paciencia.',
updateNowButton: 'Actualizar',
laterButton: 'Más tarde',
skipVersionButton: 'No preguntar de nuevo',
whatsNewLabel: 'Novedades',
),
)
All fields have sensible English defaults — you only need to override the strings you want to change.
Callbacks & Analytics #
UpdateConfig(
// Called when an update is found, before showing UI.
// Return false to suppress the default dialog and handle it yourself.
onUpdateAvailable: (UpdateInfo info) async {
print('${info.latestVersion} is available (${info.updateType.name})');
return true; // true = show default UI
},
// Called when the user taps "Update Now" and is sent to the store.
onUpdateAccepted: (UpdateInfo info) {
print('Heading to store for v${info.latestVersion}');
},
// Called when the user taps "Later" or dismisses the dialog.
onUpdateIgnored: (UpdateInfo info) {
print('User skipped v${info.latestVersion}');
},
// Called when maintenance mode is active.
onMaintenanceMode: () {
print('App is under maintenance');
},
// Called when any error occurs. The app continues normally.
onError: (Object error, StackTrace? stackTrace) {
FirebaseCrashlytics.instance.recordError(error, stackTrace);
},
// Generic analytics hook — works with any analytics SDK.
onAnalyticsEvent: (String event, Map<String, dynamic>? properties) {
FirebaseAnalytics.instance.logEvent(
name: event,
parameters: properties,
);
},
)
Analytics Events #
The package fires these events automatically through onAnalyticsEvent:
| Event name | When fired | Properties |
|---|---|---|
update_checker_update_available |
An update is detected | current_version, latest_version, update_type |
update_checker_update_accepted |
User taps "Update Now" | version |
update_checker_update_ignored |
User dismisses the dialog | version |
update_checker_maintenance |
Maintenance mode is active | — |
update_checker_error |
An error occurred | error |
Silent Check #
Run the full update check pipeline without showing any UI. Useful for background workers, notification badges, or when you want to drive your own custom UI based on the result.
final result = await UpdateChecker.checkSilently(
config: UpdateConfig(
androidPackageName: 'com.example.app',
),
);
if (result.status == UpdateStatus.forceUpdateRequired) {
// Navigate to a blocking update screen
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ForceUpdateScreen()),
);
}
Staged Rollouts #
Control what percentage of your users see the update prompt. This is useful for testing a new release with a subset of users before rolling it out to everyone.
Set rollout_percentage in your remote config:
{
"android": {
"latest_version": "3.0.0",
"rollout_percentage": 20
}
}
Only 20% of devices will be prompted. Increase the percentage gradually as you gain confidence in the release. Set it to 100 for a full rollout.
How it works: The package uses a hash of the app's package name as a stable, per-device seed. This means the same device always gets the same decision across app restarts, and the decision doesn't change until you change the rollout percentage.
Offline Support #
When the device has no network connection, the package automatically falls back to the last cached remote config response — no extra code needed.
If no cache exists (first launch while offline), check() returns UpdateResult.offline() and no dialog is shown. The app continues normally.
Custom RemoteConfigSource implementations can override fetchCached() to provide their own caching strategy:
@override
Future<RemoteConfigSchema?> fetchCached() async {
final json = await myDatabase.query('SELECT config FROM app_config LIMIT 1');
if (json == null) return null;
return RemoteConfigSchema.fromJson(jsonDecode(json));
}
Skip Version #
When allowSkipVersion: true (the default), a "Don't ask again" button appears on soft update dialogs. The user's choice is persisted across sessions using SharedPreferences.
UpdateConfig(
allowSkipVersion: true, // default — shows "Don't ask again"
)
Manage the persisted state manually:
// Clear the skipped version (e.g. when a security-critical update is released)
await UpdateChecker.clearSkippedVersion();
// Check which version was skipped
final skipped = await UpdateChecker.getSkippedVersion();
print('User skipped: $skipped');
Skip state is automatically ignored for
forceupdates — the blocking dialog always appears regardless.
Full UpdateConfig Reference #
UpdateConfig(
// ── Remote config source ─────────────────────────────────────────────────
remoteConfigSource: HttpRemoteConfigSource(url: '...'), // or Firebase, Supabase, etc.
// ── Store scraping fallback (used when remoteConfigSource is null) ────────
androidPackageName: 'com.example.app',
iosAppId: '123456789',
// ── Behaviour ────────────────────────────────────────────────────────────
checkInterval: const Duration(hours: 24), // min time between checks
disabled: false, // set true to disable all checks
debugMode: false, // prints internal logs to console
allowSkipVersion: true, // show "Don't ask again" button
displayStyle: UpdateDisplayStyle.dialog, // .dialog or .bottomSheet
// ── Callbacks ─────────────────────────────────────────────────────────────
onUpdateAvailable: (info) async => true, // return false to suppress default UI
onUpdateAccepted: (info) {},
onUpdateIgnored: (info) {},
onMaintenanceMode: () {},
onError: (error, stackTrace) {},
// ── Analytics ─────────────────────────────────────────────────────────────
onAnalyticsEvent: (event, props) {},
// ── UI customisation ──────────────────────────────────────────────────────
theme: UpdateTheme.branded(Colors.blue),
customUiBuilder: null, // replaces default UI entirely
strings: UpdateStrings(updateNowButton: 'Get It Now'),
)
Platform Setup #
Android #
Add the following to android/app/src/main/AndroidManifest.xml so the package can open the Play Store:
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required for Android 11+ to launch the Play Store intent -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
iOS #
No extra configuration is required. The package opens the App Store via url_launcher.
Web / Desktop #
Automatic store-page scraping is not supported on Web, Windows, or Linux. Use a RemoteConfigSource and provide a store_url in your config — the package will use url_launcher to open it.
Migrating from upgrader #
// Before (upgrader) — wraps your widget tree
UpgradeAlert(child: myWidget)
// After (update_checker_plus) — call once, anywhere
await UpdateChecker.check(context: context);
Key improvements over upgrader:
| upgrader | update_checker_plus | |
|---|---|---|
| Setup | Wrap widget tree | One method call |
| Remote control | ❌ | ✅ Change behaviour without releasing |
| Force update | Partial | ✅ True blocking dialog |
| Maintenance mode | ❌ | ✅ Built-in |
| Result type | None | ✅ UpdateResult enum — fully typed |
| Custom UI | Limited | ✅ Full widget builder |
| Offline | ❌ | ✅ Cached fallback |
FAQ #
Q: Can I use this without any backend?
Yes. Provide androidPackageName and/or iosAppId and the package scrapes the public Play Store / App Store pages as a fallback. No backend needed for basic version checking.
Q: What happens if the network request fails?
All errors are caught internally. onError is called with the exception, and the method returns UpdateResult.error(...). No dialog is shown. The app continues normally — the package never crashes your app.
Q: Can I show the update dialog manually without running the full check?
Yes. Call showUpdateDialog or showUpdateBottomSheet directly with a custom UpdateInfo:
await showUpdateDialog(
context: context,
info: const UpdateInfo(
latestVersion: '3.0.0',
currentVersion: '2.0.0',
updateType: UpdateType.soft,
releaseNotes: '• New features\n• Bug fixes',
),
strings: const UpdateStrings(),
);
Q: How do I prevent the dialog from showing every time in debug mode?
Set checkInterval: Duration.zero to always check, or disabled: true to skip checks entirely during development.
Q: Does force update truly block the user?
Yes. canPop: false is set on the PopScope wrapper, which disables both the Android back button and the predictive back gesture. The dialog cannot be dismissed.
Q: Does this work on Flutter Web?
Yes. Store scraping is disabled on Web (no dart:io), but if you provide a remoteConfigSource and a store_url, the full flow works — including showing the dialog and opening the store URL in a new tab.
Q: How do I test different update types in development?
Use a mock RemoteConfigSource:
class MockUpdateSource extends RemoteConfigSource {
@override
Future<RemoteConfigSchema> fetch() async {
return RemoteConfigSchema.fromJson({
'android': {
'latest_version': '99.0.0',
'force_update': true,
'enabled': true,
},
});
}
}
Contributing #
Issues and PRs are welcome. Please open an issue first for major changes.
License #
MIT © 2026 update_checker_plus contributors. See LICENSE.