update_gate 0.1.0
update_gate: ^0.1.0 copied to clipboard
Forced and optional app-update prompts for Flutter, independent of where the version rules come from. Fails open, localised in en, fr and ar, RTL ready.
update_gate #
Forced and optional app-update prompts for Flutter that work with any source of version rules: your own JSON endpoint, Remote Config or a database. It fails open, so a broken rule never locks users out.
Features #
AppVersion: semver-like parsing (1.2,5,v1.2.3,1.2.3+45,1.0.0-beta.2+7) and ordering that includes numeric build numbers.UpdatePolicy: minimum required version, latest version, store URL, "remind me later" interval, title and message per locale, and per-platform overrides. Reads a simple JSON format.UpdateDecision.evaluate: a pure function returningnone,optionalorforced. It fails open: missing data, junk versions or any error givenone.UpdatePolicySourceinterface withJsonUrlSource(package:http, injectable client) andStaticSource.UpdateGatewidget:- forced: a full-screen page that back navigation cannot pop
(
PopScope); - optional: a dismissible dialog, shown again only after the remind
interval, with a pluggable
RemindLaterStore; - opens the store through your callback, so there is no url_launcher dependency;
- reads the running version through your callback, so there is no package_info_plus dependency.
- forced: a full-screen page that back navigation cannot pop
(
- Default texts in English, French and Arabic; RTL layouts follow the
ambient
Directionality. - Custom builders for the forced page and the dialog.
Install #
dependencies:
update_gate: ^0.1.0
Usage #
Wiring package_info_plus and url_launcher #
Both are your app's dependencies, not this package's:
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:update_gate/update_gate.dart';
import 'package:url_launcher/url_launcher.dart';
MaterialApp(
home: UpdateGate(
source: JsonUrlSource(Uri.parse('https://example.com/app/update.json')),
currentVersion: () async {
final info = await PackageInfo.fromPlatform();
return '${info.version}+${info.buildNumber}';
},
onOpenStore: (decision) async {
final url = decision.storeUrl;
if (url != null) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
},
child: const HomePage(),
),
);
The gate needs a Navigator. As home it finds it by itself. If you put it
in MaterialApp.builder (above the navigator), pass the same
navigatorKey to both.
The JSON format #
{
"min_required_version": "1.2.0",
"latest_version": "1.4.0+52",
"store_url": "https://play.google.com/store/apps/details?id=my.app",
"remind_after_hours": 24,
"title": {"en": "New version", "fr": "Nouvelle version"},
"message": {
"en": "Faster sync and bug fixes.",
"fr": "Synchronisation plus rapide et corrections.",
"ar": "مزامنة أسرع وإصلاحات."
},
"platforms": {
"ios": {
"min_required_version": "1.3.0",
"store_url": "https://apps.apple.com/app/id000000000"
}
}
}
Every field is optional. title and message may be plain strings. Locale
keys are matched exactly (fr_CA or fr-CA), then by language, then
default, then en; without a match the built-in text is used. Platform
keys: android, ios, macos, windows, linux, fuchsia, web.
Deciding without the widget #
final decision = UpdateDecision.evaluate(
currentVersion: '1.2.0+12',
policy: const UpdatePolicy(minRequiredVersion: '1.3.0'),
platform: UpdatePlatform.android,
);
if (decision.isForced) {
// Block the app your own way.
}
The rules:
| Situation | Result |
|---|---|
| current < min | forced |
| min <= current < latest | optional |
| current >= latest, or only min set and current >= min | none |
| no policy, junk or empty current version | none |
| a configured min or latest that is junk | none (for both rules) |
| neither min nor latest set | none |
Remembering "Later" #
The default store forgets on restart. Persist it with any key-value store. Sketch, not compiled in this package (shared_preferences):
class PrefsRemindLaterStore implements RemindLaterStore {
static const _key = 'update_gate.remind_later';
@override
Future<RemindLaterRecord?> read() async {
final raw = (await SharedPreferences.getInstance()).getString(_key);
if (raw == null) return null;
return RemindLaterRecord.fromJson(jsonDecode(raw) as Map<String, Object?>);
}
@override
Future<void> write(RemindLaterRecord record) async {
await (await SharedPreferences.getInstance())
.setString(_key, jsonEncode(record.toJson()));
}
}
Adapter: Firebase Remote Config #
Sketch, not compiled in this package. It needs firebase_remote_config
in your app.
class RemoteConfigSource implements UpdatePolicySource {
@override
Future<UpdatePolicy?> fetch() async {
final rc = FirebaseRemoteConfig.instance;
await rc.setConfigSettings(RemoteConfigSettings(
fetchTimeout: const Duration(seconds: 8),
minimumFetchInterval: const Duration(hours: 12),
));
await rc.fetchAndActivate();
// Keep the whole policy in one JSON parameter...
final json = rc.getString('update_policy');
if (json.isEmpty) return null;
return UpdatePolicy.fromJson(jsonDecode(json) as Map<String, Object?>);
// ...or map individual parameters:
// return UpdatePolicy(
// minRequiredVersion: rc.getString('min_required_version'),
// latestVersion: rc.getString('latest_version'),
// storeUrl: rc.getString('store_url_android'),
// );
}
}
Adapter: Supabase #
Sketch, not compiled in this package. It needs supabase_flutter and a
table whose columns use the JSON field names above.
class SupabaseSource implements UpdatePolicySource {
SupabaseSource(this.appId);
final String appId;
@override
Future<UpdatePolicy?> fetch() async {
final row = await Supabase.instance.client
.from('app_update_policy')
.select()
.eq('app_id', appId)
.maybeSingle();
return row == null ? null : UpdatePolicy.fromJson(row);
}
}
Texts and custom UI #
UpdateGate(
// ...
strings: UpdateGateStrings.fr.copyWith(later: 'Pas maintenant'),
forcedBuilder: (context, prompt) => MyForcedPage(
title: prompt.title,
message: prompt.message,
onUpdate: prompt.onUpdate,
),
optionalBuilder: (context, prompt) => AlertDialog(
title: Text(prompt.title),
actions: [
TextButton(onPressed: prompt.onLater, child: Text(prompt.strings.later)),
TextButton(onPressed: prompt.onUpdate, child: Text(prompt.strings.update)),
],
),
child: const HomePage(),
);
The forced route is wrapped in PopScope(canPop: false) whatever the
builder returns.
Limitations #
- The gate checks once, when it is first built. It does not re-check on app resume.
- A forced page is a pushed route: code that later calls
Navigator.popdirectly (notmaybePop) can still remove it. - Only one "remind me later" interval per policy (default 24 hours); a newer latest version always resets it.
- Build metadata that is not a number (
+exp.sha.5114f85) is kept but ignored when comparing. - Arabic and French default texts are hand-written; review them with a native speaker for your audience.
License #
MIT, see LICENSE.
Made by Abdeldjalil Chougui.