force-update-init
Bootstraps force update in an existing Flutter project using the force_update_helper package (course shape: Andrea Bizzotto, Flutter in Production). Run phases in order; the skill first classifies the project as INIT (nothing wired yet) or AUDIT (partial/complete setup already present) and follows the matching branch below.
Force update must ship in the very first version of an app or it can never reach users already on an older one — there is no way to retroactively add the check to installs that predate it.
Before coding anything, load the bundled references in parallel:
skills/force-update-init/references/remote-sources.mdskills/force-update-init/references/store-urls-and-env.mdskills/force-update-init/references/release-playbook.md
Version baseline cited below (force_update_helper: 0.3.0) is the latest published on pub.dev at the time
this skill was written (2025-11-04) — verify current latest before pinning. The package itself has been
dormant since that release; state this plainly to the user rather than implying active maintenance. It
supports Android and iOS only — isAppUpdateRequired() and storeUrl() hard-return false/null on
every other platform, so force update simply does not apply to a web or desktop target.
If the target already has upgrader installed instead, do not run this
skill alongside it — upgrader shows an alert on every new store version with no remote control; state
the tradeoff (see references/remote-sources.md "upgrader vs force_update_helper") and let the user choose
whether to migrate. The AUDIT branch below detects this case.
Phase 0 — Intake & detection
Goal: know the project's actual state before asking a single question or touching a file.
0.1 Melos workspace detection
Grep root pubspec.yaml for workspace: (pub workspaces) or check for melos.yaml.
- Monorepo detected: parse
melos.yamlpackages:globs to find candidate app packages. If more than one plausible target exists, ask which package. Resolve every subsequent path (pubspec.yaml,lib/main.dart,android/app/src/main/AndroidManifest.xml,dart_defines.json) relative to that package, not the repo root. This mirrorssentry-init0.1 — detect and adapt, never block. - Single-app: target root is the project root.
0.2 INIT vs AUDIT classification
Grep the target for, recording found/missing:
| Signal | Where |
|---|---|
force_update_helper |
pubspec.yaml |
upgrader |
pubspec.yaml |
ForceUpdateWidget( |
lib/ |
ForceUpdateClient( |
lib/ |
<queries> intent block |
android/app/src/main/AndroidManifest.xml |
- No
force_update_helperand noupgradersignal → INIT branch (Phase 1 onward). - Any signal found → AUDIT branch — jump to AUDIT branch below.
Print the detection matrix either way before proceeding.
0.3 Distribution channel (INIT branch)
Ask, with AskUserQuestion if available:
How is this app distributed?
- App Store + Play Store (default) — both platforms go through their public stores.
- One store only — e.g. Android via Play Store, iOS side-loaded/enterprise, or vice versa.
- Non-store — Firebase App Distribution, TestFlight-only, enterprise/internal distribution, or a POC with no public store listing yet.
Record as DISTRIBUTION (both | android-only | ios-only | non-store). This gates Phase 6 — the
non-store branch only runs when DISTRIBUTION=non-store. android-only/ios-only still run the normal
store path for the covered platform; force_update_helper already no-ops the uncovered one.
0.4 Remote source — detect and propose
Grep pubspec.yaml and lib/ for signals, then propose (don't just ask blind):
| Signal | Proposed source | Rationale |
|---|---|---|
firebase_core in pubspec |
Firebase Remote Config | App already pays the Firebase SDK cost; centralized console control |
dio in pubspec + an existing API client pattern in lib/ |
Custom backend (Dart Shelf) | App already has backend infrastructure to extend |
| Neither | GitHub Gist | No backend, no Firebase SDK — fastest path to something working |
Print the proposal and rationale, ask for confirmation or an override. Record as REMOTE_SOURCE
(gist | remote_config | backend). See references/remote-sources.md for the full tradeoffs (rate
limits, throttling, deployment) to cite if the user asks why.
0.5 Flavor detection
Grep for lib/env/flavor.dart (or equivalent) and parse the actual enum Flavor { ... } arms found — do
not assume dev/stg/prod exist; read what's really there. A project may have no flavors, one, or a
custom set.
No
Flavorenum found → single unflavored app. Skip the rest of this section;REMOTE_SOURCEresolves to one value, noswitch.Flavorenum found → list the actual arms, then ask which of them should get a liverequired_version(i.e. actually enforce updates) versus a harmless floor value (e.g.0.0.1, which can never trigger an update — needed so the generatedswitchcompiles for every arm without forcing updates on flavors that don't want them).Default proposal, stated explicitly rather than silently applied: only the arm that looks like production gets a live value; every other arm gets the floor. This matches the course's own position — "force update only really applies to the production environment" — for
REMOTE_SOURCE=remote_configspecifically, and generalizes cleanly to the other two sources.
Record FLAVOR_ARMS (full list) and LIVE_FLAVORS (subset getting a real value).
0.6 APP_STORE_ID delivery — ADR 0001 convention
Only relevant when DISTRIBUTION includes iOS (both or ios-only) — for android-only or non-store,
skip to 0.7.
Per docs/adr/0001-sentry-init-dsn-source-convention.md, this toolkit's convention for a compile-time
constant is dart_defines.json + --dart-define-from-file, read as const String.fromEnvironment(...).
force-update-init is a consumer of that convention, not an owner — sentry-init remains the skill
that scaffolds dart_defines.json when nothing exists yet.
Detect, in the same priority order sentry-init uses for SENTRY_DSN (SKILL.md 0.5):
dart_defines.json/dart_defines.json.examplealready present → propose keyAPP_STORE_IDin the same file.--dart-define=APP_STORE_ID=...(or a project-specific key) already embedded in.vscode/launch.jsonor aMakefile→ detect and adapt: propose addingAPP_STORE_IDalongside the existing defines in that same file, not a competing mechanism..env/.env.<flavor>+ anEnvclass already present (the course's own shape) → detect and adapt: proposeEnv.appStoreIdreadingString.fromEnvironment('APP_STORE_ID'), added to the existing class and files.- None of the above present → default to
dart_defines.json(same defaultsentry-inituses), scaffolding it only if this skill is the first to need it in this project. Ifsentry-inithas already scaffolded it (grep for the file), add the key to it rather than creating a second file.
Confirm the mechanism and record as APP_STORE_ID_DART_EXPR (mirrors sentry-init's DSN_DART_EXPR).
State plainly: APP_STORE_ID is not sensitive (the course notes this — it's fine to see in a diff), but
it is per-project and must never be copy-pasted from another app's config.
0.7 allowCancel
Ask: should the update prompt be dismissible ("Later" button) or fully blocking? Default false (hard
block) — state the tradeoff: true is friendlier but the course's production use case (security fixes,
retired endpoints) usually wants false. Record as ALLOW_CANCEL.
0.8 Router shape
Grep for MaterialApp.router( vs MaterialApp( with onGenerateRoute/routes, and for GoRouter(.
Record ROUTER_SHAPE (material_app | gorouter) — determines which navigatorKey wiring Phase 4 uses.
0.9 Existing plumbing
Grep for:
class ErrorLogger/errorLoggerProvider/class LoggerService(from a priorsentry-initrun) → recordHAS_ERROR_LOGGERand the matching provider name.- A dialog helper matching the shape
showAlertDialog({required BuildContext context, required String title, ...})→ recordDIALOG_HELPER(found path, ornone).
Phase 0 summary
Print before proceeding:
✓ Target: <package>
✓ Branch: INIT
✓ Distribution: <both|android-only|ios-only|non-store>
✓ Remote source: <gist|remote_config|backend> (<detected reason>)
✓ Flavors: <list, or "none"> — live: <subset>
✓ APP_STORE_ID via: <dart_defines.json|.env+Env>
✓ allowCancel: <true|false>
✓ Router: <MaterialApp.builder|GoRouter>
✓ Existing error sink: <found <name>|not found — will scaffold minimal>
✓ Dialog helper: <found <path>|not found — will scaffold>
Ask the user to confirm before proceeding.
Phase 1 — Safety gate
Run git status --porcelain in the target project. If not empty, stop:
✗ Working tree is not clean.
force-update-init patches AndroidManifest.xml, main.dart (or the router file), and pubspec.yaml.
Without a clean commit, a bad run cannot be safely rolled back with:
git reset --hard HEAD && git clean -fd
Commit or stash your changes, then re-run this skill.
If clean, print the rollback command anyway, then proceed.
Phase 2 — Dependencies
2.1 package_info_plus conflict pre-check (blocking)
Published force_update_helper: 0.3.0 constrains package_info_plus: ^9.0.0. Grep the target's
pubspec.yaml/pubspec.lock for an existing package_info_plus constraint.
None, or compatible with
^9.0.0→ proceed normally.Already on
package_info_plus: ^10.xor higher → stop and present the conflict, do not silently pick a resolution:✗ package_info_plus conflict: this project already depends on ^10.x, but force_update_helper 0.3.0 constrains package_info_plus: ^9.0.0. Options: 1. Pin this project's package_info_plus back to ^9.0.0 (may affect other code using 10.x-only APIs) 2. Add a dependency_override for package_info_plus 3. Depend on force_update_helper via git (main branch has moved to ^10.1.0, unreleased)Ask which option, apply only that one.
2.2 Install
flutter pub add force_update_helper
flutter pub add url_launcher
flutter pub add package_info_plus # only if not already present
For monorepo: prefix with melos exec --scope=<pkg> -- .
Conditional, per REMOTE_SOURCE:
REMOTE_SOURCE |
Additional command |
|---|---|
gist |
flutter pub add dio (if not already present) |
remote_config |
flutter pub add firebase_remote_config |
backend |
flutter pub add dio (if not already present) |
Phase 3 — Platform config
3.1 Android <queries> intent
Required for url_launcher to open the store URL. Check android/app/src/main/AndroidManifest.xml for an
existing <queries> block; if present with a matching VIEW/BROWSABLE/https intent, skip. Otherwise
add, inside <manifest>, as a sibling of <application>:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
...
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"/>
</intent>
</queries>
</manifest>
If a <queries> block already exists for other reasons (e.g. package visibility queries), append the
<intent> inside it rather than adding a second <queries> block — Android XML does not merge multiple
top-level <queries> elements.
3.2 APP_STORE_ID
Per the mechanism resolved in Phase 0.6:
dart_defines.json→ add/update theAPP_STORE_IDkey. If flavored, one shared key is fine — the App Store ID does not vary by flavor (a per-flavor bundle ID gets a per-flavor App Store listing, but that is a Phase 0.6-adjacent concern the developer resolves once the flavor apps actually exist in App Store Connect — seereferences/store-urls-and-env.md).launch.json/Makefile→ add--dart-define=APP_STORE_ID=...alongside the project's other defines in that same file..env/Env→ addstatic String get appStoreId => const String.fromEnvironment('APP_STORE_ID');to the existingEnvclass; add the key to each.env.<flavor>file present.
See references/store-urls-and-env.md for where to find the real numeric ID in App Store Connect, and the
exact URL format it feeds.
Only run this section when DISTRIBUTION includes iOS.
Phase 4 — Wire ForceUpdateWidget
4.1 MaterialApp.builder variant
final _rootNavigatorKey = GlobalKey<NavigatorState>();
class MainApp extends ConsumerWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
...
navigatorKey: _rootNavigatorKey,
builder: (context, child) {
return ForceUpdateWidget(
navigatorKey: _rootNavigatorKey,
forceUpdateClient: FORCE_UPDATE_CLIENT_EXPR, // see Phase 5/6
allowCancel: ALLOW_CANCEL,
showForceUpdateAlert: (context, allowCancel) => showAlertDialog(
context: context,
title: 'App Update Required',
content: 'Please update to continue using the app.',
cancelActionText: allowCancel ? 'Later' : null,
defaultActionText: 'Update Now',
),
showStoreListing: (storeUrl) async {
if (await canLaunchUrl(storeUrl)) {
await launchUrl(storeUrl, mode: LaunchMode.externalApplication);
} else {
ERROR_SINK_CALL; // see below
}
},
onException: (e, st) => ERROR_SINK_CALL,
child: child!,
);
},
...
);
}
}
FORCE_UPDATE_CLIENT_EXPR is resolved in Phase 5 (store branch) or Phase 6 (non-store branch) — exactly
one of those phases runs, and it fills this placeholder.
4.2 GoRouter variant
Same widget, different navigatorKey source:
final _goRouter = GoRouter(navigatorKey: _rootNavigatorKey, routes: [ ... ]);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _goRouter,
builder: (context, child) {
return ForceUpdateWidget(
navigatorKey: _goRouter.routerDelegate.navigatorKey,
forceUpdateClient: FORCE_UPDATE_CLIENT_EXPR,
allowCancel: ALLOW_CANCEL,
showForceUpdateAlert: (context, allowCancel) => showAlertDialog(...),
showStoreListing: (storeUrl) async { ... },
onException: (e, st) => ERROR_SINK_CALL,
child: child!,
);
},
);
}
If sentry-init has already wired a _rootNavigatorKey for SentryNavigatorObserver/UpgradeAlert,
reuse the existing key — never declare a second one.
4.3 Error sink wiring
HAS_ERROR_LOGGER=true→ERROR_SINK_CALLbecomesref.read(<detected provider>).logException(e, st)(or.e('Force update error', e, st)for theLoggerServicedecorator shape — match whicheversentry-initleft behind, same two-branch distinction that skill uses).HAS_ERROR_LOGGER=false→ scaffold a minimal one atlib/src/core/monitoring/error_logger.dart(only if no equivalent exists at all):import 'dart:developer'; void logForceUpdateError(Object error, StackTrace? stackTrace) { log(error.toString(), name: 'Force Update', error: error, stackTrace: stackTrace); }Never let
onExceptionsilently swallow into nothing — a force-update bug that goes unlogged is the one place this feature is guaranteed to fail without anyone noticing.
4.4 Dialog helper
DIALOG_HELPERfound → use it as-is, matching the existing signature.- Not found → scaffold
showAlertDialogatlib/src/common_widgets/show_alert_dialog.dart, adapted from the flutter_ship_app reference implementation — a plainAlertDialog/CupertinoAlertDialogwrapper takingcontext,title,content,cancelActionText(nullable),defaultActionText.
Phase 5 — Remote source (store branch)
Runs when DISTRIBUTION is both, android-only, or ios-only. Exactly one of the three sub-sections
runs, per REMOTE_SOURCE. Full detail for each in references/remote-sources.md — read it before writing
code for the chosen branch.
Gist (REMOTE_SOURCE=gist)
Scaffold a RemoteConfigGistClient (Dio-based, @Riverpod(keepAlive: true)) that fetches
https://gist.githubusercontent.com/<owner>/<gist-id>/raw/<filename> and parses
{"config": {"required_version": "X.Y.Z"}}. If LIVE_FLAVORS has more than one entry, one gist ID per live
flavor via a switch; non-live flavor arms map to a floor-value literal, never a switch gap.
FORCE_UPDATE_CLIENT_EXPR becomes:
ForceUpdateClient(
fetchRequiredVersion: () async {
final client = ref.read(remoteConfigGistClientProvider);
final remoteConfig = await client.fetchRemoteConfig();
return remoteConfig.requiredVersion;
},
iosAppStoreId: APP_STORE_ID_DART_EXPR,
)
Print the manual checklist item: create one secret gist per live flavor at gist.new, note the gist ID(s)
back into the scaffolded switch.
Firebase Remote Config (REMOTE_SOURCE=remote_config)
Scaffold the production-grade provider (not the naive fetchAndActivate()-only docs snippet) —
default values via setDefaults, prod throttle interval (12 fetches/day cap → use a 12-minute
minimumFetchInterval for prod, 1-minute for other live flavors), immediate activate() then unawaited
fetch() for next-startup values, a real-time onConfigUpdated listener, ref.onDispose(sub.cancel).
FORCE_UPDATE_CLIENT_EXPR becomes:
ForceUpdateClient(
fetchRequiredVersion: () async {
final remoteConfig = await ref.read(firebaseRemoteConfigProvider.future);
return remoteConfig.getString('required_version');
},
iosAppStoreId: APP_STORE_ID_DART_EXPR,
)
Print the manual checklist item: in the Firebase console for the production project, Remote Config → create
parameter required_version, default value = current pubspec.yaml version, Publish.
Custom backend / Dart Shelf (REMOTE_SOURCE=backend)
Scaffold server/lib/server.dart — a minimal Shelf app with a required_version endpoint (plain-text
body, not JSON) reading Platform.environment['REQUIRED_VERSION'] with a fallback literal, plus
server/pubspec.yaml (shelf). Full source inline in references/remote-sources.md — do not tell the
user to run dart pub unpack force_update_helper to get this; the published package archive ships only
lib/, the example server folder is repo-only and that command will not produce it.
FORCE_UPDATE_CLIENT_EXPR becomes:
ForceUpdateClient(
fetchRequiredVersion: () async {
final response = await ref.read(dioProvider).get('$BASE_URL_EXPR/required_version');
return response.data as String;
},
iosAppStoreId: APP_STORE_ID_DART_EXPR,
)
BASE_URL_EXPR is per live flavor if LIVE_FLAVORS has more than one entry (one deployed environment per
flavor); otherwise a single constant. Print the manual checklist item: deploy the server (Docker/VM/edge —
see references/release-playbook.md) and fill in the real base URL(s), replacing any localhost default.
Phase 6 — Non-store branch
Runs only when DISTRIBUTION=non-store. Verified against the live package source: ForceUpdateClient has
no custom-URL parameter — iosAppStoreId is the only URL input, interpolated into a hardcoded Apple
template, and storeUrl() returns null for anything it doesn't recognize. But the class is a bare
class — not final, sealed, base, or interface — so it can be subclassed:
class NonStoreForceUpdateClient extends ForceUpdateClient {
const NonStoreForceUpdateClient({
required super.fetchRequiredVersion,
required this.distributionUrl,
}) : super(iosAppStoreId: '');
final String distributionUrl;
@override
Future<String?> storeUrl() async => distributionUrl;
}
FORCE_UPDATE_CLIENT_EXPR becomes NonStoreForceUpdateClient(fetchRequiredVersion: ..., distributionUrl: DISTRIBUTION_URL_EXPR) where DISTRIBUTION_URL_EXPR is whatever the user provides (Firebase App
Distribution invite link, TestFlight public link, internal MDM/enterprise install page). Ask for it
explicitly in Phase 0 follow-up if not already known.
State two caveats to the user:
- This path is unadvertised by the package (no test coverage upstream for it) — it works because the class isn't sealed, not because it's a documented extension point.
- If
DISTRIBUTIONwas set tonon-storebutAPP_STORE_ID_DART_EXPRstill resolves to a non-empty value somewhere (e.g. left over from a prior INIT run), the override above makes it irrelevant forstoreUrl()— but flag it anyway, since a stray non-emptyAPP_STORE_IDcombined with the default (non-overridden) client would otherwise deep-link a TestFlight/enterprise build to a public App Store listing that may not exist. This is exactly why Phase 6 is a full subclass, not a conditional inside the default one.
Phase 7 — Verification
flutter analyze
Smoke test: temporarily set the remote required_version (whichever source was chosen) above the current
pubspec.yaml version, run the app, confirm the alert appears. Then restore the real value — do not leave
a live remote source pointed at an inflated version.
Print the manual real-device checklist:
- [ ] iOS Simulator / Android Emulator: alert should appear if required_version > pubspec.yaml version.
Tapping "Update Now" WILL fail to open a real store page — this is expected on a simulator and
on an unpublished app; it is not a bug in this setup.
- [ ] Real device, once the app is published: confirm "Update Now" opens the correct store listing.
- [ ] Returning to the app without updating should re-show the alert (allowCancel=false), or allow
dismissal and re-show on next launch (allowCancel=true).
Phase 8 — Summary
Print, grouped:
Files created
- List each new file with path.
Files modified
- List each modified file with a one-line description.
Packages added
- List each package and the command used.
CI
APP_STORE_ID must reach release builds the same way it reaches local runs (dart_defines.json /
.env+Env) — a build missing it will never show the iOS alert, silently. If REMOTE_SOURCE=backend,
each deployed environment needs its own REQUIRED_VERSION set (see references/release-playbook.md).
This skill does not scaffold CI workflow files — state what CI must supply, nothing more.
Checklist for the developer
- Create the gist(s) / Remote Config parameter / deploy the backend, per Phase 5's manual item
- Set the real
APP_STORE_IDonce the app exists in App Store Connect (find it under General → App Information) - Smoke-test per Phase 7, then restore the real
required_version - Real-device test once published — see Phase 7 checklist
- Read
references/release-playbook.mdbefore the first release that relies on this
References
skills/force-update-init/references/remote-sources.md— Gist/Remote Config/Shelf tradeoffs and full source for eachskills/force-update-init/references/store-urls-and-env.md— App Store ID lookup, URL formats, env wiring detailskills/force-update-init/references/release-playbook.md— how to actually trigger a force update in production, and the rolling-release-window limitation
AUDIT branch (partial / complete projects)
Entered from Phase 0.2 when any force-update signal already exists.
Scan the project against this table:
Check How Why it matters iosAppStoreIdnon-empty (or reads a non-empty env key)Grep ForceUpdateClient(call sitestoreUrl()is awaited beforeisAppUpdateRequired(); a blank ID returnsnulland the widget returns early — the version check never runs, no alert, noonException. Completely silent. Leads the table for this reason.Android <queries>intent presentAndroidManifest.xmlMissing it makes url_launcherfail silently when opening the store URLnavigatorKeypassed toForceUpdateWidgetand matches theMaterialApp/GoRouterkeymain.dart / router file Without it, the alert has no navigator to present into Widget is inside MaterialApp.builder, nothomemain.dart home-only placement means the widget isn't an ancestor of every routefetchRequiredVersionis not still a hardcoded literal (e.g.() => Future.value('2.0.0'))Call site Leftover TODO from initial scaffold — never actually wired to a remote source Every Flavorarm has a defined value (live or floor)Remote source switchAn unhandled arm won't compile if new flavors were added later without updating the switch allowCancelmatches stated intentCall site vs. any prior decision on record Silent UX drift if changed without discussion package_info_plusconstraint conflictpubspec.yaml/pubspec.lockSame conflict as Phase 2.1, but on a project that already installed force_update_helper — check it didn't get resolved by pinning the wrong direction upgraderalso presentpubspec.yamlTwo competing mechanisms — flag for migration, don't auto-remove either Emit a findings table:
## Audit Results — Force Update | Check | Pass/Fail | Note | |---|---|---| | APP_STORE_ID set | Fail | iosAppStoreId: '' at lib/main.dart:114 — alert silently never fires on iOS | | Android <queries> intent | Pass | | | navigatorKey wired | Pass | | | Widget in MaterialApp.builder | Pass | | | fetchRequiredVersion not a literal | Fail | Still returns Future.value('2.0.0') at lib/main.dart:109 | | Flavor arms all handled | Pass | | | allowCancel matches intent | Pass | | | package_info_plus conflict | Pass | | | upgrader also present | Pass | | **Summary**: 2 failing checksIf nothing fails, say so explicitly:
No issues found. Force update setup matches the checklist.Ask which findings to fix (
all, comma-separated, ornone). For each selected:- Route through the matching phase above (e.g. a blank
APP_STORE_IDis a Phase 0.6/3.2 fix, a missing<queries>intent is a Phase 3.1 fix) rather than re-deriving the logic here. - Apply only the confirmed fixes, show the diff for each.
- Route through the matching phase above (e.g. a blank
If
upgraderis also present, describe the migration (removeUpgradeAlert, keepForceUpdateWidget) rather than doing it automatically — it's a product decision, not a mechanical fix.Re-scan touched files only, confirm which findings resolved.
Never rewrite a working, unflagged part of the project just because the AUDIT branch touched a neighboring file.
Notes
- Paths inside this skill are relative to the target Flutter project root, not this toolkit repo.
upgraderis documented, not implemented, by this skill — seereferences/remote-sources.mdfor the one-paragraph comparison to cite if asked whyforce_update_helperis the default.- Rolling-release windows (a range of allowed versions, not a single floor) are not supported by
force_update_helper— seereferences/release-playbook.md. Forking the package is the only path; this skill does not attempt it. - Does not overlap with
sentry-init(DSN/error-sink scaffolding) orflutter-flavors(flavor scaffolding) — this skill detects and adapts to both if present, and scaffolds a minimal fallback for either only when genuinely absent.