app_upgrade_checker 1.1.0
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.
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,
),
),
],
],
),
);
}
}