premier app version beta

This commit is contained in:
2026-01-18 13:38:09 +01:00
commit 031d4a4e17
164 changed files with 13698 additions and 0 deletions

19
lib/app.dart Normal file
View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'core/theme/app_theme.dart';
import 'features/home/home_screen.dart';
class BullyApp extends StatelessWidget {
const BullyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Bully - Analyse de Cibles',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
home: const HomeScreen(),
);
}
}

View File

@@ -0,0 +1,48 @@
class AppConstants {
AppConstants._();
// Database
static const String databaseName = 'bully_targets.db';
static const int databaseVersion = 1;
// Tables
static const String sessionsTable = 'sessions';
static const String shotsTable = 'shots';
// Image processing
static const double minImpactRadius = 2.0;
static const double maxImpactRadius = 20.0;
static const int gaussianBlurSize = 5;
static const double houghCirclesDp = 1.0;
static const double houghCirclesMinDist = 20.0;
static const int houghCirclesParam1 = 50;
static const int houghCirclesParam2 = 30;
// Scoring zones for concentric targets (10 zones, from center)
static const List<double> concentricZoneRadii = [
0.05, // Zone 10 (bullseye)
0.10, // Zone 9
0.15, // Zone 8
0.20, // Zone 7
0.25, // Zone 6
0.30, // Zone 5
0.40, // Zone 4
0.50, // Zone 3
0.65, // Zone 2
0.80, // Zone 1
];
// Silhouette scoring zones (as percentage of height from top)
static const Map<String, double> silhouetteZones = {
'head': 0.15, // Top 15% = head = 5 points
'center': 0.45, // 15-45% = center mass = 5 points
'body': 0.70, // 45-70% = body = 4 points
'lower': 1.0, // 70-100% = lower = 3 points
};
// UI
static const double defaultPadding = 16.0;
static const double smallPadding = 8.0;
static const double largePadding = 24.0;
static const double borderRadius = 12.0;
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
class AppTheme {
AppTheme._();
static const Color primaryColor = Color(0xFF1E88E5);
static const Color secondaryColor = Color(0xFF43A047);
static const Color errorColor = Color(0xFFE53935);
static const Color warningColor = Color(0xFFFFA726);
static const Color successColor = Color(0xFF66BB6A);
static const Color backgroundColor = Color(0xFFF5F5F5);
static const Color surfaceColor = Colors.white;
static const Color textPrimary = Color(0xFF212121);
static const Color textSecondary = Color(0xFF757575);
// Impact colors for visualization
static const Color impactColor = Color(0xFFFF5722);
static const Color impactOutlineColor = Color(0xFFFFFFFF);
static const Color groupingCenterColor = Color(0xFF2196F3);
static const Color groupingCircleColor = Color(0x4D2196F3);
// Score zone colors
static const List<Color> zoneColors = [
Color(0xFFFFEB3B), // Zone 10 - Gold
Color(0xFFFFEB3B), // Zone 9
Color(0xFFFF5722), // Zone 8
Color(0xFFFF5722), // Zone 7
Color(0xFF2196F3), // Zone 6
Color(0xFF2196F3), // Zone 5
Color(0xFF4CAF50), // Zone 4
Color(0xFF4CAF50), // Zone 3
Color(0xFFFFFFFF), // Zone 2
Color(0xFFFFFFFF), // Zone 1
];
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.light,
),
scaffoldBackgroundColor: backgroundColor,
appBarTheme: const AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: primaryColor,
foregroundColor: Colors.white,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
),
);
}
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.dark,
),
appBarTheme: const AppBarTheme(
elevation: 0,
centerTitle: true,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}

View File

