diff --git a/analysis_report.txt b/analysis_report.txt new file mode 100644 index 00000000..8f82538a Binary files /dev/null and b/analysis_report.txt differ diff --git a/lib/app.dart b/lib/app.dart index 0be017b8..b9242683 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'core/theme/theme_provider.dart'; import 'core/theme/app_theme.dart'; +import 'main_navigation_holder.dart'; import 'features/home/home_screen.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -8,23 +11,26 @@ class BullyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Bully - Analyse de Cibles', - debugShowCheckedModeBanner: false, - theme: AppTheme.lightTheme, - darkTheme: AppTheme.darkTheme, - themeMode: ThemeMode.system, - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: const [ - Locale('fr', 'FR'), // Français - ], - locale: const Locale('fr', 'FR'), // Force l'interface en français - - home: const HomeScreen(), + return Consumer( + builder: (context, themeProvider, child) { + return MaterialApp( + title: 'Bully - Analyse de Cibles', + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeProvider.themeMode, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('fr', 'FR'), // Français + ], + locale: const Locale('fr', 'FR'), // Force l'interface en français + home: const MainNavigationHolder(), + ); + }, ); } } diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index b20339f4..a9f9e595 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -3,11 +3,14 @@ class AppConstants { // Database static const String databaseName = 'bully_targets.db'; - static const int databaseVersion = 1; + static const int databaseVersion = 6; // Tables static const String sessionsTable = 'sessions'; + static const String targetAnalysesTable = 'target_analyses'; static const String shotsTable = 'shots'; + static const String weaponsTable = 'weapons'; + static const String maintenanceTable = 'weapon_maintenance'; // Image processing static const double minImpactRadius = 2.0; diff --git a/lib/core/theme/theme_provider.dart b/lib/core/theme/theme_provider.dart new file mode 100644 index 00000000..4df9997e --- /dev/null +++ b/lib/core/theme/theme_provider.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ThemeProvider with ChangeNotifier { + static const String _themeModeKey = 'user_theme_mode'; + + ThemeMode _themeMode = ThemeMode.system; + + ThemeMode get themeMode => _themeMode; + + ThemeProvider() { + loadThemeMode(); + } + + Future loadThemeMode() async { + final prefs = await SharedPreferences.getInstance(); + final modeIndex = prefs.getInt(_themeModeKey); + if (modeIndex != null) { + _themeMode = ThemeMode.values[modeIndex]; + notifyListeners(); + } + } + + Future setThemeMode(ThemeMode mode) async { + if (_themeMode == mode) return; + + _themeMode = mode; + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_themeModeKey, mode.index); + } + + String get themeModeName { + switch (_themeMode) { + case ThemeMode.system: + return 'Automatique'; + case ThemeMode.light: + return 'Clair'; + case ThemeMode.dark: + return 'Sombre'; + } + } +} diff --git a/lib/data/database/database_helper.dart b/lib/data/database/database_helper.dart index 71f97ae5..45c4f686 100644 --- a/lib/data/database/database_helper.dart +++ b/lib/data/database/database_helper.dart @@ -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 _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 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( @@ -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 = []; + 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({ - String? targetType, int? limit, int? offset, }) async { final db = await database; - String? whereClause; - List? 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 = []; 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 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 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 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 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(); diff --git a/lib/data/models/maintenance.dart b/lib/data/models/maintenance.dart new file mode 100644 index 00000000..0efd30e1 --- /dev/null +++ b/lib/data/models/maintenance.dart @@ -0,0 +1,57 @@ +enum MaintenanceType { + cleaning('Nettoyage'), + repair('Réparation'), + inspection('Inspection'), + upgrade('Amélioration'), + other('Autre'); + + final String displayName; + const MaintenanceType(this.displayName); + + static MaintenanceType fromString(String value) { + return MaintenanceType.values.firstWhere( + (type) => type.name == value, + orElse: () => MaintenanceType.other, + ); + } +} + +class MaintenanceEntry { + final String id; + final String weaponId; + final MaintenanceType type; + final String description; + final DateTime date; + final int? roundsSinceLastMaintenance; + + MaintenanceEntry({ + required this.id, + required this.weaponId, + required this.type, + required this.description, + required this.date, + this.roundsSinceLastMaintenance, + }); + + Map toMap() { + return { + 'id': id, + 'weapon_id': weaponId, + 'type': type.name, + 'description': description, + 'date': date.toIso8601String(), + 'rounds_since_last': roundsSinceLastMaintenance, + }; + } + + factory MaintenanceEntry.fromMap(Map map) { + return MaintenanceEntry( + id: map['id'] as String, + weaponId: map['weapon_id'] as String, + type: MaintenanceType.fromString(map['type'] as String), + description: map['description'] as String, + date: DateTime.parse(map['date'] as String), + roundsSinceLastMaintenance: map['rounds_since_last'] as int?, + ); + } +} diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 73ada3c5..cf956d0d 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -1,112 +1,83 @@ -import 'shot.dart'; -import 'target_type.dart'; +import 'target_analysis.dart'; class Session { final String id; - final TargetType targetType; - final String imagePath; - final List shots; - final int totalScore; - final double? groupingDiameter; - final double? groupingCenterX; - final double? groupingCenterY; + final String weapon; + final String? weaponId; + final int maxShotsPerTarget; + final List analyses; final DateTime createdAt; final String? notes; - - // Target detection data - final double? targetCenterX; - final double? targetCenterY; - final double? targetRadius; + final int distance; // Shooting distance in meters Session({ required this.id, - required this.targetType, - required this.imagePath, - required this.shots, - required this.totalScore, - this.groupingDiameter, - this.groupingCenterX, - this.groupingCenterY, + required this.weapon, + this.weaponId, + required this.maxShotsPerTarget, + required this.analyses, required this.createdAt, this.notes, - this.targetCenterX, - this.targetCenterY, - this.targetRadius, + this.distance = 25, // Default distance }); - int get shotCount => shots.length; + int get targetCount => analyses.length; + + int get totalShots => analyses.fold(0, (sum, a) => sum + a.shotCount); + + int get totalScore => analyses.fold(0, (sum, a) => sum + a.totalScore); - double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length; + double get averageScore => analyses.isEmpty ? 0.0 : totalScore / analyses.length; Session copyWith({ String? id, - TargetType? targetType, - String? imagePath, - List? shots, - int? totalScore, - double? groupingDiameter, - double? groupingCenterX, - double? groupingCenterY, + String? weapon, + String? weaponId, + int? maxShotsPerTarget, + List? analyses, DateTime? createdAt, String? notes, - double? targetCenterX, - double? targetCenterY, - double? targetRadius, + int? distance, }) { 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, + weapon: weapon ?? this.weapon, + weaponId: weaponId ?? this.weaponId, + maxShotsPerTarget: maxShotsPerTarget ?? this.maxShotsPerTarget, + analyses: analyses ?? this.analyses, createdAt: createdAt ?? this.createdAt, notes: notes ?? this.notes, - targetCenterX: targetCenterX ?? this.targetCenterX, - targetCenterY: targetCenterY ?? this.targetCenterY, - targetRadius: targetRadius ?? this.targetRadius, + distance: distance ?? this.distance, ); } Map 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, + 'weapon': weapon, + 'weapon_id': weaponId, + 'max_shots_per_target': maxShotsPerTarget, 'created_at': createdAt.toIso8601String(), 'notes': notes, - 'target_center_x': targetCenterX, - 'target_center_y': targetCenterY, - 'target_radius': targetRadius, + 'distance': distance, }; } - factory Session.fromMap(Map map, List shots) { + factory Session.fromMap(Map map, List analyses) { 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?, + weapon: map['weapon'] as String? ?? 'Inconnue', + weaponId: map['weapon_id'] as String?, + maxShotsPerTarget: map['max_shots_per_target'] as int? ?? 5, + analyses: analyses, 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?, + distance: map['distance'] as int? ?? 25, ); } @override String toString() { - return 'Session(id: $id, targetType: $targetType, shots: ${shots.length}, totalScore: $totalScore, createdAt: $createdAt)'; + return 'Session(id: $id, weapon: $weapon, distance: ${distance}m, targets: ${analyses.length}, createdAt: $createdAt)'; } } diff --git a/lib/data/models/shot.dart b/lib/data/models/shot.dart index fbb1616c..5f0e0397 100644 --- a/lib/data/models/shot.dart +++ b/lib/data/models/shot.dart @@ -3,14 +3,14 @@ class Shot { final double x; // Relative position (0.0 - 1.0) final double y; // Relative position (0.0 - 1.0) final int score; - final String sessionId; + final String analysisId; Shot({ required this.id, required this.x, required this.y, required this.score, - required this.sessionId, + required this.analysisId, }); Shot copyWith({ @@ -18,14 +18,14 @@ class Shot { double? x, double? y, int? score, - String? sessionId, + String? analysisId, }) { return Shot( id: id ?? this.id, x: x ?? this.x, y: y ?? this.y, score: score ?? this.score, - sessionId: sessionId ?? this.sessionId, + analysisId: analysisId ?? this.analysisId, ); } @@ -35,7 +35,7 @@ class Shot { 'x': x, 'y': y, 'score': score, - 'session_id': sessionId, + 'analysis_id': analysisId, }; } @@ -45,13 +45,13 @@ class Shot { x: (map['x'] as num).toDouble(), y: (map['y'] as num).toDouble(), score: map['score'] as int, - sessionId: map['session_id'] as String, + analysisId: map['analysis_id'] as String? ?? map['session_id'] as String? ?? '', ); } @override String toString() { - return 'Shot(id: $id, x: $x, y: $y, score: $score, sessionId: $sessionId)'; + return 'Shot(id: $id, x: $x, y: $y, score: $score, analysisId: $analysisId)'; } @override @@ -62,11 +62,11 @@ class Shot { other.x == x && other.y == y && other.score == score && - other.sessionId == sessionId; + other.analysisId == analysisId; } @override int get hashCode { - return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ sessionId.hashCode; + return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ analysisId.hashCode; } } diff --git a/lib/data/models/target_analysis.dart b/lib/data/models/target_analysis.dart new file mode 100644 index 00000000..8bfa95cf --- /dev/null +++ b/lib/data/models/target_analysis.dart @@ -0,0 +1,113 @@ +import 'shot.dart'; +import 'target_type.dart'; + +class TargetAnalysis { + final String id; + final String sessionId; + final TargetType targetType; + final String imagePath; + final List 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; + + TargetAnalysis({ + required this.id, + required this.sessionId, + 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; + + TargetAnalysis copyWith({ + String? id, + String? sessionId, + TargetType? targetType, + String? imagePath, + List? shots, + int? totalScore, + double? groupingDiameter, + double? groupingCenterX, + double? groupingCenterY, + DateTime? createdAt, + String? notes, + double? targetCenterX, + double? targetCenterY, + double? targetRadius, + }) { + return TargetAnalysis( + id: id ?? this.id, + sessionId: sessionId ?? this.sessionId, + 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 toMap() { + return { + 'id': id, + 'session_id': sessionId, + '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 TargetAnalysis.fromMap(Map map, List shots) { + return TargetAnalysis( + id: map['id'] as String, + sessionId: map['session_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?, + ); + } +} diff --git a/lib/data/models/weapon.dart b/lib/data/models/weapon.dart new file mode 100644 index 00000000..ad93182a --- /dev/null +++ b/lib/data/models/weapon.dart @@ -0,0 +1,114 @@ +enum WeaponType { + handgun('Arme de Poing'), + rifle('Arme d\'Épaule'), + shotgun('Fusil à Pompe'), + airgun('Airsoft / Airgun'); + + final String displayName; + const WeaponType(this.displayName); + + static WeaponType fromString(String value) { + return WeaponType.values.firstWhere( + (type) => type.name == value, + orElse: () => WeaponType.handgun, + ); + } +} + +class Weapon { + final String id; + final String name; + final WeaponType type; + final String caliber; + final int magazineCount; + final int magazineCapacity; + final String? notes; + final DateTime createdAt; + + // New options + final String? optic; + final String? silencer; + final String? trigger; + final String? customName; + + String get displayName => (customName != null && customName!.isNotEmpty) ? customName! : name; + + Weapon({ + required this.id, + required this.name, + required this.type, + required this.caliber, + required this.magazineCount, + required this.magazineCapacity, + this.notes, + required this.createdAt, + this.optic, + this.silencer, + this.trigger, + this.customName, + }); + + Weapon copyWith({ + String? id, + String? name, + WeaponType? type, + String? caliber, + int? magazineCount, + int? magazineCapacity, + String? notes, + DateTime? createdAt, + String? optic, + String? silencer, + String? trigger, + String? customName, + }) { + return Weapon( + id: id ?? this.id, + name: name ?? this.name, + type: type ?? this.type, + caliber: caliber ?? this.caliber, + magazineCount: magazineCount ?? this.magazineCount, + magazineCapacity: magazineCapacity ?? this.magazineCapacity, + notes: notes ?? this.notes, + createdAt: createdAt ?? this.createdAt, + optic: optic ?? this.optic, + silencer: silencer ?? this.silencer, + trigger: trigger ?? this.trigger, + customName: customName ?? this.customName, + ); + } + + Map toMap() { + return { + 'id': id, + 'name': name, + 'type': type.name, + 'caliber': caliber, + 'magazine_count': magazineCount, + 'magazine_capacity': magazineCapacity, + 'notes': notes, + 'created_at': createdAt.toIso8601String(), + 'optic': optic, + 'silencer': silencer, + 'trigger': trigger, + 'custom_name': customName, + }; + } + + factory Weapon.fromMap(Map map) { + return Weapon( + id: map['id'] as String, + name: map['name'] as String, + type: WeaponType.fromString(map['type'] as String), + caliber: map['caliber'] as String, + magazineCount: map['magazine_count'] as int, + magazineCapacity: map['magazine_capacity'] as int, + notes: map['notes'] as String?, + createdAt: DateTime.parse(map['created_at'] as String), + optic: map['optic'] as String?, + silencer: map['silencer'] as String?, + trigger: map['trigger'] as String?, + customName: map['custom_name'] as String?, + ); + } +} diff --git a/lib/data/repositories/session_repository.dart b/lib/data/repositories/session_repository.dart index 7912a780..dee0089b 100644 --- a/lib/data/repositories/session_repository.dart +++ b/lib/data/repositories/session_repository.dart @@ -4,8 +4,10 @@ import 'package:path/path.dart' as path; import 'package:uuid/uuid.dart'; import '../database/database_helper.dart'; import '../models/session.dart'; +import '../models/target_analysis.dart'; import '../models/shot.dart'; -import '../models/target_type.dart'; +import '../models/weapon.dart'; +import '../models/maintenance.dart'; class SessionRepository { final DatabaseHelper _databaseHelper; @@ -18,8 +20,37 @@ class SessionRepository { _uuid = uuid ?? const Uuid(); Future createSession({ - required TargetType targetType, + String? id, + required String weapon, + String? weaponId, + required int maxShotsPerTarget, + required List analyses, + String? notes, + int distance = 25, + }) async { + final session = Session( + id: id ?? _uuid.v4(), + weapon: weapon, + weaponId: weaponId, + maxShotsPerTarget: maxShotsPerTarget, + analyses: analyses, + createdAt: DateTime.now(), + notes: notes, + distance: distance, + ); + + await _databaseHelper.insertSession(session); + return session; + } + + Future addAnalysisToSession(String sessionId, TargetAnalysis analysis) async { + await _databaseHelper.insertTargetAnalysis(analysis, sessionId); + } + + Future prepareTargetAnalysis({ required String imagePath, + required String sessionId, + required dynamic targetType, // TargetType required List shots, required int totalScore, double? groupingDiameter, @@ -33,8 +64,9 @@ class SessionRepository { // Copy image to app documents directory final savedImagePath = await _saveImage(imagePath); - final session = Session( + return TargetAnalysis( id: _uuid.v4(), + sessionId: sessionId, targetType: targetType, imagePath: savedImagePath, shots: shots, @@ -48,9 +80,6 @@ class SessionRepository { targetCenterY: targetCenterY, targetRadius: targetRadius, ); - - await _databaseHelper.insertSession(session); - return session; } Future _saveImage(String sourcePath) async { @@ -75,28 +104,23 @@ class SessionRepository { } Future> getAllSessions({ - TargetType? targetType, int? limit, int? offset, }) async { return await _databaseHelper.getAllSessions( - targetType: targetType?.name, limit: limit, offset: offset, ); } - Future updateSession(Session session) async { - await _databaseHelper.updateSession(session); - } - Future 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(); + for (final analysis in session.analyses) { + final imageFile = File(analysis.imagePath); + if (await imageFile.exists()) { + await imageFile.delete(); + } } } await _databaseHelper.deleteSession(id); @@ -106,7 +130,79 @@ class SessionRepository { return await _databaseHelper.getStatistics(); } - String generateShotId() { + String generateId() { return _uuid.v4(); } + + // Weapon Repository methods + Future addWeapon({ + required String name, + required WeaponType type, + required String caliber, + required int magazineCount, + required int magazineCapacity, + String? notes, + String? optic, + String? silencer, + String? trigger, + String? customName, + }) async { + final weapon = Weapon( + id: _uuid.v4(), + name: name, + type: type, + caliber: caliber, + magazineCount: magazineCount, + magazineCapacity: magazineCapacity, + notes: notes, + createdAt: DateTime.now(), + optic: optic, + silencer: silencer, + trigger: trigger, + customName: customName, + ); + await _databaseHelper.insertWeapon(weapon); + return weapon; + } + + Future> getWeapons() async { + return await _databaseHelper.getAllWeapons(); + } + + Future updateWeapon(Weapon weapon) async { + await _databaseHelper.updateWeapon(weapon); + } + + Future deleteWeapon(String id) async { + await _databaseHelper.deleteWeapon(id); + } + + Future getRoundsFiredForWeapon(String weaponId) async { + return await _databaseHelper.getRoundsFiredForWeapon(weaponId); + } + + Future addMaintenanceEntry({ + required String weaponId, + required MaintenanceType type, + required String description, + int? roundsSinceLast, + }) async { + final entry = MaintenanceEntry( + id: _uuid.v4(), + weaponId: weaponId, + type: type, + description: description, + date: DateTime.now(), + roundsSinceLastMaintenance: roundsSinceLast, + ); + await _databaseHelper.insertMaintenance(entry); + } + + Future> getMaintenanceHistory(String weaponId) async { + return await _databaseHelper.getMaintenanceForWeapon(weaponId); + } + + Future deleteMaintenanceEntry(String id) async { + await _databaseHelper.deleteMaintenance(id); + } } diff --git a/lib/features/analysis/analysis_provider.dart b/lib/features/analysis/analysis_provider.dart index ea3f361c..27b9f05c 100644 --- a/lib/features/analysis/analysis_provider.dart +++ b/lib/features/analysis/analysis_provider.dart @@ -7,9 +7,9 @@ library; import 'dart:io'; import 'dart:ui' as ui; -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; -import '../../data/models/session.dart'; +import '../../data/models/target_analysis.dart'; import '../../data/models/shot.dart'; import '../../data/models/target_type.dart'; import '../../data/repositories/session_repository.dart'; @@ -120,6 +120,7 @@ class AnalysisProvider extends ChangeNotifier { String imagePath, TargetType targetType, { bool autoAnalyze = true, + Offset? manualCenter, }) async { _state = AnalysisState.loading; _imagePath = imagePath; @@ -138,8 +139,8 @@ class AnalysisProvider extends ChangeNotifier { if (!autoAnalyze) { // Just setup default values without running detection - _targetCenterX = 0.5; - _targetCenterY = 0.5; + _targetCenterX = manualCenter?.dx ?? 0.5; + _targetCenterY = manualCenter?.dy ?? 0.5; _targetRadius = 0.4; _targetInnerRadius = 0.04; @@ -173,7 +174,7 @@ class AnalysisProvider extends ChangeNotifier { x: impact.x, y: impact.y, score: impact.suggestedScore, - sessionId: '', // Will be set when saving + analysisId: '', // Will be set when saving ); }).toList(); @@ -195,7 +196,7 @@ class AnalysisProvider extends ChangeNotifier { /// Add a manual shot void addShot(double x, double y) { final score = _calculateShotScore(x, y); - final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: ''); + final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: ''); _shots.add(shot); _recalculateScores(); @@ -275,7 +276,7 @@ class AnalysisProvider extends ChangeNotifier { x: impact.x, y: impact.y, score: score, - sessionId: '', + analysisId: '', ); _shots.add(shot); } @@ -288,14 +289,6 @@ class AnalysisProvider extends ChangeNotifier { } /// Auto-detect impacts using OpenCV (Hough Circles + Contours) - /// - /// NOTE: OpenCV est actuellement désactivé sur Windows en raison de problèmes - /// de compilation. Cette méthode retourne 0 (aucun impact détecté). - /// Utiliser autoDetectImpacts() à la place. - /// - /// Utilise les algorithmes OpenCV pour une détection plus robuste: - /// - Transformation de Hough pour détecter les cercles - /// - Analyse de contours avec filtrage par circularité Future autoDetectImpactsWithOpenCV({ double cannyThreshold1 = 50, double cannyThreshold2 = 150, @@ -352,7 +345,7 @@ class AnalysisProvider extends ChangeNotifier { x: impact.x, y: impact.y, score: score, - sessionId: '', + analysisId: '', ); _shots.add(shot); } @@ -404,7 +397,7 @@ class AnalysisProvider extends ChangeNotifier { x: impact.x, y: impact.y, score: score, - sessionId: '', + analysisId: '', ); _shots.add(shot); } @@ -419,7 +412,7 @@ class AnalysisProvider extends ChangeNotifier { /// Add a reference impact for calibrated detection void addReferenceImpact(double x, double y) { final score = _calculateShotScore(x, y); - final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: ''); + final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: ''); _referenceImpacts.add(shot); notifyListeners(); } @@ -489,7 +482,7 @@ class AnalysisProvider extends ChangeNotifier { x: impact.x, y: impact.y, score: score, - sessionId: '', + analysisId: '', ); _shots.add(shot); } @@ -563,7 +556,7 @@ class AnalysisProvider extends ChangeNotifier { } return false; } catch (e) { - print('Auto-calibration error: $e'); + debugPrint('Auto-calibration error: $e'); return false; } } @@ -608,7 +601,6 @@ class AnalysisProvider extends ChangeNotifier { } } - /* version deux a tester*/ /// Calcule ET applique la correction pour un feedback immédiat Future calculateAndApplyDistortion() async { // 1. Calcul des paramètres (votre code actuel) @@ -644,7 +636,6 @@ class AnalysisProvider extends ChangeNotifier { notifyListeners(); } } - /* fin section deux a tester*/ int _calculateShotScore(double x, double y) { if (_targetType == TargetType.concentric) { @@ -697,10 +688,10 @@ class AnalysisProvider extends ChangeNotifier { return false; } - // Identifiant de session temporaire si non sauvegardée - final sessionId = _shots.isNotEmpty && _shots.first.sessionId.isNotEmpty - ? _shots.first.sessionId - : 'session_${DateTime.now().millisecondsSinceEpoch}'; + // Identifiant d'analyse temporaire + final analysisId = _shots.isNotEmpty && _shots.first.analysisId.isNotEmpty + ? _shots.first.analysisId + : 'analysis_${DateTime.now().millisecondsSinceEpoch}'; final service = AiExportService(); // Local instanciation for simplicity @@ -709,7 +700,7 @@ class AnalysisProvider extends ChangeNotifier { final success = await service.exportData( imagePath: _imagePath!, - sessionId: sessionId, + sessionId: 'export', targetType: _targetType!, targetCenterX: _targetCenterX, targetCenterY: _targetCenterY, @@ -725,16 +716,23 @@ class AnalysisProvider extends ChangeNotifier { return success; } - /// Save the session - Future saveSession({String? notes}) async { + /// Save the session (legacy standalone flow) + Future saveSession({ + String? notes, + String? sessionId, + String? weaponName, + String? weaponId, + int? distance, + }) async { if (_imagePath == null || _targetType == null) { throw Exception('Cannot save: missing image or target type'); } - final session = await _sessionRepository.createSession( - targetType: _targetType!, + final targetAnalysis = await _sessionRepository.prepareTargetAnalysis( imagePath: _imagePath!, - shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(), + sessionId: sessionId ?? 'standalone', + targetType: _targetType!, + shots: _shots, totalScore: totalScore, groupingDiameter: _groupingResult?.diameter, groupingCenterX: _groupingResult?.centerX, @@ -745,11 +743,34 @@ class AnalysisProvider extends ChangeNotifier { targetRadius: _targetRadius, ); - // Update shots with session ID - _shots = session.shots; - notifyListeners(); + if (sessionId != null && sessionId != 'standalone') { + final existingSession = await _sessionRepository.getSession(sessionId); + if (existingSession != null) { + await _sessionRepository.addAnalysisToSession(sessionId, targetAnalysis); + } else { + await _sessionRepository.createSession( + id: sessionId, + weapon: weaponName ?? 'Inconnue', + weaponId: weaponId, + maxShotsPerTarget: shotCount, + analyses: [targetAnalysis], + notes: notes, + distance: distance ?? 25, + ); + } + } else { + await _sessionRepository.createSession( + weapon: weaponName ?? 'Inconnue', + weaponId: weaponId, + maxShotsPerTarget: shotCount, + analyses: [targetAnalysis], + notes: notes, + distance: distance ?? 25, + ); + } - return session; + notifyListeners(); + return targetAnalysis; } /// Reset the provider diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index 20740e88..5484b224 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -11,12 +11,16 @@ import 'package:provider/provider.dart'; import '../../core/constants/app_constants.dart'; import '../../core/theme/app_theme.dart'; import '../../data/models/target_type.dart'; +import '../../data/models/shot.dart'; import '../../data/repositories/session_repository.dart'; import '../../services/target_detection_service.dart'; import '../../services/score_calculator_service.dart'; import '../../services/grouping_analyzer_service.dart'; import '../../services/wallet_identity_service.dart'; +import '../session/session_provider.dart'; import 'analysis_provider.dart'; +import '../crop/crop_screen.dart'; +import '../capture/capture_screen.dart'; import 'widgets/target_overlay.dart'; import 'widgets/target_calibration.dart'; import 'widgets/score_card.dart'; @@ -25,11 +29,17 @@ import 'widgets/grouping_stats.dart'; class AnalysisScreen extends StatelessWidget { final String imagePath; final TargetType targetType; + final Offset? targetCenter; + final double? cropScale; + final Offset? cropOffset; const AnalysisScreen({ super.key, required this.imagePath, required this.targetType, + this.targetCenter, + this.cropScale, + this.cropOffset, }); @override @@ -40,20 +50,30 @@ class AnalysisScreen extends StatelessWidget { scoreCalculatorService: context.read(), groupingAnalyzerService: context.read(), sessionRepository: context.read(), - )..analyzeImage(imagePath, targetType, autoAnalyze: false), - child: const _AnalysisScreenContent(), + )..analyzeImage(imagePath, targetType, autoAnalyze: false, manualCenter: targetCenter), + child: _AnalysisScreenContent( + cropScale: cropScale, + cropOffset: cropOffset, + ), ); } } class _AnalysisScreenContent extends StatefulWidget { - const _AnalysisScreenContent(); + final double? cropScale; + final Offset? cropOffset; + + const _AnalysisScreenContent({ + this.cropScale, + this.cropOffset, + }); @override State<_AnalysisScreenContent> createState() => _AnalysisScreenContentState(); } class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { + final GlobalKey _calibrationKey = GlobalKey(); bool _isCalibrating = false; bool _isSelectingReferences = false; bool _isFullscreenEditMode = false; @@ -88,610 +108,324 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { void dispose() { _transformationController.removeListener(_onTransformChanged); _transformationController.dispose(); + _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } void _onTransformChanged() { - final newScale = _transformationController.value.getMaxScaleOnAxis(); - if (newScale != _currentZoomScale) { + final scale = _transformationController.value.getMaxScaleOnAxis(); + if (scale != _currentZoomScale) { setState(() { - _currentZoomScale = newScale; + _currentZoomScale = scale; }); } } - void _toggleFullscreenEditMode() { - setState(() { - _isFullscreenEditMode = !_isFullscreenEditMode; - if (!_isFullscreenEditMode) { - // Reset zoom when exiting fullscreen mode - _transformationController.value = Matrix4.identity(); - } - }); - } - @override Widget build(BuildContext context) { + final provider = context.watch(); + final sessionProvider = context.watch(); + final targetNumber = sessionProvider.isSessionActive ? sessionProvider.targetCount + 1 : null; + final titlePrefix = _isCalibrating ? 'Calibration' : 'Analyse'; + final title = targetNumber != null ? '$titlePrefix - Cible $targetNumber' : ( _isCalibrating ? 'Calibration' : 'Analyse de Tir'); + return Scaffold( appBar: AppBar( - title: Text(_isCalibrating ? 'Calibration' : 'Analyse'), + title: Text(title), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (_isCalibrating) { + setState(() => _isCalibrating = false); + } else if (_isSelectingReferences) { + setState(() => _isSelectingReferences = false); + } else { + // Return to crop screen instead of popping to capture + final provider = context.read(); + final path = provider.imagePath!; + final type = provider.targetType!; + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => CropScreen( + imagePath: path, + targetType: type, + initialScale: widget.cropScale, + initialOffset: widget.cropOffset, + ), + ), + ); + } + }, + ), actions: [ - Consumer( - builder: (context, provider, _) { - if (provider.state != AnalysisState.success) { - return const SizedBox.shrink(); - } - return IconButton( - icon: Icon(_isCalibrating ? Icons.check : Icons.tune), - onPressed: () { - setState(() => _isCalibrating = !_isCalibrating); - }, - tooltip: _isCalibrating - ? 'Terminer calibration' - : 'Calibrer la cible', - color: _isCalibrating ? AppTheme.successColor : null, - ); - }, - ), - FutureBuilder( - future: WalletIdentityService().isUploadEnabled(), - builder: (context, snapshot) { - final isEnabled = snapshot.data ?? false; - if (!isEnabled) return const SizedBox.shrink(); - - return IconButton( - icon: const Icon(Icons.cloud_upload), - onPressed: () async { - final provider = context.read(); - if (provider.state != AnalysisState.success) return; - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Exportation en cours...')), - ); - - final success = await provider.exportToAiBackend(); - - if (context.mounted) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - if (success) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Export réussi vers le backend IA !'), - backgroundColor: AppTheme.successColor, - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(provider.errorMessage ?? 'Erreur d\'export'), - backgroundColor: AppTheme.errorColor, - ), - ); - } - } - }, - tooltip: 'Exporter pour IA', - ); - }, - ), - IconButton( - icon: const Icon(Icons.help_outline), - onPressed: () => _showHelpDialog(context), - tooltip: 'Aide', - ), + if (!_isCalibrating && !_isSelectingReferences) + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => provider.analyzeImage(context.read().imagePath!, context.read().targetType!), + ), + if (_isCalibrating) + TextButton( + onPressed: () => setState(() => _isCalibrating = false), + child: const Text( + 'TERMINER', + style: TextStyle(color: Colors.white), + ), + ), ], ), - body: Consumer( - builder: (context, provider, _) { - switch (provider.state) { - case AnalysisState.initial: - case AnalysisState.loading: - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Analyse en cours...'), - ], - ), - ); - case AnalysisState.error: - return Center( - child: Padding( + body: Stack( + children: [ + SingleChildScrollView( + controller: _scrollController, + child: Column( + children: [ + // Session info header + Padding( padding: const EdgeInsets.all(AppConstants.defaultPadding), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.error_outline, - size: 64, - color: AppTheme.errorColor, - ), - const SizedBox(height: 16), - Text( - provider.errorMessage ?? 'Une erreur est survenue', - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => Navigator.pop(context), - child: const Text('Retour'), - ), - ], - ), - ), - ); - case AnalysisState.success: - return _buildSuccessContent(context, provider); - } - }, - ), - ); - } - - Widget _buildSuccessContent(BuildContext context, AnalysisProvider provider) { - // Mode plein écran pour l'édition des impacts - if (_isFullscreenEditMode) { - return _buildFullscreenEditContent(context, provider); - } - - return Stack( - children: [ - SingleChildScrollView( - controller: _scrollController, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Calibration mode indicator - if (_isCalibrating) - Container( - color: AppTheme.warningColor, - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - child: const Row( - children: [ - Icon(Icons.tune, color: Colors.white, size: 20), - SizedBox(width: 8), - Expanded( - child: Text( - 'Mode Calibration - Ajustez le centre et la taille de la cible', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - - // Reference selection mode indicator - if (_isSelectingReferences) - Container( - color: Colors.deepPurple, - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - child: Row( - children: [ - const Icon( - Icons.touch_app, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Selectionnez ${provider.referenceImpacts.length}/3-4 impacts de reference', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - if (provider.referenceImpacts.isNotEmpty) - TextButton( - onPressed: () => provider.clearReferenceImpacts(), - child: const Text( - 'Effacer', - style: TextStyle(color: Colors.white), + if (provider.state == AnalysisState.loading) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: CircularProgressIndicator(), ), ), ], ), ), - // Calibration sliders (shown only in calibration mode) - if (_isCalibrating) - Container( - color: Colors.black87, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Column( - children: [ - - // Ring count slider - Row( - children: [ - const Icon( - Icons.radio_button_unchecked, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 8), - const Text( - 'Anneaux:', - style: TextStyle(color: Colors.white), - ), - Expanded( - child: Slider( - value: provider.ringCount.toDouble(), - min: 3, - max: 12, - divisions: 9, - label: '${provider.ringCount}', - activeColor: AppTheme.primaryColor, - onChanged: (value) { - provider.adjustTargetPosition( - provider.targetCenterX, - provider.targetCenterY, - provider.targetInnerRadius, - provider.targetRadius, - ringCount: value.round(), - ); - }, + // Target image with overlay or calibration + AspectRatio( + aspectRatio: provider.imageAspectRatio, + child: _isCalibrating + ? Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(provider.imagePath!), + fit: BoxFit.fill, ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: AppTheme.primaryColor, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${provider.ringCount}', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - const Divider(color: Colors.white24, height: 16), - // Distortion correction row - /*Row( - children: [ - const Icon( - Icons.lens_blur, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 8), - const Expanded( - child: Text( - 'Correction distorsion:', - style: TextStyle(color: Colors.white), - ), - ), - if (provider.distortionParams == null) - ElevatedButton.icon( - onPressed: () { - provider.calculateDistortion(); - }, - icon: const Icon(Icons.calculate, size: 16), - label: const Text('Calculer'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueGrey, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - ), - ) - else ...[ - if (provider.correctedImagePath == null) - ElevatedButton.icon( - onPressed: () { - provider.applyDistortionCorrection(); - }, - icon: const Icon(Icons.auto_fix_high, size: 16), - label: const Text('Appliquer'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - ), - ) - else - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.check_circle, - color: Colors.green, - size: 16, - ), - const SizedBox(width: 4), - const Text( - 'Corrigée', - style: TextStyle( - color: Colors.green, - fontSize: 12, - ), - ), - const SizedBox(width: 8), - /*Switch( - value: provider.distortionCorrectionEnabled, - onChanged: (value) => provider - .setDistortionCorrectionEnabled(value), - activeTrackColor: AppTheme.primaryColor - .withValues(alpha: 0.5), - activeThumbColor: AppTheme.primaryColor, - ),*/ - ], - ), - ], - ], - ),*/ - ], - ), - ), - - // Target image with overlay or calibration - AspectRatio( - aspectRatio: provider.imageAspectRatio, - child: _isCalibrating - ? Stack( - fit: StackFit.expand, - children: [ - Image.file( - File(provider.imagePath!), - fit: BoxFit.fill, - ), - TargetCalibration( - initialCenterX: provider.targetCenterX, - initialCenterY: provider.targetCenterY, - initialRadius: provider.targetRadius, - initialInnerRadius: provider.targetInnerRadius, - initialRingCount: provider.ringCount, - initialRingRadii: provider.ringRadii, - targetType: provider.targetType!, - onCalibrationChanged: - ( - centerX, - centerY, - innerRadius, - radius, - ringCount, { - List? ringRadii, - }) { - provider.adjustTargetPosition( + TargetCalibration( + key: _calibrationKey, + initialCenterX: provider.targetCenterX, + initialCenterY: provider.targetCenterY, + initialRadius: provider.targetRadius, + initialInnerRadius: provider.targetInnerRadius, + initialRingCount: provider.ringCount, + initialRingRadii: provider.ringRadii, + targetType: provider.targetType!, + onCalibrationChanged: + ( centerX, centerY, innerRadius, radius, - ringCount: ringCount, - ringRadii: ringRadii, - ); - }, - ), - ], - ) - : _buildZoomableImageWithOverlay(context, provider), - ), - - // Info cards (hidden during calibration) - if (!_isCalibrating) - Padding( - padding: const EdgeInsets.all(AppConstants.defaultPadding), - child: Column( - children: [ - // Calibration button - Card( - color: AppTheme.primaryColor.withValues(alpha: 0.1), - child: ListTile( - leading: const Icon( - Icons.tune, - color: AppTheme.primaryColor, - ), - title: const Text('Calibrer la cible'), - subtitle: const Text( - 'Ajustez le centre et la taille', - ), - trailing: const Icon( - Icons.arrow_forward_ios, - size: 16, - ), - onTap: () => setState(() => _isCalibrating = true), - ), - ), - const SizedBox(height: 12), - - // Score card - ScoreCard( - totalScore: provider.totalScore, - shotCount: provider.shotCount, - scoreResult: provider.scoreResult, - targetType: provider.targetType!, - ), - const SizedBox(height: 12), - - // Grouping stats - if (provider.groupingResult != null && - provider.shotCount > 1) - GroupingStats( - groupingResult: provider.groupingResult!, - targetCenterX: provider.targetCenterX, - targetCenterY: provider.targetCenterY, - ), - - const SizedBox(height: 12), - - // Action buttons - _buildActionButtons(context, provider), - - // Large spacing at the bottom to trigger the state change - const SizedBox(height: 50), - ], - ), - ) - else - // Calibration info - Padding( - padding: const EdgeInsets.all(AppConstants.defaultPadding), - child: Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Instructions de calibration', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + ringCount, { + List? ringRadii, + }) { + provider.adjustTargetPosition( + centerX, + centerY, + innerRadius, + radius, + ringCount: ringCount, + ringRadii: ringRadii, + ); + }, ), - ), - const SizedBox(height: 12), - _buildInstructionItem( - Icons.open_with, - 'Glissez le centre (croix bleue) pour positionner le centre de la cible', - ), - _buildInstructionItem( - Icons.zoom_out_map, - 'Glissez le bord (cercle orange) pour ajuster la taille de la cible', - ), - _buildInstructionItem( - Icons.visibility, - 'Les zones de score sont affichees en transparence', - ), - const SizedBox(height: 12), - Row( - children: [ - const Text('Centre: '), - Text( - '(${(provider.targetCenterX * 100).toStringAsFixed(1)}%, ${(provider.targetCenterY * 100).toStringAsFixed(1)}%)', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - Row( - children: [ - const Text('Rayon: '), - Text( - '${(provider.targetRadius * 100).toStringAsFixed(1)}%', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ], - ), - ), - ), + ], + ) + : _buildZoomableImageWithOverlay(context, provider), ), - ], - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Align( - alignment: _isAtBottom - ? Alignment.bottomCenter - : Alignment.bottomRight, - child: Padding( - padding: _isAtBottom - ? EdgeInsets.zero - : const EdgeInsets.all(16.0), - child: _isCalibrating - ? FloatingActionButton.extended( - onPressed: () => setState(() => _isCalibrating = false), - backgroundColor: AppTheme.successColor, - icon: const Icon(Icons.check), - label: const Text('Valider'), - ) - : AnimatedContainer( - duration: const Duration(milliseconds: 260), - curve: Curves.easeInOut, - width: _isAtBottom - ? MediaQuery.of(context).size.width - : 180, - height: 56, - decoration: BoxDecoration( - color: AppTheme.primaryColor, - borderRadius: BorderRadius.circular( - _isAtBottom ? 0 : 16, - ), - boxShadow: [ - if (!_isAtBottom) - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 6, - offset: const Offset(0, 3), + + // Info cards or Calibration info + if (!_isCalibrating) + Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + children: [ + // Calibration button + Card( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + child: ListTile( + leading: const Icon( + Icons.tune, + color: AppTheme.primaryColor, ), - ], - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _saveSession(context, provider), - borderRadius: BorderRadius.circular( - _isAtBottom ? 0 : 16, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, + title: const Text('Calibrer la cible'), + subtitle: const Text( + 'Ajustez le centre et la taille', ), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.save, color: Colors.white), - const SizedBox(width: 8), - Text( - _isAtBottom - ? 'SAUVEGARDER LA SESSION' - : 'Sauvegarder', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ], - ), + trailing: const Icon( + Icons.arrow_forward_ios, + size: 16, ), + onTap: () => setState(() => _isCalibrating = true), ), ), - ), + const SizedBox(height: 12), + + // Score card + ScoreCard( + totalScore: provider.totalScore, + shotCount: provider.shotCount, + scoreResult: provider.scoreResult, + targetType: provider.targetType!, + ), + const SizedBox(height: 12), + + // Grouping stats + if (provider.groupingResult != null && + provider.shotCount > 1) + GroupingStats( + groupingResult: provider.groupingResult!, + targetCenterX: provider.targetCenterX, + targetCenterY: provider.targetCenterY, + ), + + const SizedBox(height: 12), + + // Action buttons + _buildActionButtons(context, provider), + + const SizedBox(height: 50), + ], ), + ) + else + // Calibration info WITH directional arrows ABOVE the card + Padding( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + children: [ + // Fleches de micro-ajustement AU DESSUS du bloc + const Text( + 'Ajustement precis (pixel par pixel)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + ), + const SizedBox(height: 8), + Center( + child: Builder( + builder: (context) { + final size = MediaQuery.of(context).size; + return _calibrationKey.currentState?.buildDirectionalControls(context, size) + ?? const SizedBox.shrink(); + } + ), + ), + const SizedBox(height: 16), + + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Instructions de calibration', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 12), + _buildInstructionItem( + Icons.open_with, + 'Glissez le centre (croix bleue) pour positionner le centre de la cible', + ), + _buildInstructionItem( + Icons.zoom_out_map, + 'Glissez le bord (cercle orange) pour ajuster la taille de la cible', + ), + _buildInstructionItem( + Icons.visibility, + 'Les zones de score sont affichees en transparence', + ), + const SizedBox(height: 16), + Row( + children: [ + const Text('Centre: '), + Text( + '(${(provider.targetCenterX * 100).toStringAsFixed(1)}%, ${(provider.targetCenterY * 100).toStringAsFixed(1)}%)', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + Row( + children: [ + const Text('Rayon: '), + Text( + '${(provider.targetRadius * 100).toStringAsFixed(1)}%', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], ), ), - ), - ], + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Align( + alignment: _isAtBottom + ? Alignment.bottomCenter + : Alignment.bottomRight, + child: Padding( + padding: _isAtBottom + ? EdgeInsets.zero + : const EdgeInsets.all(16.0), + child: _isCalibrating + ? const SizedBox.shrink() + : FloatingActionButton.extended( + onPressed: () => _showSaveSessionDialog(context, provider), + backgroundColor: AppTheme.primaryColor, + icon: const Icon(Icons.save), + label: const Text('TERMINER LA SESSION'), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildInstructionItem(IconData icon, String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon(icon, size: 16, color: AppTheme.primaryColor), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), ); } @@ -699,834 +433,230 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { BuildContext context, AnalysisProvider provider, ) { - return ClipRect( + return InteractiveViewer( + transformationController: _transformationController, + minScale: 1.0, + maxScale: 10.0, + boundaryMargin: const EdgeInsets.all(double.infinity), child: Stack( - fit: StackFit.expand, children: [ - // Image zoomable avec InteractiveViewer - InteractiveViewer( - transformationController: _transformationController, - minScale: 1.0, - maxScale: 5.0, - panEnabled: true, - scaleEnabled: true, - constrained: true, - boundaryMargin: EdgeInsets.zero, - interactionEndFrictionCoefficient: 0.0000135, - child: Stack( - fit: StackFit.expand, - children: [ - Image.file( - File(provider.displayImagePath!), - fit: BoxFit.fill, - key: _imageKey, - ), - // Overlay qui se transforme avec l'image - TargetOverlay( - showRings: true, - shots: provider.shots, - targetCenterX: provider.targetCenterX, - targetCenterY: provider.targetCenterY, - targetRadius: provider.targetRadius, - targetType: provider.targetType!, - ringCount: provider.ringCount, - ringRadii: provider.ringRadii, - zoomScale: _currentZoomScale, - onShotTapped: (shot) => _isSelectingReferences - ? null - : _showShotOptions(context, provider, shot.id), - onAddShot: (x, y) { - if (_isSelectingReferences) { - provider.addReferenceImpact(x, y); - } else { - provider.addShot(x, y); - } - }, - groupingCenterX: provider.groupingResult?.centerX, - groupingCenterY: provider.groupingResult?.centerY, - groupingDiameter: provider.groupingResult?.diameter, - referenceImpacts: _isSelectingReferences - ? provider.referenceImpacts - : null, - ), - ], - ), + Image.file( + File(provider.imagePath!), + key: _imageKey, + fit: BoxFit.fill, ), - // Bouton pour effacer les impacts - if (provider.shotCount > 0 && !_isSelectingReferences) - Positioned( - left: 8, - bottom: 8, - child: FloatingActionButton.small( - heroTag: 'clearShots', - onPressed: () => _showClearConfirmation(context, provider), - backgroundColor: Colors.black54, - child: const Icon(Icons.delete, color: Colors.white), - ), - ), - // Bouton pour passer en mode plein écran d'édition - Positioned( - right: 8, - bottom: 8, - child: FloatingActionButton.small( - heroTag: 'fullscreenEdit', - onPressed: _toggleFullscreenEditMode, - backgroundColor: Colors.black54, - child: const Icon(Icons.fullscreen, color: Colors.white), - ), + TargetOverlay( + targetCenterX: provider.targetCenterX, + targetCenterY: provider.targetCenterY, + targetRadius: provider.targetRadius, + targetType: provider.targetType!, + shots: provider.shots, + showRings: true, + zoomScale: _currentZoomScale, + onShotTapped: (shot) => _showShotDetails(context, provider, shot), + onAddShot: (relX, relY) => provider.addShot(relX, relY), ), ], ), ); } - Widget _buildFullscreenEditContent( - BuildContext context, - AnalysisProvider provider, - ) { - return Stack( - fit: StackFit.expand, - children: [ - // Image zoomable en plein écran - InteractiveViewer( - transformationController: _transformationController, - minScale: 1.0, - maxScale: 5.0, - panEnabled: true, - scaleEnabled: true, - constrained: true, - boundaryMargin: EdgeInsets.zero, - interactionEndFrictionCoefficient: 0.0000135, - child: Center( - child: AspectRatio( - aspectRatio: provider.imageAspectRatio, - child: Stack( - fit: StackFit.expand, - children: [ - Image.file( - File(provider.displayImagePath!), - fit: BoxFit.fill, - key: _imageKey, - ), - // Overlay qui se transforme avec l'image - TargetOverlay( - showRings: true, - shots: provider.shots, - targetCenterX: provider.targetCenterX, - targetCenterY: provider.targetCenterY, - targetRadius: provider.targetRadius, - targetType: provider.targetType!, - ringCount: provider.ringCount, - ringRadii: provider.ringRadii, - zoomScale: _currentZoomScale, - onShotTapped: (shot) => - _showShotOptions(context, provider, shot.id), - onAddShot: (x, y) { - provider.addShot(x, y); - }, - groupingCenterX: provider.groupingResult?.centerX, - groupingCenterY: provider.groupingResult?.centerY, - groupingDiameter: provider.groupingResult?.diameter, - ), - ], - ), - ), - ), - ), - // Barre d'info en haut - Positioned( - top: 0, - left: 0, - right: 0, - child: Container( - color: Colors.black87, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: SafeArea( - bottom: false, - child: Row( - children: [ - const Icon(Icons.edit, color: Colors.white, size: 20), - const SizedBox(width: 8), - const Expanded( - child: Text( - 'Mode édition - Touchez pour ajouter des impacts', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - Text( - '${provider.shotCount} impacts', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.8), - ), - ), - ], - ), - ), - ), - ), - // Bouton pour quitter le mode plein écran - Positioned( - right: 16, - bottom: 16, - child: FloatingActionButton( - heroTag: 'exitFullscreen', - onPressed: _toggleFullscreenEditMode, - backgroundColor: AppTheme.primaryColor, - child: const Icon(Icons.fullscreen_exit, color: Colors.white), - ), - ), - // Indicateur de zoom en bas à gauche - Positioned( - left: 16, - bottom: 16, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.zoom_in, color: Colors.white, size: 18), - const SizedBox(width: 6), - Text( - '${(_currentZoomScale * 100).toStringAsFixed(0)}%', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ); - } - - Widget _buildInstructionItem(IconData icon, String text) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 20, color: AppTheme.primaryColor), - const SizedBox(width: 8), - Expanded(child: Text(text)), - ], - ), - ); - } - Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) { - return const SizedBox.shrink(); + return Column( + children: [ + Row( + children: [ + /*Expanded( + child: ElevatedButton.icon( + onPressed: null, // Desactive selon demande + icon: const Icon(Icons.auto_awesome), + label: const Text('AUTO DÉTECTER'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: null, // Desactive selon demande + icon: const Icon(Icons.straighten), + label: const Text('PAR RÉFÉRENCE'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + ), + ), + ),*/ + ], + ), + ], + ); } - void _showHelpDialog(BuildContext context) { + void _showShotDetails( + BuildContext context, + AnalysisProvider provider, + Shot shot, + ) { + showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Impact #${provider.shots.indexOf(shot) + 1}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + Text( + 'ID: ${shot.id}', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10), + ), + const SizedBox(height: 16), + ListTile( + leading: const Icon(Icons.score), + title: const Text('Modifier le score'), + trailing: DropdownButton( + value: shot.score.clamp(0, 10), + items: List.generate(11, (index) => index) + .map((s) => DropdownMenuItem( + value: s, + child: Text('$s', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), + )) + .toList(), + onChanged: (newScore) { + if (newScore != null) { + provider.updateShotScore(shot.id, newScore); + Navigator.pop(context); // Ferme pour valider + } + }, + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + provider.removeShot(shot.id); + Navigator.pop(context); + }, + icon: const Icon(Icons.delete, color: Colors.red), + label: const Text('SUPPRIMER', style: TextStyle(color: Colors.red)), + ), + ), + ], + ), + ], + ), + ), + ); + } + + void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) { + final sessionProvider = context.read(); + showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Aide'), - content: const Column( + title: const Text('Session Terminee'), + content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '1. Calibrez d\'abord la cible en utilisant le bouton de calibration', - ), - SizedBox(height: 8), - Text('2. Appuyez sur l\'image pour ajouter un impact manuellement'), - SizedBox(height: 8), - Text('3. Appuyez sur un impact pour le modifier ou le supprimer'), - SizedBox(height: 8), - Text('4. Les cercles bleus indiquent le groupement de vos tirs'), + Text('Nombre de tirs: ${provider.shotCount}'), + Text('Score total: ${provider.totalScore}'), + const SizedBox(height: 16), + const Text('Voulez-vous enregistrer cette session ?'), ], ), actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], - ), - ); - } - - void _showShotOptions( - BuildContext context, - AnalysisProvider provider, - String shotId, - ) { - final shot = provider.shots.firstWhere((s) => s.id == shotId); - - showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) => SafeArea( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Modifier l\'impact', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 20), - // Dropdown for score - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Valeur de l\'impact : ', style: TextStyle(fontSize: 16)), - const SizedBox(width: 10), - DropdownButton( - value: shot.score.clamp(0, 10), - items: List.generate(11, (i) => i).map((i) { - return DropdownMenuItem( - value: i, - child: Text(i == 10 && provider.targetType == TargetType.concentric ? '10 (X)' : '$i'), - ); - }).toList(), - onChanged: (newScore) { - if (newScore != null) { - provider.updateShotScore(shotId, newScore); - Navigator.pop(context); - } - }, - ), - ], - ), - const SizedBox(height: 20), - // Delete button at the bottom - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - provider.removeShot(shotId); - Navigator.pop(context); - }, - icon: const Icon(Icons.delete), - label: const Text('SUPPRIMER L\'IMPACT'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.errorColor, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - ], - ), - ), - ), - ); - } - - /* - void _showAddShotHint(BuildContext context) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Appuyez sur l\'image pour ajouter un impact'), - duration: Duration(seconds: 2), - ), - ); - } - */ - - void _showClearConfirmation(BuildContext context, AnalysisProvider provider) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Confirmer'), - content: const Text('Voulez-vous effacer tous les impacts?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), TextButton( onPressed: () { - for (final shot in List.from(provider.shots)) { - provider.removeShot(shot.id); - } - Navigator.pop(context); + Navigator.pop(context); // Close dialog + + final path = provider.imagePath!; + final type = provider.targetType!; + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => CropScreen( + imagePath: path, + targetType: type, + initialScale: widget.cropScale, + initialOffset: widget.cropOffset, + ), + ), + ); }, - child: const Text( - 'Effacer', - style: TextStyle(color: AppTheme.errorColor), - ), + child: const Text('ANNULER'), + ), + ElevatedButton( + onPressed: () async { + try { + final sessionProvider = context.read(); + final analysis = await provider.saveSession( + sessionId: sessionProvider.activeSessionId, + weaponName: sessionProvider.currentWeapon, + weaponId: sessionProvider.currentWeaponId, + distance: sessionProvider.distance, + ); + + // Mettre à jour le provider de session pour incrémenter le compteur + sessionProvider.addAnalysis(analysis); + + if (context.mounted) { + Navigator.pop(context); // Close dialog + // Go back to capture screen for a new target in the same session context + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const CaptureScreen()), + (route) => route.isFirst, + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor), + ); + } + } + }, + child: const Text('AJOUTER UNE CIBLE'), + ), + ElevatedButton( + onPressed: () async { + try { + final sessionProvider = context.read(); + await provider.saveSession( + sessionId: sessionProvider.activeSessionId, + weaponName: sessionProvider.currentWeapon, + weaponId: sessionProvider.currentWeaponId, + distance: sessionProvider.distance, + ); + + if (context.mounted) { + // Important: Marquer la session comme terminée dans le provider global + sessionProvider.endSession(); + + Navigator.pop(context); // Ferme le dialogue + // Retourne à l'accueil (premier écran) + Navigator.of(context).popUntil((route) => route.isFirst); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor), + ); + } + } + }, + child: const Text('TERMINER TOUT'), ), ], ), ); } - - - void _showAutoDetectDialog(BuildContext context, AnalysisProvider provider) { - // Detection settings - bool clearExisting = true; - double minCircularity = 0.6; - int darkThreshold = 80; - int minImpactSize = 20; - int maxImpactSize = 500; - double minFillRatio = 0.5; - - showDialog( - context: context, - builder: (context) => StatefulBuilder( - builder: (context, setState) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.auto_fix_high, color: AppTheme.primaryColor), - SizedBox(width: 8), - Text('Auto-Detection'), - ], - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - - const Text( - 'Détection Classique (Paramétrable):', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - - // Dark threshold slider - Text('Seuil de detection (zones sombres): $darkThreshold'), - Slider( - value: darkThreshold.toDouble(), - min: 20, - max: 150, - divisions: 13, - label: '$darkThreshold', - onChanged: (value) { - setState(() => darkThreshold = value.round()); - }, - ), - const Text( - 'Plus bas = detecte uniquement les zones tres sombres', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - const SizedBox(height: 12), - - // Circularity slider - Text( - 'Circularite minimum: ${(minCircularity * 100).toStringAsFixed(0)}%', - ), - Slider( - value: minCircularity, - min: 0.3, - max: 0.9, - divisions: 12, - label: '${(minCircularity * 100).toStringAsFixed(0)}%', - onChanged: (value) { - setState(() => minCircularity = value); - }, - ), - const Text( - 'Plus haut = detecte uniquement les formes rondes', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - const SizedBox(height: 12), - - // Fill ratio slider - Text( - 'Remplissage minimum: ${(minFillRatio * 100).toStringAsFixed(0)}%', - ), - Slider( - value: minFillRatio, - min: 0.3, - max: 0.9, - divisions: 12, - label: '${(minFillRatio * 100).toStringAsFixed(0)}%', - onChanged: (value) { - setState(() => minFillRatio = value); - }, - ), - const Text( - 'Plus haut = detecte les trous pleins (evite les cercles creux des chiffres)', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - const SizedBox(height: 12), - - // Min impact size slider - Text('Taille minimum (pixels): $minImpactSize'), - Slider( - value: minImpactSize.toDouble(), - min: 5, - max: 100, - divisions: 19, - label: '$minImpactSize', - onChanged: (value) { - setState(() => minImpactSize = value.round()); - }, - ), - const SizedBox(height: 12), - - // Max impact size slider - Text('Taille maximum (pixels): $maxImpactSize'), - Slider( - value: maxImpactSize.toDouble(), - min: 100, - max: 1000, - divisions: 18, - label: '$maxImpactSize', - onChanged: (value) { - setState(() => maxImpactSize = value.round()); - }, - ), - - const SizedBox(height: 12), - - // Clear existing checkbox - CheckboxListTile( - title: const Text('Effacer les impacts existants'), - value: clearExisting, - onChanged: (value) { - setState(() => clearExisting = value ?? true); - }, - contentPadding: EdgeInsets.zero, - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), - ElevatedButton.icon( - onPressed: () async { - Navigator.pop(context); - - // Show loading indicator - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ), - SizedBox(width: 12), - Text('Detection en cours...'), - ], - ), - duration: Duration(seconds: 10), - ), - ); - - // Run classic detection - final count = await provider.autoDetectImpacts( - darkThreshold: darkThreshold, - minImpactSize: minImpactSize, - maxImpactSize: maxImpactSize, - minCircularity: minCircularity, - minFillRatio: minFillRatio, - clearExisting: clearExisting, - ); - - // Hide loading and show result - if (context.mounted) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - count > 0 - ? '$count impact(s) detecte(s)' - : 'Aucun impact detecte. Essayez d\'ajuster les parametres.', - ), - backgroundColor: count > 0 - ? AppTheme.successColor - : AppTheme.warningColor, - ), - ); - } - }, - icon: const Icon(Icons.search), - label: const Text('Détecter (Classique)'), - ), - ], - ), - ), - ); - } - - void _showCalibratedDetectionDialog( - BuildContext context, - AnalysisProvider provider, - ) { - double tolerance = 2.0; - bool clearExisting = true; - // NOTE: OpenCV désactivé - problèmes de build Windows - // Utilisation de la détection classique uniquement - - showDialog( - context: context, - builder: (context) => StatefulBuilder( - builder: (context, setState) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.touch_app, color: Colors.deepPurple), - SizedBox(width: 8), - Text('Detection par Reference'), - ], - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - color: Colors.deepPurple.withValues(alpha: 0.1), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - const Icon( - Icons.info_outline, - color: Colors.deepPurple, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '${provider.referenceImpacts.length} impacts de reference selectionnes', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - - const Text( - 'L\'algorithme va analyser les caracteristiques des impacts ' - 'de reference (luminosite, taille, forme) et chercher des ' - 'impacts similaires sur toute l\'image.', - style: TextStyle(fontSize: 13), - ), - const SizedBox(height: 16), - - // Tolerance slider - Text('Tolerance: ${tolerance.toStringAsFixed(1)}x'), - Slider( - value: tolerance, - min: 1.0, - max: 5.0, - divisions: 8, - label: '${tolerance.toStringAsFixed(1)}x', - activeColor: Colors.deepPurple, - onChanged: (value) { - setState(() => tolerance = value); - }, - ), - const Text( - 'Tolerance basse = detection stricte (moins de faux positifs)\n' - 'Tolerance haute = detection large (plus de detections)', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - const SizedBox(height: 12), - - // Clear existing checkbox - CheckboxListTile( - title: const Text('Effacer les impacts existants'), - value: clearExisting, - activeColor: Colors.deepPurple, - onChanged: (value) { - setState(() => clearExisting = value ?? true); - }, - contentPadding: EdgeInsets.zero, - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), - ElevatedButton.icon( - onPressed: () async { - Navigator.pop(context); - - // Show loading - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ), - SizedBox(width: 12), - Text('Apprentissage des references...'), - ], - ), - duration: Duration(seconds: 10), - ), - ); - - // Classic detection: learn then detect - final learned = provider.learnFromReferences(); - - if (!learned) { - if (context.mounted) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Impossible d\'analyser les references. Essayez de selectionner d\'autres impacts.', - ), - backgroundColor: AppTheme.errorColor, - ), - ); - } - return; - } - - // Run calibrated detection - if (context.mounted) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ), - SizedBox(width: 12), - Text('Detection en cours...'), - ], - ), - duration: Duration(seconds: 10), - ), - ); - } - - final count = await provider.detectFromReferences( - tolerance: tolerance, - clearExisting: clearExisting, - ); - - // Exit reference mode - if (mounted) { - setState(() {}); - } - _isSelectingReferences = false; - provider.clearReferenceImpacts(); - - // Show result - if (context.mounted) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - count > 0 - ? '$count impact(s) detecte(s) a partir des references' - : 'Aucun impact detecte. Essayez d\'augmenter la tolerance.', - ), - backgroundColor: count > 0 - ? AppTheme.successColor - : AppTheme.warningColor, - ), - ); - } - - // Trigger rebuild - if (mounted) { - this.setState(() {}); - } - }, - icon: const Icon(Icons.search), - label: const Text('Detecter'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.deepPurple, - foregroundColor: Colors.white, - ), - ), - ], - ), - ), - ); - } - - Future _saveSession( - BuildContext context, - AnalysisProvider provider, - ) async { - final notesController = TextEditingController(); - - final shouldSave = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Sauvegarder la session'), - content: TextField( - controller: notesController, - decoration: const InputDecoration( - labelText: 'Notes (optionnel)', - hintText: 'Ex: Entrainement, 10m, debout...', - ), - maxLines: 2, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Annuler'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Sauvegarder'), - ), - ], - ), - ); - - if (shouldSave == true) { - try { - await provider.saveSession( - notes: notesController.text.isEmpty ? null : notesController.text, - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Session sauvegardee'), - backgroundColor: AppTheme.successColor, - ), - ); - Navigator.pop(context); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Erreur: $e'), - backgroundColor: AppTheme.errorColor, - ), - ); - } - } - } - } } diff --git a/lib/features/analysis/widgets/target_calibration.dart b/lib/features/analysis/widgets/target_calibration.dart index b78e2eb4..a3a1ce1b 100644 --- a/lib/features/analysis/widgets/target_calibration.dart +++ b/lib/features/analysis/widgets/target_calibration.dart @@ -40,10 +40,10 @@ class TargetCalibration extends StatefulWidget { }); @override - State createState() => _TargetCalibrationState(); + State createState() => TargetCalibrationState(); } -class _TargetCalibrationState extends State { +class TargetCalibrationState extends State { late double _centerX; late double _centerY; late double _radius; @@ -66,8 +66,8 @@ class _TargetCalibrationState extends State { _initRingRadii(); } - void _initRingRadii() { - if (widget.initialRingRadii != null && + void _initRingRadii({bool forceRecalculate = false}) { + if (!forceRecalculate && widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) { _ringRadii = List.from(widget.initialRingRadii!); } else { @@ -121,75 +121,188 @@ class _TargetCalibrationState extends State { builder: (context, constraints) { final size = constraints.biggest; - return GestureDetector( - onPanStart: (details) => _onPanStart(details, size), - onPanUpdate: (details) => _onPanUpdate(details, size), - onPanEnd: (_) => _onPanEnd(), - child: CustomPaint( - size: size, - painter: _CalibrationPainter( - centerX: _centerX, - centerY: _centerY, - radius: _radius, - innerRadius: _innerRadius, - ringCount: _ringCount, - ringRadii: _ringRadii, - targetType: widget.targetType, - isDraggingCenter: _isDraggingCenter, - isDraggingRadius: _isDraggingRadius, - isDraggingInnerRadius: _isDraggingInnerRadius, + return Stack( + children: [ + GestureDetector( + onPanStart: (details) => _onPanStart(details, size), + onPanUpdate: (details) => _onPanUpdate(details, size), + onPanEnd: (_) => _onPanEnd(), + child: CustomPaint( + size: size, + painter: _CalibrationPainter( + centerX: _centerX, + centerY: _centerY, + radius: _radius, + innerRadius: _innerRadius, + ringCount: _ringCount, + ringRadii: _ringRadii, + targetType: widget.targetType, + isDraggingCenter: _isDraggingCenter, + isDraggingRadius: false, // Plus de drag direct du rayon + isDraggingInnerRadius: false, + ), + ), ), - ), + + // Slider pour le rayon (agrandissement) + Positioned( + top: 10, + left: 40, + right: 40, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)), + const Icon(Icons.zoom_out, color: Colors.white, size: 16), + Expanded( + child: Slider( + value: _radius.clamp(0.05, 3.0), + min: 0.05, + max: 3.0, + activeColor: AppTheme.primaryColor, + onChanged: (value) { + setState(() { + _radius = value; + // On force le recalcul car on change la taille + _initRingRadii(forceRecalculate: true); + }); + _notifyChange(); + }, + ), + ), + const Icon(Icons.zoom_in, color: Colors.white, size: 16), + ], + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)), + const Icon(Icons.compress, color: Colors.white, size: 16), + Expanded( + child: Slider( + value: (_innerRadius / _radius).clamp(0.01, 0.9), + min: 0.01, + max: 0.9, + activeColor: Colors.orange, + onChanged: (value) { + setState(() { + // On ajuste l'innerRadius comme un ratio du radius global + _innerRadius = _radius * value; + _initRingRadii(forceRecalculate: true); + }); + _notifyChange(); + }, + ), + ), + const Icon(Icons.expand, color: Colors.white, size: 16), + ], + ), + ), + ], + ), + ), + + ], ); }, ); } + Widget buildDirectionalControls(BuildContext context, Size size) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white10), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDirectionButton( + Icons.keyboard_arrow_up, + () => _moveCenterByPixels(0, -1, size), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDirectionButton( + Icons.keyboard_arrow_left, + () => _moveCenterByPixels(-1, 0, size), + ), + const SizedBox(width: 40), + _buildDirectionButton( + Icons.keyboard_arrow_right, + () => _moveCenterByPixels(1, 0, size), + ), + ], + ), + _buildDirectionButton( + Icons.keyboard_arrow_down, + () => _moveCenterByPixels(0, 1, size), + ), + ], + ), + ); + } + + Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) { + return Container( + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + ), + margin: const EdgeInsets.all(2), + child: IconButton( + icon: Icon(icon, color: Colors.white, size: 28), + onPressed: onPressed, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + ), + ); + } + + void _notifyChange() { + widget.onCalibrationChanged( + _centerX, + _centerY, + _innerRadius, + _radius, + _ringCount, + ringRadii: _ringRadii, + ); + } + + void _moveCenterByPixels(double dx, double dy, Size size) { + setState(() { + _centerX = _centerX + (dx / size.width); + _centerY = _centerY + (dy / size.height); + }); + _notifyChange(); + } + void _onPanStart(DragStartDetails details, Size size) { final tapX = details.localPosition.dx / size.width; final tapY = details.localPosition.dy / size.height; - // Check if tapping on center handle final distToCenter = _distance(tapX, tapY, _centerX, _centerY); - - // Check if tapping on outer radius handle - final minDim = math.min(size.width, size.height); - final outerRadius = _radius; - final radiusHandleX = _centerX + outerRadius * minDim / size.width; - final radiusHandleY = _centerY; - final distToOuterHandle = _distance( - tapX, - tapY, - radiusHandleX.clamp(0.0, 1.0), - radiusHandleY.clamp(0.0, 1.0), - ); - - // Check if tapping on inner radius handle (top edge of innermost circle) - final actualInnerRadius = _innerRadius; - final innerHandleX = _centerX; - final innerHandleY = _centerY - actualInnerRadius * minDim / size.height; - final distToInnerHandle = _distance( - tapX, - tapY, - innerHandleX.clamp(0.0, 1.0), - innerHandleY.clamp(0.0, 1.0), - ); - - // Increase touch target size slightly for handles - if (distToCenter < 0.05) { - setState(() { - _isDraggingCenter = true; - }); - } else if (distToOuterHandle < 0.05) { - setState(() { - _isDraggingRadius = true; - }); - } else if (distToInnerHandle < 0.05) { - setState(() { - _isDraggingInnerRadius = true; - }); - } else if (distToCenter < _radius + 0.02) { - // Tapping inside the target - move center + + if (distToCenter < 0.05 || distToCenter < _radius + 0.02) { setState(() { _isDraggingCenter = true; }); @@ -206,30 +319,10 @@ class _TargetCalibrationState extends State { // Move center _centerX = _centerX + deltaX; _centerY = _centerY + deltaY; - } else if (_isDraggingRadius) { - // Adjust outer radius - final newRadius = _radius + deltaX * (size.width / minDim); - _radius = newRadius.clamp(math.max(0.05, _innerRadius + 0.01), 3.0); - _initRingRadii(); // Recalculate linear separation - } else if (_isDraggingInnerRadius) { - // Adjust inner radius (sliding up reduces Y, so deltaY is negative when growing. Thus we subtract deltaY) - final newInnerRadius = _innerRadius - deltaY * (size.height / minDim); - _innerRadius = newInnerRadius.clamp( - 0.01, - math.max(0.01, _radius - 0.01), - ); - _initRingRadii(); // Recalculate linear separation } }); - widget.onCalibrationChanged( - _centerX, - _centerY, - _innerRadius, - _radius, - _ringCount, - ringRadii: _ringRadii, - ); + _notifyChange(); } void _onPanEnd() { @@ -288,7 +381,7 @@ class _CalibrationPainter extends CustomPainter { // Fullscreen crosshairs when dragging center if (isDraggingCenter) { final crosshairLinePaint = Paint() - ..color = AppTheme.successColor.withValues(alpha: 0.5) + ..color = Colors.red.withValues(alpha: 0.5) ..strokeWidth = 1; canvas.drawLine( Offset(0, centerPx.dy), @@ -305,12 +398,6 @@ class _CalibrationPainter extends CustomPainter { // Draw center handle _drawCenterHandle(canvas, centerPx); - // Draw radius handle (for outer ring) - _drawRadiusHandle(canvas, size, centerPx, baseRadiusPx); - - // Draw inner radius handle - _drawInnerRadiusHandle(canvas, size, centerPx, innerRadiusPx); - // Draw instructions _drawInstructions(canvas, size); } @@ -342,7 +429,7 @@ class _CalibrationPainter extends CustomPainter { final strokePaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 1 - ..color = Colors.white.withValues(alpha: 0.6); + ..color = Colors.red.withValues(alpha: 0.8); // Draw from outside to inside for (int i = ringCount - 1; i >= 0; i--) { @@ -426,20 +513,20 @@ class _CalibrationPainter extends CustomPainter { void _drawCenterHandle(Canvas canvas, Offset center) { // Outer circle final outerPaint = Paint() - ..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor + ..color = isDraggingCenter ? Colors.redAccent : Colors.red ..style = PaintingStyle.stroke ..strokeWidth = 3; canvas.drawCircle(center, 15, outerPaint); // Inner dot final innerPaint = Paint() - ..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor + ..color = isDraggingCenter ? Colors.redAccent : Colors.red ..style = PaintingStyle.fill; canvas.drawCircle(center, 5, innerPaint); // Crosshair final crossPaint = Paint() - ..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor + ..color = isDraggingCenter ? Colors.redAccent : Colors.red ..strokeWidth = 2; canvas.drawLine( Offset(center.dx - 20, center.dy), @@ -463,151 +550,6 @@ class _CalibrationPainter extends CustomPainter { ); } - void _drawRadiusHandle( - Canvas canvas, - Size size, - Offset center, - double baseRadius, - ) { - // Radius handle on the right edge of the outermost ring - final actualHandleX = center.dx + baseRadius; - final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20); - final clampedHandleY = center.dy.clamp(20.0, size.height - 20); - final handlePos = Offset(clampedHandleX, clampedHandleY); - - // Check if handle is clamped (radius extends beyond visible area) - final isClamped = actualHandleX > size.width - 20; - - final paint = Paint() - ..color = isDraggingRadius - ? AppTheme.successColor - : (isClamped ? Colors.orange : AppTheme.warningColor) - ..style = PaintingStyle.fill; - - // Draw handle as a small circle with arrows - canvas.drawCircle(handlePos, 14, paint); - - // Draw arrow indicators - final arrowPaint = Paint() - ..color = Colors.white - ..strokeWidth = 2 - ..style = PaintingStyle.stroke; - - // Left arrow - canvas.drawLine( - Offset(handlePos.dx - 4, handlePos.dy), - Offset(handlePos.dx - 8, handlePos.dy - 4), - arrowPaint, - ); - canvas.drawLine( - Offset(handlePos.dx - 4, handlePos.dy), - Offset(handlePos.dx - 8, handlePos.dy + 4), - arrowPaint, - ); - - // Right arrow - canvas.drawLine( - Offset(handlePos.dx + 4, handlePos.dy), - Offset(handlePos.dx + 8, handlePos.dy - 4), - arrowPaint, - ); - canvas.drawLine( - Offset(handlePos.dx + 4, handlePos.dy), - Offset(handlePos.dx + 8, handlePos.dy + 4), - arrowPaint, - ); - - // Label - final textPainter = TextPainter( - text: const TextSpan( - text: 'EXT.', - style: TextStyle( - color: Colors.white, - fontSize: 8, - fontWeight: FontWeight.bold, - ), - ), - textDirection: TextDirection.ltr, - ); - textPainter.layout(); - textPainter.paint( - canvas, - Offset(handlePos.dx - textPainter.width / 2, handlePos.dy + 16), - ); - } - - void _drawInnerRadiusHandle( - Canvas canvas, - Size size, - Offset center, - double innerRadiusPx, - ) { - // Inner radius handle on the top edge of the innermost ring - final actualHandleY = center.dy - innerRadiusPx; - final clampedHandleX = center.dx.clamp(20.0, size.width - 20); - final clampedHandleY = actualHandleY.clamp(20.0, size.height - 20); - final handlePos = Offset(clampedHandleX, clampedHandleY); - - final isClamped = actualHandleY < 20.0; - - final paint = Paint() - ..color = isDraggingInnerRadius - ? AppTheme.successColor - : (isClamped ? Colors.orange : Colors.purpleAccent) - ..style = PaintingStyle.fill; - - // Draw handle - canvas.drawCircle(handlePos, 14, paint); - - // Up/Down arrow indicators - final arrowPaint = Paint() - ..color = Colors.white - ..strokeWidth = 2 - ..style = PaintingStyle.stroke; - - // Up arrow - canvas.drawLine( - Offset(handlePos.dx, handlePos.dy - 4), - Offset(handlePos.dx - 4, handlePos.dy - 8), - arrowPaint, - ); - canvas.drawLine( - Offset(handlePos.dx, handlePos.dy - 4), - Offset(handlePos.dx + 4, handlePos.dy - 8), - arrowPaint, - ); - - // Down arrow - canvas.drawLine( - Offset(handlePos.dx, handlePos.dy + 4), - Offset(handlePos.dx - 4, handlePos.dy + 8), - arrowPaint, - ); - canvas.drawLine( - Offset(handlePos.dx, handlePos.dy + 4), - Offset(handlePos.dx + 4, handlePos.dy + 8), - arrowPaint, - ); - - // Label - final textPainter = TextPainter( - text: const TextSpan( - text: 'INT.', - style: TextStyle( - color: Colors.white, - fontSize: 8, - fontWeight: FontWeight.bold, - ), - ), - textDirection: TextDirection.ltr, - ); - textPainter.layout(); - textPainter.paint( - canvas, - Offset(handlePos.dx - textPainter.width / 2, handlePos.dy - 24), - ); - } - void _drawInstructions(Canvas canvas, Size size) { const instruction = 'Deplacez le centre ou ajustez le rayon'; @@ -630,7 +572,8 @@ class _CalibrationPainter extends CustomPainter { } @override - bool shouldRepaint(covariant _CalibrationPainter oldDelegate) { + bool shouldRepaint(CustomPainter oldDelegate) { + if (oldDelegate is! _CalibrationPainter) return true; return centerX != oldDelegate.centerX || centerY != oldDelegate.centerY || radius != oldDelegate.radius || diff --git a/lib/features/capture/capture_screen.dart b/lib/features/capture/capture_screen.dart index b690b15a..ef61ec74 100644 --- a/lib/features/capture/capture_screen.dart +++ b/lib/features/capture/capture_screen.dart @@ -5,10 +5,12 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:provider/provider.dart'; import '../../core/constants/app_constants.dart'; import '../../core/theme/app_theme.dart'; import '../../data/models/target_type.dart'; import '../crop/crop_screen.dart'; +import '../session/session_provider.dart'; import 'widgets/image_source_button.dart'; class CaptureScreen extends StatefulWidget { @@ -80,8 +82,13 @@ class _CaptureScreenState extends State { @override Widget build(BuildContext context) { + final sessionProvider = context.watch(); + final title = sessionProvider.isSessionActive + ? 'Cible ${sessionProvider.targetCount + 1}' + : 'Source'; + return Scaffold( - appBar: AppBar(title: const Text('Source')), + appBar: AppBar(title: Text(title)), body: SingleChildScrollView( padding: const EdgeInsets.all(AppConstants.defaultPadding), child: Column( @@ -119,42 +126,8 @@ class _CaptureScreenState extends State { child: CircularProgressIndicator(), ), ) - else if (_selectedImagePath != null) - _buildImagePreview() else _buildGuide(), - - // --- NOUVEAU BOUTON VALIDER --- - if (_selectedImagePath != null && !_isLoading) ...[ - const SizedBox(height: 24), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF112233), // Ton bleu foncé - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 54), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 0, - ), - onPressed: _analyzeImage, - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Valider', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - SizedBox(width: 12), - Icon(Icons.arrow_forward, size: 20), - ], - ), - ), - const SizedBox(height: 32), - ], ], ), ), @@ -170,61 +143,9 @@ class _CaptureScreenState extends State { ); } - Widget _buildImagePreview() { - return Column( - children: [ - _buildSectionTitle('Aperçu'), - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(AppConstants.borderRadius), - child: Stack( - children: [ - Image.file( - File(_selectedImagePath!), - fit: BoxFit.contain, - width: double.infinity, - ), - Positioned( - top: 8, - right: 8, - child: IconButton( - icon: const Icon(Icons.close), - onPressed: () => setState(() => _selectedImagePath = null), - style: IconButton.styleFrom( - backgroundColor: Colors.black54, - foregroundColor: Colors.white, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 12), - _buildFramingHints(), - ], - ); - } - Widget _buildFramingHints() { - return Card( - color: AppTheme.warningColor.withOpacity(0.1), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon(Icons.info_outline, color: AppTheme.warningColor), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Assurez-vous que la cible est bien centrée et visible.', - style: TextStyle(color: AppTheme.warningColor.withOpacity(0.8)), - ), - ), - ], - ), - ), - ); - } + + Widget _buildGuide() { return Card( @@ -283,7 +204,8 @@ class _CaptureScreenState extends State { final scanner = DocumentScanner(options: options); final documents = await scanner.scanDocument(); if (documents.images.isNotEmpty) { - setState(() => _selectedImagePath = documents.images.first); + _selectedImagePath = documents.images.first; + _analyzeImage(); } } catch (e) { debugPrint('Erreur scan: $e'); @@ -302,7 +224,8 @@ class _CaptureScreenState extends State { imageQuality: 90, ); if (image != null) { - setState(() => _selectedImagePath = image.path); + _selectedImagePath = image.path; + _analyzeImage(); } } catch (e) { debugPrint('Erreur capture: $e'); diff --git a/lib/features/crop/crop_screen.dart b/lib/features/crop/crop_screen.dart index ce4e2830..c0a32a31 100644 --- a/lib/features/crop/crop_screen.dart +++ b/lib/features/crop/crop_screen.dart @@ -1,286 +1,317 @@ -import 'dart:io'; -import 'dart:math' as math; -import 'package:flutter/material.dart'; - -import '../../core/theme/app_theme.dart'; -import '../../data/models/target_type.dart'; -import '../../services/image_crop_service.dart'; -import '../analysis/analysis_screen.dart'; -import 'widgets/crop_overlay.dart'; - -class CropScreen extends StatefulWidget { - final String imagePath; - final TargetType targetType; - - const CropScreen({ - super.key, - required this.imagePath, - required this.targetType, - }); - - @override - State createState() => _CropScreenState(); -} - -class _CropScreenState extends State { - final ImageCropService _cropService = ImageCropService(); - - // États de transformation - double _scale = 1.0; - double _baseScale = 1.0; - Offset _offset = Offset.zero; - Offset _startOffset = Offset.zero; - Offset _startFocalPoint = Offset.zero; - - bool _isLoading = false; - bool _imageLoaded = false; - Size? _imageSize; - late Size _viewportSize; - late double _cropSize; - - @override - void initState() { - super.initState(); - _loadImageInfo(); - } - - Future _loadImageInfo() async { - final file = File(widget.imagePath); - final decodedImage = await decodeImageFromList(await file.readAsBytes()); - if (mounted) { - setState(() { - _imageSize = Size( - decodedImage.width.toDouble(), - decodedImage.height.toDouble(), - ); - _imageLoaded = true; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF101214), - appBar: AppBar( - backgroundColor: const Color(0xFF101214), - elevation: 0, - centerTitle: true, - title: const Text( - 'Validation image', - style: TextStyle(color: Colors.white, fontSize: 18), - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8))) - : Column( - children: [ - // Zone interactive de crop - Expanded( - child: Container( - margin: const EdgeInsets.all(20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white10), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()), - ), - ), - ), - - // Bouton d'aide / Modifier - Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.touch_app, color: Colors.white54, size: 20), - const SizedBox(width: 8), - Text( - 'Déplacez et zoomez pour cadrer', - style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14), - ), - ], - ), - ), - - // Boutons du bas - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 30), - child: Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => Navigator.pop(context), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.white24), - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: const Text('Recommencer', style: TextStyle(color: Colors.white)), - ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton( - onPressed: _onCropConfirm, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A73E8), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: const Text('Valider', style: TextStyle(fontWeight: FontWeight.bold)), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildInteractiveCrop() { - return LayoutBuilder( - builder: (context, constraints) { - _viewportSize = Size(constraints.maxWidth, constraints.maxHeight); - _cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.9; - - if (_scale == 1.0 && _offset == Offset.zero) { - _initializeImagePosition(); - } - - return GestureDetector( - onScaleStart: _onScaleStart, - onScaleUpdate: _onScaleUpdate, - child: Stack( - children: [ - Positioned.fill( - child: Center( - child: Transform( - transform: Matrix4.identity() - ..setTranslationRaw(_offset.dx, _offset.dy, 0) - ..scale(_scale, _scale), - alignment: Alignment.center, - child: Image.file( - File(widget.imagePath), - fit: BoxFit.contain, - width: _viewportSize.width, - height: _viewportSize.height, - ), - ), - ), - ), - Positioned.fill( - child: IgnorePointer( - child: CropOverlay(cropSize: _cropSize, showGrid: true), - ), - ), - ], - ), - ); - }, - ); - } - - void _initializeImagePosition() { - if (_imageSize == null) return; - final imageAspect = _imageSize!.width / _imageSize!.height; - final viewportAspect = _viewportSize.width / _viewportSize.height; - - double displayWidth, displayHeight; - if (imageAspect > viewportAspect) { - displayWidth = _viewportSize.width; - displayHeight = _viewportSize.width / imageAspect; - } else { - displayHeight = _viewportSize.height; - displayWidth = _viewportSize.height * imageAspect; - } - - final minDisplayDim = math.min(displayWidth, displayHeight); - _scale = _cropSize / minDisplayDim; - if (_scale < 1.0) _scale = 1.0; - } - - void _onScaleStart(ScaleStartDetails details) { - _baseScale = _scale; - _startFocalPoint = details.focalPoint; - _startOffset = _offset; - } - - void _onScaleUpdate(ScaleUpdateDetails details) { - setState(() { - _scale = (_baseScale * details.scale).clamp(0.5, 5.0); - final delta = details.focalPoint - _startFocalPoint; - _offset = _startOffset + delta; - }); - } - - Future _onCropConfirm() async { - setState(() => _isLoading = true); - try { - final cropRect = _calculateCropRect(); - final croppedPath = await _cropService.cropToSquare(widget.imagePath, cropRect); - if (!mounted) return; - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => AnalysisScreen( - imagePath: croppedPath, - targetType: widget.targetType, - ), - ), - ); - } catch (e) { - if (mounted) { - setState(() => _isLoading = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor), - ); - } - } - } - - CropRect _calculateCropRect() { - if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1); - final imageAspect = _imageSize!.width / _imageSize!.height; - final viewportAspect = _viewportSize.width / _viewportSize.height; - - double displayWidth, displayHeight; - if (imageAspect > viewportAspect) { - displayWidth = _viewportSize.width; - displayHeight = _viewportSize.width / imageAspect; - } else { - displayHeight = _viewportSize.height; - displayWidth = _viewportSize.height * imageAspect; - } - - final scaledWidth = displayWidth * _scale; - final scaledHeight = displayHeight * _scale; - final imageCenterX = _viewportSize.width / 2 + _offset.dx; - final imageCenterY = _viewportSize.height / 2 + _offset.dy; - final imageLeft = imageCenterX - scaledWidth / 2; - final imageTop = imageCenterY - scaledHeight / 2; - final cropLeft = (_viewportSize.width - _cropSize) / 2; - final cropTop = (_viewportSize.height - _cropSize) / 2; - - final relCropLeft = (cropLeft - imageLeft) / scaledWidth; - final relCropTop = (cropTop - imageTop) / scaledHeight; - final relCropSize = _cropSize / scaledWidth; - final relCropSizeY = _cropSize / scaledHeight; - - return CropRect( - x: relCropLeft.clamp(0.0, 1.0), - y: relCropTop.clamp(0.0, 1.0), - width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)), - height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)), - ); - } -} \ No newline at end of file +import 'dart:io'; +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +import '../../core/theme/app_theme.dart'; +import '../../data/models/target_type.dart'; +import '../../services/image_crop_service.dart'; +import '../analysis/analysis_screen.dart'; +import 'widgets/crop_overlay.dart'; + +class CropScreen extends StatefulWidget { + final String imagePath; + final TargetType targetType; + final double? initialScale; + final Offset? initialOffset; + + const CropScreen({ + super.key, + required this.imagePath, + required this.targetType, + this.initialScale, + this.initialOffset, + }); + + @override + State createState() => _CropScreenState(); +} + +class _CropScreenState extends State { + final ImageCropService _cropService = ImageCropService(); + + // États de transformation + double _scale = 1.0; + double _baseScale = 1.0; + Offset _offset = Offset.zero; + Offset _startOffset = Offset.zero; + Offset _startFocalPoint = Offset.zero; + + bool _isLoading = false; + bool _imageLoaded = false; + Size? _imageSize; + late Size _viewportSize; + late double _cropSize; + + @override + void initState() { + super.initState(); + _loadImageInfo(); + } + + Future _loadImageInfo() async { + final file = File(widget.imagePath); + final decodedImage = await decodeImageFromList(await file.readAsBytes()); + if (mounted) { + setState(() { + _imageSize = Size( + decodedImage.width.toDouble(), + decodedImage.height.toDouble(), + ); + _imageLoaded = true; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF101214), + appBar: AppBar( + backgroundColor: const Color(0xFF101214), + elevation: 0, + centerTitle: true, + title: const Text( + 'Centrage de la cible', + style: TextStyle(color: Colors.white, fontSize: 18), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8))) + : Column( + children: [ + // Zone interactive de crop + Expanded( + child: Container( + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white10), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()), + ), + ), + ), + + // Bouton d'aide / Modifier + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.center_focus_strong, color: Colors.redAccent, size: 20), + const SizedBox(width: 8), + Text( + 'Alignez le centre de la cible sur la croix', + style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Zoomez pour plus de précision', + style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12), + ), + ], + ), + ), + + // Boutons du bas + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 30), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.white24), + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Retour', style: TextStyle(color: Colors.white)), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: _onCropConfirm, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1A73E8), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Analyser', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildInteractiveCrop() { + return LayoutBuilder( + builder: (context, constraints) { + _viewportSize = Size(constraints.maxWidth, constraints.maxHeight); + + // Le carré de crop occupe presque tout l'espace disponible + _cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.95; + + if (_scale == 1.0 && _offset == Offset.zero) { + _initializeImagePosition(); + } + + return GestureDetector( + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + child: Stack( + children: [ + Positioned.fill( + child: Center( + child: Transform( + transform: Matrix4.identity() + ..setTranslationRaw(_offset.dx, _offset.dy, 0) + ..scale(_scale, _scale), + alignment: Alignment.center, + child: Image.file( + File(widget.imagePath), + fit: BoxFit.contain, + width: _viewportSize.width, + height: _viewportSize.height, + ), + ), + ), + ), + Positioned.fill( + child: IgnorePointer( + child: CropOverlay(cropSize: _cropSize, showGrid: false), + ), + ), + ], + ), + ); + }, + ); + } + + void _initializeImagePosition() { + if (_imageSize == null) return; + + final imageAspect = _imageSize!.width / _imageSize!.height; + final viewportAspect = _viewportSize.width / _viewportSize.height; + + double displayWidth, displayHeight; + if (imageAspect > viewportAspect) { + displayWidth = _viewportSize.width; + displayHeight = _viewportSize.width / imageAspect; + } else { + displayHeight = _viewportSize.height; + displayWidth = _viewportSize.height * imageAspect; + } + + final minDisplayDim = math.min(displayWidth, displayHeight); + + _scale = widget.initialScale ?? (_cropSize / minDisplayDim); + if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0; + + if (widget.initialOffset != null) { + _offset = widget.initialOffset!; + } + } + + void _onScaleStart(ScaleStartDetails details) { + _baseScale = _scale; + _startFocalPoint = details.focalPoint; + _startOffset = _offset; + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + setState(() { + _scale = (_baseScale * details.scale).clamp(0.8, 8.0); + final delta = details.focalPoint - _startFocalPoint; + _offset = _startOffset + delta; + }); + } + + Future _onCropConfirm() async { + setState(() => _isLoading = true); + try { + final cropRect = _calculateCropRect(); + // On calcule le centre relatif basé sur le centrage utilisateur + final targetCenterX = cropRect.x + cropRect.width / 2; + final targetCenterY = cropRect.y + cropRect.height / 2; + + if (!mounted) return; + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => AnalysisScreen( + imagePath: widget.imagePath, + targetType: widget.targetType, + targetCenter: Offset(targetCenterX, targetCenterY), + cropScale: _scale, + cropOffset: _offset, + ), + ), + ); + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor), + ); + } + } + } + + CropRect _calculateCropRect() { + if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1); + + final imageAspect = _imageSize!.width / _imageSize!.height; + final viewportAspect = _viewportSize.width / _viewportSize.height; + + double displayWidth, displayHeight; + if (imageAspect > viewportAspect) { + displayWidth = _viewportSize.width; + displayHeight = _viewportSize.width / imageAspect; + } else { + displayHeight = _viewportSize.height; + displayWidth = _viewportSize.height * imageAspect; + } + + final scaledWidth = displayWidth * _scale; + final scaledHeight = displayHeight * _scale; + + final imageCenterX = _viewportSize.width / 2 + _offset.dx; + final imageCenterY = _viewportSize.height / 2 + _offset.dy; + + final imageLeft = imageCenterX - scaledWidth / 2; + final imageTop = imageCenterY - scaledHeight / 2; + + final cropLeft = (_viewportSize.width - _cropSize) / 2; + final cropTop = (_viewportSize.height - _cropSize) / 2; + + final relCropLeft = (cropLeft - imageLeft) / scaledWidth; + final relCropTop = (cropTop - imageTop) / scaledHeight; + final relCropWidth = _cropSize / scaledWidth; + final relCropHeight = _cropSize / scaledHeight; + + return CropRect( + x: relCropLeft, + y: relCropTop, + width: relCropWidth, + height: relCropHeight, + ); + } +} \ No newline at end of file diff --git a/lib/features/garage/weapon_detail_screen.dart b/lib/features/garage/weapon_detail_screen.dart new file mode 100644 index 00000000..0c721100 --- /dev/null +++ b/lib/features/garage/weapon_detail_screen.dart @@ -0,0 +1,414 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_theme.dart'; +import '../../data/models/weapon.dart'; +import '../../data/models/maintenance.dart'; +import '../../data/repositories/session_repository.dart'; + +class WeaponDetailScreen extends StatefulWidget { + final Weapon weapon; + const WeaponDetailScreen({super.key, required this.weapon}); + + @override + State createState() => _WeaponDetailScreenState(); +} + +class _WeaponDetailScreenState extends State { + late Weapon _weapon; + List _maintenance = []; + int _totalRounds = 0; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _weapon = widget.weapon; + _loadData(); + } + + Future _loadData() async { + final repository = context.read(); + final history = await repository.getMaintenanceHistory(_weapon.id); + final rounds = await repository.getRoundsFiredForWeapon(_weapon.id); + if (mounted) { + setState(() { + _maintenance = history; + _totalRounds = rounds; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_weapon.customName ?? _weapon.name), + actions: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _showEditWeaponDialog(context), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildStatsSection(), + const SizedBox(height: 24), + _buildInfoCard(), + const SizedBox(height: 24), + _buildOptionsCard(), + const SizedBox(height: 24), + _buildHistorySection(), + ], + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showAddMaintenanceDialog(context), + label: const Text('Entretien'), + icon: const Icon(Icons.build), + ), + ); + } + + Widget _buildStatsSection() { + return Row( + children: [ + Expanded( + child: _buildStatCard( + 'Total Tirs', + _totalRounds.toString(), + Icons.ads_click, + AppTheme.primaryColor, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildStatCard( + 'Sessions', + 'N/A', + Icons.history, + Colors.orange, + ), + ), + ], + ); + } + + Widget _buildStatCard(String label, String value, IconData icon, Color color) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Icon(icon, color: color), + const SizedBox(height: 8), + Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + ), + ); + } + + Widget _buildInfoCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const Divider(), + _buildInfoRow('Modèle', _weapon.name), + _buildInfoRow('Type', _weapon.type.displayName), + _buildInfoRow('Calibre', _weapon.caliber), + _buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'), + if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[ + const SizedBox(height: 8), + const Text('Notes:', style: TextStyle(fontWeight: FontWeight.w500)), + Text(_weapon.notes!, style: const TextStyle(color: Colors.grey)), + ], + ], + ), + ), + ); + } + + Widget _buildOptionsCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const Divider(), + _buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'), + _buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'), + _buildInfoRow('Détente', _weapon.trigger ?? 'Standard'), + if (_weapon.customName != null) + _buildInfoRow('Surnom', _weapon.customName!), + ], + ), + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(color: Colors.grey)), + Text(value, style: const TextStyle(fontWeight: FontWeight.w500)), + ], + ), + ); + } + + Widget _buildHistorySection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Historique d\'entretien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), + const SizedBox(height: 8), + if (_maintenance.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: Center(child: Text('Aucun entretien enregistré', style: TextStyle(color: Colors.grey))), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _maintenance.length, + itemBuilder: (context, index) { + final entry = _maintenance[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: _getMaintenanceColor(entry.type).withValues(alpha: 0.1), + child: Icon(_getMaintenanceIcon(entry.type), color: _getMaintenanceColor(entry.type), size: 20), + ), + title: Text(entry.type.displayName), + subtitle: Text(entry.description), + trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)), + onLongPress: () => _confirmDeleteMaintenance(context, entry), + ), + ); + }, + ), + ], + ); + } + + Color _getMaintenanceColor(MaintenanceType type) { + switch (type) { + case MaintenanceType.cleaning: return Colors.blue; + case MaintenanceType.repair: return Colors.red; + case MaintenanceType.inspection: return Colors.green; + case MaintenanceType.upgrade: return Colors.purple; + case MaintenanceType.other: return Colors.grey; + } + } + + IconData _getMaintenanceIcon(MaintenanceType type) { + switch (type) { + case MaintenanceType.cleaning: return Icons.opacity; + case MaintenanceType.repair: return Icons.build; + case MaintenanceType.inspection: return Icons.visibility; + case MaintenanceType.upgrade: return Icons.trending_up; + case MaintenanceType.other: return Icons.more_horiz; + } + } + + void _showEditWeaponDialog(BuildContext context) async { + final nameController = TextEditingController(text: _weapon.name); + final caliberController = TextEditingController(text: _weapon.caliber); + final magCountController = TextEditingController(text: _weapon.magazineCount.toString()); + final magCapController = TextEditingController(text: _weapon.magazineCapacity.toString()); + final notesController = TextEditingController(text: _weapon.notes); + final opticController = TextEditingController(text: _weapon.optic); + final silencerController = TextEditingController(text: _weapon.silencer); + final triggerController = TextEditingController(text: _weapon.trigger); + final customNameController = TextEditingController(text: _weapon.customName); + WeaponType selectedType = _weapon.type; + + final result = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Modifier l\'arme'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'), + ), + TextField( + controller: customNameController, + decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'), + ), + DropdownButtonFormField( + value: selectedType, + decoration: const InputDecoration(labelText: 'Type'), + items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), + onChanged: (v) => setState(() => selectedType = v!), + ), + TextField( + controller: caliberController, + decoration: const InputDecoration(labelText: 'Calibre'), + ), + const SizedBox(height: 16), + const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)), + TextField( + controller: opticController, + decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'), + ), + TextField( + controller: silencerController, + decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'), + ), + TextField( + controller: triggerController, + decoration: const InputDecoration(labelText: 'Détente / Trigger'), + ), + const SizedBox(height: 16), + const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)), + Row( + children: [ + Expanded( + child: TextField( + controller: magCountController, + decoration: const InputDecoration(labelText: 'Chargeurs'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: magCapController, + decoration: const InputDecoration(labelText: 'Capacité'), + keyboardType: TextInputType.number, + ), + ), + ], + ), + TextField( + controller: notesController, + decoration: const InputDecoration(labelText: 'Notes'), + maxLines: 2, + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')), + ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Enregistrer')), + ], + ), + ), + ); + + if (result == true) { + final updatedWeapon = _weapon.copyWith( + name: nameController.text, + type: selectedType, + caliber: caliberController.text, + magazineCount: int.tryParse(magCountController.text) ?? 1, + magazineCapacity: int.tryParse(magCapController.text) ?? 10, + notes: notesController.text, + optic: opticController.text.isEmpty ? null : opticController.text, + silencer: silencerController.text.isEmpty ? null : silencerController.text, + trigger: triggerController.text.isEmpty ? null : triggerController.text, + customName: customNameController.text.isEmpty ? null : customNameController.text, + ); + + final repository = context.read(); + await repository.updateWeapon(updatedWeapon); + setState(() { + _weapon = updatedWeapon; + }); + } + } + + void _showAddMaintenanceDialog(BuildContext context) async { + final descController = TextEditingController(); + MaintenanceType selectedType = MaintenanceType.cleaning; + + final result = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Ajouter un entretien'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + value: selectedType, + decoration: const InputDecoration(labelText: 'Type d\'intervention'), + items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), + onChanged: (v) => setState(() => selectedType = v!), + ), + TextField( + controller: descController, + decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'), + maxLines: 2, + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')), + ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')), + ], + ), + ), + ); + + if (result == true && descController.text.isNotEmpty) { + final repository = context.read(); + await repository.addMaintenanceEntry( + weaponId: _weapon.id, + type: selectedType, + description: descController.text, + roundsSinceLast: _totalRounds, + ); + _loadData(); + } + } + + void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer'), + content: const Text('Supprimer cette entrée de l\'historique ?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)), + ), + ], + ), + ); + + if (confirmed == true) { + final repository = context.read(); + await repository.deleteMaintenanceEntry(entry.id); + _loadData(); + } + } +} diff --git a/lib/features/garage/weapon_list_screen.dart b/lib/features/garage/weapon_list_screen.dart new file mode 100644 index 00000000..2cc39264 --- /dev/null +++ b/lib/features/garage/weapon_list_screen.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_theme.dart'; +import '../../data/models/weapon.dart'; +import '../../data/repositories/session_repository.dart'; +import 'weapon_detail_screen.dart'; + +class WeaponListScreen extends StatefulWidget { + const WeaponListScreen({super.key}); + + @override + State createState() => _WeaponListScreenState(); +} + +class _WeaponListScreenState extends State { + List _weapons = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadWeapons(); + } + + Future _loadWeapons() async { + final repository = context.read(); + final weapons = await repository.getWeapons(); + if (mounted) { + setState(() { + _weapons = weapons; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Mon Armurerie'), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _weapons.isEmpty + ? _buildEmptyState() + : _buildWeaponList(), + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddWeaponDialog(context), + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.shield, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + const Text('Aucune arme enregistrée'), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => _showAddWeaponDialog(context), + child: const Text('Ajouter ma première arme'), + ), + ], + ), + ); + } + + Widget _buildWeaponList() { + return ListView.builder( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + itemCount: _weapons.length, + itemBuilder: (context, index) { + final weapon = _weapons[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1), + child: Icon( + weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click, + color: AppTheme.primaryColor, + ), + ), + title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('${weapon.type.displayName} • ${weapon.caliber}'), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)), + Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)), + ); + _loadWeapons(); // Reload in case it was edited or maintenance was added + }, + onLongPress: () => _confirmDelete(context, weapon), + ), + ); + }, + ); + } + + void _showAddWeaponDialog(BuildContext context) async { + final nameController = TextEditingController(); + final caliberController = TextEditingController(); + final magCountController = TextEditingController(text: '2'); + final magCapController = TextEditingController(text: '15'); + WeaponType selectedType = WeaponType.handgun; + + final result = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Ajouter une arme'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'), + ), + DropdownButtonFormField( + value: selectedType, + decoration: const InputDecoration(labelText: 'Type'), + items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), + onChanged: (v) => setState(() => selectedType = v!), + ), + TextField( + controller: caliberController, + decoration: const InputDecoration(labelText: 'Calibre', hintText: 'ex: 9mm, .22LR'), + ), + Row( + children: [ + Expanded( + child: TextField( + controller: magCountController, + decoration: const InputDecoration(labelText: 'Chargeurs'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: magCapController, + decoration: const InputDecoration(labelText: 'Capacité'), + keyboardType: TextInputType.number, + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')), + ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')), + ], + ), + ), + ); + + if (result == true && nameController.text.isNotEmpty) { + final repository = context.read(); + await repository.addWeapon( + name: nameController.text, + type: selectedType, + caliber: caliberController.text, + magazineCount: int.tryParse(magCountController.text) ?? 1, + magazineCapacity: int.tryParse(magCapController.text) ?? 10, + ); + _loadWeapons(); + } + } + + void _confirmDelete(BuildContext context, Weapon weapon) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer'), + content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)), + ), + ], + ), + ); + + if (confirmed == true) { + final repository = context.read(); + await repository.deleteWeapon(weapon.id); + _loadWeapons(); + } + } +} diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart index 4418d291..5ad0f4d2 100644 --- a/lib/features/history/history_screen.dart +++ b/lib/features/history/history_screen.dart @@ -37,7 +37,7 @@ class _HistoryScreenState extends State { try { final repository = context.read(); - final sessions = await repository.getAllSessions(targetType: _filterType); + final sessions = await repository.getAllSessions(); if (mounted) { setState(() { @@ -95,12 +95,17 @@ class _HistoryScreenState extends State { locale: const Locale('fr', 'FR'), builder: (context, child) { return Theme( - data: Theme.of(context).copyWith( - colorScheme: ColorScheme.dark( - primary: AppTheme.primaryColor, - onPrimary: Colors.white, - surface: Theme.of(context).cardColor, - onSurface: Colors.white, + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: AppTheme.primaryColor, // En-tête et sélection + onPrimary: Colors.white, // Texte sur en-tête/sélection + surface: Colors.white, // Fond du calendrier + onSurface: Colors.black87, // Texte des dates (Noir sur Blanc) + secondary: AppTheme.primaryColor, + ), + dialogBackgroundColor: Colors.white, + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor), ), ), child: child!, diff --git a/lib/features/history/session_detail_screen.dart b/lib/features/history/session_detail_screen.dart index 9e8fb6be..74b0e365 100644 --- a/lib/features/history/session_detail_screen.dart +++ b/lib/features/history/session_detail_screen.dart @@ -1,10 +1,3 @@ -/// Écran de détail d'une session. -/// -/// Affiche la visualisation complète d'une session sauvegardée : -/// image de la cible avec overlay des impacts, scores recalculés, -/// statistiques de groupement et lien vers les statistiques détaillées. -library; - import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -12,6 +5,8 @@ import 'package:intl/intl.dart'; import '../../core/constants/app_constants.dart'; import '../../core/theme/app_theme.dart'; import '../../data/models/session.dart'; +import '../../data/models/target_analysis.dart'; +import '../../data/models/target_type.dart'; import '../../data/repositories/session_repository.dart'; import '../../services/score_calculator_service.dart'; import '../../services/grouping_analyzer_service.dart'; @@ -20,7 +15,7 @@ import '../analysis/widgets/score_card.dart'; import '../analysis/widgets/grouping_stats.dart'; import '../statistics/statistics_screen.dart'; -class SessionDetailScreen extends StatelessWidget { +class SessionDetailScreen extends StatefulWidget { final Session session; const SessionDetailScreen({ @@ -28,33 +23,40 @@ class SessionDetailScreen extends StatelessWidget { required this.session, }); + @override + State createState() => _SessionDetailScreenState(); +} + +class _SessionDetailScreenState extends State { + int _currentTargetIndex = 0; + @override Widget build(BuildContext context) { final scoreCalculator = context.read(); final groupingAnalyzer = context.read(); + + final currentAnalysis = widget.session.analyses[_currentTargetIndex]; final scoreResult = scoreCalculator.calculateScores( - shots: session.shots, - targetType: session.targetType, - targetCenterX: session.targetCenterX ?? 0.5, - targetCenterY: session.targetCenterY ?? 0.5, - targetRadius: session.targetRadius ?? 0.4, + shots: currentAnalysis.shots, + targetType: currentAnalysis.targetType, + targetCenterX: currentAnalysis.targetCenterX ?? 0.5, + targetCenterY: currentAnalysis.targetCenterY ?? 0.5, + targetRadius: currentAnalysis.targetRadius ?? 0.4, ); - final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots); + final groupingResult = groupingAnalyzer.analyzeGrouping(currentAnalysis.shots); return Scaffold( appBar: AppBar( - title: Text( - DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt), - ), + title: Text(widget.session.weapon), actions: [ IconButton( icon: const Icon(Icons.analytics), onPressed: () => Navigator.push( context, MaterialPageRoute( - builder: (_) => StatisticsScreen(singleSession: session), + builder: (_) => StatisticsScreen(singleSession: widget.session), ), ), tooltip: 'Statistiques', @@ -70,15 +72,22 @@ class SessionDetailScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Weapon and session overview + _buildSessionHeader(context), + + // Target selector if multiple targets + if (widget.session.targetCount > 1) + _buildTargetSelector(), + // Target image with overlay AspectRatio( aspectRatio: 1, child: Stack( fit: StackFit.expand, children: [ - if (File(session.imagePath).existsSync()) + if (File(currentAnalysis.imagePath).existsSync()) Image.file( - File(session.imagePath), + File(currentAnalysis.imagePath), fit: BoxFit.contain, ) else @@ -89,14 +98,14 @@ class SessionDetailScreen extends StatelessWidget { ), ), TargetOverlay( - shots: session.shots, - targetCenterX: session.targetCenterX ?? 0.5, - targetCenterY: session.targetCenterY ?? 0.5, - targetRadius: session.targetRadius ?? 0.4, - targetType: session.targetType, - groupingCenterX: session.groupingCenterX, - groupingCenterY: session.groupingCenterY, - groupingDiameter: session.groupingDiameter, + shots: currentAnalysis.shots, + targetCenterX: currentAnalysis.targetCenterX ?? 0.5, + targetCenterY: currentAnalysis.targetCenterY ?? 0.5, + targetRadius: currentAnalysis.targetRadius ?? 0.4, + targetType: currentAnalysis.targetType, + groupingCenterX: currentAnalysis.groupingCenterX, + groupingCenterY: currentAnalysis.groupingCenterY, + groupingDiameter: currentAnalysis.groupingDiameter, ), ], ), @@ -107,31 +116,37 @@ class SessionDetailScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Session info - _buildSessionInfo(context), + // Analysis Info + _buildAnalysisInfo(context, currentAnalysis), const SizedBox(height: 12), - // Score card + // Score card for current target ScoreCard( - totalScore: session.totalScore, - shotCount: session.shotCount, + totalScore: currentAnalysis.totalScore, + shotCount: currentAnalysis.shotCount, scoreResult: scoreResult, - targetType: session.targetType, + targetType: currentAnalysis.targetType, ), const SizedBox(height: 12), - // Grouping stats - if (session.shotCount > 1) + // Grouping stats for current target + if (currentAnalysis.shotCount > 1) GroupingStats( groupingResult: groupingResult, - targetCenterX: session.targetCenterX ?? 0.5, - targetCenterY: session.targetCenterY ?? 0.5, + targetCenterX: currentAnalysis.targetCenterX ?? 0.5, + targetCenterY: currentAnalysis.targetCenterY ?? 0.5, ), - // Notes - if (session.notes != null && session.notes!.isNotEmpty) ...[ + // Notes for current target + if (currentAnalysis.notes != null && currentAnalysis.notes!.isNotEmpty) ...[ const SizedBox(height: 12), - _buildNotesCard(context), + _buildNotesCard(context, currentAnalysis.notes!), + ], + + // Session notes + if (widget.session.notes != null && widget.session.notes!.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildNotesCard(context, widget.session.notes!, title: 'Notes de session'), ], ], ), @@ -142,16 +157,83 @@ class SessionDetailScreen extends StatelessWidget { ); } - Widget _buildSessionInfo(BuildContext context) { + Widget _buildSessionHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + color: AppTheme.primaryColor.withValues(alpha: 0.1), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('EEEE dd MMMM yyyy', 'fr_FR').format(widget.session.createdAt), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + DateFormat('HH:mm').format(widget.session.createdAt), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Score Total: ${widget.session.totalScore}', + style: const TextStyle(fontWeight: FontWeight.bold, color: AppTheme.primaryColor), + ), + Text( + '${widget.session.totalShots} tirs au total', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ), + ], + ), + ); + } + + Widget _buildTargetSelector() { + return Container( + height: 50, + margin: const EdgeInsets.symmetric(vertical: 8), + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: widget.session.targetCount, + itemBuilder: (context, index) { + final isSelected = _currentTargetIndex == index; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text('Cible ${index + 1}'), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() => _currentTargetIndex = index); + } + }, + ), + ); + }, + ), + ); + } + + Widget _buildAnalysisInfo(BuildContext context, TargetAnalysis analysis) { return Card( child: Padding( - padding: const EdgeInsets.all(AppConstants.defaultPadding), + padding: const EdgeInsets.all(12), child: Row( children: [ Icon( - session.targetType == session.targetType - ? Icons.track_changes - : Icons.person, + analysis.targetType == TargetType.concentric ? Icons.track_changes : Icons.person, color: AppTheme.primaryColor, ), const SizedBox(width: 12), @@ -160,14 +242,11 @@ class SessionDetailScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - session.targetType.displayName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + analysis.targetType.displayName, + style: const TextStyle(fontWeight: FontWeight.bold), ), Text( - DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR') - .format(session.createdAt), + 'Cible ${_currentTargetIndex + 1} sur ${widget.session.targetCount}', style: Theme.of(context).textTheme.bodySmall, ), ], @@ -179,7 +258,7 @@ class SessionDetailScreen extends StatelessWidget { ); } - Widget _buildNotesCard(BuildContext context) { + Widget _buildNotesCard(BuildContext context, String notes, {String title = 'Notes'}) { return Card( child: Padding( padding: const EdgeInsets.all(AppConstants.defaultPadding), @@ -188,18 +267,16 @@ class SessionDetailScreen extends StatelessWidget { children: [ Row( children: [ - const Icon(Icons.notes, color: AppTheme.primaryColor), + const Icon(Icons.notes, color: AppTheme.primaryColor, size: 20), const SizedBox(width: 8), Text( - 'Notes', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + title, + style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), const Divider(), - Text(session.notes!), + Text(notes), ], ), ), @@ -211,7 +288,7 @@ class SessionDetailScreen extends StatelessWidget { context: context, builder: (context) => AlertDialog( title: const Text('Supprimer'), - content: const Text('Voulez-vous vraiment supprimer cette session?'), + content: const Text('Voulez-vous vraiment supprimer cette session entière ?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -231,20 +308,17 @@ class SessionDetailScreen extends StatelessWidget { if (confirmed == true && context.mounted) { try { final repository = context.read(); - await repository.deleteSession(session.id); + await repository.deleteSession(widget.session.id); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Session supprimee')), + const SnackBar(content: Text('Session supprimée')), ); Navigator.pop(context); } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Erreur: $e'), - backgroundColor: AppTheme.errorColor, - ), + SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor), ); } } diff --git a/lib/features/history/widgets/history_chart.dart b/lib/features/history/widgets/history_chart.dart index 9c6b182e..c4730fe8 100644 --- a/lib/features/history/widgets/history_chart.dart +++ b/lib/features/history/widgets/history_chart.dart @@ -155,7 +155,7 @@ class HistoryChart extends StatelessWidget { return touchedSpots.map((spot) { final session = displaySessions[spot.x.toInt()]; return LineTooltipItem( - 'Score: ${session.totalScore}\n${session.shotCount} tirs', + 'Score: ${session.totalScore}\n${session.totalShots} tirs', const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, diff --git a/lib/features/history/widgets/session_list_item.dart b/lib/features/history/widgets/session_list_item.dart index 4d543bd4..67835c9b 100644 --- a/lib/features/history/widgets/session_list_item.dart +++ b/lib/features/history/widgets/session_list_item.dart @@ -1,15 +1,8 @@ -/// Item de liste représentant une session. -/// -/// Affiche les informations clés d'une session (date, type de cible, -/// score, nombre de tirs) avec miniature de l'image et actions de suppression. -library; - import 'dart:io'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../core/theme/app_theme.dart'; import '../../../data/models/session.dart'; -import '../../../data/models/target_type.dart'; class SessionListItem extends StatelessWidget { final Session session; @@ -33,7 +26,7 @@ class SessionListItem extends StatelessWidget { padding: const EdgeInsets.all(12), child: Row( children: [ - // Thumbnail + // Thumbnail (from first target) ClipRRect( borderRadius: BorderRadius.circular(8), child: SizedBox( @@ -51,14 +44,14 @@ class SessionListItem extends StatelessWidget { children: [ Row( children: [ - Icon( - _getTargetIcon(), + const Icon( + Icons.shield, size: 16, color: AppTheme.primaryColor, ), const SizedBox(width: 4), Text( - session.targetType.displayName, + session.weapon, style: const TextStyle( fontWeight: FontWeight.bold, ), @@ -70,18 +63,19 @@ class SessionListItem extends StatelessWidget { DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt), style: Theme.of(context).textTheme.bodySmall, ), - if (session.notes != null && session.notes!.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - session.notes!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - fontStyle: FontStyle.italic, + const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.track_changes, size: 14, color: Colors.grey[600]), + const SizedBox(width: 4), + Text( + '${session.targetCount} cible(s) • ${session.distance}m', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ], ), ), @@ -98,7 +92,7 @@ class SessionListItem extends StatelessWidget { ), ), Text( - '${session.shotCount} tirs', + '${session.totalShots} tirs', style: Theme.of(context).textTheme.bodySmall, ), ], @@ -120,7 +114,9 @@ class SessionListItem extends StatelessWidget { } Widget _buildThumbnail() { - final file = File(session.imagePath); + if (session.analyses.isEmpty) return _buildPlaceholder(); + + final file = File(session.analyses.first.imagePath); if (file.existsSync()) { return Image.file( @@ -137,18 +133,9 @@ class SessionListItem extends StatelessWidget { return Container( color: Colors.grey[200], child: Icon( - _getTargetIcon(), + Icons.track_changes, color: Colors.grey[400], ), ); } - - IconData _getTargetIcon() { - switch (session.targetType) { - case TargetType.concentric: - return Icons.track_changes; - case TargetType.silhouette: - return Icons.person; - } - } } diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 17479da4..ad242155 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -9,6 +9,8 @@ import '../statistics/statistics_screen.dart'; import 'package:fl_chart/fl_chart.dart'; import '../../data/models/session.dart'; import '../settings/settings_screen.dart'; +import '../session/session_setup_screen.dart'; +import '../session/session_provider.dart'; import 'widgets/stats_card.dart'; class HomeScreen extends StatefulWidget { @@ -47,7 +49,6 @@ class _HomeScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - // --- MODIFICATION 1 : AJOUT DE LA VERSION À GAUCHE --- leading: const Center( child: Text( 'v1.0.2', @@ -56,16 +57,6 @@ class _HomeScreenState extends State { ), title: const Text('Bully'), actions: [ - IconButton( - icon: const Icon(Icons.analytics), - onPressed: () => _navigateToStatistics(context), - tooltip: 'Statistiques', - ), - IconButton( - icon: const Icon(Icons.history), - onPressed: () => _navigateToHistory(context), - tooltip: 'Historique', - ), IconButton( icon: const Icon(Icons.settings), onPressed: () => _navigateToSettings(context), @@ -81,15 +72,10 @@ class _HomeScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // App logo/header _buildHeader(), const SizedBox(height: AppConstants.largePadding), - - // Main action button _buildMainActionButton(context), const SizedBox(height: AppConstants.largePadding), - - // Statistics section if (_isLoading) const Center(child: CircularProgressIndicator()) else if (_stats != null) @@ -107,7 +93,7 @@ class _HomeScreenState extends State { Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.1), + color: AppTheme.primaryColor.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: const Icon( @@ -136,20 +122,23 @@ class _HomeScreenState extends State { } Widget _buildMainActionButton(BuildContext context) { + final sessionProvider = context.watch(); + final isSessionActive = sessionProvider.isSessionActive; + return ElevatedButton.icon( - onPressed: () => _navigateToCapture(context), + onPressed: () => _navigateToSessionSetup(context), style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, + backgroundColor: isSessionActive ? AppTheme.secondaryColor : AppTheme.primaryColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 20), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppConstants.borderRadius), ), ), - icon: const Icon(Icons.add_a_photo, size: 28), - label: const Text( - 'Nouvelle Analyse', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + icon: Icon(isSessionActive ? Icons.play_circle_outline : Icons.add_circle_outline, size: 28), + label: Text( + isSessionActive ? 'Continuer la session' : 'Démarrer la session', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ); } @@ -165,8 +154,6 @@ class _HomeScreenState extends State { ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), - - // Première ligne de vignettes (Sessions et Tirs) Row( children: [ Expanded( @@ -192,19 +179,17 @@ class _HomeScreenState extends State { ), ], ), - - // --- MODIFICATION 2 : GRAPHIQUE RÉEL (Restauré) --- Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: Container( height: 150, padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, + color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(AppConstants.borderRadius), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 4), ), @@ -226,8 +211,6 @@ class _HomeScreenState extends State { : _buildHomeTrendChart(), ), ), - - // Deuxième ligne de vignettes (Historique et Meilleur) Row( children: [ Expanded( @@ -257,13 +240,19 @@ class _HomeScreenState extends State { ); } - // --- MÉTHODES DE NAVIGATION --- - - void _navigateToCapture(BuildContext context) async { - await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const CaptureScreen()), - ); + void _navigateToSessionSetup(BuildContext context) async { + final sessionProvider = context.read(); + if (sessionProvider.isSessionActive) { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const CaptureScreen()), + ); + } else { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SessionSetupScreen()), + ); + } _loadStats(); } @@ -283,10 +272,7 @@ class _HomeScreenState extends State { _loadStats(); } - // --- WIDGET DU GRAPHIQUE D'ACCUEIL --- - Widget _buildHomeTrendChart() { - // Trier par date et prendre les 7 dernières final sorted = List.from(_recentSessions) ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); final display = @@ -294,22 +280,84 @@ class _HomeScreenState extends State { return LineChart( LineChartData( - gridData: const FlGridData(show: false), - titlesData: const FlTitlesData(show: false), - borderData: FlBorderData(show: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (value) => FlLine( + color: Colors.grey.withValues(alpha: 0.1), + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 22, + interval: 1, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= display.length) return const SizedBox(); + final date = display[index].createdAt; + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + '${date.day}/${date.month}', + style: TextStyle( + fontSize: 9, + color: Colors.grey.withValues(alpha: 0.8), + ), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (value, meta) { + return Text( + value.toInt().toString(), + style: TextStyle( + fontSize: 9, + color: Colors.grey.withValues(alpha: 0.8), + ), + ); + }, + ), + ), + ), + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide(color: Colors.grey.withValues(alpha: 0.2)), + left: BorderSide(color: Colors.grey.withValues(alpha: 0.2)), + ), + ), lineBarsData: [ LineChartBarData( spots: display.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), e.value.totalScore.toDouble()); + return FlSpot(e.key.toDouble(), e.value.averageScore); }).toList(), isCurved: true, color: AppTheme.primaryColor, - barWidth: 4, + barWidth: 3, isStrokeCapRound: true, - dotData: const FlDotData(show: false), + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter( + radius: 3, + color: Colors.white, + strokeWidth: 2, + strokeColor: AppTheme.primaryColor, + ), + ), belowBarData: BarAreaData( show: true, - color: AppTheme.primaryColor.withOpacity(0.1), + color: AppTheme.primaryColor.withValues(alpha: 0.1), ), ), ], @@ -317,10 +365,11 @@ class _HomeScreenState extends State { ); } - void _navigateToSettings(BuildContext context) { - Navigator.push( + void _navigateToSettings(BuildContext context) async { + await Navigator.push( context, MaterialPageRoute(builder: (_) => const SettingsScreen()), ); + _loadStats(); } } diff --git a/lib/features/session/session_provider.dart b/lib/features/session/session_provider.dart new file mode 100644 index 00000000..d82ca021 --- /dev/null +++ b/lib/features/session/session_provider.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; +import '../../data/models/session.dart'; +import '../../data/models/target_analysis.dart'; +import '../../data/models/weapon.dart'; + +class SessionProvider extends ChangeNotifier { + String? _currentWeaponName; + String? _currentWeaponId; + int _shotsPerTarget = 5; + int _distance = 25; + String? _activeSessionId; + final List _currentAnalyses = []; + bool _isSessionActive = false; + + String? get currentWeapon => _currentWeaponName; + String? get currentWeaponId => _currentWeaponId; + int get shotsPerTarget => _shotsPerTarget; + int get distance => _distance; + bool get isSessionActive => _isSessionActive; + String? get activeSessionId => _activeSessionId; + List get currentAnalyses => List.unmodifiable(_currentAnalyses); + + int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore); + int get targetCount => _currentAnalyses.length; + + void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25}) { + _currentWeaponName = weaponName; + _currentWeaponId = weaponId; + _shotsPerTarget = shots; + _distance = distance; + _activeSessionId = sessionId; + _currentAnalyses.clear(); + _isSessionActive = true; + notifyListeners(); + } + + void addAnalysis(TargetAnalysis analysis) { + _currentAnalyses.add(analysis); + notifyListeners(); + } + + void endSession() { + _isSessionActive = false; + _activeSessionId = null; + _currentWeaponId = null; + _currentWeaponName = null; + _currentAnalyses.clear(); + _distance = 25; + notifyListeners(); + } +} diff --git a/lib/features/session/session_setup_screen.dart b/lib/features/session/session_setup_screen.dart new file mode 100644 index 00000000..ffd7e498 --- /dev/null +++ b/lib/features/session/session_setup_screen.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_theme.dart'; +import '../../data/models/weapon.dart'; +import '../../data/repositories/session_repository.dart'; +import 'session_provider.dart'; +import '../capture/capture_screen.dart'; + +class SessionSetupScreen extends StatefulWidget { + const SessionSetupScreen({super.key}); + + @override + State createState() => _SessionSetupScreenState(); +} + +class _SessionSetupScreenState extends State { + final _formKey = GlobalKey(); + final _weaponController = TextEditingController(); + int _shotsPerTarget = 5; + int _distance = 25; + List _availableWeapons = []; + Weapon? _selectedWeapon; + bool _isLoadingWeapons = true; + + @override + void initState() { + super.initState(); + _loadWeapons(); + } + + Future _loadWeapons() async { + final repository = context.read(); + final weapons = await repository.getWeapons(); + if (mounted) { + setState(() { + _availableWeapons = weapons; + _isLoadingWeapons = false; + }); + } + } + + @override + void dispose() { + _weaponController.dispose(); + super.dispose(); + } + + void _startSession() { + if (_formKey.currentState!.validate()) { + final repository = context.read(); + final sessionProvider = context.read(); + + final sessionId = repository.generateId(); + sessionProvider.startSession( + _selectedWeapon != null ? _selectedWeapon!.displayName : _weaponController.text, + _shotsPerTarget, + sessionId, + weaponId: _selectedWeapon?.id, + distance: _distance, + ); + + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const CaptureScreen()), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Configuration de la session'), + ), + body: _isLoadingWeapons + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Choix de l\'arme', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + if (_availableWeapons.isNotEmpty) ...[ + DropdownButtonFormField( + value: _selectedWeapon, + decoration: InputDecoration( + labelText: 'Sélectionner une arme de l\'armurerie', + prefixIcon: const Icon(Icons.shield), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Nouvelle arme / Autre'), + ), + ..._availableWeapons.map((w) => DropdownMenuItem( + value: w, + child: Text(w.displayName), + )), + ], + onChanged: (value) { + setState(() { + _selectedWeapon = value; + if (value != null) { + _weaponController.text = value.displayName; + _shotsPerTarget = value.magazineCapacity; + // Auto-set distance based on weapon type if needed + _distance = (value.type == WeaponType.handgun) ? 25 : 50; + } else { + _weaponController.clear(); + } + }); + }, + ), + const SizedBox(height: 16), + const Center(child: Text('OU', style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold))), + const SizedBox(height: 16), + ], + + // Weapon field (manual entry if no weapon selected) + TextFormField( + controller: _weaponController, + enabled: _selectedWeapon == null, + decoration: InputDecoration( + labelText: 'Nom de l\'arme', + hintText: 'ex: Glock 17, CZ Shadow 2...', + prefixIcon: const Icon(Icons.edit), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez renseigner votre arme'; + } + return null; + }, + ), + const SizedBox(height: 24), + + // Distance selector + const Text( + 'Distance de tir (mètres)', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Slider( + value: _distance.toDouble(), + min: 5, + max: 300, + divisions: 59, // (300-5)/5 = 59 divisions for 5m steps + label: '${_distance}m', + onChanged: (value) { + setState(() { + _distance = value.round(); + }); + }, + ), + ), + Container( + width: 70, + height: 50, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppTheme.secondaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${_distance}m', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.secondaryColor, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Shots per target + const Text( + 'Nombre de balles par cible', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Slider( + value: _shotsPerTarget.toDouble(), + min: 1, + max: 30, + divisions: 29, + label: '$_shotsPerTarget', + onChanged: (value) { + setState(() { + _shotsPerTarget = value.round(); + }); + }, + ), + ), + Container( + width: 50, + height: 50, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$_shotsPerTarget', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + ), + ], + ), + const SizedBox(height: 48), + + ElevatedButton.icon( + onPressed: _startSession, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + ), + icon: const Icon(Icons.play_arrow), + label: const Text( + 'DÉMARRER LA SESSION', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 73e47c29..c36b2e37 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:flutter/services.dart'; +import '../../core/theme/theme_provider.dart'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import '../../core/constants/app_constants.dart'; import '../../core/theme/app_theme.dart'; import '../../services/wallet_identity_service.dart'; +import '../garage/weapon_list_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -81,6 +84,49 @@ class _SettingsScreenState extends State { } } + void _showThemeDialog() { + final themeProvider = context.read(); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Apparence'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildThemeOption(ThemeMode.system, 'Automatique', Icons.brightness_auto), + _buildThemeOption(ThemeMode.light, 'Clair', Icons.light_mode), + _buildThemeOption(ThemeMode.dark, 'Sombre', Icons.dark_mode), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Fermer'), + ), + ], + ), + ); + } + + Widget _buildThemeOption(ThemeMode mode, String label, IconData icon) { + final themeProvider = context.watch(); + final isSelected = themeProvider.themeMode == mode; + + return ListTile( + leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null), + title: Text(label, style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? AppTheme.primaryColor : null, + )), + trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null, + onTap: () { + themeProvider.setThemeMode(mode); + Navigator.pop(context); + }, + ); + } + void _showIdentityDialog() { if (_identityPhrase == null) return; @@ -218,14 +264,29 @@ class _SettingsScreenState extends State { subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...', onTap: _showIdentityDialog, ), + Consumer( + builder: (context, themeProvider, child) { + return _buildSettingsTile( + context: context, + icon: Icons.color_lens_outlined, + title: 'Apparence', + subtitle: themeProvider.themeModeName, + onTap: _showThemeDialog, + ); + }, + ), + + const SizedBox(height: 24), + _buildSectionHeader('Gestion'), _buildSettingsTile( context: context, - icon: Icons.color_lens_outlined, - title: 'Apparence', - subtitle: 'Thème clair/sombre (à venir)', - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Fonctionnalité en développement')), + icon: Icons.shield_outlined, + title: 'Mon Armurerie', + subtitle: 'Gérer mes armes et équipements', + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const WeaponListScreen()), ); }, ), diff --git a/lib/features/statistics/statistics_screen.dart b/lib/features/statistics/statistics_screen.dart index 5971a5b4..43b32640 100644 --- a/lib/features/statistics/statistics_screen.dart +++ b/lib/features/statistics/statistics_screen.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes @@ -24,6 +26,8 @@ class _StatisticsScreenState extends State { // Valeurs pour les Dropdowns (Filtres) String _selectedWeapon = 'Toutes'; String _selectedDistance = 'Toutes'; + List _availableWeapons = ['Toutes']; + List _availableDistances = ['Toutes']; @override void initState() { @@ -37,6 +41,35 @@ class _StatisticsScreenState extends State { try { final repository = context.read(); _allSessions = await repository.getAllSessions(); + + // Extraire les armes et distances uniques pour les filtres + final weaponsFromSessions = _allSessions.map((s) => s.weapon).toSet(); + + // Récupérer aussi les armes de l'armurerie pour s'assurer qu'elles sont présentes + final armoryWeapons = await repository.getWeapons(); + final weaponsFromArmory = armoryWeapons.map((w) => w.displayName).toSet(); + + // Combiner et trier + final allWeaponsList = {...weaponsFromSessions, ...weaponsFromArmory}.toList()..sort(); + + final distances = _allSessions.map((s) => '${s.distance}m').toSet().toList() + ..sort((a, b) { + final distA = int.tryParse(a.replaceAll('m', '')) ?? 0; + final distB = int.tryParse(b.replaceAll('m', '')) ?? 0; + return distA.compareTo(distB); + }); + + _availableWeapons = ['Toutes', ...allWeaponsList]; + _availableDistances = ['Toutes', ...distances]; + + // Sécurité : si l'arme sélectionnée n'est plus disponible, on reset + if (!_availableWeapons.contains(_selectedWeapon)) { + _selectedWeapon = 'Toutes'; + } + if (!_availableDistances.contains(_selectedDistance)) { + _selectedDistance = 'Toutes'; + } + _calculateStats(); } catch (e) { debugPrint('Error: $e'); @@ -46,8 +79,15 @@ class _StatisticsScreenState extends State { } void _calculateStats() { + // Filtrer les sessions avant calcul + final filteredSessions = _allSessions.where((s) { + final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon; + final distanceMatch = _selectedDistance == 'Toutes' || '${s.distance}m' == _selectedDistance; + return weaponMatch && distanceMatch; + }).toList(); + _statistics = _statisticsService.calculateStatistics( - _allSessions, + filteredSessions, period: _selectedPeriod, ); } @@ -68,46 +108,57 @@ class _StatisticsScreenState extends State { final sortedSessions = List.from(_statistics!.sessions) ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); return sortedSessions.map((s) { - if (s.shots.isEmpty) return 0.0; - // Simple precision proxy: score / max possible (assuming 10 is max) - return (s.totalScore / (s.shots.length * 10)) * 100; + if (s.totalShots == 0) return 0.0; + // Simple precision proxy: total score / max possible (assuming 10 is max per shot) + return (s.totalScore / (s.totalShots * 10)) * 100; }).toList(); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color( - 0xFF121212, - ), // Fond sombre comme sur le design appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - title: const Text( - 'Statistiques', - style: TextStyle(color: Colors.white), - ), + title: const Text('Statistiques'), centerTitle: true, ), body: _isLoading ? const Center(child: CircularProgressIndicator()) - : SingleChildScrollView( - padding: const EdgeInsets.all(16), + : RefreshIndicator( + onRefresh: _loadStatistics, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), child: Column( children: [ // 1. FILTRES (Arme et Distance) Row( children: [ Expanded( - child: _buildDropdown('Arme utilisée', _selectedWeapon), + child: _buildDropdown( + 'Arme utilisée', + _selectedWeapon, + _availableWeapons, + (val) { + setState(() { + _selectedWeapon = val!; + _calculateStats(); + }); + }, + ), ), const SizedBox(width: 12), Expanded( - child: _buildDropdown('Distance', _selectedDistance), + child: _buildDropdown( + 'Distance', + _selectedDistance, + _availableDistances, + (val) { + setState(() { + _selectedDistance = val!; + _calculateStats(); + }); + }, + ), ), ], ), @@ -162,71 +213,55 @@ class _StatisticsScreenState extends State { const SizedBox(height: 20), _buildQuadrantGrid(), const SizedBox(height: 20), + _buildHeatMapSection(), + const SizedBox(height: 20), if (_statistics!.regional.biasX.abs() > 0.05 || _statistics!.regional.biasY.abs() > 0.05) _buildBiasWarning(), ], const SizedBox(height: 30), - - // 4. BOUTON ACCUEIL - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A73E8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: () => Navigator.of( - context, - ).popUntil((route) => route.isFirst), - child: const Text( - 'ACCUEIL', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - ), ], ), ), + ), ); } - // Widget pour les Dropdowns de filtres - Widget _buildDropdown(String label, String value) { + Widget _buildDropdown( + String label, + String value, + List items, + void Function(String?) onChanged, + ) { + final theme = Theme.of(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( - color: const Color(0xFF1E1E1E), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white12), + border: Border.all(color: theme.dividerColor), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, - style: const TextStyle(color: Colors.white54, fontSize: 10), + style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 10), ), DropdownButton( value: value, isExpanded: true, underline: Container(), - dropdownColor: const Color(0xFF1E1E1E), - style: const TextStyle(color: Colors.white, fontSize: 14), - items: [value] + dropdownColor: theme.colorScheme.surfaceContainerHighest, + style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14), + items: items .map( (String val) => DropdownMenuItem(value: val, child: Text(val)), ) .toList(), - onChanged: (newValue) {}, + onChanged: onChanged, ), ], ), @@ -235,10 +270,11 @@ class _StatisticsScreenState extends State { // Widget pour les petites cartes de stats Widget _buildQuickStat(String label, String value, IconData icon) { + final theme = Theme.of(context); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF1E1E1E), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -247,15 +283,15 @@ class _StatisticsScreenState extends State { const SizedBox(height: 8), Text( value, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: theme.textTheme.titleLarge?.color, fontSize: 20, fontWeight: FontWeight.bold, ), ), Text( label, - style: const TextStyle(color: Colors.white54, fontSize: 12), + style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12), ), ], ), @@ -268,10 +304,11 @@ class _StatisticsScreenState extends State { String value, List dataPoints, ) { + final theme = Theme.of(context); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF1E1E1E), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -279,15 +316,15 @@ class _StatisticsScreenState extends State { children: [ Text( title, - style: const TextStyle(color: Colors.white70, fontSize: 14), + style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14), ), const SizedBox(height: 10), Row( children: [ Text( value, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: theme.textTheme.headlineMedium?.color, fontSize: 24, fontWeight: FontWeight.bold, ), @@ -328,7 +365,7 @@ class _StatisticsScreenState extends State { dotData: const FlDotData(show: false), belowBarData: BarAreaData( show: true, - color: const Color(0xFF4CAF50).withOpacity(0.2), + color: const Color(0xFF4CAF50).withValues(alpha: 0.2), ), ), ], @@ -339,19 +376,20 @@ class _StatisticsScreenState extends State { Widget _buildRegionalDistribution() { final regional = _statistics!.regional; + final theme = Theme.of(context); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF1E1E1E), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'Distribution Régionale', style: TextStyle( - color: Colors.white, + color: theme.textTheme.titleMedium?.color, fontSize: 16, fontWeight: FontWeight.bold, ), @@ -363,7 +401,7 @@ class _StatisticsScreenState extends State { const SizedBox(width: 8), Text( 'Direction dominante : ${regional.dominantDirection}', - style: const TextStyle(color: Colors.white70, fontSize: 14), + style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14), ), ], ), @@ -378,13 +416,13 @@ class _StatisticsScreenState extends State { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( - color: const Color(0xFF1A73E8).withOpacity(0.1), + color: const Color(0xFF1A73E8).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFF1A73E8).withOpacity(0.3)), + border: Border.all(color: const Color(0xFF1A73E8).withValues(alpha: 0.3)), ), child: Text( '${e.key}: ${e.value} (${percentage.toStringAsFixed(0)}%)', - style: const TextStyle(color: Colors.white, fontSize: 12), + style: TextStyle(color: theme.textTheme.bodySmall?.color, fontSize: 12), ), ); }).toList(), @@ -396,19 +434,20 @@ class _StatisticsScreenState extends State { Widget _buildQuadrantGrid() { final quadrants = _statistics!.regional.quadrantDistribution; + final theme = Theme.of(context); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF1E1E1E), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'Répartition par Quadrant', style: TextStyle( - color: Colors.white, + color: theme.textTheme.titleMedium?.color, fontSize: 16, fontWeight: FontWeight.bold, ), @@ -435,6 +474,79 @@ class _StatisticsScreenState extends State { ); } + Widget _buildHeatMapSection() { + final heatMap = _statistics!.heatMap; + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Zones de Chaleur (Heatmap)', + style: TextStyle( + color: theme.textTheme.titleMedium?.color, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Analyse de densité des impacts', + style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12), + ), + const SizedBox(height: 20), + AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white10), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CustomPaint( + painter: _HeatMapPainter(heatMap: heatMap), + ), + ), + ), + ), + const SizedBox(height: 16), + // Légende dégradée + Container( + height: 12, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + gradient: const LinearGradient( + colors: [ + Colors.blue, + Colors.yellow, + Colors.orange, + Colors.red, + ], + ), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Faible', style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.5))), + Text('Élevée', style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.5))), + ], + ), + ], + ), + ); + } + Widget _buildQuadrantCell(String label, int count) { final percentage = (count / _statistics!.totalShots * 100); final intensity = count / _statistics!.totalShots; @@ -442,10 +554,10 @@ class _StatisticsScreenState extends State { return Container( decoration: BoxDecoration( color: Color.lerp( - const Color(0xFF2A2A2A), + Theme.of(context).colorScheme.surfaceContainerHighest, const Color(0xFF1A73E8), intensity, - )!.withOpacity(0.8), + )!.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(8), ), child: Column( @@ -453,19 +565,19 @@ class _StatisticsScreenState extends State { children: [ Text( '$count', - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).textTheme.titleLarge?.color, fontSize: 20, fontWeight: FontWeight.bold, ), ), Text( '${percentage.toStringAsFixed(0)}%', - style: const TextStyle(color: Colors.white70, fontSize: 12), + style: TextStyle(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.7), fontSize: 12), ), Text( label, - style: const TextStyle(color: Colors.white54, fontSize: 10), + style: TextStyle(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.5), fontSize: 10), ), ], ), @@ -486,9 +598,9 @@ class _StatisticsScreenState extends State { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.orange.withOpacity(0.1), + color: Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.orange.withOpacity(0.3)), + border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), ), child: Row( children: [ @@ -507,7 +619,7 @@ class _StatisticsScreenState extends State { ), Text( 'Tendance $description', - style: const TextStyle(color: Colors.white70, fontSize: 13), + style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 13), ), ], ), @@ -517,3 +629,109 @@ class _StatisticsScreenState extends State { ); } } + +class _HeatMapPainter extends CustomPainter { + final HeatMap heatMap; + + _HeatMapPainter({required this.heatMap}); + + @override + void paint(Canvas canvas, Size size) { + _drawTargetBackground(canvas, size); + _drawHeatGradient(canvas, size); + } + + void _drawTargetBackground(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final maxRadius = math.min(size.width, size.height) * 0.45; + + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = Colors.grey.withValues(alpha: 0.2); // Neutre + + // Draw rings + for (int i = 1; i <= 10; i++) { + canvas.drawCircle(center, maxRadius * (i / 10), paint); + } + + // Draw crosshair + canvas.drawLine(Offset(0, center.dy), Offset(size.width, center.dy), paint); + canvas.drawLine(Offset(center.dx, 0), Offset(center.dx, size.height), paint); + } + + void _drawHeatGradient(Canvas canvas, Size size) { + final cellSizeW = size.width / heatMap.gridSize; + final cellSizeH = size.height / heatMap.gridSize; + + for (var row in heatMap.zones) { + for (var zone in row) { + if (zone.intensity <= 0) continue; + + final center = Offset( + (zone.col + 0.5) * cellSizeW, + (zone.row + 0.5) * cellSizeH, + ); + + final radius = cellSizeW * 1.2; // Légèrement réduit pour plus de netteté + + // Normalisation de l'intensité : on rend le max toujours très visible (rouge) + final normalizedIntensity = zone.intensity; + + final color = _getIntensityColor(normalizedIntensity); + + final paint = Paint() + ..shader = ui.Gradient.radial( + center, + radius, + [ + color.withValues(alpha: 0.7 * normalizedIntensity.clamp(0.4, 1.0)), + color.withValues(alpha: 0.0), + ], + [0.2, 1.0], + ) + ..style = PaintingStyle.fill; // srcOver par défaut, plus lisible + + canvas.drawCircle(center, radius, paint); + + // --- AFFICHAGE DU NOMBRE D'IMPACTS --- + if (zone.shotCount > 0) { + final textPainter = TextPainter( + text: TextSpan( + text: '${zone.shotCount}', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.8), + blurRadius: 2, + offset: const Offset(1, 1), + ), + ], + ), + ), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(center.dx - textPainter.width / 2, center.dy - textPainter.height / 2), + ); + } + } + } + } + + Color _getIntensityColor(double intensity) { + if (intensity < 0.2) return Colors.blue; + if (intensity < 0.5) return Colors.yellow; + if (intensity < 0.8) return Colors.orange; + return Colors.red; + } + + @override + bool shouldRepaint(covariant _HeatMapPainter oldDelegate) => + heatMap != oldDelegate.heatMap; +} diff --git a/lib/main.dart b/lib/main.dart index 220190d8..746588b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,11 +5,13 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:provider/provider.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'app.dart'; +import 'core/theme/theme_provider.dart'; import 'data/repositories/session_repository.dart'; import 'services/target_detection_service.dart'; import 'services/score_calculator_service.dart'; import 'services/grouping_analyzer_service.dart'; import 'services/image_processing_service.dart'; +import 'features/session/session_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -45,6 +47,8 @@ void main() async { create: (_) => GroupingAnalyzerService(), ), Provider(create: (_) => SessionRepository()), + ChangeNotifierProvider(create: (_) => ThemeProvider()), + ChangeNotifierProvider(create: (_) => SessionProvider()), ], child: const BullyApp(), ), diff --git a/lib/main_navigation_holder.dart b/lib/main_navigation_holder.dart new file mode 100644 index 00000000..6b1038b2 --- /dev/null +++ b/lib/main_navigation_holder.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'features/home/home_screen.dart'; +import 'features/history/history_screen.dart'; +import 'features/statistics/statistics_screen.dart'; +import 'features/garage/weapon_list_screen.dart'; +import 'core/theme/app_theme.dart'; + +class MainNavigationHolder extends StatefulWidget { + const MainNavigationHolder({super.key}); + + @override + State createState() => _MainNavigationHolderState(); +} + +class _MainNavigationHolderState extends State { + int _selectedIndex = 0; + + final List _screens = [ + const HomeScreen(), + const HistoryScreen(), + const StatisticsScreen(), + const WeaponListScreen(), + ]; + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _selectedIndex, + children: _screens, + ), + bottomNavigationBar: Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: BottomNavigationBar( + currentIndex: _selectedIndex, + onTap: _onItemTapped, + type: BottomNavigationBarType.fixed, + backgroundColor: Theme.of(context).cardColor, + selectedItemColor: AppTheme.primaryColor, + unselectedItemColor: Colors.grey, + showUnselectedLabels: true, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + activeIcon: Icon(Icons.home), + label: 'Accueil', + ), + BottomNavigationBarItem( + icon: Icon(Icons.history_outlined), + activeIcon: Icon(Icons.history), + label: 'Historique', + ), + BottomNavigationBarItem( + icon: Icon(Icons.analytics_outlined), + activeIcon: Icon(Icons.analytics), + label: 'Stats', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shield_outlined), + activeIcon: Icon(Icons.shield), + label: 'Armurerie', + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/ai_export_service.dart b/lib/services/ai_export_service.dart index 07062185..a186d2b2 100644 --- a/lib/services/ai_export_service.dart +++ b/lib/services/ai_export_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:device_info_plus/device_info_plus.dart'; @@ -37,7 +38,7 @@ class AiExportService { deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}'; } } catch (e) { - print('Erreur lors de la récupération des infos appareil: $e'); + debugPrint('Erreur lors de la récupération des infos appareil: $e'); } return deviceData; @@ -131,15 +132,15 @@ class AiExportService { if (response.statusCode == 200) { final responseData = await response.stream.bytesToString(); - print('Export réussi: $responseData'); + debugPrint('Export réussi: $responseData'); return true; } else { final errorData = await response.stream.bytesToString(); - print('Erreur d\'export: ${response.statusCode} - $errorData'); + debugPrint('Erreur d\'export: ${response.statusCode} - $errorData'); return false; } } catch (e) { - print('Exception lors de l\'export: $e'); + debugPrint('Exception lors de l\'export: $e'); return false; } } diff --git a/lib/services/distortion_correction_service.dart b/lib/services/distortion_correction_service.dart index 25383e86..6caa3a27 100644 --- a/lib/services/distortion_correction_service.dart +++ b/lib/services/distortion_correction_service.dart @@ -8,6 +8,7 @@ library; import 'dart:io'; import 'dart:math' as math; import 'package:image/image.dart' as img; +import 'package:flutter/foundation.dart'; import 'package:opencv_dart/opencv_dart.dart' as cv; import 'package:path_provider/path_provider.dart'; @@ -562,8 +563,9 @@ class DistortionCorrectionService { double maxArea = 0; for (final contour in contours) { - if (contour.length < 5) + if (contour.length < 5) { continue; // fitEllipse nécessite au moins 5 points + } final area = cv.contourArea(contour); if (area < 1000) continue; // Ignorer les trop petits bruits @@ -636,7 +638,7 @@ class DistortionCorrectionService { return outputPath; } catch (e) { // En cas d'erreur, retourner l'image originale - print('Erreur correction perspective cercles: $e'); + debugPrint('Erreur correction perspective cercles: $e'); return imagePath; } } @@ -761,7 +763,7 @@ class DistortionCorrectionService { return outputPath; } catch (e) { - print('Erreur correction perspective ovales: $e'); + debugPrint('Erreur correction perspective ovales: $e'); return imagePath; } } @@ -822,7 +824,7 @@ class DistortionCorrectionService { } if (concentricGroup.length < 2) { - print( + debugPrint( "Pas assez de cercles concentriques pour le maillage, utilisation de la méthode simple.", ); return await correctPerspectiveUsingOvals(imagePath); @@ -950,7 +952,7 @@ class DistortionCorrectionService { return outputPath; } catch (e) { - print('Erreur correction perspective maillage concentrique: $e'); + debugPrint('Erreur correction perspective maillage concentrique: $e'); return imagePath; } } @@ -1012,7 +1014,7 @@ class DistortionCorrectionService { // Fallback if (bestQuad == null) { - print( + debugPrint( "Aucun papier quadrilatère détecté, on utilise les cercles à la place.", ); return await correctPerspectiveUsingCircles(imagePath); @@ -1060,7 +1062,7 @@ class DistortionCorrectionService { return outputPath; } catch (e) { - print('Erreur correction perspective quadrilatère: $e'); + debugPrint('Erreur correction perspective quadrilatère: $e'); // Fallback return await correctPerspectiveUsingCircles(imagePath); } diff --git a/lib/services/image_processing_service.dart b/lib/services/image_processing_service.dart index 4c8624a3..146c83aa 100644 --- a/lib/services/image_processing_service.dart +++ b/lib/services/image_processing_service.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'dart:math' as math; import 'package:image/image.dart' as img; @@ -189,7 +190,7 @@ class ImageProcessingService { ); }).toList(); } catch (e) { - print('Error detecting impacts: $e'); + debugPrint('Error detecting impacts: $e'); return []; } } @@ -236,14 +237,14 @@ class ImageProcessingService { final fillRatios = []; final thresholds = []; - print('Analyzing ${references.length} reference impacts...'); + debugPrint('Analyzing ${references.length} reference impacts...'); for (int refIndex = 0; refIndex < references.length; refIndex++) { final ref = references[refIndex]; final centerX = (ref.x * width).round().clamp(0, width - 1); final centerY = (ref.y * height).round().clamp(0, height - 1); - print('Reference $refIndex at ($centerX, $centerY)'); + debugPrint('Reference $refIndex at ($centerX, $centerY)'); // AMÉLIORATION : Recherche du point le plus sombre dans une zone plus large int darkestX = centerX; @@ -275,7 +276,7 @@ class ImageProcessingService { if (darkestLum < 50 && r > 5) break; } - print(' Darkest point at ($darkestX, $darkestY), lum=$darkestLum'); + debugPrint(' Darkest point at ($darkestX, $darkestY), lum=$darkestLum'); // Now find the blob at the darkest point using adaptive threshold final blobResult = _findBlobAtPoint(blurred, darkestX, darkestY, width, height); @@ -286,15 +287,15 @@ class ImageProcessingService { circularities.add(blobResult.circularity); fillRatios.add(blobResult.fillRatio); thresholds.add(blobResult.threshold); - print(' Found blob: size=${blobResult.size}, circ=${blobResult.circularity.toStringAsFixed(2)}, ' + debugPrint(' Found blob: size=${blobResult.size}, circ=${blobResult.circularity.toStringAsFixed(2)}, ' 'fill=${blobResult.fillRatio.toStringAsFixed(2)}, threshold=${blobResult.threshold.toStringAsFixed(0)}'); } else { - print(' No valid blob found at this reference'); + debugPrint(' No valid blob found at this reference'); } } if (luminances.isEmpty) { - print('ERROR: No valid blobs found from any reference!'); + debugPrint('ERROR: No valid blobs found from any reference!'); return null; } @@ -329,11 +330,11 @@ class ImageProcessingService { avgDarkThreshold: avgThreshold, ); - print('Learned characteristics: $result'); + debugPrint('Learned characteristics: $result'); return result; } catch (e) { - print('Error analyzing reference impacts: $e'); + debugPrint('Error analyzing reference impacts: $e'); return null; } } @@ -680,7 +681,7 @@ class ImageProcessingService { // Calculate minimum fill ratio - impacts pleins final minFillRatio = (characteristics.avgFillRatio - 0.2).clamp(0.35, 0.85); - print('Detection params: thresholds=$thresholds, size=$minSize-$maxSize, ' + debugPrint('Detection params: thresholds=$thresholds, size=$minSize-$maxSize, ' 'circ>=$effectiveMinCircularity, fill>=$minFillRatio'); // Détecter avec plusieurs seuils et combiner les résultats @@ -722,7 +723,7 @@ class ImageProcessingService { return true; }).toList(); - print('Found ${filteredBlobs.length} impacts after filtering (from ${mergedBlobs.length} merged)'); + debugPrint('Found ${filteredBlobs.length} impacts after filtering (from ${mergedBlobs.length} merged)'); // Convert to relative coordinates return filteredBlobs.map((blob) { @@ -733,7 +734,7 @@ class ImageProcessingService { ); }).toList(); } catch (e) { - print('Error detecting impacts from references: $e'); + debugPrint('Error detecting impacts from references: $e'); return []; } } @@ -776,50 +777,6 @@ class ImageProcessingService { return merged; } - /// Detect dark spots with adaptive luminance range - List<_Blob> _detectDarkSpotsAdaptive( - img.Image image, - int minLuminance, - int maxLuminance, - int minSize, - int maxSize, { - double minCircularity = 0.5, - double minFillRatio = 0.5, - }) { - final width = image.width; - final height = image.height; - - // Create binary mask of pixels within luminance range - final mask = List.generate(height, (_) => List.filled(width, false)); - - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - final pixel = image.getPixel(x, y); - final luminance = img.getLuminance(pixel); - mask[y][x] = luminance >= minLuminance && luminance <= maxLuminance; - } - } - - // Find connected components - final visited = List.generate(height, (_) => List.filled(width, false)); - final blobs = <_Blob>[]; - - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - if (mask[y][x] && !visited[y][x]) { - final blob = _floodFill(mask, visited, x, y, width, height); - if (blob.size >= minSize && - blob.size <= maxSize && - blob.circularity >= minCircularity && - blob.fillRatio >= minFillRatio) { - blobs.add(blob); - } - } - } - } - - return _filterOverlappingBlobs(blobs); - } /// Detect dark spots in a grayscale image using blob detection List<_Blob> _detectDarkSpots( diff --git a/lib/services/statistics_service.dart b/lib/services/statistics_service.dart index 537f2f97..41041972 100644 --- a/lib/services/statistics_service.dart +++ b/lib/services/statistics_service.dart @@ -66,25 +66,12 @@ class PrecisionStats { /// Standard deviation statistics class StdDevStats { - /// Standard deviation of X positions final double stdDevX; - - /// Standard deviation of Y positions final double stdDevY; - - /// Combined standard deviation (radial) final double stdDevRadial; - - /// Standard deviation of scores final double stdDevScore; - - /// Mean X position final double meanX; - - /// Mean Y position final double meanY; - - /// Mean score final double meanScore; const StdDevStats({ @@ -98,18 +85,11 @@ class StdDevStats { }); } -/// Regional distribution (quadrants or sectors) +/// Regional distribution class RegionalStats { - /// Shot distribution by quadrant (top-left, top-right, bottom-left, bottom-right) final Map quadrantDistribution; - - /// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center) final Map sectorDistribution; - - /// Dominant direction (where most shots land) final String dominantDirection; - - /// Bias offset from center final double biasX; final double biasY; @@ -157,16 +137,16 @@ class StatisticsService { SessionStatistics calculateStatistics( List sessions, { StatsPeriod period = StatsPeriod.all, - double targetCenterX = 0.5, - double targetCenterY = 0.5, }) { // Filter sessions by period final filteredSessions = _filterByPeriod(sessions, period); - // Collect all shots + // Collect all shots from all analyses in all sessions final allShots = []; for (final session in filteredSessions) { - allShots.addAll(session.shots); + for (final analysis in session.analyses) { + allShots.addAll(analysis.shots); + } } if (allShots.isEmpty) { @@ -183,14 +163,14 @@ class StatisticsService { // Calculate heat map final heatMap = _calculateHeatMap(allShots, gridSize: 5); - // Calculate precision - final precision = _calculatePrecision(allShots, targetCenterX, targetCenterY); + // Calculate precision (using relative center 0.5, 0.5) + final precision = _calculatePrecision(allShots, 0.5, 0.5); // Calculate standard deviation final stdDev = _calculateStdDev(allShots); // Calculate regional distribution - final regional = _calculateRegional(allShots, targetCenterX, targetCenterY); + final regional = _calculateRegional(allShots, 0.5, 0.5); return SessionStatistics( totalShots: totalShots, @@ -224,20 +204,13 @@ class StatisticsService { /// Calculate heat map HeatMap _calculateHeatMap(List shots, {int gridSize = 5}) { - // Initialize grid - final grid = List.generate( - gridSize, - (_) => List.generate(gridSize, (_) => []), - ); - - // Assign shots to grid cells + final grid = List.generate(gridSize, (_) => List.generate(gridSize, (_) => [])); for (final shot in shots) { final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1); final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1); grid[row][col].add(shot); } - // Find max count for normalization int maxCount = 0; for (final row in grid) { for (final cell in row) { @@ -245,16 +218,12 @@ class StatisticsService { } } - // Create heat zones final zones = >[]; for (int row = 0; row < gridSize; row++) { final rowZones = []; for (int col = 0; col < gridSize; col++) { final cellShots = grid[row][col]; - final avgScore = cellShots.isEmpty - ? 0.0 - : cellShots.fold(0, (sum, s) => sum + s.score) / cellShots.length; - + final avgScore = cellShots.isEmpty ? 0.0 : cellShots.fold(0, (sum, s) => sum + s.score) / cellShots.length; rowZones.add(HeatZone( row: row, col: col, @@ -266,30 +235,15 @@ class StatisticsService { zones.add(rowZones); } - return HeatMap( - gridSize: gridSize, - zones: zones, - maxShotsInZone: maxCount, - totalShots: shots.length, - ); + return HeatMap(gridSize: gridSize, zones: zones, maxShotsInZone: maxCount, totalShots: shots.length); } /// Calculate precision statistics - PrecisionStats _calculatePrecision( - List shots, - double centerX, - double centerY, - ) { + PrecisionStats _calculatePrecision(List shots, double centerX, double centerY) { if (shots.isEmpty) { - return const PrecisionStats( - avgDistanceFromCenter: 0, - groupingDiameter: 0, - precisionScore: 0, - consistencyScore: 0, - ); + return const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0); } - // Calculate distances from center final distances = shots.map((shot) { final dx = shot.x - centerX; final dy = shot.y - centerY; @@ -297,8 +251,6 @@ class StatisticsService { }).toList(); final avgDistance = distances.reduce((a, b) => a + b) / distances.length; - - // Calculate grouping (spread between shots) double maxSpread = 0; for (int i = 0; i < shots.length; i++) { for (int j = i + 1; j < shots.length; j++) { @@ -309,45 +261,29 @@ class StatisticsService { } } - // Calculate standard deviation of distances (consistency) - final meanDist = avgDistance; double variance = 0; for (final d in distances) { - variance += math.pow(d - meanDist, 2); + variance += math.pow(d - avgDistance, 2); } final stdDevDist = math.sqrt(variance / distances.length); - // Precision score: based on average distance from center (0-100) - // 0 distance = 100 score, 0.5 distance = 0 score final precisionScore = math.max(0, (1 - avgDistance * 2) * 100); - - // Consistency score: based on grouping tightness (0-100) - // Lower spread = higher consistency final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100); return PrecisionStats( - avgDistanceFromCenter: avgDistance.toDouble(), - groupingDiameter: maxSpread.toDouble(), - precisionScore: precisionScore.clamp(0.0, 100.0).toDouble(), - consistencyScore: consistencyScore.clamp(0.0, 100.0).toDouble(), + avgDistanceFromCenter: avgDistance, + groupingDiameter: maxSpread, + precisionScore: precisionScore.toDouble().clamp(0.0, 100.0), + consistencyScore: consistencyScore.toDouble().clamp(0.0, 100.0), ); } /// Calculate standard deviation statistics StdDevStats _calculateStdDev(List shots) { if (shots.isEmpty) { - return const StdDevStats( - stdDevX: 0, - stdDevY: 0, - stdDevRadial: 0, - stdDevScore: 0, - meanX: 0.5, - meanY: 0.5, - meanScore: 0, - ); + return const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0); } - // Calculate means double sumX = 0, sumY = 0, sumScore = 0; for (final shot in shots) { sumX += shot.x; @@ -358,7 +294,6 @@ class StatisticsService { final meanY = sumY / shots.length; final meanScore = sumScore / shots.length; - // Calculate variances double varianceX = 0, varianceY = 0, varianceScore = 0; for (final shot in shots) { varianceX += math.pow(shot.x - meanX, 2); @@ -369,18 +304,11 @@ class StatisticsService { varianceY /= shots.length; varianceScore /= shots.length; - final stdDevX = math.sqrt(varianceX); - final stdDevY = math.sqrt(varianceY); - final stdDevScore = math.sqrt(varianceScore); - - // Radial standard deviation - final stdDevRadial = math.sqrt(varianceX + varianceY); - return StdDevStats( - stdDevX: stdDevX, - stdDevY: stdDevY, - stdDevRadial: stdDevRadial, - stdDevScore: stdDevScore, + stdDevX: math.sqrt(varianceX), + stdDevY: math.sqrt(varianceY), + stdDevRadial: math.sqrt(varianceX + varianceY), + stdDevScore: math.sqrt(varianceScore), meanX: meanX, meanY: meanY, meanScore: meanScore, @@ -388,42 +316,13 @@ class StatisticsService { } /// Calculate regional distribution - RegionalStats _calculateRegional( - List shots, - double centerX, - double centerY, - ) { + RegionalStats _calculateRegional(List shots, double centerX, double centerY) { if (shots.isEmpty) { - return const RegionalStats( - quadrantDistribution: {}, - sectorDistribution: {}, - dominantDirection: 'Centre', - biasX: 0, - biasY: 0, - ); + return const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0); } - // Quadrant distribution - final quadrants = { - 'Haut-Gauche': 0, - 'Haut-Droite': 0, - 'Bas-Gauche': 0, - 'Bas-Droite': 0, - }; - - // Sector distribution (8 sectors + center) - final sectors = { - 'N': 0, - 'NE': 0, - 'E': 0, - 'SE': 0, - 'S': 0, - 'SO': 0, - 'O': 0, - 'NO': 0, - 'Centre': 0, - }; - + final quadrants = {'Haut-Gauche': 0, 'Haut-Droite': 0, 'Bas-Gauche': 0, 'Bas-Droite': 0}; + final sectors = {'N': 0, 'NE': 0, 'E': 0, 'SE': 0, 'S': 0, 'SO': 0, 'O': 0, 'NO': 0, 'Centre': 0}; double sumDx = 0, sumDy = 0; for (final shot in shots) { @@ -432,16 +331,12 @@ class StatisticsService { sumDx += dx; sumDy += dy; - // Quadrant if (dy < 0) { - quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] = - quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1; + quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] = quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1; } else { - quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] = - quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1; + quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] = quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1; } - // Sector final distance = math.sqrt(dx * dx + dy * dy); if (distance < 0.1) { sectors['Centre'] = sectors['Centre']! + 1; @@ -452,11 +347,6 @@ class StatisticsService { } } - // Calculate bias - final biasX = sumDx / shots.length; - final biasY = sumDy / shots.length; - - // Find dominant direction String dominant = 'Centre'; int maxCount = 0; sectors.forEach((key, value) { @@ -470,14 +360,12 @@ class StatisticsService { quadrantDistribution: quadrants, sectorDistribution: sectors, dominantDirection: dominant, - biasX: biasX, - biasY: biasY, + biasX: sumDx / shots.length, + biasY: sumDy / shots.length, ); } String _angleToSector(double angle) { - // Angle is in degrees, -180 to 180 - // 0 = East, 90 = South, -90 = North, 180/-180 = West if (angle >= -22.5 && angle < 22.5) return 'E'; if (angle >= 22.5 && angle < 67.5) return 'SE'; if (angle >= 67.5 && angle < 112.5) return 'S'; @@ -491,41 +379,12 @@ class StatisticsService { SessionStatistics _emptyStatistics(StatsPeriod period, List sessions) { return SessionStatistics( - totalShots: 0, - totalScore: 0, - avgScore: 0, - maxScore: 0, - minScore: 0, - heatMap: const HeatMap( - gridSize: 5, - zones: [], - maxShotsInZone: 0, - totalShots: 0, - ), - precision: const PrecisionStats( - avgDistanceFromCenter: 0, - groupingDiameter: 0, - precisionScore: 0, - consistencyScore: 0, - ), - stdDev: const StdDevStats( - stdDevX: 0, - stdDevY: 0, - stdDevRadial: 0, - stdDevScore: 0, - meanX: 0.5, - meanY: 0.5, - meanScore: 0, - ), - regional: const RegionalStats( - quadrantDistribution: {}, - sectorDistribution: {}, - dominantDirection: 'Centre', - biasX: 0, - biasY: 0, - ), - sessions: sessions, - period: period, + totalShots: 0, totalScore: 0, avgScore: 0, maxScore: 0, minScore: 0, + heatMap: const HeatMap(gridSize: 5, zones: [], maxShotsInZone: 0, totalShots: 0), + precision: const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0), + stdDev: const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0), + regional: const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0), + sessions: sessions, period: period, ); } } diff --git a/lib/services/target_detection_service.dart b/lib/services/target_detection_service.dart index a0fe6470..77c26273 100644 --- a/lib/services/target_detection_service.dart +++ b/lib/services/target_detection_service.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import '../data/models/target_type.dart'; import 'image_processing_service.dart'; import 'opencv_impact_detection_service.dart'; @@ -320,7 +321,7 @@ class TargetDetectionService { ); }).toList(); } catch (e) { - print('Erreur détection OpenCV: $e'); + debugPrint('Erreur détection OpenCV: $e'); return []; } } @@ -369,7 +370,7 @@ class TargetDetectionService { ); }).toList(); } catch (e) { - print('Erreur détection OpenCV depuis références: $e'); + debugPrint('Erreur détection OpenCV depuis références: $e'); return []; } } diff --git a/lib/services/wallet_identity_service.dart b/lib/services/wallet_identity_service.dart index 7af58704..67d361d7 100644 --- a/lib/services/wallet_identity_service.dart +++ b/lib/services/wallet_identity_service.dart @@ -4,6 +4,7 @@ import 'package:crypto/crypto.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'dart:io'; +import 'package:flutter/foundation.dart'; class WalletIdentityService { static const String _prefsKey = 'wallet_identity_phrase'; @@ -86,7 +87,7 @@ class WalletIdentityService { deviceId = windowsInfo.deviceId; } } catch (e) { - print('Erreur lecture device ID: $e'); + debugPrint('Erreur lecture device ID: $e'); } // Add a salt to the device ID diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2db3c22a..a630fa93 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 230eabf6..5a997e6a 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)