Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
Flutter

TP Base de données SQLite : application StockFacile avec Flutter

Un cours complet pour maîtriser TP Base de données SQLite : application StockFacile avec Flutter.

0 chapitres 0 QCM Accès gratuit
Dans cet article

TP Base de données SQLite : application StockFacile avec Flutter

  1. Objectifs

  • Ce TP de 6 heures clôt le chapitre Base de données SQLite. En le réalisant, vous allez apprendre à :
    • concevoir une base relationnelle (tables, clés primaires, clés étrangères, contraintes) et la manipuler avec DB Browser for SQLite avant d'écrire la moindre ligne de Dart ;
    • installer et configurer sqflite et path (et sqflite_common_ffi pour Windows / Linux) ;
    • écrire des classes modèles avec un constructeur factory fromMap(), une méthode toMap() et une méthode copyWith(), en convertissant les types que SQLite ne connaît pas (bool, DateTime) ;
    • créer un DatabaseHelper unique (singleton) qui ouvre la base, active les clés étrangères et crée les tables ;
    • réaliser les quatre opérations CRUD une par une, en suivant toujours le même cycle : SQL dans DB Browser → méthode Dart → test → vérification dans DB Browser → interface;
    • afficher des données asynchrones avec FutureBuilder et rafraîchir l'affichage après chaque modification ;
    • écrire des requêtes avancées : recherche LIKE sécurisée avec whereArgs, tri, filtres, jointures, agrégats (COUNT, SUM, AVG, GROUP BY) ;
    • utiliser les transactions et les batch pour garantir la cohérence des données ;
    • faire évoluer la structure de la base sans perdre les données (version, onUpgrade, ALTER TABLE) ;
    • versionner son travail avec Git et le publier sur GitHub dès le départ : dépôt, commit à chaque étape, push, README.md et version finale étiquetée.
  • Prérequis : Flutter 3.22 ou plus récent, un émulateur Android, un téléphone ou Flutter desktop (Windows / Linux / macOS) ; avoir suivi les leçons du chapitre (concepts de base de données, utilisation de SQLite, opérations, mot-clé factory, application CRUD) ; connaître StatefulWidget, setState, Form, Navigator.push et FutureBuilder.
  • Avant la séance :
    • un compte GitHub gratuit et Git installé sur le poste (vérifier avec git --version) ;
    • le logiciel gratuit DB Browser for SQLite installé (voir partie 00) ;
    • si vous travaillez avec un émulateur Android : l'outil adb fourni avec Android Studio (vérifier avec adb devices).
  • Attention : sqflite ne fonctionne pas sur Flutter Web. Ne lancez pas ce TP dans Chrome : utilisez Android, iOS, macOS, Windows ou Linux.
  1. Description

  • Une épicerie de quartier souhaite remplacer son cahier de stock par une application mobile, StockFacile. Le gérant veut enregistrer ses produits, les classer par catégorie, suivre les quantités, repérer les ruptures de stock et connaître la valeur totale de sa marchandise. Les données doivent rester sur le téléphone, même sans connexion Internet : on utilise donc une base SQLite locale.
  • La base contient deux tables liées:
Table Colonnes Rôle
categories id (clé primaire), nom (unique), description Alimentation, Boissons, Hygiène, Papeterie…
produits id (clé primaire), nom, prix, quantite, disponible, date_ajout, categorie_id (clé étrangère), seuil_alerte (ajoutée en partie 10) Chaque produit appartient à une catégorie ; une catégorie contient plusieurs produits.
  • L'application finale comporte trois onglets (NavigationBar) et deux écrans secondaires:
Écran Fichier Flutter Opérations SQLite utilisées
Onglet « Produits » produits_screen.dart query, recherche LIKE, tri, filtre, jointure, delete
Onglet « Catégories » categories_screen.dart insert, query, delete (clé étrangère)
Onglet « Statistiques » stats_screen.dart rawQuery: COUNT, SUM, AVG, GROUP BY, batch, transaction
Écran « Formulaire produit » produit_form_screen.dart insert (ajout) et update (modification)
Écran « Détail produit » produit_detail_screen.dart query par id, rawUpdate, delete
  • Résultats attendus : chaque partie se termine par une capture « Résultat attendu ». Comparez votre écran avec cette capture avant de faire votre commit : si le résultat diffère, corrigez avant de passer à la partie suivante.
  • Répartition du temps :
Partie Notions travaillées Durée
Partie 00 – Mise en place du projet, de DB Browser et du dépôt GitHub Git, GitHub, DB Browser 30 min
Partie 01 – Concevoir la base dans DB Browser CREATE TABLE, INSERT, SELECT, contraintes 35 min
Partie 02 – Packages et classes modèles sqflite, factory fromMap, toMap 25 min
Partie 03 – Ouvrir et créer la base depuis Flutter DatabaseHelper, openDatabase, onCreate 35 min
Partie 04 – Opération 1 : insérer (Create) insert 35 min
Partie 05 – Opération 2 : lire (Read) query, FutureBuilder 40 min
Partie 06 – Formulaire et opération 3 : modifier (Update) Form, update, rawUpdate 45 min
Partie 07 – Opération 4 : supprimer (Delete) delete, clés étrangères 30 min
Partie 08 – Requêtes avancées LIKE, orderBy, JOIN, agrégats 30 min
Partie 09 – Transactions et batch transaction, batch 15 min
Partie 10 – Faire évoluer la base (migration) version, onUpgrade, ALTER TABLE 20 min
Partie 11 – Intégration finale et version 1.0 sur GitHub tous + git tag 20 min
Total   6 h
  1. Travail à réaliser

  • Partie 00 Mise en place du projet, de DB Browser et du dépôt GitHub
  • Installer DB Browser for SQLite
  • Q0.1. Télécharger DB Browser for SQLite sur sqlitebrowser.org/dl et l'installer selon votre système :
    • Windows : Standard installer for 64-bit Windows. Sur un poste de salle sans droits administrateur, prendre la version PortableApp (aucune installation nécessaire) ;
    • macOS : le fichier .dmg, ou brew install --cask db-browser-for-sqlite;
    • Linux (Debian / Ubuntu) : sudo apt install sqlitebrowser.
  • Q0.2. Lancer le logiciel. Si l'interface est en anglais, passer en français : Édition → Préférences → Général → Langue (Edit → Preferences → General → Language), puis relancer. Repérer les quatre onglets principaux :
Onglet (français) Onglet (anglais) À quoi il sert dans ce TP
Structure de la base de données Database Structure Voir les tables, les colonnes et l'instruction CREATE TABLE de chacune
Parcourir les données Browse Data Voir et modifier les lignes d'une table, comme dans un tableur
Éditer les pragmas Edit Pragmas Voir la version de la base (User Version) et l'état des clés étrangères
Exécuter le SQL Execute SQL Écrire et tester les requêtes avant de les mettre dans le code Flutter
Deux boutons à retenir dans DB Browser :
« Écrire les modifications » (Write Changes, Ctrl + S) enregistre réellement vos changements dans le fichier .db.
« Annuler les modifications » (Revert Changes) annule tout ce qui a été fait depuis le dernier enregistrement. Tant que vous n'avez pas cliqué sur « Écrire les modifications », rien n'est enregistré : c'est très pratique pour tester une requête dangereuse puis revenir en arrière.
  • Créer le projet Flutter
  • Q0.3. Créer le projet avec flutter create stock_facile et supprimer le code du compteur par défaut dans main.dart. Le nom du paquet Android sera com.example.stock_facile: notez-le, il servira en partie 03.
  • Q0.4. Organiser le projet selon l'arborescence ci-dessous. Créer dès maintenant les dossiers sql/ et captures/ et les fichiers vides reponses.md, sql/requetes.sql.
stock_facile/
├── .gitignore               (modifié en Q0.5)
├── README.md                (Partie 11)
├── reponses.md              (au fil du TP)
├── captures/                (au fil du TP)
├── sql/
│   ├── brouillon.db         (Partie 01, non versionné)
│   ├── schema.sql           (Partie 01, puis 10)
│   └── requetes.sql         (Parties 01, 04 à 08)
lib/
├── models/
│   ├── categorie.dart       (Partie 02)
│   └── produit.dart         (Partie 02)
├── database/
│   ├── database_helper.dart (Partie 03)
│   ├── categorie_dao.dart   (Partie 04)
│   └── produit_dao.dart     (Partie 04)
├── widgets/
│   └── produit_tile.dart    (Partie 05)
├── screens/
│   ├── tests_screen.dart    (Parties 03-04, supprimé en 11)
│   ├── produits_screen.dart
│   ├── produit_detail_screen.dart
│   ├── produit_form_screen.dart
│   ├── categories_screen.dart
│   └── stats_screen.dart
└── main.dart
# À ajouter à la fin de .gitignore

