Bloquer les versions obsolètes – Flutter
Cette partie décrit l’implémentation côté application Flutter. Pour le contexte global du mécanisme (principe, diagramme, FAQ), voir la page principale : index.mdx et l’implémentation backend : symfony.mdx.
Objectif : lorsqu’une version mobile est inférieure à la version minimale (APP_MIN_MOBILE_VERSION), le backend répond HTTP 426 Upgrade Required. L’app intercepte ce statut et affiche un dialog bloquant qui redirige vers le store.
➡ Flutter : https://bitbucket.org/doingfr/doing-cookbooks-examples/src/main/enforce-app-version/flutter/ Les extraits ci-dessous sont condensés; reporte-toi au dépôt pour structure, tests et configuration.
Pré-requis
- Convention de version (ex:
major.minor.patch). - Accès au header custom
X-App-Version(proxy/CDN ok). - Décision sur comportement si header absent (ici: on laisse passer, affichage popup seulement si 426 reçu).
- Store URLs gérées par le back (Android / iOS)
Dépendances
dependencies:
dio: ^5.7.0 # ou version déjà présente (>=5.x)
package_info_plus: ^8.0.0
url_launcher: ^6.3.0
flutter_riverpod: ^2.5.0
dev_dependencies:
flutter_flavorizr: ^2.4.1
Conserve les versions existantes si ton projet est déjà plus à jour.
Vue d’ensemble technique
| Élément | Rôle |
|---|---|
AppVersion | Fournit la version locale (cache en mémoire) |
| Interceptor Dio | Ajoute le header + intercepte 426 |
| Provider Riverpod | Stocke l’état UpdateRequirement |
| Dialog | Affichage bloquant + liens store |
Étapes de mise en place
1. Ajouter les nouvelles traductions et générer les clés.
{
...
"update": {
"required": {
"title": "Mise à jour requise",
"body_generic": "Une nouvelle version de l'application est disponible. Veuillez la mettre à jour pour continuer.",
"body_min": "La version minimale requise est {0}. Veuillez mettre à jour pour continuer.",
"action": "Mettre à jour"
}
}
}
{
...
"update": {
"required": {
"title": "Update required",
"body_generic": "A new version of the application is available. Please update to continue.",
"body_min": "Minimum required version is {0}. Please update to continue.",
"action": "Update"
}
}
}
Exécuter la commande suivante pour générer les clés de traductions :
make update-locale
2. Créer l'arborescensce de la feature UpdateRequirement
lib/
└── features/
└── update_requirement/
├── data/
│ └── models/
│ └── update_requirement_model.dart
├── domain/
│ └── entities/
│ └── update_requirement.dart
└── presentation/
├── providers/
│ ├── update_requirement_controller.dart
│ └── update_requirement_state.dart
└── widgets/
└── update_dialog.dart
3. Création de l'entité
import 'package:equatable/equatable.dart';
class UpdateRequirement extends Equatable {
final String? minRequiredVersion;
final String? androidUrl;
final String? iosUrl;
final String? serverMessage;
const UpdateRequirement({
this.minRequiredVersion,
this.androidUrl,
this.iosUrl,
this.serverMessage,
});
List<Object?> get props => [
minRequiredVersion,
androidUrl,
iosUrl,
serverMessage,
];
}
4. Création du modèle
import 'package:enforce_app_version_flutter/features/update_requirement/domain/entities/update_requirement.dart';
class UpdateRequirementModel extends UpdateRequirement {
const UpdateRequirementModel({
super.minRequiredVersion,
super.androidUrl,
super.iosUrl,
super.serverMessage,
});
factory UpdateRequirementModel.fromJson(Map<String, dynamic> json) {
return UpdateRequirementModel(
minRequiredVersion: json['min_required_version'] as String?,
androidUrl:
(json['stores'] as Map<String, dynamic>?)?['android'] as String?,
iosUrl: (json['stores'] as Map<String, dynamic>?)?['ios'] as String?,
serverMessage: json['message'] as String?,
);
}
}
5. Création du state
import 'package:enforce_app_version_flutter/features/update_requirement/domain/entities/update_requirement.dart';
import 'package:equatable/equatable.dart';
class UpdateRequirementState extends Equatable {
final UpdateRequirement? updateRequirement;
const UpdateRequirementState({
this.updateRequirement,
});
List<Object?> get props => [
updateRequirement,
];
}
6. Création du controller
import 'package:enforce_app_version_flutter/features/update_requirement/domain/entities/update_requirement.dart';
import 'package:enforce_app_version_flutter/features/update_requirement/presentation/providers/update_requirement_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
class UpdateRequirementController
extends StateNotifier<UpdateRequirementState> {
UpdateRequirementController() : super(UpdateRequirementState());
static String? _cached;
void setUpdateRequirement(UpdateRequirement updateRequirement) {
state = UpdateRequirementState(updateRequirement: updateRequirement);
}
void clearUpdateRequirement() {
state = UpdateRequirementState();
}
Future<String?> get appVersion async {
if (_cached != null) return _cached!;
final info = await PackageInfo.fromPlatform();
_cached = info.version;
return _cached!;
}
}
final updateRequirementProvider =
StateNotifierProvider<UpdateRequirementController, UpdateRequirementState>(
(ref) {
return UpdateRequirementController();
},
);
7. Interceptor Dio
Injecte le header et capture les réponses 426.
...
class DioRequestInterceptor extends Interceptor {
final Dio _dio;
...
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
...
// Always attach current app version header
try {
final version =
await ref.read(updateRequirementProvider.notifier).appVersion;
options.headers['X-App-Version'] = version;
} catch (_) {
// Fallback: ignore if version can't be read.
}
...
}
...
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
...
// Intercept 426 Upgrade Required (forced update)
if (err.response?.statusCode == 426) {
UpdateRequirement updateRequirement = UpdateRequirement();
final data = err.response?.data;
try {
Map<String, dynamic>? map;
if (data is Map<String, dynamic>) {
map = data;
} else if (data is String && data.isNotEmpty) {
map = jsonDecode(data) as Map<String, dynamic>;
}
if (map != null) {
updateRequirement = UpdateRequirementModel.fromJson(map);
}
} catch (_) {}
// Update provider state so UI layer can show dialog.
ref
.read(updateRequirementProvider.notifier)
.setUpdateRequirement(updateRequirement);
return super.onError(err, handler);
}
...
}
...
}
8. Dialog de mise à jour
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:enforce_app_version_flutter/app.dart';
import 'package:enforce_app_version_flutter/core/translations/keys.g.dart';
import 'package:enforce_app_version_flutter/features/update_requirement/domain/entities/update_requirement.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
/// Shows a dialog informing the user about a required update.
Future<void> showUpdateDialog(
BuildContext context, {
required UpdateRequirement updateRequirement,
}) async {
final String? storeUrl =
Platform.isIOS ? updateRequirement.iosUrl : updateRequirement.androidUrl;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: Text(
LocaleKeys.update_required_title.tr(),
style: appTypography.h2,
),
content: Text(
updateRequirement.serverMessage ??
(updateRequirement.minRequiredVersion == null
? LocaleKeys.update_required_body_generic.tr()
: LocaleKeys.update_required_body_min
.tr(args: [updateRequirement.minRequiredVersion!])),
style: appTypography.body,
),
actions: [
TextButton(
onPressed: () async {
if (storeUrl != null) {
final uri = Uri.parse(storeUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
},
child: Text(
LocaleKeys.update_required_action.tr(),
style: appTypography.subtitle,
),
),
],
),
);
}
9. Intégration dans app
Observer le provider et déclencher la popup.
...
class _AppState extends ConsumerState<App> {
...
/// Flag to prevent multiple dialogs
bool _updateDialogShown = false;
...
void initState() {
...
}
...
Widget build(BuildContext context) {
...
return MaterialApp.router(
...
builder: (_, child) {
...
final UpdateRequirementState state =
ref.watch(updateRequirementProvider);
if (state.updateRequirement != null) {
/// Use the context of the main Navigator provided by AutoRoute
final navCtx = _appRouter.navigatorKey.currentContext;
if (navCtx != null && !_updateDialogShown) {
_updateDialogShown = true;
showUpdateDialog(
navCtx,
updateRequirement: state.updateRequirement!,
);
}
}
...
}
);
}
}
Tests rapides
| Cas | Version envoyée | Résultat attendu |
|---|---|---|
| À jour | 2.3.1 | 200 OK normal |
| Obsolète | 2.2.0 | 426 + popup |
| Sans header | (aucun) | 200 (pas de popup) |
Checklist Flutter
| Point | OK |
|---|---|
| Traductions présentes | ✅ |
| Popup bloquante (non dismissible) | ✅ |
| Tests manuels 200 / 426 faits | ✅ |
FAQ spécifique Flutter
Pourquoi un interceptor plutôt que passer la version à la main ?
Centralisation : aucune duplication dans chaque appel réseau.
Peut-on cacher le popup et laisser fonctionner quand même ?
Non dans ce scénario: objectif = forcer la mise à jour (sinon incohérences backend/client).
Faut-il stocker la version en persistant ?
Pas nécessaire: lecture asynchrone rapide via package_info_plus, cache mémoire suffisant.