update_checker_plus

pub package pub points popularity License: MIT Flutter Dart 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

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_config to your app's pubspec.yaml and complete the Firebase setup. update_checker_plus does 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:

  1. Maintenance mode — if maintenance.enabled == true, always shown first
  2. Force update — if force_update == true OR current version < min_required_version
  3. Soft update — if current version < latest_version
  4. 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 force updates — 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.

Libraries

update_checker_plus
update_checker_plus — The most powerful, developer-friendly app update checker for Flutter.