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.

pub package License: MIT

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 returning none, optional or forced. It fails open: missing data, junk versions or any error give none.
  • UpdatePolicySource interface with JsonUrlSource (package:http, injectable client) and StaticSource.
  • UpdateGate widget:
    • 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.
  • 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.pop directly (not maybePop) 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.

Libraries

update_gate
Forced and optional app-update prompts, independent of where the version rules come from.