# Bases SQLite locales (jamais sur GitHub)
*.db
*.db-journal
*.db-wal
*.db-shm
Pourquoi un DAO ? Un DAO (Data Access Object) est une classe qui regroupe toutes les requêtes d'une table. Les écrans n'écrivent jamais de SQL : ils appellent ProduitDao().lireProduits(). Si la base change, on ne modifie que le DAO.
  • Q0.5. Ouvrir .gitignore à la racine du projet : vérifier qu'il exclut bien build/ et .dart_tool/, puis ajouter les lignes ci-dessus (à droite). Répondre dans reponses.md: pourquoi ne faut-il pas envoyer les fichiers .db sur GitHub alors qu'on envoie schema.sql?
  • Q0.6. Dans main.dart, créer un MaterialApp (titre « StockFacile », useMaterial3: true, une couleur de thème de votre choix) dont la page d'accueil est un simple Scaffold avec une AppBar « StockFacile ». Lancer l'application sur l'émulateur ou en desktop (flutter run -d windows, par exemple).
  • Publier le projet sur GitHub dès le départ
  • Q0.7. Sur github.com/new, créer un dépôt nommé stock-facile, sans cocher « Add a README », « .gitignore » ni « license » (le projet existe déjà en local). Si le dépôt est privé, inviter l'enseignant comme collaborateur (Settings → Collaborators).
  • Q0.8. Configurer Git une seule fois sur le poste avec votre nom et l'adresse e-mail de votre compte GitHub (commandes 1 et 2 ci-dessous). Si c'est déjà fait dans un TP précédent, vérifier avec git config --global --list.
  • Q0.9. Dans le dossier stock_facile, initialiser le dépôt local, faire le premier commit et l'envoyer sur GitHub (commandes 3 à 8). Remplacer VOTRE-COMPTE par votre nom d'utilisateur GitHub.
# 1-2. Identité (une seule fois par poste)
git config --global user.name "Prénom Nom"
git config --global user.email "adresse@exemple.com"

# 3-8. Premier envoi du projet
cd stock_facile
git init
git add .
git commit -m "Partie 00 : création du projet StockFacile"
git branch -M main
git remote add origin https://github.com/VOTRE-COMPTE/stock-facile.git
git push -u origin main
# Vérifications utiles
git status          # fichiers modifiés
git log --oneline   # historique des commits
git remote -v       # adresse du dépôt GitHub
git check-ignore -v sql/brouillon.db
                    # vérifie qu'un fichier est ignoré
  • Q0.10. Actualiser la page du dépôt sur GitHub et vérifier que les dossiers lib/, android/, sql/ et le fichier pubspec.yaml apparaissent. Envoyer le lien du dépôt à l'enseignant avant de commencer la partie 01.
Authentification et mots de passe : lors du premier git push, GitHub n'accepte plus le mot de passe du compte. Connectez-vous par le navigateur quand Git le propose (Git Credential Manager, VS Code ou GitHub Desktop), ou utilisez un Personal Access Token (Settings → Developer settings → Personal access tokens → Tokens (classic), cocher la portée repo) à la place du mot de passe. Copiez le jeton immédiatement : GitHub ne l'affiche qu'une seule fois.
Règle de sécurité : un mot de passe ou un jeton ne doit jamais être écrit dans le code, dans README.md ou dans un fichier envoyé sur GitHub. Un dépôt public est lu par des robots qui récupèrent automatiquement les secrets publiés.
Règle du TP : un commit et un push à la fin de chaque partie.
Chaque partie se termine par un encadré « Git » qui donne le message de commit attendu. L'historique des commits fait partie de la note : un dépôt qui ne contient qu'un seul commit déposé en fin de séance n'obtient aucun point sur le critère Git.
Question de réflexion : si votre application ne fonctionne plus après la partie 07, comment l'historique des commits vous permet-il de retrouver la dernière version qui marchait ?
  • Résultat attendu à la fin de la partie 00
Résultat attendu à la fin de la partie 00 du TP StockFacile
Application lancée (MaterialApp + AppBar), dépôt stock-facile publié sur GitHub avec un premier commit, DB Browser for SQLite installé.
  • Partie 01 Concevoir la base dans DB Browser
  • Objectif : créer et tester toute la base à la main, sans Flutter. Une requête qui fonctionne dans DB Browser fonctionnera dans l'application ; une requête qui échoue ici échouera aussi dans Flutter, mais avec un message beaucoup plus difficile à lire.
  • Q1.1. Dans DB Browser, cliquer sur « Nouvelle base de données » (New Database) et l'enregistrer sous sql/brouillon.db dans votre projet. Une fenêtre « Éditer la définition de la table » s'ouvre : cliquer sur Annuler, car on va créer les tables en SQL.
  • Q1.2. Ouvrir l'onglet « Exécuter le SQL », coller le script ci-dessous et l'exécuter (bouton ▶ ou Ctrl + Entrée). Cliquer ensuite sur « Écrire les modifications ».
CREATE TABLE categories (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  nom         TEXT    NOT NULL UNIQUE,
  description TEXT
);

CREATE TABLE produits (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  nom          TEXT    NOT NULL,
  prix         REAL    NOT NULL CHECK (prix >= 0),
  quantite     INTEGER NOT NULL DEFAULT 0 CHECK (quantite >= 0),
  disponible   INTEGER NOT NULL DEFAULT 1,   -- 1 = vrai, 0 = faux
  date_ajout   TEXT    NOT NULL,             -- date au format ISO 8601
  categorie_id INTEGER NOT NULL,
  FOREIGN KEY (categorie_id) REFERENCES categories(id) ON DELETE RESTRICT
);
  • Q1.3. Dans l'onglet « Structure de la base de données », déplier les deux tables et vérifier les colonnes. Répondre dans reponses.md:
    • SQLite ne possède ni type BOOLEAN ni type DATETIME. Comment stocke-t-on disponible et date_ajout?
    • Quel est le rôle de AUTOINCREMENT? De UNIQUE sur nom? De CHECK (prix >= 0)?
    • Que signifie ON DELETE RESTRICT?
  • Q1.4. Insérer les données de départ en exécutant le script suivant, puis cliquer sur « Écrire les modifications ». Ouvrir l'onglet « Parcourir les données » et afficher successivement les deux tables. Pourquoi écrit-on 'Huile d''olive' avec deux apostrophes ?
INSERT INTO categories (nom, description) VALUES
  ('Alimentation', 'Produits secs et conserves'),
  ('Boissons',     'Eaux, jus et sodas'),
  ('Hygiène',      'Savons et produits d''entretien'),
  ('Papeterie',    'Cahiers, stylos et fournitures');

INSERT INTO produits (nom, prix, quantite, disponible, date_ajout, categorie_id) VALUES
  ('Huile d''olive 1 L',   18.500,  24, 1, '2026-10-01T09:00:00', 1),
  ('Couscous moyen 1 kg',   2.300,  40, 1, '2026-10-01T09:05:00', 1),
  ('Eau minérale 1,5 L',    0.850, 120, 1, '2026-10-02T10:00:00', 2),
  ('Jus d''orange 1 L',     3.200,   0, 0, '2026-10-02T10:10:00', 2),
  ('Savon de Marseille',    2.900,  15, 1, '2026-10-03T11:00:00', 3),
  ('Cahier 96 pages',       1.600,  60, 1, '2026-10-04T08:30:00', 4);
  • Q1.5. Toujours dans « Exécuter le SQL », écrire et exécuter une par une les requêtes suivantes. Les copier dans sql/requetes.sql avec un commentaire -- Q1.5.x au-dessus de chacune (bouton « Enregistrer le fichier SQL » ou copier-coller) :
    1. tous les produits (SELECT * FROM produits;) ;
    2. les produits en rupture de stock (quantite = 0) ;
    3. les produits triés du plus cher au moins cher ;
    4. le nom de chaque produit avec le nom de sa catégorie (INNER JOIN) ;
    5. le nombre de produits par catégorie (COUNT + GROUP BY) ;
    6. la valeur totale du stock (SUM(prix * quantite)).
  • Q1.6. Tester les contraintes. Exécuter les trois requêtes suivantes une par une et noter le message d'erreur obtenu pour chacune :
-- a) prix négatif
INSERT INTO produits (nom, prix, quantite, date_ajout, categorie_id)
VALUES ('Test prix', -5, 1, '2026-10-05T08:00:00', 1);

-- b) catégorie qui n'existe pas
INSERT INTO produits (nom, prix, quantite, date_ajout, categorie_id)
VALUES ('Test catégorie', 1, 1, '2026-10-05T08:00:00', 99);

