Maîtrisez Flutter : Créez des Applications Mobiles Multiplateformes Performantes
Maîtrisez Flutter : Créez des Applications Mobiles Multiplateformes Performantes

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 comme Hive ou Isar seraient 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,
    );
  }
}
  • Task Class : Représente une seule tâche avec ses propriétés (id, title, description, isCompleted).
  • toJson() et fromJson() : Ces méthodes sont cruciales pour la persistance. shared_preferences ne peut stocker que des types primitifs ou des chaînes. Nous allons convertir nos objets Task en chaînes JSON pour les sauvegarder.
  • copyWith() : Une bonne pratique pour la gestion d'état est de traiter les objets comme immuables. copyWith permet de créer une nouvelle instance de Task avec 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.
  • tasks getter : Expose une version non modifiable de la liste (List.unmodifiable) pour éviter les modifications directes de l'extérieur du Provider.
  • _loadTasks() et _saveTasks() : Gèrent l'interaction avec shared_preferences. Nous utilisons json.encode et json.decode pour convertir nos listes d'objets Task en chaînes JSON et inversement.
  • notifyListeners() : C'est la fonction magique de ChangeNotifier. Chaque fois que la liste _tasks est modifiée, notifyListeners() est appelée pour informer tous les widgets qui "écoutent" ce Provider qu'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 de provider qui prend un create callback pour instancier notre TaskProvider. Il place cette instance dans l'arbre de widgets, la rendant accessible aux descendants.
  • MyApp : Notre widget racine MaterialApp qui 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 widget Consumer est la manière la plus simple d'accéder aux données d'un Provider et de reconstruire son enfant lorsque ces données changent. Il fournit l'instance de TaskProvider via le builder callback.
  • 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 utilise Navigator.of(context).push pour naviguer vers le TaskDetailScreen.
  • 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 des VoidCallback pour gérer les actions de bascule et de suppression, déléguant la logique au TaskListScreen qui, lui, interagit avec le TaskProvider.

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 des TextFormField. Important : toujours les dispose() pour éviter les fuites de mémoire.
  • _formKey : Une GlobalKey pour le widget Form, permettant de valider le formulaire.
  • Provider.of<TaskProvider>(context, listen: false) : Accède à l'instance de TaskProvider. listen: false est crucial ici car nous ne voulons pas que ce widget se reconstruise lorsque le TaskProvider change. 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épendance uuid dans votre pubspec.yaml.
  • Validation : Le validator des TextFormField permet 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 GoRouter ou Navigator 2.0 pour 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.
    • Hive ou Isar : 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 Task model et TaskProvider.
    • 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.
  • 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 !