app_upgrade_checker 1.1.0 copy "app_upgrade_checker: ^1.1.0" to clipboard
app_upgrade_checker: ^1.1.0 copied to clipboard

Check for a newer app version from the store (App Store / Google Play) or your own backend, and prompt users to update with a themeable full screen, dialog or bottom sheet.

example/lib/main.dart

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:app_upgrade_checker/app_upgrade_checker.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'App Upgrade Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const HomePage(),
    );
  }
}

/// Used by every demo except the last section: an optional update, after a beat
/// so any loading state you add is visible.
final _preview = UpdatePreview.optional(versionName: '9.9.9');

/// Section 7 downloads this — a file big enough that the fill has something to
/// say. Swap it for your own APK or OTA bundle.
const _downloadUrl =
    'https://freetestdata.com/wp-content/uploads/2022/02/Free_Test_Data_10MB_MOV.mov';

/// Your real configuration — Android reads Google Play, iOS the App Store.
///
/// Used only by the last section. Both platforms are optional: omit one to use
/// its store with default settings. Replace `appleId` with your own, or swap in
/// a [CustomSource] pointing at your backend or a hosted JSON file.
const _realConfig = AppConfig(
  android: PlayStoreSource(
    forcePolicy: ForcePolicy.auto,
    country: 'sa',
  ),
  ios: AppStoreSource(
    appleId: '6782569320',
    country: 'sa',
    forcePolicy: ForcePolicy.auto,
  ),
);

// ═════════════════════════════════════════════════════════════════════════════

const _jsonFileConfig = AppConfig(
  android: CustomSource(
    url:
        'https://alaakhaledahmed.github.io/app_upgrade_checker/version/android.json',
    // Opened by "Update now" when the JSON itself carries no `storeUrl`.
    fallbackStoreUrl: 'https://your.site/download',
  ),
  ios: CustomSource(
    url:
        'https://alaakhaledahmed.github.io/app_upgrade_checker/version/ios.json',
  ),
);

/// B) Your backend, with headers.
///
/// `headers` is a full `Map<String, String>` — not just a token. It is also the
/// *only* channel to the server, since nothing else about the device is sent.
/// Put anything the server needs to branch on here: a staged rollout, a
/// per-segment force, a beta channel.
///
/// Not `const`: the values are built at runtime.
AppConfig backendConfig({required String token, required String userSegment}) {
  final headers = <String, String>{
    'Authorization': 'Bearer $token',
    'X-User-Segment': userSegment,
    // Header values must be strings — convert numbers yourself.
    'X-Client': 'app_upgrade_checker-example',
  };

  return AppConfig(
    android: CustomSource(
      url: 'https://api.your-backend.com/app-version/android',
      headers: headers,
    ),
    ios: CustomSource(
      url: 'https://api.your-backend.com/app-version/ios',
    ),
  );
}

// ── Themes ───────────────────────────────────────────────────────────────────
final _rocketUp = AppUpgradeTheme.rocketUp();
final _superHero = AppUpgradeTheme.superHero();

// ── Opting blocks in ─────────────────────────────────────────────────────────
final _cosmicFull = AppUpgradeTheme.cosmic(
  showBadge: true,
  showFeatures: true,
);

// ── View type ────────────────────────────────────────────────────────────────
final _dialog = AppUpgradeTheme.cosmic(viewType: UpdateViewType.dialog);
final _sheet = AppUpgradeTheme.cosmic(viewType: UpdateViewType.sheet);

// ── Motion ───────────────────────────────────────────────────────────────────
const _entrances = <String, UpdateEntrance>{
  'rocketPull — pulled up from below': UpdateEntrance.rocketPull(),
  'liftoff — the backdrop sinks': UpdateEntrance.liftoff(),
};