-- c) supprimer une catégorie qui contient des produits
DELETE FROM categories WHERE id = 1;
Si la requête b) ou c) ne provoque aucune erreur : les clés étrangères sont désactivées. Par défaut, SQLite ne vérifie pas les clés étrangères : il faut les activer à chaque ouverture de la base avec PRAGMA foreign_keys = ON;. DB Browser le fait par défaut (Préférences → Base de données → Clés étrangères). Vous retrouverez ce point en partie 03 : dans Flutter, c'est à vous de l'activer.
Si une requête de test a réussi, cliquer sur « Annuler les modifications » pour revenir à l'état enregistré.
  • Q1.7. Exporter la structure de la base : Fichier → Exporter → Base de données vers fichier SQL… (File → Export → Database to SQL file), sélectionner les deux tables, choisir « Exporter uniquement le schéma » (Export schema only) et enregistrer sous sql/schema.sql. Ouvrir ce fichier dans VS Code : que contient-il ?
  • Q1.8. Fermer la base (Fichier → Fermer la base de données). Pourquoi est-il important de fermer DB Browser ou la base avant qu'un autre programme (votre application) écrive dans le même fichier ?
  • Résultat attendu à la fin de la partie 01
Résultat attendu à la fin de la partie 01 du TP StockFacile
Base brouillon.db dans DB Browser : structure des deux tables, requête avec jointure testée, et clé étrangère qui bloque la suppression d'une catégorie.
Git – fin de la partie 01 : vérifier avec git status que brouillon.db n'apparaît pas (il est ignoré), mais que schema.sql et requetes.sql apparaissent.
git status
git add .
git commit -m "Partie 01 : conception de la base dans DB Browser (schema.sql, requetes.sql)"
git push
  • Partie 02 Packages et classes modèles
  • Objectif : installer les packages nécessaires et écrire les classes Dart qui représentent une ligne de chaque table.
  • Q2.1. Installer les packages avec les commandes ci-dessous, puis ouvrir pubspec.yaml et vérifier qu'ils apparaissent dans dependencies:. Quel est le rôle de chacun ?
# Pour tous
flutter pub add sqflite path

# En plus, uniquement si vous travaillez
# en desktop Windows ou Linux
flutter pub add sqflite_common_ffi
// main.dart – début du fichier
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  // Windows / Linux : SQLite passe par FFI
  if (!kIsWeb && (Platform.isWindows || Platform.isLinux)) {
    sqfliteFfiInit();
    databaseFactory = databaseFactoryFfi;
  }
  runApp(const StockFacileApp());
}
  • Q2.2. Si vous travaillez en desktop Windows ou Linux, adapter main.dart comme ci-dessus (à droite). Si vous travaillez sur Android ou iOS, ces lignes ne sont pas nécessaires. Si une erreur indique que sqlite3.dll est introuvable sous Windows, ajouter aussi le package sqlite3_flutter_libs.
  • Q2.3. Dans models/categorie.dart, créer la classe Categorie suivante. Pourquoi id est-il de type int? (nullable) ? Pourquoi toMap() n'ajoute-t-il id que s'il n'est pas null?
class Categorie {
  final int? id;          // null tant que la catégorie n'est pas en base
  final String nom;
  final String? description;

  const Categorie({this.id, required this.nom, this.description});

  // Ligne de la base (Map) → objet Dart
  factory Categorie.fromMap(Map<String, dynamic> map) {
    return Categorie(
      id: map['id'] as int?,
      nom: map['nom'] as String,
      description: map['description'] as String?,
    );
  }

  // Objet Dart → ligne de la base (Map)
  Map<String, dynamic> toMap() {
    return {
      if (id != null) 'id': id,
      'nom': nom,
      'description': description,
    };
  }

  @override
  String toString() => 'Categorie(id: $id, nom: $nom)';
}
  • Q2.4. Dans models/produit.dart, créer la classe Produit à partir du début ci-dessous. Le constructeur fromMap est fourni : écrire vous-même la méthode toMap() en respectant les conversions du tableau, puis une méthode toString().
class Produit {
  final int? id;
  final String nom;
  final double prix;
  final int quantite;
  final bool disponible;
  final DateTime dateAjout;
  final int categorieId;

  const Produit({
    this.id,
    required this.nom,
    required this.prix,
    this.quantite = 0,
    this.disponible = true,
    required this.dateAjout,
    required this.categorieId,
  });

  factory Produit.fromMap(Map<String, dynamic> map) {
    return Produit(
      id: map['id'] as int?,
      nom: map['nom'] as String,
      prix: (map['prix'] as num).toDouble(),
      quantite: map['quantite'] as int,
      disponible: map['disponible'] == 1,
      dateAjout: DateTime.parse(map['date_ajout'] as String),
      categorieId: map['categorie_id'] as int,
    );
  }

  Map<String, dynamic> toMap() {
    // À écrire (Q2.4)
  }
}
Dart Colonne SQLite Conversion
bool disponible disponible INTEGER true → 1, false → 0
DateTime dateAjout date_ajout TEXT toIso8601String() / DateTime.parse()
double prix prix REAL attention : 5 peut revenir en int
int categorieId categorie_id INTEGER le nom change : camelCase → snake_case
Erreur classique : les clés de la Map doivent être exactement les noms des colonnes de la table. Écrire 'categorieId' au lieu de 'categorie_id' provoque l'erreur table produits has no column named categorieId.
Question de réflexion : pourquoi utilise-t-on ici un constructeur factory plutôt qu'un constructeur classique ? Pourquoi écrit-on (map['prix'] as num).toDouble() au lieu de map['prix'] as double?
  • Résultat attendu à la fin de la partie 02
Résultat attendu à la fin de la partie 02 du TP StockFacile
Packages sqflite et path installés, modèles Categorie et Produit créés ; les lignes surlignées sont les conversions à ne pas oublier dans toMap().
Git – fin de la partie 02 :
git add .
git commit -m "Partie 02 : packages sqflite et modèles Categorie / Produit"
git push
  • Partie 03 Ouvrir et créer la base depuis Flutter
  • Objectif : créer la classe qui ouvre la base une seule fois pour toute l'application, puis vérifier dans DB Browser que la base créée par Flutter est identique à celle de la partie 01.
  • Q3.1. Dans database/database_helper.dart, recopier le code suivant. Les instructions CREATE TABLE sont celles de schema.sql: comparer les deux.
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';

class DatabaseHelper {
  static const String nomBase = 'stock_facile.db';
  static const int versionBase = 1;

  // 1. Singleton : une seule instance pour toute l'application
  DatabaseHelper._interne();
  static final DatabaseHelper _instance = DatabaseHelper._interne();
  factory DatabaseHelper() => _instance;

  static Database? _db;

  // 2. La base n'est ouverte qu'au premier appel, puis réutilisée
  Future<Database> get database async {
    _db ??= await _ouvrirBase();
    return _db!;
  }

  // 3. Ouverture (et création si le fichier n'existe pas)
  Future<Database> _ouvrirBase() async {
    final dossier = await getDatabasesPath();
    final chemin = join(dossier, nomBase);
    debugPrint('Base SQLite : $chemin');

    return openDatabase(
      chemin,
      version: versionBase,
      onConfigure: (db) async {
        await db.execute('PRAGMA foreign_keys = ON');
      },
      onCreate: _creerTables,
    );
  }

  // 4. Appelée UNE SEULE FOIS : quand le fichier .db n'existe pas encore
  Future<void> _creerTables(Database db, int version) async {
    debugPrint('onCreate : création des tables (version $version)');

    await db.execute('''
      CREATE TABLE categories (
        id          INTEGER PRIMARY KEY AUTOINCREMENT,
        nom         TEXT    NOT NULL UNIQUE,
        description TEXT
      )
    ''');

    await db.execute('''
      CREATE TABLE produits (
        id           INTEGER PRIMARY KEY AUTOINCREMENT,
        nom          TEXT    NOT NULL,
        prix         REAL    NOT NULL CHECK (prix >= 0),
        quantite     INTEGER NOT NULL DEFAULT 0 CHECK (quantite >= 0),
        disponible   INTEGER NOT NULL DEFAULT 1,
        date_ajout   TEXT    NOT NULL,
        categorie_id INTEGER NOT NULL,
        FOREIGN KEY (categorie_id) REFERENCES categories(id) ON DELETE RESTRICT
      )
    ''');

    // Catégories de départ
    const depart = [
      {'nom': 'Alimentation', 'description': 'Produits secs et conserves'},
      {'nom': 'Boissons', 'description': 'Eaux, jus et sodas'},
      {'nom': 'Hygiène', 'description': "Savons et produits d'entretien"},
      {'nom': 'Papeterie', 'description': 'Cahiers, stylos et fournitures'},
    ];
    for (final c in depart) {
      await db.insert('categories', c);
    }
  }

