premier app version beta
This commit is contained in:
431
lib/features/analysis/analysis_provider.dart
Normal file
431
lib/features/analysis/analysis_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
1066
lib/features/analysis/analysis_screen.dart
Normal file
1066
lib/features/analysis/analysis_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
273
lib/features/analysis/widgets/grouping_stats.dart
Normal file
273
lib/features/analysis/widgets/grouping_stats.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
167
lib/features/analysis/widgets/score_card.dart
Normal file
167
lib/features/analysis/widgets/score_card.dart
Normal 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]!;
|
||||
}
|
||||
}
|
||||
561
lib/features/analysis/widgets/target_calibration.dart
Normal file
561
lib/features/analysis/widgets/target_calibration.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
343
lib/features/analysis/widgets/target_overlay.dart
Normal file
343
lib/features/analysis/widgets/target_overlay.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
249
lib/features/capture/capture_screen.dart
Normal file
249
lib/features/capture/capture_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
lib/features/capture/widgets/image_source_button.dart
Normal file
44
lib/features/capture/widgets/image_source_button.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
lib/features/capture/widgets/target_type_selector.dart
Normal file
106
lib/features/capture/widgets/target_type_selector.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
228
lib/features/history/history_screen.dart
Normal file
228
lib/features/history/history_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
246
lib/features/history/session_detail_screen.dart
Normal file
246
lib/features/history/session_detail_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
244
lib/features/history/widgets/history_chart.dart
Normal file
244
lib/features/history/widgets/history_chart.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
148
lib/features/history/widgets/session_list_item.dart
Normal file
148
lib/features/history/widgets/session_list_item.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
222
lib/features/home/home_screen.dart
Normal file
222
lib/features/home/home_screen.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
46
lib/features/home/widgets/stats_card.dart
Normal file
46
lib/features/home/widgets/stats_card.dart
Normal 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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
717
lib/features/statistics/statistics_screen.dart
Normal file
717
lib/features/statistics/statistics_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
232
lib/features/statistics/widgets/heat_map_widget.dart
Normal file
232
lib/features/statistics/widgets/heat_map_widget.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user