import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import '../models/session.dart'; import '../models/target_analysis.dart'; import '../models/shot.dart'; import '../models/weapon.dart'; import '../models/maintenance.dart'; import '../../core/constants/app_constants.dart'; class DatabaseHelper { static DatabaseHelper? _instance; static Database? _database; // On met en cache le Future d'initialisation (et non la Database résolue) // pour éviter qu'un démarrage concurrent (les 4 onglets de l'IndexedStack // interrogent la base en même temps) ne lance plusieurs _initDatabase() en // parallèle. Sur une base fraîche, cela dédoublait onCreate et rendait la // toute première écriture peu fiable. static Future? _initFuture; DatabaseHelper._internal(); factory DatabaseHelper() { _instance ??= DatabaseHelper._internal(); return _instance!; } Future get database async { if (_database != null) return _database!; _initFuture ??= _initDatabase(); _database = await _initFuture!; return _database!; } Future _initDatabase() async { final databasesPath = await getDatabasesPath(); final path = join(databasesPath, AppConstants.databaseName); return await openDatabase( path, version: AppConstants.databaseVersion, onCreate: _onCreate, onUpgrade: _onUpgrade, ); } Future _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE ${AppConstants.sessionsTable} ( id TEXT PRIMARY KEY, weapon TEXT NOT NULL, weapon_id TEXT, max_shots_per_target INTEGER NOT NULL, created_at TEXT NOT NULL, notes TEXT, distance INTEGER DEFAULT 25 ) '''); await db.execute(''' CREATE TABLE ${AppConstants.targetAnalysesTable} ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, target_type TEXT NOT NULL, image_path TEXT NOT NULL, total_score INTEGER NOT NULL, grouping_diameter REAL, grouping_center_x REAL, grouping_center_y REAL, created_at TEXT NOT NULL, notes TEXT, target_center_x REAL, target_center_y REAL, target_radius REAL, FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE ) '''); await db.execute(''' CREATE TABLE ${AppConstants.shotsTable} ( id TEXT PRIMARY KEY, x REAL NOT NULL, y REAL NOT NULL, score INTEGER NOT NULL, analysis_id TEXT NOT NULL, FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE ) '''); await db.execute(''' CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id) '''); await db.execute(''' CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id) '''); await db.execute(''' CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC) '''); await db.execute(''' CREATE TABLE ${AppConstants.weaponsTable} ( id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, caliber TEXT NOT NULL, magazine_count INTEGER NOT NULL, magazine_capacity INTEGER NOT NULL, notes TEXT, created_at TEXT NOT NULL, optic TEXT, silencer TEXT, trigger TEXT, custom_name TEXT ) '''); await db.execute(''' CREATE TABLE ${AppConstants.maintenanceTable} ( id TEXT PRIMARY KEY, weapon_id TEXT NOT NULL, type TEXT NOT NULL, description TEXT NOT NULL, date TEXT NOT NULL, rounds_since_last INTEGER, FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE ) '''); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { if (oldVersion < 2) { // Migration from single-target sessions to multi-target sessions // 1. Rename existing sessions table to a temporary name await db.execute('ALTER TABLE ${AppConstants.sessionsTable} RENAME TO sessions_old'); // 2. Create the new sessions table await db.execute(''' CREATE TABLE ${AppConstants.sessionsTable} ( id TEXT PRIMARY KEY, weapon TEXT NOT NULL, max_shots_per_target INTEGER NOT NULL, created_at TEXT NOT NULL, notes TEXT ) '''); // 3. Create the new target_analyses table await db.execute(''' CREATE TABLE ${AppConstants.targetAnalysesTable} ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, target_type TEXT NOT NULL, image_path TEXT NOT NULL, total_score INTEGER NOT NULL, grouping_diameter REAL, grouping_center_x REAL, grouping_center_y REAL, created_at TEXT NOT NULL, notes TEXT, target_center_x REAL, target_center_y REAL, target_radius REAL, FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE ) '''); // 4. Update shots table (rename session_id to analysis_id) await db.execute('ALTER TABLE ${AppConstants.shotsTable} RENAME TO shots_old'); await db.execute(''' CREATE TABLE ${AppConstants.shotsTable} ( id TEXT PRIMARY KEY, x REAL NOT NULL, y REAL NOT NULL, score INTEGER NOT NULL, analysis_id TEXT NOT NULL, FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE ) '''); // 5. Migrate data // For each old session, create one session and one target_analysis final oldSessions = await db.query('sessions_old'); for (final old in oldSessions) { final sessionId = old['id'] as String; final analysisId = 'analysis_$sessionId'; // Create a unique ID for the analysis // Insert into new sessions await db.insert(AppConstants.sessionsTable, { 'id': sessionId, 'weapon': 'Inconnue', 'max_shots_per_target': 5, 'created_at': old['created_at'], 'notes': old['notes'], }); // Insert into target_analyses await db.insert(AppConstants.targetAnalysesTable, { 'id': analysisId, 'session_id': sessionId, 'target_type': old['target_type'], 'image_path': old['image_path'], 'total_score': old['total_score'], 'grouping_diameter': old['grouping_diameter'], 'grouping_center_x': old['grouping_center_x'], 'grouping_center_y': old['grouping_center_y'], 'created_at': old['created_at'], 'notes': null, 'target_center_x': old['target_center_x'], 'target_center_y': old['target_center_y'], 'target_radius': old['target_radius'], }); // Migrate shots from shots_old to shots final oldShots = await db.query('shots_old', where: 'session_id = ?', whereArgs: [sessionId]); for (final shot in oldShots) { await db.insert(AppConstants.shotsTable, { 'id': shot['id'], 'x': shot['x'], 'y': shot['y'], 'score': shot['score'], 'analysis_id': analysisId, }); } } // 6. Cleanup await db.execute('DROP TABLE sessions_old'); await db.execute('DROP TABLE shots_old'); // 7. Re-create indexes await db.execute(''' CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id) '''); await db.execute(''' CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id) '''); await db.execute(''' CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC) '''); } if (oldVersion < 3) { // Add weapons table await db.execute(''' CREATE TABLE ${AppConstants.weaponsTable} ( id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, caliber TEXT NOT NULL, magazine_count INTEGER NOT NULL, magazine_capacity INTEGER NOT NULL, notes TEXT, created_at TEXT NOT NULL ) '''); // Add weapon_id to sessions table await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN weapon_id TEXT'); } if (oldVersion < 4) { // Add maintenance table await db.execute(''' CREATE TABLE ${AppConstants.maintenanceTable} ( id TEXT PRIMARY KEY, weapon_id TEXT NOT NULL, type TEXT NOT NULL, description TEXT NOT NULL, date TEXT NOT NULL, rounds_since_last INTEGER, FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE ) '''); } if (oldVersion < 5) { // Add new weapon options columns await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN optic TEXT'); await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN silencer TEXT'); await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN "trigger" TEXT'); await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN custom_name TEXT'); } if (oldVersion < 6) { // Add distance column to sessions await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN distance INTEGER DEFAULT 25'); } } // Session operations Future insertSession(Session session) async { final db = await database; return await db.transaction((txn) async { // INTERCEPTION ET FIX DE LA DATE ICI : // On extrait la map générée et on force la colonne created_at avec la vraie date de l'objet final Map sessionMap = Map.from(session.toMap()); sessionMap['created_at'] = session.createdAt.toIso8601String(); await txn.insert( AppConstants.sessionsTable, sessionMap, // On utilise notre map corrigée au lieu de session.toMap() direct ! conflictAlgorithm: ConflictAlgorithm.replace, ); for (final analysis in session.analyses) { await insertTargetAnalysisTx(txn, analysis, session.id); } return 1; }); } Future insertTargetAnalysis(TargetAnalysis analysis, String sessionId) async { final db = await database; await db.transaction((txn) async { await insertTargetAnalysisTx(txn, analysis, sessionId); }); } Future insertTargetAnalysisTx(Transaction txn, TargetAnalysis analysis, String sessionId) async { final analysisWithSessionId = analysis.copyWith(sessionId: sessionId); await txn.insert( AppConstants.targetAnalysesTable, analysisWithSessionId.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); for (final shot in analysis.shots) { final shotWithAnalysisId = shot.copyWith(analysisId: analysis.id); await txn.insert( AppConstants.shotsTable, shotWithAnalysisId.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } } Future getSession(String id) async { final db = await database; final sessionMaps = await db.query( AppConstants.sessionsTable, where: 'id = ?', whereArgs: [id], ); if (sessionMaps.isEmpty) return null; final analysisMaps = await db.query( AppConstants.targetAnalysesTable, where: 'session_id = ?', whereArgs: [id], ); final analyses = []; for (final analysisMap in analysisMaps) { final analysisId = analysisMap['id'] as String; final shotMaps = await db.query( AppConstants.shotsTable, where: 'analysis_id = ?', whereArgs: [analysisId], ); final shots = shotMaps.map((map) => Shot.fromMap(map)).toList(); analyses.add(TargetAnalysis.fromMap(analysisMap, shots)); } return Session.fromMap(sessionMaps.first, analyses); } Future> getAllSessions({ int? limit, int? offset, }) async { final db = await database; final sessionMaps = await db.query( AppConstants.sessionsTable, orderBy: 'created_at DESC', limit: limit, offset: offset, ); if (sessionMaps.isEmpty) return []; // 3 requêtes groupées au lieu d'une cascade par session puis par analyse // (N+1) : l'ancien code refaisait un getSession complet pour chaque ligne, // ce qui ralentissait l'accueil/historique/stats au fil des mois. final sessionIds = sessionMaps.map((m) => m['id'] as String).toList(); final analysisMaps = await _queryIn( db, AppConstants.targetAnalysesTable, 'session_id', sessionIds, ); final analysisIds = analysisMaps.map((m) => m['id'] as String).toList(); final shotMaps = await _queryIn( db, AppConstants.shotsTable, 'analysis_id', analysisIds, ); final shotsByAnalysis = >{}; for (final map in shotMaps) { (shotsByAnalysis[map['analysis_id'] as String] ??= []) .add(Shot.fromMap(map)); } final analysesBySession = >{}; for (final map in analysisMaps) { final analysis = TargetAnalysis.fromMap( map, shotsByAnalysis[map['id'] as String] ?? [], ); (analysesBySession[map['session_id'] as String] ??= []).add(analysis); } return sessionMaps .map((m) => Session.fromMap(m, analysesBySession[m['id'] as String] ?? [])) .toList(); } /// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets /// de 500 pour rester sous la limite de variables d'une requête SQLite (999). Future>> _queryIn( Database db, String table, String column, List values, ) async { if (values.isEmpty) return []; const chunkSize = 500; final results = >[]; for (var i = 0; i < values.length; i += chunkSize) { final chunk = values.sublist( i, i + chunkSize > values.length ? values.length : i + chunkSize, ); final placeholders = List.filled(chunk.length, '?').join(','); results.addAll(await db.query( table, where: '$column IN ($placeholders)', whereArgs: chunk, )); } return results; } Future deleteSession(String id) async { final db = await database; return await db.delete( AppConstants.sessionsTable, where: 'id = ?', whereArgs: [id], ); } Future> getStatistics() async { final db = await database; final totalSessions = Sqflite.firstIntValue( await db.rawQuery('SELECT COUNT(*) FROM ${AppConstants.sessionsTable}'), ) ?? 0; final totalShots = Sqflite.firstIntValue( await db.rawQuery('SELECT COUNT(*) FROM ${AppConstants.shotsTable}'), ) ?? 0; final avgScore = (await db.rawQuery( 'SELECT AVG(total_score) as avg FROM ${AppConstants.targetAnalysesTable}', )).first['avg'] as double? ?? 0.0; final bestScore = Sqflite.firstIntValue( await db.rawQuery( 'SELECT MAX(total_score) FROM ${AppConstants.targetAnalysesTable}', ), ) ?? 0; return { 'totalSessions': totalSessions, 'totalShots': totalShots, 'averageScore': avgScore, 'bestScore': bestScore, }; } // Weapon operations Future insertWeapon(Weapon weapon) async { final db = await database; return await db.insert( AppConstants.weaponsTable, weapon.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } Future> getAllWeapons() async { final db = await database; final List> maps = await db.query(AppConstants.weaponsTable, orderBy: 'name ASC'); return List.generate(maps.length, (i) => Weapon.fromMap(maps[i])); } Future getWeapon(String id) async { final db = await database; final List> maps = await db.query( AppConstants.weaponsTable, where: 'id = ?', whereArgs: [id], ); if (maps.isEmpty) return null; return Weapon.fromMap(maps.first); } Future updateWeapon(Weapon weapon) async { final db = await database; // Start a transaction to ensure both updates succeed return await db.transaction((txn) async { // 1. Update weapon in armory final count = await txn.update( AppConstants.weaponsTable, weapon.toMap(), where: 'id = ?', whereArgs: [weapon.id], ); // 2. Propagate name change to all sessions linked to this weapon await txn.update( AppConstants.sessionsTable, {'weapon': weapon.displayName}, where: 'weapon_id = ?', whereArgs: [weapon.id], ); return count; }); } Future deleteWeapon(String id) async { final db = await database; return await db.transaction((txn) async { // 1. Unlink sessions from this weapon but KEEP the name (gravé à jamais) await txn.update( AppConstants.sessionsTable, {'weapon_id': null}, where: 'weapon_id = ?', whereArgs: [id], ); // 2. Delete the weapon record return await txn.delete( AppConstants.weaponsTable, where: 'id = ?', whereArgs: [id], ); }); } // Weapon statistics & Maintenance Future getRoundsFiredForWeapon(String weaponId) async { final db = await database; final result = await db.rawQuery(''' SELECT COUNT(s.id) as count FROM ${AppConstants.shotsTable} s JOIN ${AppConstants.targetAnalysesTable} ta ON s.analysis_id = ta.id JOIN ${AppConstants.sessionsTable} sess ON ta.session_id = sess.id WHERE sess.weapon_id = ? ''', [weaponId]); return Sqflite.firstIntValue(result) ?? 0; } Future getSessionCountForWeapon(String weaponId) async { final db = await database; final result = await db.rawQuery(''' SELECT COUNT(id) as count FROM ${AppConstants.sessionsTable} WHERE weapon_id = ? ''', [weaponId]); return Sqflite.firstIntValue(result) ?? 0; } Future insertMaintenance(MaintenanceEntry entry) async { final db = await database; return await db.insert( AppConstants.maintenanceTable, entry.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } Future> getMaintenanceForWeapon(String weaponId) async { final db = await database; final maps = await db.query( AppConstants.maintenanceTable, where: 'weapon_id = ?', orderBy: 'date DESC', whereArgs: [weaponId], ); return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i])); } Future deleteMaintenance(String id) async { final db = await database; return await db.delete( AppConstants.maintenanceTable, where: 'id = ?', whereArgs: [id], ); } Future close() async { final db = await database; await db.close(); _database = null; _initFuture = null; } }