  // 5. Fermer la base (utile avant de copier le fichier)
  Future<void> fermer() async {
    await _db?.close();
    _db = null;
  }
}
  • Q3.2. Répondre dans reponses.md en vous appuyant sur les numéros des commentaires :
    • (1) Que se passe-t-il si on écrit deux fois DatabaseHelper() dans le code ? Combien d'objets sont créés ? Faire le lien avec la leçon sur le mot-clé factory.
    • (2) Que fait l'opérateur ??=?
    • (3) Pourquoi le PRAGMA foreign_keys = ON est-il placé dans onConfigure et non dans onCreate?
    • (4) Combien de fois onCreate est-il appelé dans toute la vie de l'application ?
  • Q3.3. Dans screens/tests_screen.dart, créer un écran TestsScreen (StatelessWidget) contenant une ListView de boutons. C'est votre « banc d'essai » : il servira à tester chaque méthode avant de créer la vraie interface. Ajouter un premier bouton « Ouvrir la base »:
ElevatedButton(
  onPressed: () async {
    final db = await DatabaseHelper().database;
    final resultat = await db.rawQuery('SELECT COUNT(*) AS nb FROM categories');
    debugPrint('Nombre de catégories : ${resultat.first['nb']}');
  },
  child: const Text('Ouvrir la base'),
),
  • Q3.4. Faire de TestsScreen la page d'accueil, lancer l'application et cliquer sur le bouton. Dans la console (Debug Console de VS Code), relever :
    • le chemin complet de la base affiché par debugPrint;
    • le message onCreate et le nombre de catégories.
  • Q3.5. Arrêter l'application, la relancer et recliquer. Le message onCreate s'affiche-t-il encore ? Pourquoi ?
  • Ouvrir la base de l'application dans DB Browser
  • Q3.6. Récupérer le fichier stock_facile.db créé par l'application et l'ouvrir dans DB Browser. Choisir la méthode qui correspond à votre environnement :
Environnement Méthode
A. Desktop (Windows, Linux, macOS) Le fichier est directement sur votre disque, au chemin affiché en Q3.4. Dans DB Browser : Ouvrir une base de données en lecture seule (flèche à côté du bouton « Ouvrir ») et coller ce chemin.
B. Émulateur Android avec Android Studio View → Tool Windows → Device Explorer, puis ouvrir data/data/com.example.stock_facile/databases/. Clic droit sur le dossier databases → Save As pour copier tout le dossier sur votre poste, puis ouvrir stock_facile.db dans DB Browser.
C. Émulateur ou téléphone Android avec adb Dans l'Invite de commandes (cmd), pas dans PowerShell, exécuter les commandes ci-dessous, puis ouvrir le fichier copié dans DB Browser.
# Méthode C – à lancer depuis le dossier sql/ du projet
adb devices
adb exec-out run-as com.example.stock_facile cat databases/stock_facile.db > stock_facile.db
adb exec-out run-as com.example.stock_facile cat databases/stock_facile.db-wal > stock_facile.db-wal
Pièges fréquents :
1) Sous Windows, PowerShell modifie les données redirigées avec > et produit un fichier illisible (« file is not a database »). Utilisez cmd.
2) Android écrit les dernières modifications dans un fichier stock_facile.db-wal à côté de la base. Si vous ne copiez que le .db, les dernières lignes ajoutées peuvent manquer. Copiez les deux fichiers dans le même dossier (ou tout le dossier avec la méthode B), ou arrêtez l'application avant la copie.
3) Sur émulateur, vous travaillez sur une copie: une modification faite dans DB Browser ne change pas la base du téléphone. Il faut recopier le fichier après chaque test.
4) Ces fichiers .db sont ignorés par Git grâce à la Q0.5 : vérifiez-le avec git status.
  • Q3.7. Dans DB Browser, vérifier :
    • onglet Structure: les deux tables existent avec les mêmes colonnes que brouillon.db;
    • onglet Parcourir les données: la table categories contient les 4 catégories de départ, la table produits est vide ;
    • onglet Éditer les pragmas: relever la valeur de User Version. À quelle ligne du code correspond-elle ?
  • Q3.8. Faire une capture d'écran de l'onglet Structure et l'enregistrer sous captures/03_structure_base.png.
  • Résultat attendu à la fin de la partie 03
Résultat attendu à la fin de la partie 03 du TP StockFacile
Écran de test, messages de la console (chemin de la base, onCreate, 4 catégories) et base de l'application ouverte dans DB Browser.
Git – fin de la partie 03 :
git add .
git commit -m "Partie 03 : DatabaseHelper, création des tables et vérification dans DB Browser"
git push
Méthode à suivre pour CHAQUE opération des parties 04 à 08 : le cycle en 5 étapes
Étape 1 – SQL : écrire et tester la requête dans DB Browser (sur brouillon.db), puis la copier dans requetes.sql.
Étape 2 – DAO : écrire la méthode Dart correspondante dans le DAO.
Étape 3 – Test : l'appeler depuis un bouton de TestsScreen et lire le résultat dans la console.
Étape 4 – Vérification : ouvrir la base de l'application dans DB Browser et constater le changement.
Étape 5 – Interface : seulement quand les étapes 1 à 4 fonctionnent, brancher la méthode sur le vrai écran, puis faire un commit.
Ne sautez pas d'étape : quand une erreur apparaît, vous savez immédiatement si elle vient du SQL, du Dart ou de l'interface.
Opération Méthode sqflite Équivalent SQL Valeur retournée
Create db.insert('produits', map) INSERT INTO produits (…) VALUES (…) l'id de la nouvelle ligne
Read db.query('produits', where: …, whereArgs: …, orderBy: …) SELECT * FROM produits WHERE … ORDER BY … List<Map<String, Object?>>
Update db.update('produits', map, where: 'id = ?', whereArgs: [id]) UPDATE produits SET … WHERE id = ? le nombre de lignes modifiées
Delete db.delete('produits', where: 'id = ?', whereArgs: [id]) DELETE FROM produits WHERE id = ? le nombre de lignes supprimées
SQL libre rawQuery, rawInsert, rawUpdate, rawDelete, execute n'importe quelle requête selon la méthode
  • Partie 04 Opération 1 : insérer des données (Create)
  • Objectif : ajouter des lignes dans la base depuis Flutter, en suivant le cycle en 5 étapes, et comprendre les erreurs renvoyées par les contraintes.
  • Insérer une catégorie
  • Q4.1 (Étape 1 – SQL). Ouvrir brouillon.db dans DB Browser. Dans « Exécuter le SQL », écrire l'instruction qui insère la catégorie « Électroménager » (description « Petits appareils »). L'exécuter, vérifier dans « Parcourir les données », puis cliquer sur « Annuler les modifications ». Copier la requête dans requetes.sql.
  • Q4.2 (Étape 2 – DAO). Dans database/categorie_dao.dart, créer la classe CategorieDao avec la méthode suivante. Expliquer en commentaire chacune des trois lignes.
import 'package:sqflite/sqflite.dart';
import '../models/categorie.dart';
import 'database_helper.dart';

class CategorieDao {
  static const String table = 'categories';

  Future<int> insererCategorie(Categorie categorie) async {
    final Database db = await DatabaseHelper().database;
    final int id = await db.insert(table, categorie.toMap());
    return id;
  }
}
  • Q4.3 (Étape 3 – Test). Dans TestsScreen, ajouter un bouton « Test : ajouter une catégorie » qui insère Categorie(nom: 'Électroménager', description: 'Petits appareils') et affiche dans la console l'id retourné. Cliquer une fois: quel id obtenez-vous ? Pourquoi cette valeur ?
  • Q4.4. Cliquer une deuxième fois sur le même bouton. Recopier le message d'erreur. Quelle contrainte de la partie 01 est en cause ? Entourer l'appel d'un try / catch qui intercepte l'erreur et affiche un SnackBar « Cette catégorie existe déjà » :
