Projet Pratique : Création d'une Application Flutter Complète
Bienvenue dans cette leçon cruciale du cours "Maîtrisez Flutter : Créez des Applications Mobiles Multiplateformes Performantes" ! Jusqu'à présent, vous avez exploré les fondations de Flutter, les widgets, la gestion d'état et la navigation. Il est maintenant temps de synthétiser toutes ces connaissances en un projet concret : la création d'une application Flutter complète, de la conception à la persistance des données.
Cette leçon vous guidera pas à pas dans la construction d'une application simple mais fonctionnelle, en vous exposant aux défis et aux solutions courantes rencontrés dans le développement réel.
1. Introduction : Pourquoi un Projet Pratique ?
L'apprentissage théorique est essentiel, mais c'est par la pratique que les concepts s'ancrent véritablement. Construire une application de A à Z vous permettra de :
- Consolider vos acquis : Mettre en œuvre les widgets, la gestion d'état, la navigation, et la gestion des données.
- Comprendre l'architecture : Appréhender comment organiser votre code pour une application maintenable et évolutive.
- Développer des réflexes : Apprendre à décomposer un problème complexe en tâches plus petites et gérables.
- Faire face aux défis réels : Gérer les interactions utilisateur, la persistance des données, et les mises à jour de l'interface.
Pour ce projet, nous allons créer une application de Gestion de Tâches Simplifiée (un "Todo List App"). Cette application permettra aux utilisateurs d'ajouter, de marquer comme complétées, et de supprimer des tâches, tout en persistant ces données localement.
2. Conception et Architecture de l'Application
Avant de plonger dans le code, une planification minimale est cruciale.
2.1. Fonctionnalités Cibles
Notre application de gestion de tâches offrira les fonctionnalités suivantes :
- Afficher une liste de toutes les tâches.
- Ajouter une nouvelle tâche (avec un titre et une description).
- Marquer une tâche comme complétée/non complétée.
- Supprimer une tâche.
- Persister les tâches localement pour qu'elles survivent aux redémarrages de l'application.
2.2. Choix Technologiques Clés
Pour ce projet, nous allons utiliser :
- Flutter SDK : Bien sûr !
- Provider : Pour la gestion d'état. C'est une solution simple, puissante et très populaire pour les applications de petite à moyenne taille.
shared_preferences: Pour la persistance locale des données. C'est une méthode simple pour stocker de petites quantités de données clés-valeurs. Pour des applications plus complexes,sqflite(base de données SQLite) ou des solutions NoSQL commeHiveouIsarseraient plus appropriées.
2.3. Structure du Projet
Une structure de dossiers claire est essentielle pour la maintenabilité. Voici une structure recommandée pour notre projet :
.
├── lib/
│ ├── main.dart # Point d'entrée de l'application
│ ├── models/ # Définitions de nos modèles de données
│ │ └── task.dart
│ ├── providers/ # Logique de gestion d'état (Provider ChangeNotifiers)
│ │ └── task_provider.dart
│ ├── screens/ # Pages principales de l'application
│ │ ├── task_list_screen.dart
│ │ └── task_detail_screen.dart # Pour ajouter/modifier une tâche
│ └── widgets/ # Widgets réutilisables (composants UI)
│ └── task_tile.dart
├── pubspec.yaml # Dépendances et métadonnées du projet
└── README.md
3. Implémentation Pas à Pas
3.1. Initialisation du Projet et Dépendances
Commencez par créer un nouveau projet Flutter :
flutter create todo_app
cd todo_app
Ensuite, ouvrez le fichier pubspec.yaml et ajoutez les dépendances provider et shared_preferences :
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
provider: ^6.0.5 # Pour la gestion d'état
shared_preferences: ^2.2.2 # Pour la persistance locale
Après avoir modifié pubspec.yaml, exécutez flutter pub get dans votre terminal pour télécharger les paquets.
3.2. Définition du Modèle de Données (Task)
Créez le fichier lib/models/task.dart. Ce fichier définira la structure de nos objets Task.
// lib/models/task.dart
class Task {
String id;
String title;
String description;
bool isCompleted;
Task({
required this.id,
required this.title,
this.description = '',
this.isCompleted = false,
});
// Méthode pour convertir un objet Task en Map (pour shared_preferences)
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'isCompleted': isCompleted,
};
}
// Méthode pour créer un objet Task à partir d'une Map
factory Task.fromJson(Map<String, dynamic> json) {
return Task(
id: json['id'],
title: json['title'],
description: json['description'] ?? '', // Gérer les descriptions nulles
isCompleted: json['isCompleted'] ?? false, // Gérer les booléens nuls
);
}
// Méthode pour copier une tâche (utile pour les mises à jour immuables)
Task copyWith({
String? id,
String? title,
String? description,
bool? isCompleted,
}) {
return Task(
id: id ?? this.id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
);
}
}
TaskClass : Représente une seule tâche avec ses propriétés (id,title,description,isCompleted).toJson()etfromJson(): Ces méthodes sont cruciales pour la persistance.shared_preferencesne peut stocker que des types primitifs ou des chaînes. Nous allons convertir nos objetsTasken chaînes JSON pour les sauvegarder.copyWith(): Une bonne pratique pour la gestion d'état est de traiter les objets comme immuables.copyWithpermet de créer une nouvelle instance deTaskavec des propriétés modifiées, plutôt que de modifier l'instance existante.
3.3. Gestionnaire d'État (TaskProvider)
Créez le fichier lib/providers/task_provider.dart. Ce ChangeNotifier gérera la liste de nos tâches et les interactions avec les données.
// lib/providers/task_provider.dart
import 'dart:convert'; // Pour encoder/décoder en JSON
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/task.dart';
class TaskProvider with ChangeNotifier {
List<Task> _tasks = []; // Liste privée des tâches
static const String _tasksKey = 'tasks'; // Clé pour shared_preferences
// Getter public pour accéder à la liste des tâches (en lecture seule)
List<Task> get tasks => List.unmodifiable(_tasks);
// Constructeur : charge les tâches au démarrage du provider
TaskProvider() {
_loadTasks();
}
// Charge les tâches depuis shared_preferences
Future<void> _loadTasks() async {
final prefs = await SharedPreferences.getInstance();
final String? tasksJson = prefs.getString(_tasksKey);
if (tasksJson != null) {
// Décode la chaîne JSON en liste de Maps
final List<dynamic> taskMaps = json.decode(tasksJson);
// Convertit chaque Map en objet Task et met à jour la liste
_tasks = taskMaps.map((map) => Task.fromJson(map)).toList();
notifyListeners(); // Informe les consommateurs que les données ont changé
}
}
// Sauvegarde les tâches dans shared_preferences
Future<void> _saveTasks() async {
final prefs = await SharedPreferences.getInstance();
// Convertit chaque objet Task en Map, puis la liste de Maps en chaîne JSON
final String tasksJson = json.encode(_tasks.map((task) => task.toJson()).toList());
await prefs.setString(_tasksKey, tasksJson);
}
// Ajoute une nouvelle tâche
void addTask(Task task) {
_tasks.add(task);
_saveTasks(); // Sauvegarde après chaque modification
notifyListeners(); // Informe les consommateurs
}
// Met à jour une tâche existante (e.g., marquer comme complétée)
void updateTask(Task updatedTask) {
final index = _tasks.indexWhere((task) => task.id == updatedTask.id);
if (index != -1) {
_tasks[index] = updatedTask; // Remplace l'ancienne tâche par la nouvelle
_saveTasks();
notifyListeners();
}
}
// Supprime une tâche
void deleteTask(String id) {
_tasks.removeWhere((task) => task.id == id);
_saveTasks();
notifyListeners();
}
// Bascule le statut de complétion d'une tâche
void toggleTaskStatus(String id) {
final index = _tasks.indexWhere((task) => task.id == id);
if (index != -1) {
// Crée une nouvelle tâche avec le statut inversé (immuabilité)
_tasks[index] = _tasks[index].copyWith(isCompleted: !_tasks[index].isCompleted);
_saveTasks();
notifyListeners();
}
}
}
_tasks: La liste interne qui contient toutes nos tâches. Elle est privée (_) pour contrôler l'accès.tasksgetter : Expose une version non modifiable de la liste (List.unmodifiable) pour éviter les modifications directes de l'extérieur duProvider._loadTasks()et_saveTasks(): Gèrent l'interaction avecshared_preferences. Nous utilisonsjson.encodeetjson.decodepour convertir nos listes d'objetsTasken chaînes JSON et inversement.notifyListeners(): C'est la fonction magique deChangeNotifier. Chaque fois que la liste_tasksest modifiée,notifyListeners()est appelée pour informer tous les widgets qui "écoutent" ceProviderqu'ils doivent se reconstruire.addTask,updateTask,deleteTask,toggleTaskStatus: Les méthodes publiques qui permettent aux écrans d'interagir avec la logique métier et de mettre à jour l'état de l'application. Notez l'appel à_saveTasks()après chaque modification.
3.4. Point d'Entrée de l'Application (main.dart)
Configurez main.dart pour utiliser le TaskProvider. C'est ici que nous "fournissons" notre TaskProvider à l'arbre de widgets.
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/task_provider.dart';
import 'screens/task_list_screen.dart';
void main() {
runApp(
// ChangeNotifierProvider rend le TaskProvider disponible pour tous les widgets enfants.
ChangeNotifierProvider(
create: (context) => TaskProvider(), // Crée une instance de TaskProvider
child: const MyApp(), // L'application elle-même
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Mon Appli de Tâches',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const TaskListScreen(), // Écran d'accueil
);
}
}
ChangeNotifierProvider: Widget deproviderqui prend uncreatecallback pour instancier notreTaskProvider. Il place cette instance dans l'arbre de widgets, la rendant accessible aux descendants.MyApp: Notre widget racineMaterialAppqui définit le thème et l'écran d'accueil.
3.5. Écran de la Liste des Tâches (TaskListScreen)
Créez le fichier lib/screens/task_list_screen.dart. Cet écran affichera la liste des tâches et un bouton pour en ajouter de nouvelles.
// lib/screens/task_list_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/task_provider.dart';
import '../models/task.dart';
import 'task_detail_screen.dart'; // Pour naviguer vers l'écran de détail
class TaskListScreen extends StatelessWidget {
const TaskListScreen({super.key});
@override
Widget build(BuildContext context) {
// Consumer écoute les changements dans TaskProvider et reconstruit uniquement cette partie.
return Consumer<TaskProvider>(
builder: (context, taskProvider, child) {
return Scaffold(
appBar: AppBar(
title: const Text('Mes Tâches'),
),
body: taskProvider.tasks.isEmpty
? const Center(
child: Text('Aucune tâche pour le moment. Ajoutez-en une !'),
)
: ListView.builder(
itemCount: taskProvider.tasks.length,
itemBuilder: (context, index) {
final task = taskProvider.tasks[index];
return TaskTile(
task: task,
onToggle: () {
taskProvider.toggleTaskStatus(task.id);
},
onDelete: () {
taskProvider.deleteTask(task.id);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Navigue vers l'écran d'ajout de tâche
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => TaskDetailScreen()),
);
},
child: const Icon(Icons.add),
),
);
},
);
}
}
// lib/widgets/task_tile.dart (Nouveau fichier pour le widget de la tuile de tâche)
class TaskTile extends StatelessWidget {
final Task task;
final VoidCallback onToggle;
final VoidCallback onDelete;
const TaskTile({
super.key,
required this.task,
required this.onToggle,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
elevation: 2,
child: ListTile(
title: Text(
task.title,
style: TextStyle(
decoration: task.isCompleted ? TextDecoration.lineThrough : null,
color: task.isCompleted ? Colors.grey : Colors.black,
),
),
subtitle: task.description.isNotEmpty
? Text(
task.description,
style: TextStyle(
decoration: task.isCompleted ? TextDecoration.lineThrough : null,
color: task.isCompleted ? Colors.grey[600] : Colors.black54,
),
)
: null,
leading: Checkbox(
value: task.isCompleted,
onChanged: (bool? value) {
onToggle(); // Appelle le callback pour basculer le statut
},
),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: onDelete, // Appelle le callback pour supprimer
),
),
);
}
}
Consumer<TaskProvider>: Un widgetConsumerest la manière la plus simple d'accéder aux données d'unProvideret de reconstruire son enfant lorsque ces données changent. Il fournit l'instance deTaskProvidervia lebuildercallback.ListView.builder: Idéal pour les listes longues ou dynamiques, car il construit les éléments "à la demande", ce qui est plus performant.FloatingActionButton: Pour ajouter de nouvelles tâches. Il utiliseNavigator.of(context).pushpour naviguer vers leTaskDetailScreen.TaskTile: Un widget séparé (lib/widgets/task_tile.dart) pour chaque élément de la liste. C'est une bonne pratique de séparer les composants UI réutilisables. Il prend desVoidCallbackpour gérer les actions de bascule et de suppression, déléguant la logique auTaskListScreenqui, lui, interagit avec leTaskProvider.
3.6. Écran d'Ajout/Modification de Tâche (TaskDetailScreen)
Créez le fichier lib/screens/task_detail_screen.dart. Cet écran permettra d'entrer les détails d'une nouvelle tâche.
// lib/screens/task_detail_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart'; // Ajoutez la dépendance 'uuid' dans pubspec.yaml
import '../models/task.dart';
import '../providers/task_provider.dart';
// Ajout de la dépendance uuid dans pubspec.yaml:
// dependencies:
// uuid: ^4.2.1
class TaskDetailScreen extends StatefulWidget {
final Task? task; // Optional: If we're editing an existing task
const TaskDetailScreen({super.key, this.task});
@override
State<TaskDetailScreen> createState() => _TaskDetailScreenState();
}
class _TaskDetailScreenState extends State<TaskDetailScreen> {
final _formKey = GlobalKey<FormState>(); // Clé pour valider le formulaire
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void initState() {
super.initState();
// Pré-remplit les champs si nous sommes en mode édition
if (widget.task != null) {
_titleController.text = widget.task!.title;
_descriptionController.text = widget.task!.description;
}
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _saveTask() {
if (_formKey.currentState!.validate()) {
final taskProvider = Provider.of<TaskProvider>(context, listen: false); // listen: false car on ne reconstruit pas
if (widget.task == null) {
// Nouvelle tâche
final newTask = Task(
id: const Uuid().v4(), // Génère un ID unique
title: _titleController.text,
description: _descriptionController.text,
);
taskProvider.addTask(newTask);
} else {
// Mise à jour de tâche existante
final updatedTask = widget.task!.copyWith(
title: _titleController.text,
description: _descriptionController.text,
);
taskProvider.updateTask(updatedTask);
}
Navigator.of(context).pop(); // Retourne à l'écran précédent
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.task == null ? 'Ajouter une Tâche' : 'Modifier la Tâche'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Titre de la tâche',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un titre';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (optionnel)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _saveTask,
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(50), // Bouton pleine largeur
),
child: Text(widget.task == null ? 'Ajouter la Tâche' : 'Enregistrer les Modifications'),
),
],
),
),
),
);
}
}
TextEditingController: Utilisé pour récupérer et contrôler le texte desTextFormField. Important : toujours lesdispose()pour éviter les fuites de mémoire._formKey: UneGlobalKeypour le widgetForm, permettant de valider le formulaire.Provider.of<TaskProvider>(context, listen: false): Accède à l'instance deTaskProvider.listen: falseest crucial ici car nous ne voulons pas que ce widget se reconstruise lorsque leTaskProviderchange. Nous voulons simplement appeler des méthodes sur lui.Uuid().v4(): Pour générer un ID unique pour chaque nouvelle tâche. N'oubliez pas d'ajouter la dépendanceuuiddans votrepubspec.yaml.- Validation : Le
validatordesTextFormFieldpermet de vérifier que l'entrée de l'utilisateur est valide avant de sauvegarder.
4. Améliorations Possibles et Bonnes Pratiques
Ce projet fournit une base solide. Voici des pistes pour l'améliorer et le rendre plus robuste :
- Gestion des Erreurs : Afficher des messages d'erreur à l'utilisateur en cas de problème (ex: échec de chargement des données).
- Thèmes et Personnalisation : Approfondir la personnalisation de l'interface utilisateur avec des thèmes, des couleurs et des polices spécifiques.
- Navigation Avancée : Utiliser des solutions comme
GoRouterouNavigator 2.0pour une navigation plus complexe, notamment pour les applications multi-plateformes et le web. - Persistance plus Robuste :
sqflite: Pour une base de données locale relationnelle.HiveouIsar: Des bases de données NoSQL locales, souvent plus rapides et plus simples pour les objets complexes.- Firebase / Supabase : Pour une solution backend cloud, permettant la synchronisation des données entre appareils et la gestion des utilisateurs.
- Tests :
- Tests Unitaires : Pour tester la logique de votre
Taskmodel etTaskProvider. - Tests de Widgets : Pour vérifier que vos composants UI se comportent comme prévu.
- Tests d'Intégration : Pour tester des flux utilisateur complets à travers plusieurs écrans.
- Tests Unitaires : Pour tester la logique de votre
- Internationalisation (i18n) : Permettre à votre application de supporter plusieurs langues.
- Notifications : Envoyer des rappels pour les tâches à venir.
5. Conclusion et Résumé
Félicitations ! Vous avez parcouru le processus de création d'une application Flutter complète, du concept initial à la persistance locale des données.
Dans cette leçon, vous avez appris à :
- Structurer un projet Flutter pour la clarté et la maintenabilité.
- Définir des modèles de données (comme la classe
Task). - Gérer l'état de l'application de manière centralisée avec
Provider. - Persister les données localement en utilisant
shared_preferences. - Construire des interfaces utilisateur interactives avec
ListView.builder,TextFormField, et d'autres widgets essentiels. - Naviguer entre les différents écrans de votre application.
Ce projet est un tremplin pour vos futures créations. N'hésitez pas à le modifier, à y ajouter des fonctionnalités, et à explorer les "améliorations possibles" pour approfondir votre maîtrise de Flutter. La meilleure façon d'apprendre est de construire !