feat: implement base architecture and core repositories for weapon tracking and target analysis functionality

This commit is contained in:
streaper2
2026-05-08 20:29:18 +02:00
parent 5dd58da51c
commit 774dbfcf40
37 changed files with 3687 additions and 2713 deletions

View File

@@ -7,9 +7,9 @@ library;
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart';
import '../../data/models/shot.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart';
@@ -120,6 +120,7 @@ class AnalysisProvider extends ChangeNotifier {
String imagePath,
TargetType targetType, {
bool autoAnalyze = true,
Offset? manualCenter,
}) async {
_state = AnalysisState.loading;
_imagePath = imagePath;
@@ -138,8 +139,8 @@ class AnalysisProvider extends ChangeNotifier {
if (!autoAnalyze) {
// Just setup default values without running detection
_targetCenterX = 0.5;
_targetCenterY = 0.5;
_targetCenterX = manualCenter?.dx ?? 0.5;
_targetCenterY = manualCenter?.dy ?? 0.5;
_targetRadius = 0.4;
_targetInnerRadius = 0.04;
@@ -173,7 +174,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x,
y: impact.y,
score: impact.suggestedScore,
sessionId: '', // Will be set when saving
analysisId: '', // Will be set when saving
);
}).toList();
@@ -195,7 +196,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Add a manual shot
void addShot(double x, double y) {
final score = _calculateShotScore(x, y);
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: '');
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
_shots.add(shot);
_recalculateScores();
@@ -275,7 +276,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
analysisId: '',
);
_shots.add(shot);
}
@@ -288,14 +289,6 @@ class AnalysisProvider extends ChangeNotifier {
}
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
///
/// NOTE: OpenCV est actuellement désactivé sur Windows en raison de problèmes
/// de compilation. Cette méthode retourne 0 (aucun impact détecté).
/// Utiliser autoDetectImpacts() à la place.
///
/// Utilise les algorithmes OpenCV pour une détection plus robuste:
/// - Transformation de Hough pour détecter les cercles
/// - Analyse de contours avec filtrage par circularité
Future<int> autoDetectImpactsWithOpenCV({
double cannyThreshold1 = 50,
double cannyThreshold2 = 150,
@@ -352,7 +345,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
analysisId: '',
);
_shots.add(shot);
}
@@ -404,7 +397,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
analysisId: '',
);
_shots.add(shot);
}
@@ -419,7 +412,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Add a reference impact for calibrated detection
void addReferenceImpact(double x, double y) {
final score = _calculateShotScore(x, y);
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: '');
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
_referenceImpacts.add(shot);
notifyListeners();
}
@@ -489,7 +482,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x,
y: impact.y,
score: score,
sessionId: '',
analysisId: '',
);
_shots.add(shot);
}
@@ -563,7 +556,7 @@ class AnalysisProvider extends ChangeNotifier {
}
return false;
} catch (e) {
print('Auto-calibration error: $e');
debugPrint('Auto-calibration error: $e');
return false;
}
}
@@ -608,7 +601,6 @@ class AnalysisProvider extends ChangeNotifier {
}
}
/* version deux a tester*/
/// Calcule ET applique la correction pour un feedback immédiat
Future<void> calculateAndApplyDistortion() async {
// 1. Calcul des paramètres (votre code actuel)
@@ -644,7 +636,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners();
}
}
/* fin section deux a tester*/
int _calculateShotScore(double x, double y) {
if (_targetType == TargetType.concentric) {
@@ -697,10 +688,10 @@ class AnalysisProvider extends ChangeNotifier {
return false;
}
// Identifiant de session temporaire si non sauvegardée
final sessionId = _shots.isNotEmpty && _shots.first.sessionId.isNotEmpty
? _shots.first.sessionId
: 'session_${DateTime.now().millisecondsSinceEpoch}';
// Identifiant d'analyse temporaire
final analysisId = _shots.isNotEmpty && _shots.first.analysisId.isNotEmpty
? _shots.first.analysisId
: 'analysis_${DateTime.now().millisecondsSinceEpoch}';
final service = AiExportService(); // Local instanciation for simplicity
@@ -709,7 +700,7 @@ class AnalysisProvider extends ChangeNotifier {
final success = await service.exportData(
imagePath: _imagePath!,
sessionId: sessionId,
sessionId: 'export',
targetType: _targetType!,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
@@ -725,16 +716,23 @@ class AnalysisProvider extends ChangeNotifier {
return success;
}
/// Save the session
Future<Session> saveSession({String? notes}) async {
/// Save the session (legacy standalone flow)
Future<TargetAnalysis> saveSession({
String? notes,
String? sessionId,
String? weaponName,
String? weaponId,
int? distance,
}) async {
if (_imagePath == null || _targetType == null) {
throw Exception('Cannot save: missing image or target type');
}
final session = await _sessionRepository.createSession(
targetType: _targetType!,
final targetAnalysis = await _sessionRepository.prepareTargetAnalysis(
imagePath: _imagePath!,
shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(),
sessionId: sessionId ?? 'standalone',
targetType: _targetType!,
shots: _shots,
totalScore: totalScore,
groupingDiameter: _groupingResult?.diameter,
groupingCenterX: _groupingResult?.centerX,
@@ -745,11 +743,34 @@ class AnalysisProvider extends ChangeNotifier {
targetRadius: _targetRadius,
);
// Update shots with session ID
_shots = session.shots;
notifyListeners();
if (sessionId != null && sessionId != 'standalone') {
final existingSession = await _sessionRepository.getSession(sessionId);
if (existingSession != null) {
await _sessionRepository.addAnalysisToSession(sessionId, targetAnalysis);
} else {
await _sessionRepository.createSession(
id: sessionId,
weapon: weaponName ?? 'Inconnue',
weaponId: weaponId,
maxShotsPerTarget: shotCount,
analyses: [targetAnalysis],
notes: notes,
distance: distance ?? 25,
);
}
} else {
await _sessionRepository.createSession(
weapon: weaponName ?? 'Inconnue',
weaponId: weaponId,
maxShotsPerTarget: shotCount,
analyses: [targetAnalysis],
notes: notes,
distance: distance ?? 25,
);
}
return session;
notifyListeners();
return targetAnalysis;
}
/// Reset the provider

File diff suppressed because it is too large Load Diff

View File

@@ -40,10 +40,10 @@ class TargetCalibration extends StatefulWidget {
});
@override
State<TargetCalibration> createState() => _TargetCalibrationState();
State<TargetCalibration> createState() => TargetCalibrationState();
}
class _TargetCalibrationState extends State<TargetCalibration> {
class TargetCalibrationState extends State<TargetCalibration> {
late double _centerX;
late double _centerY;
late double _radius;
@@ -66,8 +66,8 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_initRingRadii();
}
void _initRingRadii() {
if (widget.initialRingRadii != null &&
void _initRingRadii({bool forceRecalculate = false}) {
if (!forceRecalculate && widget.initialRingRadii != null &&
widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!);
} else {
@@ -121,75 +121,188 @@ class _TargetCalibrationState extends State<TargetCalibration> {
builder: (context, constraints) {
final size = constraints.biggest;
return GestureDetector(
onPanStart: (details) => _onPanStart(details, size),
onPanUpdate: (details) => _onPanUpdate(details, size),
onPanEnd: (_) => _onPanEnd(),
child: CustomPaint(
size: size,
painter: _CalibrationPainter(
centerX: _centerX,
centerY: _centerY,
radius: _radius,
innerRadius: _innerRadius,
ringCount: _ringCount,
ringRadii: _ringRadii,
targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter,
isDraggingRadius: _isDraggingRadius,
isDraggingInnerRadius: _isDraggingInnerRadius,
return Stack(
children: [
GestureDetector(
onPanStart: (details) => _onPanStart(details, size),
onPanUpdate: (details) => _onPanUpdate(details, size),
onPanEnd: (_) => _onPanEnd(),
child: CustomPaint(
size: size,
painter: _CalibrationPainter(
centerX: _centerX,
centerY: _centerY,
radius: _radius,
innerRadius: _innerRadius,
ringCount: _ringCount,
ringRadii: _ringRadii,
targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter,
isDraggingRadius: false, // Plus de drag direct du rayon
isDraggingInnerRadius: false,
),
),
),
),
// Slider pour le rayon (agrandissement)
Positioned(
top: 10,
left: 40,
right: 40,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const Icon(Icons.zoom_out, color: Colors.white, size: 16),
Expanded(
child: Slider(
value: _radius.clamp(0.05, 3.0),
min: 0.05,
max: 3.0,
activeColor: AppTheme.primaryColor,
onChanged: (value) {
setState(() {
_radius = value;
// On force le recalcul car on change la taille
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
},
),
),
const Icon(Icons.zoom_in, color: Colors.white, size: 16),
],
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const Icon(Icons.compress, color: Colors.white, size: 16),
Expanded(
child: Slider(
value: (_innerRadius / _radius).clamp(0.01, 0.9),
min: 0.01,
max: 0.9,
activeColor: Colors.orange,
onChanged: (value) {
setState(() {
// On ajuste l'innerRadius comme un ratio du radius global
_innerRadius = _radius * value;
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
},
),
),
const Icon(Icons.expand, color: Colors.white, size: 16),
],
),
),
],
),
),
],
);
},
);
}
Widget buildDirectionalControls(BuildContext context, Size size) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(
Icons.keyboard_arrow_up,
() => _moveCenterByPixels(0, -1, size),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(
Icons.keyboard_arrow_left,
() => _moveCenterByPixels(-1, 0, size),
),
const SizedBox(width: 40),
_buildDirectionButton(
Icons.keyboard_arrow_right,
() => _moveCenterByPixels(1, 0, size),
),
],
),
_buildDirectionButton(
Icons.keyboard_arrow_down,
() => _moveCenterByPixels(0, 1, size),
),
],
),
);
}
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
return Container(
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
),
margin: const EdgeInsets.all(2),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 28),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
);
}
void _notifyChange() {
widget.onCalibrationChanged(
_centerX,
_centerY,
_innerRadius,
_radius,
_ringCount,
ringRadii: _ringRadii,
);
}
void _moveCenterByPixels(double dx, double dy, Size size) {
setState(() {
_centerX = _centerX + (dx / size.width);
_centerY = _centerY + (dy / size.height);
});
_notifyChange();
}
void _onPanStart(DragStartDetails details, Size size) {
final tapX = details.localPosition.dx / size.width;
final tapY = details.localPosition.dy / size.height;
// Check if tapping on center handle
final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
// Check if tapping on outer radius handle
final minDim = math.min(size.width, size.height);
final outerRadius = _radius;
final radiusHandleX = _centerX + outerRadius * minDim / size.width;
final radiusHandleY = _centerY;
final distToOuterHandle = _distance(
tapX,
tapY,
radiusHandleX.clamp(0.0, 1.0),
radiusHandleY.clamp(0.0, 1.0),
);
// Check if tapping on inner radius handle (top edge of innermost circle)
final actualInnerRadius = _innerRadius;
final innerHandleX = _centerX;
final innerHandleY = _centerY - actualInnerRadius * minDim / size.height;
final distToInnerHandle = _distance(
tapX,
tapY,
innerHandleX.clamp(0.0, 1.0),
innerHandleY.clamp(0.0, 1.0),
);
// Increase touch target size slightly for handles
if (distToCenter < 0.05) {
setState(() {
_isDraggingCenter = true;
});
} else if (distToOuterHandle < 0.05) {
setState(() {
_isDraggingRadius = true;
});
} else if (distToInnerHandle < 0.05) {
setState(() {
_isDraggingInnerRadius = true;
});
} else if (distToCenter < _radius + 0.02) {
// Tapping inside the target - move center
if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
setState(() {
_isDraggingCenter = true;
});
@@ -206,30 +319,10 @@ class _TargetCalibrationState extends State<TargetCalibration> {
// Move center
_centerX = _centerX + deltaX;
_centerY = _centerY + deltaY;
} else if (_isDraggingRadius) {
// Adjust outer radius
final newRadius = _radius + deltaX * (size.width / minDim);
_radius = newRadius.clamp(math.max(0.05, _innerRadius + 0.01), 3.0);
_initRingRadii(); // Recalculate linear separation
} else if (_isDraggingInnerRadius) {
// Adjust inner radius (sliding up reduces Y, so deltaY is negative when growing. Thus we subtract deltaY)
final newInnerRadius = _innerRadius - deltaY * (size.height / minDim);
_innerRadius = newInnerRadius.clamp(
0.01,
math.max(0.01, _radius - 0.01),
);
_initRingRadii(); // Recalculate linear separation
}
});
widget.onCalibrationChanged(
_centerX,
_centerY,
_innerRadius,
_radius,
_ringCount,
ringRadii: _ringRadii,
);
_notifyChange();
}
void _onPanEnd() {
@@ -288,7 +381,7 @@ class _CalibrationPainter extends CustomPainter {
// Fullscreen crosshairs when dragging center
if (isDraggingCenter) {
final crosshairLinePaint = Paint()
..color = AppTheme.successColor.withValues(alpha: 0.5)
..color = Colors.red.withValues(alpha: 0.5)
..strokeWidth = 1;
canvas.drawLine(
Offset(0, centerPx.dy),
@@ -305,12 +398,6 @@ class _CalibrationPainter extends CustomPainter {
// Draw center handle
_drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw inner radius handle
_drawInnerRadiusHandle(canvas, size, centerPx, innerRadiusPx);
// Draw instructions
_drawInstructions(canvas, size);
}
@@ -342,7 +429,7 @@ class _CalibrationPainter extends CustomPainter {
final strokePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.white.withValues(alpha: 0.6);
..color = Colors.red.withValues(alpha: 0.8);
// Draw from outside to inside
for (int i = ringCount - 1; i >= 0; i--) {
@@ -426,20 +513,20 @@ class _CalibrationPainter extends CustomPainter {
void _drawCenterHandle(Canvas canvas, Offset center) {
// Outer circle
final outerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..color = isDraggingCenter ? Colors.redAccent : Colors.red
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(center, 15, outerPaint);
// Inner dot
final innerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..color = isDraggingCenter ? Colors.redAccent : Colors.red
..style = PaintingStyle.fill;
canvas.drawCircle(center, 5, innerPaint);
// Crosshair
final crossPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..color = isDraggingCenter ? Colors.redAccent : Colors.red
..strokeWidth = 2;
canvas.drawLine(
Offset(center.dx - 20, center.dy),
@@ -463,151 +550,6 @@ class _CalibrationPainter extends CustomPainter {
);
}
void _drawRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Radius handle on the right edge of the outermost ring
final actualHandleX = center.dx + baseRadius;
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
// Check if handle is clamped (radius extends beyond visible area)
final isClamped = actualHandleX > size.width - 20;
final paint = Paint()
..color = isDraggingRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : AppTheme.warningColor)
..style = PaintingStyle.fill;
// Draw handle as a small circle with arrows
canvas.drawCircle(handlePos, 14, paint);
// Draw arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Left arrow
canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy),
Offset(handlePos.dx - 8, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy),
Offset(handlePos.dx - 8, handlePos.dy + 4),
arrowPaint,
);
// Right arrow
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy + 4),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'EXT.',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy + 16),
);
}
void _drawInnerRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double innerRadiusPx,
) {
// Inner radius handle on the top edge of the innermost ring
final actualHandleY = center.dy - innerRadiusPx;
final clampedHandleX = center.dx.clamp(20.0, size.width - 20);
final clampedHandleY = actualHandleY.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
final isClamped = actualHandleY < 20.0;
final paint = Paint()
..color = isDraggingInnerRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : Colors.purpleAccent)
..style = PaintingStyle.fill;
// Draw handle
canvas.drawCircle(handlePos, 14, paint);
// Up/Down arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Up arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx - 4, handlePos.dy - 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx + 4, handlePos.dy - 8),
arrowPaint,
);
// Down arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx - 4, handlePos.dy + 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx + 4, handlePos.dy + 8),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'INT.',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy - 24),
);
}
void _drawInstructions(Canvas canvas, Size size) {
const instruction = 'Deplacez le centre ou ajustez le rayon';
@@ -630,7 +572,8 @@ class _CalibrationPainter extends CustomPainter {
}
@override
bool shouldRepaint(covariant _CalibrationPainter oldDelegate) {
bool shouldRepaint(CustomPainter oldDelegate) {
if (oldDelegate is! _CalibrationPainter) return true;
return centerX != oldDelegate.centerX ||
centerY != oldDelegate.centerY ||
radius != oldDelegate.radius ||

View File

@@ -5,10 +5,12 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../crop/crop_screen.dart';
import '../session/session_provider.dart';
import 'widgets/image_source_button.dart';
class CaptureScreen extends StatefulWidget {
@@ -80,8 +82,13 @@ class _CaptureScreenState extends State<CaptureScreen> {
@override
Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
final title = sessionProvider.isSessionActive
? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source';
return Scaffold(
appBar: AppBar(title: const Text('Source')),
appBar: AppBar(title: Text(title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
@@ -119,42 +126,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
child: CircularProgressIndicator(),
),
)
else if (_selectedImagePath != null)
_buildImagePreview()
else
_buildGuide(),
// --- NOUVEAU BOUTON VALIDER ---
if (_selectedImagePath != null && !_isLoading) ...[
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF112233), // Ton bleu foncé
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 54),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
onPressed: _analyzeImage,
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Valider',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
SizedBox(width: 12),
Icon(Icons.arrow_forward, size: 20),
],
),
),
const SizedBox(height: 32),
],
],
),
),
@@ -170,61 +143,9 @@ class _CaptureScreenState extends State<CaptureScreen> {
);
}
Widget _buildImagePreview() {
return Column(
children: [
_buildSectionTitle('Aperçu'),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: Stack(
children: [
Image.file(
File(_selectedImagePath!),
fit: BoxFit.contain,
width: double.infinity,
),
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() => _selectedImagePath = null),
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
foregroundColor: Colors.white,
),
),
),
],
),
),
const SizedBox(height: 12),
_buildFramingHints(),
],
);
}
Widget _buildFramingHints() {
return Card(
color: AppTheme.warningColor.withOpacity(0.1),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.info_outline, color: AppTheme.warningColor),
const SizedBox(width: 12),
Expanded(
child: Text(
'Assurez-vous que la cible est bien centrée et visible.',
style: TextStyle(color: AppTheme.warningColor.withOpacity(0.8)),
),
),
],
),
),
);
}
Widget _buildGuide() {
return Card(
@@ -283,7 +204,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
final scanner = DocumentScanner(options: options);
final documents = await scanner.scanDocument();
if (documents.images.isNotEmpty) {
setState(() => _selectedImagePath = documents.images.first);
_selectedImagePath = documents.images.first;
_analyzeImage();
}
} catch (e) {
debugPrint('Erreur scan: $e');
@@ -302,7 +224,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
imageQuality: 90,
);
if (image != null) {
setState(() => _selectedImagePath = image.path);
_selectedImagePath = image.path;
_analyzeImage();
}
} catch (e) {
debugPrint('Erreur capture: $e');

View File

@@ -1,286 +1,317 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../../services/image_crop_service.dart';
import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget {
final String imagePath;
final TargetType targetType;
const CropScreen({
super.key,
required this.imagePath,
required this.targetType,
});
@override
State<CropScreen> createState() => _CropScreenState();
}
class _CropScreenState extends State<CropScreen> {
final ImageCropService _cropService = ImageCropService();
// États de transformation
double _scale = 1.0;
double _baseScale = 1.0;
Offset _offset = Offset.zero;
Offset _startOffset = Offset.zero;
Offset _startFocalPoint = Offset.zero;
bool _isLoading = false;
bool _imageLoaded = false;
Size? _imageSize;
late Size _viewportSize;
late double _cropSize;
@override
void initState() {
super.initState();
_loadImageInfo();
}
Future<void> _loadImageInfo() async {
final file = File(widget.imagePath);
final decodedImage = await decodeImageFromList(await file.readAsBytes());
if (mounted) {
setState(() {
_imageSize = Size(
decodedImage.width.toDouble(),
decodedImage.height.toDouble(),
);
_imageLoaded = true;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF101214),
appBar: AppBar(
backgroundColor: const Color(0xFF101214),
elevation: 0,
centerTitle: true,
title: const Text(
'Validation image',
style: TextStyle(color: Colors.white, fontSize: 18),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column(
children: [
// Zone interactive de crop
Expanded(
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
),
),
),
// Bouton d'aide / Modifier
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.touch_app, color: Colors.white54, size: 20),
const SizedBox(width: 8),
Text(
'Déplacez et zoomez pour cadrer',
style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14),
),
],
),
),
// Boutons du bas
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white24),
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Recommencer', style: TextStyle(color: Colors.white)),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _onCropConfirm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Valider', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
),
),
],
),
);
}
Widget _buildInteractiveCrop() {
return LayoutBuilder(
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.9;
if (_scale == 1.0 && _offset == Offset.zero) {
_initializeImagePosition();
}
return GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
child: Stack(
children: [
Positioned.fill(
child: Center(
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale),
alignment: Alignment.center,
child: Image.file(
File(widget.imagePath),
fit: BoxFit.contain,
width: _viewportSize.width,
height: _viewportSize.height,
),
),
),
),
Positioned.fill(
child: IgnorePointer(
child: CropOverlay(cropSize: _cropSize, showGrid: true),
),
),
],
),
);
},
);
}
void _initializeImagePosition() {
if (_imageSize == null) return;
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
final minDisplayDim = math.min(displayWidth, displayHeight);
_scale = _cropSize / minDisplayDim;
if (_scale < 1.0) _scale = 1.0;
}
void _onScaleStart(ScaleStartDetails details) {
_baseScale = _scale;
_startFocalPoint = details.focalPoint;
_startOffset = _offset;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() {
_scale = (_baseScale * details.scale).clamp(0.5, 5.0);
final delta = details.focalPoint - _startFocalPoint;
_offset = _startOffset + delta;
});
}
Future<void> _onCropConfirm() async {
setState(() => _isLoading = true);
try {
final cropRect = _calculateCropRect();
final croppedPath = await _cropService.cropToSquare(widget.imagePath, cropRect);
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: croppedPath,
targetType: widget.targetType,
),
),
);
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
);
}
}
}
CropRect _calculateCropRect() {
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
final scaledWidth = displayWidth * _scale;
final scaledHeight = displayHeight * _scale;
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
final imageLeft = imageCenterX - scaledWidth / 2;
final imageTop = imageCenterY - scaledHeight / 2;
final cropLeft = (_viewportSize.width - _cropSize) / 2;
final cropTop = (_viewportSize.height - _cropSize) / 2;
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
final relCropTop = (cropTop - imageTop) / scaledHeight;
final relCropSize = _cropSize / scaledWidth;
final relCropSizeY = _cropSize / scaledHeight;
return CropRect(
x: relCropLeft.clamp(0.0, 1.0),
y: relCropTop.clamp(0.0, 1.0),
width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)),
height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)),
);
}
}
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart';
import '../../services/image_crop_service.dart';
import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget {
final String imagePath;
final TargetType targetType;
final double? initialScale;
final Offset? initialOffset;
const CropScreen({
super.key,
required this.imagePath,
required this.targetType,
this.initialScale,
this.initialOffset,
});
@override
State<CropScreen> createState() => _CropScreenState();
}
class _CropScreenState extends State<CropScreen> {
final ImageCropService _cropService = ImageCropService();
// États de transformation
double _scale = 1.0;
double _baseScale = 1.0;
Offset _offset = Offset.zero;
Offset _startOffset = Offset.zero;
Offset _startFocalPoint = Offset.zero;
bool _isLoading = false;
bool _imageLoaded = false;
Size? _imageSize;
late Size _viewportSize;
late double _cropSize;
@override
void initState() {
super.initState();
_loadImageInfo();
}
Future<void> _loadImageInfo() async {
final file = File(widget.imagePath);
final decodedImage = await decodeImageFromList(await file.readAsBytes());
if (mounted) {
setState(() {
_imageSize = Size(
decodedImage.width.toDouble(),
decodedImage.height.toDouble(),
);
_imageLoaded = true;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF101214),
appBar: AppBar(
backgroundColor: const Color(0xFF101214),
elevation: 0,
centerTitle: true,
title: const Text(
'Centrage de la cible',
style: TextStyle(color: Colors.white, fontSize: 18),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column(
children: [
// Zone interactive de crop
Expanded(
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
),
),
),
// Bouton d'aide / Modifier
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.center_focus_strong, color: Colors.redAccent, size: 20),
const SizedBox(width: 8),
Text(
'Alignez le centre de la cible sur la croix',
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
const SizedBox(height: 4),
Text(
'Zoomez pour plus de précision',
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
),
],
),
),
// Boutons du bas
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white24),
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Retour', style: TextStyle(color: Colors.white)),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _onCropConfirm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Analyser', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
),
),
],
),
);
}
Widget _buildInteractiveCrop() {
return LayoutBuilder(
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// Le carré de crop occupe presque tout l'espace disponible
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.95;
if (_scale == 1.0 && _offset == Offset.zero) {
_initializeImagePosition();
}
return GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
child: Stack(
children: [
Positioned.fill(
child: Center(
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale),
alignment: Alignment.center,
child: Image.file(
File(widget.imagePath),
fit: BoxFit.contain,
width: _viewportSize.width,
height: _viewportSize.height,
),
),
),
),
Positioned.fill(
child: IgnorePointer(
child: CropOverlay(cropSize: _cropSize, showGrid: false),
),
),
],
),
);
},
);
}
void _initializeImagePosition() {
if (_imageSize == null) return;
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
final minDisplayDim = math.min(displayWidth, displayHeight);
_scale = widget.initialScale ?? (_cropSize / minDisplayDim);
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
if (widget.initialOffset != null) {
_offset = widget.initialOffset!;
}
}
void _onScaleStart(ScaleStartDetails details) {
_baseScale = _scale;
_startFocalPoint = details.focalPoint;
_startOffset = _offset;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() {
_scale = (_baseScale * details.scale).clamp(0.8, 8.0);
final delta = details.focalPoint - _startFocalPoint;
_offset = _startOffset + delta;
});
}
Future<void> _onCropConfirm() async {
setState(() => _isLoading = true);
try {
final cropRect = _calculateCropRect();
// On calcule le centre relatif basé sur le centrage utilisateur
final targetCenterX = cropRect.x + cropRect.width / 2;
final targetCenterY = cropRect.y + cropRect.height / 2;
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: widget.imagePath,
targetType: widget.targetType,
targetCenter: Offset(targetCenterX, targetCenterY),
cropScale: _scale,
cropOffset: _offset,
),
),
);
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
);
}
}
}
CropRect _calculateCropRect() {
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
final scaledWidth = displayWidth * _scale;
final scaledHeight = displayHeight * _scale;
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
final imageLeft = imageCenterX - scaledWidth / 2;
final imageTop = imageCenterY - scaledHeight / 2;
final cropLeft = (_viewportSize.width - _cropSize) / 2;
final cropTop = (_viewportSize.height - _cropSize) / 2;
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
final relCropTop = (cropTop - imageTop) / scaledHeight;
final relCropWidth = _cropSize / scaledWidth;
final relCropHeight = _cropSize / scaledHeight;
return CropRect(
x: relCropLeft,
y: relCropTop,
width: relCropWidth,
height: relCropHeight,
);
}
}

