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.
app_upgrade_checker #
Tell your users a new version is out β and get them to install it.
app_upgrade_checker checks the version running on the user's device against the latest
one you've released. If they're behind, it shows them a full-screen update screen
that takes them straight to the download.
β¨ Why app_upgrade_checker? #
You decide what "the latest version" is β read it from the store, or from your own backend or a simple JSON file. And the screen your users see is a designed, animated, fully customizable one.
| π The version comes from wherever you want | The store, or a URL you control β your API or a JSON file. With your own URL, you set the latest version, the oldest build still allowed, and whether updating is mandatory. |
| β¬οΈ You own the update action | Send them to the store, or apply the update inside your app β your own download, a code push, Play In-App Update. The button shows the work: a real percentage when you can measure it, a spinner when you can't. |
| π 7 languages built in | Arabic, English, Urdu, Spanish, Hindi, French, Indonesian β follows the device, with automatic RTL. |
| πͺ Screen, dialog or bottom sheet | Show the update as a full screen, a dialog, or a bottom sheet. |
| π§ͺ Test mode | See the update screen while you build β even if your app isn't on the store yet. Try any case: forced, optional, up-to-date, or failed. |
| π¨ 3 full-screen designs | Artwork, animation and a clear call to action β ready to ship as-is. |
| β¨ 7 entrance animations | Automatically respect reduce motion. |
The three designs #
| Cosmic (default) | RocketUp | SuperHero |
|---|---|---|
![]() |
![]() |
![]() |
AppUpgradeTheme.cosmic() |
AppUpgradeTheme.rocketUp() |
AppUpgradeTheme.superHero() |
Optional blocks, on any design #
Every design ships with a badge pill and three feature cards. Both are off by default β one flag each brings in that design's own content. β Blocks: show, hide, reorder
| Default | showBadge: true |
showBadge + showFeatures |
|---|---|---|
![]() |
![]() |
![]() |
Or as a dialog, or a bottom sheet #
The same design, the same blocks β a different container. One field: viewType.
β View type: screen, dialog or sheet
UpdateViewType.screen (default) |
UpdateViewType.dialog |
UpdateViewType.sheet |
|---|---|---|
![]() |
![]() |
![]() |
AppUpgradeTheme.cosmic() |
AppUpgradeTheme.cosmic(viewType: UpdateViewType.dialog) |
AppUpgradeTheme.cosmic(viewType: UpdateViewType.sheet) |
Platforms: Android and iOS only. This is a mobile-focused package; it does not support web or desktop.
Table of contents #
Getting started
- How it works
- Install
- Quick start β the store setup, in one line
Behaviour
- Choosing where the version comes from
- Your own update action β apply the update inside your app
Styling
- Theming the screen
- Motion β entrances, button glow, reduce motion
How it works #
app_upgrade_checker reads the currently installed version from the device, asks a source what the latest version is, and returns
one of three outcomes: an update is available (optionally forced), no update, or
the check failed.
The source is what you choose per platform. That's the whole API surface.
Install #
dependencies:
app_upgrade_checker: ^1.1.0
import 'package:app_upgrade_checker/app_upgrade_checker.dart';
Quick start #
This section covers the default setup β your app is public on Google Play or the App Store. That needs no backend, no JSON, and no configuration.
1. The one line #
await AppUpgrade.checkAndPrompt(context);
That's a complete, working integration. With no arguments it reads the store listing for the current platform, compares it to the installed version, and β if the user is behind β shows the built-in update screen.
2. Where to call it #
At app start, from initState. It's safe there by design: it waits for the
first frame internally, so the Navigator is ready before anything is shown.
@override
void initState() {
super.initState();
AppUpgrade.checkAndPrompt(context); // no post-frame callback needed
}
You can also call it from a button ("Check for updates" in settings), or
after login β anywhere you have a BuildContext. Calling it twice is safe: the
second call reuses the first request and never stacks a second screen. To show a
spinner or disable that button meanwhile, read AppUpgrade.isChecking.
3. What you can pass to checkAndPrompt #
context is the only required argument. Everything else is optional β this
table is about when you'd bother:
| Parameter | Type | Default | Pass it when⦠|
|---|---|---|---|
context |
BuildContext |
required | always β it's how the screen is shown. |
config |
AppConfig |
AppConfig() β the store |
you want the version to come from your backend or a JSON file instead of the store β choosing where the version comes from. |
theme |
AppUpgradeTheme? |
Cosmic design | you want a different look β another design, your colors, your font β theming. |
preview |
UpdatePreview? |
null |
you're still developing and want to see the screen before your app is on the store. UpdatePreview.optional() / .forced() / .upToDate() / .failure() β each takes versionName, releaseNotes, storeUrl, delay. Throws in release builds, so it can't ship by accident. |
builder |
Widget Function(UpdateAvailable)? |
null |
you want your own screen instead of the built-in one: builder: (update) => MyScreen(update). It receives the full result to render from. |
onError |
void Function(UpdateCheckError)? |
null |
you want failed checks reported to Crashlytics / Sentry. The user never sees an error either way. |
onUpdate |
FutureOr<void> Function(UpdateAvailable)? |
opens the store listing | the update is applied somewhere other than the store β an in-app download, a code push, an enterprise install β your own update action. |
onSkip |
FutureOr<void> Function()? |
dismisses the prompt | dismissing should do something first β log it, postpone it for a week. Yours closes the prompt itself β your own update action. |
progress |
ValueListenable<double?>? |
null |
your onUpdate can measure itself and you want the button to fill to match β showing the work on the button. |
So a typical real-world call is still short:
await AppUpgrade.checkAndPrompt(
context,
theme: AppUpgradeTheme.rocketUp(), // just a different design
);
What happens in each case: update found β the screen is shown. No update β
nothing happens. Check failed β the user sees nothing, and onError fires.
Your app isn't published yet? Then there is no store listing to read, so the check finds nothing. Add
preview: UpdatePreview.optional()(see the table above) to see the screen anyway.
4. The other entry point: checkUpdate #
Use this when you don't want the built-in behaviour: it runs the same check
but shows nothing, handing you the answer so you can decide β a banner instead of
a screen, a postponed prompt, a silent background check. It takes config and
preview (no context, since it renders nothing) and returns an
UpdateCheckResult, which is always exactly one of three:
| Result | Meaning | What you can read from it |
|---|---|---|
UpdateAvailable |
the user is behind | the version, the store link, the release notes, whether it's forced β see the example below |
NoUpdate |
already up to date | β |
UpdateCheckError |
the check failed | state β socket / timeout / server / unAuthorized / format, so you can retry a dropped connection but report a bad response β and message |
final result = await AppUpgrade.checkUpdate();
switch (result) {
case UpdateAvailable():
// Every field is optional to use β read only what you need:
result.isForceUpdate; // bool β is it mandatory?
result.versionName; // String?β "3.5.0"
result.storeUrl; // String?β the download link
result.releaseNotes; // String?β what's new
result.versionCode; // int? β CustomSource only, else null
result.minSupportedVersionCode; // int? β CustomSource only, else null
result.data; // the raw response
if (context.mounted) await AppUpgrade.show(context, result);
// ...or send them straight there: AppUpgrade.openStore(result.storeUrl!);
case NoUpdate():
break;
case UpdateCheckError():
debugPrint('check failed: ${result.message}');
}
checkAndPromptis just these two combined βcheckUpdateto get the result, thenAppUpgrade.showto display it.
5. Showing the prompt: AppUpgrade.show #
The second half of that pair. Call it directly when you already have an
UpdateAvailable β you checked at startup and postponed the prompt, or you
want it shown on your own terms rather than the moment the check returns.
AppUpgrade.show β what you can pass:
| Parameter | Type | Default | Pass it when⦠|
|---|---|---|---|
context |
BuildContext |
required | always β it's how the prompt is shown. |
update |
UpdateAvailable |
required | always β the result you got from checkUpdate. |
theme |
AppUpgradeTheme? |
Cosmic design | you want a different look β theming. The theme's viewType also decides whether it's a screen, a dialog or a sheet. |
onUpdate |
FutureOr<void> Function(UpdateAvailable)? |
opens the store listing | the update is applied somewhere other than the store β your own update action. |
onSkip |
FutureOr<void> Function()? |
dismisses the prompt | dismissing should do something first. Yours closes the prompt itself β your own update action. |
progress |
ValueListenable<double?>? |
null |
your onUpdate can measure itself β showing the work on the button. |
final result = await AppUpgrade.checkUpdate();
if (result is UpdateAvailable && context.mounted) {
await AppUpgrade.show(context, result); // the whole call
}
It completes when the user dismisses the prompt. Calling it while a prompt is already up is a no-op, so a double tap can't stack two copies.
The same
theme,onUpdate,onSkipandprogressare accepted directly byAppUpgradeScreen,AppUpgradeDialogandAppUpgradeSheet, for when you build one of them yourself instead of going throughshow.
That's the quick start. Everything below goes deeper: using your own backend instead of the store, the JSON contract, theming the screen, animations, and the full reference for every class.
Choosing where the version comes from #
First: what is AppConfig? #
AppConfig answers one question β "where do I find out the latest version?" β
and it answers it separately for each platform, because Android and iOS are
usually distributed differently.
AppConfig(
android: /* a source */, // null β check Google Play
ios: /* a source */, // null β check the App Store
)
- Leave a platform
nulland it checks that platform's store with defaults. That's whyAppConfig()β the default in Quick start β needs no arguments. - Set a platform and you replace its source. Each platform gets exactly one.
- You can mix them freely: Android from your backend, iOS from the App Store, or any other combination.
There are two kinds of source to choose from:
| Source | Where the version comes from | You need |
|---|---|---|
CustomSource |
a URL you control β your API or a JSON file | a URL returning the JSON contract |
PlayStoreSource / AppStoreSource |
the public store listing | nothing β your app just has to be published |
1. Store sources β read the listing directly #
The simplest path, and the default: the device reads your public store
listing and takes the version from there. Nothing to host, nothing to maintain.
This is what runs when you call checkAndPrompt(context) with no config.
| Platform | Class | Reads from |
|---|---|---|
| Android | PlayStoreSource |
the Google Play listing (an HTML read β unofficial) |
| iOS | AppStoreSource |
Apple's iTunes Lookup API (official, public, no auth) |
Use it when your app is published publicly and you're happy to treat "the version on the store" as the truth.
One thing to know: a store listing only exposes the version name
("3.2.0") β never a numeric build number. So there is nothing like
minSupportedVersionCode here; "is this update mandatory?" is decided by
forcePolicy instead.
// Both platforms on their store, with defaults β same as AppConfig()
const AppConfig();
// Or configure them:
AppConfig(
android: const PlayStoreSource(forcePolicy: ForcePolicy.always),
ios: const AppStoreSource(appleId: '123456789'),
)
PlayStoreSource β what you can pass:
| Parameter | Type | Default | Pass it when⦠|
|---|---|---|---|
forcePolicy |
ForcePolicy |
auto |
you want to change when an update becomes mandatory β ForcePolicy. |
language |
String? |
null |
you want the release notes in a specific language (hl=), e.g. 'ar'. |
country |
String? |
null |
your listing differs per storefront (gl=), e.g. 'SA'. |
AppStoreSource β what you can pass:
| Parameter | Type | Default | Pass it when⦠|
|---|---|---|---|
forcePolicy |
ForcePolicy |
auto |
same as above β ForcePolicy. |
appleId |
String? |
null |
the bundle-id lookup returns nothing. Pass the numeric Apple ID (trackId) instead. |
country |
String? |
null |
your app is published in a specific storefront, e.g. 'sa'. Defaults to the US store. |
ForcePolicy
Controls when a store-method update is mandatory (ignored for
CustomSource, which carries its own flags):
| Value | Behaviour |
|---|---|
ForcePolicy.auto (default) |
Forced only on a major bump (3.x.x β 4.x.x). |
ForcePolicy.always |
Every available update is forced. |
ForcePolicy.never |
Every update is optional. |
Not published yet, or on a closed track? A store lookup has no listing to read β use a
CustomSource, or thepreviewparameter during development.
2. CustomSource β your backend or a JSON file #
Point it at any URL you control and you decide the latest version, the oldest build still allowed, and whether the update is mandatory. Use it for private or non-store distribution (TestFlight, enterprise, Huawei, a direct APK), staged rollouts, or whenever you need a real numeric build number β the stores only expose a version name to a device.
The library sends a plain GET and expects the JSON below. A static file and a real backend are identical here β only the URL differs, so a file on GitHub Pages, Cloudflare, S3 or Netlify works with no server at all.
AppConfig(
android: const CustomSource(url: 'https://api.you.com/app-version/android'),
ios: const CustomSource(url: 'https://api.you.com/app-version/ios'),
)
| Parameter | Type | Default | Pass it when⦠|
|---|---|---|---|
url |
String |
required | always. Use a separate URL per platform so each carries its own storeUrl. |
headers |
Map<String,String>? |
null |
your endpoint needs auth or any extra header β it's the only thing sent, so also handy for a segment header. Note the check often runs before sign-in. |
fallbackStoreUrl |
String? |
null |
you'd rather keep the download link in the app than in the JSON. Used only if the response has no storeUrl. |
Want the real store version? Have your backend call the official store APIs (Google Play Developer, App Store Connect) and return the JSON below. Both need a secret key, so they can only be called from a server β and it's the only way to get a numeric build number or a closed-track version.
What your URL must return
All fields are optional (and may be nested under a data key). The minimum that
works: { "latestVersionCode": 55, "storeUrl": "..." }.
{
"latestVersionCode": 55,
"latestVersionName": "3.5.0",
"minSupportedVersionCode": 50,
"storeUrl": "https://play.google.com/store/apps/details?id=com.you.app",
"releaseNotes": "Performance improvements and bug fixes",
"hasUpdate": true,
"isForceUpdate": false
}
| Field | Type | Meaning |
|---|---|---|
latestVersionCode |
int | Build number of the latest release. Primary basis for comparison. |
latestVersionName |
string | Display version, e.g. "3.5.0" (also accepted as versionName). |
minSupportedVersionCode |
int | Builds below this are force-updated. |
storeUrl |
string | Where "Update now" sends the user. |
releaseNotes |
string | Shown in the update screen. |
hasUpdate |
bool | Optional override β if present, the library trusts it. |
isForceUpdate |
bool | Optional override β if present, the library trusts it. |
Your endpoint receives nothing β no body, no parameters, and the installed version is never sent. The response is the same for everyone, so cache it hard.
A live example: https://alaakhaledahmed.github.io/app_upgrade_checker/version/android.json β served from
docs/version/in this repo and used byexample/lib/main.dart.
Your own update action #
By default UPDATE NOW opens the store listing and LATER dismisses the
prompt. Two callbacks replace those defaults β both accepted by
show and
checkAndPrompt:
| Callback | Replaces | Use it for |
|---|---|---|
onUpdate |
opening the store | applying the update yourself. |
onSkip |
dismissing the prompt | doing something before it closes β logging it, postponing it. |
Each replaces its default rather than running alongside it, so call
AppUpgrade.openStore(url) inside onUpdate if you still want the store too.
onUpdate β where the update is applied #
onUpdate: (update) async {
try {
await _install(update.versionName!);
if (context.mounted) Navigator.of(context).pop();
} catch (error) {
if (context.mounted) _showRetry(context);
rethrow;
}
},
Success and failure are yours to handle β the package runs your action and nothing more:
| What the package does | |
|---|---|
| It succeeded | Nothing. Close the prompt yourself, unless a restart follows. |
| It threw | The button restores itself and stays tappable, and the prompt stays up β so a retry costs nothing. |
| Either way | The error can't crash the app: rethrow sends it to FlutterError.reportError and on to Crashlytics; swallowing it silences the report. |
onSkip β what dismissing does #
onSkip: () {
_logPostponed();
Navigator.of(context).pop(); // yours to do β nothing pops it for you
},
A mandatory update ignores onSkip β when isForceUpdate is true the
dismiss action isn't built at all.
Showing the work on the button #
The return type of onUpdate decides this; there's nothing to configure:
Your onUpdate |
The button |
|---|---|
| synchronous | Doesn't change β it's a hand-off, and the store opens over your app. |
returns a Future |
Owns the wait: colour sweeps across it, then a spinner until the future settles, then it restores itself. LATER freezes and the pulse stops meanwhile. |
The sweep is a fixed acknowledgement, not a measure of the work. It's styled on the button rather than passed to the call:
UpdateButtonStyle β the loading state:
| Field | Type | Default | Set it when⦠|
|---|---|---|---|
sweepDuration |
Duration |
1100ms |
you want the colour to travel faster or slower. Duration.zero skips it, straight to the spinner. |
sweepColor |
Color |
translucent grey | the grey doesn't suit the design. Keep it translucent β an opaque colour hides the label instead of muting it. |
progressText |
String |
'{p}%', localized |
"updating" is the wrong word for your action: 'Downloading {p}%'. |
progress β a measured action #
Pass a ValueListenable<double?> and the fill follows your numbers instead of
the sweep, which is then skipped entirely:
final _progress = ValueNotifier<double?>(null); // yours to create and dispose
await AppUpgrade.checkAndPrompt(
context,
onUpdate: (_) => _download(), // sets _progress as the bytes arrive
progress: _progress,
);
| You report | The button shows |
|---|---|
null, before any value |
UPDATING 0%, waiting at zero. |
a fraction β 0.45 |
UPDATING 45%, with the fill to match. |
1 |
the spinner β the measured part is done, the action isn't. |
null after a value |
the spinner β for a phase with no fraction, like unpacking an archive. |
So a download reports null, its fraction, then null again: an unmeasurable
start, a measured middle, an unmeasurable end.
example/lib/main.dart has it against a real URL. The
percentage is translated in all seven languages.
Wiring it to a real updater #
Any updater fits, because onUpdate takes any Future and progress takes any
ValueListenable. None of the below is a built-in integration.
Play In-App Update (Android), via
in_app_update:
final info = await InAppUpdate.checkForUpdate();
await AppUpgrade.checkAndPrompt(
context,
onUpdate: (_) async {
if (!info.flexibleUpdateAllowed) return;
await InAppUpdate.startFlexibleUpdate(); // downloads
await InAppUpdate.completeFlexibleUpdate(); // installs, restarts the app
},
// no progress β Play reports a status, not a fraction
);
This shows the spinner, not a percentage. Play reports bytesDownloaded, but
in_app_update doesn't forward it β installUpdateListener is a
Stream<InstallStatus>, so only a state reaches Dart. With no fraction to give
progress, the sweep and spinner carry the wait.
completeFlexibleUpdate()restarts the app, soonUpdatenever returns on the happy path β there's nothing to close.performImmediateUpdate()draws Google's own full-screen UI over yours, so it's a synchronous hand-off β don'tawaitit into the button.- Installing an APK yourself needs
REQUEST_INSTALL_PACKAGES, which Play reviews strictly. - The same snippet is in
example/lib/main.dartas_promptViaPlay, commented out:checkForUpdateneeds an app installed from Play, so it can't run there.
iOS has no equivalent β Apple owns binary delivery (App Store Review
Guideline 2.5.2), so opening the listing is the only route, which is the default
already. The exception is code push (Shorebird and friends), allowed on both
platforms because it replaces Dart code rather than the binary. It reports no
fraction either, so leave progress off.
Theming the screen #
The screen carries no styling of its own β a AppUpgradeTheme supplies every
colour, text, asset and animation it draws, and which blocks it draws at all. So
restyling is never a subclass: you build a theme and pass it as theme:.
Three designs ship as named constructors; each fills all 30 fields with its own values, and you override only what you need.
Every AppUpgradeTheme field #
Every field is optional; anything you omit keeps the design's own value.
| Field | Type | Default | Purpose |
|---|---|---|---|
lang |
ThemeLang? |
the device's language, else en |
Language of the default texts: en, ar, ur, es, hi, fr, id. ar/ur also switch the screen to RTL. Any text you set yourself wins over the translation. |
order |
List<UpdateSection> |
visual β badge β title β version β description β features β buttons | Which blocks appear, and in what order. |
background |
UpdateBackground |
per design | Solid colour, gradient, or an image. |
visual |
UpdateVisual? |
per design | The top artwork β Lottie, image, icon, or your widget. |
badge |
UpdateBadgeStyle |
per design | The pill above the headline. |
title |
UpdateTitle |
per design | The two-line headline. |
version |
UpdateVersionStyle |
default | The version pill. |
description |
UpdateTextStyle |
per design | The body paragraph. |
fallbackDescription |
String |
'A new version is ready to install.' |
Used when neither the theme nor the response has text. |
features |
List<UpdateFeature> |
per design | The cards. |
updateButton |
UpdateButtonStyle |
per design | Primary button, including how it looks while your action runs. |
laterButton |
LaterButtonStyle |
default | Dismiss link. |
showVisual, showTitle, showDescription, showUpdateButton, showLaterButton |
bool |
true |
On by default. |
showBadge, showFeatures, showVersion |
bool |
false |
Off by default β one flag brings in the design's own content. |
contentPadding |
EdgeInsetsGeometry |
horizontal: 15 |
Padding around the column. |
sectionSpacing |
double |
20 |
Gap between blocks. |
featureSpacing |
double |
10 |
Gap between feature cards. |
scrollable |
bool |
true |
Keep true β prevents overflow on short screens. |
alignment |
CrossAxisAlignment |
center |
Horizontal alignment. |
fontFamily |
String? |
null |
Your font; null uses the bundled one. |
textDirection |
TextDirection? |
null |
null follows the host app; set rtl to force it. |
viewType |
UpdateViewType |
screen |
The form it takes: screen (full page), dialog (centred card), sheet (bottom sheet). The blocks, the order and the show* flags are identical in all three β dialog and sheet lift the artwork into a header above the card and cap their height at 85% of the screen, scrolling inside it. |
entrance |
UpdateEntrance |
per design | How the full screen arrives: .warpIn(), .rocketPull(), .liftoff(), .descend(), .slideUp(), .fade(), .none(). Ignored when viewType is dialog or sheet. |
dialogEntrance |
DialogEntrance |
per design | How the dialog and sheet arrive: .popIn() β zooms up to full size, .slideUp(), .fade(), .none(). Ignored when viewType is screen. A separate type from entrance because a card cannot move its backdrop and content apart the way a full screen can β so a screen-only entrance can't be passed here by mistake. |
pulse |
UpdatePulse? |
per design | Button glow. copyWith(noPulse: true) switches it off. |
Theme building blocks #
| Class | Key fields |
|---|---|
UpdateTitle |
firstLine, secondLine, highlight, sparkle, color, highlightColor, sparkleColor, shadowColor, fontSize, fontWeight, textAlign, textDirection, firstLineHeight β the gap between the two lines, as a multiple of fontSize (default 1.0). Lower it to pull them together, but note it also tightens a line that wraps, so a long headline can overlap itself. |
UpdateBadgeStyle |
text ('NEW UPDATE AVAILABLE'), prefix ('Λβ¦ '), textColor, backgroundColor, borderColor, borderWidth (2), radius (50), padding, margin. |
UpdateButtonStyle |
text ('UPDATE NOW'), icon (rocket_launch_rounded), iconWidget, iconColor, gradient, backgroundColor, textColor, fontSize, fontWeight, borderColor, radius, padding β plus sweepDuration, sweepColor and progressText β showing the work on the button. |
LaterButtonStyle |
text, color, fontSize, fontWeight, underline styling. |
UpdateFeature |
icon or iconWidget, title, subtitle, iconColor, iconSize, iconGradient, card border and background. |
UpdateTextStyle |
text, color, fontSize, fontWeight, textAlign. |
UpdateVersionStyle |
text, colours, border, radius, padding. |
UpdateBackground |
.solid(color), .gradient(colors), AssetBackground(path, package:, color:), .none(). |
UpdateVisual |
.lottie(path, package:), .asset(path, package:), .network(url), .icon(icon), .custom(builder) β plus heightFactor / height. |
UpdateEntrance |
.fade(), .slideUp(), .rocketPull(), .descend(), .warpIn(), .liftoff(), .none() β each takes a duration. Full screen only. |
DialogEntrance |
.popIn() (a zoom up to full size β takes fromScale, default 0.85, and overshoot, default true), .slideUp(), .fade(), .none(). Dialog and sheet only. |
UpdateViewType |
screen, dialog, sheet β the enum used by viewType. |
UpdatePulse |
The breathing glow behind the primary button; null on the theme switches it off. |
UpdateSection |
The enum used by order: visual, badge, title, version, description, features, updateButton, laterButton. |
One rule for every style above. Whatever you set is yours; everything you leave out keeps the design's value. So changing a label never costs you its colours, gradient or border β and to change one of those, you just name it:
updateButton: const UpdateButtonStyle(text: 'Get it now'), // Cosmic's gradient kept updateButton: const UpdateButtonStyle(backgroundColor: Colors.green), // flat green insteadA
featureslist works the same way, pairing your cards with the design's by position; any extra card you add is kept as-is.
Picking and building a theme #
AppUpgradeTheme.cosmic() // astronaut over a blue starfield
AppUpgradeTheme.rocketUp() // rocket over a pink starfield
AppUpgradeTheme.superHero() // cartoon hero over a red starfield
final myTheme = AppUpgradeTheme.cosmic(
title: const UpdateTitle(
firstLine: 'Ready for', secondLine: 'something', highlight: 'better!'),
showFeatures: true,
updateButton: const UpdateButtonStyle(text: 'Get it now'), // keeps the design's gradient + icon
lang: ThemeLang.ar, // default texts in Arabic + RTL
fontFamily: 'Cairo', // the family: from YOUR pubspec.yaml
);
await AppUpgrade.checkAndPrompt(context, theme: myTheme);
// inline, or a variant of one you already have:
await AppUpgrade.checkAndPrompt(context, theme: AppUpgradeTheme.rocketUp());
await AppUpgrade.checkAndPrompt(context, theme: myTheme.copyWith(showBadge: false));
Blocks: show, hide, reorder #
visual β badge β title β version β description β features β
updateButton β laterButton
showBadge, showFeatures and showVersion are off by default; the rest
are on. Turning one on brings in that design's own content
(pictured at the top):
AppUpgradeTheme.cosmic(
showBadge: true, // the design's own badge text
showFeatures: true, // the design's own three cards
showDescription: false, // drop the paragraph
order: const [ // reorder or drop blocks
UpdateSection.visual,
UpdateSection.features, // cards above the headline
UpdateSection.title,
UpdateSection.updateButton,
],
);
Title, background and artwork #
All three are theme fields, so you set them where you build the theme:
AppUpgradeTheme.cosmic(
// Headline: line one, line two, and the highlighted last word.
title: const UpdateTitle(
firstLine: 'Ready for', secondLine: 'something', highlight: 'better!'),
// Behind everything β pick exactly one:
background: const UpdateBackground.solid(Color(0xff01114f)),
// The artwork on top β pick exactly one:
visual: const UpdateVisual.lottie('assets/rocket.json', package: null),
);
Every option for each field:
// title β the same three-part shape in any language
const UpdateTitle(
firstLine: 'Ready for', secondLine: 'something', highlight: 'better!');
const UpdateTitle(
firstLine: 'ΩΩ Ψ£ΩΨͺ Ω
Ψ³ΨͺΨΉΨ―', secondLine: 'ΩΨ΄ΩΨ‘', highlight: 'Ψ£ΩΨΆΩ!');
// background
const UpdateBackground.solid(Color(0xff01114f));
const UpdateBackground.gradient(LinearGradient(colors: [...]));
const UpdateBackground.asset('assets/bg.png', package: null, color: Color(0xff01114f));
const UpdateBackground.network('https://β¦', color: Color(0xff01114f));
const UpdateBackground.none();
// visual
const UpdateVisual.lottie('assets/anim.json', package: null);
const UpdateVisual.asset('assets/hero.png', package: null);
const UpdateVisual.network('https://β¦');
const UpdateVisual.icon(Icons.system_update_rounded, circleGradient: [...]);
UpdateVisual.custom((context) => MyWidget());
package: nullβ the asset is in your app, not the package's.- On an image background,
colorfills the rest of the screen: match the image's own edge so there's no visible seam. textDirection: TextDirection.rtlforces RTL in an app with no RTL locale.- No
fontFamilyβ the bundled font (IBM Plex Sans Arabic, Regular + Bold). - Pull the headline's two lines closer with
UpdateTitle(firstLineHeight: 0.7)β keep it near1.0for long text. - Borrow a palette from another design:
AppUpgradeTheme.cosmic(background: RocketUpDesign.backgroundStyle).
View type: screen, dialog or sheet #
One field decides the form. Everything else about the theme is unchanged:
AppUpgradeTheme.cosmic() // full screen (default)
AppUpgradeTheme.cosmic(viewType: UpdateViewType.dialog) // centred dialog
AppUpgradeTheme.cosmic(viewType: UpdateViewType.sheet) // bottom sheet
The same blocks, the same order, the same show* flags in all three β so
everything above applies unchanged:
AppUpgradeTheme.cosmic(
viewType: UpdateViewType.dialog,
showBadge: true, // works exactly as it does full-screen
showFeatures: true,
);
Two things the dialog and the sheet do differently:
- the artwork becomes a header. It's drawn on the background above the card,
wherever
orderplaces it β so the card below holds only text and buttons.showVisual: falsedrops the header entirely and leaves a text-only card. - they cap their own height at 85% of the screen and scroll inside it, so a long release note or a large system font can't push them off-screen.
Switching an existing theme over is one line, and each form keeps its own motion:
final theme = AppUpgradeTheme.cosmic();
theme; // a full screen
theme.copyWith(viewType: UpdateViewType.dialog); // the same, as a dialog
All three designs take viewType, and each brings its own dialogEntrance β
popIn() on Cosmic, slideUp() on RocketUp and SuperHero, matching the way
each one arrives as a full screen:
AppUpgradeTheme.rocketUp(viewType: UpdateViewType.sheet); // rises, as a sheet
AppUpgradeTheme.superHero(viewType: UpdateViewType.dialog); // rises, as a card
Motion #
Entrances β full screen #
How the full screen arrives, picked per design like any other value. For the dialog and the sheet see Entrances β dialog and sheet.
AppUpgradeTheme.cosmic(entrance: const UpdateEntrance.rocketPull());
| Entrance | Motion |
|---|---|
rocketPull() |
panel rises while the artwork leads it, so the artwork appears to pull the page |
warpIn() |
eases down from a slight over-scale, like settling after a jump |
liftoff() |
the backdrop sinks while content holds still β the camera seems to climb |
descend() |
arrives from above |
slideUp() |
plain slide from the bottom |
fade() |
cross-fade |
none() |
no entrance, shown instantly |
Defaults: Cosmic warpIn, RocketUp rocketPull, SuperHero slideUp. All are
tunable (duration, parallax, stagger, β¦).
Every entrance runs for 900ms by default and takes a duration of its own:
UpdateEntrance.warpIn(duration: Duration(milliseconds: 500)).
Entrances β dialog and sheet #
A card cannot move its backdrop and its content apart the way a full screen can,
so the dialog and the sheet have their own, separate set β passed as
dialogEntrance:
AppUpgradeTheme.cosmic(
viewType: UpdateViewType.dialog,
dialogEntrance: const DialogEntrance.slideUp(),
);
| Entrance | Motion |
|---|---|
popIn() |
zooms up from smaller to full size while fading in β Cosmic's default |
slideUp() |
rises from the bottom edge |
fade() |
cross-fade |
none() |
shown instantly |
Each design picks its own: popIn() on Cosmic, slideUp() on RocketUp and
SuperHero.
popIn() starts at fromScale: 0.85 and passes full size by a hair before
settling β pass overshoot: false for a clean stop, or another fromScale for a
bigger or smaller zoom.
Because the two are different types, a screen-only entrance cannot reach a dialog by mistake β the compiler rejects it. Each field is simply ignored in the form it does not apply to, so one theme can carry both:
AppUpgradeTheme.cosmic(
entrance: const UpdateEntrance.liftoff(), // used as a screen
dialogEntrance: const DialogEntrance.popIn(), // used as a dialog or sheet
);
Button glow #
A slow breathing glow keeps the eye on the action:
AppUpgradeTheme.cosmic(
pulse: const UpdatePulse(period: Duration(milliseconds: 1800)),
);
AppUpgradeTheme.cosmic().copyWith(noPulse: true); // off
Reduce motion #
Every entrance degrades to a short fade and the glow is suppressed when the platform's "reduce motion" accessibility setting is on. That setting exists for motion sensitivity, so it is honoured automatically and cannot be overridden.
Author #
Alaa Khaled Ahmed β @AlaaKhaledAhmed
Thanks to @Ahmed-Ibrahim-a-a for pointing out that an update doesn't always hand off to the store β the case behind Your own update action.
Issues and pull requests are welcome on GitHub.
License #
MIT β see LICENSE.







