feat: implement base architecture and core repositories for weapon tracking and target analysis functionality
This commit is contained in:
BIN
analysis_report.txt
Normal file
BIN
analysis_report.txt
Normal file
Binary file not shown.
40
lib/app.dart
40
lib/app.dart
@@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'core/theme/theme_provider.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
|
import 'main_navigation_holder.dart';
|
||||||
import 'features/home/home_screen.dart';
|
import 'features/home/home_screen.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
@@ -8,23 +11,26 @@ class BullyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return Consumer<ThemeProvider>(
|
||||||
title: 'Bully - Analyse de Cibles',
|
builder: (context, themeProvider, child) {
|
||||||
debugShowCheckedModeBanner: false,
|
return MaterialApp(
|
||||||
theme: AppTheme.lightTheme,
|
title: 'Bully - Analyse de Cibles',
|
||||||
darkTheme: AppTheme.darkTheme,
|
debugShowCheckedModeBanner: false,
|
||||||
themeMode: ThemeMode.system,
|
theme: AppTheme.lightTheme,
|
||||||
localizationsDelegates: const [
|
darkTheme: AppTheme.darkTheme,
|
||||||
GlobalMaterialLocalizations.delegate,
|
themeMode: themeProvider.themeMode,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
localizationsDelegates: const [
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
],
|
GlobalWidgetsLocalizations.delegate,
|
||||||
supportedLocales: const [
|
GlobalCupertinoLocalizations.delegate,
|
||||||
Locale('fr', 'FR'), // Français
|
],
|
||||||
],
|
supportedLocales: const [
|
||||||
locale: const Locale('fr', 'FR'), // Force l'interface en français
|
Locale('fr', 'FR'), // Français
|
||||||
|
],
|
||||||
home: const HomeScreen(),
|
locale: const Locale('fr', 'FR'), // Force l'interface en français
|
||||||
|
home: const MainNavigationHolder(),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ class AppConstants {
|
|||||||
|
|
||||||
// Database
|
// Database
|
||||||
static const String databaseName = 'bully_targets.db';
|
static const String databaseName = 'bully_targets.db';
|
||||||
static const int databaseVersion = 1;
|
static const int databaseVersion = 6;
|
||||||
|
|
||||||
// Tables
|
// Tables
|
||||||
static const String sessionsTable = 'sessions';
|
static const String sessionsTable = 'sessions';
|
||||||
|
static const String targetAnalysesTable = 'target_analyses';
|
||||||
static const String shotsTable = 'shots';
|
static const String shotsTable = 'shots';
|
||||||
|
static const String weaponsTable = 'weapons';
|
||||||
|
static const String maintenanceTable = 'weapon_maintenance';
|
||||||
|
|
||||||
// Image processing
|
// Image processing
|
||||||
static const double minImpactRadius = 2.0;
|
static const double minImpactRadius = 2.0;
|
||||||
|
|||||||
44
lib/core/theme/theme_provider.dart
Normal file
44
lib/core/theme/theme_provider.dart
Normal file
@@ -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<void> loadThemeMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final modeIndex = prefs.getInt(_themeModeKey);
|
||||||
|
if (modeIndex != null) {
|
||||||
|
_themeMode = ThemeMode.values[modeIndex];
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import '../models/session.dart';
|
import '../models/session.dart';
|
||||||
|
import '../models/target_analysis.dart';
|
||||||
import '../models/shot.dart';
|
import '../models/shot.dart';
|
||||||
|
import '../models/weapon.dart';
|
||||||
|
import '../models/maintenance.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
|
|
||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
@@ -36,6 +39,19 @@ class DatabaseHelper {
|
|||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE ${AppConstants.sessionsTable} (
|
CREATE TABLE ${AppConstants.sessionsTable} (
|
||||||
id TEXT PRIMARY KEY,
|
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,
|
target_type TEXT NOT NULL,
|
||||||
image_path TEXT NOT NULL,
|
image_path TEXT NOT NULL,
|
||||||
total_score INTEGER NOT NULL,
|
total_score INTEGER NOT NULL,
|
||||||
@@ -46,7 +62,8 @@ class DatabaseHelper {
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
target_center_x REAL,
|
target_center_x REAL,
|
||||||
target_center_y 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,
|
x REAL NOT NULL,
|
||||||
y REAL NOT NULL,
|
y REAL NOT NULL,
|
||||||
score INTEGER NOT NULL,
|
score INTEGER NOT NULL,
|
||||||
session_id TEXT NOT NULL,
|
analysis_id TEXT NOT NULL,
|
||||||
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
|
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
await db.execute('''
|
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('''
|
await db.execute('''
|
||||||
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
|
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.weaponsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
caliber TEXT NOT NULL,
|
||||||
|
magazine_count INTEGER NOT NULL,
|
||||||
|
magazine_capacity INTEGER NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
optic TEXT,
|
||||||
|
silencer TEXT,
|
||||||
|
trigger TEXT,
|
||||||
|
custom_name TEXT
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.maintenanceTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
rounds_since_last INTEGER,
|
||||||
|
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
// Handle future database migrations here
|
if (oldVersion < 2) {
|
||||||
|
// Migration from single-target sessions to multi-target sessions
|
||||||
|
|
||||||
|
// 1. Rename existing sessions table to a temporary name
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} RENAME TO sessions_old');
|
||||||
|
|
||||||
|
// 2. Create the new sessions table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.sessionsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon TEXT NOT NULL,
|
||||||
|
max_shots_per_target INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
notes TEXT
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 3. Create the new target_analyses table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.targetAnalysesTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
total_score INTEGER NOT NULL,
|
||||||
|
grouping_diameter REAL,
|
||||||
|
grouping_center_x REAL,
|
||||||
|
grouping_center_y REAL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
target_center_x REAL,
|
||||||
|
target_center_y REAL,
|
||||||
|
target_radius REAL,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 4. Update shots table (rename session_id to analysis_id)
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.shotsTable} RENAME TO shots_old');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.shotsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
x REAL NOT NULL,
|
||||||
|
y REAL NOT NULL,
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
analysis_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 5. Migrate data
|
||||||
|
// For each old session, create one session and one target_analysis
|
||||||
|
final oldSessions = await db.query('sessions_old');
|
||||||
|
for (final old in oldSessions) {
|
||||||
|
final sessionId = old['id'] as String;
|
||||||
|
final analysisId = 'analysis_$sessionId'; // Create a unique ID for the analysis
|
||||||
|
|
||||||
|
// Insert into new sessions
|
||||||
|
await db.insert(AppConstants.sessionsTable, {
|
||||||
|
'id': sessionId,
|
||||||
|
'weapon': 'Inconnue',
|
||||||
|
'max_shots_per_target': 5,
|
||||||
|
'created_at': old['created_at'],
|
||||||
|
'notes': old['notes'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert into target_analyses
|
||||||
|
await db.insert(AppConstants.targetAnalysesTable, {
|
||||||
|
'id': analysisId,
|
||||||
|
'session_id': sessionId,
|
||||||
|
'target_type': old['target_type'],
|
||||||
|
'image_path': old['image_path'],
|
||||||
|
'total_score': old['total_score'],
|
||||||
|
'grouping_diameter': old['grouping_diameter'],
|
||||||
|
'grouping_center_x': old['grouping_center_x'],
|
||||||
|
'grouping_center_y': old['grouping_center_y'],
|
||||||
|
'created_at': old['created_at'],
|
||||||
|
'notes': null,
|
||||||
|
'target_center_x': old['target_center_x'],
|
||||||
|
'target_center_y': old['target_center_y'],
|
||||||
|
'target_radius': old['target_radius'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrate shots from shots_old to shots
|
||||||
|
final oldShots = await db.query('shots_old', where: 'session_id = ?', whereArgs: [sessionId]);
|
||||||
|
for (final shot in oldShots) {
|
||||||
|
await db.insert(AppConstants.shotsTable, {
|
||||||
|
'id': shot['id'],
|
||||||
|
'x': shot['x'],
|
||||||
|
'y': shot['y'],
|
||||||
|
'score': shot['score'],
|
||||||
|
'analysis_id': analysisId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Cleanup
|
||||||
|
await db.execute('DROP TABLE sessions_old');
|
||||||
|
await db.execute('DROP TABLE shots_old');
|
||||||
|
|
||||||
|
// 7. Re-create indexes
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 3) {
|
||||||
|
// Add weapons table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.weaponsTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
caliber TEXT NOT NULL,
|
||||||
|
magazine_count INTEGER NOT NULL,
|
||||||
|
magazine_capacity INTEGER NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// Add weapon_id to sessions table
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN weapon_id TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
// Add maintenance table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${AppConstants.maintenanceTable} (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
weapon_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
rounds_since_last INTEGER,
|
||||||
|
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 5) {
|
||||||
|
// Add new weapon options columns
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN optic TEXT');
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN silencer TEXT');
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN "trigger" TEXT');
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN custom_name TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
// Add distance column to sessions
|
||||||
|
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN distance INTEGER DEFAULT 25');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session operations
|
// Session operations
|
||||||
@@ -84,20 +291,39 @@ class DatabaseHelper {
|
|||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (final shot in session.shots) {
|
for (final analysis in session.analyses) {
|
||||||
// Ensure shot has correct session_id
|
await insertTargetAnalysisTx(txn, analysis, session.id);
|
||||||
final shotWithSessionId = shot.copyWith(sessionId: session.id);
|
|
||||||
await txn.insert(
|
|
||||||
AppConstants.shotsTable,
|
|
||||||
shotWithSessionId.toMap(),
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> insertTargetAnalysis(TargetAnalysis analysis, String sessionId) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await insertTargetAnalysisTx(txn, analysis, sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insertTargetAnalysisTx(Transaction txn, TargetAnalysis analysis, String sessionId) async {
|
||||||
|
final analysisWithSessionId = analysis.copyWith(sessionId: sessionId);
|
||||||
|
await txn.insert(
|
||||||
|
AppConstants.targetAnalysesTable,
|
||||||
|
analysisWithSessionId.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final shot in analysis.shots) {
|
||||||
|
final shotWithAnalysisId = shot.copyWith(analysisId: analysis.id);
|
||||||
|
await txn.insert(
|
||||||
|
AppConstants.shotsTable,
|
||||||
|
shotWithAnalysisId.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Session?> getSession(String id) async {
|
Future<Session?> getSession(String id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final sessionMaps = await db.query(
|
final sessionMaps = await db.query(
|
||||||
@@ -108,106 +334,52 @@ class DatabaseHelper {
|
|||||||
|
|
||||||
if (sessionMaps.isEmpty) return null;
|
if (sessionMaps.isEmpty) return null;
|
||||||
|
|
||||||
final shotMaps = await db.query(
|
final analysisMaps = await db.query(
|
||||||
AppConstants.shotsTable,
|
AppConstants.targetAnalysesTable,
|
||||||
where: 'session_id = ?',
|
where: 'session_id = ?',
|
||||||
whereArgs: [id],
|
whereArgs: [id],
|
||||||
);
|
);
|
||||||
|
|
||||||
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
final analyses = <TargetAnalysis>[];
|
||||||
return Session.fromMap(sessionMaps.first, shots);
|
for (final analysisMap in analysisMaps) {
|
||||||
|
final analysisId = analysisMap['id'] as String;
|
||||||
|
final shotMaps = await db.query(
|
||||||
|
AppConstants.shotsTable,
|
||||||
|
where: 'analysis_id = ?',
|
||||||
|
whereArgs: [analysisId],
|
||||||
|
);
|
||||||
|
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
||||||
|
analyses.add(TargetAnalysis.fromMap(analysisMap, shots));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Session.fromMap(sessionMaps.first, analyses);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Session>> getAllSessions({
|
Future<List<Session>> getAllSessions({
|
||||||
String? targetType,
|
|
||||||
int? limit,
|
int? limit,
|
||||||
int? offset,
|
int? offset,
|
||||||
}) async {
|
}) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|
||||||
String? whereClause;
|
|
||||||
List<dynamic>? whereArgs;
|
|
||||||
|
|
||||||
if (targetType != null) {
|
|
||||||
whereClause = 'target_type = ?';
|
|
||||||
whereArgs = [targetType];
|
|
||||||
}
|
|
||||||
|
|
||||||
final sessionMaps = await db.query(
|
final sessionMaps = await db.query(
|
||||||
AppConstants.sessionsTable,
|
AppConstants.sessionsTable,
|
||||||
where: whereClause,
|
|
||||||
whereArgs: whereArgs,
|
|
||||||
orderBy: 'created_at DESC',
|
orderBy: 'created_at DESC',
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
);
|
);
|
||||||
|
|
||||||
// First, check if there are orphaned shots (with empty session_id)
|
|
||||||
// and only one session - if so, assign them to that session
|
|
||||||
if (sessionMaps.length == 1) {
|
|
||||||
final orphanedShots = await db.query(
|
|
||||||
AppConstants.shotsTable,
|
|
||||||
where: 'session_id = ?',
|
|
||||||
whereArgs: [''],
|
|
||||||
);
|
|
||||||
if (orphanedShots.isNotEmpty) {
|
|
||||||
await db.update(
|
|
||||||
AppConstants.shotsTable,
|
|
||||||
{'session_id': sessionMaps.first['id']},
|
|
||||||
where: 'session_id = ?',
|
|
||||||
whereArgs: [''],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final sessions = <Session>[];
|
final sessions = <Session>[];
|
||||||
for (final sessionMap in sessionMaps) {
|
for (final sessionMap in sessionMaps) {
|
||||||
final sessionId = sessionMap['id'] as String;
|
final sessionId = sessionMap['id'] as String;
|
||||||
|
final session = await getSession(sessionId);
|
||||||
final shotMaps = await db.query(
|
if (session != null) {
|
||||||
AppConstants.shotsTable,
|
sessions.add(session);
|
||||||
where: 'session_id = ?',
|
}
|
||||||
whereArgs: [sessionId],
|
|
||||||
);
|
|
||||||
|
|
||||||
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
|
|
||||||
sessions.add(Session.fromMap(sessionMap, shots));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions;
|
return sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> updateSession(Session session) async {
|
|
||||||
final db = await database;
|
|
||||||
return await db.transaction((txn) async {
|
|
||||||
await txn.update(
|
|
||||||
AppConstants.sessionsTable,
|
|
||||||
session.toMap(),
|
|
||||||
where: 'id = ?',
|
|
||||||
whereArgs: [session.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete existing shots and insert new ones
|
|
||||||
await txn.delete(
|
|
||||||
AppConstants.shotsTable,
|
|
||||||
where: 'session_id = ?',
|
|
||||||
whereArgs: [session.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
for (final shot in session.shots) {
|
|
||||||
// Ensure shot has correct session_id
|
|
||||||
final shotWithSessionId = shot.copyWith(sessionId: session.id);
|
|
||||||
await txn.insert(
|
|
||||||
AppConstants.shotsTable,
|
|
||||||
shotWithSessionId.toMap(),
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> deleteSession(String id) async {
|
Future<int> deleteSession(String id) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
return await db.delete(
|
return await db.delete(
|
||||||
@@ -229,12 +401,12 @@ class DatabaseHelper {
|
|||||||
) ?? 0;
|
) ?? 0;
|
||||||
|
|
||||||
final avgScore = (await db.rawQuery(
|
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;
|
)).first['avg'] as double? ?? 0.0;
|
||||||
|
|
||||||
final bestScore = Sqflite.firstIntValue(
|
final bestScore = Sqflite.firstIntValue(
|
||||||
await db.rawQuery(
|
await db.rawQuery(
|
||||||
'SELECT MAX(total_score) FROM ${AppConstants.sessionsTable}',
|
'SELECT MAX(total_score) FROM ${AppConstants.targetAnalysesTable}',
|
||||||
),
|
),
|
||||||
) ?? 0;
|
) ?? 0;
|
||||||
|
|
||||||
@@ -246,6 +418,121 @@ class DatabaseHelper {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weapon operations
|
||||||
|
Future<int> insertWeapon(Weapon weapon) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.insert(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
weapon.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Weapon>> getAllWeapons() async {
|
||||||
|
final db = await database;
|
||||||
|
final List<Map<String, dynamic>> maps = await db.query(AppConstants.weaponsTable, orderBy: 'name ASC');
|
||||||
|
return List.generate(maps.length, (i) => Weapon.fromMap(maps[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Weapon?> getWeapon(String id) async {
|
||||||
|
final db = await database;
|
||||||
|
final List<Map<String, dynamic>> maps = await db.query(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
if (maps.isEmpty) return null;
|
||||||
|
return Weapon.fromMap(maps.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> updateWeapon(Weapon weapon) async {
|
||||||
|
final db = await database;
|
||||||
|
|
||||||
|
// Start a transaction to ensure both updates succeed
|
||||||
|
return await db.transaction((txn) async {
|
||||||
|
// 1. Update weapon in armory
|
||||||
|
final count = await txn.update(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
weapon.toMap(),
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [weapon.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Propagate name change to all sessions linked to this weapon
|
||||||
|
await txn.update(
|
||||||
|
AppConstants.sessionsTable,
|
||||||
|
{'weapon': weapon.displayName},
|
||||||
|
where: 'weapon_id = ?',
|
||||||
|
whereArgs: [weapon.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> deleteWeapon(String id) async {
|
||||||
|
final db = await database;
|
||||||
|
|
||||||
|
return await db.transaction((txn) async {
|
||||||
|
// 1. Unlink sessions from this weapon but KEEP the name (gravé à jamais)
|
||||||
|
await txn.update(
|
||||||
|
AppConstants.sessionsTable,
|
||||||
|
{'weapon_id': null},
|
||||||
|
where: 'weapon_id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Delete the weapon record
|
||||||
|
return await txn.delete(
|
||||||
|
AppConstants.weaponsTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weapon statistics & Maintenance
|
||||||
|
Future<int> getRoundsFiredForWeapon(String weaponId) async {
|
||||||
|
final db = await database;
|
||||||
|
final result = await db.rawQuery('''
|
||||||
|
SELECT COUNT(s.id) as count
|
||||||
|
FROM ${AppConstants.shotsTable} s
|
||||||
|
JOIN ${AppConstants.targetAnalysesTable} ta ON s.analysis_id = ta.id
|
||||||
|
JOIN ${AppConstants.sessionsTable} sess ON ta.session_id = sess.id
|
||||||
|
WHERE sess.weapon_id = ?
|
||||||
|
''', [weaponId]);
|
||||||
|
return Sqflite.firstIntValue(result) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> insertMaintenance(MaintenanceEntry entry) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.insert(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
entry.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
|
||||||
|
final db = await database;
|
||||||
|
final maps = await db.query(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
where: 'weapon_id = ?',
|
||||||
|
orderBy: 'date DESC',
|
||||||
|
whereArgs: [weaponId],
|
||||||
|
);
|
||||||
|
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> deleteMaintenance(String id) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.delete(
|
||||||
|
AppConstants.maintenanceTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.close();
|
await db.close();
|
||||||
|
|||||||
57
lib/data/models/maintenance.dart
Normal file
57
lib/data/models/maintenance.dart
Normal file
@@ -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<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'weapon_id': weaponId,
|
||||||
|
'type': type.name,
|
||||||
|
'description': description,
|
||||||
|
'date': date.toIso8601String(),
|
||||||
|
'rounds_since_last': roundsSinceLastMaintenance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MaintenanceEntry.fromMap(Map<String, dynamic> 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,112 +1,83 @@
|
|||||||
import 'shot.dart';
|
import 'target_analysis.dart';
|
||||||
import 'target_type.dart';
|
|
||||||
|
|
||||||
class Session {
|
class Session {
|
||||||
final String id;
|
final String id;
|
||||||
final TargetType targetType;
|
final String weapon;
|
||||||
final String imagePath;
|
final String? weaponId;
|
||||||
final List<Shot> shots;
|
final int maxShotsPerTarget;
|
||||||
final int totalScore;
|
final List<TargetAnalysis> analyses;
|
||||||
final double? groupingDiameter;
|
|
||||||
final double? groupingCenterX;
|
|
||||||
final double? groupingCenterY;
|
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? notes;
|
final String? notes;
|
||||||
|
final int distance; // Shooting distance in meters
|
||||||
// Target detection data
|
|
||||||
final double? targetCenterX;
|
|
||||||
final double? targetCenterY;
|
|
||||||
final double? targetRadius;
|
|
||||||
|
|
||||||
Session({
|
Session({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.targetType,
|
required this.weapon,
|
||||||
required this.imagePath,
|
this.weaponId,
|
||||||
required this.shots,
|
required this.maxShotsPerTarget,
|
||||||
required this.totalScore,
|
required this.analyses,
|
||||||
this.groupingDiameter,
|
|
||||||
this.groupingCenterX,
|
|
||||||
this.groupingCenterY,
|
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.notes,
|
this.notes,
|
||||||
this.targetCenterX,
|
this.distance = 25, // Default distance
|
||||||
this.targetCenterY,
|
|
||||||
this.targetRadius,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
Session copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
TargetType? targetType,
|
String? weapon,
|
||||||
String? imagePath,
|
String? weaponId,
|
||||||
List<Shot>? shots,
|
int? maxShotsPerTarget,
|
||||||
int? totalScore,
|
List<TargetAnalysis>? analyses,
|
||||||
double? groupingDiameter,
|
|
||||||
double? groupingCenterX,
|
|
||||||
double? groupingCenterY,
|
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? notes,
|
String? notes,
|
||||||
double? targetCenterX,
|
int? distance,
|
||||||
double? targetCenterY,
|
|
||||||
double? targetRadius,
|
|
||||||
}) {
|
}) {
|
||||||
return Session(
|
return Session(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
targetType: targetType ?? this.targetType,
|
weapon: weapon ?? this.weapon,
|
||||||
imagePath: imagePath ?? this.imagePath,
|
weaponId: weaponId ?? this.weaponId,
|
||||||
shots: shots ?? this.shots,
|
maxShotsPerTarget: maxShotsPerTarget ?? this.maxShotsPerTarget,
|
||||||
totalScore: totalScore ?? this.totalScore,
|
analyses: analyses ?? this.analyses,
|
||||||
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
|
|
||||||
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
|
|
||||||
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
|
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
notes: notes ?? this.notes,
|
notes: notes ?? this.notes,
|
||||||
targetCenterX: targetCenterX ?? this.targetCenterX,
|
distance: distance ?? this.distance,
|
||||||
targetCenterY: targetCenterY ?? this.targetCenterY,
|
|
||||||
targetRadius: targetRadius ?? this.targetRadius,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'id': id,
|
||||||
'target_type': targetType.name,
|
'weapon': weapon,
|
||||||
'image_path': imagePath,
|
'weapon_id': weaponId,
|
||||||
'total_score': totalScore,
|
'max_shots_per_target': maxShotsPerTarget,
|
||||||
'grouping_diameter': groupingDiameter,
|
|
||||||
'grouping_center_x': groupingCenterX,
|
|
||||||
'grouping_center_y': groupingCenterY,
|
|
||||||
'created_at': createdAt.toIso8601String(),
|
'created_at': createdAt.toIso8601String(),
|
||||||
'notes': notes,
|
'notes': notes,
|
||||||
'target_center_x': targetCenterX,
|
'distance': distance,
|
||||||
'target_center_y': targetCenterY,
|
|
||||||
'target_radius': targetRadius,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
factory Session.fromMap(Map<String, dynamic> map, List<Shot> shots) {
|
factory Session.fromMap(Map<String, dynamic> map, List<TargetAnalysis> analyses) {
|
||||||
return Session(
|
return Session(
|
||||||
id: map['id'] as String,
|
id: map['id'] as String,
|
||||||
targetType: TargetType.fromString(map['target_type'] as String),
|
weapon: map['weapon'] as String? ?? 'Inconnue',
|
||||||
imagePath: map['image_path'] as String,
|
weaponId: map['weapon_id'] as String?,
|
||||||
shots: shots,
|
maxShotsPerTarget: map['max_shots_per_target'] as int? ?? 5,
|
||||||
totalScore: map['total_score'] as int,
|
analyses: analyses,
|
||||||
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),
|
createdAt: DateTime.parse(map['created_at'] as String),
|
||||||
notes: map['notes'] as String?,
|
notes: map['notes'] as String?,
|
||||||
targetCenterX: map['target_center_x'] as double?,
|
distance: map['distance'] as int? ?? 25,
|
||||||
targetCenterY: map['target_center_y'] as double?,
|
|
||||||
targetRadius: map['target_radius'] as double?,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ class Shot {
|
|||||||
final double x; // Relative position (0.0 - 1.0)
|
final double x; // Relative position (0.0 - 1.0)
|
||||||
final double y; // Relative position (0.0 - 1.0)
|
final double y; // Relative position (0.0 - 1.0)
|
||||||
final int score;
|
final int score;
|
||||||
final String sessionId;
|
final String analysisId;
|
||||||
|
|
||||||
Shot({
|
Shot({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.x,
|
required this.x,
|
||||||
required this.y,
|
required this.y,
|
||||||
required this.score,
|
required this.score,
|
||||||
required this.sessionId,
|
required this.analysisId,
|
||||||
});
|
});
|
||||||
|
|
||||||
Shot copyWith({
|
Shot copyWith({
|
||||||
@@ -18,14 +18,14 @@ class Shot {
|
|||||||
double? x,
|
double? x,
|
||||||
double? y,
|
double? y,
|
||||||
int? score,
|
int? score,
|
||||||
String? sessionId,
|
String? analysisId,
|
||||||
}) {
|
}) {
|
||||||
return Shot(
|
return Shot(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
x: x ?? this.x,
|
x: x ?? this.x,
|
||||||
y: y ?? this.y,
|
y: y ?? this.y,
|
||||||
score: score ?? this.score,
|
score: score ?? this.score,
|
||||||
sessionId: sessionId ?? this.sessionId,
|
analysisId: analysisId ?? this.analysisId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class Shot {
|
|||||||
'x': x,
|
'x': x,
|
||||||
'y': y,
|
'y': y,
|
||||||
'score': score,
|
'score': score,
|
||||||
'session_id': sessionId,
|
'analysis_id': analysisId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,13 +45,13 @@ class Shot {
|
|||||||
x: (map['x'] as num).toDouble(),
|
x: (map['x'] as num).toDouble(),
|
||||||
y: (map['y'] as num).toDouble(),
|
y: (map['y'] as num).toDouble(),
|
||||||
score: map['score'] as int,
|
score: map['score'] as int,
|
||||||
sessionId: map['session_id'] as String,
|
analysisId: map['analysis_id'] as String? ?? map['session_id'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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
|
@override
|
||||||
@@ -62,11 +62,11 @@ class Shot {
|
|||||||
other.x == x &&
|
other.x == x &&
|
||||||
other.y == y &&
|
other.y == y &&
|
||||||
other.score == score &&
|
other.score == score &&
|
||||||
other.sessionId == sessionId;
|
other.analysisId == analysisId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
lib/data/models/target_analysis.dart
Normal file
113
lib/data/models/target_analysis.dart
Normal file
@@ -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<Shot> shots;
|
||||||
|
final int totalScore;
|
||||||
|
final double? groupingDiameter;
|
||||||
|
final double? groupingCenterX;
|
||||||
|
final double? groupingCenterY;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
// Target detection data
|
||||||
|
final double? targetCenterX;
|
||||||
|
final double? targetCenterY;
|
||||||
|
final double? targetRadius;
|
||||||
|
|
||||||
|
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<Shot>? 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<String, dynamic> 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<String, dynamic> map, List<Shot> 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
lib/data/models/weapon.dart
Normal file
114
lib/data/models/weapon.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import 'package:path/path.dart' as path;
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../database/database_helper.dart';
|
import '../database/database_helper.dart';
|
||||||
import '../models/session.dart';
|
import '../models/session.dart';
|
||||||
|
import '../models/target_analysis.dart';
|
||||||
import '../models/shot.dart';
|
import '../models/shot.dart';
|
||||||
import '../models/target_type.dart';
|
import '../models/weapon.dart';
|
||||||
|
import '../models/maintenance.dart';
|
||||||
|
|
||||||
class SessionRepository {
|
class SessionRepository {
|
||||||
final DatabaseHelper _databaseHelper;
|
final DatabaseHelper _databaseHelper;
|
||||||
@@ -18,8 +20,37 @@ class SessionRepository {
|
|||||||
_uuid = uuid ?? const Uuid();
|
_uuid = uuid ?? const Uuid();
|
||||||
|
|
||||||
Future<Session> createSession({
|
Future<Session> createSession({
|
||||||
required TargetType targetType,
|
String? id,
|
||||||
|
required String weapon,
|
||||||
|
String? weaponId,
|
||||||
|
required int maxShotsPerTarget,
|
||||||
|
required List<TargetAnalysis> 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<void> addAnalysisToSession(String sessionId, TargetAnalysis analysis) async {
|
||||||
|
await _databaseHelper.insertTargetAnalysis(analysis, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TargetAnalysis> prepareTargetAnalysis({
|
||||||
required String imagePath,
|
required String imagePath,
|
||||||
|
required String sessionId,
|
||||||
|
required dynamic targetType, // TargetType
|
||||||
required List<Shot> shots,
|
required List<Shot> shots,
|
||||||
required int totalScore,
|
required int totalScore,
|
||||||
double? groupingDiameter,
|
double? groupingDiameter,
|
||||||
@@ -33,8 +64,9 @@ class SessionRepository {
|
|||||||
// Copy image to app documents directory
|
// Copy image to app documents directory
|
||||||
final savedImagePath = await _saveImage(imagePath);
|
final savedImagePath = await _saveImage(imagePath);
|
||||||
|
|
||||||
final session = Session(
|
return TargetAnalysis(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
|
sessionId: sessionId,
|
||||||
targetType: targetType,
|
targetType: targetType,
|
||||||
imagePath: savedImagePath,
|
imagePath: savedImagePath,
|
||||||
shots: shots,
|
shots: shots,
|
||||||
@@ -48,9 +80,6 @@ class SessionRepository {
|
|||||||
targetCenterY: targetCenterY,
|
targetCenterY: targetCenterY,
|
||||||
targetRadius: targetRadius,
|
targetRadius: targetRadius,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _databaseHelper.insertSession(session);
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> _saveImage(String sourcePath) async {
|
Future<String> _saveImage(String sourcePath) async {
|
||||||
@@ -75,28 +104,23 @@ class SessionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Session>> getAllSessions({
|
Future<List<Session>> getAllSessions({
|
||||||
TargetType? targetType,
|
|
||||||
int? limit,
|
int? limit,
|
||||||
int? offset,
|
int? offset,
|
||||||
}) async {
|
}) async {
|
||||||
return await _databaseHelper.getAllSessions(
|
return await _databaseHelper.getAllSessions(
|
||||||
targetType: targetType?.name,
|
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateSession(Session session) async {
|
|
||||||
await _databaseHelper.updateSession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteSession(String id) async {
|
Future<void> deleteSession(String id) async {
|
||||||
final session = await getSession(id);
|
final session = await getSession(id);
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
// Delete the image file
|
for (final analysis in session.analyses) {
|
||||||
final imageFile = File(session.imagePath);
|
final imageFile = File(analysis.imagePath);
|
||||||
if (await imageFile.exists()) {
|
if (await imageFile.exists()) {
|
||||||
await imageFile.delete();
|
await imageFile.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await _databaseHelper.deleteSession(id);
|
await _databaseHelper.deleteSession(id);
|
||||||
@@ -106,7 +130,79 @@ class SessionRepository {
|
|||||||
return await _databaseHelper.getStatistics();
|
return await _databaseHelper.getStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
String generateShotId() {
|
String generateId() {
|
||||||
return _uuid.v4();
|
return _uuid.v4();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weapon Repository methods
|
||||||
|
Future<Weapon> 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<List<Weapon>> getWeapons() async {
|
||||||
|
return await _databaseHelper.getAllWeapons();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateWeapon(Weapon weapon) async {
|
||||||
|
await _databaseHelper.updateWeapon(weapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteWeapon(String id) async {
|
||||||
|
await _databaseHelper.deleteWeapon(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> getRoundsFiredForWeapon(String weaponId) async {
|
||||||
|
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<List<MaintenanceEntry>> getMaintenanceHistory(String weaponId) async {
|
||||||
|
return await _databaseHelper.getMaintenanceForWeapon(weaponId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMaintenanceEntry(String id) async {
|
||||||
|
await _databaseHelper.deleteMaintenance(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ library;
|
|||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:uuid/uuid.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/shot.dart';
|
||||||
import '../../data/models/target_type.dart';
|
import '../../data/models/target_type.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
@@ -120,6 +120,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
String imagePath,
|
String imagePath,
|
||||||
TargetType targetType, {
|
TargetType targetType, {
|
||||||
bool autoAnalyze = true,
|
bool autoAnalyze = true,
|
||||||
|
Offset? manualCenter,
|
||||||
}) async {
|
}) async {
|
||||||
_state = AnalysisState.loading;
|
_state = AnalysisState.loading;
|
||||||
_imagePath = imagePath;
|
_imagePath = imagePath;
|
||||||
@@ -138,8 +139,8 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
if (!autoAnalyze) {
|
if (!autoAnalyze) {
|
||||||
// Just setup default values without running detection
|
// Just setup default values without running detection
|
||||||
_targetCenterX = 0.5;
|
_targetCenterX = manualCenter?.dx ?? 0.5;
|
||||||
_targetCenterY = 0.5;
|
_targetCenterY = manualCenter?.dy ?? 0.5;
|
||||||
_targetRadius = 0.4;
|
_targetRadius = 0.4;
|
||||||
_targetInnerRadius = 0.04;
|
_targetInnerRadius = 0.04;
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
x: impact.x,
|
x: impact.x,
|
||||||
y: impact.y,
|
y: impact.y,
|
||||||
score: impact.suggestedScore,
|
score: impact.suggestedScore,
|
||||||
sessionId: '', // Will be set when saving
|
analysisId: '', // Will be set when saving
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -195,7 +196,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
/// Add a manual shot
|
/// Add a manual shot
|
||||||
void addShot(double x, double y) {
|
void addShot(double x, double y) {
|
||||||
final score = _calculateShotScore(x, 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);
|
_shots.add(shot);
|
||||||
_recalculateScores();
|
_recalculateScores();
|
||||||
@@ -275,7 +276,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
x: impact.x,
|
x: impact.x,
|
||||||
y: impact.y,
|
y: impact.y,
|
||||||
score: score,
|
score: score,
|
||||||
sessionId: '',
|
analysisId: '',
|
||||||
);
|
);
|
||||||
_shots.add(shot);
|
_shots.add(shot);
|
||||||
}
|
}
|
||||||
@@ -288,14 +289,6 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
|
/// 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<int> autoDetectImpactsWithOpenCV({
|
Future<int> autoDetectImpactsWithOpenCV({
|
||||||
double cannyThreshold1 = 50,
|
double cannyThreshold1 = 50,
|
||||||
double cannyThreshold2 = 150,
|
double cannyThreshold2 = 150,
|
||||||
@@ -352,7 +345,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
x: impact.x,
|
x: impact.x,
|
||||||
y: impact.y,
|
y: impact.y,
|
||||||
score: score,
|
score: score,
|
||||||
sessionId: '',
|
analysisId: '',
|
||||||
);
|
);
|
||||||
_shots.add(shot);
|
_shots.add(shot);
|
||||||
}
|
}
|
||||||
@@ -404,7 +397,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
x: impact.x,
|
x: impact.x,
|
||||||
y: impact.y,
|
y: impact.y,
|
||||||
score: score,
|
score: score,
|
||||||
sessionId: '',
|
analysisId: '',
|
||||||
);
|
);
|
||||||
_shots.add(shot);
|
_shots.add(shot);
|
||||||
}
|
}
|
||||||
@@ -419,7 +412,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
/// Add a reference impact for calibrated detection
|
/// Add a reference impact for calibrated detection
|
||||||
void addReferenceImpact(double x, double y) {
|
void addReferenceImpact(double x, double y) {
|
||||||
final score = _calculateShotScore(x, 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);
|
_referenceImpacts.add(shot);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -489,7 +482,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
x: impact.x,
|
x: impact.x,
|
||||||
y: impact.y,
|
y: impact.y,
|
||||||
score: score,
|
score: score,
|
||||||
sessionId: '',
|
analysisId: '',
|
||||||
);
|
);
|
||||||
_shots.add(shot);
|
_shots.add(shot);
|
||||||
}
|
}
|
||||||
@@ -563,7 +556,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Auto-calibration error: $e');
|
debugPrint('Auto-calibration error: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,7 +601,6 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* version deux a tester*/
|
|
||||||
/// Calcule ET applique la correction pour un feedback immédiat
|
/// Calcule ET applique la correction pour un feedback immédiat
|
||||||
Future<void> calculateAndApplyDistortion() async {
|
Future<void> calculateAndApplyDistortion() async {
|
||||||
// 1. Calcul des paramètres (votre code actuel)
|
// 1. Calcul des paramètres (votre code actuel)
|
||||||
@@ -644,7 +636,6 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* fin section deux a tester*/
|
|
||||||
|
|
||||||
int _calculateShotScore(double x, double y) {
|
int _calculateShotScore(double x, double y) {
|
||||||
if (_targetType == TargetType.concentric) {
|
if (_targetType == TargetType.concentric) {
|
||||||
@@ -697,10 +688,10 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identifiant de session temporaire si non sauvegardée
|
// Identifiant d'analyse temporaire
|
||||||
final sessionId = _shots.isNotEmpty && _shots.first.sessionId.isNotEmpty
|
final analysisId = _shots.isNotEmpty && _shots.first.analysisId.isNotEmpty
|
||||||
? _shots.first.sessionId
|
? _shots.first.analysisId
|
||||||
: 'session_${DateTime.now().millisecondsSinceEpoch}';
|
: 'analysis_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|
||||||
final service = AiExportService(); // Local instanciation for simplicity
|
final service = AiExportService(); // Local instanciation for simplicity
|
||||||
|
|
||||||
@@ -709,7 +700,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
final success = await service.exportData(
|
final success = await service.exportData(
|
||||||
imagePath: _imagePath!,
|
imagePath: _imagePath!,
|
||||||
sessionId: sessionId,
|
sessionId: 'export',
|
||||||
targetType: _targetType!,
|
targetType: _targetType!,
|
||||||
targetCenterX: _targetCenterX,
|
targetCenterX: _targetCenterX,
|
||||||
targetCenterY: _targetCenterY,
|
targetCenterY: _targetCenterY,
|
||||||
@@ -725,16 +716,23 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the session
|
/// Save the session (legacy standalone flow)
|
||||||
Future<Session> saveSession({String? notes}) async {
|
Future<TargetAnalysis> saveSession({
|
||||||
|
String? notes,
|
||||||
|
String? sessionId,
|
||||||
|
String? weaponName,
|
||||||
|
String? weaponId,
|
||||||
|
int? distance,
|
||||||
|
}) async {
|
||||||
if (_imagePath == null || _targetType == null) {
|
if (_imagePath == null || _targetType == null) {
|
||||||
throw Exception('Cannot save: missing image or target type');
|
throw Exception('Cannot save: missing image or target type');
|
||||||
}
|
}
|
||||||
|
|
||||||
final session = await _sessionRepository.createSession(
|
final targetAnalysis = await _sessionRepository.prepareTargetAnalysis(
|
||||||
targetType: _targetType!,
|
|
||||||
imagePath: _imagePath!,
|
imagePath: _imagePath!,
|
||||||
shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(),
|
sessionId: sessionId ?? 'standalone',
|
||||||
|
targetType: _targetType!,
|
||||||
|
shots: _shots,
|
||||||
totalScore: totalScore,
|
totalScore: totalScore,
|
||||||
groupingDiameter: _groupingResult?.diameter,
|
groupingDiameter: _groupingResult?.diameter,
|
||||||
groupingCenterX: _groupingResult?.centerX,
|
groupingCenterX: _groupingResult?.centerX,
|
||||||
@@ -745,11 +743,34 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
targetRadius: _targetRadius,
|
targetRadius: _targetRadius,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update shots with session ID
|
if (sessionId != null && sessionId != 'standalone') {
|
||||||
_shots = session.shots;
|
final existingSession = await _sessionRepository.getSession(sessionId);
|
||||||
notifyListeners();
|
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
|
/// Reset the provider
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,10 +40,10 @@ class TargetCalibration extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TargetCalibration> createState() => _TargetCalibrationState();
|
State<TargetCalibration> createState() => TargetCalibrationState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TargetCalibrationState extends State<TargetCalibration> {
|
class TargetCalibrationState extends State<TargetCalibration> {
|
||||||
late double _centerX;
|
late double _centerX;
|
||||||
late double _centerY;
|
late double _centerY;
|
||||||
late double _radius;
|
late double _radius;
|
||||||
@@ -66,8 +66,8 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
_initRingRadii();
|
_initRingRadii();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initRingRadii() {
|
void _initRingRadii({bool forceRecalculate = false}) {
|
||||||
if (widget.initialRingRadii != null &&
|
if (!forceRecalculate && widget.initialRingRadii != null &&
|
||||||
widget.initialRingRadii!.length == _ringCount) {
|
widget.initialRingRadii!.length == _ringCount) {
|
||||||
_ringRadii = List.from(widget.initialRingRadii!);
|
_ringRadii = List.from(widget.initialRingRadii!);
|
||||||
} else {
|
} else {
|
||||||
@@ -121,75 +121,188 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final size = constraints.biggest;
|
final size = constraints.biggest;
|
||||||
|
|
||||||
return GestureDetector(
|
return Stack(
|
||||||
onPanStart: (details) => _onPanStart(details, size),
|
children: [
|
||||||
onPanUpdate: (details) => _onPanUpdate(details, size),
|
GestureDetector(
|
||||||
onPanEnd: (_) => _onPanEnd(),
|
onPanStart: (details) => _onPanStart(details, size),
|
||||||
child: CustomPaint(
|
onPanUpdate: (details) => _onPanUpdate(details, size),
|
||||||
size: size,
|
onPanEnd: (_) => _onPanEnd(),
|
||||||
painter: _CalibrationPainter(
|
child: CustomPaint(
|
||||||
centerX: _centerX,
|
size: size,
|
||||||
centerY: _centerY,
|
painter: _CalibrationPainter(
|
||||||
radius: _radius,
|
centerX: _centerX,
|
||||||
innerRadius: _innerRadius,
|
centerY: _centerY,
|
||||||
ringCount: _ringCount,
|
radius: _radius,
|
||||||
ringRadii: _ringRadii,
|
innerRadius: _innerRadius,
|
||||||
targetType: widget.targetType,
|
ringCount: _ringCount,
|
||||||
isDraggingCenter: _isDraggingCenter,
|
ringRadii: _ringRadii,
|
||||||
isDraggingRadius: _isDraggingRadius,
|
targetType: widget.targetType,
|
||||||
isDraggingInnerRadius: _isDraggingInnerRadius,
|
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) {
|
void _onPanStart(DragStartDetails details, Size size) {
|
||||||
final tapX = details.localPosition.dx / size.width;
|
final tapX = details.localPosition.dx / size.width;
|
||||||
final tapY = details.localPosition.dy / size.height;
|
final tapY = details.localPosition.dy / size.height;
|
||||||
|
|
||||||
// Check if tapping on center handle
|
|
||||||
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
|
||||||
|
|
||||||
// Check if tapping on outer radius handle
|
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
|
||||||
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
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDraggingCenter = true;
|
_isDraggingCenter = true;
|
||||||
});
|
});
|
||||||
@@ -206,30 +319,10 @@ class _TargetCalibrationState extends State<TargetCalibration> {
|
|||||||
// Move center
|
// Move center
|
||||||
_centerX = _centerX + deltaX;
|
_centerX = _centerX + deltaX;
|
||||||
_centerY = _centerY + deltaY;
|
_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(
|
_notifyChange();
|
||||||
_centerX,
|
|
||||||
_centerY,
|
|
||||||
_innerRadius,
|
|
||||||
_radius,
|
|
||||||
_ringCount,
|
|
||||||
ringRadii: _ringRadii,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPanEnd() {
|
void _onPanEnd() {
|
||||||
@@ -288,7 +381,7 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
// Fullscreen crosshairs when dragging center
|
// Fullscreen crosshairs when dragging center
|
||||||
if (isDraggingCenter) {
|
if (isDraggingCenter) {
|
||||||
final crosshairLinePaint = Paint()
|
final crosshairLinePaint = Paint()
|
||||||
..color = AppTheme.successColor.withValues(alpha: 0.5)
|
..color = Colors.red.withValues(alpha: 0.5)
|
||||||
..strokeWidth = 1;
|
..strokeWidth = 1;
|
||||||
canvas.drawLine(
|
canvas.drawLine(
|
||||||
Offset(0, centerPx.dy),
|
Offset(0, centerPx.dy),
|
||||||
@@ -305,12 +398,6 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
// Draw center handle
|
// Draw center handle
|
||||||
_drawCenterHandle(canvas, centerPx);
|
_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
|
// Draw instructions
|
||||||
_drawInstructions(canvas, size);
|
_drawInstructions(canvas, size);
|
||||||
}
|
}
|
||||||
@@ -342,7 +429,7 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
final strokePaint = Paint()
|
final strokePaint = Paint()
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 1
|
..strokeWidth = 1
|
||||||
..color = Colors.white.withValues(alpha: 0.6);
|
..color = Colors.red.withValues(alpha: 0.8);
|
||||||
|
|
||||||
// Draw from outside to inside
|
// Draw from outside to inside
|
||||||
for (int i = ringCount - 1; i >= 0; i--) {
|
for (int i = ringCount - 1; i >= 0; i--) {
|
||||||
@@ -426,20 +513,20 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
void _drawCenterHandle(Canvas canvas, Offset center) {
|
void _drawCenterHandle(Canvas canvas, Offset center) {
|
||||||
// Outer circle
|
// Outer circle
|
||||||
final outerPaint = Paint()
|
final outerPaint = Paint()
|
||||||
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
|
..color = isDraggingCenter ? Colors.redAccent : Colors.red
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 3;
|
..strokeWidth = 3;
|
||||||
canvas.drawCircle(center, 15, outerPaint);
|
canvas.drawCircle(center, 15, outerPaint);
|
||||||
|
|
||||||
// Inner dot
|
// Inner dot
|
||||||
final innerPaint = Paint()
|
final innerPaint = Paint()
|
||||||
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
|
..color = isDraggingCenter ? Colors.redAccent : Colors.red
|
||||||
..style = PaintingStyle.fill;
|
..style = PaintingStyle.fill;
|
||||||
canvas.drawCircle(center, 5, innerPaint);
|
canvas.drawCircle(center, 5, innerPaint);
|
||||||
|
|
||||||
// Crosshair
|
// Crosshair
|
||||||
final crossPaint = Paint()
|
final crossPaint = Paint()
|
||||||
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
|
..color = isDraggingCenter ? Colors.redAccent : Colors.red
|
||||||
..strokeWidth = 2;
|
..strokeWidth = 2;
|
||||||
canvas.drawLine(
|
canvas.drawLine(
|
||||||
Offset(center.dx - 20, center.dy),
|
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) {
|
void _drawInstructions(Canvas canvas, Size size) {
|
||||||
const instruction = 'Deplacez le centre ou ajustez le rayon';
|
const instruction = 'Deplacez le centre ou ajustez le rayon';
|
||||||
|
|
||||||
@@ -630,7 +572,8 @@ class _CalibrationPainter extends CustomPainter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _CalibrationPainter oldDelegate) {
|
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||||
|
if (oldDelegate is! _CalibrationPainter) return true;
|
||||||
return centerX != oldDelegate.centerX ||
|
return centerX != oldDelegate.centerX ||
|
||||||
centerY != oldDelegate.centerY ||
|
centerY != oldDelegate.centerY ||
|
||||||
radius != oldDelegate.radius ||
|
radius != oldDelegate.radius ||
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import 'package:permission_handler/permission_handler.dart';
|
|||||||
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
|
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../data/models/target_type.dart';
|
import '../../data/models/target_type.dart';
|
||||||
import '../crop/crop_screen.dart';
|
import '../crop/crop_screen.dart';
|
||||||
|
import '../session/session_provider.dart';
|
||||||
import 'widgets/image_source_button.dart';
|
import 'widgets/image_source_button.dart';
|
||||||
|
|
||||||
class CaptureScreen extends StatefulWidget {
|
class CaptureScreen extends StatefulWidget {
|
||||||
@@ -80,8 +82,13 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final sessionProvider = context.watch<SessionProvider>();
|
||||||
|
final title = sessionProvider.isSessionActive
|
||||||
|
? 'Cible ${sessionProvider.targetCount + 1}'
|
||||||
|
: 'Source';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Source')),
|
appBar: AppBar(title: Text(title)),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -119,42 +126,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_selectedImagePath != null)
|
|
||||||
_buildImagePreview()
|
|
||||||
else
|
else
|
||||||
_buildGuide(),
|
_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<CaptureScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
Widget _buildGuide() {
|
||||||
return Card(
|
return Card(
|
||||||
@@ -283,7 +204,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
final scanner = DocumentScanner(options: options);
|
final scanner = DocumentScanner(options: options);
|
||||||
final documents = await scanner.scanDocument();
|
final documents = await scanner.scanDocument();
|
||||||
if (documents.images.isNotEmpty) {
|
if (documents.images.isNotEmpty) {
|
||||||
setState(() => _selectedImagePath = documents.images.first);
|
_selectedImagePath = documents.images.first;
|
||||||
|
_analyzeImage();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Erreur scan: $e');
|
debugPrint('Erreur scan: $e');
|
||||||
@@ -302,7 +224,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
imageQuality: 90,
|
imageQuality: 90,
|
||||||
);
|
);
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
setState(() => _selectedImagePath = image.path);
|
_selectedImagePath = image.path;
|
||||||
|
_analyzeImage();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Erreur capture: $e');
|
debugPrint('Erreur capture: $e');
|
||||||
|
|||||||
@@ -1,286 +1,317 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../data/models/target_type.dart';
|
import '../../data/models/target_type.dart';
|
||||||
import '../../services/image_crop_service.dart';
|
import '../../services/image_crop_service.dart';
|
||||||
import '../analysis/analysis_screen.dart';
|
import '../analysis/analysis_screen.dart';
|
||||||
import 'widgets/crop_overlay.dart';
|
import 'widgets/crop_overlay.dart';
|
||||||
|
|
||||||
class CropScreen extends StatefulWidget {
|
class CropScreen extends StatefulWidget {
|
||||||
final String imagePath;
|
final String imagePath;
|
||||||
final TargetType targetType;
|
final TargetType targetType;
|
||||||
|
final double? initialScale;
|
||||||
const CropScreen({
|
final Offset? initialOffset;
|
||||||
super.key,
|
|
||||||
required this.imagePath,
|
const CropScreen({
|
||||||
required this.targetType,
|
super.key,
|
||||||
});
|
required this.imagePath,
|
||||||
|
required this.targetType,
|
||||||
@override
|
this.initialScale,
|
||||||
State<CropScreen> createState() => _CropScreenState();
|
this.initialOffset,
|
||||||
}
|
});
|
||||||
|
|
||||||
class _CropScreenState extends State<CropScreen> {
|
@override
|
||||||
final ImageCropService _cropService = ImageCropService();
|
State<CropScreen> createState() => _CropScreenState();
|
||||||
|
}
|
||||||
// États de transformation
|
|
||||||
double _scale = 1.0;
|
class _CropScreenState extends State<CropScreen> {
|
||||||
double _baseScale = 1.0;
|
final ImageCropService _cropService = ImageCropService();
|
||||||
Offset _offset = Offset.zero;
|
|
||||||
Offset _startOffset = Offset.zero;
|
// États de transformation
|
||||||
Offset _startFocalPoint = Offset.zero;
|
double _scale = 1.0;
|
||||||
|
double _baseScale = 1.0;
|
||||||
bool _isLoading = false;
|
Offset _offset = Offset.zero;
|
||||||
bool _imageLoaded = false;
|
Offset _startOffset = Offset.zero;
|
||||||
Size? _imageSize;
|
Offset _startFocalPoint = Offset.zero;
|
||||||
late Size _viewportSize;
|
|
||||||
late double _cropSize;
|
bool _isLoading = false;
|
||||||
|
bool _imageLoaded = false;
|
||||||
@override
|
Size? _imageSize;
|
||||||
void initState() {
|
late Size _viewportSize;
|
||||||
super.initState();
|
late double _cropSize;
|
||||||
_loadImageInfo();
|
|
||||||
}
|
@override
|
||||||
|
void initState() {
|
||||||
Future<void> _loadImageInfo() async {
|
super.initState();
|
||||||
final file = File(widget.imagePath);
|
_loadImageInfo();
|
||||||
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
}
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
Future<void> _loadImageInfo() async {
|
||||||
_imageSize = Size(
|
final file = File(widget.imagePath);
|
||||||
decodedImage.width.toDouble(),
|
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
||||||
decodedImage.height.toDouble(),
|
if (mounted) {
|
||||||
);
|
setState(() {
|
||||||
_imageLoaded = true;
|
_imageSize = Size(
|
||||||
});
|
decodedImage.width.toDouble(),
|
||||||
}
|
decodedImage.height.toDouble(),
|
||||||
}
|
);
|
||||||
|
_imageLoaded = true;
|
||||||
@override
|
});
|
||||||
Widget build(BuildContext context) {
|
}
|
||||||
return Scaffold(
|
}
|
||||||
backgroundColor: const Color(0xFF101214),
|
|
||||||
appBar: AppBar(
|
@override
|
||||||
backgroundColor: const Color(0xFF101214),
|
Widget build(BuildContext context) {
|
||||||
elevation: 0,
|
return Scaffold(
|
||||||
centerTitle: true,
|
backgroundColor: const Color(0xFF101214),
|
||||||
title: const Text(
|
appBar: AppBar(
|
||||||
'Validation image',
|
backgroundColor: const Color(0xFF101214),
|
||||||
style: TextStyle(color: Colors.white, fontSize: 18),
|
elevation: 0,
|
||||||
),
|
centerTitle: true,
|
||||||
leading: IconButton(
|
title: const Text(
|
||||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
'Centrage de la cible',
|
||||||
onPressed: () => Navigator.pop(context),
|
style: TextStyle(color: Colors.white, fontSize: 18),
|
||||||
),
|
),
|
||||||
),
|
leading: IconButton(
|
||||||
body: _isLoading
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
onPressed: () => Navigator.pop(context),
|
||||||
: Column(
|
),
|
||||||
children: [
|
),
|
||||||
// Zone interactive de crop
|
body: _isLoading
|
||||||
Expanded(
|
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||||||
child: Container(
|
: Column(
|
||||||
margin: const EdgeInsets.all(20),
|
children: [
|
||||||
decoration: BoxDecoration(
|
// Zone interactive de crop
|
||||||
borderRadius: BorderRadius.circular(12),
|
Expanded(
|
||||||
border: Border.all(color: Colors.white10),
|
child: Container(
|
||||||
),
|
margin: const EdgeInsets.all(20),
|
||||||
child: ClipRRect(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
|
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,
|
// Bouton d'aide / Modifier
|
||||||
children: [
|
Padding(
|
||||||
const Icon(Icons.touch_app, color: Colors.white54, size: 20),
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
const SizedBox(width: 8),
|
child: Column(
|
||||||
Text(
|
children: [
|
||||||
'Déplacez et zoomez pour cadrer',
|
Row(
|
||||||
style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14),
|
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',
|
||||||
// Boutons du bas
|
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
|
],
|
||||||
child: Row(
|
),
|
||||||
children: [
|
const SizedBox(height: 4),
|
||||||
Expanded(
|
Text(
|
||||||
child: OutlinedButton(
|
'Zoomez pour plus de précision',
|
||||||
onPressed: () => Navigator.pop(context),
|
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
|
||||||
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)),
|
// Boutons du bas
|
||||||
),
|
Padding(
|
||||||
),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
|
||||||
const SizedBox(width: 16),
|
child: Row(
|
||||||
Expanded(
|
children: [
|
||||||
child: ElevatedButton(
|
Expanded(
|
||||||
onPressed: _onCropConfirm,
|
child: OutlinedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
onPressed: () => Navigator.pop(context),
|
||||||
backgroundColor: const Color(0xFF1A73E8),
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: Colors.white,
|
side: const BorderSide(color: Colors.white24),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
),
|
),
|
||||||
child: const Text('Valider', style: TextStyle(fontWeight: FontWeight.bold)),
|
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),
|
||||||
Widget _buildInteractiveCrop() {
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
return LayoutBuilder(
|
),
|
||||||
builder: (context, constraints) {
|
child: const Text('Analyser', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
_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(
|
Widget _buildInteractiveCrop() {
|
||||||
children: [
|
return LayoutBuilder(
|
||||||
Positioned.fill(
|
builder: (context, constraints) {
|
||||||
child: Center(
|
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||||
child: Transform(
|
|
||||||
transform: Matrix4.identity()
|
// Le carré de crop occupe presque tout l'espace disponible
|
||||||
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.95;
|
||||||
..scale(_scale, _scale),
|
|
||||||
alignment: Alignment.center,
|
if (_scale == 1.0 && _offset == Offset.zero) {
|
||||||
child: Image.file(
|
_initializeImagePosition();
|
||||||
File(widget.imagePath),
|
}
|
||||||
fit: BoxFit.contain,
|
|
||||||
width: _viewportSize.width,
|
return GestureDetector(
|
||||||
height: _viewportSize.height,
|
onScaleStart: _onScaleStart,
|
||||||
),
|
onScaleUpdate: _onScaleUpdate,
|
||||||
),
|
child: Stack(
|
||||||
),
|
children: [
|
||||||
),
|
Positioned.fill(
|
||||||
Positioned.fill(
|
child: Center(
|
||||||
child: IgnorePointer(
|
child: Transform(
|
||||||
child: CropOverlay(cropSize: _cropSize, showGrid: true),
|
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,
|
||||||
|
),
|
||||||
void _initializeImagePosition() {
|
),
|
||||||
if (_imageSize == null) return;
|
),
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
),
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
double displayWidth, displayHeight;
|
child: CropOverlay(cropSize: _cropSize, showGrid: false),
|
||||||
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;
|
void _initializeImagePosition() {
|
||||||
if (_scale < 1.0) _scale = 1.0;
|
if (_imageSize == null) return;
|
||||||
}
|
|
||||||
|
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||||
void _onScaleStart(ScaleStartDetails details) {
|
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||||
_baseScale = _scale;
|
|
||||||
_startFocalPoint = details.focalPoint;
|
double displayWidth, displayHeight;
|
||||||
_startOffset = _offset;
|
if (imageAspect > viewportAspect) {
|
||||||
}
|
displayWidth = _viewportSize.width;
|
||||||
|
displayHeight = _viewportSize.width / imageAspect;
|
||||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
} else {
|
||||||
setState(() {
|
displayHeight = _viewportSize.height;
|
||||||
_scale = (_baseScale * details.scale).clamp(0.5, 5.0);
|
displayWidth = _viewportSize.height * imageAspect;
|
||||||
final delta = details.focalPoint - _startFocalPoint;
|
}
|
||||||
_offset = _startOffset + delta;
|
|
||||||
});
|
final minDisplayDim = math.min(displayWidth, displayHeight);
|
||||||
}
|
|
||||||
|
_scale = widget.initialScale ?? (_cropSize / minDisplayDim);
|
||||||
Future<void> _onCropConfirm() async {
|
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
|
||||||
setState(() => _isLoading = true);
|
|
||||||
try {
|
if (widget.initialOffset != null) {
|
||||||
final cropRect = _calculateCropRect();
|
_offset = widget.initialOffset!;
|
||||||
final croppedPath = await _cropService.cropToSquare(widget.imagePath, cropRect);
|
}
|
||||||
if (!mounted) return;
|
}
|
||||||
Navigator.pushReplacement(
|
|
||||||
context,
|
void _onScaleStart(ScaleStartDetails details) {
|
||||||
MaterialPageRoute(
|
_baseScale = _scale;
|
||||||
builder: (_) => AnalysisScreen(
|
_startFocalPoint = details.focalPoint;
|
||||||
imagePath: croppedPath,
|
_startOffset = _offset;
|
||||||
targetType: widget.targetType,
|
}
|
||||||
),
|
|
||||||
),
|
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||||
);
|
setState(() {
|
||||||
} catch (e) {
|
_scale = (_baseScale * details.scale).clamp(0.8, 8.0);
|
||||||
if (mounted) {
|
final delta = details.focalPoint - _startFocalPoint;
|
||||||
setState(() => _isLoading = false);
|
_offset = _startOffset + delta;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
});
|
||||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
|
}
|
||||||
);
|
|
||||||
}
|
Future<void> _onCropConfirm() async {
|
||||||
}
|
setState(() => _isLoading = true);
|
||||||
}
|
try {
|
||||||
|
final cropRect = _calculateCropRect();
|
||||||
CropRect _calculateCropRect() {
|
// On calcule le centre relatif basé sur le centrage utilisateur
|
||||||
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
final targetCenterX = cropRect.x + cropRect.width / 2;
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
final targetCenterY = cropRect.y + cropRect.height / 2;
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
|
||||||
|
if (!mounted) return;
|
||||||
double displayWidth, displayHeight;
|
Navigator.pushReplacement(
|
||||||
if (imageAspect > viewportAspect) {
|
context,
|
||||||
displayWidth = _viewportSize.width;
|
MaterialPageRoute(
|
||||||
displayHeight = _viewportSize.width / imageAspect;
|
builder: (_) => AnalysisScreen(
|
||||||
} else {
|
imagePath: widget.imagePath,
|
||||||
displayHeight = _viewportSize.height;
|
targetType: widget.targetType,
|
||||||
displayWidth = _viewportSize.height * imageAspect;
|
targetCenter: Offset(targetCenterX, targetCenterY),
|
||||||
}
|
cropScale: _scale,
|
||||||
|
cropOffset: _offset,
|
||||||
final scaledWidth = displayWidth * _scale;
|
),
|
||||||
final scaledHeight = displayHeight * _scale;
|
),
|
||||||
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
);
|
||||||
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
} catch (e) {
|
||||||
final imageLeft = imageCenterX - scaledWidth / 2;
|
if (mounted) {
|
||||||
final imageTop = imageCenterY - scaledHeight / 2;
|
setState(() => _isLoading = false);
|
||||||
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
|
||||||
|
);
|
||||||
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
|
}
|
||||||
final relCropTop = (cropTop - imageTop) / scaledHeight;
|
}
|
||||||
final relCropSize = _cropSize / scaledWidth;
|
}
|
||||||
final relCropSizeY = _cropSize / scaledHeight;
|
|
||||||
|
CropRect _calculateCropRect() {
|
||||||
return CropRect(
|
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
||||||
x: relCropLeft.clamp(0.0, 1.0),
|
|
||||||
y: relCropTop.clamp(0.0, 1.0),
|
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||||
width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)),
|
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||||
height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)),
|
|
||||||
);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
414
lib/features/garage/weapon_detail_screen.dart
Normal file
414
lib/features/garage/weapon_detail_screen.dart
Normal file
@@ -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<WeaponDetailScreen> createState() => _WeaponDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||||
|
late Weapon _weapon;
|
||||||
|
List<MaintenanceEntry> _maintenance = [];
|
||||||
|
int _totalRounds = 0;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_weapon = widget.weapon;
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
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<bool>(
|
||||||
|
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<WeaponType>(
|
||||||
|
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<SessionRepository>();
|
||||||
|
await repository.updateWeapon(updatedWeapon);
|
||||||
|
setState(() {
|
||||||
|
_weapon = updatedWeapon;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddMaintenanceDialog(BuildContext context) async {
|
||||||
|
final descController = TextEditingController();
|
||||||
|
MaintenanceType selectedType = MaintenanceType.cleaning;
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) => AlertDialog(
|
||||||
|
title: const Text('Ajouter un entretien'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<MaintenanceType>(
|
||||||
|
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<SessionRepository>();
|
||||||
|
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<bool>(
|
||||||
|
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<SessionRepository>();
|
||||||
|
await repository.deleteMaintenanceEntry(entry.id);
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
208
lib/features/garage/weapon_list_screen.dart
Normal file
208
lib/features/garage/weapon_list_screen.dart
Normal file
@@ -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<WeaponListScreen> createState() => _WeaponListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||||
|
List<Weapon> _weapons = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadWeapons() async {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
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<bool>(
|
||||||
|
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<WeaponType>(
|
||||||
|
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<SessionRepository>();
|
||||||
|
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<bool>(
|
||||||
|
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<SessionRepository>();
|
||||||
|
await repository.deleteWeapon(weapon.id);
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final sessions = await repository.getAllSessions(targetType: _filterType);
|
final sessions = await repository.getAllSessions();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -95,12 +95,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
locale: const Locale('fr', 'FR'),
|
locale: const Locale('fr', 'FR'),
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return Theme(
|
return Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: ThemeData.light().copyWith(
|
||||||
colorScheme: ColorScheme.dark(
|
colorScheme: ColorScheme.light(
|
||||||
primary: AppTheme.primaryColor,
|
primary: AppTheme.primaryColor, // En-tête et sélection
|
||||||
onPrimary: Colors.white,
|
onPrimary: Colors.white, // Texte sur en-tête/sélection
|
||||||
surface: Theme.of(context).cardColor,
|
surface: Colors.white, // Fond du calendrier
|
||||||
onSurface: Colors.white,
|
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!,
|
child: child!,
|
||||||
|
|||||||
@@ -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 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -12,6 +5,8 @@ import 'package:intl/intl.dart';
|
|||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../data/models/session.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 '../../data/repositories/session_repository.dart';
|
||||||
import '../../services/score_calculator_service.dart';
|
import '../../services/score_calculator_service.dart';
|
||||||
import '../../services/grouping_analyzer_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 '../analysis/widgets/grouping_stats.dart';
|
||||||
import '../statistics/statistics_screen.dart';
|
import '../statistics/statistics_screen.dart';
|
||||||
|
|
||||||
class SessionDetailScreen extends StatelessWidget {
|
class SessionDetailScreen extends StatefulWidget {
|
||||||
final Session session;
|
final Session session;
|
||||||
|
|
||||||
const SessionDetailScreen({
|
const SessionDetailScreen({
|
||||||
@@ -28,33 +23,40 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
required this.session,
|
required this.session,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SessionDetailScreen> createState() => _SessionDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionDetailScreenState extends State<SessionDetailScreen> {
|
||||||
|
int _currentTargetIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scoreCalculator = context.read<ScoreCalculatorService>();
|
final scoreCalculator = context.read<ScoreCalculatorService>();
|
||||||
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
|
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
|
||||||
|
|
||||||
|
final currentAnalysis = widget.session.analyses[_currentTargetIndex];
|
||||||
|
|
||||||
final scoreResult = scoreCalculator.calculateScores(
|
final scoreResult = scoreCalculator.calculateScores(
|
||||||
shots: session.shots,
|
shots: currentAnalysis.shots,
|
||||||
targetType: session.targetType,
|
targetType: currentAnalysis.targetType,
|
||||||
targetCenterX: session.targetCenterX ?? 0.5,
|
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
|
||||||
targetCenterY: session.targetCenterY ?? 0.5,
|
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
|
||||||
targetRadius: session.targetRadius ?? 0.4,
|
targetRadius: currentAnalysis.targetRadius ?? 0.4,
|
||||||
);
|
);
|
||||||
|
|
||||||
final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots);
|
final groupingResult = groupingAnalyzer.analyzeGrouping(currentAnalysis.shots);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(widget.session.weapon),
|
||||||
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.analytics),
|
icon: const Icon(Icons.analytics),
|
||||||
onPressed: () => Navigator.push(
|
onPressed: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => StatisticsScreen(singleSession: session),
|
builder: (_) => StatisticsScreen(singleSession: widget.session),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tooltip: 'Statistiques',
|
tooltip: 'Statistiques',
|
||||||
@@ -70,15 +72,22 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
// Weapon and session overview
|
||||||
|
_buildSessionHeader(context),
|
||||||
|
|
||||||
|
// Target selector if multiple targets
|
||||||
|
if (widget.session.targetCount > 1)
|
||||||
|
_buildTargetSelector(),
|
||||||
|
|
||||||
// Target image with overlay
|
// Target image with overlay
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (File(session.imagePath).existsSync())
|
if (File(currentAnalysis.imagePath).existsSync())
|
||||||
Image.file(
|
Image.file(
|
||||||
File(session.imagePath),
|
File(currentAnalysis.imagePath),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -89,14 +98,14 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TargetOverlay(
|
TargetOverlay(
|
||||||
shots: session.shots,
|
shots: currentAnalysis.shots,
|
||||||
targetCenterX: session.targetCenterX ?? 0.5,
|
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
|
||||||
targetCenterY: session.targetCenterY ?? 0.5,
|
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
|
||||||
targetRadius: session.targetRadius ?? 0.4,
|
targetRadius: currentAnalysis.targetRadius ?? 0.4,
|
||||||
targetType: session.targetType,
|
targetType: currentAnalysis.targetType,
|
||||||
groupingCenterX: session.groupingCenterX,
|
groupingCenterX: currentAnalysis.groupingCenterX,
|
||||||
groupingCenterY: session.groupingCenterY,
|
groupingCenterY: currentAnalysis.groupingCenterY,
|
||||||
groupingDiameter: session.groupingDiameter,
|
groupingDiameter: currentAnalysis.groupingDiameter,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -107,31 +116,37 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Session info
|
// Analysis Info
|
||||||
_buildSessionInfo(context),
|
_buildAnalysisInfo(context, currentAnalysis),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Score card
|
// Score card for current target
|
||||||
ScoreCard(
|
ScoreCard(
|
||||||
totalScore: session.totalScore,
|
totalScore: currentAnalysis.totalScore,
|
||||||
shotCount: session.shotCount,
|
shotCount: currentAnalysis.shotCount,
|
||||||
scoreResult: scoreResult,
|
scoreResult: scoreResult,
|
||||||
targetType: session.targetType,
|
targetType: currentAnalysis.targetType,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Grouping stats
|
// Grouping stats for current target
|
||||||
if (session.shotCount > 1)
|
if (currentAnalysis.shotCount > 1)
|
||||||
GroupingStats(
|
GroupingStats(
|
||||||
groupingResult: groupingResult,
|
groupingResult: groupingResult,
|
||||||
targetCenterX: session.targetCenterX ?? 0.5,
|
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
|
||||||
targetCenterY: session.targetCenterY ?? 0.5,
|
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Notes
|
// Notes for current target
|
||||||
if (session.notes != null && session.notes!.isNotEmpty) ...[
|
if (currentAnalysis.notes != null && currentAnalysis.notes!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
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(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
session.targetType == session.targetType
|
analysis.targetType == TargetType.concentric ? Icons.track_changes : Icons.person,
|
||||||
? Icons.track_changes
|
|
||||||
: Icons.person,
|
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.primaryColor,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -160,14 +242,11 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
session.targetType.displayName,
|
analysis.targetType.displayName,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR')
|
'Cible ${_currentTargetIndex + 1} sur ${widget.session.targetCount}',
|
||||||
.format(session.createdAt),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
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(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
@@ -188,18 +267,16 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.notes, color: AppTheme.primaryColor),
|
const Icon(Icons.notes, color: AppTheme.primaryColor, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Notes',
|
title,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Text(session.notes!),
|
Text(notes),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -211,7 +288,7 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Supprimer'),
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
@@ -231,20 +308,17 @@ class SessionDetailScreen extends StatelessWidget {
|
|||||||
if (confirmed == true && context.mounted) {
|
if (confirmed == true && context.mounted) {
|
||||||
try {
|
try {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.deleteSession(session.id);
|
await repository.deleteSession(widget.session.id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Session supprimee')),
|
const SnackBar(content: Text('Session supprimée')),
|
||||||
);
|
);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
|
||||||
content: Text('Erreur: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class HistoryChart extends StatelessWidget {
|
|||||||
return touchedSpots.map((spot) {
|
return touchedSpots.map((spot) {
|
||||||
final session = displaySessions[spot.x.toInt()];
|
final session = displaySessions[spot.x.toInt()];
|
||||||
return LineTooltipItem(
|
return LineTooltipItem(
|
||||||
'Score: ${session.totalScore}\n${session.shotCount} tirs',
|
'Score: ${session.totalScore}\n${session.totalShots} tirs',
|
||||||
const TextStyle(
|
const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|||||||
@@ -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 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../data/models/session.dart';
|
import '../../../data/models/session.dart';
|
||||||
import '../../../data/models/target_type.dart';
|
|
||||||
|
|
||||||
class SessionListItem extends StatelessWidget {
|
class SessionListItem extends StatelessWidget {
|
||||||
final Session session;
|
final Session session;
|
||||||
@@ -33,7 +26,7 @@ class SessionListItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Thumbnail
|
// Thumbnail (from first target)
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@@ -51,14 +44,14 @@ class SessionListItem extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
_getTargetIcon(),
|
Icons.shield,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.primaryColor,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
session.targetType.displayName,
|
session.weapon,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -70,18 +63,19 @@ class SessionListItem extends StatelessWidget {
|
|||||||
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
if (session.notes != null && session.notes!.isNotEmpty) ...[
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 2),
|
Row(
|
||||||
Text(
|
children: [
|
||||||
session.notes!,
|
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
const SizedBox(width: 4),
|
||||||
color: Colors.grey[600],
|
Text(
|
||||||
fontStyle: FontStyle.italic,
|
'${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(
|
Text(
|
||||||
'${session.shotCount} tirs',
|
'${session.totalShots} tirs',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -120,7 +114,9 @@ class SessionListItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildThumbnail() {
|
Widget _buildThumbnail() {
|
||||||
final file = File(session.imagePath);
|
if (session.analyses.isEmpty) return _buildPlaceholder();
|
||||||
|
|
||||||
|
final file = File(session.analyses.first.imagePath);
|
||||||
|
|
||||||
if (file.existsSync()) {
|
if (file.existsSync()) {
|
||||||
return Image.file(
|
return Image.file(
|
||||||
@@ -137,18 +133,9 @@ class SessionListItem extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
color: Colors.grey[200],
|
color: Colors.grey[200],
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_getTargetIcon(),
|
Icons.track_changes,
|
||||||
color: Colors.grey[400],
|
color: Colors.grey[400],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _getTargetIcon() {
|
|
||||||
switch (session.targetType) {
|
|
||||||
case TargetType.concentric:
|
|
||||||
return Icons.track_changes;
|
|
||||||
case TargetType.silhouette:
|
|
||||||
return Icons.person;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import '../statistics/statistics_screen.dart';
|
|||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import '../../data/models/session.dart';
|
import '../../data/models/session.dart';
|
||||||
import '../settings/settings_screen.dart';
|
import '../settings/settings_screen.dart';
|
||||||
|
import '../session/session_setup_screen.dart';
|
||||||
|
import '../session/session_provider.dart';
|
||||||
import 'widgets/stats_card.dart';
|
import 'widgets/stats_card.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
@@ -47,7 +49,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
// --- MODIFICATION 1 : AJOUT DE LA VERSION À GAUCHE ---
|
|
||||||
leading: const Center(
|
leading: const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'v1.0.2',
|
'v1.0.2',
|
||||||
@@ -56,16 +57,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
title: const Text('Bully'),
|
title: const Text('Bully'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.analytics),
|
|
||||||
onPressed: () => _navigateToStatistics(context),
|
|
||||||
tooltip: 'Statistiques',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.history),
|
|
||||||
onPressed: () => _navigateToHistory(context),
|
|
||||||
tooltip: 'Historique',
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
onPressed: () => _navigateToSettings(context),
|
onPressed: () => _navigateToSettings(context),
|
||||||
@@ -81,15 +72,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// App logo/header
|
|
||||||
_buildHeader(),
|
_buildHeader(),
|
||||||
const SizedBox(height: AppConstants.largePadding),
|
const SizedBox(height: AppConstants.largePadding),
|
||||||
|
|
||||||
// Main action button
|
|
||||||
_buildMainActionButton(context),
|
_buildMainActionButton(context),
|
||||||
const SizedBox(height: AppConstants.largePadding),
|
const SizedBox(height: AppConstants.largePadding),
|
||||||
|
|
||||||
// Statistics section
|
|
||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
const Center(child: CircularProgressIndicator())
|
const Center(child: CircularProgressIndicator())
|
||||||
else if (_stats != null)
|
else if (_stats != null)
|
||||||
@@ -107,7 +93,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
@@ -136,20 +122,23 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMainActionButton(BuildContext context) {
|
Widget _buildMainActionButton(BuildContext context) {
|
||||||
|
final sessionProvider = context.watch<SessionProvider>();
|
||||||
|
final isSessionActive = sessionProvider.isSessionActive;
|
||||||
|
|
||||||
return ElevatedButton.icon(
|
return ElevatedButton.icon(
|
||||||
onPressed: () => _navigateToCapture(context),
|
onPressed: () => _navigateToSessionSetup(context),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.primaryColor,
|
backgroundColor: isSessionActive ? AppTheme.secondaryColor : AppTheme.primaryColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.add_a_photo, size: 28),
|
icon: Icon(isSessionActive ? Icons.play_circle_outline : Icons.add_circle_outline, size: 28),
|
||||||
label: const Text(
|
label: Text(
|
||||||
'Nouvelle Analyse',
|
isSessionActive ? 'Continuer la session' : 'Démarrer la session',
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,8 +154,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Première ligne de vignettes (Sessions et Tirs)
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -192,19 +179,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- MODIFICATION 2 : GRAPHIQUE RÉEL (Restauré) ---
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 150,
|
height: 150,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Theme.of(context).cardColor,
|
||||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.05),
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
@@ -226,8 +211,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
: _buildHomeTrendChart(),
|
: _buildHomeTrendChart(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Deuxième ligne de vignettes (Historique et Meilleur)
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -257,13 +240,19 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MÉTHODES DE NAVIGATION ---
|
void _navigateToSessionSetup(BuildContext context) async {
|
||||||
|
final sessionProvider = context.read<SessionProvider>();
|
||||||
void _navigateToCapture(BuildContext context) async {
|
if (sessionProvider.isSessionActive) {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const CaptureScreen()),
|
MaterialPageRoute(builder: (_) => const CaptureScreen()),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const SessionSetupScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
_loadStats();
|
_loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,10 +272,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_loadStats();
|
_loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WIDGET DU GRAPHIQUE D'ACCUEIL ---
|
|
||||||
|
|
||||||
Widget _buildHomeTrendChart() {
|
Widget _buildHomeTrendChart() {
|
||||||
// Trier par date et prendre les 7 dernières
|
|
||||||
final sorted = List<Session>.from(_recentSessions)
|
final sorted = List<Session>.from(_recentSessions)
|
||||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
final display =
|
final display =
|
||||||
@@ -294,22 +280,84 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
return LineChart(
|
return LineChart(
|
||||||
LineChartData(
|
LineChartData(
|
||||||
gridData: const FlGridData(show: false),
|
gridData: FlGridData(
|
||||||
titlesData: const FlTitlesData(show: false),
|
show: true,
|
||||||
borderData: FlBorderData(show: false),
|
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: [
|
lineBarsData: [
|
||||||
LineChartBarData(
|
LineChartBarData(
|
||||||
spots: display.asMap().entries.map((e) {
|
spots: display.asMap().entries.map((e) {
|
||||||
return FlSpot(e.key.toDouble(), e.value.totalScore.toDouble());
|
return FlSpot(e.key.toDouble(), e.value.averageScore);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
isCurved: true,
|
isCurved: true,
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.primaryColor,
|
||||||
barWidth: 4,
|
barWidth: 3,
|
||||||
isStrokeCapRound: true,
|
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(
|
belowBarData: BarAreaData(
|
||||||
show: true,
|
show: true,
|
||||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -317,10 +365,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToSettings(BuildContext context) {
|
void _navigateToSettings(BuildContext context) async {
|
||||||
Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||||
);
|
);
|
||||||
|
_loadStats();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
lib/features/session/session_provider.dart
Normal file
51
lib/features/session/session_provider.dart
Normal file
@@ -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<TargetAnalysis> _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<TargetAnalysis> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
259
lib/features/session/session_setup_screen.dart
Normal file
259
lib/features/session/session_setup_screen.dart
Normal file
@@ -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<SessionSetupScreen> createState() => _SessionSetupScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _weaponController = TextEditingController();
|
||||||
|
int _shotsPerTarget = 5;
|
||||||
|
int _distance = 25;
|
||||||
|
List<Weapon> _availableWeapons = [];
|
||||||
|
Weapon? _selectedWeapon;
|
||||||
|
bool _isLoadingWeapons = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadWeapons();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadWeapons() async {
|
||||||
|
final repository = context.read<SessionRepository>();
|
||||||
|
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<SessionRepository>();
|
||||||
|
final sessionProvider = context.read<SessionProvider>();
|
||||||
|
|
||||||
|
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<Weapon?>(
|
||||||
|
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<Weapon?>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../../core/theme/theme_provider.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../services/wallet_identity_service.dart';
|
import '../../services/wallet_identity_service.dart';
|
||||||
|
import '../garage/weapon_list_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -81,6 +84,49 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showThemeDialog() {
|
||||||
|
final themeProvider = context.read<ThemeProvider>();
|
||||||
|
|
||||||
|
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<ThemeProvider>();
|
||||||
|
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() {
|
void _showIdentityDialog() {
|
||||||
if (_identityPhrase == null) return;
|
if (_identityPhrase == null) return;
|
||||||
|
|
||||||
@@ -218,14 +264,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
|
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
|
||||||
onTap: _showIdentityDialog,
|
onTap: _showIdentityDialog,
|
||||||
),
|
),
|
||||||
|
Consumer<ThemeProvider>(
|
||||||
|
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(
|
_buildSettingsTile(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.color_lens_outlined,
|
icon: Icons.shield_outlined,
|
||||||
title: 'Apparence',
|
title: 'Mon Armurerie',
|
||||||
subtitle: 'Thème clair/sombre (à venir)',
|
subtitle: 'Gérer mes armes et équipements',
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
await Navigator.push(
|
||||||
const SnackBar(content: Text('Fonctionnalité en développement')),
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui' as ui;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
|
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
|
||||||
@@ -24,6 +26,8 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
// Valeurs pour les Dropdowns (Filtres)
|
// Valeurs pour les Dropdowns (Filtres)
|
||||||
String _selectedWeapon = 'Toutes';
|
String _selectedWeapon = 'Toutes';
|
||||||
String _selectedDistance = 'Toutes';
|
String _selectedDistance = 'Toutes';
|
||||||
|
List<String> _availableWeapons = ['Toutes'];
|
||||||
|
List<String> _availableDistances = ['Toutes'];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -37,6 +41,35 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
try {
|
try {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
_allSessions = await repository.getAllSessions();
|
_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();
|
_calculateStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error: $e');
|
debugPrint('Error: $e');
|
||||||
@@ -46,8 +79,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _calculateStats() {
|
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(
|
_statistics = _statisticsService.calculateStatistics(
|
||||||
_allSessions,
|
filteredSessions,
|
||||||
period: _selectedPeriod,
|
period: _selectedPeriod,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,46 +108,57 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
final sortedSessions = List<Session>.from(_statistics!.sessions)
|
final sortedSessions = List<Session>.from(_statistics!.sessions)
|
||||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
return sortedSessions.map((s) {
|
return sortedSessions.map((s) {
|
||||||
if (s.shots.isEmpty) return 0.0;
|
if (s.totalShots == 0) return 0.0;
|
||||||
// Simple precision proxy: score / max possible (assuming 10 is max)
|
// Simple precision proxy: total score / max possible (assuming 10 is max per shot)
|
||||||
return (s.totalScore / (s.shots.length * 10)) * 100;
|
return (s.totalScore / (s.totalShots * 10)) * 100;
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(
|
|
||||||
0xFF121212,
|
|
||||||
), // Fond sombre comme sur le design
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
title: const Text('Statistiques'),
|
||||||
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),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: SingleChildScrollView(
|
: RefreshIndicator(
|
||||||
padding: const EdgeInsets.all(16),
|
onRefresh: _loadStatistics,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 1. FILTRES (Arme et Distance)
|
// 1. FILTRES (Arme et Distance)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildDropdown('Arme utilisée', _selectedWeapon),
|
child: _buildDropdown(
|
||||||
|
'Arme utilisée',
|
||||||
|
_selectedWeapon,
|
||||||
|
_availableWeapons,
|
||||||
|
(val) {
|
||||||
|
setState(() {
|
||||||
|
_selectedWeapon = val!;
|
||||||
|
_calculateStats();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildDropdown('Distance', _selectedDistance),
|
child: _buildDropdown(
|
||||||
|
'Distance',
|
||||||
|
_selectedDistance,
|
||||||
|
_availableDistances,
|
||||||
|
(val) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDistance = val!;
|
||||||
|
_calculateStats();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -162,71 +213,55 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildQuadrantGrid(),
|
_buildQuadrantGrid(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
_buildHeatMapSection(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
if (_statistics!.regional.biasX.abs() > 0.05 ||
|
if (_statistics!.regional.biasX.abs() > 0.05 ||
|
||||||
_statistics!.regional.biasY.abs() > 0.05)
|
_statistics!.regional.biasY.abs() > 0.05)
|
||||||
_buildBiasWarning(),
|
_buildBiasWarning(),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 30),
|
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(
|
||||||
Widget _buildDropdown(String label, String value) {
|
String label,
|
||||||
|
String value,
|
||||||
|
List<String> items,
|
||||||
|
void Function(String?) onChanged,
|
||||||
|
) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1E1E1E),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: Colors.white12),
|
border: Border.all(color: theme.dividerColor),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 10),
|
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 10),
|
||||||
),
|
),
|
||||||
DropdownButton<String>(
|
DropdownButton<String>(
|
||||||
value: value,
|
value: value,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
underline: Container(),
|
underline: Container(),
|
||||||
dropdownColor: const Color(0xFF1E1E1E),
|
dropdownColor: theme.colorScheme.surfaceContainerHighest,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14),
|
||||||
items: [value]
|
items: items
|
||||||
.map(
|
.map(
|
||||||
(String val) =>
|
(String val) =>
|
||||||
DropdownMenuItem(value: val, child: Text(val)),
|
DropdownMenuItem(value: val, child: Text(val)),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (newValue) {},
|
onChanged: onChanged,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -235,10 +270,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
|
|
||||||
// Widget pour les petites cartes de stats
|
// Widget pour les petites cartes de stats
|
||||||
Widget _buildQuickStat(String label, String value, IconData icon) {
|
Widget _buildQuickStat(String label, String value, IconData icon) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1E1E1E),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -247,15 +283,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: theme.textTheme.titleLarge?.color,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
label,
|
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<StatisticsScreen> {
|
|||||||
String value,
|
String value,
|
||||||
List<double> dataPoints,
|
List<double> dataPoints,
|
||||||
) {
|
) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1E1E1E),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -279,15 +316,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
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),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: theme.textTheme.headlineMedium?.color,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -328,7 +365,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
dotData: const FlDotData(show: false),
|
dotData: const FlDotData(show: false),
|
||||||
belowBarData: BarAreaData(
|
belowBarData: BarAreaData(
|
||||||
show: true,
|
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<StatisticsScreen> {
|
|||||||
|
|
||||||
Widget _buildRegionalDistribution() {
|
Widget _buildRegionalDistribution() {
|
||||||
final regional = _statistics!.regional;
|
final regional = _statistics!.regional;
|
||||||
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1E1E1E),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Distribution Régionale',
|
'Distribution Régionale',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: theme.textTheme.titleMedium?.color,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -363,7 +401,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Direction dominante : ${regional.dominantDirection}',
|
'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<StatisticsScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1A73E8).withOpacity(0.1),
|
color: const Color(0xFF1A73E8).withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(20),
|
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(
|
child: Text(
|
||||||
'${e.key}: ${e.value} (${percentage.toStringAsFixed(0)}%)',
|
'${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(),
|
}).toList(),
|
||||||
@@ -396,19 +434,20 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
|
|
||||||
Widget _buildQuadrantGrid() {
|
Widget _buildQuadrantGrid() {
|
||||||
final quadrants = _statistics!.regional.quadrantDistribution;
|
final quadrants = _statistics!.regional.quadrantDistribution;
|
||||||
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1E1E1E),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Répartition par Quadrant',
|
'Répartition par Quadrant',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: theme.textTheme.titleMedium?.color,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -435,6 +474,79 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
Widget _buildQuadrantCell(String label, int count) {
|
||||||
final percentage = (count / _statistics!.totalShots * 100);
|
final percentage = (count / _statistics!.totalShots * 100);
|
||||||
final intensity = count / _statistics!.totalShots;
|
final intensity = count / _statistics!.totalShots;
|
||||||
@@ -442,10 +554,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color.lerp(
|
color: Color.lerp(
|
||||||
const Color(0xFF2A2A2A),
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
const Color(0xFF1A73E8),
|
const Color(0xFF1A73E8),
|
||||||
intensity,
|
intensity,
|
||||||
)!.withOpacity(0.8),
|
)!.withValues(alpha: 0.8),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -453,19 +565,19 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'$count',
|
'$count',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Theme.of(context).textTheme.titleLarge?.color,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${percentage.toStringAsFixed(0)}%',
|
'${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(
|
Text(
|
||||||
label,
|
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<StatisticsScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withOpacity(0.1),
|
color: Colors.orange.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -507,7 +619,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Tendance $description',
|
'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<StatisticsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import 'package:intl/date_symbol_data_local.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'core/theme/theme_provider.dart';
|
||||||
import 'data/repositories/session_repository.dart';
|
import 'data/repositories/session_repository.dart';
|
||||||
import 'services/target_detection_service.dart';
|
import 'services/target_detection_service.dart';
|
||||||
import 'services/score_calculator_service.dart';
|
import 'services/score_calculator_service.dart';
|
||||||
import 'services/grouping_analyzer_service.dart';
|
import 'services/grouping_analyzer_service.dart';
|
||||||
import 'services/image_processing_service.dart';
|
import 'services/image_processing_service.dart';
|
||||||
|
import 'features/session/session_provider.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -45,6 +47,8 @@ void main() async {
|
|||||||
create: (_) => GroupingAnalyzerService(),
|
create: (_) => GroupingAnalyzerService(),
|
||||||
),
|
),
|
||||||
Provider<SessionRepository>(create: (_) => SessionRepository()),
|
Provider<SessionRepository>(create: (_) => SessionRepository()),
|
||||||
|
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
|
||||||
|
ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()),
|
||||||
],
|
],
|
||||||
child: const BullyApp(),
|
child: const BullyApp(),
|
||||||
),
|
),
|
||||||
|
|||||||
82
lib/main_navigation_holder.dart
Normal file
82
lib/main_navigation_holder.dart
Normal file
@@ -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<MainNavigationHolder> createState() => _MainNavigationHolderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
final List<Widget> _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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
@@ -37,7 +38,7 @@ class AiExportService {
|
|||||||
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
|
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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;
|
return deviceData;
|
||||||
@@ -131,15 +132,15 @@ class AiExportService {
|
|||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final responseData = await response.stream.bytesToString();
|
final responseData = await response.stream.bytesToString();
|
||||||
print('Export réussi: $responseData');
|
debugPrint('Export réussi: $responseData');
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
final errorData = await response.stream.bytesToString();
|
final errorData = await response.stream.bytesToString();
|
||||||
print('Erreur d\'export: ${response.statusCode} - $errorData');
|
debugPrint('Erreur d\'export: ${response.statusCode} - $errorData');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Exception lors de l\'export: $e');
|
debugPrint('Exception lors de l\'export: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ library;
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
@@ -562,8 +563,9 @@ class DistortionCorrectionService {
|
|||||||
double maxArea = 0;
|
double maxArea = 0;
|
||||||
|
|
||||||
for (final contour in contours) {
|
for (final contour in contours) {
|
||||||
if (contour.length < 5)
|
if (contour.length < 5) {
|
||||||
continue; // fitEllipse nécessite au moins 5 points
|
continue; // fitEllipse nécessite au moins 5 points
|
||||||
|
}
|
||||||
|
|
||||||
final area = cv.contourArea(contour);
|
final area = cv.contourArea(contour);
|
||||||
if (area < 1000) continue; // Ignorer les trop petits bruits
|
if (area < 1000) continue; // Ignorer les trop petits bruits
|
||||||
@@ -636,7 +638,7 @@ class DistortionCorrectionService {
|
|||||||
return outputPath;
|
return outputPath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// En cas d'erreur, retourner l'image originale
|
// En cas d'erreur, retourner l'image originale
|
||||||
print('Erreur correction perspective cercles: $e');
|
debugPrint('Erreur correction perspective cercles: $e');
|
||||||
return imagePath;
|
return imagePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,7 +763,7 @@ class DistortionCorrectionService {
|
|||||||
|
|
||||||
return outputPath;
|
return outputPath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur correction perspective ovales: $e');
|
debugPrint('Erreur correction perspective ovales: $e');
|
||||||
return imagePath;
|
return imagePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -822,7 +824,7 @@ class DistortionCorrectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (concentricGroup.length < 2) {
|
if (concentricGroup.length < 2) {
|
||||||
print(
|
debugPrint(
|
||||||
"Pas assez de cercles concentriques pour le maillage, utilisation de la méthode simple.",
|
"Pas assez de cercles concentriques pour le maillage, utilisation de la méthode simple.",
|
||||||
);
|
);
|
||||||
return await correctPerspectiveUsingOvals(imagePath);
|
return await correctPerspectiveUsingOvals(imagePath);
|
||||||
@@ -950,7 +952,7 @@ class DistortionCorrectionService {
|
|||||||
|
|
||||||
return outputPath;
|
return outputPath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur correction perspective maillage concentrique: $e');
|
debugPrint('Erreur correction perspective maillage concentrique: $e');
|
||||||
return imagePath;
|
return imagePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1012,7 +1014,7 @@ class DistortionCorrectionService {
|
|||||||
|
|
||||||
// Fallback
|
// Fallback
|
||||||
if (bestQuad == null) {
|
if (bestQuad == null) {
|
||||||
print(
|
debugPrint(
|
||||||
"Aucun papier quadrilatère détecté, on utilise les cercles à la place.",
|
"Aucun papier quadrilatère détecté, on utilise les cercles à la place.",
|
||||||
);
|
);
|
||||||
return await correctPerspectiveUsingCircles(imagePath);
|
return await correctPerspectiveUsingCircles(imagePath);
|
||||||
@@ -1060,7 +1062,7 @@ class DistortionCorrectionService {
|
|||||||
|
|
||||||
return outputPath;
|
return outputPath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur correction perspective quadrilatère: $e');
|
debugPrint('Erreur correction perspective quadrilatère: $e');
|
||||||
// Fallback
|
// Fallback
|
||||||
return await correctPerspectiveUsingCircles(imagePath);
|
return await correctPerspectiveUsingCircles(imagePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
@@ -189,7 +190,7 @@ class ImageProcessingService {
|
|||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error detecting impacts: $e');
|
debugPrint('Error detecting impacts: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,14 +237,14 @@ class ImageProcessingService {
|
|||||||
final fillRatios = <double>[];
|
final fillRatios = <double>[];
|
||||||
final thresholds = <double>[];
|
final thresholds = <double>[];
|
||||||
|
|
||||||
print('Analyzing ${references.length} reference impacts...');
|
debugPrint('Analyzing ${references.length} reference impacts...');
|
||||||
|
|
||||||
for (int refIndex = 0; refIndex < references.length; refIndex++) {
|
for (int refIndex = 0; refIndex < references.length; refIndex++) {
|
||||||
final ref = references[refIndex];
|
final ref = references[refIndex];
|
||||||
final centerX = (ref.x * width).round().clamp(0, width - 1);
|
final centerX = (ref.x * width).round().clamp(0, width - 1);
|
||||||
final centerY = (ref.y * height).round().clamp(0, height - 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
|
// AMÉLIORATION : Recherche du point le plus sombre dans une zone plus large
|
||||||
int darkestX = centerX;
|
int darkestX = centerX;
|
||||||
@@ -275,7 +276,7 @@ class ImageProcessingService {
|
|||||||
if (darkestLum < 50 && r > 5) break;
|
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
|
// Now find the blob at the darkest point using adaptive threshold
|
||||||
final blobResult = _findBlobAtPoint(blurred, darkestX, darkestY, width, height);
|
final blobResult = _findBlobAtPoint(blurred, darkestX, darkestY, width, height);
|
||||||
@@ -286,15 +287,15 @@ class ImageProcessingService {
|
|||||||
circularities.add(blobResult.circularity);
|
circularities.add(blobResult.circularity);
|
||||||
fillRatios.add(blobResult.fillRatio);
|
fillRatios.add(blobResult.fillRatio);
|
||||||
thresholds.add(blobResult.threshold);
|
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)}');
|
'fill=${blobResult.fillRatio.toStringAsFixed(2)}, threshold=${blobResult.threshold.toStringAsFixed(0)}');
|
||||||
} else {
|
} else {
|
||||||
print(' No valid blob found at this reference');
|
debugPrint(' No valid blob found at this reference');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (luminances.isEmpty) {
|
if (luminances.isEmpty) {
|
||||||
print('ERROR: No valid blobs found from any reference!');
|
debugPrint('ERROR: No valid blobs found from any reference!');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,11 +330,11 @@ class ImageProcessingService {
|
|||||||
avgDarkThreshold: avgThreshold,
|
avgDarkThreshold: avgThreshold,
|
||||||
);
|
);
|
||||||
|
|
||||||
print('Learned characteristics: $result');
|
debugPrint('Learned characteristics: $result');
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error analyzing reference impacts: $e');
|
debugPrint('Error analyzing reference impacts: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,7 +681,7 @@ class ImageProcessingService {
|
|||||||
// Calculate minimum fill ratio - impacts pleins
|
// Calculate minimum fill ratio - impacts pleins
|
||||||
final minFillRatio = (characteristics.avgFillRatio - 0.2).clamp(0.35, 0.85);
|
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');
|
'circ>=$effectiveMinCircularity, fill>=$minFillRatio');
|
||||||
|
|
||||||
// Détecter avec plusieurs seuils et combiner les résultats
|
// Détecter avec plusieurs seuils et combiner les résultats
|
||||||
@@ -722,7 +723,7 @@ class ImageProcessingService {
|
|||||||
return true;
|
return true;
|
||||||
}).toList();
|
}).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
|
// Convert to relative coordinates
|
||||||
return filteredBlobs.map((blob) {
|
return filteredBlobs.map((blob) {
|
||||||
@@ -733,7 +734,7 @@ class ImageProcessingService {
|
|||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error detecting impacts from references: $e');
|
debugPrint('Error detecting impacts from references: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -776,50 +777,6 @@ class ImageProcessingService {
|
|||||||
return merged;
|
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
|
/// Detect dark spots in a grayscale image using blob detection
|
||||||
List<_Blob> _detectDarkSpots(
|
List<_Blob> _detectDarkSpots(
|
||||||
|
|||||||
@@ -66,25 +66,12 @@ class PrecisionStats {
|
|||||||
|
|
||||||
/// Standard deviation statistics
|
/// Standard deviation statistics
|
||||||
class StdDevStats {
|
class StdDevStats {
|
||||||
/// Standard deviation of X positions
|
|
||||||
final double stdDevX;
|
final double stdDevX;
|
||||||
|
|
||||||
/// Standard deviation of Y positions
|
|
||||||
final double stdDevY;
|
final double stdDevY;
|
||||||
|
|
||||||
/// Combined standard deviation (radial)
|
|
||||||
final double stdDevRadial;
|
final double stdDevRadial;
|
||||||
|
|
||||||
/// Standard deviation of scores
|
|
||||||
final double stdDevScore;
|
final double stdDevScore;
|
||||||
|
|
||||||
/// Mean X position
|
|
||||||
final double meanX;
|
final double meanX;
|
||||||
|
|
||||||
/// Mean Y position
|
|
||||||
final double meanY;
|
final double meanY;
|
||||||
|
|
||||||
/// Mean score
|
|
||||||
final double meanScore;
|
final double meanScore;
|
||||||
|
|
||||||
const StdDevStats({
|
const StdDevStats({
|
||||||
@@ -98,18 +85,11 @@ class StdDevStats {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regional distribution (quadrants or sectors)
|
/// Regional distribution
|
||||||
class RegionalStats {
|
class RegionalStats {
|
||||||
/// Shot distribution by quadrant (top-left, top-right, bottom-left, bottom-right)
|
|
||||||
final Map<String, int> quadrantDistribution;
|
final Map<String, int> quadrantDistribution;
|
||||||
|
|
||||||
/// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center)
|
|
||||||
final Map<String, int> sectorDistribution;
|
final Map<String, int> sectorDistribution;
|
||||||
|
|
||||||
/// Dominant direction (where most shots land)
|
|
||||||
final String dominantDirection;
|
final String dominantDirection;
|
||||||
|
|
||||||
/// Bias offset from center
|
|
||||||
final double biasX;
|
final double biasX;
|
||||||
final double biasY;
|
final double biasY;
|
||||||
|
|
||||||
@@ -157,16 +137,16 @@ class StatisticsService {
|
|||||||
SessionStatistics calculateStatistics(
|
SessionStatistics calculateStatistics(
|
||||||
List<Session> sessions, {
|
List<Session> sessions, {
|
||||||
StatsPeriod period = StatsPeriod.all,
|
StatsPeriod period = StatsPeriod.all,
|
||||||
double targetCenterX = 0.5,
|
|
||||||
double targetCenterY = 0.5,
|
|
||||||
}) {
|
}) {
|
||||||
// Filter sessions by period
|
// Filter sessions by period
|
||||||
final filteredSessions = _filterByPeriod(sessions, period);
|
final filteredSessions = _filterByPeriod(sessions, period);
|
||||||
|
|
||||||
// Collect all shots
|
// Collect all shots from all analyses in all sessions
|
||||||
final allShots = <Shot>[];
|
final allShots = <Shot>[];
|
||||||
for (final session in filteredSessions) {
|
for (final session in filteredSessions) {
|
||||||
allShots.addAll(session.shots);
|
for (final analysis in session.analyses) {
|
||||||
|
allShots.addAll(analysis.shots);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allShots.isEmpty) {
|
if (allShots.isEmpty) {
|
||||||
@@ -183,14 +163,14 @@ class StatisticsService {
|
|||||||
// Calculate heat map
|
// Calculate heat map
|
||||||
final heatMap = _calculateHeatMap(allShots, gridSize: 5);
|
final heatMap = _calculateHeatMap(allShots, gridSize: 5);
|
||||||
|
|
||||||
// Calculate precision
|
// Calculate precision (using relative center 0.5, 0.5)
|
||||||
final precision = _calculatePrecision(allShots, targetCenterX, targetCenterY);
|
final precision = _calculatePrecision(allShots, 0.5, 0.5);
|
||||||
|
|
||||||
// Calculate standard deviation
|
// Calculate standard deviation
|
||||||
final stdDev = _calculateStdDev(allShots);
|
final stdDev = _calculateStdDev(allShots);
|
||||||
|
|
||||||
// Calculate regional distribution
|
// Calculate regional distribution
|
||||||
final regional = _calculateRegional(allShots, targetCenterX, targetCenterY);
|
final regional = _calculateRegional(allShots, 0.5, 0.5);
|
||||||
|
|
||||||
return SessionStatistics(
|
return SessionStatistics(
|
||||||
totalShots: totalShots,
|
totalShots: totalShots,
|
||||||
@@ -224,20 +204,13 @@ class StatisticsService {
|
|||||||
|
|
||||||
/// Calculate heat map
|
/// Calculate heat map
|
||||||
HeatMap _calculateHeatMap(List<Shot> shots, {int gridSize = 5}) {
|
HeatMap _calculateHeatMap(List<Shot> shots, {int gridSize = 5}) {
|
||||||
// Initialize grid
|
final grid = List.generate(gridSize, (_) => List.generate(gridSize, (_) => <Shot>[]));
|
||||||
final grid = List.generate(
|
|
||||||
gridSize,
|
|
||||||
(_) => List.generate(gridSize, (_) => <Shot>[]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assign shots to grid cells
|
|
||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1);
|
final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1);
|
||||||
final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1);
|
final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1);
|
||||||
grid[row][col].add(shot);
|
grid[row][col].add(shot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find max count for normalization
|
|
||||||
int maxCount = 0;
|
int maxCount = 0;
|
||||||
for (final row in grid) {
|
for (final row in grid) {
|
||||||
for (final cell in row) {
|
for (final cell in row) {
|
||||||
@@ -245,16 +218,12 @@ class StatisticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create heat zones
|
|
||||||
final zones = <List<HeatZone>>[];
|
final zones = <List<HeatZone>>[];
|
||||||
for (int row = 0; row < gridSize; row++) {
|
for (int row = 0; row < gridSize; row++) {
|
||||||
final rowZones = <HeatZone>[];
|
final rowZones = <HeatZone>[];
|
||||||
for (int col = 0; col < gridSize; col++) {
|
for (int col = 0; col < gridSize; col++) {
|
||||||
final cellShots = grid[row][col];
|
final cellShots = grid[row][col];
|
||||||
final avgScore = cellShots.isEmpty
|
final avgScore = cellShots.isEmpty ? 0.0 : cellShots.fold<int>(0, (sum, s) => sum + s.score) / cellShots.length;
|
||||||
? 0.0
|
|
||||||
: cellShots.fold<int>(0, (sum, s) => sum + s.score) / cellShots.length;
|
|
||||||
|
|
||||||
rowZones.add(HeatZone(
|
rowZones.add(HeatZone(
|
||||||
row: row,
|
row: row,
|
||||||
col: col,
|
col: col,
|
||||||
@@ -266,30 +235,15 @@ class StatisticsService {
|
|||||||
zones.add(rowZones);
|
zones.add(rowZones);
|
||||||
}
|
}
|
||||||
|
|
||||||
return HeatMap(
|
return HeatMap(gridSize: gridSize, zones: zones, maxShotsInZone: maxCount, totalShots: shots.length);
|
||||||
gridSize: gridSize,
|
|
||||||
zones: zones,
|
|
||||||
maxShotsInZone: maxCount,
|
|
||||||
totalShots: shots.length,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate precision statistics
|
/// Calculate precision statistics
|
||||||
PrecisionStats _calculatePrecision(
|
PrecisionStats _calculatePrecision(List<Shot> shots, double centerX, double centerY) {
|
||||||
List<Shot> shots,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
) {
|
|
||||||
if (shots.isEmpty) {
|
if (shots.isEmpty) {
|
||||||
return const PrecisionStats(
|
return const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0);
|
||||||
avgDistanceFromCenter: 0,
|
|
||||||
groupingDiameter: 0,
|
|
||||||
precisionScore: 0,
|
|
||||||
consistencyScore: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate distances from center
|
|
||||||
final distances = shots.map((shot) {
|
final distances = shots.map((shot) {
|
||||||
final dx = shot.x - centerX;
|
final dx = shot.x - centerX;
|
||||||
final dy = shot.y - centerY;
|
final dy = shot.y - centerY;
|
||||||
@@ -297,8 +251,6 @@ class StatisticsService {
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final avgDistance = distances.reduce((a, b) => a + b) / distances.length;
|
final avgDistance = distances.reduce((a, b) => a + b) / distances.length;
|
||||||
|
|
||||||
// Calculate grouping (spread between shots)
|
|
||||||
double maxSpread = 0;
|
double maxSpread = 0;
|
||||||
for (int i = 0; i < shots.length; i++) {
|
for (int i = 0; i < shots.length; i++) {
|
||||||
for (int j = i + 1; j < shots.length; j++) {
|
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;
|
double variance = 0;
|
||||||
for (final d in distances) {
|
for (final d in distances) {
|
||||||
variance += math.pow(d - meanDist, 2);
|
variance += math.pow(d - avgDistance, 2);
|
||||||
}
|
}
|
||||||
final stdDevDist = math.sqrt(variance / distances.length);
|
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);
|
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);
|
final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100);
|
||||||
|
|
||||||
return PrecisionStats(
|
return PrecisionStats(
|
||||||
avgDistanceFromCenter: avgDistance.toDouble(),
|
avgDistanceFromCenter: avgDistance,
|
||||||
groupingDiameter: maxSpread.toDouble(),
|
groupingDiameter: maxSpread,
|
||||||
precisionScore: precisionScore.clamp(0.0, 100.0).toDouble(),
|
precisionScore: precisionScore.toDouble().clamp(0.0, 100.0),
|
||||||
consistencyScore: consistencyScore.clamp(0.0, 100.0).toDouble(),
|
consistencyScore: consistencyScore.toDouble().clamp(0.0, 100.0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate standard deviation statistics
|
/// Calculate standard deviation statistics
|
||||||
StdDevStats _calculateStdDev(List<Shot> shots) {
|
StdDevStats _calculateStdDev(List<Shot> shots) {
|
||||||
if (shots.isEmpty) {
|
if (shots.isEmpty) {
|
||||||
return const StdDevStats(
|
return const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0);
|
||||||
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;
|
double sumX = 0, sumY = 0, sumScore = 0;
|
||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
sumX += shot.x;
|
sumX += shot.x;
|
||||||
@@ -358,7 +294,6 @@ class StatisticsService {
|
|||||||
final meanY = sumY / shots.length;
|
final meanY = sumY / shots.length;
|
||||||
final meanScore = sumScore / shots.length;
|
final meanScore = sumScore / shots.length;
|
||||||
|
|
||||||
// Calculate variances
|
|
||||||
double varianceX = 0, varianceY = 0, varianceScore = 0;
|
double varianceX = 0, varianceY = 0, varianceScore = 0;
|
||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
varianceX += math.pow(shot.x - meanX, 2);
|
varianceX += math.pow(shot.x - meanX, 2);
|
||||||
@@ -369,18 +304,11 @@ class StatisticsService {
|
|||||||
varianceY /= shots.length;
|
varianceY /= shots.length;
|
||||||
varianceScore /= 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(
|
return StdDevStats(
|
||||||
stdDevX: stdDevX,
|
stdDevX: math.sqrt(varianceX),
|
||||||
stdDevY: stdDevY,
|
stdDevY: math.sqrt(varianceY),
|
||||||
stdDevRadial: stdDevRadial,
|
stdDevRadial: math.sqrt(varianceX + varianceY),
|
||||||
stdDevScore: stdDevScore,
|
stdDevScore: math.sqrt(varianceScore),
|
||||||
meanX: meanX,
|
meanX: meanX,
|
||||||
meanY: meanY,
|
meanY: meanY,
|
||||||
meanScore: meanScore,
|
meanScore: meanScore,
|
||||||
@@ -388,42 +316,13 @@ class StatisticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate regional distribution
|
/// Calculate regional distribution
|
||||||
RegionalStats _calculateRegional(
|
RegionalStats _calculateRegional(List<Shot> shots, double centerX, double centerY) {
|
||||||
List<Shot> shots,
|
|
||||||
double centerX,
|
|
||||||
double centerY,
|
|
||||||
) {
|
|
||||||
if (shots.isEmpty) {
|
if (shots.isEmpty) {
|
||||||
return const RegionalStats(
|
return const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0);
|
||||||
quadrantDistribution: {},
|
|
||||||
sectorDistribution: {},
|
|
||||||
dominantDirection: 'Centre',
|
|
||||||
biasX: 0,
|
|
||||||
biasY: 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quadrant distribution
|
final quadrants = <String, int>{'Haut-Gauche': 0, 'Haut-Droite': 0, 'Bas-Gauche': 0, 'Bas-Droite': 0};
|
||||||
final quadrants = <String, int>{
|
final sectors = <String, int>{'N': 0, 'NE': 0, 'E': 0, 'SE': 0, 'S': 0, 'SO': 0, 'O': 0, 'NO': 0, 'Centre': 0};
|
||||||
'Haut-Gauche': 0,
|
|
||||||
'Haut-Droite': 0,
|
|
||||||
'Bas-Gauche': 0,
|
|
||||||
'Bas-Droite': 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sector distribution (8 sectors + center)
|
|
||||||
final sectors = <String, int>{
|
|
||||||
'N': 0,
|
|
||||||
'NE': 0,
|
|
||||||
'E': 0,
|
|
||||||
'SE': 0,
|
|
||||||
'S': 0,
|
|
||||||
'SO': 0,
|
|
||||||
'O': 0,
|
|
||||||
'NO': 0,
|
|
||||||
'Centre': 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
double sumDx = 0, sumDy = 0;
|
double sumDx = 0, sumDy = 0;
|
||||||
|
|
||||||
for (final shot in shots) {
|
for (final shot in shots) {
|
||||||
@@ -432,16 +331,12 @@ class StatisticsService {
|
|||||||
sumDx += dx;
|
sumDx += dx;
|
||||||
sumDy += dy;
|
sumDy += dy;
|
||||||
|
|
||||||
// Quadrant
|
|
||||||
if (dy < 0) {
|
if (dy < 0) {
|
||||||
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] =
|
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] = quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1;
|
||||||
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1;
|
|
||||||
} else {
|
} else {
|
||||||
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] =
|
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] = quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1;
|
||||||
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sector
|
|
||||||
final distance = math.sqrt(dx * dx + dy * dy);
|
final distance = math.sqrt(dx * dx + dy * dy);
|
||||||
if (distance < 0.1) {
|
if (distance < 0.1) {
|
||||||
sectors['Centre'] = sectors['Centre']! + 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';
|
String dominant = 'Centre';
|
||||||
int maxCount = 0;
|
int maxCount = 0;
|
||||||
sectors.forEach((key, value) {
|
sectors.forEach((key, value) {
|
||||||
@@ -470,14 +360,12 @@ class StatisticsService {
|
|||||||
quadrantDistribution: quadrants,
|
quadrantDistribution: quadrants,
|
||||||
sectorDistribution: sectors,
|
sectorDistribution: sectors,
|
||||||
dominantDirection: dominant,
|
dominantDirection: dominant,
|
||||||
biasX: biasX,
|
biasX: sumDx / shots.length,
|
||||||
biasY: biasY,
|
biasY: sumDy / shots.length,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _angleToSector(double angle) {
|
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 < 22.5) return 'E';
|
||||||
if (angle >= 22.5 && angle < 67.5) return 'SE';
|
if (angle >= 22.5 && angle < 67.5) return 'SE';
|
||||||
if (angle >= 67.5 && angle < 112.5) return 'S';
|
if (angle >= 67.5 && angle < 112.5) return 'S';
|
||||||
@@ -491,41 +379,12 @@ class StatisticsService {
|
|||||||
|
|
||||||
SessionStatistics _emptyStatistics(StatsPeriod period, List<Session> sessions) {
|
SessionStatistics _emptyStatistics(StatsPeriod period, List<Session> sessions) {
|
||||||
return SessionStatistics(
|
return SessionStatistics(
|
||||||
totalShots: 0,
|
totalShots: 0, totalScore: 0, avgScore: 0, maxScore: 0, minScore: 0,
|
||||||
totalScore: 0,
|
heatMap: const HeatMap(gridSize: 5, zones: [], maxShotsInZone: 0, totalShots: 0),
|
||||||
avgScore: 0,
|
precision: const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0),
|
||||||
maxScore: 0,
|
stdDev: const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0),
|
||||||
minScore: 0,
|
regional: const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0),
|
||||||
heatMap: const HeatMap(
|
sessions: sessions, period: period,
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import '../data/models/target_type.dart';
|
import '../data/models/target_type.dart';
|
||||||
import 'image_processing_service.dart';
|
import 'image_processing_service.dart';
|
||||||
import 'opencv_impact_detection_service.dart';
|
import 'opencv_impact_detection_service.dart';
|
||||||
@@ -320,7 +321,7 @@ class TargetDetectionService {
|
|||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur détection OpenCV: $e');
|
debugPrint('Erreur détection OpenCV: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,7 +370,7 @@ class TargetDetectionService {
|
|||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur détection OpenCV depuis références: $e');
|
debugPrint('Erreur détection OpenCV depuis références: $e');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:crypto/crypto.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class WalletIdentityService {
|
class WalletIdentityService {
|
||||||
static const String _prefsKey = 'wallet_identity_phrase';
|
static const String _prefsKey = 'wallet_identity_phrase';
|
||||||
@@ -86,7 +87,7 @@ class WalletIdentityService {
|
|||||||
deviceId = windowsInfo.deviceId;
|
deviceId = windowsInfo.deviceId;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lecture device ID: $e');
|
debugPrint('Erreur lecture device ID: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a salt to the device ID
|
// Add a salt to the device ID
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
Reference in New Issue
Block a user