View File

@@ -0,0 +1,414 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/models/maintenance.dart';
import '../../data/repositories/session_repository.dart';
class WeaponDetailScreen extends StatefulWidget {
final Weapon weapon;
const WeaponDetailScreen({super.key, required this.weapon});
@override
State<WeaponDetailScreen> createState() => _WeaponDetailScreenState();
}
class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
late Weapon _weapon;
List<MaintenanceEntry> _maintenance = [];
int _totalRounds = 0;
bool _isLoading = true;
@override
void initState() {
super.initState();
_weapon = widget.weapon;
_loadData();
}
Future<void> _loadData() async {
final repository = context.read<SessionRepository>();
final history = await repository.getMaintenanceHistory(_weapon.id);
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
if (mounted) {
setState(() {
_maintenance = history;
_totalRounds = rounds;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_weapon.customName ?? _weapon.name),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _showEditWeaponDialog(context),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatsSection(),
const SizedBox(height: 24),
_buildInfoCard(),
const SizedBox(height: 24),
_buildOptionsCard(),
const SizedBox(height: 24),
_buildHistorySection(),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddMaintenanceDialog(context),
label: const Text('Entretien'),
icon: const Icon(Icons.build),
),
);
}
Widget _buildStatsSection() {
return Row(
children: [
Expanded(
child: _buildStatCard(
'Total Tirs',
_totalRounds.toString(),
Icons.ads_click,
AppTheme.primaryColor,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
'Sessions',
'N/A',
Icons.history,
Colors.orange,
),
),
],
);
}
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 8),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
);
}
Widget _buildInfoCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(),
_buildInfoRow('Modèle', _weapon.name),
_buildInfoRow('Type', _weapon.type.displayName),
_buildInfoRow('Calibre', _weapon.caliber),
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
const SizedBox(height: 8),
const Text('Notes:', style: TextStyle(fontWeight: FontWeight.w500)),
Text(_weapon.notes!, style: const TextStyle(color: Colors.grey)),
],
],
),
),
);
}
Widget _buildOptionsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(),
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
_buildInfoRow('Détente', _weapon.trigger ?? 'Standard'),
if (_weapon.customName != null)
_buildInfoRow('Surnom', _weapon.customName!),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(color: Colors.grey)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
);
}
Widget _buildHistorySection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Historique d\'entretien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
const SizedBox(height: 8),
if (_maintenance.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Center(child: Text('Aucun entretien enregistré', style: TextStyle(color: Colors.grey))),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _maintenance.length,
itemBuilder: (context, index) {
final entry = _maintenance[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getMaintenanceColor(entry.type).withValues(alpha: 0.1),
child: Icon(_getMaintenanceIcon(entry.type), color: _getMaintenanceColor(entry.type), size: 20),
),
title: Text(entry.type.displayName),
subtitle: Text(entry.description),
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
onLongPress: () => _confirmDeleteMaintenance(context, entry),
),
);
},
),
],
);
}
Color _getMaintenanceColor(MaintenanceType type) {
switch (type) {
case MaintenanceType.cleaning: return Colors.blue;
case MaintenanceType.repair: return Colors.red;
case MaintenanceType.inspection: return Colors.green;
case MaintenanceType.upgrade: return Colors.purple;
case MaintenanceType.other: return Colors.grey;
}
}
IconData _getMaintenanceIcon(MaintenanceType type) {
switch (type) {
case MaintenanceType.cleaning: return Icons.opacity;
case MaintenanceType.repair: return Icons.build;
case MaintenanceType.inspection: return Icons.visibility;
case MaintenanceType.upgrade: return Icons.trending_up;
case MaintenanceType.other: return Icons.more_horiz;
}
}
void _showEditWeaponDialog(BuildContext context) async {
final nameController = TextEditingController(text: _weapon.name);
final caliberController = TextEditingController(text: _weapon.caliber);
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
final magCapController = TextEditingController(text: _weapon.magazineCapacity.toString());
final notesController = TextEditingController(text: _weapon.notes);
final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger);
final customNameController = TextEditingController(text: _weapon.customName);
WeaponType selectedType = _weapon.type;
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Modifier l\'arme'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
),
TextField(
controller: customNameController,
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
),
DropdownButtonFormField<WeaponType>(
value: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre'),
),
const SizedBox(height: 16),
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
),
TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
),
TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
),
const SizedBox(height: 16),
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
Row(
children: [
Expanded(
child: TextField(
controller: magCountController,
decoration: const InputDecoration(labelText: 'Chargeurs'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: magCapController,
decoration: const InputDecoration(labelText: 'Capacité'),
keyboardType: TextInputType.number,
),
),
],
),
TextField(
controller: notesController,
decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 2,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Enregistrer')),
],
),
),
);
if (result == true) {
final updatedWeapon = _weapon.copyWith(
name: nameController.text,
type: selectedType,
caliber: caliberController.text,
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
notes: notesController.text,
optic: opticController.text.isEmpty ? null : opticController.text,
silencer: silencerController.text.isEmpty ? null : silencerController.text,
trigger: triggerController.text.isEmpty ? null : triggerController.text,
customName: customNameController.text.isEmpty ? null : customNameController.text,
);
final repository = context.read<SessionRepository>();
await repository.updateWeapon(updatedWeapon);
setState(() {
_weapon = updatedWeapon;
});
}
}
void _showAddMaintenanceDialog(BuildContext context) async {
final descController = TextEditingController();
MaintenanceType selectedType = MaintenanceType.cleaning;
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Ajouter un entretien'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<MaintenanceType>(
value: selectedType,
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
TextField(
controller: descController,
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
maxLines: 2,
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
],
),
),
);
if (result == true && descController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addMaintenanceEntry(
weaponId: _weapon.id,
type: selectedType,
description: descController.text,
roundsSinceLast: _totalRounds,
);
_loadData();
}
}
void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: const Text('Supprimer cette entrée de l\'historique ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
),
],
),
);
if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteMaintenanceEntry(entry.id);
_loadData();
}
}
}