try {
  final id = await CategorieDao().insererCategorie(nouvelle);
  debugPrint('Catégorie insérée avec l\'id $id');
} on DatabaseException catch (e) {
  if (e.isUniqueConstraintError()) {
    // afficher le SnackBar « Cette catégorie existe déjà »
  } else {
    debugPrint('Erreur SQLite : $e');
  }
}
  • Q4.5 (Étape 4 – Vérification). Récupérer la base de l'application (méthode de la Q3.6) et l'ouvrir dans DB Browser. Retrouver la ligne « Électroménager » dans la table categories. Faire une capture : captures/04_insert_categorie.png.
  • Insérer un produit
  • Q4.6 (Étape 1 – SQL). Sur brouillon.db, écrire l'INSERT du produit « Stylo bleu », 0,500 DT, 100 unités, disponible, catégorie Papeterie. L'exécuter, vérifier, puis annuler les modifications. Copier la requête dans requetes.sql.
  • Q4.7 (Étape 2 – DAO). Dans database/produit_dao.dart, créer la classe ProduitDao et écrire vous-même la méthode Future<int> insererProduit(Produit produit) sur le modèle de insererCategorie.
  • Q4.8 (Étape 3 – Test). Ajouter un bouton « Test : ajouter un produit » qui insère le stylo avec dateAjout: DateTime.now() et categorieId: 4. Afficher l'id dans la console.
  • Q4.9 (Étape 4 – Vérification). Ouvrir la base de l'application dans DB Browser. Comment sont stockées les valeurs de disponible et date_ajout? Est-ce conforme à votre méthode toMap()? Capture : captures/04_insert_produit.png.
  • Q4.10. Ajouter un bouton qui essaie d'insérer un produit avec categorieId: 99. Quelle erreur obtenez-vous ? Commenter temporairement la ligne PRAGMA foreign_keys = ON dans DatabaseHelper, arrêter puis relancer l'application (un hot reload ne suffit pas : pourquoi ?) et recommencer. Que se passe-t-il ? Remettre la ligne et supprimer le produit orphelin dans DB Browser (desktop) ou désinstaller l'application (émulateur).
  • Q4.11. Ajouter un bouton qui insère un produit avec un prix de -2. Quelle contrainte bloque l'insertion ? Pourquoi est-il malgré tout utile de vérifier aussi le prix dans le formulaire Flutter (partie 06) ?
Question de réflexion :
L'option conflictAlgorithm de db.insert() accepte notamment ConflictAlgorithm.ignore et ConflictAlgorithm.replace. Rechercher dans la documentation de sqflite ce que fait chacune et expliquer pourquoi replace peut être dangereux sur une table référencée par une clé étrangère.
  • Résultat attendu à la fin de la partie 04
Résultat attendu à la fin de la partie 04 du TP StockFacile
Boutons de test et SnackBar du doublon, erreurs des contraintes dans la console, puis vérification des lignes insérées dans DB Browser.
Git – fin de la partie 04 :
git add .
git commit -m "Partie 04 : insertion de catégories et de produits (CategorieDao, ProduitDao)"
git push
  • Partie 05 Opération 2 : lire et afficher les données (Read)
  • Objectif : lire les lignes de la base, les convertir en objets Dart, puis les afficher dans une liste qui se met à jour.
  • Q5.1 (Étape 1 – SQL). Sur brouillon.db, écrire la requête qui retourne tous les produits triés par nom, puis celle qui retourne le produit dont l'id vaut 3. Les copier dans requetes.sql.
  • Q5.2 (Étape 2 – DAO). Dans CategorieDao, ajouter la méthode fournie ci-dessous. Dans ProduitDao, écrire vous-même Future<List<Produit>> lireProduits() (triés par nom) et Future<Produit?> lireProduitParId(int id).
// CategorieDao
Future<List<Categorie>> lireCategories() async {
  final db = await DatabaseHelper().database;
  final lignes = await db.query(table, orderBy: 'nom ASC');
  return lignes.map((ligne) => Categorie.fromMap(ligne)).toList();
}
// ProduitDao – indice pour lireProduitParId
final lignes = await db.query(
  table,
  where: 'id = ?',
  whereArgs: [id],
  limit: 1,
);
return lignes.isEmpty ? null : Produit.fromMap(lignes.first);
  • Q5.3 (Étape 3 – Test, en deux temps). Ajouter un bouton « Test : lire les produits »:
    • a) d'abord, sans conversion: dans le bouton, exécuter db.query('produits') et afficher le résultat brut avec debugPrint(lignes.toString()). Quel est le type exact du résultat ? À quoi ressemble une ligne ?
    • b) essayer d'écrire lignes.first['nom'] = 'Test';. Quelle erreur obtenez-vous ? Qu'en déduisez-vous sur les Map renvoyées par sqflite?
    • c) ensuite, avec conversion: appeler ProduitDao().lireProduits() et afficher le toString() de chaque produit.
  • Q5.4. Tester lireProduitParId(1) puis lireProduitParId(999). Pourquoi le type de retour est-il Produit? et pas Produit?
  • Étape 5 – L'écran de la liste des produits
  • Q5.5. Dans widgets/produit_tile.dart, créer un StatelessWidget ProduitTile qui reçoit un Produit et un callback onTap, et retourne un ListTile:
    • leading: un CircleAvatar affichant la quantité (rouge si 0, vert sinon) ;
    • title: le nom ; subtitle: le prix formaté avec 3 décimales (prix.toStringAsFixed(3)) suivi de « DT » ;
    • trailing: Icons.check_circle si disponible, Icons.cancel sinon.
  • Q5.6. Dans screens/produits_screen.dart, créer ProduitsScreen (StatefulWidget). Charger les produits dans initState et les afficher avec un FutureBuilder qui gère les quatre cas :
class _ProduitsScreenState extends State<ProduitsScreen> {
  final ProduitDao _dao = ProduitDao();
  late Future<List<Produit>> _futurProduits;

  @override
  void initState() {
    super.initState();
    _futurProduits = _dao.lireProduits();
  }

  void _rafraichir() {
    setState(() {
      _futurProduits = _dao.lireProduits();
    });
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Produit>>(
      future: _futurProduits,
      builder: (context, snapshot) {
        // 1. Chargement en cours     → CircularProgressIndicator centré
        // 2. Erreur                  → message rouge avec snapshot.error
        // 3. Liste vide              → icône + texte « Aucun produit »
        // 4. Données reçues          → ListView.builder de ProduitTile
        // À compléter (Q5.6)
      },
    );
  }
}
  • Q5.7. Un camarade écrit future: _dao.lireProduits() directement dans le FutureBuilder, sans passer par initState. Ajouter un debugPrint dans lireProduits() et faire tourner l'écran ou ouvrir le clavier : combien de fois la requête est-elle exécutée ? Expliquer le problème.
  • Q5.8. Entourer la liste d'un RefreshIndicator dont onRefresh appelle _rafraichir() (tirer la liste vers le bas pour recharger). Dans TestsScreen, ajouter un bouton qui ouvre ProduitsScreen avec Navigator.push.
  • Q5.9 (desktop uniquement). Ouvrir la base de l'application dans DB Browser en mode normal (pas en lecture seule). Modifier le prix d'un produit dans « Parcourir les données », cliquer sur « Écrire les modifications », puis tirer la liste vers le bas dans l'application. Le nouveau prix apparaît-il ? Fermer ensuite la base dans DB Browser. (Sur émulateur, expliquer pourquoi cette manipulation ne fonctionne pas.)
  • L'écran de détail
  • Q5.10. Dans CategorieDao, écrire Future<Categorie?> lireCategorieParId(int id).
  • Q5.11. Créer ProduitDetailScreen qui reçoit seulement l'id du produit (et non l'objet entier), charge le produit avec lireProduitParId et sa catégorie avec lireCategorieParId, puis affiche dans une Card: nom, prix, quantité, disponibilité, nom de la catégorie et date d'ajout au format jj/mm/aaaa. Pourquoi est-il préférable de passer l'id et de relire la base ?
  • Q5.12. Au clic sur un ProduitTile, ouvrir ProduitDetailScreen. Capture de la liste : captures/05_liste_produits.png.
  • Résultat attendu à la fin de la partie 05
Résultat attendu à la fin de la partie 05 du TP StockFacile
Les états du FutureBuilder: chargement, liste des produits, puis écran de détail relu en base par son id.
Git – fin de la partie 05 :
git add .
git commit -m "Partie 05 : lecture des produits, FutureBuilder, liste et détail"
git push
  • Partie 06 Formulaire et opération 3 : modifier des données (Update)
  • Objectif : créer un seul formulaire qui sert à la fois à ajouter et à modifier un produit, puis réaliser des modifications partielles.
  • Le formulaire d'ajout
  • Q6.1. Créer ProduitFormScreen (StatefulWidget) avec un paramètre optionnel final Produit? produit. Le titre de l'AppBar est « Nouveau produit » si produit est null, « Modifier le produit » sinon.
  • Q6.2. Construire un Form avec une GlobalKey<FormState> et trois TextEditingController (nom, prix, quantité). Libérer les contrôleurs dans dispose(): pourquoi est-ce nécessaire ?
  • Q6.3. Ajouter les champs avec leur validation :
    • Nom: obligatoire, au moins 2 caractères ;
    • Prix: clavier numérique (TextInputType.numberWithOptions(decimal: true)), nombre positif ; accepter la virgule en la remplaçant par un point avant double.tryParse;
    • Quantité: entier positif ou nul (int.tryParse).
  • Q6.4. Ajouter un DropdownButtonFormField<int> pour choisir la catégorie. Les catégories sont chargées depuis la base dans initState avec CategorieDao().lireCategories(). La valeur du menu est l'id de la catégorie ; le texte affiché est son nom. Le choix est obligatoire.
  • Q6.5. Ajouter un SwitchListTile « Disponible à la vente ».
  • Q6.6. Le bouton « Enregistrer » exécute les étapes suivantes, dans cet ordre :
    1. valider le formulaire (_formKey.currentState!.validate()) ; s'il est invalide, s'arrêter ;
    2. construire l'objet Produit à partir des champs (dateAjout: DateTime.now()) ;
    3. appeler ProduitDao().insererProduit(produit) avec await;
    4. vérifier que l'écran existe encore : if (!context.mounted) return;;
    5. fermer l'écran en renvoyant true: Navigator.pop(context, true).
  • Q6.7. Dans ProduitsScreen, ajouter un FloatingActionButton qui ouvre le formulaire. Au retour, si le résultat vaut true, appeler _rafraichir() et afficher un SnackBar « Produit ajouté ».