@@ -0,0 +1,254 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/session.dart';
import '../models/shot.dart';
import '../../core/constants/app_constants.dart';
class DatabaseHelper {
static DatabaseHelper? _instance;
static Database? _database;
DatabaseHelper._internal();
factory DatabaseHelper() {
_instance ??= DatabaseHelper._internal();
return _instance!;
}
Future<Database> get database async {
_database ??= await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
final databasesPath = await getDatabasesPath();
final path = join(databasesPath, AppConstants.databaseName);
return await openDatabase(
path,
version: AppConstants.databaseVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE ${AppConstants.sessionsTable} (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL,
image_path TEXT NOT NULL,
total_score INTEGER NOT NULL,
grouping_diameter REAL,
grouping_center_x REAL,
grouping_center_y REAL,
created_at TEXT NOT NULL,
notes TEXT,
target_center_x REAL,
target_center_y REAL,
target_radius REAL
)
''');
await db.execute('''
CREATE TABLE ${AppConstants.shotsTable} (
id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL,
score INTEGER NOT NULL,
session_id TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
)
''');
await db.execute('''
CREATE INDEX idx_shots_session_id ON ${AppConstants.shotsTable}(session_id)
''');
await db.execute('''
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
''');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
// Handle future database migrations here
}
// Session operations
Future<int> insertSession(Session session) async {
final db = await database;
return await db.transaction((txn) async {
await txn.insert(
AppConstants.sessionsTable,
session.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
for (final shot in session.shots) {
// Ensure shot has correct session_id
final shotWithSessionId = shot.copyWith(sessionId: session.id);
await txn.insert(
AppConstants.shotsTable,
shotWithSessionId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
return 1;
});
}
Future<Session?> getSession(String id) async {
final db = await database;
final sessionMaps = await db.query(
AppConstants.sessionsTable,
where: 'id = ?',
whereArgs: [id],
);
if (sessionMaps.isEmpty) return null;
final shotMaps = await db.query(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [id],
);
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
return Session.fromMap(sessionMaps.first, shots);
}
Future<List<Session>> getAllSessions({
String? targetType,
int? limit,
int? offset,
}) async {
final db = await database;
String? whereClause;
List<dynamic>? whereArgs;
if (targetType != null) {
whereClause = 'target_type = ?';
whereArgs = [targetType];
}
final sessionMaps = await db.query(
AppConstants.sessionsTable,
where: whereClause,
whereArgs: whereArgs,
orderBy: 'created_at DESC',
limit: limit,
offset: offset,
);
// First, check if there are orphaned shots (with empty session_id)
// and only one session - if so, assign them to that session
if (sessionMaps.length == 1) {
final orphanedShots = await db.query(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [''],
);
if (orphanedShots.isNotEmpty) {
await db.update(
AppConstants.shotsTable,
{'session_id': sessionMaps.first['id']},
where: 'session_id = ?',
whereArgs: [''],
);
}
}
final sessions = <Session>[];
for (final sessionMap in sessionMaps) {
final sessionId = sessionMap['id'] as String;
final shotMaps = await db.query(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [sessionId],
);
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
sessions.add(Session.fromMap(sessionMap, shots));
}
return sessions;
}
Future<int> updateSession(Session session) async {
final db = await database;
return await db.transaction((txn) async {
await txn.update(
AppConstants.sessionsTable,
session.toMap(),
where: 'id = ?',
whereArgs: [session.id],
);
// Delete existing shots and insert new ones
await txn.delete(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [session.id],
);
for (final shot in session.shots) {
// Ensure shot has correct session_id
final shotWithSessionId = shot.copyWith(sessionId: session.id);
await txn.insert(
AppConstants.shotsTable,
shotWithSessionId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
return 1;
});
}
Future<int> deleteSession(String id) async {
final db = await database;
return await db.delete(
AppConstants.sessionsTable,
where: 'id = ?',
whereArgs: [id],
);
}
Future<Map<String, dynamic>> getStatistics() async {
final db = await database;
final totalSessions = Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM ${AppConstants.sessionsTable}'),
) ?? 0;
final totalShots = Sqflite.firstIntValue(
await db.rawQuery('SELECT COUNT(*) FROM ${AppConstants.shotsTable}'),
) ?? 0;
final avgScore = (await db.rawQuery(
'SELECT AVG(total_score) as avg FROM ${AppConstants.sessionsTable}',
)).first['avg'] as double? ?? 0.0;
final bestScore = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT MAX(total_score) FROM ${AppConstants.sessionsTable}',
),
) ?? 0;
return {
'totalSessions': totalSessions,
'totalShots': totalShots,
'averageScore': avgScore,
'bestScore': bestScore,
};
}
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
}

View File

@@ -0,0 +1,112 @@
import 'shot.dart';
import 'target_type.dart';
class Session {
final String id;
final TargetType targetType;
final String imagePath;
final List<Shot> shots;
final int totalScore;
final double? groupingDiameter;
final double? groupingCenterX;
final double? groupingCenterY;
final DateTime createdAt;
final String? notes;
// Target detection data
final double? targetCenterX;
final double? targetCenterY;
final double? targetRadius;
Session({
required this.id,
required this.targetType,
required this.imagePath,
required this.shots,
required this.totalScore,
this.groupingDiameter,
this.groupingCenterX,
this.groupingCenterY,
required this.createdAt,
this.notes,
this.targetCenterX,
this.targetCenterY,
this.targetRadius,
});
int get shotCount => shots.length;
double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length;
Session copyWith({
String? id,
TargetType? targetType,
String? imagePath,
List<Shot>? shots,
int? totalScore,
double? groupingDiameter,
double? groupingCenterX,
double? groupingCenterY,
DateTime? createdAt,
String? notes,
double? targetCenterX,
double? targetCenterY,
double? targetRadius,
}) {
return Session(
id: id ?? this.id,
targetType: targetType ?? this.targetType,
imagePath: imagePath ?? this.imagePath,
shots: shots ?? this.shots,
totalScore: totalScore ?? this.totalScore,
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
createdAt: createdAt ?? this.createdAt,
notes: notes ?? this.notes,
targetCenterX: targetCenterX ?? this.targetCenterX,
targetCenterY: targetCenterY ?? this.targetCenterY,
targetRadius: targetRadius ?? this.targetRadius,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'target_type': targetType.name,
'image_path': imagePath,
'total_score': totalScore,
'grouping_diameter': groupingDiameter,
'grouping_center_x': groupingCenterX,
'grouping_center_y': groupingCenterY,
'created_at': createdAt.toIso8601String(),
'notes': notes,
'target_center_x': targetCenterX,
'target_center_y': targetCenterY,
'target_radius': targetRadius,
};
}
factory Session.fromMap(Map<String, dynamic> map, List<Shot> shots) {
return Session(
id: map['id'] as String,
targetType: TargetType.fromString(map['target_type'] as String),
imagePath: map['image_path'] as String,
shots: shots,
totalScore: map['total_score'] as int,
groupingDiameter: map['grouping_diameter'] as double?,
groupingCenterX: map['grouping_center_x'] as double?,
groupingCenterY: map['grouping_center_y'] as double?,
createdAt: DateTime.parse(map['created_at'] as String),
notes: map['notes'] as String?,
targetCenterX: map['target_center_x'] as double?,
targetCenterY: map['target_center_y'] as double?,
targetRadius: map['target_radius'] as double?,
);
}
@override
String toString() {
return 'Session(id: $id, targetType: $targetType, shots: ${shots.length}, totalScore: $totalScore, createdAt: $createdAt)';
}
}

72
lib/data/models/shot.dart Normal file
View File

@@ -0,0 +1,72 @@
class Shot {
final String id;
final double x; // Relative position (0.0 - 1.0)
final double y; // Relative position (0.0 - 1.0)
final int score;
final String sessionId;
Shot({
required this.id,
required this.x,
required this.y,
required this.score,
required this.sessionId,
});
Shot copyWith({
String? id,
double? x,
double? y,
int? score,
String? sessionId,
}) {
return Shot(
id: id ?? this.id,
x: x ?? this.x,
y: y ?? this.y,
score: score ?? this.score,
sessionId: sessionId ?? this.sessionId,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'x': x,
'y': y,
'score': score,
'session_id': sessionId,
};
}
factory Shot.fromMap(Map<String, dynamic> map) {
return Shot(
id: map['id'] as String,
x: (map['x'] as num).toDouble(),
y: (map['y'] as num).toDouble(),
score: map['score'] as int,
sessionId: map['session_id'] as String,
);
}
@override
String toString() {
return 'Shot(id: $id, x: $x, y: $y, score: $score, sessionId: $sessionId)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Shot &&
other.id == id &&
other.x == x &&
other.y == y &&
other.score == score &&
other.sessionId == sessionId;
}
@override
int get hashCode {
return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ sessionId.hashCode;
}
}

View File

@@ -0,0 +1,84 @@
import 'target_type.dart';
class Target {
final String id;
final TargetType type;
final String imagePath;
final DateTime createdAt;
// Detected target bounds (relative coordinates 0.0-1.0)
final double? centerX;
final double? centerY;
final double? radius; // For concentric targets
final double? width; // For silhouette targets
final double? height; // For silhouette targets
Target({
required this.id,
required this.type,
required this.imagePath,
required this.createdAt,
this.centerX,
this.centerY,
this.radius,
this.width,
this.height,
});
Target copyWith({
String? id,
TargetType? type,
String? imagePath,
DateTime? createdAt,
double? centerX,
double? centerY,
double? radius,
double? width,
double? height,
}) {
return Target(
id: id ?? this.id,
type: type ?? this.type,
imagePath: imagePath ?? this.imagePath,
createdAt: createdAt ?? this.createdAt,
centerX: centerX ?? this.centerX,
centerY: centerY ?? this.centerY,
radius: radius ?? this.radius,
width: width ?? this.width,
height: height ?? this.height,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'type': type.name,
'image_path': imagePath,
'created_at': createdAt.toIso8601String(),
'center_x': centerX,
'center_y': centerY,
'radius': radius,
'width': width,
'height': height,
};
}
factory Target.fromMap(Map<String, dynamic> map) {
return Target(
id: map['id'] as String,
type: TargetType.fromString(map['type'] as String),
imagePath: map['image_path'] as String,
createdAt: DateTime.parse(map['created_at'] as String),
centerX: map['center_x'] as double?,
centerY: map['center_y'] as double?,
radius: map['radius'] as double?,
width: map['width'] as double?,
height: map['height'] as double?,
);
}
@override
String toString() {
return 'Target(id: $id, type: $type, imagePath: $imagePath, createdAt: $createdAt)';
}
}

View File

@@ -0,0 +1,16 @@
enum TargetType {
concentric('Concentrique', 'Cible avec anneaux concentriques'),
silhouette('Silhouette', 'Cible en forme de silhouette');
final String displayName;
final String description;
const TargetType(this.displayName, this.description);
static TargetType fromString(String value) {
return TargetType.values.firstWhere(
(type) => type.name == value,
orElse: () => TargetType.concentric,
);
}
}

View File

@@ -0,0 +1,112 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:uuid/uuid.dart';
import '../database/database_helper.dart';
import '../models/session.dart';
import '../models/shot.dart';
import '../models/target_type.dart';
class SessionRepository {
final DatabaseHelper _databaseHelper;
final Uuid _uuid;
SessionRepository({
DatabaseHelper? databaseHelper,
Uuid? uuid,
}) : _databaseHelper = databaseHelper ?? DatabaseHelper(),
_uuid = uuid ?? const Uuid();
Future<Session> createSession({
required TargetType targetType,
required String imagePath,
required List<Shot> shots,
required int totalScore,
double? groupingDiameter,
double? groupingCenterX,
double? groupingCenterY,
String? notes,
double? targetCenterX,
double? targetCenterY,
double? targetRadius,
}) async {
// Copy image to app documents directory
final savedImagePath = await _saveImage(imagePath);
final session = Session(
id: _uuid.v4(),
targetType: targetType,
imagePath: savedImagePath,
shots: shots,
totalScore: totalScore,
groupingDiameter: groupingDiameter,
groupingCenterX: groupingCenterX,
groupingCenterY: groupingCenterY,
createdAt: DateTime.now(),
notes: notes,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetRadius: targetRadius,
);
await _databaseHelper.insertSession(session);
return session;
}
Future<String> _saveImage(String sourcePath) async {
final appDir = await getApplicationDocumentsDirectory();
final imagesDir = Directory(path.join(appDir.path, 'target_images'));
if (!await imagesDir.exists()) {
await imagesDir.create(recursive: true);
}
final fileName = '${_uuid.v4()}${path.extension(sourcePath)}';
final destPath = path.join(imagesDir.path, fileName);
final sourceFile = File(sourcePath);
await sourceFile.copy(destPath);
return destPath;
}
Future<Session?> getSession(String id) async {
return await _databaseHelper.getSession(id);
}
Future<List<Session>> getAllSessions({
TargetType? targetType,
int? limit,
int? offset,
}) async {
return await _databaseHelper.getAllSessions(
targetType: targetType?.name,
limit: limit,
offset: offset,
);
}
Future<void> updateSession(Session session) async {
await _databaseHelper.updateSession(session);
}
Future<void> deleteSession(String id) async {
final session = await getSession(id);
if (session != null) {
// Delete the image file
final imageFile = File(session.imagePath);
if (await imageFile.exists()) {
await imageFile.delete();
}
}
await _databaseHelper.deleteSession(id);
}
Future<Map<String, dynamic>> getStatistics() async {
return await _databaseHelper.getStatistics();
}
String generateShotId() {
return _uuid.v4();
}
}

View File

@@ -0,0 +1,431 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../../data/models/session.dart';
import '../../data/models/shot.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
enum AnalysisState { initial, loading, success, error }
class AnalysisProvider extends ChangeNotifier {
final TargetDetectionService _detectionService;
final ScoreCalculatorService _scoreCalculatorService;
final GroupingAnalyzerService _groupingAnalyzerService;
final SessionRepository _sessionRepository;
final Uuid _uuid = const Uuid();
AnalysisProvider({
required TargetDetectionService detectionService,
required ScoreCalculatorService scoreCalculatorService,
required GroupingAnalyzerService groupingAnalyzerService,
required SessionRepository sessionRepository,
}) : _detectionService = detectionService,
_scoreCalculatorService = scoreCalculatorService,
_groupingAnalyzerService = groupingAnalyzerService,
_sessionRepository = sessionRepository;
AnalysisState _state = AnalysisState.initial;
String? _errorMessage;
String? _imagePath;
TargetType? _targetType;
// Target detection results
double _targetCenterX = 0.5;
double _targetCenterY = 0.5;
double _targetRadius = 0.4;
int _ringCount = 10;
List<double>? _ringRadii; // Individual ring radii multipliers
double _imageAspectRatio = 1.0; // width / height
// Shots
List<Shot> _shots = [];
// Score results
ScoreResult? _scoreResult;
// Grouping results
GroupingResult? _groupingResult;
// Reference-based detection
List<Shot> _referenceImpacts = [];
ImpactCharacteristics? _learnedCharacteristics;
// Getters
AnalysisState get state => _state;
String? get errorMessage => _errorMessage;
String? get imagePath => _imagePath;
TargetType? get targetType => _targetType;
double get targetCenterX => _targetCenterX;
double get targetCenterY => _targetCenterY;
double get targetRadius => _targetRadius;
int get ringCount => _ringCount;
List<double>? get ringRadii => _ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
double get imageAspectRatio => _imageAspectRatio;
List<Shot> get shots => List.unmodifiable(_shots);
ScoreResult? get scoreResult => _scoreResult;
GroupingResult? get groupingResult => _groupingResult;
int get totalScore => _scoreResult?.totalScore ?? 0;
int get shotCount => _shots.length;
List<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
/// Analyze an image
Future<void> analyzeImage(String imagePath, TargetType targetType) async {
_state = AnalysisState.loading;
_imagePath = imagePath;
_targetType = targetType;
_errorMessage = null;
notifyListeners();
try {
// Load image to get dimensions
final file = File(imagePath);
final bytes = await file.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
_imageAspectRatio = frame.image.width / frame.image.height;
frame.image.dispose();
// Detect target and impacts
final result = _detectionService.detectTarget(imagePath, targetType);
if (!result.success) {
_state = AnalysisState.error;
_errorMessage = result.errorMessage;
notifyListeners();
return;
}
_targetCenterX = result.centerX;
_targetCenterY = result.centerY;
_targetRadius = result.radius;
// Create shots from detected impacts
_shots = result.impacts.map((impact) {
return Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: impact.suggestedScore,
sessionId: '', // Will be set when saving
);
}).toList();
// Calculate scores
_recalculateScores();
// Calculate grouping
_recalculateGrouping();
_state = AnalysisState.success;
notifyListeners();
} catch (e) {
_state = AnalysisState.error;
_errorMessage = 'Erreur d\'analyse: $e';
notifyListeners();
}
}
/// Add a manual shot
void addShot(double x, double y) {
final score = _calculateShotScore(x, y);
final shot = Shot(
id: _uuid.v4(),
x: x,
y: y,
score: score,
sessionId: '',
);
_shots.add(shot);
_recalculateScores();
_recalculateGrouping();
notifyListeners();
}
/// Remove a shot
void removeShot(String shotId) {
_shots.removeWhere((shot) => shot.id == shotId);
_recalculateScores();
_recalculateGrouping();
notifyListeners();
}
/// Move a shot to a new position
void moveShot(String shotId, double newX, double newY) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
if (index == -1) return;
final newScore = _calculateShotScore(newX, newY);
_shots[index] = _shots[index].copyWith(
x: newX,
y: newY,
score: newScore,
);
_recalculateScores();
_recalculateGrouping();
notifyListeners();
}
/// Auto-detect impacts using image processing
Future<int> autoDetectImpacts({
int darkThreshold = 80,
int minImpactSize = 20,
int maxImpactSize = 500,
double minCircularity = 0.6,
double minFillRatio = 0.5,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null) return 0;
final settings = ImpactDetectionSettings(
darkThreshold: darkThreshold,
minImpactSize: minImpactSize,
maxImpactSize: maxImpactSize,
minCircularity: minCircularity,
minFillRatio: minFillRatio,
);
final detectedImpacts = _detectionService.detectImpactsOnly(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
settings,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Add a reference impact for calibrated detection
void addReferenceImpact(double x, double y) {
final score = _calculateShotScore(x, y);
final shot = Shot(
id: _uuid.v4(),
x: x,
y: y,
score: score,
sessionId: '',
);
_referenceImpacts.add(shot);
notifyListeners();
}
/// Remove a reference impact
void removeReferenceImpact(String shotId) {
_referenceImpacts.removeWhere((shot) => shot.id == shotId);
_learnedCharacteristics = null;
notifyListeners();
}
/// Clear all reference impacts
void clearReferenceImpacts() {
_referenceImpacts.clear();
_learnedCharacteristics = null;
notifyListeners();
}
/// Learn characteristics from reference impacts
bool learnFromReferences() {
if (_imagePath == null || _referenceImpacts.length < 2) return false;
final references = _referenceImpacts
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList();
_learnedCharacteristics = _detectionService.analyzeReferenceImpacts(
_imagePath!,
references,
);
notifyListeners();
return _learnedCharacteristics != null;
}
/// Auto-detect impacts using learned reference characteristics
Future<int> detectFromReferences({
double tolerance = 2.0,
bool clearExisting = false,
}) async {
if (_imagePath == null || _targetType == null || _learnedCharacteristics == null) {
return 0;
}
final detectedImpacts = _detectionService.detectImpactsFromReferences(
_imagePath!,
_targetType!,
_targetCenterX,
_targetCenterY,
_targetRadius,
_ringCount,
_learnedCharacteristics!,
tolerance: tolerance,
);
if (clearExisting) {
_shots.clear();
}
// Add detected impacts as shots
for (final impact in detectedImpacts) {
final score = _calculateShotScore(impact.x, impact.y);
final shot = Shot(
id: _uuid.v4(),
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
);
_shots.add(shot);
}
_recalculateScores();
_recalculateGrouping();
notifyListeners();
return detectedImpacts.length;
}
/// Adjust target position
void adjustTargetPosition(double centerX, double centerY, double radius, {int? ringCount, List<double>? ringRadii}) {
_targetCenterX = centerX;
_targetCenterY = centerY;
_targetRadius = radius;
if (ringCount != null) {
_ringCount = ringCount;
}
if (ringRadii != null) {
_ringRadii = ringRadii;
}
// Recalculate all shot scores based on new target position
_shots = _shots.map((shot) {
final newScore = _calculateShotScore(shot.x, shot.y);
return shot.copyWith(score: newScore);
}).toList();
_recalculateScores();
notifyListeners();
}
int _calculateShotScore(double x, double y) {
if (_targetType == TargetType.concentric) {
return _scoreCalculatorService.calculateConcentricScore(
shotX: x,
shotY: y,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
ringCount: _ringCount,
imageAspectRatio: _imageAspectRatio,
ringRadii: _ringRadii,
);
} else {
return _scoreCalculatorService.calculateSilhouetteScore(
shotX: x,
shotY: y,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetWidth: _targetRadius * 0.8,
targetHeight: _targetRadius * 2,
);
}
}
void _recalculateScores() {
if (_targetType == null) return;
_scoreResult = _scoreCalculatorService.calculateScores(
shots: _shots,
targetType: _targetType!,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
ringCount: _ringCount,
imageAspectRatio: _imageAspectRatio,
ringRadii: _ringRadii,
);
}
void _recalculateGrouping() {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
}
/// Save the session
Future<Session> saveSession({String? notes}) async {
if (_imagePath == null || _targetType == null) {
throw Exception('Cannot save: missing image or target type');
}
final session = await _sessionRepository.createSession(
targetType: _targetType!,
imagePath: _imagePath!,
shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(),
totalScore: totalScore,
groupingDiameter: _groupingResult?.diameter,
groupingCenterX: _groupingResult?.centerX,
groupingCenterY: _groupingResult?.centerY,
notes: notes,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
);
// Update shots with session ID
_shots = session.shots;
notifyListeners();
return session;
}
/// Reset the provider
void reset() {
_state = AnalysisState.initial;
_errorMessage = null;
_imagePath = null;
_targetType = null;
_targetCenterX = 0.5;
_targetCenterY = 0.5;
_targetRadius = 0.4;
_ringCount = 10;
_ringRadii = null;
_imageAspectRatio = 1.0;
_shots = [];
_scoreResult = null;
_groupingResult = null;
_referenceImpacts = [];
_learnedCharacteristics = null;
notifyListeners();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../services/grouping_analyzer_service.dart';
class GroupingStats extends StatelessWidget {
final GroupingResult groupingResult;
final double targetCenterX;
final double targetCenterY;
const GroupingStats({
super.key,
required this.groupingResult,
required this.targetCenterX,
required this.targetCenterY,
});
@override
Widget build(BuildContext context) {
final offsetX = groupingResult.centerX - targetCenterX;
final offsetY = groupingResult.centerY - targetCenterY;
final offsetDescription = _getOffsetDescription(offsetX, offsetY);
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.center_focus_strong, color: AppTheme.groupingCenterColor),
const SizedBox(width: 8),
Text(
'Groupement',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
_buildQualityBadge(context),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStat(
context,
'Diametre',
'${(groupingResult.diameter * 100).toStringAsFixed(1)}%',
icon: Icons.straighten,
),
_buildStat(
context,
'Dispersion',
'${(groupingResult.standardDeviation * 100).toStringAsFixed(1)}%',
icon: Icons.scatter_plot,
),
_buildStat(
context,
'Decalage',
offsetDescription,
icon: Icons.compare_arrows,
),
],
),
const SizedBox(height: 12),
_buildOffsetIndicator(context, offsetX, offsetY),
],
),
),
);
}
Widget _buildQualityBadge(BuildContext context) {
final rating = groupingResult.qualityRating;
final color = _getQualityColor(rating);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
...List.generate(5, (index) {
return Icon(
index < rating ? Icons.star : Icons.star_border,
size: 16,
color: color,
);
}),
const SizedBox(width: 4),
Text(
groupingResult.qualityDescription,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
);
}
Widget _buildStat(
BuildContext context,
String label,
String value, {
required IconData icon,
}) {
return Column(
children: [
Icon(icon, size: 20, color: Colors.grey),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildOffsetIndicator(BuildContext context, double offsetX, double offsetY) {
return Container(
height: 80,
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
// Grid lines
Center(
child: Container(
width: 1,
color: Colors.grey[300],
),
),
Center(
child: Container(
height: 1,
color: Colors.grey[300],
),
),
// Center point (target)
const Center(
child: Icon(Icons.add, size: 16, color: Colors.grey),
),
// Grouping center
LayoutBuilder(
builder: (context, constraints) {
// Scale offset for visualization (max 40 pixels from center)
final maxOffset = 40.0;
final scaledX = (offsetX * 200).clamp(-maxOffset, maxOffset);
final scaledY = (offsetY * 200).clamp(-maxOffset, maxOffset);
return Center(
child: Transform.translate(
offset: Offset(scaledX, scaledY),
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: AppTheme.groupingCenterColor,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
);
},
),
// Labels
Positioned(
top: 4,
left: 0,
right: 0,
child: Text(
'Haut',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
Positioned(
bottom: 4,
left: 0,
right: 0,
child: Text(
'Bas',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
Positioned(
left: 4,
top: 0,
bottom: 0,
child: Center(
child: Text(
'G',
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
),
Positioned(
right: 4,
top: 0,
bottom: 0,
child: Center(
child: Text(
'D',
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
),
),
],
),
);
}
Color _getQualityColor(int rating) {
switch (rating) {
case 5:
return AppTheme.successColor;
case 4:
return Colors.lightGreen;
case 3:
return AppTheme.warningColor;
case 2:
return Colors.orange;
default:
return AppTheme.errorColor;
}
}
String _getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centre';
}
String vertical = '';
String horizontal = '';
if (offsetY < -0.02) {
vertical = 'H';
} else if (offsetY > 0.02) {
vertical = 'B';
}
if (offsetX < -0.02) {
horizontal = 'G';
} else if (offsetX > 0.02) {
horizontal = 'D';
}
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
return '$vertical-$horizontal';
}
return vertical.isNotEmpty ? vertical : horizontal;
}
}

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/target_type.dart';
import '../../../services/score_calculator_service.dart';
class ScoreCard extends StatelessWidget {
final int totalScore;
final int shotCount;
final ScoreResult? scoreResult;
final TargetType targetType;
const ScoreCard({
super.key,
required this.totalScore,
required this.shotCount,
this.scoreResult,
required this.targetType,
});
@override
Widget build(BuildContext context) {
final maxScore = targetType == TargetType.concentric ? 10 : 5;
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.scoreboard, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Score',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildScoreStat(
context,
'Total',
'$totalScore',
subtitle: '/ ${shotCount * maxScore}',
),
_buildScoreStat(
context,
'Impacts',
'$shotCount',
),
_buildScoreStat(
context,
'Moyenne',
shotCount > 0
? (totalScore / shotCount).toStringAsFixed(1)
: '-',
),
if (scoreResult != null)
_buildScoreStat(
context,
'Pourcentage',
'${scoreResult!.percentage.toStringAsFixed(0)}%',
),
],
),
if (scoreResult != null && scoreResult!.scoreDistribution.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
Text(
'Distribution des scores',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
_buildScoreDistribution(context),
],
],
),
),
);
}
Widget _buildScoreStat(
BuildContext context,
String label,
String value, {
String? subtitle,
}) {
return Column(
children: [
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
if (subtitle != null)
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildScoreDistribution(BuildContext context) {
final maxScoreValue = targetType == TargetType.concentric ? 10 : 5;
final distribution = scoreResult!.scoreDistribution;
return Wrap(
spacing: 8,
runSpacing: 4,
children: List.generate(maxScoreValue + 1, (index) {
final score = maxScoreValue - index;
final count = distribution[score] ?? 0;
if (count == 0) return const SizedBox.shrink();
return Chip(
label: Text(
'x$count',
style: const TextStyle(fontSize: 12),
),
avatar: CircleAvatar(
radius: 12,
backgroundColor: _getScoreColor(score, maxScoreValue),
child: Text(
'$score',
style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}),
);
}
Color _getScoreColor(int score, int maxScore) {
if (score == maxScore) return Colors.amber;
if (score >= maxScore * 0.8) return Colors.orange;
if (score >= maxScore * 0.6) return Colors.blue;
if (score >= maxScore * 0.4) return Colors.green;
if (score >= maxScore * 0.2) return Colors.grey;
return Colors.grey[400]!;
}
}

View File

@@ -0,0 +1,561 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/target_type.dart';
class TargetCalibration extends StatefulWidget {
final double initialCenterX;
final double initialCenterY;
final double initialRadius;
final int initialRingCount;
final TargetType targetType;
final List<double>? initialRingRadii; // Individual ring radii multipliers
final Function(double centerX, double centerY, double radius, int ringCount, {List<double>? ringRadii}) onCalibrationChanged;
const TargetCalibration({
super.key,
required this.initialCenterX,
required this.initialCenterY,
required this.initialRadius,
this.initialRingCount = 10,
required this.targetType,
this.initialRingRadii,
required this.onCalibrationChanged,
});
@override
State<TargetCalibration> createState() => _TargetCalibrationState();
}
class _TargetCalibrationState extends State<TargetCalibration> {
late double _centerX;
late double _centerY;
late double _radius;
late int _ringCount;
late List<double> _ringRadii; // Multipliers for each ring (1.0 = normal)
bool _isDraggingCenter = false;
bool _isDraggingRadius = false;
int? _selectedRingIndex; // Index of the ring being adjusted individually
@override
void initState() {
super.initState();
_centerX = widget.initialCenterX;
_centerY = widget.initialCenterY;
_radius = widget.initialRadius;
_ringCount = widget.initialRingCount;
_initRingRadii();
}
void _initRingRadii() {
if (widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!);
} else {
// Initialize with default proportional radii
_ringRadii = List.generate(_ringCount, (i) => (i + 1) / _ringCount);
}
}
@override
void didUpdateWidget(TargetCalibration oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialRingCount != oldWidget.initialRingCount) {
_ringCount = widget.initialRingCount;
_initRingRadii();
}
if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius && _selectedRingIndex == null) {
// Update from slider - scale all rings proportionally
final scale = widget.initialRadius / _radius;
_radius = widget.initialRadius;
// Ring radii are relative, so they don't need to change
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final size = constraints.biggest;
return GestureDetector(
onTapDown: (details) => _onTapDown(details, size),
onPanStart: (details) => _onPanStart(details, size),
onPanUpdate: (details) => _onPanUpdate(details, size),
onPanEnd: (_) => _onPanEnd(),
child: CustomPaint(
size: size,
painter: _CalibrationPainter(
centerX: _centerX,
centerY: _centerY,
radius: _radius,
ringCount: _ringCount,
ringRadii: _ringRadii,
targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter,
isDraggingRadius: _isDraggingRadius,
selectedRingIndex: _selectedRingIndex,
),
),
);
},
);
}
void _onTapDown(TapDownDetails details, Size size) {
final tapX = details.localPosition.dx / size.width;
final tapY = details.localPosition.dy / size.height;
// Check if tapping on a specific ring
final ringIndex = _findRingAtPosition(tapX, tapY, size);
if (ringIndex != null && ringIndex != _selectedRingIndex) {
setState(() {
_selectedRingIndex = ringIndex;
});
}
}
int? _findRingAtPosition(double tapX, double tapY, Size size) {
final minDim = math.min(size.width, size.height);
final distFromCenter = math.sqrt(
math.pow((tapX - _centerX) * size.width, 2) +
math.pow((tapY - _centerY) * size.height, 2)
);
// Check each ring from outside to inside
for (int i = _ringCount - 1; i >= 0; i--) {
final ringRadius = _radius * _ringRadii[i] * minDim;
final prevRingRadius = i > 0 ? _radius * _ringRadii[i - 1] * minDim : 0.0;
// Check if tap is on this ring's edge (within tolerance)
final tolerance = 15.0;
if ((distFromCenter - ringRadius).abs() < tolerance) {
return i;
}
}
return null;
}
void _onPanStart(DragStartDetails details, Size size) {
final tapX = details.localPosition.dx / size.width;
final tapY = details.localPosition.dy / size.height;
// Check if tapping on center handle
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
// Check if tapping on radius handle (on the right edge of the outermost circle)
final minDim = math.min(size.width, size.height);
final outerRadius = _radius * (_ringRadii.isNotEmpty ? _ringRadii.last : 1.0);
final radiusHandleX = _centerX + outerRadius * minDim / size.width;
final radiusHandleY = _centerY;
final distToRadiusHandle = _distance(tapX, tapY, radiusHandleX.clamp(0.0, 1.0), radiusHandleY.clamp(0.0, 1.0));
if (distToCenter < 0.05) {
setState(() {
_isDraggingCenter = true;
_selectedRingIndex = null;
});
} else if (distToRadiusHandle < 0.05) {
setState(() {
_isDraggingRadius = true;
_selectedRingIndex = null;
});
} else {
// Check if dragging a specific ring
final ringIndex = _findRingAtPosition(tapX, tapY, size);
if (ringIndex != null) {
setState(() {
_selectedRingIndex = ringIndex;
});
} else if (distToCenter < _radius + 0.02) {
// Tapping inside the target - move center
setState(() {
_isDraggingCenter = true;
_selectedRingIndex = null;
});
}
}
}
void _onPanUpdate(DragUpdateDetails details, Size size) {
final deltaX = details.delta.dx / size.width;
final deltaY = details.delta.dy / size.height;
final minDim = math.min(size.width, size.height);
setState(() {
if (_isDraggingCenter) {
// Move center
_centerX = _centerX + deltaX;
_centerY = _centerY + deltaY;
} else if (_isDraggingRadius) {
// Adjust outer radius (scales all rings proportionally)
final newRadius = _radius + deltaX * (size.width / minDim);
_radius = newRadius.clamp(0.05, 3.0);
} else if (_selectedRingIndex != null) {
// Adjust individual ring
final currentPos = details.localPosition;
final distFromCenter = math.sqrt(
math.pow(currentPos.dx - _centerX * size.width, 2) +
math.pow(currentPos.dy - _centerY * size.height, 2)
);
// Calculate new ring radius as proportion of base radius
final newRingRadius = distFromCenter / (minDim * _radius);
// Get constraints from adjacent rings
final minAllowed = _selectedRingIndex! > 0
? _ringRadii[_selectedRingIndex! - 1] + 0.02
: 0.05;
final maxAllowed = _selectedRingIndex! < _ringCount - 1
? _ringRadii[_selectedRingIndex! + 1] - 0.02
: 1.5;
_ringRadii[_selectedRingIndex!] = newRingRadius.clamp(minAllowed, maxAllowed);
}
});
widget.onCalibrationChanged(_centerX, _centerY, _radius, _ringCount, ringRadii: _ringRadii);
}
void _onPanEnd() {
setState(() {
_isDraggingCenter = false;
_isDraggingRadius = false;
// Keep selected ring for visual feedback
});
}
double _distance(double x1, double y1, double x2, double y2) {
final dx = x1 - x2;
final dy = y1 - y2;
return (dx * dx + dy * dy);
}
}
class _CalibrationPainter extends CustomPainter {
final double centerX;
final double centerY;
final double radius;
final int ringCount;
final List<double> ringRadii;
final TargetType targetType;
final bool isDraggingCenter;
final bool isDraggingRadius;
final int? selectedRingIndex;
_CalibrationPainter({
required this.centerX,
required this.centerY,
required this.radius,
required this.ringCount,
required this.ringRadii,
required this.targetType,
required this.isDraggingCenter,
required this.isDraggingRadius,
this.selectedRingIndex,
});
@override
void paint(Canvas canvas, Size size) {
final centerPx = Offset(centerX * size.width, centerY * size.height);
final minDim = size.width < size.height ? size.width : size.height;
final baseRadiusPx = radius * minDim;
if (targetType == TargetType.concentric) {
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
} else {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
}
// Draw center handle
_drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw instructions
_drawInstructions(canvas, size);
}
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
// Generate colors for zones
List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) {
final ratio = i / ringCount;
if (ratio < 0.2) {
zoneColors.add(Colors.yellow.withValues(alpha: 0.3 - ratio * 0.5));
} else if (ratio < 0.4) {
zoneColors.add(Colors.orange.withValues(alpha: 0.25 - ratio * 0.3));
} else if (ratio < 0.6) {
zoneColors.add(Colors.blue.withValues(alpha: 0.2 - ratio * 0.2));
} else if (ratio < 0.8) {
zoneColors.add(Colors.green.withValues(alpha: 0.15 - ratio * 0.1));
} else {
zoneColors.add(Colors.white.withValues(alpha: 0.1));
}
}
final zonePaint = Paint()..style = PaintingStyle.fill;
final strokePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.white.withValues(alpha: 0.6);
final selectedStrokePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = Colors.cyan;
// Draw from outside to inside
for (int i = ringCount - 1; i >= 0; i--) {
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
final zoneRadius = baseRadius * ringRadius;
zonePaint.color = zoneColors[i];
canvas.drawCircle(center, zoneRadius, zonePaint);
// Highlight selected ring
if (selectedRingIndex == i) {
canvas.drawCircle(center, zoneRadius, selectedStrokePaint);
// Draw drag handle on selected ring
_drawRingHandle(canvas, size, center, zoneRadius, i);
} else {
canvas.drawCircle(center, zoneRadius, strokePaint);
}
}
// Draw zone labels (only if within visible area)
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
);
for (int i = 0; i < ringCount; i++) {
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
final prevRingRadius = i > 0
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
: 0.0;
final zoneRadius = baseRadius * (ringRadius + prevRingRadius) / 2;
// Score: center = 10, decrement by 1 for each ring
final score = 10 - i;
// Only draw label if it's within the visible area
final labelX = center.dx + zoneRadius;
if (labelX < 0 || labelX > size.width) continue;
textPainter.text = TextSpan(
text: '$score',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
fontWeight: FontWeight.bold,
shadows: const [
Shadow(color: Colors.black, blurRadius: 2),
],
),
);
textPainter.layout();
// Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) {
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
}
}
}
void _drawRingHandle(Canvas canvas, Size size, Offset center, double ringRadius, int ringIndex) {
// Draw handle at the right edge of the selected ring
final handleX = center.dx + ringRadius;
final handleY = center.dy;
if (handleX < 0 || handleX > size.width) return;
final handlePos = Offset(handleX, handleY);
// Handle background
final handlePaint = Paint()
..color = Colors.cyan
..style = PaintingStyle.fill;
canvas.drawCircle(handlePos, 12, handlePaint);
// Arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Outward arrow
canvas.drawLine(
Offset(handlePos.dx + 3, handlePos.dy),
Offset(handlePos.dx + 7, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx + 3, handlePos.dy),
Offset(handlePos.dx + 7, handlePos.dy + 4),
arrowPaint,
);
// Inward arrow
canvas.drawLine(
Offset(handlePos.dx - 3, handlePos.dy),
Offset(handlePos.dx - 7, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx - 3, handlePos.dy),
Offset(handlePos.dx - 7, handlePos.dy + 4),
arrowPaint,
);
}
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
// Simplified silhouette zones
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now)
final silhouetteWidth = radius * 0.8;
final silhouetteHeight = radius * 2;
paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect(
Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight),
paint,
);
}
void _drawCenterHandle(Canvas canvas, Offset center) {
// Outer circle
final outerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(center, 15, outerPaint);
// Inner dot
final innerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..style = PaintingStyle.fill;
canvas.drawCircle(center, 5, innerPaint);
// Crosshair
final crossPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..strokeWidth = 2;
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy + 8), Offset(center.dx, center.dy + 20), crossPaint);
}
void _drawRadiusHandle(Canvas canvas, Size size, Offset center, double baseRadius) {
// Radius handle on the right edge of the outermost ring
final outerRingRadius = ringRadii.isNotEmpty ? ringRadii.last : 1.0;
final actualRadius = baseRadius * outerRingRadius;
final actualHandleX = center.dx + actualRadius;
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: TextSpan(
text: 'GLOBAL',
style: const 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 _drawInstructions(Canvas canvas, Size size) {
String instruction;
if (selectedRingIndex != null) {
instruction = 'Anneau ${10 - selectedRingIndex!} selectionne - Glissez pour ajuster';
} else {
instruction = 'Touchez un anneau pour l\'ajuster individuellement';
}
final textPainter = TextPainter(
text: TextSpan(
text: instruction,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
backgroundColor: Colors.black54,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset((size.width - textPainter.width) / 2, size.height - 30),
);
}
@override
bool shouldRepaint(covariant _CalibrationPainter oldDelegate) {
return centerX != oldDelegate.centerX ||
centerY != oldDelegate.centerY ||
radius != oldDelegate.radius ||
ringCount != oldDelegate.ringCount ||
isDraggingCenter != oldDelegate.isDraggingCenter ||
isDraggingRadius != oldDelegate.isDraggingRadius ||
selectedRingIndex != oldDelegate.selectedRingIndex ||
ringRadii != oldDelegate.ringRadii;
}
}

View File

@@ -0,0 +1,343 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/shot.dart';
import '../../../data/models/target_type.dart';
class TargetOverlay extends StatelessWidget {
final List<Shot> shots;
final double targetCenterX;
final double targetCenterY;
final double targetRadius;
final TargetType targetType;
final int ringCount;
final List<double>? ringRadii; // Individual ring radii multipliers
final void Function(Shot shot)? onShotTapped;
final void Function(double x, double y)? onAddShot;
final double? groupingCenterX;
final double? groupingCenterY;
final double? groupingDiameter;
final List<Shot>? referenceImpacts;
const TargetOverlay({
super.key,
required this.shots,
required this.targetCenterX,
required this.targetCenterY,
required this.targetRadius,
required this.targetType,
this.ringCount = 10,
this.ringRadii,
this.onShotTapped,
this.onAddShot,
this.groupingCenterX,
this.groupingCenterY,
this.groupingDiameter,
this.referenceImpacts,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapUp: (details) {
if (onAddShot != null) {
final RenderBox box = context.findRenderObject() as RenderBox;
final localPosition = details.localPosition;
final relX = localPosition.dx / box.size.width;
final relY = localPosition.dy / box.size.height;
onAddShot!(relX, relY);
}
},
child: CustomPaint(
painter: _TargetOverlayPainter(
shots: shots,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetRadius: targetRadius,
targetType: targetType,
ringCount: ringCount,
ringRadii: ringRadii,
groupingCenterX: groupingCenterX,
groupingCenterY: groupingCenterY,
groupingDiameter: groupingDiameter,
referenceImpacts: referenceImpacts,
),
child: Stack(
children: shots.map((shot) {
return Positioned(
left: 0,
top: 0,
right: 0,
bottom: 0,
child: LayoutBuilder(
builder: (context, constraints) {
final x = shot.x * constraints.maxWidth;
final y = shot.y * constraints.maxHeight;
return Stack(
children: [
Positioned(
left: x - 15,
top: y - 15,
child: GestureDetector(
onTap: () => onShotTapped?.call(shot),
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: Colors.transparent,
shape: BoxShape.circle,
),
),
),
),
],
);
},
),
);
}).toList(),
),
),
);
}
}
class _TargetOverlayPainter extends CustomPainter {
final List<Shot> shots;
final double targetCenterX;
final double targetCenterY;
final double targetRadius;
final TargetType targetType;
final int ringCount;
final List<double>? ringRadii;
final double? groupingCenterX;
final double? groupingCenterY;
final double? groupingDiameter;
final List<Shot>? referenceImpacts;
_TargetOverlayPainter({
required this.shots,
required this.targetCenterX,
required this.targetCenterY,
required this.targetRadius,
required this.targetType,
this.ringCount = 10,
this.ringRadii,
this.groupingCenterX,
this.groupingCenterY,
this.groupingDiameter,
this.referenceImpacts,
});
@override
void paint(Canvas canvas, Size size) {
// Draw target center indicator
_drawTargetCenter(canvas, size);
// Draw grouping circle
if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) {
_drawGroupingCircle(canvas, size);
}
// Draw impacts
for (final shot in shots) {
_drawImpact(canvas, size, shot);
}
// Draw reference impacts (with different color)
if (referenceImpacts != null) {
for (final ref in referenceImpacts!) {
_drawReferenceImpact(canvas, size, ref);
}
}
}
void _drawTargetCenter(Canvas canvas, Size size) {
final centerX = targetCenterX * size.width;
final centerY = targetCenterY * size.height;
final minDim = size.width < size.height ? size.width : size.height;
final maxRadius = targetRadius * minDim;
final strokePaint = Paint()
..color = Colors.green.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = 1;
// Draw concentric rings based on ringCount (with individual radii if provided)
for (int i = 0; i < ringCount; i++) {
final ringMultiplier = (ringRadii != null && ringRadii!.length == ringCount)
? ringRadii![i]
: (i + 1) / ringCount;
final ringRadius = maxRadius * ringMultiplier;
canvas.drawCircle(Offset(centerX, centerY), ringRadius, strokePaint);
}
// Draw score labels on rings (only if within visible area)
final textPainter = TextPainter(
textDirection: TextDirection.ltr,
);
for (int i = 0; i < ringCount; i++) {
// Calculate zone center (midpoint between this ring and previous)
final currentMultiplier = (ringRadii != null && ringRadii!.length == ringCount)
? ringRadii![i]
: (i + 1) / ringCount;
final prevMultiplier = i == 0
? 0.0
: (ringRadii != null && ringRadii!.length == ringCount)
? ringRadii![i - 1]
: i / ringCount;
final zoneRadius = maxRadius * (currentMultiplier + prevMultiplier) / 2;
final score = 10 - i;
// Only draw label if it's within the visible area
final labelX = centerX + zoneRadius;
if (labelX < 0 || labelX > size.width) continue;
textPainter.text = TextSpan(
text: '$score',
style: TextStyle(
color: Colors.green.withValues(alpha: 0.8),
fontSize: 10,
fontWeight: FontWeight.bold,
shadows: const [
Shadow(color: Colors.black, blurRadius: 2),
],
),
);
textPainter.layout();
// Draw label on the right side of each zone
final labelY = centerY - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) {
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
}
}
// Draw crosshair at center
final crosshairPaint = Paint()
..color = Colors.green.withValues(alpha: 0.7)
..strokeWidth = 1;
canvas.drawLine(
Offset(centerX - 10, centerY),
Offset(centerX + 10, centerY),
crosshairPaint,
);
canvas.drawLine(
Offset(centerX, centerY - 10),
Offset(centerX, centerY + 10),
crosshairPaint,
);
}
void _drawGroupingCircle(Canvas canvas, Size size) {
final centerX = groupingCenterX! * size.width;
final centerY = groupingCenterY! * size.height;
final diameter = groupingDiameter! * size.width.clamp(0, size.height);
// Draw filled circle
final fillPaint = Paint()
..color = AppTheme.groupingCircleColor
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(centerX, centerY), diameter / 2, fillPaint);
// Draw outline
final strokePaint = Paint()
..color = AppTheme.groupingCenterColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(Offset(centerX, centerY), diameter / 2, strokePaint);
// Draw center point
final centerPaint = Paint()
..color = AppTheme.groupingCenterColor
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(centerX, centerY), 4, centerPaint);
}
void _drawImpact(Canvas canvas, Size size, Shot shot) {
final x = shot.x * size.width;
final y = shot.y * size.height;
// Draw outer circle (white outline for visibility)
final outlinePaint = Paint()
..color = AppTheme.impactOutlineColor
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(Offset(x, y), 10, outlinePaint);
// Draw impact marker
final impactPaint = Paint()
..color = AppTheme.impactColor
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), 8, impactPaint);
// Draw score number
final textPainter = TextPainter(
text: TextSpan(
text: '${shot.score}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
);
}
void _drawReferenceImpact(Canvas canvas, Size size, Shot ref) {
final x = ref.x * size.width;
final y = ref.y * size.height;
// Draw outer circle (white outline for visibility)
final outlinePaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(Offset(x, y), 12, outlinePaint);
// Draw reference marker (purple)
final refPaint = Paint()
..color = Colors.deepPurple
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(x, y), 10, refPaint);
// Draw "R" to indicate reference
final textPainter = TextPainter(
text: const TextSpan(
text: 'R',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
);
}
@override
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
return shots != oldDelegate.shots ||
targetCenterX != oldDelegate.targetCenterX ||
targetCenterY != oldDelegate.targetCenterY ||
targetRadius != oldDelegate.targetRadius ||
ringCount != oldDelegate.ringCount ||
ringRadii != oldDelegate.ringRadii ||
groupingCenterX != oldDelegate.groupingCenterX ||
groupingCenterY != oldDelegate.groupingCenterY ||
groupingDiameter != oldDelegate.groupingDiameter ||
referenceImpacts != oldDelegate.referenceImpacts;
}
}

View File

@@ -0,0 +1,249 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../analysis/analysis_screen.dart';
import 'widgets/target_type_selector.dart';
import 'widgets/image_source_button.dart';
class CaptureScreen extends StatefulWidget {
const CaptureScreen({super.key});
@override
State<CaptureScreen> createState() => _CaptureScreenState();
}
class _CaptureScreenState extends State<CaptureScreen> {
final ImagePicker _picker = ImagePicker();
TargetType _selectedType = TargetType.concentric;
String? _selectedImagePath;
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Nouvelle Analyse'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Target type selection
_buildSectionTitle('Type de Cible'),
const SizedBox(height: 12),
TargetTypeSelector(
selectedType: _selectedType,
onTypeSelected: (type) {
setState(() => _selectedType = type);
},
),
const SizedBox(height: AppConstants.largePadding),
// Image source selection
_buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ImageSourceButton(
icon: Icons.camera_alt,
label: 'Camera',
onPressed: _isLoading ? null : () => _captureImage(ImageSource.camera),
),
),
const SizedBox(width: 12),
Expanded(
child: ImageSourceButton(
icon: Icons.photo_library,
label: 'Galerie',
onPressed: _isLoading ? null : () => _captureImage(ImageSource.gallery),
),
),
],
),
const SizedBox(height: AppConstants.largePadding),
// Image preview
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
)
else if (_selectedImagePath != null)
_buildImagePreview(),
// Guide text
if (_selectedImagePath == null && !_isLoading)
_buildGuide(),
],
),
),
floatingActionButton: _selectedImagePath != null
? FloatingActionButton.extended(
onPressed: _analyzeImage,
icon: const Icon(Icons.analytics),
label: const Text('Analyser'),
)
: null,
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
);
}
Widget _buildImagePreview() {
return Column(
children: [
_buildSectionTitle('Apercu'),
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.withValues(alpha: 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 centree et visible.',
style: TextStyle(color: AppTheme.warningColor.withValues(alpha: 0.8)),
),
),
],
),
),
);
}
Widget _buildGuide() {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
Icon(
Icons.help_outline,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 12),
Text(
'Conseils pour une bonne analyse',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
_buildGuideItem(Icons.crop_free, 'Cadrez la cible entiere dans l\'image'),
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon eclairage'),
_buildGuideItem(Icons.straighten, 'Prenez la photo de face'),
_buildGuideItem(Icons.blur_off, 'Evitez les images floues'),
],
),
),
);
}
Widget _buildGuideItem(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, size: 20, color: AppTheme.primaryColor),
const SizedBox(width: 12),
Expanded(child: Text(text)),
],
),
);
}
Future<void> _captureImage(ImageSource source) async {
setState(() => _isLoading = true);
try {
final XFile? image = await _picker.pickImage(
source: source,
maxWidth: 2048,
maxHeight: 2048,
imageQuality: 90,
);
if (image != null) {
setState(() => _selectedImagePath = image.path);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la capture: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
void _analyzeImage() {
if (_selectedImagePath == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: _selectedImagePath!,
targetType: _selectedType,
),
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
class ImageSourceButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback? onPressed;
const ImageSourceButton({
super.key,
required this.icon,
required this.label,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
side: BorderSide(color: AppTheme.primaryColor),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 32, color: AppTheme.primaryColor),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/target_type.dart';
class TargetTypeSelector extends StatelessWidget {
final TargetType selectedType;
final ValueChanged<TargetType> onTypeSelected;
const TargetTypeSelector({
super.key,
required this.selectedType,
required this.onTypeSelected,
});
@override
Widget build(BuildContext context) {
return Row(
children: TargetType.values.map((type) {
final isSelected = type == selectedType;
return Expanded(
child: Padding(
padding: EdgeInsets.only(
right: type != TargetType.values.last ? 12 : 0,
),
child: _TargetTypeCard(
type: type,
isSelected: isSelected,
onTap: () => onTypeSelected(type),
),
),
);
}).toList(),
);
}
}
class _TargetTypeCard extends StatelessWidget {
final TargetType type;
final bool isSelected;
final VoidCallback onTap;
const _TargetTypeCard({
required this.type,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? AppTheme.primaryColor.withValues(alpha: 0.1)
: Colors.white,
border: Border.all(
color: isSelected ? AppTheme.primaryColor : Colors.grey[300]!,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
child: Column(
children: [
Icon(
_getIcon(type),
size: 48,
color: isSelected ? AppTheme.primaryColor : Colors.grey[600],
),
const SizedBox(height: 8),
Text(
type.displayName,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.primaryColor : Colors.grey[800],
),
),
const SizedBox(height: 4),
Text(
type.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
IconData _getIcon(TargetType type) {
switch (type) {
case TargetType.concentric:
return Icons.track_changes;
case TargetType.silhouette:
return Icons.person;
}
}
}

View File

@@ -0,0 +1,228 @@
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/session.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart';
import 'session_detail_screen.dart';
import 'widgets/session_list_item.dart';
import 'widgets/history_chart.dart';
class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
List<Session> _sessions = [];
bool _isLoading = true;
TargetType? _filterType;
@override
void initState() {
super.initState();
_loadSessions();
}
Future<void> _loadSessions() async {
setState(() => _isLoading = true);
try {
final repository = context.read<SessionRepository>();
final sessions = await repository.getAllSessions(
targetType: _filterType,
);
if (mounted) {
setState(() {
_sessions = sessions;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur de chargement: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Historique'),
actions: [
PopupMenuButton<TargetType?>(
icon: const Icon(Icons.filter_list),
tooltip: 'Filtrer',
onSelected: (type) {
setState(() => _filterType = type);
_loadSessions();
},
itemBuilder: (context) => [
const PopupMenuItem(
value: null,
child: Text('Tous'),
),
...TargetType.values.map((type) => PopupMenuItem(
value: type,
child: Text(type.displayName),
)),
],
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty
? _buildEmptyState()
: _buildContent(),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.history, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'Aucune session',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
_filterType != null
? 'Aucune session de type ${_filterType!.displayName}'
: 'Commencez par analyser une cible',
style: TextStyle(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildContent() {
return RefreshIndicator(
onRefresh: _loadSessions,
child: CustomScrollView(
slivers: [
// Chart section
if (_sessions.length >= 2)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: HistoryChart(sessions: _sessions),
),
),
// Filter indicator
if (_filterType != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppConstants.defaultPadding),
child: Chip(
label: Text('Filtre: ${_filterType!.displayName}'),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () {
setState(() => _filterType = null);
_loadSessions();
},
),
),
),
// Sessions list
SliverPadding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final session = _sessions[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SessionListItem(
session: session,
onTap: () => _openSessionDetail(session),
onDelete: () => _deleteSession(session),
),
);
},
childCount: _sessions.length,
),
),
),
],
),
);
}
void _openSessionDetail(Session session) async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SessionDetailScreen(session: session),
),
);
_loadSessions(); // Refresh in case session was deleted
}
Future<void> _deleteSession(Session session) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: Text(
'Supprimer la session du ${DateFormat('dd/MM/yyyy').format(session.createdAt)}?',
),
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 && mounted) {
try {
final repository = context.read<SessionRepository>();
await repository.deleteSession(session.id);
_loadSessions();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session supprimee')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
}
}
}

View File

@@ -0,0 +1,246 @@
import 'dart:io';
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/session.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
import '../analysis/widgets/target_overlay.dart';
import '../analysis/widgets/score_card.dart';
import '../analysis/widgets/grouping_stats.dart';
import '../statistics/statistics_screen.dart';
class SessionDetailScreen extends StatelessWidget {
final Session session;
const SessionDetailScreen({
super.key,
required this.session,
});
@override
Widget build(BuildContext context) {
final scoreCalculator = context.read<ScoreCalculatorService>();
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
final scoreResult = scoreCalculator.calculateScores(
shots: session.shots,
targetType: session.targetType,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4,
);
final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots);
return Scaffold(
appBar: AppBar(
title: Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
),
actions: [
IconButton(
icon: const Icon(Icons.analytics),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StatisticsScreen(singleSession: session),
),
),
tooltip: 'Statistiques',
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _confirmDelete(context),
tooltip: 'Supprimer',
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Target image with overlay
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
if (File(session.imagePath).existsSync())
Image.file(
File(session.imagePath),
fit: BoxFit.contain,
)
else
Container(
color: Colors.grey[200],
child: const Center(
child: Icon(Icons.image_not_supported, size: 64),
),
),
TargetOverlay(
shots: session.shots,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4,
targetType: session.targetType,
groupingCenterX: session.groupingCenterX,
groupingCenterY: session.groupingCenterY,
groupingDiameter: session.groupingDiameter,
),
],
),
),
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Session info
_buildSessionInfo(context),
const SizedBox(height: 12),
// Score card
ScoreCard(
totalScore: session.totalScore,
shotCount: session.shotCount,
scoreResult: scoreResult,
targetType: session.targetType,
),
const SizedBox(height: 12),
// Grouping stats
if (session.shotCount > 1)
GroupingStats(
groupingResult: groupingResult,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
),
// Notes
if (session.notes != null && session.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildNotesCard(context),
],
],
),
),
],
),
),
);
}
Widget _buildSessionInfo(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Row(
children: [
Icon(
session.targetType == session.targetType
? Icons.track_changes
: Icons.person,
color: AppTheme.primaryColor,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.targetType.displayName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR')
.format(session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
);
}
Widget _buildNotesCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.notes, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Notes',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
Text(session.notes!),
],
),
),
);
}
Future<void> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: const Text('Voulez-vous vraiment supprimer cette session?'),
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 && context.mounted) {
try {
final repository = context.read<SessionRepository>();
await repository.deleteSession(session.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session supprimee')),
);
Navigator.pop(context);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
}
}
}

View File

@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/session.dart';
class HistoryChart extends StatelessWidget {
final List<Session> sessions;
const HistoryChart({
super.key,
required this.sessions,
});
@override
Widget build(BuildContext context) {
if (sessions.length < 2) {
return const SizedBox.shrink();
}
// Sort sessions by date and take last 10
final sortedSessions = List<Session>.from(sessions)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final displaySessions = sortedSessions.length > 10
? sortedSessions.sublist(sortedSessions.length - 10)
: sortedSessions;
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.show_chart, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Evolution des scores',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 20,
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey[300],
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= displaySessions.length) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
DateFormat('dd/MM').format(displaySessions[index].createdAt),
style: const TextStyle(fontSize: 10),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: 20,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: const TextStyle(fontSize: 10),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey[300]!),
left: BorderSide(color: Colors.grey[300]!),
),
),
minX: 0,
maxX: (displaySessions.length - 1).toDouble(),
minY: 0,
maxY: _getMaxY(displaySessions),
lineBarsData: [
// Score line
LineChartBarData(
spots: displaySessions.asMap().entries.map((entry) {
return FlSpot(
entry.key.toDouble(),
entry.value.totalScore.toDouble(),
);
}).toList(),
isCurved: true,
color: AppTheme.primaryColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppTheme.primaryColor,
strokeWidth: 2,
strokeColor: Colors.white,
);
},
),
belowBarData: BarAreaData(
show: true,
color: AppTheme.primaryColor.withValues(alpha: 0.1),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {
final session = displaySessions[spot.x.toInt()];
return LineTooltipItem(
'Score: ${session.totalScore}\n${session.shotCount} tirs',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}).toList();
},
),
),
),
),
),
const SizedBox(height: 8),
_buildLegend(context, displaySessions),
],
),
),
);
}
double _getMaxY(List<Session> sessions) {
double maxScore = 0;
for (final session in sessions) {
if (session.totalScore > maxScore) {
maxScore = session.totalScore.toDouble();
}
}
return (maxScore * 1.2).ceilToDouble();
}
Widget _buildLegend(BuildContext context, List<Session> displaySessions) {
final avgScore = displaySessions.fold<int>(0, (sum, s) => sum + s.totalScore) /
displaySessions.length;
final trend = displaySessions.length >= 2
? displaySessions.last.totalScore - displaySessions.first.totalScore
: 0;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildLegendItem(
context,
'Moyenne',
avgScore.toStringAsFixed(1),
Icons.analytics,
AppTheme.primaryColor,
),
_buildLegendItem(
context,
'Tendance',
trend >= 0 ? '+$trend' : '$trend',
trend >= 0 ? Icons.trending_up : Icons.trending_down,
trend >= 0 ? AppTheme.successColor : AppTheme.errorColor,
),
_buildLegendItem(
context,
'Sessions',
'${displaySessions.length}',
Icons.list,
Colors.grey,
),
],
);
}
Widget _buildLegendItem(
BuildContext context,
String label,
String value,
IconData icon,
Color color,
) {
return Column(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}

View File

@@ -0,0 +1,148 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/session.dart';
import '../../../data/models/target_type.dart';
class SessionListItem extends StatelessWidget {
final Session session;
final VoidCallback? onTap;
final VoidCallback? onDelete;
const SessionListItem({
super.key,
required this.session,
this.onTap,
this.onDelete,
});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Thumbnail
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 60,
height: 60,
child: _buildThumbnail(),
),
),
const SizedBox(width: 12),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getTargetIcon(),
size: 16,
color: AppTheme.primaryColor,
),
const SizedBox(width: 4),
Text(
session.targetType.displayName,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 4),
Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
if (session.notes != null && session.notes!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
session.notes!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Score
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${session.totalScore}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
Text(
'${session.shotCount} tirs',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
// Delete button
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
color: Colors.grey,
iconSize: 20,
),
],
),
),
),
);
}
Widget _buildThumbnail() {
final file = File(session.imagePath);
if (file.existsSync()) {
return Image.file(
file,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildPlaceholder(),
);
}
return _buildPlaceholder();
}
Widget _buildPlaceholder() {
return Container(
color: Colors.grey[200],
child: Icon(
_getTargetIcon(),
color: Colors.grey[400],
),
);
}
IconData _getTargetIcon() {
switch (session.targetType) {
case TargetType.concentric:
return Icons.track_changes;
case TargetType.silhouette:
return Icons.person;
}
}
}

View File

@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/repositories/session_repository.dart';
import '../capture/capture_screen.dart';
import '../history/history_screen.dart';
import '../statistics/statistics_screen.dart';
import 'widgets/stats_card.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
Map<String, dynamic>? _stats;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadStats();
}
Future<void> _loadStats() async {
final repository = context.read<SessionRepository>();
final stats = await repository.getStatistics();
if (mounted) {
setState(() {
_stats = stats;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Bully'),
actions: [
IconButton(
icon: const Icon(Icons.analytics),
onPressed: () => _navigateToStatistics(context),
tooltip: 'Statistiques',
),
IconButton(
icon: const Icon(Icons.history),
onPressed: () => _navigateToHistory(context),
tooltip: 'Historique',
),
],
),
body: RefreshIndicator(
onRefresh: _loadStats,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App logo/header
_buildHeader(),
const SizedBox(height: AppConstants.largePadding),
// Main action button
_buildMainActionButton(context),
const SizedBox(height: AppConstants.largePadding),
// Statistics section
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_stats != null)
_buildStatsSection(),
],
),
),
),
);
}
Widget _buildHeader() {
return Column(
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.track_changes,
size: 64,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 16),
Text(
'Analyse de Cibles',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Scannez vos cibles et analysez vos performances',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildMainActionButton(BuildContext context) {
return ElevatedButton.icon(
onPressed: () => _navigateToCapture(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: const Icon(Icons.add_a_photo, size: 28),
label: const Text(
'Nouvelle Analyse',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
);
}
Widget _buildStatsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Statistiques',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: StatsCard(
icon: Icons.assessment,
title: 'Sessions',
value: '${_stats!['totalSessions']}',
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 12),
Expanded(
child: StatsCard(
icon: Icons.gps_fixed,
title: 'Tirs',
value: '${_stats!['totalShots']}',
color: AppTheme.secondaryColor,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: StatsCard(
icon: Icons.trending_up,
title: 'Score Moyen',
value: (_stats!['averageScore'] as double).toStringAsFixed(1),
color: AppTheme.warningColor,
),
),
const SizedBox(width: 12),
Expanded(
child: StatsCard(
icon: Icons.emoji_events,
title: 'Meilleur',
value: '${_stats!['bestScore']}',
color: AppTheme.successColor,
),
),
],
),
],
);
}
void _navigateToCapture(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const CaptureScreen()),
);
// Refresh stats when returning
_loadStats();
}
void _navigateToHistory(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const HistoryScreen()),
);
// Refresh stats when returning
_loadStats();
}
void _navigateToStatistics(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const StatisticsScreen()),
);
// Refresh stats when returning
_loadStats();
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
class StatsCard extends StatelessWidget {
final IconData icon;
final String title;
final String value;
final Color color;
const StatsCard({
super.key,
required this.icon,
required this.title,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,717 @@
import 'package:flutter/foundation.dart';
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/session.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/statistics_service.dart';
import 'widgets/heat_map_widget.dart';
class StatisticsScreen extends StatefulWidget {
final Session? singleSession; // If provided, show stats for this session only
const StatisticsScreen({super.key, this.singleSession});
@override
State<StatisticsScreen> createState() => _StatisticsScreenState();
}
class _StatisticsScreenState extends State<StatisticsScreen> {
final StatisticsService _statisticsService = StatisticsService();
StatsPeriod _selectedPeriod = StatsPeriod.all;
SessionStatistics? _statistics;
bool _isLoading = true;
List<Session> _allSessions = [];
@override
void initState() {
super.initState();
// Use addPostFrameCallback to ensure context is available
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadStatistics();
});
}
Future<void> _loadStatistics() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
if (widget.singleSession != null) {
// Single session mode
_statistics = _statisticsService.calculateStatistics(
[widget.singleSession!],
period: StatsPeriod.session,
targetCenterX: widget.singleSession!.targetCenterX ?? 0.5,
targetCenterY: widget.singleSession!.targetCenterY ?? 0.5,
);
} else {
// Load all sessions
final repository = context.read<SessionRepository>();
_allSessions = await repository.getAllSessions();
_calculateStats();
}
} catch (e) {
debugPrint('Error loading statistics: $e');
}
if (mounted) {
setState(() => _isLoading = false);
}
}
void _calculateStats() {
debugPrint('Calculating stats for ${_allSessions.length} sessions, period: $_selectedPeriod');
for (final session in _allSessions) {
debugPrint(' Session: ${session.id}, shots: ${session.shots.length}, date: ${session.createdAt}');
}
_statistics = _statisticsService.calculateStatistics(
_allSessions,
period: _selectedPeriod,
);
debugPrint('Statistics result: totalShots=${_statistics?.totalShots}, totalScore=${_statistics?.totalScore}');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.singleSession != null ? 'Statistiques Session' : 'Statistiques'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _statistics == null || _statistics!.totalShots == 0
? _buildEmptyState()
: _buildStatistics(),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.analytics_outlined, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Aucune donnee disponible',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Effectuez des sessions de tir pour voir vos statistiques',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
Text(
'Sessions trouvees: ${_allSessions.length}',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
),
if (_allSessions.isNotEmpty)
Text(
'Tirs totaux: ${_allSessions.fold<int>(0, (sum, s) => sum + s.shots.length)}',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
),
],
),
),
);
}
Widget _buildStatistics() {
return RefreshIndicator(
onRefresh: _loadStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Period filter (only for multi-session view)
if (widget.singleSession == null) _buildPeriodFilter(),
const SizedBox(height: 16),
// Summary cards
_buildSummaryCards(),
const SizedBox(height: 24),
// Heat Map
_buildHeatMapSection(),
const SizedBox(height: 24),
// Precision stats
_buildPrecisionSection(),
const SizedBox(height: 24),
// Standard deviation
_buildStdDevSection(),
const SizedBox(height: 24),
// Regional distribution
_buildRegionalSection(),
],
),
),
);
}
Widget _buildPeriodFilter() {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Periode',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SegmentedButton<StatsPeriod>(
segments: const [
ButtonSegment(
value: StatsPeriod.week,
label: Text('7 jours'),
icon: Icon(Icons.date_range),
),
ButtonSegment(
value: StatsPeriod.month,
label: Text('30 jours'),
icon: Icon(Icons.calendar_month),
),
ButtonSegment(
value: StatsPeriod.all,
label: Text('Tout'),
icon: Icon(Icons.all_inclusive),
),
],
selected: {_selectedPeriod},
onSelectionChanged: (selection) {
setState(() {
_selectedPeriod = selection.first;
_calculateStats();
});
},
),
const SizedBox(height: 8),
Text(
'${_statistics!.sessions.length} session(s) - ${_statistics!.totalShots} tir(s)',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
],
),
),
);
}
Widget _buildSummaryCards() {
return Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.gps_fixed,
title: 'Tirs',
value: '${_statistics!.totalShots}',
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 12),
Expanded(
child: _StatCard(
icon: Icons.score,
title: 'Score Total',
value: '${_statistics!.totalScore}',
color: AppTheme.secondaryColor,
),
),
],
);
}
Widget _buildHeatMapSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.grid_on, color: AppTheme.primaryColor),
const SizedBox(width: 8),
const Text(
'Zones Chaudes',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const SizedBox(height: 8),
Text(
'Repartition de vos tirs sur la cible',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
const SizedBox(height: 16),
Center(
child: HeatMapWidget(
heatMap: _statistics!.heatMap,
size: MediaQuery.of(context).size.width - 80,
),
),
const SizedBox(height: 12),
// Legend - gradient bar
Container(
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: const LinearGradient(
colors: [
Color(0xFF2196F3), // Blue (cold)
Color(0xFF00BCD4), // Cyan
Color(0xFFFFEB3B), // Yellow
Color(0xFFFF9800), // Orange
Color(0xFFFF1744), // Red (hot)
],
),
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 16),
child: Text('Peu', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
),
Padding(
padding: const EdgeInsets.only(right: 16),
child: Text('Beaucoup', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
),
],
),
],
),
),
);
}
Widget _buildLegendItem(Color color, String label) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: Colors.grey.shade400),
),
),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 10)),
],
),
);
}
Widget _buildPrecisionSection() {
final precision = _statistics!.precision;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.center_focus_strong, color: AppTheme.successColor),
const SizedBox(width: 8),
const Text(
'Precision',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildPrecisionGauge(
'Precision',
precision.precisionScore,
'Distance moyenne du centre',
),
),
const SizedBox(width: 16),
Expanded(
child: _buildPrecisionGauge(
'Regularite',
precision.consistencyScore,
'Groupement des tirs',
),
),
],
),
const Divider(height: 32),
_buildStatRow('Distance moyenne du centre',
'${(precision.avgDistanceFromCenter * 100).toStringAsFixed(1)}%'),
_buildStatRow('Diametre de groupement',
'${(precision.groupingDiameter * 100).toStringAsFixed(1)}%'),
_buildStatRow('Score moyen',
_statistics!.avgScore.toStringAsFixed(2)),
_buildStatRow('Meilleur score', '${_statistics!.maxScore}'),
_buildStatRow('Plus bas score', '${_statistics!.minScore}'),
],
),
),
);
}
Widget _buildPrecisionGauge(String title, double value, String subtitle) {
final color = value > 70
? AppTheme.successColor
: value > 40
? AppTheme.warningColor
: AppTheme.errorColor;
return Column(
children: [
Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
value: value / 100,
strokeWidth: 8,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation(color),
),
),
Text(
'${value.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
const SizedBox(height: 8),
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
subtitle,
style: TextStyle(fontSize: 10, color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildStdDevSection() {
final stdDev = _statistics!.stdDev;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.stacked_line_chart, color: AppTheme.warningColor),
const SizedBox(width: 8),
const Text(
'Ecart Type',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const SizedBox(height: 8),
Text(
'Mesure de la dispersion de vos tirs',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
const SizedBox(height: 16),
_buildStatRow('Ecart type X (horizontal)',
'${(stdDev.stdDevX * 100).toStringAsFixed(2)}%'),
_buildStatRow('Ecart type Y (vertical)',
'${(stdDev.stdDevY * 100).toStringAsFixed(2)}%'),
_buildStatRow('Ecart type radial',
'${(stdDev.stdDevRadial * 100).toStringAsFixed(2)}%'),
_buildStatRow('Ecart type score',
stdDev.stdDevScore.toStringAsFixed(2)),
const Divider(height: 24),
_buildStatRow('Position moyenne X',
'${(stdDev.meanX * 100).toStringAsFixed(1)}%'),
_buildStatRow('Position moyenne Y',
'${(stdDev.meanY * 100).toStringAsFixed(1)}%'),
_buildStatRow('Score moyen',
stdDev.meanScore.toStringAsFixed(2)),
],
),
),
);
}
Widget _buildRegionalSection() {
final regional = _statistics!.regional;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.explore, color: AppTheme.secondaryColor),
const SizedBox(width: 8),
const Text(
'Distribution Regionale',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const SizedBox(height: 16),
// Dominant direction
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.compass_calibration, color: AppTheme.primaryColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Direction dominante'),
Text(
regional.dominantDirection,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
// Bias
if (regional.biasX.abs() > 0.02 || regional.biasY.abs() > 0.02)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.warningColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.warning_amber, color: AppTheme.warningColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Biais detecte'),
Text(
_getBiasDescription(regional.biasX, regional.biasY),
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
],
),
),
const SizedBox(height: 16),
// Sector distribution
const Text('Repartition par secteur:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: regional.sectorDistribution.entries.map((entry) {
final percentage = _statistics!.totalShots > 0
? (entry.value / _statistics!.totalShots * 100)
: 0.0;
return _buildSectorChip(entry.key, entry.value, percentage);
}).toList(),
),
const SizedBox(height: 16),
// Quadrant distribution
const Text('Repartition par quadrant:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
_buildQuadrantGrid(regional.quadrantDistribution),
],
),
),
);
}
Widget _buildStatRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label),
Text(value, style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
);
}
Widget _buildSectorChip(String sector, int count, double percentage) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: count > 0 ? AppTheme.primaryColor.withValues(alpha: 0.1) : Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade300,
),
),
child: Text(
'$sector: $count (${percentage.toStringAsFixed(0)}%)',
style: TextStyle(
fontSize: 12,
color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade600,
),
),
);
}
Widget _buildQuadrantGrid(Map<String, int> quadrants) {
return Table(
border: TableBorder.all(color: Colors.grey.shade300),
children: [
TableRow(
children: [
_buildQuadrantCell('Haut-Gauche', quadrants['Haut-Gauche'] ?? 0),
_buildQuadrantCell('Haut-Droite', quadrants['Haut-Droite'] ?? 0),
],
),
TableRow(
children: [
_buildQuadrantCell('Bas-Gauche', quadrants['Bas-Gauche'] ?? 0),
_buildQuadrantCell('Bas-Droite', quadrants['Bas-Droite'] ?? 0),
],
),
],
);
}
Widget _buildQuadrantCell(String label, int count) {
final percentage = _statistics!.totalShots > 0
? (count / _statistics!.totalShots * 100)
: 0.0;
final intensity = _statistics!.totalShots > 0
? count / _statistics!.totalShots
: 0.0;
return Container(
padding: const EdgeInsets.all(16),
color: Color.lerp(Colors.white, AppTheme.primaryColor, intensity * 0.5),
child: Column(
children: [
Text(
'$count',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
Text(
'${percentage.toStringAsFixed(0)}%',
style: TextStyle(color: Colors.grey.shade600),
),
Text(
label,
style: const TextStyle(fontSize: 10),
textAlign: TextAlign.center,
),
],
),
);
}
String _getBiasDescription(double biasX, double biasY) {
final descriptions = <String>[];
if (biasX.abs() > 0.02) {
descriptions.add(biasX > 0 ? 'vers la droite' : 'vers la gauche');
}
if (biasY.abs() > 0.02) {
descriptions.add(biasY > 0 ? 'vers le bas' : 'vers le haut');
}
return 'Tendance ${descriptions.join(' et ')}';
}
}
class _StatCard extends StatelessWidget {
final IconData icon;
final String title;
final String value;
final Color color;
const _StatCard({
required this.icon,
required this.title,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
title,
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,232 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../../services/statistics_service.dart';
class HeatMapWidget extends StatelessWidget {
final HeatMap heatMap;
final double size;
const HeatMapWidget({
super.key,
required this.heatMap,
this.size = 250,
});
@override
Widget build(BuildContext context) {
if (heatMap.zones.isEmpty || heatMap.totalShots == 0) {
return SizedBox(
width: size,
height: size,
child: const Center(
child: Text('Aucune donnee'),
),
);
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(7),
child: CustomPaint(
size: Size(size, size),
painter: _HeatMapFogPainter(heatMap: heatMap),
),
),
);
}
}
class _HeatMapFogPainter extends CustomPainter {
final HeatMap heatMap;
_HeatMapFogPainter({required this.heatMap});
@override
void paint(Canvas canvas, Size size) {
if (heatMap.zones.isEmpty) return;
// Draw base background (cold blue)
final bgPaint = Paint()
..color = const Color(0xFF1A237E).withValues(alpha: 0.3); // Dark blue base
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint);
final cellWidth = size.width / heatMap.gridSize;
final cellHeight = size.height / heatMap.gridSize;
// Collect all shot positions with their intensities for fog effect
final hotSpots = <_HotSpot>[];
for (int row = 0; row < heatMap.gridSize; row++) {
for (int col = 0; col < heatMap.gridSize; col++) {
final zone = heatMap.zones[row][col];
if (zone.shotCount > 0) {
hotSpots.add(_HotSpot(
x: (col + 0.5) * cellWidth,
y: (row + 0.5) * cellHeight,
intensity: zone.intensity,
shotCount: zone.shotCount,
));
}
}
}
// Draw fog effect using radial gradients for each hot spot
for (final spot in hotSpots) {
_drawFogSpot(canvas, size, spot, cellWidth, cellHeight);
}
// Draw target overlay (concentric circles)
final center = Offset(size.width / 2, size.height / 2);
final maxRadius = size.width / 2;
final circlePaint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = 1;
for (int i = 1; i <= 5; i++) {
canvas.drawCircle(center, maxRadius * (i / 5), circlePaint);
}
// Draw crosshair
canvas.drawLine(
Offset(center.dx, 0),
Offset(center.dx, size.height),
circlePaint,
);
canvas.drawLine(
Offset(0, center.dy),
Offset(size.width, center.dy),
circlePaint,
);
// Draw shot counts
for (final spot in hotSpots) {
final textPainter = TextPainter(
text: TextSpan(
text: '${spot.shotCount}',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
shadows: [
Shadow(color: Colors.black.withValues(alpha: 0.8), blurRadius: 4),
Shadow(color: Colors.black.withValues(alpha: 0.8), blurRadius: 2),
],
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(spot.x - textPainter.width / 2, spot.y - textPainter.height / 2),
);
}
}
void _drawFogSpot(Canvas canvas, Size size, _HotSpot spot, double cellWidth, double cellHeight) {
// Calculate fog radius based on intensity and cell size
final baseRadius = math.max(cellWidth, cellHeight) * 1.5;
final radius = baseRadius * (0.5 + spot.intensity * 0.5);
// Create gradient from hot (red/orange) to transparent
final gradient = ui.Gradient.radial(
Offset(spot.x, spot.y),
radius,
[
_getHeatColor(spot.intensity).withValues(alpha: 0.7 * spot.intensity + 0.3),
_getHeatColor(spot.intensity * 0.5).withValues(alpha: 0.3 * spot.intensity),
Colors.transparent,
],
[0.0, 0.5, 1.0],
);
final paint = Paint()
..shader = gradient
..blendMode = BlendMode.screen; // Additive blending for fog effect
canvas.drawCircle(Offset(spot.x, spot.y), radius, paint);
// Add a second layer for more intensity
if (spot.intensity > 0.3) {
final innerGradient = ui.Gradient.radial(
Offset(spot.x, spot.y),
radius * 0.6,
[
_getHeatColor(spot.intensity).withValues(alpha: 0.5 * spot.intensity),
Colors.transparent,
],
[0.0, 1.0],
);
final innerPaint = Paint()
..shader = innerGradient
..blendMode = BlendMode.screen;
canvas.drawCircle(Offset(spot.x, spot.y), radius * 0.6, innerPaint);
}
}
Color _getHeatColor(double intensity) {
// Gradient from blue (cold) to red (hot)
if (intensity <= 0) return const Color(0xFF2196F3); // Blue
if (intensity >= 1) return const Color(0xFFFF1744); // Red
// Interpolate between blue -> cyan -> yellow -> orange -> red
if (intensity < 0.25) {
// Blue to Cyan
return Color.lerp(
const Color(0xFF2196F3), // Blue
const Color(0xFF00BCD4), // Cyan
intensity / 0.25,
)!;
} else if (intensity < 0.5) {
// Cyan to Yellow
return Color.lerp(
const Color(0xFF00BCD4), // Cyan
const Color(0xFFFFEB3B), // Yellow
(intensity - 0.25) / 0.25,
)!;
} else if (intensity < 0.75) {
// Yellow to Orange
return Color.lerp(
const Color(0xFFFFEB3B), // Yellow
const Color(0xFFFF9800), // Orange
(intensity - 0.5) / 0.25,
)!;
} else {
// Orange to Red
return Color.lerp(
const Color(0xFFFF9800), // Orange
const Color(0xFFFF1744), // Red
(intensity - 0.75) / 0.25,
)!;
}
}
@override
bool shouldRepaint(covariant _HeatMapFogPainter oldDelegate) {
return heatMap != oldDelegate.heatMap;
}
}
class _HotSpot {
final double x;
final double y;
final double intensity;
final int shotCount;
_HotSpot({
required this.x,
required this.y,
required this.intensity,
required this.shotCount,
});
}

54
lib/main.dart Normal file
View File

@@ -0,0 +1,54 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'app.dart';
import 'data/repositories/session_repository.dart';
import 'services/target_detection_service.dart';
import 'services/score_calculator_service.dart';
import 'services/grouping_analyzer_service.dart';
import 'services/image_processing_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize date formatting for French locale
await initializeDateFormatting('fr_FR', null);
// Initialize FFI for desktop platforms
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
};
runApp(
MultiProvider(
providers: [
Provider<ImageProcessingService>(
create: (_) => ImageProcessingService(),
),
Provider<TargetDetectionService>(
create: (context) => TargetDetectionService(
imageProcessingService: context.read<ImageProcessingService>(),
),
),
Provider<ScoreCalculatorService>(
create: (_) => ScoreCalculatorService(),
),
Provider<GroupingAnalyzerService>(
create: (_) => GroupingAnalyzerService(),
),
Provider<SessionRepository>(
create: (_) => SessionRepository(),
),
],
child: const BullyApp(),
),
);
}

View File

@@ -0,0 +1,230 @@
import 'dart:math' as math;
import '../data/models/shot.dart';
class GroupingResult {
final double centerX; // Center of the group (relative 0-1)
final double centerY;
final double diameter; // Maximum spread diameter (relative 0-1)
final double standardDeviation; // Dispersion metric
final double meanRadius; // Average distance from center
final int shotCount;
GroupingResult({
required this.centerX,
required this.centerY,
required this.diameter,
required this.standardDeviation,
required this.meanRadius,
required this.shotCount,
});
/// Grouping quality rating (1-5 stars)
int get qualityRating {
// Based on standard deviation relative to typical target size
if (standardDeviation < 0.02) return 5;
if (standardDeviation < 0.04) return 4;
if (standardDeviation < 0.06) return 3;
if (standardDeviation < 0.10) return 2;
return 1;
}
String get qualityDescription {
switch (qualityRating) {
case 5:
return 'Excellent';
case 4:
return 'Tres bien';
case 3:
return 'Bien';
case 2:
return 'Moyen';
default:
return 'A ameliorer';
}
}
factory GroupingResult.empty() {
return GroupingResult(
centerX: 0.5,
centerY: 0.5,
diameter: 0,
standardDeviation: 0,
meanRadius: 0,
shotCount: 0,
);
}
}
class GroupingAnalyzerService {
/// Analyze the grouping of a list of shots
GroupingResult analyzeGrouping(List<Shot> shots) {
if (shots.isEmpty) {
return GroupingResult.empty();
}
if (shots.length == 1) {
return GroupingResult(
centerX: shots.first.x,
centerY: shots.first.y,
diameter: 0,
standardDeviation: 0,
meanRadius: 0,
shotCount: 1,
);
}
// Calculate center of group (centroid)
double sumX = 0;
double sumY = 0;
for (final shot in shots) {
sumX += shot.x;
sumY += shot.y;
}
final centerX = sumX / shots.length;
final centerY = sumY / shots.length;
// Calculate distances from center
final distances = <double>[];
for (final shot in shots) {
final dx = shot.x - centerX;
final dy = shot.y - centerY;
distances.add(math.sqrt(dx * dx + dy * dy));
}
// Calculate mean radius
final meanRadius = distances.reduce((a, b) => a + b) / distances.length;
// Calculate standard deviation
double sumSquaredDiff = 0;
for (final distance in distances) {
sumSquaredDiff += math.pow(distance - meanRadius, 2);
}
final standardDeviation = math.sqrt(sumSquaredDiff / distances.length);
// Calculate maximum spread (diameter)
// Find the two points that are farthest apart
double maxDistance = 0;
for (int i = 0; i < shots.length; i++) {
for (int j = i + 1; j < shots.length; j++) {
final dx = shots[i].x - shots[j].x;
final dy = shots[i].y - shots[j].y;
final distance = math.sqrt(dx * dx + dy * dy);
if (distance > maxDistance) {
maxDistance = distance;
}
}
}
return GroupingResult(
centerX: centerX,
centerY: centerY,
diameter: maxDistance,
standardDeviation: standardDeviation,
meanRadius: meanRadius,
shotCount: shots.length,
);
}
/// Calculate offset from target center
(double, double) calculateOffset({
required double groupCenterX,
required double groupCenterY,
required double targetCenterX,
required double targetCenterY,
}) {
return (
groupCenterX - targetCenterX,
groupCenterY - targetCenterY,
);
}
/// Get directional offset description (e.g., "haut-gauche")
String getOffsetDescription(double offsetX, double offsetY) {
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
return 'Centre';
}
String vertical = '';
String horizontal = '';
if (offsetY < -0.02) {
vertical = 'Haut';
} else if (offsetY > 0.02) {
vertical = 'Bas';
}
if (offsetX < -0.02) {
horizontal = 'Gauche';
} else if (offsetX > 0.02) {
horizontal = 'Droite';
}
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
return '$vertical-$horizontal';
}
return vertical.isNotEmpty ? vertical : horizontal;
}
/// Analyze trend across multiple sessions
GroupingTrend analyzeTrend(List<GroupingResult> results) {
if (results.length < 2) {
return GroupingTrend(
improving: false,
averageDiameter: results.isEmpty ? 0 : results.first.diameter,
recentDiameter: results.isEmpty ? 0 : results.first.diameter,
improvementPercentage: 0,
);
}
// Calculate averages for first half vs second half
final midpoint = results.length ~/ 2;
final firstHalf = results.sublist(0, midpoint);
final secondHalf = results.sublist(midpoint);
double firstHalfAvg = 0;
for (final r in firstHalf) {
firstHalfAvg += r.diameter;
}
firstHalfAvg /= firstHalf.length;
double secondHalfAvg = 0;
for (final r in secondHalf) {
secondHalfAvg += r.diameter;
}
secondHalfAvg /= secondHalf.length;
// Overall average
double totalAvg = 0;
for (final r in results) {
totalAvg += r.diameter;
}
totalAvg /= results.length;
final improvement = ((firstHalfAvg - secondHalfAvg) / firstHalfAvg) * 100;
return GroupingTrend(
improving: secondHalfAvg < firstHalfAvg,
averageDiameter: totalAvg,
recentDiameter: secondHalfAvg,
improvementPercentage: improvement,
);
}
}
class GroupingTrend {
final bool improving;
final double averageDiameter;
final double recentDiameter;
final double improvementPercentage;
GroupingTrend({
required this.improving,
required this.averageDiameter,
required this.recentDiameter,
required this.improvementPercentage,
});
}

View File

@@ -0,0 +1,775 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:image/image.dart' as img;
class DetectedCircle {
final double centerX;
final double centerY;
final double radius;
DetectedCircle({
required this.centerX,
required this.centerY,
required this.radius,
});
}
class DetectedImpact {
final double x;
final double y;
final double radius;
DetectedImpact({
required this.x,
required this.y,
required this.radius,
});
}
/// Image processing settings for impact detection
class ImpactDetectionSettings {
/// Threshold for dark spot detection (0-255, lower = darker)
final int darkThreshold;
/// Minimum impact size in pixels
final int minImpactSize;
/// Maximum impact size in pixels
final int maxImpactSize;
/// Blur radius for noise reduction
final int blurRadius;
/// Contrast enhancement factor
final double contrastFactor;
/// Minimum circularity (0-1, 1 = perfect circle)
/// Used to filter out non-circular shapes like numbers
final double minCircularity;
/// Maximum aspect ratio (width/height or height/width)
/// Used to filter out elongated shapes like numbers
final double maxAspectRatio;
/// Minimum fill ratio (0-1, ~0.7 for filled circle, lower for rings/hollow shapes)
/// A bullet hole is FILLED, numbers on target are hollow rings
final double minFillRatio;
const ImpactDetectionSettings({
this.darkThreshold = 80,
this.minImpactSize = 20,
this.maxImpactSize = 500,
this.blurRadius = 2,
this.contrastFactor = 1.2,
this.minCircularity = 0.6,
this.maxAspectRatio = 2.0,
this.minFillRatio = 0.5, // Filled circles should have ratio > 0.5
});
}
/// Reference impact for calibrated detection
class ReferenceImpact {
final double x; // Normalized 0-1
final double y; // Normalized 0-1
const ReferenceImpact({required this.x, required this.y});
}
/// Characteristics learned from reference impacts
class ImpactCharacteristics {
final double avgLuminance;
final double luminanceStdDev;
final double avgSize;
final double sizeStdDev;
final double avgCircularity;
final double avgFillRatio; // How filled is the blob vs its bounding circle
final double avgDarkThreshold; // The threshold used to detect the blob
const ImpactCharacteristics({
required this.avgLuminance,
required this.luminanceStdDev,
required this.avgSize,
required this.sizeStdDev,
required this.avgCircularity,
required this.avgFillRatio,
required this.avgDarkThreshold,
});
@override
String toString() {
return 'ImpactCharacteristics(lum: ${avgLuminance.toStringAsFixed(1)} ± ${luminanceStdDev.toStringAsFixed(1)}, '
'size: ${avgSize.toStringAsFixed(1)} ± ${sizeStdDev.toStringAsFixed(1)}, '
'circ: ${avgCircularity.toStringAsFixed(2)}, fill: ${avgFillRatio.toStringAsFixed(2)})';
}
}
/// Service for image processing and impact detection
class ImageProcessingService {
/// Detect the main target circle from an image
DetectedCircle? detectMainTarget(String imagePath) {
// Return center of image as target (basic implementation)
// Could be enhanced with circle detection algorithm
return DetectedCircle(
centerX: 0.5,
centerY: 0.5,
radius: 0.4,
);
}
/// Detect impacts (bullet holes) from an image file
List<DetectedImpact> detectImpacts(String imagePath) {
return detectImpactsWithSettings(
imagePath,
const ImpactDetectionSettings(),
);
}
/// Detect impacts with custom settings
List<DetectedImpact> detectImpactsWithSettings(
String imagePath,
ImpactDetectionSettings settings,
) {
try {
// Load the image
final file = File(imagePath);
final bytes = file.readAsBytesSync();
final originalImage = img.decodeImage(bytes);
if (originalImage == null) {
return [];
}
// Resize for faster processing if image is too large
img.Image image;
final maxDimension = 1000;
if (originalImage.width > maxDimension || originalImage.height > maxDimension) {
final scale = maxDimension / math.max(originalImage.width, originalImage.height);
image = img.copyResize(
originalImage,
width: (originalImage.width * scale).round(),
height: (originalImage.height * scale).round(),
);
} else {
image = originalImage;
}
// Convert to grayscale
final grayscale = img.grayscale(image);
// Apply gaussian blur to reduce noise
final blurred = img.gaussianBlur(grayscale, radius: settings.blurRadius);
// Enhance contrast
final enhanced = img.adjustColor(
blurred,
contrast: settings.contrastFactor,
);
// Detect dark spots (potential impacts)
// Filter by circularity and fill ratio to avoid detecting numbers (hollow rings)
final impacts = _detectDarkSpots(
enhanced,
settings.darkThreshold,
settings.minImpactSize,
settings.maxImpactSize,
minCircularity: settings.minCircularity,
maxAspectRatio: settings.maxAspectRatio,
minFillRatio: settings.minFillRatio,
);
// Convert to relative coordinates
final width = originalImage.width.toDouble();
final height = originalImage.height.toDouble();
return impacts.map((impact) {
return DetectedImpact(
x: impact.x / width,
y: impact.y / height,
radius: impact.radius,
);
}).toList();
} catch (e) {
print('Error detecting impacts: $e');
return [];
}
}
/// Analyze reference impacts to learn their characteristics
/// This actually finds the blob at each reference point and extracts its real properties
ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath,
List<ReferenceImpact> references, {
int searchRadius = 30,
}) {
if (references.length < 2) return null;
try {
final file = File(imagePath);
final bytes = file.readAsBytesSync();
final originalImage = img.decodeImage(bytes);
if (originalImage == null) return null;
// Resize for faster processing
img.Image image;
double scale = 1.0;
final maxDimension = 1000;
if (originalImage.width > maxDimension || originalImage.height > maxDimension) {
scale = maxDimension / math.max(originalImage.width, originalImage.height);
image = img.copyResize(
originalImage,
width: (originalImage.width * scale).round(),
height: (originalImage.height * scale).round(),
);
} else {
image = originalImage;
}
final grayscale = img.grayscale(image);
final blurred = img.gaussianBlur(grayscale, radius: 2);
final width = image.width;
final height = image.height;
final luminances = <double>[];
final sizes = <double>[];
final circularities = <double>[];
final fillRatios = <double>[];
final thresholds = <double>[];
for (final ref in references) {
final centerX = (ref.x * width).round().clamp(0, width - 1);
final centerY = (ref.y * height).round().clamp(0, height - 1);
// Find the darkest point in the search area (the center of the impact)
int darkestX = centerX;
int darkestY = centerY;
double darkestLum = 255;
for (int dy = -searchRadius; dy <= searchRadius; dy++) {
for (int dx = -searchRadius; dx <= searchRadius; dx++) {
final px = centerX + dx;
final py = centerY + dy;
if (px < 0 || px >= width || py < 0 || py >= height) continue;
final pixel = blurred.getPixel(px, py);
final lum = img.getLuminance(pixel).toDouble();
if (lum < darkestLum) {
darkestLum = lum;
darkestX = px;
darkestY = py;
}
}
}
// Now find the blob at the darkest point using adaptive threshold
// Start from the darkest point and expand until we find the boundary
final blobResult = _findBlobAtPoint(blurred, darkestX, darkestY, width, height);
if (blobResult != null) {
luminances.add(blobResult.avgLuminance);
sizes.add(blobResult.size.toDouble());
circularities.add(blobResult.circularity);
fillRatios.add(blobResult.fillRatio);
thresholds.add(blobResult.threshold);
}
}
if (luminances.isEmpty) return null;
// Calculate statistics
final avgLum = luminances.reduce((a, b) => a + b) / luminances.length;
final avgSize = sizes.reduce((a, b) => a + b) / sizes.length;
final avgCirc = circularities.reduce((a, b) => a + b) / circularities.length;
final avgFill = fillRatios.reduce((a, b) => a + b) / fillRatios.length;
final avgThreshold = thresholds.reduce((a, b) => a + b) / thresholds.length;
// Calculate standard deviations
double lumVariance = 0;
double sizeVariance = 0;
for (int i = 0; i < luminances.length; i++) {
lumVariance += math.pow(luminances[i] - avgLum, 2);
sizeVariance += math.pow(sizes[i] - avgSize, 2);
}
final lumStdDev = math.sqrt(lumVariance / luminances.length);
final sizeStdDev = math.sqrt(sizeVariance / sizes.length);
return ImpactCharacteristics(
avgLuminance: avgLum,
luminanceStdDev: lumStdDev,
avgSize: avgSize,
sizeStdDev: sizeStdDev,
avgCircularity: avgCirc,
avgFillRatio: avgFill,
avgDarkThreshold: avgThreshold,
);
} catch (e) {
print('Error analyzing reference impacts: $e');
return null;
}
}
/// Find a blob at a specific point and extract its characteristics
_BlobAnalysis? _findBlobAtPoint(img.Image image, int startX, int startY, int width, int height) {
// Get the luminance at the center point
final centerPixel = image.getPixel(startX, startY);
final centerLum = img.getLuminance(centerPixel).toDouble();
// Find the threshold by looking at the luminance gradient around the point
// Sample in expanding circles to find where the blob ends
double sumLum = centerLum;
int pixelCount = 1;
double maxRadius = 0;
// Sample at different radii to find the edge
for (int r = 1; r <= 50; r++) {
double ringSum = 0;
int ringCount = 0;
// Sample points on a ring
for (int i = 0; i < 16; i++) {
final angle = (i / 16) * 2 * math.pi;
final px = startX + (r * math.cos(angle)).round();
final py = startY + (r * math.sin(angle)).round();
if (px < 0 || px >= width || py < 0 || py >= height) continue;
final pixel = image.getPixel(px, py);
final lum = img.getLuminance(pixel).toDouble();
ringSum += lum;
ringCount++;
}
if (ringCount > 0) {
final avgRingLum = ringSum / ringCount;
// If the ring is significantly brighter than the center, we've found the edge
if (avgRingLum > centerLum + 40) {
maxRadius = r.toDouble();
break;
}
sumLum += ringSum;
pixelCount += ringCount;
}
}
if (maxRadius < 3) return null; // Too small to be a valid blob
// Calculate threshold as the midpoint between center and edge luminance
final edgeRadius = (maxRadius * 1.2).round();
double edgeLum = 0;
int edgeCount = 0;
for (int i = 0; i < 16; i++) {
final angle = (i / 16) * 2 * math.pi;
final px = startX + (edgeRadius * math.cos(angle)).round();
final py = startY + (edgeRadius * math.sin(angle)).round();
if (px < 0 || px >= width || py < 0 || py >= height) continue;
final pixel = image.getPixel(px, py);
edgeLum += img.getLuminance(pixel).toDouble();
edgeCount++;
}
if (edgeCount > 0) {
edgeLum /= edgeCount;
}
final threshold = ((centerLum + edgeLum) / 2).round();
// Now do a flood fill with this threshold to get the actual blob
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 lum = img.getLuminance(pixel);
mask[y][x] = lum < threshold;
}
}
final visited = List.generate(height, (_) => List.filled(width, false));
// Find the blob containing the start point
if (!mask[startY][startX]) {
// Start point might not be in mask, find nearest point that is
for (int r = 1; r <= 10; r++) {
bool found = false;
for (int dy = -r; dy <= r && !found; dy++) {
for (int dx = -r; dx <= r && !found; dx++) {
final px = startX + dx;
final py = startY + dy;
if (px >= 0 && px < width && py >= 0 && py < height && mask[py][px]) {
final blob = _floodFill(mask, visited, px, py, width, height);
// Calculate fill ratio: actual pixels / bounding circle area
final boundingRadius = math.max(blob.radius, 1);
final boundingCircleArea = math.pi * boundingRadius * boundingRadius;
final fillRatio = (blob.size / boundingCircleArea).clamp(0.0, 1.0);
return _BlobAnalysis(
avgLuminance: sumLum / pixelCount,
size: blob.size,
circularity: blob.circularity,
fillRatio: fillRatio,
threshold: threshold.toDouble(),
);
}
}
}
}
return null;
}
final blob = _floodFill(mask, visited, startX, startY, width, height);
// Calculate fill ratio
final boundingRadius = math.max(blob.radius, 1);
final boundingCircleArea = math.pi * boundingRadius * boundingRadius;
final fillRatio = (blob.size / boundingCircleArea).clamp(0.0, 1.0);
return _BlobAnalysis(
avgLuminance: sumLum / pixelCount,
size: blob.size,
circularity: blob.circularity,
fillRatio: fillRatio,
threshold: threshold.toDouble(),
);
}
/// Detect impacts based on reference characteristics with tolerance
List<DetectedImpact> detectImpactsFromReferences(
String imagePath,
ImpactCharacteristics characteristics, {
double tolerance = 2.0, // Number of standard deviations
double minCircularity = 0.4,
}) {
try {
final file = File(imagePath);
final bytes = file.readAsBytesSync();
final originalImage = img.decodeImage(bytes);
if (originalImage == null) return [];
// Resize for faster processing
img.Image image;
double scale = 1.0;
final maxDimension = 1000;
if (originalImage.width > maxDimension || originalImage.height > maxDimension) {
scale = maxDimension / math.max(originalImage.width, originalImage.height);
image = img.copyResize(
originalImage,
width: (originalImage.width * scale).round(),
height: (originalImage.height * scale).round(),
);
} else {
image = originalImage;
}
final grayscale = img.grayscale(image);
final blurred = img.gaussianBlur(grayscale, radius: 2);
// Use the threshold learned from references
final threshold = characteristics.avgDarkThreshold.round();
// Calculate size range based on learned characteristics
final minSize = (characteristics.avgSize / (tolerance * 2)).clamp(5, 10000).round();
final maxSize = (characteristics.avgSize * tolerance * 2).clamp(10, 10000).round();
// Calculate minimum fill ratio based on learned characteristics
// Allow some variance but still filter out hollow shapes
final minFillRatio = (characteristics.avgFillRatio - 0.2).clamp(0.3, 0.9);
// Detect blobs using the learned threshold
final impacts = _detectDarkSpots(
blurred,
threshold,
minSize,
maxSize,
minCircularity: math.max(characteristics.avgCircularity - 0.2, minCircularity),
minFillRatio: minFillRatio,
);
// Convert to relative coordinates
final width = originalImage.width.toDouble();
final height = originalImage.height.toDouble();
return impacts.map((impact) {
return DetectedImpact(
x: impact.x / image.width,
y: impact.y / image.height,
radius: impact.radius / scale,
);
}).toList();
} catch (e) {
print('Error detecting impacts from references: $e');
return [];
}
}
/// Detect dark spots with adaptive luminance range
List<_Blob> _detectDarkSpotsAdaptive(
img.Image image,
int minLuminance,
int maxLuminance,
int minSize,
int maxSize, {
double minCircularity = 0.5,
double minFillRatio = 0.5,
}) {
final width = image.width;
final height = image.height;
// Create binary mask of pixels within luminance range
final mask = List.generate(height, (_) => List.filled(width, false));
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
final pixel = image.getPixel(x, y);
final luminance = img.getLuminance(pixel);
mask[y][x] = luminance >= minLuminance && luminance <= maxLuminance;
}
}
// Find connected components
final visited = List.generate(height, (_) => List.filled(width, false));
final blobs = <_Blob>[];
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (mask[y][x] && !visited[y][x]) {
final blob = _floodFill(mask, visited, x, y, width, height);
if (blob.size >= minSize &&
blob.size <= maxSize &&
blob.circularity >= minCircularity &&
blob.fillRatio >= minFillRatio) {
blobs.add(blob);
}
}
}
}
return _filterOverlappingBlobs(blobs);
}
/// Detect dark spots in a grayscale image using blob detection
List<_Blob> _detectDarkSpots(
img.Image image,
int threshold,
int minSize,
int maxSize, {
double minCircularity = 0.6,
double maxAspectRatio = 2.0,
double minFillRatio = 0.5,
}) {
final width = image.width;
final height = image.height;
// Create binary mask of dark pixels
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 < threshold;
}
}
// Find connected components (blobs)
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);
// Filter by size
if (blob.size < minSize || blob.size > maxSize) continue;
// Filter by circularity (reject non-circular shapes like numbers)
if (blob.circularity < minCircularity) continue;
// Filter by aspect ratio (reject elongated shapes)
if (blob.aspectRatio > maxAspectRatio) continue;
// Filter by fill ratio (reject hollow rings - numbers on target)
// A filled bullet hole should have fill ratio > 0.5
// A hollow ring (like number "0" or "8") has a much lower fill ratio
if (blob.fillRatio < minFillRatio) continue;
blobs.add(blob);
}
}
}
// Filter overlapping blobs (keep larger ones)
final filteredBlobs = _filterOverlappingBlobs(blobs);
return filteredBlobs;
}
/// Flood fill to find connected component
_Blob _floodFill(
List<List<bool>> mask,
List<List<bool>> visited,
int startX,
int startY,
int width,
int height,
) {
final stack = <_Point>[_Point(startX, startY)];
final points = <_Point>[];
int minX = startX, maxX = startX;
int minY = startY, maxY = startY;
int perimeterCount = 0;
while (stack.isNotEmpty) {
final point = stack.removeLast();
final x = point.x;
final y = point.y;
if (x < 0 || x >= width || y < 0 || y >= height) continue;
if (visited[y][x] || !mask[y][x]) continue;
visited[y][x] = true;
points.add(point);
minX = math.min(minX, x);
maxX = math.max(maxX, x);
minY = math.min(minY, y);
maxY = math.max(maxY, y);
// Check if this is a perimeter pixel (has at least one non-blob neighbor)
bool isPerimeter = false;
for (final delta in [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
final nx = x + delta[0];
final ny = y + delta[1];
if (nx < 0 || nx >= width || ny < 0 || ny >= height || !mask[ny][nx]) {
isPerimeter = true;
break;
}
}
if (isPerimeter) perimeterCount++;
// Add neighbors (4-connectivity)
stack.add(_Point(x + 1, y));
stack.add(_Point(x - 1, y));
stack.add(_Point(x, y + 1));
stack.add(_Point(x, y - 1));
}
// Calculate centroid
double sumX = 0, sumY = 0;
for (final p in points) {
sumX += p.x;
sumY += p.y;
}
final centerX = points.isNotEmpty ? sumX / points.length : startX.toDouble();
final centerY = points.isNotEmpty ? sumY / points.length : startY.toDouble();
// Calculate bounding box dimensions
final blobWidth = (maxX - minX + 1).toDouble();
final blobHeight = (maxY - minY + 1).toDouble();
// Calculate approximate radius based on bounding box
final radius = math.max(blobWidth, blobHeight) / 2.0;
// Calculate circularity: 4 * pi * area / perimeter^2
// For a perfect circle, this equals 1
final area = points.length.toDouble();
final perimeter = perimeterCount.toDouble();
final circularity = perimeter > 0
? (4 * math.pi * area) / (perimeter * perimeter)
: 0.0;
// Calculate aspect ratio (always >= 1)
final aspectRatio = blobWidth > blobHeight
? blobWidth / blobHeight
: blobHeight / blobWidth;
// Calculate fill ratio: actual area vs bounding circle area
// A filled circle has fill ratio ~0.78 (pi/4), a ring/hollow circle has much lower
final boundingCircleArea = math.pi * radius * radius;
final fillRatio = boundingCircleArea > 0 ? (area / boundingCircleArea).clamp(0.0, 1.0) : 0.0;
return _Blob(
x: centerX,
y: centerY,
radius: radius,
size: points.length,
circularity: circularity.clamp(0.0, 1.0),
aspectRatio: aspectRatio,
fillRatio: fillRatio,
);
}
/// Filter overlapping blobs, keeping the larger ones
List<_Blob> _filterOverlappingBlobs(List<_Blob> blobs) {
if (blobs.isEmpty) return [];
// Sort by size (largest first)
blobs.sort((a, b) => b.size.compareTo(a.size));
final filtered = <_Blob>[];
for (final blob in blobs) {
bool overlaps = false;
for (final existing in filtered) {
final dx = blob.x - existing.x;
final dy = blob.y - existing.y;
final distance = math.sqrt(dx * dx + dy * dy);
// Check if blobs overlap
if (distance < (blob.radius + existing.radius) * 0.8) {
overlaps = true;
break;
}
}
if (!overlaps) {
filtered.add(blob);
}
}
return filtered;
}
}
class _Point {
final int x;
final int y;
_Point(this.x, this.y);
}
class _Blob {
final double x;
final double y;
final double radius;
final int size;
final double circularity; // 0-1, 1 = perfect circle
final double aspectRatio; // width/height ratio
final double fillRatio; // How filled vs hollow the blob is
_Blob({
required this.x,
required this.y,
required this.radius,
required this.size,
required this.circularity,
required this.aspectRatio,
this.fillRatio = 1.0,
});
}
class _BlobAnalysis {
final double avgLuminance;
final int size;
final double circularity;
final double fillRatio;
final double threshold;
_BlobAnalysis({
required this.avgLuminance,
required this.size,
required this.circularity,
required this.fillRatio,
required this.threshold,
});
}

View File

@@ -0,0 +1,220 @@
import 'dart:math' as math;
import '../data/models/shot.dart';
import '../data/models/target_type.dart';
import '../core/constants/app_constants.dart';
class ScoreResult {
final int totalScore;
final int maxPossibleScore;
final double percentage;
final Map<int, int> scoreDistribution; // score -> count
final int shotCount;
ScoreResult({
required this.totalScore,
required this.maxPossibleScore,
required this.percentage,
required this.scoreDistribution,
required this.shotCount,
});
}
class ScoreCalculatorService {
/// Calculate score for a single shot on a concentric target
/// ringCount determines the number of scoring zones (default 10)
/// Center is always 10 (bullseye), each ring decrements by 1
/// Example: 5 rings = 10, 9, 8, 7, 6
/// imageAspectRatio is width/height to account for non-square images
int calculateConcentricScore({
required double shotX,
required double shotY,
required double targetCenterX,
required double targetCenterY,
required double targetRadius,
int ringCount = 10,
double imageAspectRatio = 1.0,
List<double>? ringRadii, // Optional individual ring radii multipliers
}) {
final dx = shotX - targetCenterX;
final dy = shotY - targetCenterY;
// Account for aspect ratio to match visual representation
// The visual uses min(width, height) for the radius
double normalizedDistance;
if (imageAspectRatio >= 1.0) {
// Landscape or square: scale x by aspectRatio
normalizedDistance = math.sqrt(dx * dx * imageAspectRatio * imageAspectRatio + dy * dy) / targetRadius;
} else {
// Portrait: scale y by 1/aspectRatio
normalizedDistance = math.sqrt(dx * dx + dy * dy / (imageAspectRatio * imageAspectRatio)) / targetRadius;
}
// Use custom ring radii if provided, otherwise use equal spacing
if (ringRadii != null && ringRadii.length == ringCount) {
for (int i = 0; i < ringCount; i++) {
if (normalizedDistance <= ringRadii[i]) {
// Center = 10, decrement by 1 for each ring
return 10 - i;
}
}
} else {
// Generate dynamic zone radii based on ring count
// Each zone has equal width
for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount;
if (normalizedDistance <= zoneRadius) {
// Center = 10, decrement by 1 for each ring
return 10 - i;
}
}
}
return 0; // Outside target
}
/// Calculate score for a single shot on a silhouette target
int calculateSilhouetteScore({
required double shotX,
required double shotY,
required double targetCenterX,
required double targetCenterY,
required double targetWidth,
required double targetHeight,
}) {
// Check if shot is within silhouette bounds
final relativeX = (shotX - targetCenterX).abs() / (targetWidth / 2);
final relativeY = (shotY - (targetCenterY - targetHeight / 2)) / targetHeight;
// Outside horizontal bounds
if (relativeX > 1.0) return 0;
// Check vertical zones (from top of silhouette)
if (relativeY < 0) return 0; // Above silhouette
if (relativeY <= AppConstants.silhouetteZones['head']!) {
// Head zone - 5 points, but narrower
if (relativeX <= 0.4) return 5;
} else if (relativeY <= AppConstants.silhouetteZones['center']!) {
// Center mass - 5 points
if (relativeX <= 0.6) return 5;
} else if (relativeY <= AppConstants.silhouetteZones['body']!) {
// Body - 4 points
if (relativeX <= 0.7) return 4;
} else if (relativeY <= AppConstants.silhouetteZones['lower']!) {
// Lower body - 3 points
if (relativeX <= 0.5) return 3;
}
return 0; // Outside target
}
/// Calculate scores for all shots
ScoreResult calculateScores({
required List<Shot> shots,
required TargetType targetType,
required double targetCenterX,
required double targetCenterY,
double? targetRadius, // For concentric
double? targetWidth, // For silhouette
double? targetHeight, // For silhouette
int ringCount = 10, // For concentric
double imageAspectRatio = 1.0, // For concentric
List<double>? ringRadii, // For concentric with individual ring radii
}) {
final scoreDistribution = <int, int>{};
int totalScore = 0;
for (final shot in shots) {
int score;
if (targetType == TargetType.concentric) {
score = calculateConcentricScore(
shotX: shot.x,
shotY: shot.y,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetRadius: targetRadius ?? 0.4,
ringCount: ringCount,
imageAspectRatio: imageAspectRatio,
ringRadii: ringRadii,
);
} else {
score = calculateSilhouetteScore(
shotX: shot.x,
shotY: shot.y,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetWidth: targetWidth ?? 0.3,
targetHeight: targetHeight ?? 0.7,
);
}
totalScore += score;
scoreDistribution[score] = (scoreDistribution[score] ?? 0) + 1;
}
final maxScore = targetType == TargetType.concentric ? 10 : 5;
final maxPossibleScore = shots.length * maxScore;
final percentage = maxPossibleScore > 0
? (totalScore / maxPossibleScore) * 100
: 0.0;
return ScoreResult(
totalScore: totalScore,
maxPossibleScore: maxPossibleScore,
percentage: percentage,
scoreDistribution: scoreDistribution,
shotCount: shots.length,
);
}
/// Recalculate a shot's score
Shot recalculateShot({
required Shot shot,
required TargetType targetType,
required double targetCenterX,
required double targetCenterY,
double? targetRadius,
double? targetWidth,
double? targetHeight,
int ringCount = 10,
double imageAspectRatio = 1.0,
List<double>? ringRadii,
}) {
int newScore;
if (targetType == TargetType.concentric) {
newScore = calculateConcentricScore(
shotX: shot.x,
shotY: shot.y,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetRadius: targetRadius ?? 0.4,
ringCount: ringCount,
imageAspectRatio: imageAspectRatio,
ringRadii: ringRadii,
);
} else {
newScore = calculateSilhouetteScore(
shotX: shot.x,
shotY: shot.y,
targetCenterX: targetCenterX,
targetCenterY: targetCenterY,
targetWidth: targetWidth ?? 0.3,
targetHeight: targetHeight ?? 0.7,
);
}
return shot.copyWith(score: newScore);
}
/// Get score color index (0-9 for zone colors)
int getScoreColorIndex(int score, TargetType targetType) {
if (targetType == TargetType.concentric) {
return (10 - score).clamp(0, 9);
} else {
// Map silhouette scores (0-5) to color indices
return ((5 - score) * 2).clamp(0, 9);
}
}
}

View File

@@ -0,0 +1,531 @@
import 'dart:math' as math;
import '../data/models/session.dart';
import '../data/models/shot.dart';
/// Time period for filtering statistics
enum StatsPeriod {
session, // Single session
week, // Last 7 days
month, // Last 30 days
all, // All time
}
/// Heat zone data for a region of the target
class HeatZone {
final int row;
final int col;
final int shotCount;
final double intensity; // 0-1, normalized
final double avgScore;
const HeatZone({
required this.row,
required this.col,
required this.shotCount,
required this.intensity,
required this.avgScore,
});
}
/// Heat map grid for the target
class HeatMap {
final int gridSize;
final List<List<HeatZone>> zones;
final int maxShotsInZone;
final int totalShots;
const HeatMap({
required this.gridSize,
required this.zones,
required this.maxShotsInZone,
required this.totalShots,
});
}
/// Precision statistics
class PrecisionStats {
/// Average distance from target center (0-1 normalized)
final double avgDistanceFromCenter;
/// Grouping diameter (spread of shots)
final double groupingDiameter;
/// Precision score (0-100, higher = better)
final double precisionScore;
/// Consistency score based on standard deviation (0-100)
final double consistencyScore;
const PrecisionStats({
required this.avgDistanceFromCenter,
required this.groupingDiameter,
required this.precisionScore,
required this.consistencyScore,
});
}
/// Standard deviation statistics
class StdDevStats {
/// Standard deviation of X positions
final double stdDevX;
/// Standard deviation of Y positions
final double stdDevY;
/// Combined standard deviation (radial)
final double stdDevRadial;
/// Standard deviation of scores
final double stdDevScore;
/// Mean X position
final double meanX;
/// Mean Y position
final double meanY;
/// Mean score
final double meanScore;
const StdDevStats({
required this.stdDevX,
required this.stdDevY,
required this.stdDevRadial,
required this.stdDevScore,
required this.meanX,
required this.meanY,
required this.meanScore,
});
}
/// Regional distribution (quadrants or sectors)
class RegionalStats {
/// Shot distribution by quadrant (top-left, top-right, bottom-left, bottom-right)
final Map<String, int> quadrantDistribution;
/// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center)
final Map<String, int> sectorDistribution;
/// Dominant direction (where most shots land)
final String dominantDirection;
/// Bias offset from center
final double biasX;
final double biasY;
const RegionalStats({
required this.quadrantDistribution,
required this.sectorDistribution,
required this.dominantDirection,
required this.biasX,
required this.biasY,
});
}
/// Complete statistics result
class SessionStatistics {
final int totalShots;
final int totalScore;
final double avgScore;
final int maxScore;
final int minScore;
final HeatMap heatMap;
final PrecisionStats precision;
final StdDevStats stdDev;
final RegionalStats regional;
final List<Session> sessions;
final StatsPeriod period;
const SessionStatistics({
required this.totalShots,
required this.totalScore,
required this.avgScore,
required this.maxScore,
required this.minScore,
required this.heatMap,
required this.precision,
required this.stdDev,
required this.regional,
required this.sessions,
required this.period,
});
}
/// Service for calculating shooting statistics
class StatisticsService {
/// Calculate statistics for given sessions
SessionStatistics calculateStatistics(
List<Session> sessions, {
StatsPeriod period = StatsPeriod.all,
double targetCenterX = 0.5,
double targetCenterY = 0.5,
}) {
// Filter sessions by period
final filteredSessions = _filterByPeriod(sessions, period);
// Collect all shots
final allShots = <Shot>[];
for (final session in filteredSessions) {
allShots.addAll(session.shots);
}
if (allShots.isEmpty) {
return _emptyStatistics(period, filteredSessions);
}
// Calculate basic stats
final totalShots = allShots.length;
final totalScore = allShots.fold<int>(0, (sum, shot) => sum + shot.score);
final avgScore = totalScore / totalShots;
final maxScore = allShots.map((s) => s.score).reduce(math.max);
final minScore = allShots.map((s) => s.score).reduce(math.min);
// Calculate heat map
final heatMap = _calculateHeatMap(allShots, gridSize: 5);
// Calculate precision
final precision = _calculatePrecision(allShots, targetCenterX, targetCenterY);
// Calculate standard deviation
final stdDev = _calculateStdDev(allShots);
// Calculate regional distribution
final regional = _calculateRegional(allShots, targetCenterX, targetCenterY);
return SessionStatistics(
totalShots: totalShots,
totalScore: totalScore,
avgScore: avgScore,
maxScore: maxScore,
minScore: minScore,
heatMap: heatMap,
precision: precision,
stdDev: stdDev,
regional: regional,
sessions: filteredSessions,
period: period,
);
}
/// Filter sessions by time period
List<Session> _filterByPeriod(List<Session> sessions, StatsPeriod period) {
if (period == StatsPeriod.all) return sessions;
final now = DateTime.now();
final cutoff = switch (period) {
StatsPeriod.session => now.subtract(const Duration(hours: 24)),
StatsPeriod.week => now.subtract(const Duration(days: 7)),
StatsPeriod.month => now.subtract(const Duration(days: 30)),
StatsPeriod.all => DateTime(1970),
};
return sessions.where((s) => s.createdAt.isAfter(cutoff)).toList();
}
/// Calculate heat map
HeatMap _calculateHeatMap(List<Shot> shots, {int gridSize = 5}) {
// Initialize grid
final grid = List.generate(
gridSize,
(_) => List.generate(gridSize, (_) => <Shot>[]),
);
// Assign shots to grid cells
for (final shot in shots) {
final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1);
final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1);
grid[row][col].add(shot);
}
// Find max count for normalization
int maxCount = 0;
for (final row in grid) {
for (final cell in row) {
if (cell.length > maxCount) maxCount = cell.length;
}
}
// Create heat zones
final zones = <List<HeatZone>>[];
for (int row = 0; row < gridSize; row++) {
final rowZones = <HeatZone>[];
for (int col = 0; col < gridSize; col++) {
final cellShots = grid[row][col];
final avgScore = cellShots.isEmpty
? 0.0
: cellShots.fold<int>(0, (sum, s) => sum + s.score) / cellShots.length;
rowZones.add(HeatZone(
row: row,
col: col,
shotCount: cellShots.length,
intensity: maxCount > 0 ? cellShots.length / maxCount : 0,
avgScore: avgScore,
));
}
zones.add(rowZones);
}
return HeatMap(
gridSize: gridSize,
zones: zones,
maxShotsInZone: maxCount,
totalShots: shots.length,
);
}
/// Calculate precision statistics
PrecisionStats _calculatePrecision(
List<Shot> shots,
double centerX,
double centerY,
) {
if (shots.isEmpty) {
return const PrecisionStats(
avgDistanceFromCenter: 0,
groupingDiameter: 0,
precisionScore: 0,
consistencyScore: 0,
);
}
// Calculate distances from center
final distances = shots.map((shot) {
final dx = shot.x - centerX;
final dy = shot.y - centerY;
return math.sqrt(dx * dx + dy * dy);
}).toList();
final avgDistance = distances.reduce((a, b) => a + b) / distances.length;
// Calculate grouping (spread between shots)
double maxSpread = 0;
for (int i = 0; i < shots.length; i++) {
for (int j = i + 1; j < shots.length; j++) {
final dx = shots[i].x - shots[j].x;
final dy = shots[i].y - shots[j].y;
final dist = math.sqrt(dx * dx + dy * dy);
if (dist > maxSpread) maxSpread = dist;
}
}
// Calculate standard deviation of distances (consistency)
final meanDist = avgDistance;
double variance = 0;
for (final d in distances) {
variance += math.pow(d - meanDist, 2);
}
final stdDevDist = math.sqrt(variance / distances.length);
// Precision score: based on average distance from center (0-100)
// 0 distance = 100 score, 0.5 distance = 0 score
final precisionScore = math.max(0, (1 - avgDistance * 2) * 100);
// Consistency score: based on grouping tightness (0-100)
// Lower spread = higher consistency
final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100);
return PrecisionStats(
avgDistanceFromCenter: avgDistance.toDouble(),
groupingDiameter: maxSpread.toDouble(),
precisionScore: precisionScore.clamp(0.0, 100.0).toDouble(),
consistencyScore: consistencyScore.clamp(0.0, 100.0).toDouble(),
);
}
/// Calculate standard deviation statistics
StdDevStats _calculateStdDev(List<Shot> shots) {
if (shots.isEmpty) {
return const StdDevStats(
stdDevX: 0,
stdDevY: 0,
stdDevRadial: 0,
stdDevScore: 0,
meanX: 0.5,
meanY: 0.5,
meanScore: 0,
);
}
// Calculate means
double sumX = 0, sumY = 0, sumScore = 0;
for (final shot in shots) {
sumX += shot.x;
sumY += shot.y;
sumScore += shot.score;
}
final meanX = sumX / shots.length;
final meanY = sumY / shots.length;
final meanScore = sumScore / shots.length;
// Calculate variances
double varianceX = 0, varianceY = 0, varianceScore = 0;
for (final shot in shots) {
varianceX += math.pow(shot.x - meanX, 2);
varianceY += math.pow(shot.y - meanY, 2);
varianceScore += math.pow(shot.score - meanScore, 2);
}
varianceX /= shots.length;
varianceY /= shots.length;
varianceScore /= shots.length;
final stdDevX = math.sqrt(varianceX);
final stdDevY = math.sqrt(varianceY);
final stdDevScore = math.sqrt(varianceScore);
// Radial standard deviation
final stdDevRadial = math.sqrt(varianceX + varianceY);
return StdDevStats(
stdDevX: stdDevX,
stdDevY: stdDevY,
stdDevRadial: stdDevRadial,
stdDevScore: stdDevScore,
meanX: meanX,
meanY: meanY,
meanScore: meanScore,
);
}
/// Calculate regional distribution
RegionalStats _calculateRegional(
List<Shot> shots,
double centerX,
double centerY,
) {
if (shots.isEmpty) {
return const RegionalStats(
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,
};
// 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;
for (final shot in shots) {
final dx = shot.x - centerX;
final dy = shot.y - centerY;
sumDx += dx;
sumDy += dy;
// Quadrant
if (dy < 0) {
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] =
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1;
} else {
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] =
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1;
}
// Sector
final distance = math.sqrt(dx * dx + dy * dy);
if (distance < 0.1) {
sectors['Centre'] = sectors['Centre']! + 1;
} else {
final angle = math.atan2(dy, dx) * 180 / math.pi;
final sector = _angleToSector(angle);
sectors[sector] = sectors[sector]! + 1;
}
}
// Calculate bias
final biasX = sumDx / shots.length;
final biasY = sumDy / shots.length;
// Find dominant direction
String dominant = 'Centre';
int maxCount = 0;
sectors.forEach((key, value) {
if (value > maxCount) {
maxCount = value;
dominant = key;
}
});
return RegionalStats(
quadrantDistribution: quadrants,
sectorDistribution: sectors,
dominantDirection: dominant,
biasX: biasX,
biasY: biasY,
);
}
String _angleToSector(double angle) {
// Angle is in degrees, -180 to 180
// 0 = East, 90 = South, -90 = North, 180/-180 = West
if (angle >= -22.5 && angle < 22.5) return 'E';
if (angle >= 22.5 && angle < 67.5) return 'SE';
if (angle >= 67.5 && angle < 112.5) return 'S';
if (angle >= 112.5 && angle < 157.5) return 'SO';
if (angle >= 157.5 || angle < -157.5) return 'O';
if (angle >= -157.5 && angle < -112.5) return 'NO';
if (angle >= -112.5 && angle < -67.5) return 'N';
if (angle >= -67.5 && angle < -22.5) return 'NE';
return 'Centre';
}
SessionStatistics _emptyStatistics(StatsPeriod period, List<Session> sessions) {
return SessionStatistics(
totalShots: 0,
totalScore: 0,
avgScore: 0,
maxScore: 0,
minScore: 0,
heatMap: const HeatMap(
gridSize: 5,
zones: [],
maxShotsInZone: 0,
totalShots: 0,
),
precision: const PrecisionStats(
avgDistanceFromCenter: 0,
groupingDiameter: 0,
precisionScore: 0,
consistencyScore: 0,
),
stdDev: const StdDevStats(
stdDevX: 0,
stdDevY: 0,
stdDevRadial: 0,
stdDevScore: 0,
meanX: 0.5,
meanY: 0.5,
meanScore: 0,
),
regional: const RegionalStats(
quadrantDistribution: {},
sectorDistribution: {},
dominantDirection: 'Centre',
biasX: 0,
biasY: 0,
),
sessions: sessions,
period: period,
);
}
}

View File

@@ -0,0 +1,257 @@
import 'dart:math' as math;
import '../data/models/target_type.dart';
import 'image_processing_service.dart';
export 'image_processing_service.dart' show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
class TargetDetectionResult {
final double centerX; // Relative (0-1)
final double centerY; // Relative (0-1)
final double radius; // Relative (0-1)
final List<DetectedImpactResult> impacts;
final bool success;
final String? errorMessage;
TargetDetectionResult({
required this.centerX,
required this.centerY,
required this.radius,
required this.impacts,
this.success = true,
this.errorMessage,
});
factory TargetDetectionResult.error(String message) {
return TargetDetectionResult(
centerX: 0.5,
centerY: 0.5,
radius: 0.4,
impacts: [],
success: false,
errorMessage: message,
);
}
}
class DetectedImpactResult {
final double x; // Relative (0-1)
final double y; // Relative (0-1)
final double radius; // Absolute pixels
final int suggestedScore;
DetectedImpactResult({
required this.x,
required this.y,
required this.radius,
required this.suggestedScore,
});
}
class TargetDetectionService {
final ImageProcessingService _imageProcessingService;
TargetDetectionService({
ImageProcessingService? imageProcessingService,
}) : _imageProcessingService = imageProcessingService ?? ImageProcessingService();
/// Detect target and impacts from an image file
TargetDetectionResult detectTarget(
String imagePath,
TargetType targetType,
) {
try {
// Detect main target
final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
// Detect impacts
final impacts = _imageProcessingService.detectImpacts(imagePath);
// Convert impacts to relative coordinates and calculate scores
final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScore(impact.x, impact.y, centerX, centerY, radius)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection: $e');
}
}
int _calculateConcentricScore(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones (10 zones)
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0; // Outside target
}
int _calculateSilhouetteScore(
double impactX,
double impactY,
double centerX,
double centerY,
) {
// For silhouettes, scoring is typically based on zones
// Head and center mass = 5, body = 4, lower = 3
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
// Check if within silhouette bounds (approximate)
if (dx > 0.15) return 0; // Too far left/right
// Vertical zones
if (dy < -0.25) return 5; // Head zone (top)
if (dy < 0.0) return 5; // Center mass (upper body)
if (dy < 0.15) return 4; // Body
if (dy < 0.35) return 3; // Lower body
return 0; // Outside target
}
/// Detect only impacts with custom settings (doesn't affect target position)
List<DetectedImpactResult> detectImpactsOnly(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactDetectionSettings settings,
) {
try {
// Detect impacts with custom settings
final impacts = _imageProcessingService.detectImpactsWithSettings(
imagePath,
settings,
);
// Convert impacts to relative coordinates and calculate scores
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
int _calculateConcentricScoreWithRings(
double impactX,
double impactY,
double centerX,
double centerY,
double targetRadius,
int ringCount,
) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones based on ringCount
for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount;
if (distance <= zoneRadius) {
return 10 - i;
}
}
return 0; // Outside target
}
/// Analyze reference impacts to learn their characteristics
ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath,
List<ReferenceImpact> references,
) {
return _imageProcessingService.analyzeReferenceImpacts(imagePath, references);
}
/// Detect impacts based on reference characteristics (calibrated detection)
List<DetectedImpactResult> detectImpactsFromReferences(
String imagePath,
TargetType targetType,
double centerX,
double centerY,
double radius,
int ringCount,
ImpactCharacteristics characteristics, {
double tolerance = 2.0,
}) {
try {
final impacts = _imageProcessingService.detectImpactsFromReferences(
imagePath,
characteristics,
tolerance: tolerance,
);
return impacts.map((impact) {
final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
} catch (e) {
return [];
}
}
}