View File

@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart';
import 'weapon_detail_screen.dart';
class WeaponListScreen extends StatefulWidget {
const WeaponListScreen({super.key});
@override
State<WeaponListScreen> createState() => _WeaponListScreenState();
}
class _WeaponListScreenState extends State<WeaponListScreen> {
List<Weapon> _weapons = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadWeapons();
}
Future<void> _loadWeapons() async {
final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons();
if (mounted) {
setState(() {
_weapons = weapons;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Mon Armurerie'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _weapons.isEmpty
? _buildEmptyState()
: _buildWeaponList(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddWeaponDialog(context),
child: const Icon(Icons.add),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shield, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('Aucune arme enregistrée'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => _showAddWeaponDialog(context),
child: const Text('Ajouter ma première arme'),
),
],
),
);
}
Widget _buildWeaponList() {
return ListView.builder(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
itemCount: _weapons.length,
itemBuilder: (context, index) {
final weapon = _weapons[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${weapon.type.displayName}${weapon.caliber}'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)),
);
_loadWeapons(); // Reload in case it was edited or maintenance was added
},
onLongPress: () => _confirmDelete(context, weapon),
),
);
},
);
}
void _showAddWeaponDialog(BuildContext context) async {
final nameController = TextEditingController();
final caliberController = TextEditingController();
final magCountController = TextEditingController(text: '2');
final magCapController = TextEditingController(text: '15');
WeaponType selectedType = WeaponType.handgun;
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Ajouter une arme'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
),
DropdownButtonFormField<WeaponType>(
value: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre', hintText: 'ex: 9mm, .22LR'),
),
Row(
children: [
Expanded(
child: TextField(
controller: magCountController,
decoration: const InputDecoration(labelText: 'Chargeurs'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: magCapController,
decoration: const InputDecoration(labelText: 'Capacité'),
keyboardType: TextInputType.number,
),
),
],
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
],
),
),
);
if (result == true && nameController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addWeapon(
name: nameController.text,
type: selectedType,
caliber: caliberController.text,
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
);
_loadWeapons();
}
}
void _confirmDelete(BuildContext context, Weapon weapon) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
),
],
),
);
if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteWeapon(weapon.id);
_loadWeapons();
}
}
}