final ajoute = await Navigator.push<bool>(
  context,
  MaterialPageRoute(builder: (_) => const ProduitFormScreen()),
);
if (ajoute == true) {
  _rafraichir();
  // SnackBar « Produit ajouté »
}
  • Q6.8. Ajouter trois produits par le formulaire, puis vérifier leur présence dans DB Browser. Capture du formulaire : captures/06_formulaire.png.
  • La modification complète d'un produit
  • Q6.9 (Étape 1 – SQL). Sur brouillon.db:
    • exécuter UPDATE produits SET prix = 19.000 WHERE id = 1; et vérifier le résultat ;
    • exécuter ensuite UPDATE produits SET prix = 1; sans WHERE. Que constatez-vous dans « Parcourir les données » ?
    • cliquer sur « Annuler les modifications ». Noter dans reponses.md la leçon à retenir.
  • Q6.10. Dans la classe Produit, écrire une méthode copyWith() qui retourne une copie du produit en remplaçant seulement les champs fournis. Pourquoi en a-t-on besoin alors que tous les champs sont final?
  • Q6.11 (Étape 2 – DAO). Dans ProduitDao, écrire Future<int> modifierProduit(Produit produit) avec db.update, where: 'id = ?' et whereArgs: [produit.id].
  • Q6.12 (Étape 3 – Test). Dans TestsScreen, ajouter un bouton qui lit le produit 1, augmente son prix de 1 DT avec copyWith, appelle modifierProduit et affiche la valeur retournée. Que vaut-elle ? Que vaudrait-elle si l'id n'existait pas ?
  • Q6.13 (Étape 4 – Vérification). Vérifier dans DB Browser que seul le produit 1 a changé.
  • Q6.14 (Étape 5 – Interface). Réutiliser le formulaire en mode modification :
    • dans initState, si widget.produit n'est pas null, pré-remplir les contrôleurs, la catégorie et le switch ;
    • à l'enregistrement, utiliser widget.produit!.copyWith(…) (on garde l'id et la date d'ajout d'origine) puis modifierProduit au lieu de insererProduit;
    • dans ProduitDetailScreen, ajouter un IconButton (Icons.edit) qui ouvre le formulaire avec le produit. Au retour, recharger le détail. En quittant le détail, renvoyer true pour que la liste se rafraîchisse aussi.
  • Les modifications partielles
  • Q6.15. Dans ProduitDao, ajouter une méthode qui modifie une seule colonne. Passer une Map qui ne contient que cette colonne :
Future<int> changerDisponibilite(int id, bool disponible) async {
  final db = await DatabaseHelper().database;
  return db.update(
    table,
    {'disponible': disponible ? 1 : 0},
    where: 'id = ?',
    whereArgs: [id],
  );
}
Future<int> ajusterQuantite(int id, int delta) async {
  final db = await DatabaseHelper().database;
  return db.rawUpdate(
    'UPDATE produits SET quantite = quantite + ? '
    'WHERE id = ? AND quantite + ? >= 0',
    [delta, id, delta],
  );
}
  • Q6.16. Dans ProduitDetailScreen, ajouter deux boutons « − » et « + » qui appellent ajusterQuantite(id, -1) et ajusterQuantite(id, 1), puis rechargent le produit. Que retourne ajusterQuantite quand on clique sur « − » alors que la quantité vaut 0 ? Pourquoi le calcul quantite + ? fait par SQLite est-il plus sûr que lire la quantité en Dart, l'augmenter puis l'enregistrer ?
  • Q6.17. Quand la quantité passe à 0, le produit doit devenir automatiquement indisponible (changerDisponibilite). L'implémenter et le vérifier dans DB Browser.
  • Résultat attendu à la fin de la partie 06