/// The button's breathing glow: retuned, and switched off.
const _pulses = <String, UpdatePulse?>{
  'Stronger button glow': UpdatePulse(
    period: Duration(milliseconds: 1200),
    maxBlur: 34,
    maxOpacity: 0.7,
  ),
  'No button glow': null,
};

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  /// Section 7 reads this. `null` means "working, but there is nothing to
  /// measure" — the button falls back to its spinner for those stretches.
  ///
  /// Owned here, so it is disposed here: the package never touches its
  /// lifetime.
  final _progress = ValueNotifier<double?>(null);

  @override
  void dispose() {
    _progress.dispose();
    super.dispose();
  }

  // ═══════════════════════════════════════════════════════════════════════════
  // The screen. Every button calls a method further down — jump to one by
  // clicking its name.
  // ═══════════════════════════════════════════════════════════════════════════

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AppUpgrade Demo')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // ── 1. The designs ────────────────────────────────────────────────
          const _SectionLabel(
            '1 · Designs',
            'Three looks from the same building blocks.',
          ),
          FilledButton(
            onPressed: () => _promptDefault(context),
            child: const Text('Cosmic'),
          ),
          FilledButton(
            onPressed: () => _promptThemed(context, _rocketUp),
            child: const Text('RocketUp'),
          ),
          FilledButton(
            onPressed: () => _promptThemed(context, _superHero),
            child: const Text('SuperHero'),
          ),

          // ── 2. View type ──────────────────────────────────────────────────
          const _SectionLabel(
            '2 · View type',
            'The same design in a dialog or a bottom sheet — same blocks, same '
                'order, same show* flags.',
          ),
          FilledButton(
            onPressed: () => _promptThemed(context, _dialog),
            child: const Text('Dialog'),
          ),
          FilledButton(
            onPressed: () => _promptThemed(context, _sheet),
            child: const Text('Bottom sheet'),
          ),

          // ── 3. Content ────────────────────────────────────────────────────
          const _SectionLabel(
            '3 · Content',
            'Blocks are off until you ask for them. Whatever you set is yours; '
                'the rest keeps the design.',
          ),
          FilledButton(
            onPressed: () => _promptThemed(context, _cosmicFull),
            child: const Text('Badge + features'),
          ),
          // `lang` translates every default text. Also: en, ur, es, hi, fr, id
          // — and ar/ur flip the screen to RTL on their own.
          FilledButton(
            onPressed: () => _promptThemed(
                context,
                AppUpgradeTheme.cosmic(
                    lang: ThemeLang.ar, showBadge: true, showFeatures: true)),
            child: const Text('Arabic (lang: ThemeLang.ar)'),
          ),

          // ── 4. Updates that finish inside the app ─────────────────────────
          const _SectionLabel(
            '4 · Loading on the button',
            'When onUpdate returns a Future the button owns the wait: it fills '
                'with colour, then spins until the action settles. Pass a '
                'progress notifier as well and the fill follows your download '
                'instead of its own timed sweep.',
          ),
          FilledButton(
            onPressed: () => _promptWithProgress(context),
            child: const Text('Real download — the fill follows the bytes'),
          ),
          FilledButton(
            onPressed: () => _promptUnmeasured(context),
            child: const Text('Unmeasurable work — sweep, then spinner'),
          ),
          FilledButton(
            onPressed: () => _promptSlowSweep(context),
            child: const Text('A slower sweep (sweepDuration)'),
          ),
          // Points at a host that does not resolve, so the download throws
          // part-way and the failure hook runs for real.
          FilledButton.tonal(
            onPressed: () => _promptFailingDownload(context),
            child: const Text('A download that fails'),
          ),
          // Play In-App Update belongs in this section too, but it cannot run
          // from this example: `checkForUpdate` needs the app installed from
          // Play under the same applicationId. See `_promptViaPlay` below for
          // the shape, and the README for why it shows a spinner.
          FilledButton.tonal(
            onPressed: () => _snack(
              context,
              'Play In-App Update needs an app installed from Play — see '
              '_promptViaPlay in this file',
            ),
            child: const Text('Play In-App Update — see the code'),
          ),
          // Mirrors what the button is reading, so the two can be compared
          // while a download runs.
          Padding(
            padding: const EdgeInsets.only(top: 12),
            child: ValueListenableBuilder<double?>(
              valueListenable: _progress,
              builder: (context, value, _) => Text(
                value == null
                    ? 'progress: null → spinner'
                    : 'progress: ${(value * 100).toStringAsFixed(0)}%',
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ),
          ),

          // ── 5. Motion ─────────────────────────────────────────────────────
          const _SectionLabel(
            '5 · Motion',
            'How the screen arrives, and the glow on the button. Two of the '
                'seven entrances are shown here — see UpdateEntrance for the '
                'rest, and DialogEntrance for the dialog and sheet.',
          ),
          for (final e in _entrances.entries)
            FilledButton(
              onPressed: () => _promptThemed(
                  context, AppUpgradeTheme.cosmic(entrance: e.value)),
              child: Text(e.key),
            ),
          for (final p in _pulses.entries)
            FilledButton(
              onPressed: () => _promptThemed(
                  context,
                  AppUpgradeTheme.cosmic()
                      .copyWith(pulse: p.value, noPulse: p.value == null)),
              child: Text(p.key),
            ),

          // ── 6. The real check ─────────────────────────────────────────────
          const _SectionLabel(
            '6 · Real check',
            'No preview: a live store lookup using your own AppConfig. Expect '
                'it to fail until the app is actually published.',
          ),
          OutlinedButton(
            onPressed: () => _realCheck(context),
            child: const Text('Run a real store check'),
          ),

          // ── 7. The other method: CustomSource ─────────────────────────────
          const _SectionLabel(
            '7 · CustomSource — a URL you control',
            'The alternative to a store lookup: the library GETs your URL and '
                'reads the JSON contract from it. Nothing about the device is '
                'sent, so a static file works exactly like a backend. These '
                'point at placeholder URLs — swap in your own to see them pass.',
          ),
          OutlinedButton(
            onPressed: () => _customSourceCheck(
              context,
              _jsonFileConfig,
              'Hosted JSON file',
            ),
            child: const Text('Check a hosted JSON file (no backend)'),
          ),
          OutlinedButton(
            onPressed: () => _customSourceCheck(
              context,
              // Built at runtime: headers are the only channel to the server,
              // so this is where a token or a rollout segment goes.
              backendConfig(token: 'demo-token', userSegment: 'beta'),
              'Backend',
            ),
            child: const Text('Check a backend (with headers)'),
          ),
        ],
      ),
    );
  }

  // ═══════════════════════════════════════════════════════════════════════════
  // 1–3, 5 · Previewed demos: the screen always appears
  //
  // These pass a preview and no config, because there is nothing to check —
  // the outcome is forced. That keeps each demo about the *screen*.
  // ═══════════════════════════════════════════════════════════════════════════

  /// The shipped look, with no arguments beyond the preview.
  Future<void> _promptDefault(BuildContext context) =>
      AppUpgrade.checkAndPrompt(context, preview: _preview);

  /// A specific theme — how every design and variant above is shown.
  Future<void> _promptThemed(BuildContext context, AppUpgradeTheme theme) =>
      AppUpgrade.checkAndPrompt(
        context,
        preview: _preview,
        theme: theme,
      );

  // ═══════════════════════════════════════════════════════════════════════════
  // 6–7 · The real thing: no preview
  // ═══════════════════════════════════════════════════════════════════════════

  /// Runs an actual store lookup using [_realConfig].
  ///
  /// Expect "check failed" until your app is actually published — that is the
  /// store telling you there is no listing to read yet, not a library problem.
  Future<void> _realCheck(BuildContext context) async {
    final result = await AppUpgrade.checkUpdate(
        config: _realConfig, preview: UpdatePreview.optional());
    if (!context.mounted) return;
    switch (result) {
      case UpdateAvailable(:final versionName):
        _snack(context, 'Real update found: $versionName');
        await AppUpgrade.show(context, result,
            theme: AppUpgradeTheme.cosmic(viewType: UpdateViewType.sheet));
      case NoUpdate():
        _snack(context, 'Real check: you are on the latest version');
      case UpdateCheckError(:final message):
        _snack(context, 'Real check failed — $message');
    }
  }

  /// Runs a real check against a [CustomSource] — a hosted JSON file or your
  /// backend. Identical to [_realCheck] except for the config it is handed:
  /// once a config is built, every method behaves the same from here on.
  ///
  /// Expect "check failed" for the placeholder URLs above until you point them
  /// at something that actually serves the JSON contract.
  Future<void> _customSourceCheck(
    BuildContext context,
    AppConfig config,
    String label,
  ) async {
    final result = await AppUpgrade.checkUpdate(config: config);
    if (!context.mounted) return;
    switch (result) {
      case UpdateAvailable():
        await AppUpgrade.show(context, result);
        break;
      case NoUpdate():
        _snack(context, '$label: you are on the latest version');
        break;
      case UpdateCheckError(:final message):
        _snack(context, '$label failed — $message');
        break;
    }
  }

  // ═══════════════════════════════════════════════════════════════════════════
  // 7 · Loading on the button
  // ═══════════════════════════════════════════════════════════════════════════

  /// A real download, with the button's fill following it.
  ///
  /// Two arguments do it: `onUpdate` says what to run, `progress` says how far
  /// it has got. Without the second the button would still wait — it just would
  /// not know the shape of the wait.
  /// Success and failure are handled here, in this function's own
  /// `try`/`catch` — the package runs the action and nothing more, so whatever
  /// you would have put in an `onSuccess` callback goes in the `try`.
  ///
  /// [rethrow] is what sends the error on to `FlutterError.reportError`, and
  /// from there to Crashlytics. Swallowing it silences the report; either way
  /// the button restores itself and the prompt stays up.
  Future<void> _promptWithProgress(BuildContext context) =>
      AppUpgrade.checkAndPrompt(
        context,
        preview: _preview,
        theme: AppUpgradeTheme.cosmic(),
        onUpdate: (_) async {
          try {
            final bytes = await _download();
            _onDownloadSuccess(bytes);
          } catch (error) {
            _onDownloadFailure(error);
            rethrow; // reported through FlutterError, so it reaches the logs
          }
        },
        progress: _progress,
      );

  /// Your "download finished" hook. Install the APK, apply the patch, ask the
  /// user to restart — whatever comes next.
  ///
  /// Reached after an `await`, so the State's own `mounted` is what guards it:
  /// the prompt may already be gone.
  void _onDownloadSuccess(int bytes) {
    if (!mounted) return;
    final mb = (bytes / 1024 / 1024).toStringAsFixed(1);
    _snack(context, 'Download complete — $mb MB');
  }

  /// Your "download failed" hook. Log it, offer a retry, fall back to the
  /// store.
  void _onDownloadFailure(Object error) {
    if (!mounted) return;
    _snack(context, 'Download failed — $error');
  }

  /// Asynchronous, but with nothing to measure — a patch, an enterprise
  /// install, a sync. The button sweeps once, then spins until this settles.
  Future<void> _promptUnmeasured(BuildContext context) =>
      AppUpgrade.checkAndPrompt(
        context,
        preview: _preview,
        theme: AppUpgradeTheme.rocketUp(),
        onUpdate: (_) => Future<void>.delayed(const Duration(seconds: 5)),
      );

  /// The same wait, with the sweep slowed down. `Duration.zero` skips it
  /// entirely and goes straight to the spinner.
  Future<void> _promptSlowSweep(BuildContext context) =>
      AppUpgrade.checkAndPrompt(
        context,
        preview: _preview,
        theme: AppUpgradeTheme.superHero(
          updateButton: const UpdateButtonStyle(
            sweepDuration: Duration(seconds: 3),
          ),
        ),
        onUpdate: (_) => Future<void>.delayed(const Duration(seconds: 6)),
      );

  /// The same wiring against a URL that cannot resolve.
  ///
  /// Worth running once: the error goes to Flutter's error channel, the button
  /// restores itself and stays tappable, and the prompt stays up — a failed
  /// update has to be retryable, not a dead end.
  Future<void> _promptFailingDownload(BuildContext context) =>
      AppUpgrade.checkAndPrompt(
        context,
        preview: _preview,
        theme: AppUpgradeTheme.cosmic(),
        onUpdate: (_) async {
          try {
            await _download(url: 'https://this-host-does-not-exist.invalid/f');
          } catch (error) {
            _onDownloadFailure(error);
            rethrow;
          }
        },
        progress: _progress,
      );

  /// Play In-App Update — the shape, for copying.
  ///
  /// Commented out because it needs the `in_app_update` package and an app
  /// actually installed from Play: `checkForUpdate` throws anywhere else, so a
  /// live button here could only ever fail.
  ///
  /// Note what is **missing**: `progress`. Play reports an `InstallStatus`
  /// (`downloading`, `downloaded`, `installing`) and not a byte count, so there
  /// is no fraction to report — the button sweeps once and then spins, which is
  /// what that state is for. `completeFlexibleUpdate` restarts the app, so this
  /// callback never returns on the happy path.
  ///
  /// ```dart
  /// Future<void> _promptViaPlay(BuildContext context) async {
  ///   final info = await InAppUpdate.checkForUpdate();
  ///   if (!context.mounted) return;
  ///
  ///   await AppUpgrade.checkAndPrompt(
  ///     context,
  ///     onUpdate: (_) async {
  ///       if (!info.flexibleUpdateAllowed) return;
  ///       await InAppUpdate.startFlexibleUpdate();
  ///       await InAppUpdate.completeFlexibleUpdate();
  ///     },
  ///   );
  /// }
  /// ```

  /// Downloads [_downloadUrl], reporting bytes as they arrive.
  ///
  /// The shape worth copying is the three phases: an unmeasurable start, a
  /// measured middle, an unmeasurable end. Reporting `null` on either side is
  /// what keeps the button honest — it shows a spinner exactly when there is no
  /// number to show.
  ///
  /// `HttpClient` rather than a package, so the example needs no extra
  /// dependency. Dio's `onReceiveProgress` hands you the same two numbers.
  Future<int> _download({String url = _downloadUrl}) async {
    final client = HttpClient();
    try {
      // Phase 1 — connecting. Nothing to measure yet.
      _progress.value = null;

      final request = await client.getUrl(Uri.parse(url));
      final response = await request.close();

      final total = response.contentLength; // -1 when the server omits it
      final file = File('${Directory.systemTemp.path}/update.bin');
      final sink = file.openWrite();
      var received = 0;

      // Phase 2 — receiving. The only part with a real fraction.
      await for (final chunk in response) {
        sink.add(chunk);
        received += chunk.length;
        // No Content-Length leaves nothing to divide by, so report null and
        // let the spinner carry it.
        _progress.value = total > 0 ? received / total : null;
      }
      await sink.close();

      // Phase 3 — installing. Back to null: opening an installer, applying an
      // OTA bundle or unpacking an archive has no fraction either.
      _progress.value = null;
      await Future<void>.delayed(const Duration(seconds: 2)); // stands in

      return received;
    } finally {
      client.close();
      _progress.value = null;
    }
  }

  // ═══════════════════════════════════════════════════════════════════════════

  void _snack(BuildContext context, String message) =>
      ScaffoldMessenger.of(context)
          .showSnackBar(SnackBar(content: Text(message)));
}

class _SectionLabel extends StatelessWidget {
  const _SectionLabel(this.text, [this.subtitle]);
  final String text;
  final String? subtitle;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Padding(
      padding: const EdgeInsets.only(top: 24, bottom: 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            text,
            style: theme.textTheme.titleMedium?.copyWith(
              fontWeight: FontWeight.bold,
              color: theme.colorScheme.primary,
            ),
          ),
          if (subtitle != null) ...[
            const SizedBox(height: 2),
            Text(
              subtitle!,
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
              ),
            ),
          ],
        ],
      ),
    );
  }
}
23
likes
160
points
235
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Check for a newer app version from the store (App Store / Google Play) or your own backend, and prompt users to update with a themeable full screen, dialog or bottom sheet.

Repository (GitHub)
View/report issues

Topics

#update #upgrade #version #app-store #google-play

License

MIT (license)

Dependencies

flutter, http, lottie, package_info_plus, url_launcher

More

Packages that depend on app_upgrade_checker