Introduction :
Le développement d'applications nécessite l'utilisation de différents outils et technologies permettant de concevoir, programmer, tester et déployer des logiciels.
Dans ce cours, nous allons découvrir deux technologies complémentaires : Dart et Flutter.
Dart est un langage de programmation développé par Google. Il permet d'écrire les instructions, les traitements et la logique nécessaires au fonctionnement d'un programme.
Dart a été conçu avec une attention particulière portée à la productivité des développeurs, à la sécurité du typage et aux performances.
Flutter est un framework open source également développé par Google. Il permet de concevoir des interfaces graphiques et de développer des applications à partir du langage Dart.
Flutter permet notamment de créer des applications destinées à plusieurs environnements tels qu'Android, iOS, le Web, Windows, macOS et Linux.
Le principe multiplateforme :
Traditionnellement, développer une application pour plusieurs plateformes pouvait nécessiter plusieurs technologies et plusieurs bases de code.
Par exemple, une application mobile pouvait être développée séparément pour Android et iOS.
Flutter propose une autre approche : une grande partie du code de l'application peut être partagée entre les différentes plateformes.
Cela présente plusieurs avantages :
-
réduction de la duplication du code ;
-
maintenance simplifiée ;
-
développement plus rapide ;
-
comportement plus homogène entre les plateformes ;
-
possibilité de partager une grande partie de la logique métier.
Cependant, une application Flutter ne signifie pas que toutes les plateformes sont identiques. Certaines fonctionnalités peuvent nécessiter du code spécifique à une plateforme.
Installation et environnement de développement :
Le Flutter SDK :
Le Flutter SDK contient les outils nécessaires au développement d'applications Flutter. Il inclut notamment le framework Flutter ainsi que l'environnement Dart nécessaire au développement.
Flutter propose également une procédure d'installation adaptée à chaque système d'exploitation (Windows, macOS, Linux et ChromeOS).
La documentation officielle recommande également de vérifier les prérequis nécessaires à la plateforme utilisée avant de commencer l'installation.
Visual Studio Code :
Pour écrire le code Dart et Flutter, il est possible d'utiliser Visual Studio Code qui est un éditeur de code permettant notamment de bénéficier de l'autocomplétion, de la coloration syntaxique, du débogage et de l'intégration avec Flutter.
Une fois Visual Studio Code installé, il est recommandé d'installer les extensions Dart et Flutter afin d'obtenir les fonctionnalités spécifiques au développement Flutter. La documentation officielle de Flutter propose une procédure d'installation de Flutter directement depuis Visual Studio Code.
Android Studio :
Si l'objectif est de développer et tester des applications Flutter pour Android, il est nécessaire de disposer des outils Android appropriés.
Android Studio, disponible pour Windows, macOS, Linux et ChromeOS, fournit notamment l'Android SDK et l'Android Emulator, qui permettent de tester une application sur un appareil Android virtuel.
Lors de sa configuration, Android Studio permet notamment d'installer les composants du SDK Android nécessaires au développement.
Git :
Git est également utilisé dans l'environnement de développement Flutter, notamment pour récupérer et gérer le SDK Flutter. La documentation officielle de Flutter le présente parmi les logiciels prérequis de certaines installations.
Vérifier l'installation :
Une fois les différents outils installés, il est nécessaire de vérifier que l'environnement est correctement configuré.
La commande suivante permet d'effectuer cette vérification :
flutter doctor
Pour obtenir davantage de détails sur les différents composants détectés, il est également possible d'utiliser :
flutter doctor -v
La documentation officielle de Flutter recommande la commande ci-dessus pour valider l'installation.
Si Flutter n'est pas reconnu par le terminal, il faut notamment vérifier que le répertoire bin du Flutter SDK a bien été ajouté à la variable d'environnement PATH. Cette configuration permet d'utiliser les commandes flutter et dart depuis le terminal.
Les fondamentaux de Dart :
Dans cette partie, nous allons commencer à programmer réellement avec le langage Dart.
Précédemment, nous avons découvert Dart et Flutter, leur rôle ainsi que l'environnement nécessaire à leur utilisation.
Nous allons maintenant apprendre les principaux éléments qui constituent un programme Dart : les variables, les types de données, les constantes, les opérateurs, les conditions, les boucles, les fonctions et les collections.
Avant d'aborder Flutter et la création d'interfaces graphiques, il est donc nécessaire de maîtriser les principaux mécanismes du langage Dart.
Pour la programmation en Dart, certaines notions peuvent rappeler TypeScript, notamment au niveau de la syntaxe et du typage. Voici les principaux types de données Dart :
| Type | Utilisation | Exemple |
|---|---|---|
| String | Texte | "Bonjour" |
| int | Nombre entier | 42 |
| double | Nombre décimal | 3.14 |
| num | Nombre entier ou décimal | 10.5 |
| bool | Valeur logique | true |
Dart permet également d'utiliser les mots-clés var, const et final.
Dart permet l'interpolation de chaînes, c'est-à-dire d'insérer directement le contenu d'une variable dans une chaîne de caractères.
String prenom = "Alice";
int age = 25;
print("Bonjour $prenom, tu as $age ans.");
Les principales collections Dart sont :
| Collection | Utilisation |
|---|---|
| List | Liste ordonnée de valeurs |
| Set | Ensemble de valeurs uniques |
| Map | Association clé / valeur |
En programmation orientée objet (POO) avec Dart, la différence entre membres publics et privés est simple : par défaut, les membres sont publics. Pour rendre un membre privé, il suffit de faire précéder son nom d'un underscore (_). En Dart, la notion de privé s'applique au niveau de la bibliothèque, et non simplement à la classe comme dans Java ou C#. Par exemple :
class Personne {
String nom; // public
int _age; // privé
}
Dans Dart, on peut notamment utiliser un constructeur non nommé et des constructeurs nommés.
Pour le constructeur simple, on va faire un exemple pour créer une personne directement avec son prénom et son âge :
class Personne {
String prenom;
int age;
Personne(this.prenom, this.age);
}
Un constructeur nommé permet notamment de proposer plusieurs manières de créer un objet. Par exemple :
class Personne {
String prenom;
int age;
Personne(this.prenom, this.age);
Personne.enfant(this.prenom) {
age = 10;
}
}
Voici la différence dans la fonction main() :
void main() {
// Constructeur simple
Personne personne = Personne("Alice", 25);
print(personne.prenom); // Alice
print(personne.age); // 25
// Constructeur nommé
Personne enfant = Personne.enfant("Lucas");
print(enfant.prenom); // Lucas
print(enfant.age); // 10
}
Comme la plupart des langages de programmation, l'encapsulation consiste notamment à contrôler la manière dont les données d'un objet peuvent être utilisées ou modifiées. Un getter permet notamment de fournir un accès contrôlé à une donnée. Un setter permet de contrôler la manière dont une valeur peut être modifiée. Par exemple :
class Compte {
double _solde = 0;
double get solde => _solde;
set solde(double valeur) {
if (valeur >= 0) {
_solde = valeur;
}
}
}
void main() {
Compte compte = Compte();
// Utilisation du setter
compte.solde = 100;
// Utilisation du getter
print(compte.solde);
// Le setter refuse une valeur négative
compte.solde = -50;
// Le solde reste à 100
print(compte.solde);
}
Dart utilise un système appelé Null Safety. Son objectif est notamment de réduire les erreurs provoquées par l'utilisation accidentelle d'une valeur inexistante, appelée null. Pour indiquer qu'une variable peut contenir null, on utilise le caractère ?.
String? nom;
En résumé, cette variable peut donc contenir une chaîne de caractères ou la valeur null.
L'opérateur ?? permet de fournir une valeur de remplacement lorsqu'une expression vaut null.
String? nom;
String affichage = nom ?? "Inconnu";
print(affichage);
L'opérateur ! permet d'indiquer au programmeur qu'il considère une valeur nullable comme ne pouvant pas être null à cet endroit.
String? nom = "Alice";
print(nom!.length);
Cet opérateur doit être utilisé avec prudence : si la valeur est réellement null au moment de l'exécution, une erreur peut se produire.
Une classe enfant peut modifier le comportement d'une méthode héritée. On utilise alors l'annotation @override.
class Animal {
void parler() {
print("L'animal fait un bruit.");
}
}
class Chien extends Animal {
@override
void parler() {
print("Le chien aboie.");
}
}
La méthode parler() existe dans les deux classes, mais son comportement est différent.
En Dart, toute classe peut servir d'interface. Le mot-clé implements permet à une classe d'indiquer qu'elle respecte le contrat défini par une autre classe.
class Animal {
void parler() {
print("Animal");
}
}
class Chien implements Animal {
@override
void parler() {
print("Wouf !");
}
}
Lorsqu'une classe utilise implements, elle doit fournir les membres nécessaires correspondant à l'interface utilisée.
Un mixin permet de réutiliser certaines fonctionnalités dans plusieurs classes sans nécessairement utiliser une relation d'héritage classique.
mixin PeutVoler {
void voler() {
print("Je vole.");
}
}
class Oiseau with PeutVoler {
}
La classe Oiseau peut maintenant utiliser la méthode fournie par le mixin.
void main() {
Oiseau oiseau = Oiseau();
oiseau.voler();
}
Les mixins sont particulièrement utiles lorsqu'une fonctionnalité doit être partagée entre plusieurs classes.
Une extension permet d'ajouter des méthodes ou des propriétés à un type existant sans modifier directement sa définition.
extension StringExtension on String {
String enMajuscules() {
return toUpperCase();
}
}
Nous pouvons alors utiliser cette nouvelle méthode sur une chaîne de caractères.
void main() {
String texte = "bonjour";
print(texte.enMajuscules());
}
Les extensions permettent ainsi d'améliorer la lisibilité du code et de regrouper certaines fonctionnalités utiles.
Une application peut rencontrer des situations inattendues. Par exemple, une opération peut échouer ou une donnée reçue peut être incorrecte. Dart permet de gérer ces situations avec try, catch, on et finally.
try {
int resultat = 10 ~/ 0;
print(resultat);
} catch (e) {
print("Une erreur est survenue.");
}
L'opérateur ~/ est utilisé pour une division entière.
Il est également possible de traiter un type d'exception particulier.
try {
int resultat = 10 ~/ 0;
print(resultat);
} on IntegerDivisionByZeroException {
print("Division par zéro.");
}
Dart fournit le type Future pour représenter le résultat d'une opération asynchrone. Son fonctionnement peut être comparé, dans certains cas, à celui des Promise en JavaScript.
Les mots-clés async et await permettent de travailler plus facilement avec les opérations asynchrones.
Future<String> recupererNom() async {
return Future.delayed(
Duration(seconds: 2),
() => "Alice"
);
}
Future<void> main() async {
String nom = await recupererNom();
print(nom);
}
La fonction recupererNom() renvoie une Future<String>. Elle signifie qu'un texte sera disponible ultérieurement.
Cette syntaxe sera particulièrement importante lorsque nous commencerons à connecter une application Flutter à des services externes.
Dart fournit notamment la bibliothèque dart:convert pour manipuler du JSON.
import 'dart:convert';
void main() {
String texte = '''
{
"prenom": "Alice",
"age": 25
}
''';
Map<String, dynamic> donnees = jsonDecode(texte);
print(donnees["prenom"]);
print(donnees["age"]);
}
Il est également possible de transformer une structure Dart en texte JSON.
import 'dart:convert';
void main() {
Map<String, dynamic> personne = {
"prenom": "Alice",
"age": 25
};
String texte = jsonEncode(personne);
print(texte);
}
Dans une application réelle, les données reçues depuis un serveur doivent généralement être transformées en objets Dart.
Nous pouvons par exemple créer une classe représentant un utilisateur.
class Utilisateur {
String nom;
String email;
Utilisateur({
required this.nom,
required this.email
});
}
Cette manière de représenter les données sera très importante dans les applications Flutter.
Nous pourrons par exemple créer des classes pour représenter un utilisateur, un message, une image, un produit ou encore une notification.
Création du premier projet Flutter :
La création d'un projet Flutter peut être réalisée directement à partir d'un terminal. Pour créer un nouveau projet Flutter, il faut utiliser la commande suivante :
flutter create mon_projet
mon_projet correspond au nom du projet. Il peut être remplacé par le nom souhaité. Flutter crée alors automatiquement l'ensemble des fichiers et dossiers nécessaires au fonctionnement du projet.
Une fois le projet créé, il faut se déplacer dans son répertoire :
cd mon_projet
Les dépendances du projet peuvent ensuite être récupérées avec :
flutter pub get
Enfin, l'application peut être lancée avec :
flutter run
Flutter recherche alors un appareil disponible, comme un smartphone connecté, un émulateur Android, un navigateur Web ou un environnement de bureau compatible.
Pour connaître les appareils disponibles, la commande suivante peut être utilisée :
flutter devices
Il est également possible de choisir directement une plateforme. Par exemple, pour lancer l'application dans Google Chrome :
flutter run -d chrome
Tableau récapitulatif des commandes Flutter :
| Commande | Utilité |
|---|---|
| flutter --version | Afficher la version de Flutter |
| flutter doctor | Vérifier l'installation et les problèmes |
| flutter upgrade | Mettre Flutter à jour |
| flutter create mon_projet | Créer un nouveau projet Flutter |
| flutter pub get | Installer les dépendances |
| flutter pub add nom_du_package | Ajouter une dépendance au projet |
| flutter pub remove nom_du_package | Supprimer une dépendance au projet |
| flutter pub outdated | Vérifier les dépendances obsolètes |
| flutter pub upgrade | Mettre à jour les dépendances |
| flutter devices | Lister les appareils disponibles |
| flutter run | Lancer l'application |
| flutter run -d chrome | Lancer sur Chrome |
| flutter clean | Nettoyer les fichiers de compilation |
| flutter analyze | Analyser le code et détecter des erreurs |
| flutter test | Exécuter les tests |
| flutter build apk | Générer un APK Android |
| flutter build appbundle | Générer un fichier pour Google Play |
| flutter build ios | Construire l'application iOS |
| flutter build web | Construire l'application Web |
Le fonctionnement du hot reload :
Flutter propose une fonctionnalité appelée Hot Reload.
Elle permet d'appliquer rapidement certaines modifications du code à l'application en cours d'exécution sans devoir redémarrer complètement celle-ci.
Cela permet notamment de modifier une interface, d'enregistrer le fichier puis d'observer rapidement le résultat.
Lorsqu'une application est lancée avec flutter run, la touche r permet généralement de déclencher un hot reload depuis le terminal.
Le Hot Restart, accessible avec R, redémarre davantage l'application et réinitialise notamment son état en mémoire.
Hiérarchie des dossiers d'un projet Flutter :
Lorsqu'un projet est créé avec flutter create, Flutter génère automatiquement une structure de dossiers et de fichiers.
Une structure simplifiée peut être représentée ainsi :
mon_projet/
├── android/
├── ios/
├── lib/
│ └── main.dart
├── test/
├── web/
├── windows/
├── macos/
├── linux/
├── pubspec.yaml
├── pubspec.lock
└── README.md
Tous les dossiers ne sont pas nécessairement utilisés selon les plateformes ciblées par le projet.
Le dossier lib/ :
Le dossier lib est l'un des dossiers les plus importants d'une application Flutter.
Il contient principalement le code Dart de l'application.
Le fichier main.dart se trouve généralement directement dans ce dossier.
Lorsque l'application devient plus importante, il est possible d'organiser le code en plusieurs sous-dossiers, par exemple :
lib/
├── main.dart
├── screens/
├── widgets/
├── models/
├── services/
└── utils/
Cette organisation permet de séparer les différentes responsabilités de l'application. Par exemple :
-
models/ peut contenir les classes représentant les données;
-
services/ peut contenir les communications avec des services externes;
-
screens/ peut contenir les différentes pages de l'application;
-
widgets/ peut contenir les composants graphiques réutilisables;
-
utils/ peut contenir des fonctions utilitaires.
Cette organisation deviendra particulièrement importante lorsque nous commencerons à développer des applications Flutter complètes.
Le fichier main.dart :
Après la création du projet, le fichier principal de l'application se trouve généralement dans lib/main.dart.
Ce fichier constitue le point d'entrée de l'application Flutter. Il contient notamment la fonction main() qui permet de démarrer l'application.
void main() {
runApp(const MyApp());
}
La fonction runApp() demande à Flutter de lancer notre application. MyApp représente le widget racine de notre application.
Un des concepts les plus importants de Flutter est que tout est widget. Une interface Flutter est construite à partir de widgets. Par exemple :
Application
│
└── Page
│
├── Texte
├── Image
├── Bouton
└── Champ de saisie
Dans un projet Flutter classique, le développement de l'interface et de la logique de l'application se fait principalement dans le dossier lib.
Le dossier test/ :
Le dossier test contient les tests automatisés de l'application.
Ces tests permettent notamment de vérifier que certaines parties du programme fonctionnent correctement et de détecter plus facilement les régressions lors des modifications du projet.
Les tests peuvent être exécutés avec :
flutter test
Le dossier android/ :
Le dossier android contient les fichiers spécifiques à la plateforme Android.
Flutter génère automatiquement cette partie du projet lors de la création de l'application.
Elle permet notamment de configurer les éléments propres à Android lorsque cela est nécessaire.
Le dossier ios/ :
Le dossier ios contient les fichiers spécifiques à la plateforme iOS.
Il est principalement utilisé lorsqu'une application Flutter doit être construite ou configurée pour les appareils Apple.
Le dossier web/ :
Le dossier web contient les éléments nécessaires à l'exécution de l'application Flutter sur le Web.
Une application Flutter peut ainsi être exécutée dans un navigateur compatible.
La compilation de la version Web peut être réalisée avec :
flutter build web
Les dossiers windows/, macos/ et linux/ :
Ces dossiers contiennent les fichiers spécifiques aux plateformes de bureau correspondantes :
-
windows/ → Windows
-
macos/ → macOS
-
linux/ → Linux
Ils sont générés lorsqu'une plateforme de bureau est activée dans l'environnement Flutter.
Le fichier pubspec.yaml :
Le fichier pubspec.yaml est un fichier essentiel du projet Flutter.
Il contient notamment :
-
le nom du projet;
-
la version de l'application;
-
les dépendances utilisées;
-
les ressources de l'application, comme les images et les polices;
-
certaines informations de configuration.
Lorsqu'un package est ajouté avec :
flutter pub add nom_du_package
Flutter ajoute notamment cette dépendance au fichier pubspec.yaml.
Le fichier pubspec.lock :
Le fichier pubspec.lock contient les versions précises des dépendances résolues pour le projet.
Il permet notamment de conserver une résolution cohérente des informations et des instructions concernant le projet.
Il peut être utilisé pour documenter le fonctionnement de l'application, son installation ou les différentes étapes nécessaires pour la lancer.
À retenir :
Lorsqu'un projet Flutter est créé, les éléments les plus importants à connaître au début sont principalement :
-
lib/ → code principal de l'application;
-
lib/main.dart → point d'entrée habituel de l'application;
-
test/ → tests;
-
android/ → configuration Android;
-
ios/ → configuration iOS;
-
web/ → configuration Web;
-
windows/, macos/, linux/ → plateformes de bureau;
-
pubspec.yaml → configuration et dépendances du projet;
-
pubspec.lock → versions résolues des dépendances.
Le concept de widget :
Dans Flutter, l'interface d'une application est construite à partir de widgets.
Un widget représente une partie de l'interface ou une description de celle-ci.
Un widget peut représenter par exemple :
-
du texte;
-
une image;
-
un bouton;
-
une zone de saisie;
-
une ligne;
-
une colonne;
-
une page entière.
Les widgets peuvent être imbriqués les uns dans les autres.
L'arbre des widgets :
Une interface Flutter peut être représentée sous la forme d'un arbre de widgets.
MaterialApp
│
└── Scaffold
│
├── AppBar
│
└── body
│
└── Column
├── Text
├── Image
└── ElevatedButton
Chaque widget peut avoir un ou plusieurs widgets enfants.
Cette organisation permet de construire des interfaces complexes à partir de composants plus simples.
StatelessWidget :
Un StatelessWidget est un widget dont la configuration ne possède pas d'état mutable interne.
Un exemple simple est un widget affichant un texte.
import 'package:flutter/material.dart';
class Bonjour extends StatelessWidget {
const Bonjour({super.key});
@override
Widget build(BuildContext context) {
return const Text("Bonjour !");
}
}
Le mot-clé const permet de créer une instance constante lorsque tous les éléments nécessaires sont connus à la compilation. Flutter utilise fréquemment const pour les widgets dont la configuration ne change pas.
La méthode build() est fondamentale dans Flutter. Elle reçoit un BuildContext et retourne un widget. Flutter utilise cette description pour construire l'interface correspondante.
Le BuildContext représente notamment la position d'un widget dans l'arbre des widgets. Il est utilisé par de nombreuses fonctionnalités Flutter, notamment pour accéder à certaines informations liées à l'environnement du widget.
StatefulWidget :
Certains widgets doivent conserver des informations qui peuvent changer pendant l'utilisation de l'application. Pour ce type de situation, Flutter propose le StatefulWidget.
class Compteur extends StatefulWidget {
const Compteur({super.key});
@override
State<Compteur> createState() {
return _CompteurState();
}
}
class _CompteurState extends State<Compteur> {
int compteur = 0;
@override
Widget build(BuildContext context) {
return Text("$compteur");
}
}
Le widget et son état sont deux éléments distincts.
Modifier l'état avec setState :
Lorsqu'une valeur appartenant à l'état d'un StatefulWidget change, la méthode setState() permet d'indiquer à Flutter que l'interface doit être reconstruite.
setState(() {
compteur++;
});
Flutter peut alors reconstruire les parties nécessaires de l'interface afin d'afficher le nouvel état.
MaterialApp :
MaterialApp est un widget couramment utilisé comme point de départ d'une application Flutter utilisant les composants Material.
void main() {
runApp(
const MaterialApp(
home: Text("Bonjour")
)
);
}
Dans une application plus complète, nous utiliserons généralement une structure plus élaborée.
Scaffold :
Le widget Scaffold fournit une structure de base pour de nombreuses interfaces Material.
Il peut notamment accueillir une barre supérieure, un corps de page et une barre de navigation.
Scaffold(
appBar: AppBar(
title: const Text("Accueil"),
),
body: const Center(
child: Text("Bonjour")
),
)
Le thème de l'application :
Une application Flutter peut définir un thème global permettant notamment de centraliser certains choix visuels.
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue
),
),
home: const Accueil(),
)
Le thème permet notamment d'éviter de répéter certains réglages dans chaque widget.
Première interface :
Nous pouvons maintenant combiner plusieurs notions afin de créer une première interface Flutter.
import 'package:flutter/material.dart';
void main() {
runApp(const MonApplication());
}
class MonApplication extends StatelessWidget {
const MonApplication({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: "Mon application",
home: Scaffold(
appBar: AppBar(
title: const Text("Accueil"),
),
body: const Center(
child: Text("Bienvenue dans Flutter !"),
),
),
);
}
}
Cette application contient plusieurs niveaux de widgets :
MonApplication
│
└── MaterialApp
│
└── Scaffold
│
├── AppBar
│ └── Text
│
└── Center
└── Text
Accessibilité dès la conception :
L'accessibilité ne doit pas être ajoutée uniquement à la fin du développement.
Elle doit être prise en compte dès la conception de l'interface.
Une application accessible doit notamment pouvoir être utilisée avec différentes méthodes d'interaction et différents outils d'assistance.
Dans la suite de ce cours, nous porterons une attention particulière à la lisibilité, à la taille des éléments interactifs, au contraste, à la navigation au clavier, aux lecteurs d'écran et aux alternatives aux informations qui ne reposent pas uniquement sur le son ou la couleur.
Pour les utilisateurs sourds ou malentendants, une information importante ne doit notamment pas être transmise uniquement par un signal sonore.
Pour les utilisateurs malvoyants, les interfaces devront être pensées pour fonctionner correctement avec les technologies d'assistance disponibles sur les appareils.
Les widgets d'interface et de mise en page :
Après avoir découvert le concept de widget, nous pouvons maintenant découvrir les principaux widgets utilisés pour construire une interface Flutter.
Flutter fournit un grand nombre de widgets permettant notamment d'afficher des informations, de positionner des éléments, de créer des boutons et de construire des interfaces complexes.
Il est important de comprendre que les widgets ne servent pas uniquement à afficher des éléments graphiques. Certains widgets permettent également d'organiser les autres widgets qui composent l'interface.
Nous pouvons donc distinguer plusieurs grandes catégories de widgets :
-
les widgets permettant d'afficher du contenu ;
-
les widgets permettant de positionner les éléments ;
-
les widgets permettant de gérer l'espace et les dimensions ;
-
les widgets permettant les interactions avec l'utilisateur ;
-
les widgets permettant de construire la structure générale d'une page.
Le widget Text :
Le widget Text permet d'afficher une chaîne de caractères dans l'interface.
const Text("Bonjour !")
Il est possible de personnaliser l'apparence du texte avec la propriété style.
Text(
"Bonjour !",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
)
Le widget Text possède de nombreuses propriétés permettant notamment de contrôler l'alignement, le nombre de lignes ou encore le comportement du texte lorsqu'il ne dispose pas de suffisamment d'espace.
Le widget Icon :
Comme son nom l'indique, le widget Icon permet d'afficher une icône.
const Icon(Icons.home)
Flutter fournit de nombreuses icônes Material accessibles à partir de la classe Icons.
const Icon(Icons.favorite)
Une icône peut également être utilisée à l'intérieur d'un bouton ou d'un autre widget.
Le widget Image :
Le widget Image permet d'afficher une image.
Une image peut notamment provenir d'un fichier présent dans l'application ou d'une adresse Internet.
Image provenant d'Internet :
Image.network(
"https://example.com/image.jpg"
)
Image provenant d'une ressource locale :
Image.asset("assets/images/logo.png")
Pour utiliser des images provenant du projet lui-même, il faut notamment déclarer les ressources dans le fichier pubspec.yaml.
Le widget Container :
Le widget Container permet notamment de contrôler la taille, les marges internes, la décoration ou encore l'alignement d'un élément.
Container(
padding: const EdgeInsets.all(16),
child: const Text("Bonjour"),
)
Un Container peut notamment contenir un seul widget enfant.
Le widget Padding :
Le widget Padding permet d'ajouter un espace intérieur autour de son widget enfant.
Padding(
padding: const EdgeInsets.all(16),
child: const Text("Bonjour"),
)
Il est également possible de définir différentes valeurs pour chaque côté.
Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: 8,
),
child: const Text("Bonjour"),
)
Le widget SizedBox :
Le widget SizedBox permet notamment de créer un espace ou de donner une largeur et une hauteur précises à un élément.
Pour créer un espace vertical :
const SizedBox(height: 20)
Pour créer un espace horizontal :
const SizedBox(width: 20)
Le widget Row :
Le widget Row permet de disposer plusieurs widgets horizontalement.
Row(
children: [
const Icon(Icons.home),
const Text("Accueil"),
],
)
Les widgets contenus dans un Row sont placés les uns à côté des autres.
Le widget Column :
Le widget Column permet de disposer plusieurs widgets verticalement.
Column(
children: [
const Text("Prénom"),
const Text("Nom"),
const Text("Email"),
],
)
Le widget Center :
Le widget Center permet de positionner son widget enfant au centre de l'espace qui lui est disponible.
Center(
child: const Text("Bonjour"),
)
Il est particulièrement utile lorsque l'on souhaite centrer un élément dans le corps d'une page.
Le widget Align :
Le widget Align permet de contrôler la position de son widget enfant dans l'espace disponible.
Align(
alignment: Alignment.topRight,
child: const Text("Bonjour"),
)
La propriété alignment permet notamment de choisir la position du widget enfant. Par exemple :
Alignment.topLeft
Alignment.topCenter
Alignment.topRight
Alignment.center
Alignment.bottomLeft
Alignment.bottomCenter
Alignment.bottomRight
mainAxisAlignment :
Les widgets Row et Column utilisent un axe principal appelé main axis.
Pour un Row, l'axe principal est horizontal.
Pour une Column, l'axe principal est vertical.
La propriété mainAxisAlignment permet de contrôler la répartition des enfants sur cet axe.
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Premier"),
const Text("Deuxième"),
],
)
Flutter propose plusieurs valeurs, notamment :
-
start;
-
center;
-
end;
-
spaceBetween;
-
spaceAround;
-
spaceEvenly.
crossAxisAlignment :
La propriété crossAxisAlignment permet de contrôler la position des enfants sur l'axe secondaire.
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Prénom"),
const Text("Nom"),
const Text("Email"),
],
)
Dans une Column, l'axe secondaire est horizontal.
Dans une Row, l'axe secondaire est vertical.
Le widget Expanded :
Le widget Expanded permet à son enfant d'occuper l'espace disponible dans un Row, une Column ou un Flex.
Row(
children:[
const Icon(Icons.person),
Expanded(
child: const Text("Utilisateur"),
),
],
)
Lorsqu'il est utilisé dans un Row, l'enfant de Expanded peut occuper l'espace horizontal restant.
Le nombre indiqué dans la propriété flex permet de répartir l'espace disponible entre plusieurs widgets Expanded.
Row(
children: [
Expanded(
flex: 1,
child: Container(),
),
Expanded(
flex: 2,
child: Container(),
),
],
)
Le widget Flexible :
Le widget Flexible permet de contrôler la manière dont un enfant occupe l'espace disponible dans un Row, une Column ou un Flex.
Contrairement à Expanded, il ne force pas nécessairement l'enfant à remplir tout l'espace disponible.
Row(
children: [
Flexible(
child: const Text(
"Un texte pouvant occuper "
"l'espace disponible.",
),
),
],
)
Le widget Spacer :
Le widget Spacer permet d'insérer un espace flexible entre plusieurs widgets placés dans un Row, une Column ou un Flex.
Row(
children: [
const Text("Accueil"),
const Spacer(),
const Icon(Icons.settings),
],
)
Dans cet exemple, le widget Spacer occupe l'espace disponible entre le texte et l'icône.
Le widget Stack :
Le widget Stack permet de superposer plusieurs widgets.
Stack(
children: [
Container(
width: 200,
height: 200,
),
const Text("Texte"),
],
)
Les widgets contenus dans un Stack sont placés les uns au-dessus des autres.
Le widget Positioned :
Le widget Positioned peut être utilisé à l'intérieur d'un Stack afin de contrôler la position d'un élément.
Stack(
children: [
Container(
width: 300,
height: 200,
),
Positioned(
right: 10,
bottom: 10,
child: const Icon(
Icons.favorite,
),
),
],
)
Le widget Wrap :
Le widget Wrap permet de placer des widgets les uns à côté des autres et de passer automatiquement à une nouvelle ligne lorsqu'il n'y a plus suffisamment d'espace.
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text("Dart")),
Chip(label: Text("Flutter")),
Chip(label: Text("Mobile")),
Chip(label: Text("Web")),
],
)
Il est particulièrement utile pour les interfaces contenant des éléments dont la taille peut varier.
Le système de contraintes :
La compréhension du système de contraintes est essentielle pour comprendre la mise en page Flutter.
De manière simplifiée, le parent fournit des contraintes à son enfant, l'enfant détermine sa taille en fonction de ces contraintes, puis le parent positionne l'enfant.
Une mauvaise compréhension de ce fonctionnement peut provoquer des erreurs de mise en page, notamment lorsqu'un widget reçoit un espace non borné alors qu'il nécessite une dimension déterminée.
Un exemple fréquent est l'utilisation d'une liste défilante dans une Column.
Column(
children: [
const Text("Titre"),
ListView(
children: [
const Text("Élément 1"),
const Text("Élément 2"),
],
),
],
)
Dans cette situation, la hauteur de la liste peut ne pas être correctement déterminée.
On peut notamment utiliser Expanded afin de lui donner l'espace restant :
Column(
children: [
const Text("Titre"),
Expanded(
child: ListView(
children: [
const Text("Élément 1"),
const Text("Élément 2"),
],
),
),
],
)
Cette utilisation de Expanded est un cas très courant dans les interfaces Flutter.
Le défilement et les listes :
Les interfaces mobiles et les applications de bureau peuvent contenir davantage d'informations que ce qui peut être affiché simultanément à l'écran.
Flutter fournit plusieurs widgets permettant de gérer le défilement.
SingleChildScrollView :
Le widget SingleChildScrollView permet de rendre une partie de l'interface défilable.
SingleChildScrollView(
child: Column(
children: [
const Text("Titre"),
const Text("Description"),
const Text("Informations"),
],
),
)
Il est particulièrement adapté lorsque le contenu est relativement limité et peut être construit en une seule fois.
ListView :
Le widget ListView permet de créer une liste verticale ou horizontale défilante.
ListView(
children: [
const ListTile(
title: Text("Accueil"),
),
const ListTile(
title: Text("Profil"),
),
const ListTile(
title: Text("Paramètres"),
),
],
)
ListTile :
Le widget ListTile fournit une structure pratique pour représenter une ligne dans une liste.
ListTile(
leading: const Icon(Icons.person),
title: const Text("Alice"),
subtitle: const Text(
"alice@example.com",
),
trailing: const Icon(
Icons.arrow_forward,
),
)
ListView.builder :
Lorsqu'une liste contient un grand nombre d'éléments, il est préférable de construire les éléments au fur et à mesure qu'ils sont nécessaires.
Le widget ListView.builder permet notamment de construire dynamiquement une liste.
ListView.builder(
itemCount: utilisateurs.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(
utilisateurs[index].nom,
),
);
},
)
Cette approche est particulièrement utile pour les listes provenant de données externes. Flutter recommande les constructeurs comme ListView.builder ou GridView.builder lorsque les listes sont longues ou construites progressivement.
GridView :
Le widget GridView permet de disposer des éléments sous forme de grille.
GridView.count(
crossAxisCount: 2,
children: [
Card(
child: const Center(
child: Text("1"),
),
),
Card(
child: const Center(
child: Text("2"),
),
),
],
)
Il peut être utilisé pour présenter des images, des produits, des catégories ou d'autres éléments organisés en grille.
À retenir :
| Widget | Utilisation |
|---|---|
| SingleChildScrollView | Rendre un contenu défilable |
| ListView | Créer une liste défilante |
| ListView.builder | Construire dynamiquement les éléments d'une liste |
| ListTile | Créer une ligne structurée |
| GridView | Présenter des éléments sous forme de grille |
Les widgets interactifs :
Une application doit permettre à l'utilisateur d'effectuer différentes actions.
Flutter fournit plusieurs widgets permettant notamment de créer des boutons, d'afficher des commandes et de réagir aux actions de l'utilisateur.
ElevatedButton :
ElevatedButton(
onPressed: () {
print("Bouton activé");
},
child: const Text("Valider"),
)
La propriété onPressed contient l'action exécutée lorsque l'utilisateur active le bouton.
FilledButton :
FilledButton(
onPressed: () {
print("Action");
},
child: const Text("Continuer"),
)
OutlinedButton :
OutlinedButton(
onPressed: () {
print("Annuler");
},
child: const Text("Annuler"),
)
TextButton :
TextButton(
onPressed: () {
print("En savoir plus");
},
child: const Text("En savoir plus"),
)
IconButton :
IconButton(
onPressed: () {
print("Recherche");
},
icon: const Icon(
Icons.search,
),
)
FloatingActionButton :
Le widget FloatingActionButton permet de présenter une action principale sous la forme d'un bouton flottant.
FloatingActionButton(
onPressed: () {
print("Nouvel élément");
},
child: const Icon(
Icons.add,
),
)
Les callbacks :
Les propriétés comme onPressed représentent des fonctions appelées lorsqu'un événement se produit.
Cette manière de transmettre une fonction à un widget est appelée un callback.
void afficherMessage() {
print("Bonjour !");
}
ElevatedButton(
onPressed: afficherMessage,
child: const Text("Bonjour"),
)
Les callbacks permettent de séparer l'interface de l'action réalisée lorsqu'un utilisateur interagit avec celle-ci.
Accessibilité des éléments interactifs :
Les éléments interactifs doivent fournir suffisamment d'informations à l'utilisateur pour comprendre leur rôle.
Un bouton contenant uniquement une icône doit notamment être identifiable correctement lorsqu'il est utilisé avec une technologie d'assistance.
La navigation au clavier et la gestion du focus sont également importantes, notamment sur ordinateur. Les composants Flutter courants prennent en charge une partie de la navigation clavier, mais celle-ci doit être vérifiée lors des tests.
Les formulaires :
Les applications ont régulièrement besoin de demander des informations à l'utilisateur.
Il peut s'agir d'un nom, d'une adresse électronique, d'un mot de passe, d'une recherche ou d'une description.
Flutter fournit différents widgets permettant de créer ces interfaces.
TextField :
Le widget TextField permet à l'utilisateur de saisir du texte.
TextField(
decoration: const InputDecoration(
labelText: "Email",
hintText: "exemple@email.com",
border: OutlineInputBorder(),
),
)
La propriété decoration permet de personnaliser la présentation du champ de saisie avec le widget InputDecoration. Elle permet notamment d'ajouter un label, une indication, une icône ou une bordure.
TextEditingController :
La classe TextEditingController permet notamment de récupérer et de modifier le texte d'un champ de saisie.
final TextEditingController nomController = TextEditingController();
Le contrôleur est ensuite associé au champ :
TextField(
controller: nomController,
)
La valeur saisie peut alors être récupérée avec :
String nom = nomController.text;
Le widget Form :
Le widget Form permet de regrouper plusieurs champs afin de gérer leur validation, leur sauvegarde ou leur réinitialisation.
Une clé de formulaire peut être utilisée pour contrôler l'état du formulaire.
final formKey = GlobalKey<FormState>();
Le formulaire peut ensuite être créé :
Form(
key: formKey,
child: Column(
children: [
TextFormField(),
ElevatedButton(
onPressed: () {
if (formKey.currentState!.validate()) {
print("Formulaire valide");
}
},
child: const Text("Envoyer"),
),
],
)
)
TextFormField :
Le widget TextFormField associe un champ de saisie à la logique des formulaires. Il permet notamment d'utiliser une fonction de validation.
TextFormField(
decoration: const InputDecoration(
labelText: "Email",
),
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez saisir votre email.";
}
return null;
}
)
La fonction validator retourne généralement un message lorsque la valeur est invalide et null lorsqu'elle est valide.
Validation d'une adresse électronique :
TextFormField(
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: "Adresse email",
),
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez saisir une adresse email.";
}
if (!value.contains("@")) {
return "Adresse email invalide.";
}
return null;
},
)
Cette validation est volontairement simple. Dans une application réelle, les règles de validation devront être adaptées au besoin et ne remplaceront pas les vérifications effectuées côté serveur.
Les cases à cocher :
Le widget Checkbox permet de représenter une valeur pouvant être activée ou désactivée.
Checkbox(
value: accepte,
onChanged: (value) {
setState(() {
accepte = value ?? false;
});
},
)
Le widget Switch :
Le widget Switch permet de représenter une option activée ou désactivée.
Switch(
value: notifications,
onChanged: (value) {
setState(() {
notifications = value;
});
},
)
Les formulaires et l'accessibilité :
Les formulaires doivent être compréhensibles et utilisables avec différentes méthodes d'interaction.
Les champs doivent notamment posséder des intitulés explicites et les messages d'erreur doivent être compréhensibles.
Sur ordinateur, la navigation au clavier et l'ordre de déplacement du focus doivent également être vérifiés.
Pour les personnes utilisant un lecteur d'écran, les informations importantes doivent être correctement associées aux champs concernés.
Premier formulaire complet :
class FormulaireInscription extends StatefulWidget {
const FormulaireInscription({super.key});
@override
State<FormulaireInscription> createState() {
return _FormulaireInscriptionState();
}
}
class _FormulaireInscriptionState extends State<FormulaireInscription> {
final formKey = GlobalKey<FormState>();
final emailController = TextEditingController();
@override
void dispose() {
emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: formKey,
child: Column(
children: [
TextFormField(
controller: emailController,
decoration: const InputDecoration(
labelText: "Adresse email",
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez saisir votre email.";
}
return null;
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (formKey.currentState!.validate()) {
print(emailController.text);
}
},
child: const Text("Envoyer"),
),
],
),
);
}
}
Cet exemple réunit plusieurs notions étudiées précédemment : StatefulWidget, Form, TextFormField, TextEditingController, validation et bouton.
Nous pouvons maintenant commencer à construire des interfaces capables de recevoir et de traiter les informations saisies par l'utilisateur.
La navigation entre les pages :
Une application contient généralement plusieurs pages permettant d'afficher différentes informations ou de réaliser différentes actions.
Dans Flutter, les écrans et les pages sont représentés par des routes.
Le widget Navigator permet de gérer la navigation entre ces différentes routes.
Le Navigator fonctionne notamment comme une pile de navigation. Une nouvelle route peut être ajoutée au sommet de cette pile et la route située au sommet peut ensuite être retirée.
Les routes :
Une route représente une page ou un écran vers lequel l'utilisateur peut naviguer.
Par exemple : Accueil → Profil → Paramètres.
Chaque écran peut donc être représenté par un widget Flutter.
Navigator.push :
La méthode Navigator.push() permet d'ajouter une nouvelle route à la pile de navigation.
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DeuxiemePage(),
),
)
Dans cet exemple, Flutter crée une nouvelle route à partir du widget DeuxiemePage et l'affiche.
La méthode push() retourne une Future qui se termine lorsque la route est retirée de la pile. Cela permettra notamment de récupérer une valeur renvoyée par la page suivante.
MaterialPageRoute :
Le widget MaterialPageRoute permet de créer une route adaptée à une application utilisant Material Design.
MaterialPageRoute(
builder: (context) => const ProfilPage(),
)
La propriété builder indique le widget qui doit être utilisé pour construire la nouvelle page.
Navigator.pop :
Pour revenir à la page précédente, il est possible d'utiliser Navigator.pop().
Navigator.pop(context);
Cette méthode retire la route située au sommet de la pile de navigation.
Premier exemple complet :
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: AccueilPage(),
),
);
}
class AccueilPage extends StatelessWidget {
const AccueilPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Accueil"),
),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.push(
context, MaterialPageRoute(
builder: (context) => const ProfilPage(),
),
);
},
child: const Text("Voir le profil"),
),
),
);
}
}
class ProfilPage extends StatelessWidget {
const ProfilPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Profil"),
),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text("Retour"),
),
),
);
}
}
Dans cet exemple, l'utilisateur se trouve d'abord sur la page AccueilPage.
Lorsqu'il active le bouton, Navigator.push() ajoute la page ProfilPage à la pile.
Lorsqu'il active le bouton de retour, Navigator.pop() retire ProfilPage et l'utilisateur revient à la page précédente.
Passer des données à une autre page :
Lorsqu'une nouvelle page est ouverte, il est souvent nécessaire de lui transmettre des informations.
Ces informations peuvent être transmises au constructeur du widget qui représente la nouvelle page.
class ProfilPage extends StatelessWidget {
final String nom;
const ProfilPage({
super.key,
required this.nom,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Profil"),
),
body: Center(
child: Text("Bonjour $nom"),
),
);
}
}
Lors de la navigation, nous pouvons fournir la valeur :
Navigator.push(
context, MaterialPageRoute(
builder: (context) => const ProfilPage(
nom: "Alice",
),
),
);
Il est ainsi possible de transmettre un objet ou plusieurs informations à une nouvelle page. Cette technique est notamment utilisée lorsqu'un utilisateur sélectionne un élément dans une liste et que la page suivante doit afficher les détails de cet élément.
Récupérer une valeur renvoyée par une page :
Une route peut également retourner une valeur lorsqu'elle est fermée.
Pour cela, la valeur peut être fournie avec Navigator.pop().
Navigator.pop(
context,
true,
);
La page qui a ouvert la route peut récupérer cette valeur en attendant le résultat de Navigator.push().
final bool? resultat = await Navigator.push<bool>(
context, MaterialPageRoute(
builder: (context) => const ConfirmationPage(),
),
);
Cette possibilité est particulièrement utile lorsqu'une page demande une information ou un choix à l'utilisateur avant de revenir à la page précédente.
Les routes nommées :
Flutter permet également de définir des routes possédant un nom. Par exemple :
MaterialApp(
routes: {
"/": (context) => const AccueilPage(),
"/profil": (context) => const ProfilPage(),
"/parametres": (context) => const ParametresPage(),
},
)
Une route nommée peut ensuite être utilisée avec :
Navigator.pushNamed(
context,
"/profil",
);
Cette approche permet notamment de centraliser les chemins de navigation.
Les routes nommées restent utiles pour certains cas simples, mais la documentation actuelle de Flutter ne les recommande plus comme solution générale pour la plupart des applications. Pour des besoins de navigation plus complexes, Flutter recommande notamment d'envisager Navigator avec MaterialPageRoute ou un routeur comme go_router.
Navigation avec plusieurs pages :
Une application peut organiser ses différentes pages de la manière suivante :
Application
│
├── Accueil
│
├── Profil
│
├── Paramètres
│
└── Détails
Chaque page peut elle-même permettre d'accéder à d'autres pages.
Navigation et accessibilité :
La navigation doit rester compréhensible pour les utilisateurs utilisant des technologies d'assistance.
Les titres des pages doivent être explicites et les boutons permettant de naviguer doivent clairement indiquer leur fonction.
Il est également important de vérifier que l'ordre de navigation et le focus restent cohérents lorsqu'une nouvelle page est affichée.
Sur ordinateur, la navigation au clavier doit également être testée.
À retenir :
| Méthode / élément | Utilisation |
|---|---|
| Navigator | Gérer la navigation entre les routes |
| Route | Représenter une page ou un écran |
| MaterialPageRoute | Créer une route utilisant une transition Material |
| Navigator.push() | Ajouter une nouvelle route |
| Navigator.pop() | Retirer la route courante |
| Navigator.pushNamed() | Naviguer vers une route nommée |
| MaterialApp.routes | Définir une table de routes nommées |
La gestion de l'état :
Une application interactive doit pouvoir modifier son interface en fonction des actions de l'utilisateur et des données qu'elle manipule.
Par exemple, lorsqu'un utilisateur active une option, ajoute un élément à une liste ou reçoit de nouvelles données, l'interface doit pouvoir être actualisée.
La donnée qui peut changer au cours de l'utilisation d'une application est appelée état.
La gestion de l'état consiste donc notamment à déterminer quelles données peuvent changer, où elles doivent être conservées et comment l'interface doit être informée lorsqu'elles changent.
Flutter utilise une approche déclarative : l'interface est décrite en fonction de l'état courant de l'application. Lorsque cet état évolue, Flutter reconstruit les widgets concernés afin de représenter le nouvel état.
On peut représenter ce principe de manière simplifiée :
État de l'application
↓
Interface Flutter
↓
Action de l'utilisateur
↓
Modification de l'état
↓
Nouvelle interface
L'état local :
L'état local correspond aux données utilisées par un widget et qui ne doivent pas nécessairement être accessibles à l'ensemble de l'application.
Il peut par exemple s'agir :
-
d'un compteur;
-
de l'état d'une case à cocher;
-
de la sélection d'un élément;
-
de l'ouverture ou de la fermeture d'une interface;
-
d'une information temporaire propre à un écran.
Lorsque l'état concerne uniquement un widget, StatefulWidget et setState() constituent généralement une solution suffisante. La documentation Flutter présente notamment setState comme une approche adaptée à l'état local et éphémère d'un widget.
setState() :
La méthode setState() permet d'indiquer à Flutter qu'une modification de l'état d'un widget nécessite une reconstruction de son interface.
Considérons un compteur :
class Compteur extends StatefulWidget {
const Compteur({super.key});
@override
State<Compteur> createState() {
return _CompteurState();
}
}
class _CompteurState extends State<Compteur> {
int compteur = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text("$compteur"),
ElevatedButton(
onPressed: () {
setState(() {
compteur++;
});
},
child: const Text("Ajouter"),
),
],
);
}
}
Lorsque l'utilisateur active le bouton, la valeur de compteur est modifiée.
L'appel à setState() indique alors à Flutter que l'état a changé et que l'interface doit être reconstruite.
Sans setState(), la variable pourrait changer en mémoire sans que Flutter sache qu'il doit mettre à jour l'interface.
Le cycle de vie d'un StatefulWidget :
Un StatefulWidget possède un cycle de vie permettant notamment d'initialiser, de mettre à jour et de libérer les ressources utilisées par son état.
Parmi les méthodes importantes, nous retrouverons notamment :
-
initState();
-
didChangeDependencies();
-
build();
-
didUpdateWidget();
-
dispose().
initState() :
La méthode initState() est appelée lors de la création de l'état du widget.
Elle est notamment utilisée pour effectuer certaines initialisations.
@override
void initState() {
super.initState();
print("Initialisation");
}
dispose() :
La méthode dispose() est appelée lorsque l'état du widget n'est plus utilisé.
Elle permet notamment de libérer les ressources qui doivent être explicitement nettoyées.
Par exemple, un TextEditingController créé par un état doit être libéré lorsqu'il n'est plus nécessaire.
@override
void dispose() {
controller.dispose();
super.dispose();
}
La gestion correcte du cycle de vie permet notamment d'éviter de conserver inutilement des ressources après la destruction d'un widget.
L'état partagé :
Dans une application réelle, certaines données doivent être accessibles par plusieurs widgets ou plusieurs pages.
Par exemple, une application peut devoir conserver :
-
l'utilisateur actuellement connecté;
-
les préférences de l'utilisateur;
-
une liste de données utilisée par plusieurs écrans;
-
l'état de chargement d'une opération;
-
le résultat d'une requête distante.
Dans ce cas, conserver toutes les données dans un seul StatefulWidget et les transmettre manuellement à travers de nombreux widgets peut rapidement rendre le code difficile à maintenir.
Il devient alors nécessaire d'utiliser un mécanisme permettant de centraliser et de partager certaines données.
Flutter présente plusieurs approches possibles pour la gestion d'état. Le choix dépend notamment de la taille et des besoins de l'application.
ChangeNotifier :
ChangeNotifier est une classe du SDK Flutter permettant à un objet de signaler à ses observateurs qu'une donnée a changé.
Une classe de gestion d'état peut hériter de ChangeNotifier.
class CompteurModel extends ChangeNotifier {
int compteur = 0;
void incrementer() {
compteur++;
notifyListeners();
}
}
La méthode notifyListeners() informe les objets qui écoutent le ChangeNotifier que son état a été modifié.
ChangeNotifier n'est pas un widget. Il s'agit d'une classe permettant notamment de gérer et de notifier les changements d'un état.
ListenableBuilder :
Le widget ListenableBuilder permet de reconstruire une partie de l'interface lorsqu'un objet Listenable, comme un ChangeNotifier, signale une modification.
On peut par exemple utiliser notre modèle de compteur :
final compteur = CompteurModel();
ListenableBuilder(
listenable: compteur,
builder: (context, child) {
return Column(
children: [
Text("${compteur.compteur}"),
ElevatedButton(
onPressed: compteur.incrementer,
child: const Text("Ajouter"),
),
],
);
}
)
Lorsque incrementer() appelle notifyListeners(), le ListenableBuilder reconstruit son interface avec la nouvelle valeur.
Flutter utilise cette approche dans ses tutoriels récents pour connecter un ViewModel à une interface.
Organisation de l'état :
Lorsque l'application devient plus importante, il est préférable de séparer l'interface et la logique qui manipule les données.
Une architecture couramment recommandée dans la documentation Flutter récente distingue notamment une couche d'interface et une couche de données. Les ViewModels peuvent notamment conserver l'état nécessaire à une vue et contenir la logique applicative associée.
Une représentation simplifiée peut être la suivante :
Interface
↓
ViewModel
↓
Repository
↓
Source de données
Cette séparation permet notamment d'éviter de placer toute la logique directement dans les widgets.
Un widget devrait principalement décrire l'interface et transmettre les actions de l'utilisateur au mécanisme chargé de traiter ces actions. La logique liée aux données peut être placée dans une couche dédiée.
Pourquoi séparer l'interface et la logique ?
Cette séparation apporte notamment plusieurs avantages :
-
code plus lisible;
-
code plus facilement testable;
-
meilleure réutilisation de la logique;
-
maintenance facilitée;
-
évolution plus simple lorsque l'application grandit.
Communication avec Internet et les API :
Une application moderne doit régulièrement communiquer avec des services externes afin de récupérer, envoyer ou modifier des données.
Ces données peuvent par exemple provenir d'un serveur, d'une API ou d'un autre service accessible par Internet.
Jusqu'à présent, nous avons principalement travaillé avec des données créées directement dans l'application. Nous allons maintenant apprendre à communiquer avec une source de données externe.
Cette étape permettra ensuite d'aborder des fonctionnalités plus complexes comme l'authentification, les bases de données distantes, le stockage de fichiers et la synchronisation des données.
Flutter fournit notamment le package http permettant d'effectuer des requêtes réseau. La documentation officielle de Flutter utilise ce package dans ses exemples de communication avec des serveurs.
Les requêtes HTTP :
Le protocole HTTP permet notamment à un client de communiquer avec un serveur.
Dans notre cas, le client sera généralement l'application Flutter et le serveur sera un service distant.
Application Flutter
↓ Requête HTTP
Serveur
↓ Réponse HTTP
Application Flutter
Une requête HTTP peut notamment demander au serveur de récupérer, créer, modifier ou supprimer une ressource.
Les principales méthodes HTTP :
| Méthode | Utilisation courante |
|---|---|
| GET | Récupérer des données |
| POST | Créer ou envoyer des données |
| PUT | Modifier une ressource |
| PATCH | Modifier partiellement une ressource |
| DELETE | Supprimer une ressource |
Les API REST :
Une API, ou Application Programming Interface, permet à différents logiciels de communiquer entre eux.
Une API REST utilise généralement HTTP pour permettre à une application d'accéder à des ressources disponibles sur un serveur.
On peut représenter une API REST de manière simplifiée :
Flutter
↓ HTTP
API REST
│
├── Utilisateurs
├── Messages
├── Produits
└── Autres données
Une API peut retourner des données dans différents formats. Le format JSON est particulièrement courant pour les API Web.
Ajouter le package http :
Flutter permet d'ajouter des packages à l'aide de la commande flutter pub add.
Pour ajouter le package http, nous pouvons utiliser :
flutter pub add http
Le package peut ensuite être importé dans un fichier Dart :
import 'package:http/http.dart' as http;
Le package fournit notamment les méthodes permettant d'effectuer des requêtes HTTP.
Récupérer des données :
La méthode http.get() permet d'effectuer une requête HTTP GET.
final response = await http.get(
Uri.parse(
"https://jsonplaceholder.typicode.com/albums/1"
),
);
La méthode renvoie une Future contenant une réponse HTTP.
Nous utilisons donc await afin d'attendre la réponse du serveur.
Le code de statut :
Une réponse HTTP possède notamment un code de statut permettant d'indiquer le résultat de la requête.
Par exemple, le code 200 correspond généralement à une requête réussie.
Les codes HTTP seront étudiés plus précisément dans la suite de ce cours.
JSON avec Flutter :
Lorsqu'une API retourne des données, celles-ci sont fréquemment représentées sous forme de JSON.
En Dart, le package dart:convert fournit notamment les fonctions jsonDecode() et jsonEncode().
Envoyer des données :
Une application ne se contente pas de récupérer des données. Elle doit également pouvoir envoyer des informations au serveur.
La méthode http.post() permet notamment d'envoyer des données à un serveur. La documentation Flutter utilise cette approche pour envoyer du JSON à une API.
final response = await http.post(
Uri.parse(
"https://example.com/users"
),
headers: {
"Content-Type": "application/json; charset=UTF-8",
},
body: jsonEncode({
"nom": "Alice",
"email": "alice@example.com",
}),
);
Les données sont ici transformées en JSON avec jsonEncode().
L'en-tête Content-Type permet notamment d'indiquer au serveur que le contenu envoyé est du JSON.
Modifier et supprimer des données :
PUT :
La méthode http.put() peut être utilisée lorsqu'une ressource doit être modifiée.
final response = await http.put(
Uri.parse(
"https://example.com/users/1"
),
headers: {
"Content-Type": "application/json; charset=UTF-8",
},
body: jsonEncode({
"nom": "Alice Dupont",
}),
);
Flutter utilise notamment http.put() dans ses recettes consacrées à la modification de données sur Internet.
DELETE :
La méthode http.delete() permet de demander au serveur de supprimer une ressource.
final response = await http.delete(
Uri.parse(
"https://example.com/users/1"
),
);
La suppression réelle dépend naturellement du comportement défini par le serveur ou l'API.
CRUD :
Les opérations principales d'une application manipulant des données sont souvent regroupées sous le terme CRUD.
| Opération | Signification | Exemple HTTP |
|---|---|---|
| Create | Créer | POST |
| Read | Lire | GET |
| Update | Modifier | PUT / PATCH |
| Delete | Supprimer | DELETE |
Gestion des erreurs réseau :
Une requête Internet peut échouer pour différentes raisons.
Par exemple :
-
absence de connexion Internet;
-
serveur indisponible;
-
réponse invalide;
-
temps d'attente dépassé;
-
erreur d'authentification;
-
erreur côté serveur.
Il est donc important de ne pas supposer qu'une requête réussira toujours.
try {
final response = await http.get(
Uri.parse(
"https://example.com/data"
),
);
if (response.statusCode == 200) {
print(response.body);
} else {
throw Exception(
"Erreur HTTP : ${response.statusCode}"
);
}
} catch (e) {
print("Erreur $e");
}
Cette gestion pourra ensuite être améliorée afin de présenter à l'utilisateur un message adapté plutôt qu'un simple affichage dans la console.
Les requêtes authentifiées :
Certaines API ne sont accessibles qu'aux utilisateurs ou aux applications disposant d'une autorisation.
Il est alors nécessaire d'envoyer des informations d'authentification dans la requête.
Une méthode courante consiste notamment à utiliser l'en-tête HTTP Authorization.
final response = await http.get(
Uri.parse(
"https://example.com/profile"
),
headers: {
"Authorization": "Bearer VOTRE_JETON",
},
);
Dans une véritable application, le jeton d'authentification ne doit pas être écrit directement en dur dans le code source.
La manière de stocker et de renouveler ce jeton sera étudiée prochainement.
L'authentification :
De nombreuses applications nécessitent d'identifier leurs utilisateurs afin de leur permettre d'accéder à des fonctionnalités ou à des données qui leur sont propres.
Cette identification est généralement réalisée à l'aide d'un système d'authentification.
L'authentification permet notamment de vérifier qu'un utilisateur est bien celui qu'il prétend être.
Dans une application, l'authentification peut notamment être basée sur une adresse électronique et un mot de passe.
Elle peut également utiliser d'autres mécanismes comme des codes temporaires, des comptes provenant d'autres services ou des mécanismes biométriques.
Dans la suite de ce cours, nous allons nous concentrer sur le fonctionnement général d'une authentification basée sur une application cliente communiquant avec un service distant.
Nous utiliserons les connaissances acquises précédemment concernant les formulaires, les requêtes HTTP, les API, le JSON et la gestion de l'état.
Utilisateur
│
▼
Formulaire Flutter
│
▼
API d'authentification
│
├── Identification réussie
│
└── Identification refusée
│
▼
Session utilisateur
L'inscription :
L'inscription permet à un nouvel utilisateur de créer un compte.
Un formulaire d'inscription peut notamment demander :
-
un nom ou un prénom;
-
une adresse électronique;
-
un mot de passe;
-
la confirmation du mot de passe.
Les informations saisies sont ensuite transmises au service chargé de créer le compte.
Application Flutter
↓ POST
API d'inscription
↓
Création du compte
Avant d'envoyer les données, l'application peut effectuer des vérifications simples afin d'améliorer l'expérience utilisateur.
Ces vérifications ne remplacent cependant jamais les contrôles effectués côté serveur.
Validation du mot de passe :
Un mot de passe peut être soumis à différentes règles définies par le service utilisé.
Une application peut notamment vérifier la longueur minimale du mot de passe avant de l'envoyer.
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez saisir un mot de passe.";
}
if (value.length < 8) {
return "Le mot de passe doit contenir au moins 8 caractères.";
}
return null;
}
La connexion :
La connexion permet à un utilisateur disposant déjà d'un compte de s'identifier auprès du service.
Un formulaire de connexion contient généralement une adresse électronique et un mot de passe.
TextFormField(
decoration: const InputDecoration(
labelText: "Adresse électronique",
),
)
TextFormField(
obscureText: true,
decoration: const InputDecoration(
labelText: "Mot de passe",
),
)
Lorsque le formulaire est valide, l'application peut envoyer les informations au serveur.
final response = await http.post(
Uri.parse(
"https://example.com/login"
),
headers: {
"Content-Type": "application/json; charset=UTF-8",
},
body: jsonEncode({
"email": email,
"password": password,
}),
);
Le serveur reçoit alors les informations transmises et vérifie si elles correspondent à un compte valide.
En cas de succès, il peut retourner des informations permettant à l'application de conserver l'état de connexion de l'utilisateur.
La session utilisateur :
Après une authentification réussie, l'application doit savoir qu'un utilisateur est actuellement connecté.
On peut représenter les différents états de l'application de la manière suivante :
Application
│
├── Utilisateur non connecté
│ │
│ ▼
│ Connexion
│ │
│ ▼
└── Utilisateur connecté
La navigation de l'application peut alors dépendre de cet état.
Par exemple, un utilisateur non connecté peut être envoyé vers la page de connexion, tandis qu'un utilisateur connecté peut accéder à la page d'accueil.
Exemple d'état :
bool estConnecte = false;
Lorsque l'authentification réussit, cette valeur peut être modifiée dans le mécanisme de l'état utilisé par l'application.
Les jetons d'authentification :
De nombreux systèmes d'authentification utilisent un jeton permettant à l'application de prouver qu'elle possède une session authentifiée.
Un mécanisme courant consiste à transmettre un jeton dans l'en-tête Authorization.
headers: {
"Authorization": "Bearer $token",
}
Lorsqu'une requête protégée est envoyée, le serveur peut utiliser ce jeton pour déterminer si l'utilisateur possède l'autorisation nécessaire.
Application Flutter
│
│ Authorization: Bearer ...
▼
Serveur
│
▼
Vérification du jeton
│
┌────┴─────┐
▼ ▼
Autorisé Refusé
La structure exacte du système dépend du service d'authentification utilisé.
Il est important de ne pas considérer tous les jetons comme identiques : leur durée de validité, leur format et la manière de les renouveler dépendent notamment du système utilisé.
La déconnexion :
La déconnexion permet de mettre fin à la session de l'utilisateur sur l'appareil.
Selon l'architecture du service utilisé, cette opération peut notamment consister à supprimer les informations de session stockées localement et, si le service le prévoit, à informer le serveur.
Future<void> deconnexion() async {
// Suppression des informations de session locales.
}
Après la déconnexion, l'application doit remettre son état dans une situation correspondant à un utilisateur non authentifié.
Elle peut ensuite rediriger l'utilisateur vers la page de connexion.
Les erreurs d'authentification :
Une authentification peut échouer pour différentes raisons :
-
adresse électronique incorrecte;
-
mot de passe incorrect;
-
compte inexistant;
-
compte désactivé;
-
serveur indisponible;
-
problème de connexion Internet;
-
session expirée.
L'application doit distinguer autant que possible les problèmes techniques des problèmes liés aux informations fournies par l'utilisateur.
if (response.statusCode == 401) {
print("Les informations de connexion sont incorrectes");
}
Les messages présentés à l'utilisateur doivent rester suffisamment clairs sans révéler inutilement des informations sensibles.
À retenir :
Toutes les erreurs ne sont pas de même nature.
| Type | Exemple |
|---|---|
| Erreur de validation | Adresse électronique invalide |
| Erreur d'authentification | Identifiants incorrects |
| Erreur réseau | Impossible de contacter le serveur |
| Erreur serveur | Le serveur ne peut pas traiter la demande |
Sécurité :
L'authentification implique la manipulation de données sensibles. Il est donc indispensable de respecter plusieurs principes de sécurité.
Ne jamais écrire un mot de passe en dur :
Un mot de passe fourni par un utilisateur ne doit pas être intégré directement dans le code source.
Utiliser HTTPS :
Les communications avec un service d'authentification doivent utiliser une connexion sécurisée, généralement basée sur HTTPS.
Ne pas afficher les mots de passe :
Dans un champ destiné à un mot de passe, on utilisera généralement obscureText: true.
TextFormField(
obscureText: true,
decoration: const InputDecoration(
labelText: "Mot de passe",
),
);
Ne pas stocker inutilement les informations sensibles :
Une application ne doit pas conserver plus d'informations sensibles que nécessaire.
Lorsque des informations de session doivent être conservées localement, il convient d'utiliser un mécanismé de stockage adapté aux données concernées et aux exigences de sécurité de la plateforme.
Ne pas faire confiance au client :
Les vérifications effectuées dans l'application Flutter améliorent l'expérience utilisateur, mais elles ne constituent pas une mesure de sécurité suffisante.
Le serveur doit effectuer ses propres vérifications et contrôler les autorisations.
Organisation du code :
L'authentification ne devrait pas être entièrement placée dans le widget de la page de connexion.
Une organisation séparant les différentes responsabilités rend le code plus facile à comprendre et à tester.
lib/
├── models/
│ └── utilisateur.dart
│
├── services/
│ └── auth_service.dart
│
├── repositories/
│ └── auth_repository.dart
│
├── viewmodels/
│ └── auth_viewmodel.dart
│
└── screens/
├── connexion_page.dart
└── inscription_page.dart
On peut alors représenter la communication de la manière suivante :
ConnexionPage
↓
AuthViewModel
↓
AuthRepository
↓
AuthService
↓
API d'authentification
Cette organisation permet notamment de séparer l'interface graphique, la gestion de l'état et la communication avec le service d'authentification.
Flux complet d'une authentification :
Le fonctionnement général d'une connexion peut être résumé de la manière suivante :
Utilisateur
│
▼
Formulaire de connexion
│
▼
Validation locale
│
▼
ViewModel
│
▼
Repository
│
▼
Service HTTP
│
▼
API
│
▼
Vérification
│
┌───┴────┐
▼ ▼
Succès Erreur
│ │
▼ ▼
Session Message
│
▼
Application authentifiée
Ce flux permet de réutiliser les différentes notions vues précédemment.
Accessibilité des interfaces d'authentification :
Les pages d'inscription et de connexion doivent être accessibles aux différents utilisateurs.
Les champs doivent notamment être correctement identifiés et les erreurs de validation doivent être compréhensibles.
Le mot de passe ne doit pas être rendu visible automatiquement et l'utilisateur doit comprendre la fonction des commandes permettant éventuellement de l'afficher ou de le masquer.
Les boutons de connexion et d'inscription doivent également posséder des intitulés explicites.
Il faut également éviter de signaler une erreur uniquement par une couleur.
Les interfaces doivent pouvoir être utilisées avec le clavier et avec les technologies d'assistance disponibles sur les plateformes ciblées.
Les bases de données et le stockage des données :
Une application réelle doit pouvoir conserver des informations afin de les réutiliser ultérieurement.
Jusqu'à présent, nous avons principalement manipulé des données conservées en mémoire pendant l'exécution de l'application.
Ces données ne sont cependant pas nécessairement conservées lorsque l'application est fermée.
Pour conserver durablement des informations, nous devons utiliser un système de stockage.
Il existe plusieurs possibilités selon le type de données que l'application doit conserver.
Données
│
├── Mémoire
│
├── Stockage local
│
└── Stockage distant
Dans la suite du cours, nous allons apprendre à distinguer ces différentes solutions et à comprendre dans quelles situations elles peuvent être utilisées.
Mémoire, stockage local et stockage distant :
Les données en mémoire :
Une variable Dart conserve une valeur en mémoire pendant l'exécution du programme.
String nom = "Alice";
int age = 25;
Ces valeurs peuvent être utilisées par l'application mais elles ne constituent pas nécessairement un stockage permanent.
Le stockage local :
Le stockage local permet de conserver certaines données directement sur l'appareil de l'utilisateur.
Il peut notamment être utilisé pour :
-
des préférences;
-
des paramètres;
-
un cache;
-
certaines données disponibles hors connexion.
Le stockage distant :
Le stockage distant permet de conserver les données sur un serveur accessible par Internet.
Cette solution est notamment utile lorsque les données doivent pouvoir être consultées depuis plusieurs appareils ou par plusieurs utilisateurs autorisés.
Application Flutter
↓
API
↓
Serveur
↓
Base de données
Le stockage local :
Le stockage local est particulièrement utile pour conserver des informations qui n'ont pas nécessairement besoin d'être envoyées à un serveur.
Par exemple, une application peut mémoriser la langue choisie par l'utilisateur.
langue = "fr"
Elle peut également conserver une préférence concernant l'affichage des notifications.
notifications = true
Ces données peuvent être chargées automatiquement lorsque l'application démarre.
Les données locales ne sont pas toutes équivalentes :
Une simple préférence de configuration ne possède pas les mêmes exigences qu'une base de données complète ou qu'une information sensible.
Le choix du mécanisme de stockage dépend donc du type de donnée et de son niveau de sensibilité.
Les préférences locales :
Pour conserver de petites valeurs de configuration, une application peut utiliser un stockage de type clé-valeur.
Le principe consiste à associer une clé à une valeur.
"langue" → "fr"
"notifications" → true
"tailleTexte" → 18
Dans l'écosystème Flutter, le package shared_preferences permet notamment de conserver ce type de préférences.
Le package peut être ajouté avec :
flutter pub add shared_preferences
Il peut ensuite être utilisé pour enregistrer une valeur :
final preferences = await SharedPreferences.getInstance();
await preferences.setBool(
"notifications",
true,
);
La valeur peut ensuite être récupérée :
final notifications = preferences.getBool("notifications") ?? false;
Ce mécanisme est adapté aux préférences simples mais ne remplace pas une véritable base de données.
Les bases de données locales :
Une base de données locale permet de conserver des informations structurées directement sur l'appareil.
Elle peut être particulièrement utile lorsqu'une application doit manipuler un grand nombre de données ou fonctionner partiellement hors connexion.
Une base de données peut par exemple contenir une table représentant les utilisateurs.
| ID | Nom | |
|---|---|---|
| 1 | Alice | alice@example.com |
| 2 | Bob | bob@example.com |
Les lignes :
Chaque ligne représente généralement un élément enregistré dans la table.
Les colonnes :
Chaque colonne représente une propriété ou un type d'information.
La clé primaire :
Une clé primaire permet d'identifier de manière unique une ligne dans une table.
Dans l'exemple précédent, la colonne ID peut jouer ce rôle.
Les bases de données distantes :
Une base de données distante est située sur un serveur.
Une application Flutter ne devrait généralement pas se connecter directement à une base de données distante en exposant ses accès.
Elle communique généralement avec un serveur ou une API qui contrôle l'accès aux données.
Flutter
↓
API
↓
Serveur
↓
Base de données
Cette architecture permet notamment d'appliquer des règles d'autorisation avant de permettre l'accès aux données.
Elle permet également de centraliser certaines règles métier et de sécurité.
Le CRUD :
Les quatre opérations principales permettant de manipuler des données sont souvent regroupées sous l'acronyme CRUD.
| Opération | Signification |
|---|---|
| Create | Créer une donnée |
| Read | Lire une donnée |
| Update | Modifier une donnée |
| Delete | Supprimer une donnée |
Create :
Créer une donnée consiste à ajouter une nouvelle information au système de stockage.
Read :
Lire une donnée consiste à récupérer une information déjà enregistrée.
Update :
Modifier une donnée consiste à mettre à jour une information existante.
Delete :
Supprimer une donnée consiste à retirer une information du système de stockage.
Modélisation des données :
Avant de stocker les données d'une application, il faut déterminer leur structure.
Nous pouvons par exemple représenter un utilisateur avec une classe Dart.
class Utilisateur {
final int id;
final String nom;
final String email;
const Utilisateur({
required this.id,
required this.nom,
required this.email,
});
}
La classe devient alors le modèle utilisé par l'application pour représenter un utilisateur.
Conversion depuis le JSON :
factory Utilisateur.fromJson(
Map<String, dynamic> json
) {
return Utilisateur(
id: json["id"] as int,
nom: json["nom"] as String,
email: json["email"] as String,
);
}
Conversion vers le JSON :
Map<String, dynamic> toJson() {
return {
"id": id,
"nom": nom,
"email": email,
};
}
Cette séparation permet de distinguer la manière dont les données sont représentées dans le programme et la manière dont elles sont échangées avec un service externe.
Le Repository :
Dans une application structurée, l'interface ne devrait pas nécessairement accéder directement à la base de données.
Un Repository peut servir d'intermédiaire entre l'application et la source de données.
Interface
│
▼
ViewModel
│
▼
Repository
│
├── Base locale
│
└── API distante
Le repository peut choisir la source de données appropriée selon la situation.
Par exemple, il peut récupérer des données locales lorsque l'appareil n'est pas connecté et utiliser une API lorsqu'une connexion Internet est disponible.
La synchronisation des données :
Lorsqu'une application utilise à la fois des données locales et distantes, il peut être nécessaire de les synchroniser.
On peut par exemple imaginer le scénario suivant :
Modification locale
↓
Stockage local
↓
Connexion Internet
↓
Synchronisation
↓
Serveur
L'application peut alors transmettre au serveur les modifications effectuées localement.
La synchronisation peut devenir complexe lorsque plusieurs appareils modifient les mêmes données. Les règles permettant de résoudre les conflits doivent donc être définies par l'application et le serveur.
La sécurité des données :
Les données d'une application ne possèdent pas toutes le même niveau de sensibilité.
Une préférence d'affichage n'a pas les mêmes exigences qu'un jeton d'authentification ou qu'une information personnelle.
Il est donc nécessaire d'adapter le mécanisme de stockage au type de donnée.
Les données sensibles doivent être protégées contre les accès non autorisés et ne doivent pas être stockées inutilement.
Pour les données distantes, les règles d'accès doivent être vérifiées par le serveur et non uniquement par l'application Flutter.
Architecture complète des données :
Nous pouvons maintenant représenter l'organisation générale d'une application utilisant plusieurs sources de données.
Utilisateur
│
▼
Interface Flutter
│
▼
ViewModel
│
▼
Repository
│
┌───┴─────────┐
▼ ▼
Local Distant
│ │
▼ ▼
Base locale API
│
▼
Base de données
Cette organisation permet à l'interface de rester relativement indépendante de la manière dont les données sont réellement stockées.
Les images et les fichiers :
Les images et les fichiers sont présents dans de nombreuses applications.
Une application peut par exemple afficher des images fournies avec son interface, charger des images provenant d'un serveur ou permettre à l'utilisateur de sélectionner une photographie depuis son appareil.
Elle peut également permettre l'envoi de fichiers vers un serveur ou un espace de stockage distant.
Il est donc important de distinguer plusieurs situations :
-
une image intégrée à l'application;
-
une image située sur Internet;
-
une image sélectionnée par l'utilisateur;
-
un fichier choisi par l'utilisateur;
-
un fichier envoyé vers un serveur;
-
une image stockée durablement dans un service distant.
Le traitement d'une image dépend donc de son origine et de l'utilisation que l'application souhaite en faire.
Image
│
├── Asset local
│
├── URL distante
│
├── Caméra
│
└── Galerie
│
▼
Application
│
▼
Stockage distant
Les images locales :
Une image locale est une image présente dans les ressources de l'application ou disponible localement sur l'appareil.
Dans Flutter, une image fournie avec l'application peut notamment être affichée avec Image.asset(). La documentation Flutter décrit les assets comme des fichiers regroupés et déployés avec l'application.
Image.asset(
"assets/images/logo.png",
)
Le chemin indiqué correspond au chemin de la ressource dans le projet.
Organisation des images :
Il est possible d'organiser les images dans un dossier dédié :
mon_projet/
│
├── assets/
│ └── images/
│ ├── logo.png
│ ├── accueil.jpg
│ └── profil.png
│
└── lib/
└── main.dart
Les assets :
Les assets sont des fichiers fournis avec l'application.
Ils peuvent notamment correspondre à des images, des fichiers texte, des polices ou d'autres ressources.
Les ressources doivent être déclarées dans le fichier pubspec.yaml afin d'être intégrées au projet.
flutter:
assets:
- assets/images/
Il est ensuite possible d'utiliser l'image :
Image.asset(
"assets/images/logo.png",
)
La documentation Flutter décrit plusieurs possibilités pour déclarer des assets et gérer notamment les images adaptées aux différentes résolutions.
Déclarer une seule image :
flutter:
assets:
- assets/images/logo.png
Déclarer un dossier :
flutter:
assets:
- assets/images/
Après une modification du fichier pubspec.yaml, il peut être nécessaire de relancer ou de reconstruire l'application selon la modification effectuée.
Les images distantes :
Une image distante est une image située sur un serveur et accessible par une URL.
Flutter fournit notamment le constructeur Image.network() pour charger une image à partir d'une adresse Internet.
Image.network(
"https://example.com/image.jpg",
)
Cette image est récupérée depuis le réseau puis affichée dans l'interface.
Définir une taille :
Image.network(
"https://example.com/image.jpg",
width: 200,
height: 200,
)
Adapter le contenu :
La propriété fit permet notamment de contrôler la manière dont l'image est adaptée à l'espace disponible.
Image.network(
"https://example.com/image.jpg",
width: 200,
height: 200,
fit: BoxFit.cover,
)
La documentation Flutter utilise également BoxFit pour contrôler le redimensionnement et l'ajustement des images.
Chargement et placeholder :
Une image distante peut nécessiter un certain temps avant d'être affichée.
Il est donc possible d'afficher un contenu temporaire pendant son chargement.
Flutter propose notamment FadeInImage pour afficher une image progressivement avec une image temporaire.
FadeInImage.assetNetwork(
placeholder: "assets/images/loading.png",
image: "https://example.com/image.jpg",
)
Cette technique permet notamment d'éviter que l'interface reste vide pendant le chargement de l'image.
Sélectionner une image :
Une application mobile peut permettre à l'utilisateur de choisir une image dans sa galerie ou d'en prendre une nouvelle avec son appareil photo.
Le package image_picker, publié par l'équipe Flutter, permet notamment de sélectionner une image depuis une bibliothèque d'images ou de prendre une nouvelle photographie avec la caméra. Il prend en charge plusieurs plateformes, dont Android, iOS, Web, Windows, macOS et Linux selon les capacités de la plateforme.
Le package peut être ajouté avec :
flutter pub add image_picker
Importer le package :
import 'package:image_picker/image_picker.dart';
Créer un ImagePicker :
final ImagePicker picker = ImagePicker();
Utiliser la galerie :
La méthode pickImage() permet de demander à l'utilisateur de choisir une image.
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
);
La valeur retournée peut être null si l'utilisateur annule la sélection.
if (image != null) {
print(image.name);
}
Le type XFile provient de l'écosystème cross_file utilisé par plusieurs plugins multiplateformes.
Utiliser la caméra :
Le même mécanisme peut être utilisé pour demander à l'utilisateur de prendre une photographie avec la caméra.
final XFile? image = await picker.pickImage(
source: ImageSource.camera,
);
Le système d'exploitation peut demander à l'utilisateur d'autoriser l'accès à la caméra ou aux ressources nécessaires.
Les autorisations et les exigences de configuration peuvent dépendre de la plateforme ciblée.
Il faut donc consulter la documentation du plugin et de la plateforme avant de publier l'application.
Afficher une image sélectionnée :
Une fois l'image sélectionnée, il est possible de l'afficher dans l'interface.
FileImage(File(image.path))
Sur les plateformes disposant d'un accès classique au système de fichiers, il est possible d'utiliser un objet File.
import 'dart:io';
Image.file(
File(image.path),
)
Le code spécifique au système de fichiers doit toutefois être adapté lorsque l'application cible également le Web.
Une application Flutter multiplateforme doit tenir compte des différences entre les plateformes. Une solution reposant sur dart:io ne doit pas être utilisée sans vérification dans du code destiné au Web.
Sélectionner un fichier :
Une application peut également devoir sélectionner des fichiers qui ne sont pas nécessairement des images.
Le package file_picker fournit un sélecteur de fichiers natif et prend en charge notamment Android, iOS, Linux, macOS, Windows et Web.
Il peut être ajouté avec :
flutter pub add file_picker
Pour sélectionner un fichier :
final PlatformFile? file = await FilePicker.pickFile();
La sélection retourne null si l'utilisateur annule l'opération avec la version actuelle du package.
Le package permet également de filtrer les extensions et de sélectionner plusieurs fichiers.
Par exemple :
final PlatformFile? image = await FilePicker.pickFile(
type: FileType.custom,
allowedExtensions: [
"jpg",
"jpeg",
"png",
],
);
Lire une image sous forme d'octets :
Dans certaines situations, il est nécessaire de manipuler le contenu binaire du fichier plutôt que son chemin.
Un fichier image peut notamment être représenté sous la forme d'une suite d'octets.
final bytes = await image.readAsBytes();
Ces données peuvent ensuite être utilisées pour certaines opérations, notamment lors de l'envoi d'un fichier vers un service distant.
Le package file_picker permet de lire le contenu d'un fichier sous forme d'octets ou de flux.
Envoyer une image vers un serveur :
Lorsqu'un utilisateur sélectionne une image, l'application peut devoir envoyer celle-ci vers un serveur.
On parle alors de téléversement ou d'upload.
Le fonctionnement général peut être représenté ainsi :
Galerie / Caméra
↓
Application Flutter
↓ Upload
Serveur / Stockage
↓
URL de l'image
↓
Base de données
Une image ne doit donc pas nécessairement être stockée directement dans une table de base de données.
Une architecture fréquente consiste à conserver le fichier dans un service de stockage et à enregistrer dans la base de données les informations permettant de retrouver ce fichier.
Exemple :
Utilisateur
id : 25
nom : Alice
photoUrl : https://example.com/images/25.jpg
La base de données contient ici l'adresse permettant de retrouver l'image, tandis que le fichier image est conservé dans un stockage de fichiers.
Envoyer un fichier avec une requête multipart :
Les fichiers peuvent notamment être envoyés avec une requête HTTP multipart.
Le package http permet notamment de construire une requête MultipartRequest.
final request = http.MultipartRequest(
"POST",
Uri.parse("https://example.com/upload"),
);
request.files.add(
await http.MultipartFile.fromPath(
"image",
image.path,
),
);
final response = await request.send();
Cette approche permet notamment d'envoyer un fichier vers un serveur qui accepte les requêtes multipart.
La manière exacte de traiter le fichier dépend cependant du serveur ou du service de stockage utilisé.
Le stockage des images :
Il faut distinguer le stockage du fichier image et le stockage des informations qui décrivent cette image.
Image
│
▼
Stockage de fichiers
│
└── URL
│
▼
Base de données
│
├── utilisateurId
├── nom
├── URL
└── date
Cette séparation est pratique lorsqu'une application doit gérer un grand nombre d'images.
La base de données peut notamment conserver :
-
l'identifiant de l'image;
-
l'utilisateur propriétaire;
-
l'URL ou la référence du fichier;
-
le nom du fichier;
-
la date de création;
-
des informations complémentaires.
URL et métadonnées :
Lorsqu'une image est stockée sur un serveur, l'application doit généralement disposer d'une information permettant de la retrouver.
Cette information peut notamment être une URL.
https://example.com/images/profil-25.jpg
L'application peut ensuite utiliser cette adresse avec :
Image.network(
imageUrl,
)
Les métadonnées permettent de décrire le fichier sans avoir à conserver le fichier lui-même dans la base de données.
Gestion des erreurs :
Le chargement et l'envoi d'une image peuvent échouer.
Il faut donc prévoir notamment les situations suivantes :
-
l'utilisateur annule la sélection;
-
le fichier n'est plus disponible;
-
l'image est trop volumineuse;
-
le format n'est pas accepté;
-
le réseau est indisponible;
-
le serveur refuse le fichier;
-
l'URL de l'image n'est plus valide.
Une interface doit présenter un comportement compréhensible lorsque l'une de ces situations se produit.
Image indisponible :
Lorsqu'une image distante ne peut pas être chargée, une solution consiste à afficher une représentation alternative.
Image.network(
imageUrl,
errorBuilder: (
context,
error,
stackTrace,
) {
return const Icon(
Icons.broken_image,
);
},
)
Accessibilité des images :
Une image peut transmettre une information importante. Elle doit donc être rendue compréhensible par les utilisateurs qui ne peuvent pas nécessairement la percevoir visuellement.
Lorsqu'une image possède une information utile, il faut fournir une description adaptée au contexte.
Dans Flutter, le widget Semantics peut notamment être utilisé pour fournir des informations destinées aux technologies d'assistance.
Semantics(
label: "Photo de profil de Alice",
child: Image.network(
imageUrl,
),
)
Pour les images purement décoratives, il faut éviter d'ajouter une description inutile qui serait lue par un lecteur d'écran.
Il faut également veiller à ce que les informations importantes ne soient pas communiquées uniquement par la couleur ou par une image.
Les boutons contenant une image :
Lorsqu'une image ou une icône représente une action, son rôle doit être compréhensible par l'utilisateur.
Un bouton permettant de modifier une photographie doit par exemple être identifiable comme une commande de modification et pas uniquement comme une image.
En résumé, un bouton contenant uniquement une icône peut être difficile à comprendre avec un lecteur d'écran si son rôle n'est pas correctement annoncé. Par exemple :
IconButton(
onPressed: supprimer,
icon: const Icon(
Icons.delete,
),
)
L'icône permet de comprendre visuellement qu'il s'agit probablement d'une suppression, mais une information supplémentaire peut être nécessaire pour les technologies d'assistance.
Une solution consiste à utiliser une information sémantique explicite :
Semantics(
label: "Supprimer l'utilisateur",
button: true,
child: IconButton(
onPressed: supprimer,
icon: const Icon(
Icons.delete,
),
),
)
Le lecteur d'écran peut alors annoncer l'action associée au bouton.
L'accessibilité dans Flutter :
L'accessibilité consiste à concevoir une application pouvant être utilisée par le plus grand nombre de personnes, y compris les utilisateurs ayant des limitations visuelles, auditives, motrices ou cognitives.
Elle ne doit pas être considérée comme une fonctionnalité supplémentaire ajoutée à la fin du développement. Elle doit être prise en compte dès la conception de l'interface.
Dans une application Flutter, l'accessibilité concerne notamment la structure des widgets, les textes, les éléments interactifs, la navigation, les contrastes, les informations sonores et l'utilisation des technologies d'assistance.
Les technologies d'assistance :
Les technologies d'assistance permettent à certains utilisateurs d'interagir avec une application autrement qu'en utilisant uniquement sa présentation visuelle.
Parmi celles-ci, on trouve notamment les lecteurs d'écran.
Sur Android, le lecteur d'écran le plus couramment utilisé est TalkBack.
Sur les appareils Apple, on utilise notamment VoiceOver.
Sur ordinateur, plusieurs lecteurs d'écran existent également, comme NVDA ou JAWS sous Windows.
Une application accessible doit être testée avec les technologies d'assistance pertinentes pour les plateformes ciblées.
Les informations textuelles :
Les informations importantes doivent être transmises sous forme de texte lorsque cela est pertinent.
Il faut notamment éviter de communiquer une information uniquement à l'aide d'une couleur, d'une animation, d'une image ou d'un son.
Par exemple, un message d'erreur ne devrait pas uniquement rendre un champ rouge.
Text(
"L'adresse électronique est invalide.",
)
L'utilisateur dispose ainsi d'une information explicite sur le problème rencontré.
Les images :
Une image informative doit pouvoir être comprise par un utilisateur qui ne la perçoit pas visuellement.
Lorsqu'une description est nécessaire, le widget Semantics peut notamment être utilisé.
Semantics(
label: "Photo de profil de Alice",
child: Image.network(
imageUrl,
),
)
La description doit être concise et donner l'information réellement utile. Il faut éviter les descriptions inutiles ou trop longues.
À l'inverse, une image purement décorative ne doit pas fournir une description inutile qui serait annoncée par un lecteur d'écran.
Par exemple, une image servant uniquement à décorer une carte peut être masquée des informations sémantiques :
Semantics(
excludeSemantics: true,
child: Image.asset(
"assets/images/decoration.png",
),
)
L'objectif est d'éviter que le lecteur d'écran annonce des informations qui ne sont pas nécessaires à la compréhension de l'interface.
Les boutons :
Les boutons doivent permettre à l'utilisateur de comprendre clairement l'action qui sera effectuée.
Un intitulé explicite est généralement préférable à une information reposant uniquement sur l'apparence du bouton.
ElevatedButton(
onPressed: enregistrer,
child: const Text(
"Enregistrer",
),
)
Lorsqu'un bouton utilise uniquement une icône, il faut veiller à ce que son rôle reste compréhensible pour les technologies d'assistance.
Un bouton contenant une icône représentant une corbeille doit par exemple être identifiable comme une commande de suppression.
Le widget Tooltip :
Le widget Tooltip permet d'afficher une information complémentaire lorsqu'un utilisateur survole ou interagit avec certains éléments.
Tooltip(
message: "Supprimer l'utilisateur",
child: IconButton(
onPressed: supprimer,
icon: const Icon(
Icons.delete,
),
),
)
Un Tooltip peut notamment aider à comprendre la fonction d'un bouton représenté uniquement par une icône.
Le contraste :
Le contraste entre le texte et son arrière-plan doit être suffisant pour permettre une lecture confortable.
Il faut également éviter d'utiliser uniquement une couleur pour distinguer plusieurs états.
Par exemple, une erreur ne devrait pas être indiquée uniquement en rouge.
Il est préférable d'associer la couleur à un texte ou à un autre indicateur explicite.
La taille du texte :
Les utilisateurs doivent pouvoir agrandir le texte lorsque leur appareil ou leur système d'exploitation propose cette fonctionnalité.
Une interface ne doit donc pas supposer que le texte sera toujours affiché avec une taille fixe.
Il faut tester l'application avec différentes tailles de texte afin de vérifier que les informations restent accessibles et que les éléments importants ne disparaissent pas.
En résumé, il faut éviter de concevoir une interface dans laquelle un texte devient inutilisable dès qu'il est agrandi.
Padding(
padding: const EdgeInsets.all(16),
child: Text(
"Bienvenue",
style: Theme.of(context).textTheme.headlineMedium,
),
)
L'utilisation des styles du thème facilite notamment l'adaptation globale de l'apparence de l'application.
La navigation au clavier :
Sur ordinateur, certains utilisateurs peuvent naviguer principalement à l'aide du clavier.
Les éléments interactifs doivent donc pouvoir recevoir le focus et être parcourus dans un ordre logique.
Champ prénom
↓
Champ nom
↓
Champ email
↓
Bouton Enregistrer
Un ordre de navigation incohérent peut rendre une interface difficile, voire impossible, à utiliser au clavier.
Exemple : zone d'interaction :
Un élément interactif doit disposer d'une zone suffisamment grande pour être facilement utilisé sur un écran tactile.
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 48,
minHeight: 48,
),
child: IconButton(
onPressed: supprimer,
icon: const Icon(
Icons.delete,
),
),
)
La taille visuelle de l'icône et la taille de la zone interactive ne doivent pas nécessairement être identiques.
Le focus :
Le FocusNode permet notamment de contrôler le focus d'un élément interactif dans Flutter.
Le focus indique notamment quel élément interactif est actuellement sélectionné pour l'interaction au clavier.
Un utilisateur peut par exemple utiliser la touche Tab pour passer d'un champ à un autre.
Lorsqu'une nouvelle page ou un nouveau composant apparaît, il faut vérifier que le focus reste cohérent.
Cette notion devient particulièrement importante pour les formulaires, les dialogues et les interfaces comportant de nombreux éléments interactifs.
Créer un FocusNode :
Pour contrôler directement le focus d'un champ, nous pouvons créer un objet FocusNode.
final emailFocusNode = FocusNode();
Le FocusNode est un objet qui doit avoir une durée de vie suffisamment longue. Il ne faut donc pas en créer un nouveau à chaque reconstruction du widget.
Lorsqu'un StatefulWidget possède un FocusNode, celui-ci peut être créé dans initState() et libéré dans dispose().
class FormulairePage extends StatefulWidget {
const FormulairePage({super.key});
@override
State<FormulairePage> createState() {
return _FormulairePageState();
}
}
class _FormulairePageState extends State<FormulairePage> {
final emailFocusNode = FocusNode();
@override
void dispose() {
emailFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
focusNode: emailFocusNode,
);
}
}
La documentation Flutter précise que les FocusNode sont des objets persistants et que leur cycle de vie doit être géré par l'objet qui les possède. Ils doivent notamment être libérés avec dispose() lorsqu'ils ne sont plus utilisés.
Donner le focus à un champ :
La méthode requestFocus() permet de demander le focus d'un élément.
emailFocusNode.requestFocus();
On peut par exemple donner le focus à un champ lorsqu'un bouton est activé.
ElevatedButton(
onPressed: () {
emailFocusNode.requestFocus();
},
child: const Text(
"Saisir l'email",
),
)
Flutter présente également cette technique dans sa documentation consacrée au focus des champs de texte.
Passer au champ suivant :
Un formulaire peut contenir plusieurs champs. Il est alors possible de déplacer le focus vers le champ suivant.
final nomFocusNode = FocusNode();
final emailFocusNode = FocusNode();
Chaque champ peut recevoir son propre FocusNode.
TextField(
focusNode: nomFocusNode,
)
TextField(
focusNode: emailFocusNode,
)
Le focus peut ensuite être déplacé :
emailFocusNode.requestFocus();
En résumé, dans un formulaire, il est possible d'organiser le déplacement du focus afin que l'utilisateur puisse passer logiquement d'un champ au suivant.
final nomFocusNode = FocusNode();
final emailFocusNode = FocusNode();
TextField(
focusNode: nomFocusNode,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) {
emailFocusNode.requestFocus();
},
decoration: const InputDecoration(
labelText: "Nom",
),
)
TextField(
focusNode: emailFocusNode,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: "Adresse électronique",
),
)
Le champ "Nom" permet ici de passer directement au champ "Adresse électronique" lorsque l'utilisateur termine sa saisie.
Le focus automatique :
Lorsqu'il est pertinent de placer immédiatement le curseur dans un champ, il est possible d'utiliser autofocus.
TextField(
autofocus: true,
)
Cette possibilité doit cependant être utilisée avec discernement : déplacer automatiquement le focus peut être perturbant si l'utilisateur s'attend à rester sur l'élément qu'il vient de sélectionner.
Focus et accessibilité :
Une gestion correcte du focus permet notamment de rendre les formulaires plus faciles à parcourir au clavier.
Elle est également importante lorsqu'une erreur est détectée et que l'utilisateur doit être orienté vers le champ concerné.
Par exemple, après validation d'un formulaire, l'application peut déplacer le focus vers le premier champ incorrect.
if (!formKey.currentState!.validate()) {
emailFocusNode.requestFocus();
}
Le focus est alors placé sur le champ qui doit être corrigé.
Cette technique peut améliorer la navigation au clavier et faciliter l'utilisation du formulaire avec certaines technologies d'assistance.
Attention : ne pas créer un FocusNode dans build() :
Il ne faut pas créer un nouveau FocusNode à chaque appel de la méthode build().
La méthode build() peut être appelée plusieurs fois. Créer un nouveau FocusNode à chaque reconstruction peut donc faire perdre le focus et empêcher une gestion correcte de son cycle de vie.
Le FocusNode doit plutôt être conservé comme propriété du StatefulWidget et libéré dans dispose().
FocusScopeNode :
Un FocusScopeNode permet de gérer le focus au sein d'une zone particulière de l'interface.
Cette notion peut notamment être utile dans les formulaires et les interfaces comportant plusieurs groupes de commandes.
FocusScope.of(context).nextFocus();
Cette méthode permet notamment de demander au système de déplacer le focus vers l'élément focusable suivant.
FocusTraversalGroup :
Le widget FocusTraversalGroup permet de regrouper des éléments qui participent à la navigation du focus.
Il peut être utile dans les interfaces complexes afin de conserver une navigation au clavier cohérente.
FocusTraversalGroup(
child: Column(
children: [
TextField(),
TextField(),
ElevatedButton(
onPressed: enregistrer,
child: const Text("Enregistrer"),
),
],
),
)
À retenir :
Le FocusNode permet de :
-
identifier un élément focusable;
-
demander le focus;
-
détecter les changements de focus;
-
gérer la navigation au clavier.
Le FocusNode doit être considéré comme un objet persistant et son cycle de vie doit être correctement géré.
Les utilisateurs sourds ou malentendants :
Une information importante ne doit pas être transmise uniquement par un signal sonore.
Lorsqu'une notification sonore indique un événement important, une alternative visuelle ou textuelle doit également être proposée.
Par exemple, une application de messagerie peut signaler l'arrivée d'un nouveau message par un son, mais également afficher une notification ou mettre à jour une information textuelle.
Les utilisateurs malvoyants :
Les utilisateurs malvoyants peuvent notamment utiliser un lecteur d'écran ou modifier l'agrandissement et la taille du texte de leur appareil.
Il est donc important que l'application ne repose pas uniquement sur des indications visuelles.
Les titres, boutons, images informatives et messages importants doivent posséder une structure compréhensible par les technologies d'assistance.
Les éléments interactifs suffisamment grands :
Un élément interactif très petit peut être difficile à utiliser, notamment sur un écran tactile.
Il faut donc prévoir une zone d'interaction suffisamment grande et éviter de placer plusieurs commandes très proches les unes des autres.
Cette règle est particulièrement importante pour les boutons, les icônes et les commandes utilisées sur smartphone.
Les formulaires accessibles :
Les formulaires doivent indiquer clairement le rôle de chaque champ.
TextFormField(
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: "Adresse électronique",
hintText: "exemple@email.com",
errorText: "L'adresse électronique est invalide.",
),
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez saisir votre adresse électronique.";
}
return null;
}
)
Le labelText permet d'identifier le champ, tandis que le message retourné par validator permet d'expliquer l'erreur.
Lorsqu'une erreur est détectée, l'utilisateur doit pouvoir comprendre ce qui doit être corrigé.
Veuillez saisir une adresse électronique valide.
Les erreurs doivent également être associées au champ concerné et ne pas dépendre uniquement d'une couleur.
Par exemple, afficher uniquement un champ avec une bordure rouge peut être insuffisant :
TextField(
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.red,
),
),
),
)
Il est préférable d'associer la couleur à un message explicite :
TextFormField(
decoration: const InputDecoration(
labelText: "Adresse électronique",
),
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez saisir une adresse électronique.";
}
return null;
}
)
L'utilisateur dispose ainsi d'une information textuelle expliquant clairement le problème.
Les animations :
Les animations peuvent améliorer l'expérience utilisateur, mais elles ne doivent pas rendre l'application difficile à utiliser.
Une interface contenant beaucoup d'animations peut être particulièrement gênante pour certains utilisateurs.
Les animations doivent donc rester utiles et ne pas empêcher l'accès à l'information ou aux commandes.
Certaines personnes peuvent préférer limiter les animations de l'interface.
L'application peut tenir compte des préférences d'accessibilité du système afin d'éviter certaines animations lorsqu'elles ne sont nécessaires à la compréhension de l'interface.
Accessibilité et responsive design :
Une application multiplateforme doit également être capable de s'adapter à différentes tailles d'écran.
Une interface conçue pour un smartphone ne doit pas nécessairement être affichée exactement de la même manière sur un ordinateur.
Il faut notamment vérifier :
-
la lisibilité du texte;
-
la disposition des éléments;
-
la taille des boutons;
-
la navigation au clavier;
-
l'utilisation avec une technologie d'assistance.
Tester l'accessibilité :
L'accessibilité doit être testée directement sur l'application.
Un développeur ne doit pas supposer qu'une interface est accessible simplement parce qu'elle semble correcte visuellement.
Il faut notamment effectuer des tests avec :
-
un lecteur d'écran;
-
le clavier;
-
différentes tailles de texte;
-
des paramètres de contraste adaptés.
Principe général :
Conception
↓
Développement
↓
Tests d'accessibilité
↓
Corrections
↓
Nouveaux tests
↓
Publication
L'accessibilité doit donc être considérée comme une partie intégrante du cycle de développement.
Audit d'accessibilité :
Avant de considérer une interface comme accessible, il est nécessaire de la tester dans différentes situations.
| Test | Question à vérifier |
|---|---|
| Lecteur d'écran | Les éléments sont-ils correctement annoncés ? |
| Clavier | Tous les éléments interactifs peuvent-ils recevoir le focus ? |
| Ordre du focus | L'ordre de navigation est-il logique ? |
| Taille du texte | L'interface reste-t-ele utilisable lorsque le texte est agrandi ? |
| Contraste | Les informations restent-elles lisibles ? |
| Couleur | Une information importante dépend-elle uniquement d'une couleur ? |
Le déploiement et la publication d'une application :
Jusqu'à présent, nous avons appris à créer, tester et organiser une application Flutter.
Une application destinée aux utilisateurs doit cependant être transformée en une version pouvant être installée ou consultée sur la plateforme concernée.
Cette étape est appelée déploiement.
Le développement consiste principalement à créer l'application, tandis que le déploiement consiste à préparer une version destinée aux utilisateurs.
Selon la plateforme, la procédure est différente.
| Plateforme | Distribution principale |
|---|---|
| Android | Google Play |
| iOS | App Store |
| Web | Hébergement Web |
| Windows | Distribution d'une application Windows |
| macOS | Distribution d'une application macOS |
| Linux | Distribution d'une application Linux |
Dans ce cours, nous nous concentrons principalement sur Android, iOS et le Web.
Préparer l'application :
Avant de publier une application, il faut vérifier plusieurs éléments.
Une application destinée aux utilisateurs ne doit pas être considérée comme prête simplement parce qu'elle fonctionne sur l'ordinateur du développeur.
Il faut notamment vérifier :
-
le nom de l'application;
-
la version;
-
le numéro de build;
-
l'icône;
-
les informations de l'application;
-
les autorisations utilisées;
-
les informations relatives à la confidentialité et aux données;
-
les tests;
-
la configuration spécifique à chaque plateforme.
Il est également nécessaire de supprimer les informations de test qui ne doivent pas être présentes dans la version destinée aux utilisateurs.
L'icône de l'application :
Lorsqu'une application est installée sur un smartphone ou sur un ordinateur, elle possède généralement une icône permettant de l'identifier.
Cette icône est différente du favicon utilisé par un site Web.
Pour une application mobile, plusieurs versions de l'icône peuvent être nécessaires afin de s'adapter aux exigences de la plateforme et aux différentes résolutions d'écran.
Android :
Un projet Flutter contient initialement des icônes de remplacement pour l'application Android.
Ces ressources peuvent être remplacées par les icônes de l'application finale.
Il existe également des packages permettant d'automatiser la génération des icônes pour différentes plateformes.
Une solution courante consiste notamment à utiliser un package de génération d'icônes afin d'éviter de préparer manuellement toutes les ressources nécessaires.
iOS :
Pour iOS, l'icône de l'application se configure notamment dans les ressources Assets.xcassets du projet Xcode.
La documentation Flutter indique que le projet iOS créé par Flutter contient un jeu d'icônes de remplacement qui doit être remplacé par les icônes de l'application finale.
Préparer une bonne icône :
Une icône doit notamment rester identifiable lorsqu'elle est affichée dans une petite taille.
Il faut également respecter les recommandations graphiques de la plateforme ciblée.
L'icône doit être fournie dans une qualité suffisante et ne pas contenir des éléments trop difficiles à distinguer lorsqu'elle est réduite.
La version et le numéro de build :
Une application doit posséder une version permettant d'identifier la version distribuée.
Dans Flutter, le fichier pubspec.yaml peut notamment contenir :
version: 1.0.0+1
La partie 1.0.0 correspond à la version présentée aux utilisateurs.
La partie 1 correspond au numéro de build.
Chaque nouvelle version publiée doit être gérée avec attention afin de permettre de distinguer les différents builds.
La documentation Flutter utilise cette syntaxe pour la configuration des versions Android et iOS.
Exemple :
version: 1.2.0+5
Dans cet exemple :
-
1.2.0 correspond au numéro de version;
-
5 correspond au numéro de build.
Installer une application Android :
Pendant le développement, une application Flutter peut être installée directement sur un appareil Android de test.
Après avoir connecté l'appareil et activé les options nécessaires au développement, il est possible d'utiliser :
flutter devices
Cette commande permet de vérifier que Flutter détecte correctement l'appareil.
L'application peut ensuite être lancée avec :
flutter run
Cette méthode est particulièrement utile pendant le développement et les tests.
Créer un APK :
FLutter permet également de construire un fichier APK.
flutter build apk
L'APK est un paquet d'application Android qui peut notamment être utilisé pour des installations ou des tests en dehors de Google Play.
Créer un App Bundle :
Pour une publication sur Google Play, Flutter permet de générer un Android App Bundle :
flutter build appbundle
Le fichier produit possède l'extension .aab.
Google Play utilise le format Android App Bundle pour générer et distribuer les APK adaptés aux appareils des utilisateurs.La documentation Flutter recommande donc l'App Bundle pour publier une application Android sur Google Play.
Publier sur Google Play :
Pour rendre une application Android disponible au public sur Google Play, il faut utiliser Google Play Console.
La publication ne consiste donc pas simplement à envoyer un fichier APK.
Il faut préparer une fiche d'application et fournir les informations demandées par Google.
Le compte développeur :
La publication nécessite un compte développeur Google Play.
Google demande notamment différentes informations permettant d'identifier et de vérifier le développeur. Les exigences diffèrent notamment selon qu'il s'agit d'un compte personnel ou d'une organisation.
Créer l'application :
Une fois le compte configuré, l'application peut être créée dans Google Play Console.
Il faut notamment définir :
-
le nom de l'application;
-
la langue par défaut;
-
la catégorie;
- les informations de la fiche;
-
les informations relatives au contenu et aux données;
-
les éléments graphiques.
Importer le fichier AAB :
Après avoir construit l'application avec flutter build appbundle, le fichier .aab peut être envoyé dans Google Play Console.
Google effectue ensuite différents contrôles avant que l'application puisse être publiée.
Informations concernant les données :
Google Play demande notamment de déclarer certaines informations concernant les données collectées ou partagées par l'application.
Le développeur doit fournir des informations exactes correspondant au comportement réel de l'application.
Exigences techniques :
Les exigences de Google Play évoluent dans le temps.
Depuis le 31 août 2026, les nouvelles applications et les mises à jour doivent cibler Android 16, API level 36 ou une version supérieure, sauf certaines catégories de produits Android soumises à d'autres exigences.
Les exigences des magasins d'applications peuvent changer. Il faut donc toujours vérifier la documentation officielle de Google Play avant une publication réelle.
Publication :
Une fois toutes les informations complétées et les contrôles effectués, une version peut être proposée à la publication.
Google Play dispose également de mécanismes de test et de diffusion permettant de limiter progressivement la distribution d'une nouvelle version.
Publier sur l'App Store :
La publication d'une application Flutter sur les appareils Apple suit une procédure différente de celle utilisée pour Android.
Flutter indique qu'un ordinateur fonctionnant sous macOS et équipé de Xcode est nécessaire pour construire et publier une application iOS. Il faut également être inscrit à l'Apple Developer Program pour publier sur l'App Store.
Le Bundle ID :
Chaque application iOS possède un identifiant unique appelé Bundle ID.
Cet identifiant doit être enregistré dans le compte Apple Developer.
Il permet notamment d'identifier l'application auprès des services Apple.
App Store Connect :
App Store Connect est le service utilisé pour gérer une application destinée à l'App Store.
Il permet notamment de :
-
créer la fiche de l'application;
-
ajouter les captures d'écran;
-
définir certaines informations de distribution;
-
gérer les versions;
-
organiser les tests avec TestFlight.
La documentation Flutter décrit App Store Connect comme l'outil permettant notamment de gérer le cycle de vie de l'application distribuée sur l'App Store et via TestFlight.
Créer le build iOS :
Une version destinée à la distribution doit être construite en mode release.
La commande Flutter suivante permet de créer l'archive et le fichier IPA :
flutter build ipa
Flutter génère notamment une archive .xcarchive ainsi qu'un fichier .ipa.
Tester avec TestFlight :
TestFlight permet de distribuer une version de test à des personnes chargées de tester l'application avant sa publication publique.
Il peut être utilisé pour effectuer des tests internes ou externes avant la mise à disposition sur l'App Store.
La documentation Flutter décrit notamment la possibilité d'utiliser TestFlight avant de publier définitivement l'application.
Soumettre l'application :
Lorsque l'application est prête, elle peut être soumise à la procédure de vérification d'Apple.
Apple examine alors l'application avant sa publication selon ses règles de validation.
Une application approuvée peut ensuite être rendue disponible sur l'App Store.
L'icône iOS :
L'icône de l'application iOS se configure notamment dans Assets.xcassets du projet Xcode.
Il faut remplacer les icônes de remplacement générées lors de la création du projet par celles de l'application finale.
Publier l'application sur le Web :
Flutter permet également de transformer une application en version Web.
Cette version peut ensuite être hébergée sur un serveur ou un service d'hébergement compatible.
Construire la version Web :
La commande suivante permet de construire l'application Web :
flutter build web
Flutter produit alors les fichiers nécessaires à la publication dans le dossier build/web.
Architecture :
Application Flutter
↓
flutter build web
↓
build/web/
↓
Hébergement Web
↓
Navigateur
Héberger l'application :
Les fichiers produits peuvent être envoyés vers un service d'hébergement Web.
Flutter cite notamment des solutions telles que Firebase Hosting, GitHub Pages et Google Cloud Hosting.
Une fois les fichiers hébergés, les utilisateurs peuvent accéder à l'application depuis leur navigateur avec une adresse Web.
Créer une URL :
Un hébergement peut être associé à un nom de domaine.
https://www.monapplication.be
L'utilisateur peut alors accéder directement à l'application sans installer un paquet comme sur Android ou iOS.
Application Web progressive :
Une application Web peut également proposer certaines caractéristiques proches d'une application installée selon la configuration et les fonctionnalités utilisées.
Il faut cependant distinguer une application Web accessible depuis un navigateur d'une application publiée dans un magasin d'applications mobile.
Publier une application sur ordinateur :
Flutter permet également de construire des applications destinées aux systèmes de bureau.
Les plateformes de bureau prises en charge comprennent notamment Windows, macOS et Linux.
La procédure dépend de la plateforme et de son système de distribution.
Windows :
flutter build windows
macOS :
flutter build macos
Linux :
flutter build linux
Les fichiers générés peuvent ensuite être distribués selon les méthodes adaptées au système d'exploitation.
Installation et distribution :
Il est important de distinguer plusieurs façons de distribuer une application.
Mettre à jour l'application :
Une application publiée devra généralement être mise à jour au cours de sa durée de vie.
Une mise à jour peut permettre :
-
de corriger des erreurs;
-
d'améliorer l'accessibilité;
-
d'ajouter des fonctionnalités;
-
d'améliorer les performances;
-
de répondre aux nouvelles exigences plateformes.
Chaque nouvelle version doit être identifiée correctement à l'aide de sa version et de son numéro de build.
Cycle de publication :
Développement
│
▼
Tests
│
▼
Version Release
│
▼
Build
│
▼
Publication
│
▼
Tests utilisateurs
│
▼
Mise à jour
│
└──────────► Nouvelle version