Résultat attendu à la fin de la partie 06 du TP StockFacile
Formulaire validé (ajout), liste rafraîchie au retour, puis écran de détail en mode modification avec les boutons − / +.
Git – fin de la partie 06 :
git add .
git commit -m "Partie 06 : formulaire ajout/modification, update et modifications partielles"
git push
  • Partie 07 Opération 4 : supprimer des données (Delete)
  • Objectif : supprimer des lignes en toute sécurité (confirmation, annulation) et observer le rôle des clés étrangères.
  • Q7.1 (Étape 1 – SQL). Sur brouillon.db, exécuter DELETE FROM produits WHERE id = 6;, vérifier, puis annuler les modifications. Copier la requête dans requetes.sql.
  • Q7.2 (Étapes 2 et 3). Dans ProduitDao, écrire Future<int> supprimerProduit(int id). La tester avec un bouton de TestsScreen sur un produit que vous avez créé, puis vérifier dans DB Browser (étape 4).
  • Q7.3 (Étape 5 – Interface). Dans ProduitDetailScreen, ajouter un IconButton (Icons.delete) qui affiche un AlertDialog « Supprimer ce produit ? » avec « Annuler » et « Supprimer ». Si l'utilisateur confirme : supprimer, puis revenir à la liste avec Navigator.pop(context, true).
  • Q7.4. Dans ProduitsScreen, entourer chaque ProduitTile d'un Dismissible (glisser vers la gauche pour supprimer, fond rouge avec une icône de corbeille) :
    • key: ValueKey(produit.id);
    • confirmDismiss: afficher le même AlertDialog et retourner true ou false;
    • onDismissed: retirer d'abord le produit de la liste affichée (snapshot.data!.removeAt(index)), puis appeler supprimerProduit.
  • Q7.5. Pourquoi faut-il retirer le produit de la liste affichée avant la fin de l'appel à la base ? Que dit l'erreur si on ne le fait pas (« A dismissed Dismissible widget is still part of the tree ») ?
  • Q7.6. Après une suppression, afficher un SnackBar « Produit supprimé » avec une action « Annuler » qui réinsère le produit supprimé avec insererProduit, puis rafraîchit la liste. Vérifier dans DB Browser que le produit réinséré a retrouvé son ancien id. Pourquoi ?
  • Supprimer une catégorie : le rôle de la clé étrangère
  • Q7.7. Créer CategoriesScreen qui affiche les catégories (lireCategories) dans une liste. Un FloatingActionButton ouvre un AlertDialog contenant un TextField pour ajouter une catégorie (insererCategorie, avec la gestion du doublon de la Q4.4).
  • Q7.8. Dans CategorieDao, écrire supprimerCategorie(int id). Ajouter un bouton de suppression sur chaque catégorie. Supprimer la catégorie « Électroménager » (vide) : cela fonctionne-t-il ? Supprimer « Alimentation » (qui contient des produits) : quelle erreur obtenez-vous ? L'intercepter avec on DatabaseException catch (e) et afficher « Impossible : cette catégorie contient des produits ».
  • Q7.9. Dans DB Browser, sur brouillon.db, écrire la requête qui retrouve les produits orphelins (produits dont la catégorie n'existe pas) avec un LEFT JOIN et WHERE c.id IS NULL. Elle doit retourner 0 ligne. Expliquer ce qu'elle retournerait si la Q4.10 avait été faite sans clé étrangère.
Question de réflexion :
Comparer les trois comportements possibles d'une clé étrangère lors de la suppression de la catégorie : ON DELETE RESTRICT, ON DELETE CASCADE et ON DELETE SET NULL. Pour une épicerie, lequel choisiriez-vous et pourquoi ? Quelle modification de la table produits serait nécessaire pour utiliser SET NULL?
  • Résultat attendu à la fin de la partie 07
Résultat attendu à la fin de la partie 07 du TP StockFacile
Suppression par glissement avec confirmation, SnackBar d'annulation, et suppression d'une catégorie refusée par la clé étrangère.
Git – fin de la partie 07 :
git add .
git commit -m "Partie 07 : suppression avec confirmation, annulation et écran Catégories"
git push
  • Partie 08 Requêtes avancées : recherche, tri, jointure et statistiques
  • Objectif : écrire des requêtes plus riches, toujours en les mettant au point d'abord dans DB Browser.
  • Recherche sécurisée
  • Q8.1. Dans DB Browser, écrire la requête qui retourne les produits dont le nom contient « eau » (LIKE '%eau%'). La recherche est-elle sensible aux majuscules ? Et aux accents (« Eau » / « éau ») ?
  • Q8.2. Dans ProduitDao, écrire rechercherProduits(String texte) avec where: 'nom LIKE ?' et whereArgs: ['%$texte%']. Dans ProduitsScreen, ajouter un TextField de recherche au-dessus de la liste : à chaque modification (onChanged), relancer la recherche avec setState.
  • Q8.3. Un camarade a écrit : db.rawQuery("SELECT * FROM produits WHERE nom LIKE '%$texte%'"). Dans DB Browser, recopier la requête qu'il obtiendrait si l'utilisateur tapait ' OR 1=1 --, puis l'exécuter. Que se passe-t-il ? Comment s'appelle cette attaque et pourquoi whereArgs l'empêche-t-il ?
  • Tri et filtre
  • Q8.4. Ajouter dans l'AppBar un PopupMenuButton de tri : nom (A→Z), prix croissant, prix décroissant, quantité croissante. Le tri est transmis au DAO via le paramètre orderBy de db.query. Le texte de orderBy ne peut pas utiliser de ?: pour éviter toute injection, n'accepter que des valeurs d'une liste autorisée :
const Map<String, String> trisAutorises = {
  'nom': 'nom COLLATE NOCASE ASC',
  'prix_asc': 'prix ASC',
  'prix_desc': 'prix DESC',
  'quantite': 'quantite ASC',
};

Future<List<Produit>> lireProduits({String tri = 'nom', String texte = '', int? categorieId}) async {
  final db = await DatabaseHelper().database;
  final conditions = <String>[];
  final arguments = <Object>[];
  // À compléter (Q8.5) : ajouter une condition pour le texte et pour la catégorie
  final lignes = await db.query(
    'produits',
    where: conditions.isEmpty ? null : conditions.join(' AND '),
    whereArgs: arguments.isEmpty ? null : arguments,
    orderBy: trisAutorises[tri] ?? trisAutorises['nom'],
  );
  return lignes.map(Produit.fromMap).toList();
}
  • Q8.5. Compléter la méthode ci-dessus pour combiner recherche + filtre par catégorie + tri. Sous le champ de recherche, afficher une rangée de ChoiceChip (une par catégorie, plus « Toutes ») qui fixe categorieId. À quoi sert COLLATE NOCASE?
  • Jointure
  • Q8.6. Dans DB Browser, mettre au point la requête suivante, puis la copier dans requetes.sql. Pourquoi l'alias AS categorie_nom est-il obligatoire ici ? (Indice : que deviendrait la clé nom dans la Map sans lui ?)
SELECT p.*, c.nom AS categorie_nom
FROM produits p
INNER JOIN categories c ON c.id = p.categorie_id
ORDER BY p.nom;
  • Q8.7. Dans ProduitDao, écrire Future<List<Map<String, Object?>>> lireProduitsAvecCategorie() avec db.rawQuery. Utiliser cette méthode pour afficher le nom de la catégorie dans le subtitle de ProduitTile (par exemple « 18,500 DT – Alimentation »).
  • Statistiques
  • Q8.8. Dans DB Browser, écrire et tester les requêtes suivantes (copier dans requetes.sql) : nombre total de produits, valeur totale du stock, prix moyen, nombre de produits en rupture, nombre de produits et valeur du stock par catégorie. Pour cette dernière, comparer INNER JOIN et LEFT JOIN: laquelle affiche aussi les catégories vides ?
  • Q8.9. Créer StatsScreen qui affiche ces statistiques dans des Card. Utiliser les méthodes de ProduitDao que vous écrirez avec rawQuery; pour une valeur unique, Sqflite.firstIntValue() est pratique :
Future<int> compterProduits() async {
  final db = await DatabaseHelper().database;
  final resultat = await db.rawQuery('SELECT COUNT(*) FROM produits');
  return Sqflite.firstIntValue(resultat) ?? 0;
}
Future<double> valeurStock() async {
  final db = await DatabaseHelper().database;
  final resultat = await db.rawQuery(
    'SELECT SUM(prix * quantite) AS total FROM produits');
  return (resultat.first['total'] as num?)?.toDouble() ?? 0;
}
  • Q8.10. Pourquoi SUM peut-il renvoyer null? Dans quel cas ? Comment le code ci-dessus le gère-t-il ? Capture : captures/08_statistiques.png.
  • Résultat attendu à la fin de la partie 08
Résultat attendu à la fin de la partie 08 du TP StockFacile
Recherche et filtre combinés, tri avec le nom de la catégorie (jointure), et écran Statistiques.
Git – fin de la partie 08 :
git add .
git commit -m "Partie 08 : recherche, tri, filtre, jointure et statistiques"
git push
  • Partie 09 Transactions et batch
  • Objectif : exécuter plusieurs requêtes comme un tout : soit elles réussissent toutes, soit aucune n'est appliquée.
  • Q9.1. Dans ProduitDao, écrire importerProduits(List<Produit> produits) avec un batch. Un batch regroupe les requêtes et les exécute en une seule fois, dans une transaction :
Future<void> importerProduits(List<Produit> produits) async {
  final db = await DatabaseHelper().database;
  final batch = db.batch();
  for (final p in produits) {
    batch.insert('produits', p.toMap());
  }
  await batch.commit(noResult: true);
}
Future<void> importerSansTransaction(List<Produit> produits) async {
  final db = await DatabaseHelper().database;
  for (final p in produits) {
    await db.insert('produits', p.toMap());
  }
}
  • Q9.2. Dans StatsScreen, ajouter un bouton « Importer un jeu de démonstration » qui importe 15 produits de votre choix avec importerProduits. Vérifier le nombre de produits dans DB Browser.
  • Q9.3. Test d'atomicité : préparer une liste de 10 produits dont le 6e a un prix de -1.
    • avec importerSansTransaction: combien de produits sont insérés avant l'erreur ? Vérifier dans DB Browser ;
    • avec importerProduits (batch) : combien de produits sont insérés ? Vérifier dans DB Browser ;
    • expliquer la différence en une phrase avec le mot « atomique ».
  • Q9.4. Écrire transfererStock(int idSource, int idDestination, int quantite) qui retire une quantité d'un produit et l'ajoute à un autre, avec db.transaction((txn) async { … }). Si le produit source n'a pas assez de stock, lever une exception : aucune des deux modifications ne doit être appliquée. Dans la transaction, pourquoi faut-il utiliser txn et jamais db?
  • Résultat attendu à la fin de la partie 09
Résultat attendu à la fin de la partie 09 du TP StockFacile
Import par batch depuis le menu, test d'atomicité dans la console et vérification du nombre de lignes dans DB Browser.
Git – fin de la partie 09 :
git add .
git commit -m "Partie 09 : import par batch et transfert de stock en transaction"
git push
  • Partie 10 Faire évoluer la base sans perdre les données (migration)
  • Objectif : le gérant veut maintenant un seuil d'alerte par produit (alerte quand la quantité descend à ce seuil). Il faut ajouter une colonne seuil_alerte à une base qui contient déjà des données, sur un téléphone où l'application est déjà installée.
  • La mauvaise méthode
  • Q10.1. Ajouter seulement la ligne seuil_alerte INTEGER NOT NULL DEFAULT 5, dans le CREATE TABLE produits de _creerTables, ajouter le champ seuilAlerte dans Produit (avec fromMap et toMap), puis relancer l'application et ajouter un produit. Recopier l'erreur obtenue.
  • Q10.2. Ouvrir la base de l'application dans DB Browser : la colonne existe-t-elle ? Quelle est la valeur de User Version dans « Éditer les pragmas » ? Expliquer pourquoi onCreate n'a pas été rappelé.
  • La bonne méthode
  • Q10.3. Dans DatabaseHelper:
    • passer versionBase à 2;
    • ajouter onUpgrade: _migrer dans openDatabase;
    • garder la colonne dans _creerTables: elle sert aux nouvelles installations.
// Appelée quand la base existante a une version inférieure à versionBase
Future<void> _migrer(Database db, int ancienneVersion, int nouvelleVersion) async {
  debugPrint('onUpgrade : $ancienneVersion → $nouvelleVersion');

  if (ancienneVersion < 2) {
    await db.execute(
      'ALTER TABLE produits ADD COLUMN seuil_alerte INTEGER NOT NULL DEFAULT 5',
    );
  }
  // Plus tard : if (ancienneVersion < 3) { … }
}
  • Q10.4. Relancer l'application. Quel message s'affiche dans la console ? Ouvrir la base dans DB Browser et vérifier :
    • la colonne seuil_alerte existe et les produits déjà présents ont reçu la valeur 5 ;
    • aucun produit n'a été perdu ;
    • User Version vaut maintenant 2.
  • Capture :
captures/10_migration.png
  • .
  • Q10.5. Désinstaller l'application et la réinstaller. Quel message apparaît cette fois : onCreate ou onUpgrade? Pourquoi est-il indispensable de mettre à jour à la fois _creerTables et _migrer?
  • Q10.6. Pourquoi écrit-on if (ancienneVersion < 2) et non if (ancienneVersion == 1)? Imaginer un utilisateur qui passe directement de la version 1 à la version 3.
  • Q10.7. Utiliser la nouvelle colonne :
    • dans fromMap, lire la valeur avec une valeur par défaut : map['seuil_alerte'] as int? ?? 5;
    • ajouter un champ « Seuil d'alerte » dans le formulaire ;
    • dans ProduitTile, afficher une icône Icons.warning orange quand quantite <= seuilAlerte;
    • dans StatsScreen, ajouter une Card « Produits à recommander » (WHERE quantite <= seuil_alerte, requête testée d'abord dans DB Browser).
  • Q10.8. Ré-exporter le schéma de la base de l'application avec DB Browser dans sql/schema.sql (il remplace la version 1).
Question de réflexion :
Pendant le développement, « désinstaller l'application » efface la base et fait disparaître l'erreur de la Q10.1. Pourquoi cette solution est-elle inacceptable une fois l'application publiée sur le Play Store ?
  • Résultat attendu à la fin de la partie 10
Résultat attendu à la fin de la partie 10 du TP StockFacile
Erreur sans migration puis onUpgrade : 1 → 2, User Version = 2, nouvelle colonne seuil_alerte et icônes d'alerte dans la liste.
Git – fin de la partie 10 :
git add .
git commit -m "Partie 10 : migration version 2, colonne seuil_alerte"
git push
  • Partie 11 Intégration finale et version 1.0 sur GitHub
  • Objectif : assembler les écrans dans une application cohérente et finaliser le dépôt.
  • Q11.1. Créer une HomePage (StatefulWidget) avec une NavigationBar à trois destinations : « Produits » (Icons.inventory_2), « Catégories » (Icons.category), « Statistiques » (Icons.bar_chart). En faire la page d'accueil de MaterialApp.
  • Q11.2. Supprimer tests_screen.dart et tous ses appels. Déplacer le bouton « Importer un jeu de démonstration » dans un menu (PopupMenuButton) de l'AppBar de StatsScreen.
  • Q11.3. Dans ce même menu, ajouter « Réinitialiser la base ». Après confirmation, il appelle une nouvelle méthode reinitialiser() de DatabaseHelper qui ferme la base, supprime le fichier avec deleteDatabase(chemin) et remet _db à null. Au prochain accès, que se passe-t-il ?
  • Q11.4. Vérifier la cohérence des données : un produit ajouté, modifié ou supprimé doit être pris en compte dans les trois onglets (pensez à recharger les données quand on change d'onglet), et toujours présent après un redémarrage complet de l'application.
  • Q11.5. Faire les captures finales dans captures/: liste des produits (avec recherche et alerte), détail, formulaire, catégories, statistiques, ainsi que trois captures de DB Browser (structure, données de produits, pragma User Version = 2).
  • Finaliser le dépôt GitHub
  • Q11.6. Créer à la racine du projet un fichier README.md (modèle ci-dessous) : description, fonctionnalités, schéma de la base, captures d'écran et commandes pour lancer le projet.
  • Q11.7. Vérifier que reponses.md, sql/schema.sql et sql/requetes.sql sont complets, et qu'aucun fichier .db, mot de passe ou jeton n'apparaît dans le dépôt (git ls-files | findstr .db sous Windows, git ls-files | grep .db sous Linux/macOS doit ne rien afficher).
  • Q11.8. Faire le dernier commit, puis marquer la version finale avec une étiquette v1.0 et l'envoyer sur GitHub. Vérifier que le README s'affiche avec les captures et que l'étiquette apparaît dans Tags.
# StockFacile

Application Flutter de gestion de stock réalisée
dans le cadre du TP « Base de données SQLite ».

## Fonctionnalités
- Ajout, modification et suppression de produits (CRUD)
- Catégories liées par clé étrangère
- Recherche, tri et filtre par catégorie
- Statistiques (valeur du stock, ruptures, alertes)
- Import par batch, transactions, migration de version

## Base de données
- `categories` (id, nom, description)
- `produits` (id, nom, prix, quantite, disponible,
  date_ajout, categorie_id, seuil_alerte)
- Schéma complet : `sql/schema.sql`

## Captures d'écran
![Produits](captures/11_produits.png)
![Statistiques](captures/11_statistiques.png)
![DB Browser](captures/11_dbbrowser_structure.png)

## Lancer le projet
flutter pub get
flutter run

## Auteur
Prénom Nom – Groupe
git add .
git commit -m "Partie 11 : intégration finale, README et captures"
git push

git tag v1.0
git push origin v1.0
  • Résultat attendu à la fin de la partie 11
Résultat attendu à la fin de la partie 11 du TP StockFacile
Application finale à trois onglets et dépôt GitHub finalisé : historique d'un commit par partie, README et étiquette v1.0.
  1. Exercices complémentaires (bonus)

  • Base pré-remplie : préparer une base complète dans DB Browser, la placer dans assets/ et la copier dans getDatabasesPath() au premier lancement (si le fichier n'existe pas encore), au lieu de la créer avec onCreate.
  • Base protégée par mot de passe : remplacer sqflite par sqflite_sqlcipher pour chiffrer la base avec un mot de passe. Essayer ensuite de l'ouvrir dans DB Browser (version SQLCipher) avec et sans le mot de passe. Expliquer pourquoi ce mot de passe ne doit pas être écrit en clair dans le code envoyé sur GitHub.
  • Historique des mouvements : ajouter une troisième table mouvements (entrée / sortie, quantité, date, produit_id) remplie à chaque appel de ajusterQuantite, dans une transaction (migration en version 3).
  • Export CSV : exporter la liste des produits dans un fichier .csv et l'ouvrir dans un tableur.
  • Intégration continue : ajouter un fichier .github/workflows/flutter.yml qui lance flutter analyze à chaque git push (GitHub Actions), et afficher le badge de statut dans le README.md.
  1. Compte rendu

  • À rendre :
    • le lien de votre dépôt GitHub stock-facile, envoyé dès la partie 00 : c'est ce dépôt qui est corrigé, dans l'état de l'étiquette v1.0;
    • dans le dépôt, le fichier reponses.md: réponses aux questions Q0.5, Q1.3, Q1.4, Q1.6, Q1.7, Q1.8, Q2.3, Q3.2, Q3.5, Q3.7, Q4.3, Q4.4, Q4.9, Q4.10, Q4.11, Q5.3, Q5.4, Q5.7, Q5.11, Q6.2, Q6.9, Q6.10, Q6.12, Q6.16, Q7.5, Q7.6, Q7.8, Q7.9, Q8.1, Q8.3, Q8.5, Q8.6, Q8.8, Q8.10, Q9.3, Q9.4, Q10.1, Q10.2, Q10.5, Q10.6, Q11.3 et aux questions de réflexion ;
    • dans le dépôt, les fichiers sql/schema.sql (version 2) et sql/requetes.sql (toutes les requêtes testées dans DB Browser, commentées) ;
    • dans le dépôt, le README.md et les captures d'écran du dossier captures/, dont celles de DB Browser.
  • Barème indicatif (/20) :
Critère Points
Partie 00 – mise en place, DB Browser, .gitignore 1
Partie 01 – base conçue dans DB Browser, tests des contraintes, schema.sql 1,5
Partie 02 – modèles, fromMap / toMap, conversions de types 1,5
Partie 03 – DatabaseHelper, clés étrangères, base ouverte dans DB Browser 1,5
Partie 04 – insertion et gestion des erreurs de contraintes 2
Partie 05 – lecture, FutureBuilder, liste, rafraîchissement, détail 2
Partie 06 – formulaire validé, update, modifications partielles 2,5
Partie 07 – suppression, confirmation, annulation, clé étrangère 2
Partie 08 – recherche sécurisée, tri, filtre, jointure, statistiques 2
Partie 09 – batch et transaction 1
Partie 10 – migration sans perte de données 1
Partie 11 – intégration finale et cohérence des données 0,5
Git / GitHub – dépôt créé en partie 00, un commit par partie avec un message clair, aucun .db ni secret versionné, README.md, étiquette v1.0 1,5
Total 20
Bonus +2 max
Question de réflexion (à rédiger en 5 lignes) :
Tout au long du TP, vous avez testé chaque requête dans DB Browser avant de l'écrire en Dart, puis vérifié le résultat dans DB Browser après l'avoir exécutée depuis Flutter. Expliquer en quoi cette méthode vous a aidé à trouver vos erreurs, en citant un exemple précis rencontré pendant la séance.