feat: implement base architecture and core repositories for weapon tracking and target analysis functionality
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
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 {
|
||||
@@ -36,6 +39,19 @@ class DatabaseHelper {
|
||||
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,
|
||||
@@ -46,7 +62,8 @@ class DatabaseHelper {
|
||||
notes TEXT,
|
||||
target_center_x REAL,
|
||||
target_center_y REAL,
|
||||
target_radius REAL
|
||||
target_radius REAL,
|
||||
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
@@ -56,22 +73,212 @@ class DatabaseHelper {
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
|
||||
analysis_id TEXT NOT NULL,
|
||||
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE INDEX idx_shots_session_id ON ${AppConstants.shotsTable}(session_id)
|
||||
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 {
|
||||
// Handle future database migrations here
|
||||
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
|
||||
@@ -84,20 +291,39 @@ class DatabaseHelper {
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
|
||||
for (final shot in session.shots) {
|
||||
// Ensure shot has correct session_id
|
||||
final shotWithSessionId = shot.copyWith(sessionId: session.id);
|
||||
await txn.insert(
|
||||
AppConstants.shotsTable,
|
||||
shotWithSessionId.toMap(),
|
||||
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(
|
||||
@@ -108,106 +334,52 @@ class DatabaseHelper {
|
||||
|
||||
if (sessionMaps.isEmpty) return null;
|
||||
|
||||
final shotMaps = await db.query(
|
||||
AppConstants.shotsTable,
|
||||
final analysisMaps = await db.query(
|
||||
AppConstants.targetAnalysesTable,
|
||||
where: 'session_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
||||
return Session.fromMap(sessionMaps.first, shots);
|
||||
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({
|
||||
String? targetType,
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) async {
|
||||
final db = await database;
|
||||
|
||||
String? whereClause;
|
||||
List<dynamic>? whereArgs;
|
||||
|
||||
if (targetType != null) {
|
||||
whereClause = 'target_type = ?';
|
||||
whereArgs = [targetType];
|
||||
}
|
||||
|
||||
final sessionMaps = await db.query(
|
||||
AppConstants.sessionsTable,
|
||||
where: whereClause,
|
||||
whereArgs: whereArgs,
|
||||
orderBy: 'created_at DESC',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
|
||||
// First, check if there are orphaned shots (with empty session_id)
|
||||
// and only one session - if so, assign them to that session
|
||||
if (sessionMaps.length == 1) {
|
||||
final orphanedShots = await db.query(
|
||||
AppConstants.shotsTable,
|
||||
where: 'session_id = ?',
|
||||
whereArgs: [''],
|
||||
);
|
||||
if (orphanedShots.isNotEmpty) {
|
||||
await db.update(
|
||||
AppConstants.shotsTable,
|
||||
{'session_id': sessionMaps.first['id']},
|
||||
where: 'session_id = ?',
|
||||
whereArgs: [''],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final sessions = <Session>[];
|
||||
for (final sessionMap in sessionMaps) {
|
||||
final sessionId = sessionMap['id'] as String;
|
||||
|
||||
final shotMaps = await db.query(
|
||||
AppConstants.shotsTable,
|
||||
where: 'session_id = ?',
|
||||
whereArgs: [sessionId],
|
||||
);
|
||||
|
||||
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
||||
sessions.add(Session.fromMap(sessionMap, shots));
|
||||
final session = await getSession(sessionId);
|
||||
if (session != null) {
|
||||
sessions.add(session);
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
Future<int> updateSession(Session session) async {
|
||||
final db = await database;
|
||||
return await db.transaction((txn) async {
|
||||
await txn.update(
|
||||
AppConstants.sessionsTable,
|
||||
session.toMap(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [session.id],
|
||||
);
|
||||
|
||||
// Delete existing shots and insert new ones
|
||||
await txn.delete(
|
||||
AppConstants.shotsTable,
|
||||
where: 'session_id = ?',
|
||||
whereArgs: [session.id],
|
||||
);
|
||||
|
||||
for (final shot in session.shots) {
|
||||
// Ensure shot has correct session_id
|
||||
final shotWithSessionId = shot.copyWith(sessionId: session.id);
|
||||
await txn.insert(
|
||||
AppConstants.shotsTable,
|
||||
shotWithSessionId.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
Future<int> deleteSession(String id) async {
|
||||
final db = await database;
|
||||
return await db.delete(
|
||||
@@ -229,12 +401,12 @@ class DatabaseHelper {
|
||||
) ?? 0;
|
||||
|
||||
final avgScore = (await db.rawQuery(
|
||||
'SELECT AVG(total_score) as avg FROM ${AppConstants.sessionsTable}',
|
||||
'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.sessionsTable}',
|
||||
'SELECT MAX(total_score) FROM ${AppConstants.targetAnalysesTable}',
|
||||
),
|
||||
) ?? 0;
|
||||
|
||||
@@ -246,6 +418,121 @@ class DatabaseHelper {
|
||||
};
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
Reference in New Issue
Block a user