premier app version beta
This commit is contained in:
254
lib/data/database/database_helper.dart
Normal file
254
lib/data/database/database_helper.dart
Normal file
@@ -0,0 +1,254 @@
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import '../models/session.dart';
|
||||
import '../models/shot.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,
|
||||
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
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE ${AppConstants.shotsTable} (
|
||||
id TEXT PRIMARY KEY,
|
||||
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
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE INDEX idx_shots_session_id ON ${AppConstants.shotsTable}(session_id)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
// Handle future database migrations here
|
||||
}
|
||||
|
||||
// Session operations
|
||||
Future<int> insertSession(Session session) async {
|
||||
final db = await database;
|
||||
return await db.transaction((txn) async {
|
||||
await txn.insert(
|
||||
AppConstants.sessionsTable,
|
||||
session.toMap(),
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
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 shotMaps = await db.query(
|
||||
AppConstants.shotsTable,
|
||||
where: 'session_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
||||
return Session.fromMap(sessionMaps.first, shots);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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(
|
||||
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.sessionsTable}',
|
||||
)).first['avg'] as double? ?? 0.0;
|
||||
|
||||
final bestScore = Sqflite.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT MAX(total_score) FROM ${AppConstants.sessionsTable}',
|
||||
),
|
||||
) ?? 0;
|
||||
|
||||
return {
|
||||
'totalSessions': totalSessions,
|
||||
'totalShots': totalShots,
|
||||
'averageScore': avgScore,
|
||||
'bestScore': bestScore,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
}
|
||||
112
lib/data/models/session.dart
Normal file
112
lib/data/models/session.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'shot.dart';
|
||||
import 'target_type.dart';
|
||||
|
||||
class Session {
|
||||
final String id;
|
||||
final TargetType targetType;
|
||||
final String imagePath;
|
||||
final List<Shot> shots;
|
||||
final int totalScore;
|
||||
final double? groupingDiameter;
|
||||
final double? groupingCenterX;
|
||||
final double? groupingCenterY;
|
||||
final DateTime createdAt;
|
||||
final String? notes;
|
||||
|
||||
// Target detection data
|
||||
final double? targetCenterX;
|
||||
final double? targetCenterY;
|
||||
final double? targetRadius;
|
||||
|
||||
Session({
|
||||
required this.id,
|
||||
required this.targetType,
|
||||
required this.imagePath,
|
||||
required this.shots,
|
||||
required this.totalScore,
|
||||
this.groupingDiameter,
|
||||
this.groupingCenterX,
|
||||
this.groupingCenterY,
|
||||
required this.createdAt,
|
||||
this.notes,
|
||||
this.targetCenterX,
|
||||
this.targetCenterY,
|
||||
this.targetRadius,
|
||||
});
|
||||
|
||||
int get shotCount => shots.length;
|
||||
|
||||
double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length;
|
||||
|
||||
Session copyWith({
|
||||
String? id,
|
||||
TargetType? targetType,
|
||||
String? imagePath,
|
||||
List<Shot>? shots,
|
||||
int? totalScore,
|
||||
double? groupingDiameter,
|
||||
double? groupingCenterX,
|
||||
double? groupingCenterY,
|
||||
DateTime? createdAt,
|
||||
String? notes,
|
||||
double? targetCenterX,
|
||||
double? targetCenterY,
|
||||
double? targetRadius,
|
||||
}) {
|
||||
return Session(
|
||||
id: id ?? this.id,
|
||||
targetType: targetType ?? this.targetType,
|
||||
imagePath: imagePath ?? this.imagePath,
|
||||
shots: shots ?? this.shots,
|
||||
totalScore: totalScore ?? this.totalScore,
|
||||
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
|
||||
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
|
||||
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
notes: notes ?? this.notes,
|
||||
targetCenterX: targetCenterX ?? this.targetCenterX,
|
||||
targetCenterY: targetCenterY ?? this.targetCenterY,
|
||||
targetRadius: targetRadius ?? this.targetRadius,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'target_type': targetType.name,
|
||||
'image_path': imagePath,
|
||||
'total_score': totalScore,
|
||||
'grouping_diameter': groupingDiameter,
|
||||
'grouping_center_x': groupingCenterX,
|
||||
'grouping_center_y': groupingCenterY,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'notes': notes,
|
||||
'target_center_x': targetCenterX,
|
||||
'target_center_y': targetCenterY,
|
||||
'target_radius': targetRadius,
|
||||
};
|
||||
}
|
||||
|
||||
factory Session.fromMap(Map<String, dynamic> map, List<Shot> shots) {
|
||||
return Session(
|
||||
id: map['id'] as String,
|
||||
targetType: TargetType.fromString(map['target_type'] as String),
|
||||
imagePath: map['image_path'] as String,
|
||||
shots: shots,
|
||||
totalScore: map['total_score'] as int,
|
||||
groupingDiameter: map['grouping_diameter'] as double?,
|
||||
groupingCenterX: map['grouping_center_x'] as double?,
|
||||
groupingCenterY: map['grouping_center_y'] as double?,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
notes: map['notes'] as String?,
|
||||
targetCenterX: map['target_center_x'] as double?,
|
||||
targetCenterY: map['target_center_y'] as double?,
|
||||
targetRadius: map['target_radius'] as double?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Session(id: $id, targetType: $targetType, shots: ${shots.length}, totalScore: $totalScore, createdAt: $createdAt)';
|
||||
}
|
||||
}
|
||||
72
lib/data/models/shot.dart
Normal file
72
lib/data/models/shot.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
class Shot {
|
||||
final String id;
|
||||
final double x; // Relative position (0.0 - 1.0)
|
||||
final double y; // Relative position (0.0 - 1.0)
|
||||
final int score;
|
||||
final String sessionId;
|
||||
|
||||
Shot({
|
||||
required this.id,
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.score,
|
||||
required this.sessionId,
|
||||
});
|
||||
|
||||
Shot copyWith({
|
||||
String? id,
|
||||
double? x,
|
||||
double? y,
|
||||
int? score,
|
||||
String? sessionId,
|
||||
}) {
|
||||
return Shot(
|
||||
id: id ?? this.id,
|
||||
x: x ?? this.x,
|
||||
y: y ?? this.y,
|
||||
score: score ?? this.score,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'score': score,
|
||||
'session_id': sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
factory Shot.fromMap(Map<String, dynamic> map) {
|
||||
return Shot(
|
||||
id: map['id'] as String,
|
||||
x: (map['x'] as num).toDouble(),
|
||||
y: (map['y'] as num).toDouble(),
|
||||
score: map['score'] as int,
|
||||
sessionId: map['session_id'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Shot(id: $id, x: $x, y: $y, score: $score, sessionId: $sessionId)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Shot &&
|
||||
other.id == id &&
|
||||
other.x == x &&
|
||||
other.y == y &&
|
||||
other.score == score &&
|
||||
other.sessionId == sessionId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ sessionId.hashCode;
|
||||
}
|
||||
}
|
||||
84
lib/data/models/target.dart
Normal file
84
lib/data/models/target.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'target_type.dart';
|
||||
|
||||
class Target {
|
||||
final String id;
|
||||
final TargetType type;
|
||||
final String imagePath;
|
||||
final DateTime createdAt;
|
||||
|
||||
// Detected target bounds (relative coordinates 0.0-1.0)
|
||||
final double? centerX;
|
||||
final double? centerY;
|
||||
final double? radius; // For concentric targets
|
||||
final double? width; // For silhouette targets
|
||||
final double? height; // For silhouette targets
|
||||
|
||||
Target({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.imagePath,
|
||||
required this.createdAt,
|
||||
this.centerX,
|
||||
this.centerY,
|
||||
this.radius,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
Target copyWith({
|
||||
String? id,
|
||||
TargetType? type,
|
||||
String? imagePath,
|
||||
DateTime? createdAt,
|
||||
double? centerX,
|
||||
double? centerY,
|
||||
double? radius,
|
||||
double? width,
|
||||
double? height,
|
||||
}) {
|
||||
return Target(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
imagePath: imagePath ?? this.imagePath,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
centerX: centerX ?? this.centerX,
|
||||
centerY: centerY ?? this.centerY,
|
||||
radius: radius ?? this.radius,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type.name,
|
||||
'image_path': imagePath,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'center_x': centerX,
|
||||
'center_y': centerY,
|
||||
'radius': radius,
|
||||
'width': width,
|
||||
'height': height,
|
||||
};
|
||||
}
|
||||
|
||||
factory Target.fromMap(Map<String, dynamic> map) {
|
||||
return Target(
|
||||
id: map['id'] as String,
|
||||
type: TargetType.fromString(map['type'] as String),
|
||||
imagePath: map['image_path'] as String,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
centerX: map['center_x'] as double?,
|
||||
centerY: map['center_y'] as double?,
|
||||
radius: map['radius'] as double?,
|
||||
width: map['width'] as double?,
|
||||
height: map['height'] as double?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Target(id: $id, type: $type, imagePath: $imagePath, createdAt: $createdAt)';
|
||||
}
|
||||
}
|
||||
16
lib/data/models/target_type.dart
Normal file
16
lib/data/models/target_type.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
enum TargetType {
|
||||
concentric('Concentrique', 'Cible avec anneaux concentriques'),
|
||||
silhouette('Silhouette', 'Cible en forme de silhouette');
|
||||
|
||||
final String displayName;
|
||||
final String description;
|
||||
|
||||
const TargetType(this.displayName, this.description);
|
||||
|
||||
static TargetType fromString(String value) {
|
||||
return TargetType.values.firstWhere(
|
||||
(type) => type.name == value,
|
||||
orElse: () => TargetType.concentric,
|
||||
);
|
||||
}
|
||||
}
|
||||
112
lib/data/repositories/session_repository.dart
Normal file
112
lib/data/repositories/session_repository.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../database/database_helper.dart';
|
||||
import '../models/session.dart';
|
||||
import '../models/shot.dart';
|
||||
import '../models/target_type.dart';
|
||||
|
||||
class SessionRepository {
|
||||
final DatabaseHelper _databaseHelper;
|
||||
final Uuid _uuid;
|
||||
|
||||
SessionRepository({
|
||||
DatabaseHelper? databaseHelper,
|
||||
Uuid? uuid,
|
||||
}) : _databaseHelper = databaseHelper ?? DatabaseHelper(),
|
||||
_uuid = uuid ?? const Uuid();
|
||||
|
||||
Future<Session> createSession({
|
||||
required TargetType targetType,
|
||||
required String imagePath,
|
||||
required List<Shot> shots,
|
||||
required int totalScore,
|
||||
double? groupingDiameter,
|
||||
double? groupingCenterX,
|
||||
double? groupingCenterY,
|
||||
String? notes,
|
||||
double? targetCenterX,
|
||||
double? targetCenterY,
|
||||
double? targetRadius,
|
||||
}) async {
|
||||
// Copy image to app documents directory
|
||||
final savedImagePath = await _saveImage(imagePath);
|
||||
|
||||
final session = Session(
|
||||
id: _uuid.v4(),
|
||||
targetType: targetType,
|
||||
imagePath: savedImagePath,
|
||||
shots: shots,
|
||||
totalScore: totalScore,
|
||||
groupingDiameter: groupingDiameter,
|
||||
groupingCenterX: groupingCenterX,
|
||||
groupingCenterY: groupingCenterY,
|
||||
createdAt: DateTime.now(),
|
||||
notes: notes,
|
||||
targetCenterX: targetCenterX,
|
||||
targetCenterY: targetCenterY,
|
||||
targetRadius: targetRadius,
|
||||
);
|
||||
|
||||
await _databaseHelper.insertSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
Future<String> _saveImage(String sourcePath) async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory(path.join(appDir.path, 'target_images'));
|
||||
|
||||
if (!await imagesDir.exists()) {
|
||||
await imagesDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final fileName = '${_uuid.v4()}${path.extension(sourcePath)}';
|
||||
final destPath = path.join(imagesDir.path, fileName);
|
||||
|
||||
final sourceFile = File(sourcePath);
|
||||
await sourceFile.copy(destPath);
|
||||
|
||||
return destPath;
|
||||
}
|
||||
|
||||
Future<Session?> getSession(String id) async {
|
||||
return await _databaseHelper.getSession(id);
|
||||
}
|
||||
|
||||
Future<List<Session>> getAllSessions({
|
||||
TargetType? targetType,
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) async {
|
||||
return await _databaseHelper.getAllSessions(
|
||||
targetType: targetType?.name,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateSession(Session session) async {
|
||||
await _databaseHelper.updateSession(session);
|
||||
}
|
||||
|
||||
Future<void> deleteSession(String id) async {
|
||||
final session = await getSession(id);
|
||||
if (session != null) {
|
||||
// Delete the image file
|
||||
final imageFile = File(session.imagePath);
|
||||
if (await imageFile.exists()) {
|
||||
await imageFile.delete();
|
||||
}
|
||||
}
|
||||
await _databaseHelper.deleteSession(id);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getStatistics() async {
|
||||
return await _databaseHelper.getStatistics();
|
||||
}
|
||||
|
||||
String generateShotId() {
|
||||
return _uuid.v4();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user