TP Base de données SQLite : application StockFacile avec Flutter
-
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
sqfliteetpath(etsqflite_common_ffipour Windows / Linux) ; - écrire des classes modèles avec un constructeur
factory fromMap(), une méthodetoMap()et une méthodecopyWith(), en convertissant les types que SQLite ne connaît pas (bool,DateTime) ; - créer un
DatabaseHelperunique (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
FutureBuilderet rafraîchir l'affichage après chaque modification ; - écrire des requêtes avancées : recherche
LIKEsécurisée avecwhereArgs, 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.mdet 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îtreStatefulWidget,setState,Form,Navigator.pushetFutureBuilder. - 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
adbfourni avec Android Studio (vérifier avecadb devices).
- un compte GitHub gratuit et Git installé sur le poste (vérifier avec
- Attention :
sqflitene fonctionne pas sur Flutter Web. Ne lancez pas ce TP dans Chrome : utilisez Android, iOS, macOS, Windows ou Linux.
-
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 |
-
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, oubrew 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,
« 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.
« É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_facileet supprimer le code du compteur par défaut dansmain.dart. Le nom du paquet Android seracom.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/etcaptures/et les fichiers videsreponses.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 bienbuild/et.dart_tool/, puis ajouter les lignes ci-dessus (à droite). Répondre dansreponses.md: pourquoi ne faut-il pas envoyer les fichiers.dbsur GitHub alors qu'on envoieschema.sql? - Q0.6. Dans
main.dart, créer unMaterialApp(titre « StockFacile »,useMaterial3: true, une couleur de thème de votre choix) dont la page d'accueil est un simpleScaffoldavec uneAppBar« 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). RemplacerVOTRE-COMPTEpar 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 fichierpubspec.yamlapparaissent. Envoyer le lien du dépôt à l'enseignant avant de commencer la partie 01.
Authentification et mots de passe : lors du premier
Règle de sécurité : un mot de passe ou un jeton ne doit jamais être écrit dans le code, dans
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 ?
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
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.dbdans 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
BOOLEANni typeDATETIME. Comment stocke-t-ondisponibleetdate_ajout? - Quel est le rôle de
AUTOINCREMENT? DeUNIQUEsurnom? DeCHECK (prix >= 0)? - Que signifie
ON DELETE RESTRICT?
- SQLite ne possède ni type
- 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.sqlavec un commentaire-- Q1.5.xau-dessus de chacune (bouton « Enregistrer le fichier SQL » ou copier-coller) :- tous les produits (
SELECT * FROM produits;) ; - les produits en rupture de stock (
quantite = 0) ; - les produits triés du plus cher au moins cher ;
- le nom de chaque produit avec le nom de sa catégorie (
INNER JOIN) ; - le nombre de produits par catégorie (
COUNT+GROUP BY) ; - la valeur totale du stock (
SUM(prix * quantite)).
- tous les produits (
- 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
Si une requête de test a réussi, cliquer sur « Annuler les modifications » pour revenir à l'état enregistré.
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
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.yamlet vérifier qu'ils apparaissent dansdependencies:. 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.dartcomme ci-dessus (à droite). Si vous travaillez sur Android ou iOS, ces lignes ne sont pas nécessaires. Si une erreur indique quesqlite3.dllest introuvable sous Windows, ajouter aussi le packagesqlite3_flutter_libs. - Q2.3. Dans
models/categorie.dart, créer la classeCategoriesuivante. Pourquoiidest-il de typeint?(nullable) ? PourquoitoMap()n'ajoute-t-ilidque s'il n'est pasnull?
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 classeProduità partir du début ci-dessous. Le constructeurfromMapest fourni : écrire vous-même la méthodetoMap()en respectant les conversions du tableau, puis une méthodetoString().
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
Question de réflexion : pourquoi utilise-t-on ici un constructeur
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
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 instructionsCREATE TABLEsont celles deschema.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.mden 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 = ONest-il placé dansonConfigureet non dansonCreate? - (4) Combien de fois
onCreateest-il appelé dans toute la vie de l'application ?
- (1) Que se passe-t-il si on écrit deux fois
- Q3.3. Dans
screens/tests_screen.dart, créer un écranTestsScreen(StatelessWidget) contenant uneListViewde 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
TestsScreenla 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
onCreateet le nombre de catégories.
- le chemin complet de la base affiché par
- Q3.5. Arrêter l'application, la relancer et recliquer. Le message
onCreates'affiche-t-il encore ? Pourquoi ? -
Ouvrir la base de l'application dans DB Browser
- Q3.6. Récupérer le fichier
stock_facile.dbcréé 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
2) Android écrit les dernières modifications dans un fichier
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
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
categoriescontient les 4 catégories de départ, la tableproduitsest vide ; - onglet Éditer les pragmas: relever la valeur de User Version. À quelle ligne du code correspond-elle ?
- onglet Structure: les deux tables existent avec les mêmes colonnes que
- 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
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
Étape 2 – DAO : écrire la méthode Dart correspondante dans le DAO.
Étape 3 – Test : l'appeler depuis un bouton de
É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.
É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.dbdans 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 dansrequetes.sql. - Q4.2 (Étape 2 – DAO). Dans
database/categorie_dao.dart, créer la classeCategorieDaoavec 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èreCategorie(nom: 'Électroménager', description: 'Petits appareils')et affiche dans la console l'idretourné. Cliquer une fois: quelidobtenez-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 / catchqui intercepte l'erreur et affiche unSnackBar« 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'INSERTdu 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 dansrequetes.sql. - Q4.7 (Étape 2 – DAO). Dans
database/produit_dao.dart, créer la classeProduitDaoet écrire vous-même la méthodeFuture<int> insererProduit(Produit produit)sur le modèle deinsererCategorie. - Q4.8 (Étape 3 – Test). Ajouter un bouton « Test : ajouter un produit » qui insère le stylo avec
dateAjout: DateTime.now()etcategorieId: 4. Afficher l'iddans la console. - Q4.9 (Étape 4 – Vérification). Ouvrir la base de l'application dans DB Browser. Comment sont stockées les valeurs de
disponibleetdate_ajout? Est-ce conforme à votre méthodetoMap()? 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 lignePRAGMA foreign_keys = ONdansDatabaseHelper, 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
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
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'idvaut 3. Les copier dansrequetes.sql. - Q5.2 (Étape 2 – DAO). Dans
CategorieDao, ajouter la méthode fournie ci-dessous. DansProduitDao, écrire vous-mêmeFuture<List<Produit>> lireProduits()(triés par nom) etFuture<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 avecdebugPrint(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 lesMaprenvoyées parsqflite? - c) ensuite, avec conversion: appeler
ProduitDao().lireProduits()et afficher letoString()de chaque produit.
- a) d'abord, sans conversion: dans le bouton, exécuter
- Q5.4. Tester
lireProduitParId(1)puislireProduitParId(999). Pourquoi le type de retour est-ilProduit?et pasProduit? -
Étape 5 – L'écran de la liste des produits
- Q5.5. Dans
widgets/produit_tile.dart, créer unStatelessWidgetProduitTilequi reçoit unProduitet un callbackonTap, et retourne unListTile:leading: unCircleAvataraffichant 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_circlesi disponible,Icons.cancelsinon.
- Q5.6. Dans
screens/produits_screen.dart, créerProduitsScreen(StatefulWidget). Charger les produits dansinitStateet les afficher avec unFutureBuilderqui 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 leFutureBuilder, sans passer parinitState. Ajouter undebugPrintdanslireProduits()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
RefreshIndicatordontonRefreshappelle_rafraichir()(tirer la liste vers le bas pour recharger). DansTestsScreen, ajouter un bouton qui ouvreProduitsScreenavecNavigator.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, écrireFuture<Categorie?> lireCategorieParId(int id). - Q5.11. Créer
ProduitDetailScreenqui reçoit seulement l'iddu produit (et non l'objet entier), charge le produit aveclireProduitParIdet sa catégorie aveclireCategorieParId, puis affiche dans uneCard: 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'idet de relire la base ? - Q5.12. Au clic sur un
ProduitTile, ouvrirProduitDetailScreen. Capture de la liste :captures/05_liste_produits.png.
-
Résultat attendu à la fin de la partie 05
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 optionnelfinal Produit? produit. Le titre de l'AppBarest « Nouveau produit » siproduitestnull, « Modifier le produit » sinon. - Q6.2. Construire un
Formavec uneGlobalKey<FormState>et troisTextEditingController(nom, prix, quantité). Libérer les contrôleurs dansdispose(): 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 avantdouble.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 dansinitStateavecCategorieDao().lireCategories(). La valeur du menu est l'idde 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 :
- valider le formulaire (
_formKey.currentState!.validate()) ; s'il est invalide, s'arrêter ; - construire l'objet
Produità partir des champs (dateAjout: DateTime.now()) ; - appeler
ProduitDao().insererProduit(produit)avecawait; - vérifier que l'écran existe encore :
if (!context.mounted) return;; - fermer l'écran en renvoyant
true:Navigator.pop(context, true).
- valider le formulaire (
- Q6.7. Dans
ProduitsScreen, ajouter unFloatingActionButtonqui ouvre le formulaire. Au retour, si le résultat vauttrue, appeler_rafraichir()et afficher unSnackBar« 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;sansWHERE. Que constatez-vous dans « Parcourir les données » ? - cliquer sur « Annuler les modifications ». Noter dans
reponses.mdla leçon à retenir.
- exécuter
- Q6.10. Dans la classe
Produit, écrire une méthodecopyWith()qui retourne une copie du produit en remplaçant seulement les champs fournis. Pourquoi en a-t-on besoin alors que tous les champs sontfinal? - Q6.11 (Étape 2 – DAO). Dans
ProduitDao, écrireFuture<int> modifierProduit(Produit produit)avecdb.update,where: 'id = ?'etwhereArgs: [produit.id]. - Q6.12 (Étape 3 – Test). Dans
TestsScreen, ajouter un bouton qui lit le produit 1, augmente son prix de 1 DT aveccopyWith, appellemodifierProduitet affiche la valeur retournée. Que vaut-elle ? Que vaudrait-elle si l'idn'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, siwidget.produitn'est pasnull, pré-remplir les contrôleurs, la catégorie et le switch ; - à l'enregistrement, utiliser
widget.produit!.copyWith(…)(on garde l'idet la date d'ajout d'origine) puismodifierProduitau lieu deinsererProduit; - dans
ProduitDetailScreen, ajouter unIconButton(Icons.edit) qui ouvre le formulaire avec le produit. Au retour, recharger le détail. En quittant le détail, renvoyertruepour que la liste se rafraîchisse aussi.
- dans
-
Les modifications partielles
- Q6.15. Dans
ProduitDao, ajouter une méthode qui modifie une seule colonne. Passer uneMapqui 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 appellentajusterQuantite(id, -1)etajusterQuantite(id, 1), puis rechargent le produit. Que retourneajusterQuantitequand on clique sur « − » alors que la quantité vaut 0 ? Pourquoi le calculquantite + ?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
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écuterDELETE FROM produits WHERE id = 6;, vérifier, puis annuler les modifications. Copier la requête dansrequetes.sql. - Q7.2 (Étapes 2 et 3). Dans
ProduitDao, écrireFuture<int> supprimerProduit(int id). La tester avec un bouton deTestsScreensur un produit que vous avez créé, puis vérifier dans DB Browser (étape 4). - Q7.3 (Étape 5 – Interface). Dans
ProduitDetailScreen, ajouter unIconButton(Icons.delete) qui affiche unAlertDialog« Supprimer ce produit ? » avec « Annuler » et « Supprimer ». Si l'utilisateur confirme : supprimer, puis revenir à la liste avecNavigator.pop(context, true). - Q7.4. Dans
ProduitsScreen, entourer chaqueProduitTiled'unDismissible(glisser vers la gauche pour supprimer, fond rouge avec une icône de corbeille) :key: ValueKey(produit.id);confirmDismiss: afficher le mêmeAlertDialoget retournertrueoufalse;onDismissed: retirer d'abord le produit de la liste affichée (snapshot.data!.removeAt(index)), puis appelersupprimerProduit.
- 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é avecinsererProduit, puis rafraîchit la liste. Vérifier dans DB Browser que le produit réinséré a retrouvé son ancienid. Pourquoi ? -
Supprimer une catégorie : le rôle de la clé étrangère
- Q7.7. Créer
CategoriesScreenqui affiche les catégories (lireCategories) dans une liste. UnFloatingActionButtonouvre unAlertDialogcontenant unTextFieldpour ajouter une catégorie (insererCategorie, avec la gestion du doublon de la Q4.4). - Q7.8. Dans
CategorieDao, écriresupprimerCategorie(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 avecon 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 unLEFT JOINetWHERE 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 :
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
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, écrirerechercherProduits(String texte)avecwhere: 'nom LIKE ?'etwhereArgs: ['%$texte%']. DansProduitsScreen, ajouter unTextFieldde recherche au-dessus de la liste : à chaque modification (onChanged), relancer la recherche avecsetState. - 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 pourquoiwhereArgsl'empêche-t-il ? -
Tri et filtre
- Q8.4. Ajouter dans l'
AppBarunPopupMenuButtonde tri : nom (A→Z), prix croissant, prix décroissant, quantité croissante. Le tri est transmis au DAO via le paramètreorderBydedb.query. Le texte deorderByne 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 fixecategorieId. À quoi sertCOLLATE NOCASE? -
Jointure
- Q8.6. Dans DB Browser, mettre au point la requête suivante, puis la copier dans
requetes.sql. Pourquoi l'aliasAS categorie_nomest-il obligatoire ici ? (Indice : que deviendrait la clénomdans laMapsans 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, écrireFuture<List<Map<String, Object?>>> lireProduitsAvecCategorie()avecdb.rawQuery. Utiliser cette méthode pour afficher le nom de la catégorie dans lesubtitledeProduitTile(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, comparerINNER JOINetLEFT JOIN: laquelle affiche aussi les catégories vides ? - Q8.9. Créer
StatsScreenqui affiche ces statistiques dans desCard. Utiliser les méthodes deProduitDaoque vous écrirez avecrawQuery; 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
SUMpeut-il renvoyernull? 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
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, écrireimporterProduits(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 avecimporterProduits. 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 ».
- avec
- Q9.4. Écrire
transfererStock(int idSource, int idDestination, int quantite)qui retire une quantité d'un produit et l'ajoute à un autre, avecdb.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 utilisertxnet jamaisdb?
-
Résultat attendu à la fin de la partie 09
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 leCREATE TABLE produitsde_creerTables, ajouter le champseuilAlertedansProduit(avecfromMapettoMap), 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
onCreaten'a pas été rappelé. -
La bonne méthode
- Q10.3. Dans
DatabaseHelper:- passer
versionBaseà 2; - ajouter
onUpgrade: _migrerdansopenDatabase; - garder la colonne dans
_creerTables: elle sert aux nouvelles installations.
- passer
// 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_alerteexiste et les produits déjà présents ont reçu la valeur 5 ; - aucun produit n'a été perdu ;
- User Version vaut maintenant 2.
- la colonne
- Capture :
captures/10_migration.png
- .
- Q10.5. Désinstaller l'application et la réinstaller. Quel message apparaît cette fois :
onCreateouonUpgrade? Pourquoi est-il indispensable de mettre à jour à la fois_creerTableset_migrer? - Q10.6. Pourquoi écrit-on
if (ancienneVersion < 2)et nonif (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ôneIcons.warningorange quandquantite <= seuilAlerte; - dans
StatsScreen, ajouter uneCard« Produits à recommander » (WHERE quantite <= seuil_alerte, requête testée d'abord dans DB Browser).
- dans
- 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 ?
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
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 uneNavigationBarà trois destinations : « Produits » (Icons.inventory_2), « Catégories » (Icons.category), « Statistiques » (Icons.bar_chart). En faire la page d'accueil deMaterialApp. - Q11.2. Supprimer
tests_screen.dartet tous ses appels. Déplacer le bouton « Importer un jeu de démonstration » dans un menu (PopupMenuButton) de l'AppBardeStatsScreen. - Q11.3. Dans ce même menu, ajouter « Réinitialiser la base ». Après confirmation, il appelle une nouvelle méthode
reinitialiser()deDatabaseHelperqui ferme la base, supprime le fichier avecdeleteDatabase(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 deproduits, 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.sqletsql/requetes.sqlsont complets, et qu'aucun fichier.db, mot de passe ou jeton n'apparaît dans le dépôt (git ls-files | findstr .dbsous Windows,git ls-files | grep .dbsous Linux/macOS doit ne rien afficher). - Q11.8. Faire le dernier commit, puis marquer la version finale avec une étiquette
v1.0et 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



## 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
v1.0. -
Exercices complémentaires (bonus)
- Base pré-remplie : préparer une base complète dans DB Browser, la placer dans
assets/et la copier dansgetDatabasesPath()au premier lancement (si le fichier n'existe pas encore), au lieu de la créer aveconCreate. - Base protégée par mot de passe : remplacer
sqfliteparsqflite_sqlcipherpour 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 deajusterQuantite, dans une transaction (migration en version 3). - Export CSV : exporter la liste des produits dans un fichier
.csvet l'ouvrir dans un tableur. - Intégration continue : ajouter un fichier
.github/workflows/flutter.ymlqui lanceflutter analyzeà chaquegit push(GitHub Actions), et afficher le badge de statut dans leREADME.md.
-
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'étiquettev1.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) etsql/requetes.sql(toutes les requêtes testées dans DB Browser, commentées) ; - dans le dépôt, le
README.mdet les captures d'écran du dossiercaptures/, dont celles de DB Browser.
- le lien de votre dépôt GitHub
- 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.
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.