547 lines
16 KiB
Dart
547 lines
16 KiB
Dart
import 'dart:io';
|
|
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;
|
|
|
|
DatabaseHelper._internal();
|
|
|
|
factory DatabaseHelper() {
|
|
_instance ??= DatabaseHelper._internal();
|
|
return _instance!;
|
|
}
|
|
|
|
Future<Database> get database async {
|
|
_database ??= await _initDatabase();
|
|
return _database!;
|
|
}
|
|
|
|
Future<Database> _initDatabase() async {
|
|
final databasesPath = await getDatabasesPath();
|
|
final path = join(databasesPath, AppConstants.databaseName);
|
|
|
|
return await openDatabase(
|
|
path,
|
|
version: AppConstants.databaseVersion,
|
|
onCreate: _onCreate,
|
|
onUpgrade: _onUpgrade,
|
|
);
|
|
}
|
|
|
|
Future<void> _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<void> _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<int> 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<String, dynamic> 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<void> insertTargetAnalysis(TargetAnalysis analysis, String sessionId) async {
|
|
final db = await database;
|
|
await db.transaction((txn) async {
|
|
await insertTargetAnalysisTx(txn, analysis, sessionId);
|
|
});
|
|
}
|
|
|
|
Future<void> 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<Session?> 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 = <TargetAnalysis>[];
|
|
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<List<Session>> 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,
|
|
);
|
|
|
|
final sessions = <Session>[];
|
|
for (final sessionMap in sessionMaps) {
|
|
final sessionId = sessionMap['id'] as String;
|
|
final session = await getSession(sessionId);
|
|
if (session != null) {
|
|
sessions.add(session);
|
|
}
|
|
}
|
|
|
|
return sessions;
|
|
}
|
|
|
|
Future<int> deleteSession(String id) async {
|
|
final db = await database;
|
|
return await db.delete(
|
|
AppConstants.sessionsTable,
|
|
where: 'id = ?',
|
|
whereArgs: [id],
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> 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<int> insertWeapon(Weapon weapon) async {
|
|
final db = await database;
|
|
return await db.insert(
|
|
AppConstants.weaponsTable,
|
|
weapon.toMap(),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
|
|
Future<List<Weapon>> getAllWeapons() async {
|
|
final db = await database;
|
|
final List<Map<String, dynamic>> maps = await db.query(AppConstants.weaponsTable, orderBy: 'name ASC');
|
|
return List.generate(maps.length, (i) => Weapon.fromMap(maps[i]));
|
|
}
|
|
|
|
Future<Weapon?> getWeapon(String id) async {
|
|
final db = await database;
|
|
final List<Map<String, dynamic>> maps = await db.query(
|
|
AppConstants.weaponsTable,
|
|
where: 'id = ?',
|
|
whereArgs: [id],
|
|
);
|
|
if (maps.isEmpty) return null;
|
|
return Weapon.fromMap(maps.first);
|
|
}
|
|
|
|
Future<int> 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<int> 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<int> 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<int> insertMaintenance(MaintenanceEntry entry) async {
|
|
final db = await database;
|
|
return await db.insert(
|
|
AppConstants.maintenanceTable,
|
|
entry.toMap(),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
|
|
Future<List<MaintenanceEntry>> 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<int> deleteMaintenance(String id) async {
|
|
final db = await database;
|
|
return await db.delete(
|
|
AppConstants.maintenanceTable,
|
|
where: 'id = ?',
|
|
whereArgs: [id],
|
|
);
|
|
}
|
|
|
|
Future<void> close() async {
|
|
final db = await database;
|
|
await db.close();
|
|
_database = null;
|
|
}
|
|
} |