Aller au contenu principal

Bloquer les versions obsolètes – Flutter

remarque

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.

Code source complet

➡ 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.

Flutter : 1h

Pré-requis

  1. Convention de version (ex: major.minor.patch).
  2. Accès au header custom X-App-Version (proxy/CDN ok).
  3. Décision sur comportement si header absent (ici: on laisse passer, affichage popup seulement si 426 reçu).
  4. Store URLs gérées par le back (Android / iOS)

Dépendances

pubspec.yaml (extrait)
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émentRôle
AppVersionFournit la version locale (cache en mémoire)
Interceptor DioAjoute le header + intercepte 426
Provider RiverpodStocke l’état UpdateRequirement
DialogAffichage bloquant + liens store

Étapes de mise en place

1. Ajouter les nouvelles traductions et générer les clés.

assets/translations/fr.json (extrait)
{
...
"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"
}
}
}
assets/translations/en.json (extrait)
{
...
"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é

lib/features/update_requirement/domain/entities/update_requirement.dart
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

lib/features/update_requirement/data/models/update_requirement_model.dart
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

lib/features/update_requirement/presentation/providers/update_requirement_state.dart
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

lib/features/update_requirement/presentation/providers/update_requirement_controller.dart
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.

lib/core/utils/dio_request_interceptor.dart (extrait)
...
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

lib/features/update_requirement/presentation/widgets/update_dialog.dart
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.

lib/app.dart (extrait)
...
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

CasVersion envoyéeRésultat attendu
À jour2.3.1200 OK normal
Obsolète2.2.0426 + popup
Sans header(aucun)200 (pas de popup)

Checklist Flutter

PointOK
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.