View File

@@ -37,7 +37,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
try {
final repository = context.read<SessionRepository>();
final sessions = await repository.getAllSessions(targetType: _filterType);
final sessions = await repository.getAllSessions();
if (mounted) {
setState(() {
@@ -95,12 +95,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
locale: const Locale('fr', 'FR'),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.dark(
primary: AppTheme.primaryColor,
onPrimary: Colors.white,
surface: Theme.of(context).cardColor,
onSurface: Colors.white,
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppTheme.primaryColor, // En-tête et sélection
onPrimary: Colors.white, // Texte sur en-tête/sélection
surface: Colors.white, // Fond du calendrier
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor,
),
dialogBackgroundColor: Colors.white,
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
),
),
child: child!,

View File

@@ -1,10 +1,3 @@
/// Écran de détail d'une session.
///
/// Affiche la visualisation complète d'une session sauvegardée :
/// image de la cible avec overlay des impacts, scores recalculés,
/// statistiques de groupement et lien vers les statistiques détaillées.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -12,6 +5,8 @@ import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
@@ -20,7 +15,7 @@ import '../analysis/widgets/score_card.dart';
import '../analysis/widgets/grouping_stats.dart';
import '../statistics/statistics_screen.dart';
class SessionDetailScreen extends StatelessWidget {
class SessionDetailScreen extends StatefulWidget {
final Session session;
const SessionDetailScreen({
@@ -28,33 +23,40 @@ class SessionDetailScreen extends StatelessWidget {
required this.session,
});
@override
State<SessionDetailScreen> createState() => _SessionDetailScreenState();
}
class _SessionDetailScreenState extends State<SessionDetailScreen> {
int _currentTargetIndex = 0;
@override
Widget build(BuildContext context) {
final scoreCalculator = context.read<ScoreCalculatorService>();
final groupingAnalyzer = context.read<GroupingAnalyzerService>();
final currentAnalysis = widget.session.analyses[_currentTargetIndex];
final scoreResult = scoreCalculator.calculateScores(
shots: session.shots,
targetType: session.targetType,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4,
shots: currentAnalysis.shots,
targetType: currentAnalysis.targetType,
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
targetRadius: currentAnalysis.targetRadius ?? 0.4,
);
final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots);
final groupingResult = groupingAnalyzer.analyzeGrouping(currentAnalysis.shots);
return Scaffold(
appBar: AppBar(
title: Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
),
title: Text(widget.session.weapon),
actions: [
IconButton(
icon: const Icon(Icons.analytics),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StatisticsScreen(singleSession: session),
builder: (_) => StatisticsScreen(singleSession: widget.session),
),
),
tooltip: 'Statistiques',
@@ -70,15 +72,22 @@ class SessionDetailScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Weapon and session overview
_buildSessionHeader(context),
// Target selector if multiple targets
if (widget.session.targetCount > 1)
_buildTargetSelector(),
// Target image with overlay
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
if (File(session.imagePath).existsSync())
if (File(currentAnalysis.imagePath).existsSync())
Image.file(
File(session.imagePath),
File(currentAnalysis.imagePath),
fit: BoxFit.contain,
)
else
@@ -89,14 +98,14 @@ class SessionDetailScreen extends StatelessWidget {
),
),
TargetOverlay(
shots: session.shots,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4,
targetType: session.targetType,
groupingCenterX: session.groupingCenterX,
groupingCenterY: session.groupingCenterY,
groupingDiameter: session.groupingDiameter,
shots: currentAnalysis.shots,
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
targetRadius: currentAnalysis.targetRadius ?? 0.4,
targetType: currentAnalysis.targetType,
groupingCenterX: currentAnalysis.groupingCenterX,
groupingCenterY: currentAnalysis.groupingCenterY,
groupingDiameter: currentAnalysis.groupingDiameter,
),
],
),
@@ -107,31 +116,37 @@ class SessionDetailScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Session info
_buildSessionInfo(context),
// Analysis Info
_buildAnalysisInfo(context, currentAnalysis),
const SizedBox(height: 12),
// Score card
// Score card for current target
ScoreCard(
totalScore: session.totalScore,
shotCount: session.shotCount,
totalScore: currentAnalysis.totalScore,
shotCount: currentAnalysis.shotCount,
scoreResult: scoreResult,
targetType: session.targetType,
targetType: currentAnalysis.targetType,
),
const SizedBox(height: 12),
// Grouping stats
if (session.shotCount > 1)
// Grouping stats for current target
if (currentAnalysis.shotCount > 1)
GroupingStats(
groupingResult: groupingResult,
targetCenterX: session.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5,
targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
),
// Notes
if (session.notes != null && session.notes!.isNotEmpty) ...[
// Notes for current target
if (currentAnalysis.notes != null && currentAnalysis.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildNotesCard(context),
_buildNotesCard(context, currentAnalysis.notes!),
],
// Session notes
if (widget.session.notes != null && widget.session.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildNotesCard(context, widget.session.notes!, title: 'Notes de session'),
],
],
),
@@ -142,16 +157,83 @@ class SessionDetailScreen extends StatelessWidget {
);
}
Widget _buildSessionInfo(BuildContext context) {
Widget _buildSessionHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
DateFormat('EEEE dd MMMM yyyy', 'fr_FR').format(widget.session.createdAt),
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
DateFormat('HH:mm').format(widget.session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Score Total: ${widget.session.totalScore}',
style: const TextStyle(fontWeight: FontWeight.bold, color: AppTheme.primaryColor),
),
Text(
'${widget.session.totalShots} tirs au total',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
),
],
),
);
}
Widget _buildTargetSelector() {
return Container(
height: 50,
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: widget.session.targetCount,
itemBuilder: (context, index) {
final isSelected = _currentTargetIndex == index;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text('Cible ${index + 1}'),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _currentTargetIndex = index);
}
},
),
);
},
),
);
}
Widget _buildAnalysisInfo(BuildContext context, TargetAnalysis analysis) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(
session.targetType == session.targetType
? Icons.track_changes
: Icons.person,
analysis.targetType == TargetType.concentric ? Icons.track_changes : Icons.person,
color: AppTheme.primaryColor,
),
const SizedBox(width: 12),
@@ -160,14 +242,11 @@ class SessionDetailScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.targetType.displayName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
analysis.targetType.displayName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR')
.format(session.createdAt),
'Cible ${_currentTargetIndex + 1} sur ${widget.session.targetCount}',
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -179,7 +258,7 @@ class SessionDetailScreen extends StatelessWidget {
);
}
Widget _buildNotesCard(BuildContext context) {
Widget _buildNotesCard(BuildContext context, String notes, {String title = 'Notes'}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
@@ -188,18 +267,16 @@ class SessionDetailScreen extends StatelessWidget {
children: [
Row(
children: [
const Icon(Icons.notes, color: AppTheme.primaryColor),
const Icon(Icons.notes, color: AppTheme.primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Notes',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const Divider(),
Text(session.notes!),
Text(notes),
],
),
),
@@ -211,7 +288,7 @@ class SessionDetailScreen extends StatelessWidget {
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: const Text('Voulez-vous vraiment supprimer cette session?'),
content: const Text('Voulez-vous vraiment supprimer cette session entière ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -231,20 +308,17 @@ class SessionDetailScreen extends StatelessWidget {
if (confirmed == true && context.mounted) {
try {
final repository = context.read<SessionRepository>();
await repository.deleteSession(session.id);
await repository.deleteSession(widget.session.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session supprimee')),
const SnackBar(content: Text('Session supprimée')),
);
Navigator.pop(context);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
);
}
}

View File

@@ -155,7 +155,7 @@ class HistoryChart extends StatelessWidget {
return touchedSpots.map((spot) {
final session = displaySessions[spot.x.toInt()];
return LineTooltipItem(
'Score: ${session.totalScore}\n${session.shotCount} tirs',
'Score: ${session.totalScore}\n${session.totalShots} tirs',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,

View File

@@ -1,15 +1,8 @@
/// Item de liste représentant une session.
///
/// Affiche les informations clés d'une session (date, type de cible,
/// score, nombre de tirs) avec miniature de l'image et actions de suppression.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart';
import '../../../data/models/session.dart';
import '../../../data/models/target_type.dart';
class SessionListItem extends StatelessWidget {
final Session session;
@@ -33,7 +26,7 @@ class SessionListItem extends StatelessWidget {
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Thumbnail
// Thumbnail (from first target)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
@@ -51,14 +44,14 @@ class SessionListItem extends StatelessWidget {
children: [
Row(
children: [
Icon(
_getTargetIcon(),
const Icon(
Icons.shield,
size: 16,
color: AppTheme.primaryColor,
),
const SizedBox(width: 4),
Text(
session.targetType.displayName,
session.weapon,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
@@ -70,18 +63,19 @@ class SessionListItem extends StatelessWidget {
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
if (session.notes != null && session.notes!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
session.notes!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
fontStyle: FontStyle.italic,
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${session.targetCount} cible(s) • ${session.distance}m',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
],
),
),
@@ -98,7 +92,7 @@ class SessionListItem extends StatelessWidget {
),
),
Text(
'${session.shotCount} tirs',
'${session.totalShots} tirs',
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -120,7 +114,9 @@ class SessionListItem extends StatelessWidget {
}
Widget _buildThumbnail() {
final file = File(session.imagePath);
if (session.analyses.isEmpty) return _buildPlaceholder();
final file = File(session.analyses.first.imagePath);
if (file.existsSync()) {
return Image.file(
@@ -137,18 +133,9 @@ class SessionListItem extends StatelessWidget {
return Container(
color: Colors.grey[200],
child: Icon(
_getTargetIcon(),
Icons.track_changes,
color: Colors.grey[400],
),
);
}
IconData _getTargetIcon() {
switch (session.targetType) {
case TargetType.concentric:
return Icons.track_changes;
case TargetType.silhouette:
return Icons.person;
}
}
}

View File

@@ -9,6 +9,8 @@ import '../statistics/statistics_screen.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../data/models/session.dart';
import '../settings/settings_screen.dart';
import '../session/session_setup_screen.dart';
import '../session/session_provider.dart';
import 'widgets/stats_card.dart';
class HomeScreen extends StatefulWidget {
@@ -47,7 +49,6 @@ class _HomeScreenState extends State<HomeScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// --- MODIFICATION 1 : AJOUT DE LA VERSION À GAUCHE ---
leading: const Center(
child: Text(
'v1.0.2',
@@ -56,16 +57,6 @@ class _HomeScreenState extends State<HomeScreen> {
),
title: const Text('Bully'),
actions: [
IconButton(
icon: const Icon(Icons.analytics),
onPressed: () => _navigateToStatistics(context),
tooltip: 'Statistiques',
),
IconButton(
icon: const Icon(Icons.history),
onPressed: () => _navigateToHistory(context),
tooltip: 'Historique',
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => _navigateToSettings(context),
@@ -81,15 +72,10 @@ class _HomeScreenState extends State<HomeScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App logo/header
_buildHeader(),
const SizedBox(height: AppConstants.largePadding),
// Main action button
_buildMainActionButton(context),
const SizedBox(height: AppConstants.largePadding),
// Statistics section
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_stats != null)
@@ -107,7 +93,7 @@ class _HomeScreenState extends State<HomeScreen> {
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
color: AppTheme.primaryColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
@@ -136,20 +122,23 @@ class _HomeScreenState extends State<HomeScreen> {
}
Widget _buildMainActionButton(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
final isSessionActive = sessionProvider.isSessionActive;
return ElevatedButton.icon(
onPressed: () => _navigateToCapture(context),
onPressed: () => _navigateToSessionSetup(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
backgroundColor: isSessionActive ? AppTheme.secondaryColor : AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: const Icon(Icons.add_a_photo, size: 28),
label: const Text(
'Nouvelle Analyse',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
icon: Icon(isSessionActive ? Icons.play_circle_outline : Icons.add_circle_outline, size: 28),
label: Text(
isSessionActive ? 'Continuer la session' : 'Démarrer la session',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
);
}
@@ -165,8 +154,6 @@ class _HomeScreenState extends State<HomeScreen> {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
// Première ligne de vignettes (Sessions et Tirs)
Row(
children: [
Expanded(
@@ -192,19 +179,17 @@ class _HomeScreenState extends State<HomeScreen> {
),
],
),
// --- MODIFICATION 2 : GRAPHIQUE RÉEL (Restauré) ---
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
height: 150,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -226,8 +211,6 @@ class _HomeScreenState extends State<HomeScreen> {
: _buildHomeTrendChart(),
),
),
// Deuxième ligne de vignettes (Historique et Meilleur)
Row(
children: [
Expanded(
@@ -257,13 +240,19 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
// --- MÉTHODES DE NAVIGATION ---
void _navigateToCapture(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const CaptureScreen()),
);
void _navigateToSessionSetup(BuildContext context) async {
final sessionProvider = context.read<SessionProvider>();
if (sessionProvider.isSessionActive) {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const CaptureScreen()),
);
} else {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SessionSetupScreen()),
);
}
_loadStats();
}
@@ -283,10 +272,7 @@ class _HomeScreenState extends State<HomeScreen> {
_loadStats();
}
// --- WIDGET DU GRAPHIQUE D'ACCUEIL ---
Widget _buildHomeTrendChart() {
// Trier par date et prendre les 7 dernières
final sorted = List<Session>.from(_recentSessions)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final display =
@@ -294,22 +280,84 @@ class _HomeScreenState extends State<HomeScreen> {
return LineChart(
LineChartData(
gridData: const FlGridData(show: false),
titlesData: const FlTitlesData(show: false),
borderData: FlBorderData(show: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (value) => FlLine(
color: Colors.grey.withValues(alpha: 0.1),
strokeWidth: 1,
),
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= display.length) return const SizedBox();
final date = display[index].createdAt;
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'${date.day}/${date.month}',
style: TextStyle(
fontSize: 9,
color: Colors.grey.withValues(alpha: 0.8),
),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: TextStyle(
fontSize: 9,
color: Colors.grey.withValues(alpha: 0.8),
),
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey.withValues(alpha: 0.2)),
left: BorderSide(color: Colors.grey.withValues(alpha: 0.2)),
),
),
lineBarsData: [
LineChartBarData(
spots: display.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.totalScore.toDouble());
return FlSpot(e.key.toDouble(), e.value.averageScore);
}).toList(),
isCurved: true,
color: AppTheme.primaryColor,
barWidth: 4,
barWidth: 3,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
radius: 3,
color: Colors.white,
strokeWidth: 2,
strokeColor: AppTheme.primaryColor,
),
),
belowBarData: BarAreaData(
show: true,
color: AppTheme.primaryColor.withOpacity(0.1),
color: AppTheme.primaryColor.withValues(alpha: 0.1),
),
),
],
@@ -317,10 +365,11 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
void _navigateToSettings(BuildContext context) {
Navigator.push(
void _navigateToSettings(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
_loadStats();
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/foundation.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart';
import '../../data/models/weapon.dart';
class SessionProvider extends ChangeNotifier {
String? _currentWeaponName;
String? _currentWeaponId;
int _shotsPerTarget = 5;
int _distance = 25;
String? _activeSessionId;
final List<TargetAnalysis> _currentAnalyses = [];
bool _isSessionActive = false;
String? get currentWeapon => _currentWeaponName;
String? get currentWeaponId => _currentWeaponId;
int get shotsPerTarget => _shotsPerTarget;
int get distance => _distance;
bool get isSessionActive => _isSessionActive;
String? get activeSessionId => _activeSessionId;
List<TargetAnalysis> get currentAnalyses => List.unmodifiable(_currentAnalyses);
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
int get targetCount => _currentAnalyses.length;
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25}) {
_currentWeaponName = weaponName;
_currentWeaponId = weaponId;
_shotsPerTarget = shots;
_distance = distance;
_activeSessionId = sessionId;
_currentAnalyses.clear();
_isSessionActive = true;
notifyListeners();
}
void addAnalysis(TargetAnalysis analysis) {
_currentAnalyses.add(analysis);
notifyListeners();
}
void endSession() {
_isSessionActive = false;
_activeSessionId = null;
_currentWeaponId = null;
_currentWeaponName = null;
_currentAnalyses.clear();
_distance = 25;
notifyListeners();
}
}

View File

@@ -0,0 +1,259 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart';
import 'session_provider.dart';
import '../capture/capture_screen.dart';
class SessionSetupScreen extends StatefulWidget {
const SessionSetupScreen({super.key});
@override
State<SessionSetupScreen> createState() => _SessionSetupScreenState();
}
class _SessionSetupScreenState extends State<SessionSetupScreen> {
final _formKey = GlobalKey<FormState>();
final _weaponController = TextEditingController();
int _shotsPerTarget = 5;
int _distance = 25;
List<Weapon> _availableWeapons = [];
Weapon? _selectedWeapon;
bool _isLoadingWeapons = true;
@override
void initState() {
super.initState();
_loadWeapons();
}
Future<void> _loadWeapons() async {
final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons();
if (mounted) {
setState(() {
_availableWeapons = weapons;
_isLoadingWeapons = false;
});
}
}
@override
void dispose() {
_weaponController.dispose();
super.dispose();
}
void _startSession() {
if (_formKey.currentState!.validate()) {
final repository = context.read<SessionRepository>();
final sessionProvider = context.read<SessionProvider>();
final sessionId = repository.generateId();
sessionProvider.startSession(
_selectedWeapon != null ? _selectedWeapon!.displayName : _weaponController.text,
_shotsPerTarget,
sessionId,
weaponId: _selectedWeapon?.id,
distance: _distance,
);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const CaptureScreen()),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Configuration de la session'),
),
body: _isLoadingWeapons
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Choix de l\'arme',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
if (_availableWeapons.isNotEmpty) ...[
DropdownButtonFormField<Weapon?>(
value: _selectedWeapon,
decoration: InputDecoration(
labelText: 'Sélectionner une arme de l\'armurerie',
prefixIcon: const Icon(Icons.shield),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
items: [
const DropdownMenuItem<Weapon?>(
value: null,
child: Text('Nouvelle arme / Autre'),
),
..._availableWeapons.map((w) => DropdownMenuItem(
value: w,
child: Text(w.displayName),
)),
],
onChanged: (value) {
setState(() {
_selectedWeapon = value;
if (value != null) {
_weaponController.text = value.displayName;
_shotsPerTarget = value.magazineCapacity;
// Auto-set distance based on weapon type if needed
_distance = (value.type == WeaponType.handgun) ? 25 : 50;
} else {
_weaponController.clear();
}
});
},
),
const SizedBox(height: 16),
const Center(child: Text('OU', style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold))),
const SizedBox(height: 16),
],
// Weapon field (manual entry if no weapon selected)
TextFormField(
controller: _weaponController,
enabled: _selectedWeapon == null,
decoration: InputDecoration(
labelText: 'Nom de l\'arme',
hintText: 'ex: Glock 17, CZ Shadow 2...',
prefixIcon: const Icon(Icons.edit),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez renseigner votre arme';
}
return null;
},
),
const SizedBox(height: 24),
// Distance selector
const Text(
'Distance de tir (mètres)',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59, // (300-5)/5 = 59 divisions for 5m steps
label: '${_distance}m',
onChanged: (value) {
setState(() {
_distance = value.round();
});
},
),
),
Container(
width: 70,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${_distance}m',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.secondaryColor,
),
),
),
],
),
const SizedBox(height: 24),
// Shots per target
const Text(
'Nombre de balles par cible',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 30,
divisions: 29,
label: '$_shotsPerTarget',
onChanged: (value) {
setState(() {
_shotsPerTarget = value.round();
});
},
),
),
Container(
width: 50,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_shotsPerTarget',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
),
],
),
const SizedBox(height: 48),
ElevatedButton.icon(
onPressed: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: const Icon(Icons.play_arrow),
label: const Text(
'DÉMARRER LA SESSION',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
],
),
),
),
);
}
}

View File

@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../../core/theme/theme_provider.dart';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../services/wallet_identity_service.dart';
import '../garage/weapon_list_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@@ -81,6 +84,49 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
void _showThemeDialog() {
final themeProvider = context.read<ThemeProvider>();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Apparence'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeOption(ThemeMode.system, 'Automatique', Icons.brightness_auto),
_buildThemeOption(ThemeMode.light, 'Clair', Icons.light_mode),
_buildThemeOption(ThemeMode.dark, 'Sombre', Icons.dark_mode),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
Widget _buildThemeOption(ThemeMode mode, String label, IconData icon) {
final themeProvider = context.watch<ThemeProvider>();
final isSelected = themeProvider.themeMode == mode;
return ListTile(
leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null),
title: Text(label, style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.primaryColor : null,
)),
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null,
onTap: () {
themeProvider.setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showIdentityDialog() {
if (_identityPhrase == null) return;
@@ -218,14 +264,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
onTap: _showIdentityDialog,
),
Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return _buildSettingsTile(
context: context,
icon: Icons.color_lens_outlined,
title: 'Apparence',
subtitle: themeProvider.themeModeName,
onTap: _showThemeDialog,
);
},
),
const SizedBox(height: 24),
_buildSectionHeader('Gestion'),
_buildSettingsTile(
context: context,
icon: Icons.color_lens_outlined,
title: 'Apparence',
subtitle: 'Thème clair/sombre (à venir)',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fonctionnalité en développement')),
icon: Icons.shield_outlined,
title: 'Mon Armurerie',
subtitle: 'Gérer mes armes et équipements',
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
);
},
),

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
@@ -24,6 +26,8 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
// Valeurs pour les Dropdowns (Filtres)
String _selectedWeapon = 'Toutes';
String _selectedDistance = 'Toutes';
List<String> _availableWeapons = ['Toutes'];
List<String> _availableDistances = ['Toutes'];
@override
void initState() {
@@ -37,6 +41,35 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
try {
final repository = context.read<SessionRepository>();
_allSessions = await repository.getAllSessions();
// Extraire les armes et distances uniques pour les filtres
final weaponsFromSessions = _allSessions.map((s) => s.weapon).toSet();
// Récupérer aussi les armes de l'armurerie pour s'assurer qu'elles sont présentes
final armoryWeapons = await repository.getWeapons();
final weaponsFromArmory = armoryWeapons.map((w) => w.displayName).toSet();
// Combiner et trier
final allWeaponsList = {...weaponsFromSessions, ...weaponsFromArmory}.toList()..sort();
final distances = _allSessions.map((s) => '${s.distance}m').toSet().toList()
..sort((a, b) {
final distA = int.tryParse(a.replaceAll('m', '')) ?? 0;
final distB = int.tryParse(b.replaceAll('m', '')) ?? 0;
return distA.compareTo(distB);
});
_availableWeapons = ['Toutes', ...allWeaponsList];
_availableDistances = ['Toutes', ...distances];
// Sécurité : si l'arme sélectionnée n'est plus disponible, on reset
if (!_availableWeapons.contains(_selectedWeapon)) {
_selectedWeapon = 'Toutes';
}
if (!_availableDistances.contains(_selectedDistance)) {
_selectedDistance = 'Toutes';
}
_calculateStats();
} catch (e) {
debugPrint('Error: $e');
@@ -46,8 +79,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
}
void _calculateStats() {
// Filtrer les sessions avant calcul
final filteredSessions = _allSessions.where((s) {
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
final distanceMatch = _selectedDistance == 'Toutes' || '${s.distance}m' == _selectedDistance;
return weaponMatch && distanceMatch;
}).toList();
_statistics = _statisticsService.calculateStatistics(
_allSessions,
filteredSessions,
period: _selectedPeriod,
);
}
@@ -68,46 +108,57 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
final sortedSessions = List<Session>.from(_statistics!.sessions)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return sortedSessions.map((s) {
if (s.shots.isEmpty) return 0.0;
// Simple precision proxy: score / max possible (assuming 10 is max)
return (s.totalScore / (s.shots.length * 10)) * 100;
if (s.totalShots == 0) return 0.0;
// Simple precision proxy: total score / max possible (assuming 10 is max per shot)
return (s.totalScore / (s.totalShots * 10)) * 100;
}).toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(
0xFF121212,
), // Fond sombre comme sur le design
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'Statistiques',
style: TextStyle(color: Colors.white),
),
title: const Text('Statistiques'),
centerTitle: true,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
: RefreshIndicator(
onRefresh: _loadStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 1. FILTRES (Arme et Distance)
Row(
children: [
Expanded(
child: _buildDropdown('Arme utilisée', _selectedWeapon),
child: _buildDropdown(
'Arme utilisée',
_selectedWeapon,
_availableWeapons,
(val) {
setState(() {
_selectedWeapon = val!;
_calculateStats();
});
},
),
),
const SizedBox(width: 12),
Expanded(
child: _buildDropdown('Distance', _selectedDistance),
child: _buildDropdown(
'Distance',
_selectedDistance,
_availableDistances,
(val) {
setState(() {
_selectedDistance = val!;
_calculateStats();
});
},
),
),
],
),
@@ -162,71 +213,55 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
const SizedBox(height: 20),
_buildQuadrantGrid(),
const SizedBox(height: 20),
_buildHeatMapSection(),
const SizedBox(height: 20),
if (_statistics!.regional.biasX.abs() > 0.05 ||
_statistics!.regional.biasY.abs() > 0.05)
_buildBiasWarning(),
],
const SizedBox(height: 30),
// 4. BOUTON ACCUEIL
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: () => Navigator.of(
context,
).popUntil((route) => route.isFirst),
child: const Text(
'ACCUEIL',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
);
}
// Widget pour les Dropdowns de filtres
Widget _buildDropdown(String label, String value) {
Widget _buildDropdown(
String label,
String value,
List<String> items,
void Function(String?) onChanged,
) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white12),
border: Border.all(color: theme.dividerColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(color: Colors.white54, fontSize: 10),
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 10),
),
DropdownButton<String>(
value: value,
isExpanded: true,
underline: Container(),
dropdownColor: const Color(0xFF1E1E1E),
style: const TextStyle(color: Colors.white, fontSize: 14),
items: [value]
dropdownColor: theme.colorScheme.surfaceContainerHighest,
style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14),
items: items
.map(
(String val) =>
DropdownMenuItem(value: val, child: Text(val)),
)
.toList(),
onChanged: (newValue) {},
onChanged: onChanged,
),
],
),
@@ -235,10 +270,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
// Widget pour les petites cartes de stats
Widget _buildQuickStat(String label, String value, IconData icon) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -247,15 +283,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: theme.textTheme.titleLarge?.color,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12),
),
],
),
@@ -268,10 +304,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
String value,
List<double> dataPoints,
) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -279,15 +316,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [
Text(
title,
style: const TextStyle(color: Colors.white70, fontSize: 14),
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
),
const SizedBox(height: 10),
Row(
children: [
Text(
value,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: theme.textTheme.headlineMedium?.color,
fontSize: 24,
fontWeight: FontWeight.bold,
),
@@ -328,7 +365,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: const Color(0xFF4CAF50).withOpacity(0.2),
color: const Color(0xFF4CAF50).withValues(alpha: 0.2),
),
),
],
@@ -339,19 +376,20 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
Widget _buildRegionalDistribution() {
final regional = _statistics!.regional;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Distribution Régionale',
style: TextStyle(
color: Colors.white,
color: theme.textTheme.titleMedium?.color,
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -363,7 +401,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
const SizedBox(width: 8),
Text(
'Direction dominante : ${regional.dominantDirection}',
style: const TextStyle(color: Colors.white70, fontSize: 14),
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
),
],
),
@@ -378,13 +416,13 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF1A73E8).withOpacity(0.1),
color: const Color(0xFF1A73E8).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF1A73E8).withOpacity(0.3)),
border: Border.all(color: const Color(0xFF1A73E8).withValues(alpha: 0.3)),
),
child: Text(
'${e.key}: ${e.value} (${percentage.toStringAsFixed(0)}%)',
style: const TextStyle(color: Colors.white, fontSize: 12),
style: TextStyle(color: theme.textTheme.bodySmall?.color, fontSize: 12),
),
);
}).toList(),
@@ -396,19 +434,20 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
Widget _buildQuadrantGrid() {
final quadrants = _statistics!.regional.quadrantDistribution;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Répartition par Quadrant',
style: TextStyle(
color: Colors.white,
color: theme.textTheme.titleMedium?.color,
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -435,6 +474,79 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
);
}
Widget _buildHeatMapSection() {
final heatMap = _statistics!.heatMap;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Zones de Chaleur (Heatmap)',
style: TextStyle(
color: theme.textTheme.titleMedium?.color,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Analyse de densité des impacts',
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12),
),
const SizedBox(height: 20),
AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CustomPaint(
painter: _HeatMapPainter(heatMap: heatMap),
),
),
),
),
const SizedBox(height: 16),
// Légende dégradée
Container(
height: 12,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
gradient: const LinearGradient(
colors: [
Colors.blue,
Colors.yellow,
Colors.orange,
Colors.red,
],
),
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Faible', style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.5))),
Text('Élevée', style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.5))),
],
),
],
),
);
}
Widget _buildQuadrantCell(String label, int count) {
final percentage = (count / _statistics!.totalShots * 100);
final intensity = count / _statistics!.totalShots;
@@ -442,10 +554,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
return Container(
decoration: BoxDecoration(
color: Color.lerp(
const Color(0xFF2A2A2A),
Theme.of(context).colorScheme.surfaceContainerHighest,
const Color(0xFF1A73E8),
intensity,
)!.withOpacity(0.8),
)!.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(8),
),
child: Column(
@@ -453,19 +565,19 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [
Text(
'$count',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
'${percentage.toStringAsFixed(0)}%',
style: const TextStyle(color: Colors.white70, fontSize: 12),
style: TextStyle(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.7), fontSize: 12),
),
Text(
label,
style: const TextStyle(color: Colors.white54, fontSize: 10),
style: TextStyle(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.5), fontSize: 10),
),
],
),
@@ -486,9 +598,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Row(
children: [
@@ -507,7 +619,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
),
Text(
'Tendance $description',
style: const TextStyle(color: Colors.white70, fontSize: 13),
style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 13),
),
],
),
@@ -517,3 +629,109 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
);
}
}
class _HeatMapPainter extends CustomPainter {
final HeatMap heatMap;
_HeatMapPainter({required this.heatMap});
@override
void paint(Canvas canvas, Size size) {
_drawTargetBackground(canvas, size);
_drawHeatGradient(canvas, size);
}
void _drawTargetBackground(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final maxRadius = math.min(size.width, size.height) * 0.45;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.grey.withValues(alpha: 0.2); // Neutre
// Draw rings
for (int i = 1; i <= 10; i++) {
canvas.drawCircle(center, maxRadius * (i / 10), paint);
}
// Draw crosshair
canvas.drawLine(Offset(0, center.dy), Offset(size.width, center.dy), paint);
canvas.drawLine(Offset(center.dx, 0), Offset(center.dx, size.height), paint);
}
void _drawHeatGradient(Canvas canvas, Size size) {
final cellSizeW = size.width / heatMap.gridSize;
final cellSizeH = size.height / heatMap.gridSize;
for (var row in heatMap.zones) {
for (var zone in row) {
if (zone.intensity <= 0) continue;
final center = Offset(
(zone.col + 0.5) * cellSizeW,
(zone.row + 0.5) * cellSizeH,
);
final radius = cellSizeW * 1.2; // Légèrement réduit pour plus de netteté
// Normalisation de l'intensité : on rend le max toujours très visible (rouge)
final normalizedIntensity = zone.intensity;
final color = _getIntensityColor(normalizedIntensity);
final paint = Paint()
..shader = ui.Gradient.radial(
center,
radius,
[
color.withValues(alpha: 0.7 * normalizedIntensity.clamp(0.4, 1.0)),
color.withValues(alpha: 0.0),
],
[0.2, 1.0],
)
..style = PaintingStyle.fill; // srcOver par défaut, plus lisible
canvas.drawCircle(center, radius, paint);
// --- AFFICHAGE DU NOMBRE D'IMPACTS ---
if (zone.shotCount > 0) {
final textPainter = TextPainter(
text: TextSpan(
text: '${zone.shotCount}',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
offset: const Offset(1, 1),
),
],
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(center.dx - textPainter.width / 2, center.dy - textPainter.height / 2),
);
}
}
}
}
Color _getIntensityColor(double intensity) {
if (intensity < 0.2) return Colors.blue;
if (intensity < 0.5) return Colors.yellow;
if (intensity < 0.8) return Colors.orange;
return Colors.red;
}
@override
bool shouldRepaint(covariant _HeatMapPainter oldDelegate) =>
heatMap != oldDelegate.heatMap;
}