Files
impact/lib/features/analysis/analysis_screen.dart

668 lines
25 KiB
Dart

/// Écran principal de Plotting et d'analyse - Interface centrale de traitement des cibles.
///
/// Affiche d'abord la calibration de la cible, puis l'overlay des anneaux et impacts détectés.
/// Permet le calcul des scores et statistiques de groupement (Plotting).
library;
import 'dart:io';
import 'dart:math' as math;
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/target_type.dart';
import '../../data/models/shot.dart';
import '../../data/repositories/session_repository.dart';
import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
import '../session/session_provider.dart';
import 'analysis_provider.dart';
import '../crop/crop_screen.dart';
import '../capture/capture_screen.dart';
import 'widgets/target_overlay.dart';
import 'widgets/target_calibration.dart';
import 'widgets/score_card.dart';
import 'widgets/grouping_stats.dart';
class AnalysisScreen extends StatelessWidget {
final String imagePath;
final TargetType targetType;
// CORRECTION : Utilisation de coordonnées directes pour effacer l'erreur rouge
final double? initialCenterX;
final double? initialCenterY;
final double? cropScale;
final Offset? cropOffset;
const AnalysisScreen({
super.key,
required this.imagePath,
required this.targetType,
this.initialCenterX,
this.initialCenterY,
this.cropScale,
this.cropOffset,
});
@override
Widget build(BuildContext context) {
// Reconstitution de l'Offset si présent pour le provider en arrière-plan
final manualCenterOffset = (initialCenterX != null && initialCenterY != null)
? Offset(initialCenterX!, initialCenterY!)
: null;
return ChangeNotifierProvider(
create: (context) => AnalysisProvider(
detectionService: context.read<TargetDetectionService>(),
scoreCalculatorService: context.read<ScoreCalculatorService>(),
groupingAnalyzerService: context.read<GroupingAnalyzerService>(),
sessionRepository: context.read<SessionRepository>(),
)..analyzeImage(imagePath, targetType, autoAnalyze: false, manualCenter: manualCenterOffset),
child: _AnalysisScreenContent(
cropScale: cropScale,
cropOffset: cropOffset,
),
);
}
}
class _AnalysisScreenContent extends StatefulWidget {
final double? cropScale;
final Offset? cropOffset;
const _AnalysisScreenContent({
this.cropScale,
this.cropOffset,
});
@override
State<_AnalysisScreenContent> createState() => _AnalysisScreenContentState();
}
class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
final GlobalKey<TargetCalibrationState> _calibrationKey = GlobalKey<TargetCalibrationState>();
// CORRECTION : Forcé à TRUE pour arriver directement sur la calibration après le Crop !
bool _isCalibrating = true;
bool _isSelectingReferences = false;
bool _isAtBottom = false;
final ScrollController _scrollController = ScrollController();
final TransformationController _transformationController = TransformationController();
final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0;
@override
void initState() {
super.initState();
_transformationController.addListener(_onTransformChanged);
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final isBottom =
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 20;
if (isBottom != _isAtBottom) {
setState(() {
_isAtBottom = isBottom;
});
}
}
@override
void dispose() {
_transformationController.removeListener(_onTransformChanged);
_transformationController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onTransformChanged() {
final scale = _transformationController.value.getMaxScaleOnAxis();
if (scale != _currentZoomScale) {
setState(() {
_currentZoomScale = scale;
});
}
}
@override
Widget build(BuildContext context) {
final provider = context.watch<AnalysisProvider>();
final sessionProvider = context.watch<SessionProvider>();
final targetNumber = sessionProvider.isSessionActive ? sessionProvider.targetCount + 1 : null;
// CORRECTION : Remplacement du mot "Analyse" par "Plotting"
final titlePrefix = _isCalibrating ? 'Calibration' : 'Plotting';
final title = targetNumber != null ? '$titlePrefix - Cible $targetNumber' : ( _isCalibrating ? 'Calibration' : 'Plotting du Tir');
return Scaffold(
appBar: AppBar(
title: Text(title),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (_isCalibrating) {
// Si on fait retour pendant la calibration, on retourne sagement au CropScreen
final provider = context.read<AnalysisProvider>();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CropScreen(
imagePath: provider.imagePath!,
targetType: provider.targetType!,
initialScale: widget.cropScale,
initialOffset: widget.cropOffset,
),
),
);
} else {
// Si on fait retour depuis le Plotting, on revient en arrière dans la calibration
setState(() => _isCalibrating = true);
}
},
),
actions: [
if (!_isCalibrating && !_isSelectingReferences)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => provider.analyzeImage(context.read<AnalysisProvider>().imagePath!, context.read<AnalysisProvider>().targetType!),
),
if (_isCalibrating)
TextButton(
onPressed: () => setState(() => _isCalibrating = false), // Dévoile le Plotting Screen
child: const Text(
'TERMINER',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
),
body: Stack(
children: [
Navigator.canPop(context) ? const SizedBox.shrink() : const SizedBox.shrink(), // Garde structurellement invisible
SingleChildScrollView(
controller: _scrollController,
child: Column(
children: [
// Header de chargement
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (provider.state == AnalysisState.loading)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: CircularProgressIndicator(),
),
),
],
),
),
// Zone centrale d'affichage : Calibration OU Plotting interactif
AspectRatio(
aspectRatio: provider.imageAspectRatio,
child: _isCalibrating
? Stack(
fit: StackFit.expand,
children: [
Image.file(
File(provider.imagePath!),
fit: BoxFit.fill,
),
TargetCalibration(
key: _calibrationKey,
initialCenterX: provider.targetCenterX,
initialCenterY: provider.targetCenterY,
initialRadius: provider.targetRadius,
initialInnerRadius: provider.targetInnerRadius,
initialRingCount: provider.ringCount,
initialRingRadii: provider.ringRadii,
targetType: provider.targetType!,
onCalibrationChanged:
(
centerX,
centerY,
innerRadius,
radius,
ringCount, {
List<double>? ringRadii,
}) {
provider.adjustTargetPosition(
centerX,
centerY,
innerRadius,
radius,
ringCount: ringCount,
ringRadii: ringRadii,
);
},
),
],
)
: _buildZoomableImageWithOverlay(context, provider),
),
// Vue inférieure : Cartes de scores (Plotting) OU Flèches de réglages (Calibration)
if (!_isCalibrating)
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
// Petit bouton pour réajuster la calibration si besoin
Card(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: ListTile(
leading: const Icon(
Icons.tune,
color: AppTheme.primaryColor,
),
title: const Text('Ajuster la calibration'),
subtitle: const Text(
'Modifier le centre ou le rayon global',
),
trailing: const Icon(
Icons.arrow_forward_ios,
size: 16,
),
onTap: () => setState(() => _isCalibrating = true),
),
),
const SizedBox(height: 12),
// Score card
ScoreCard(
totalScore: provider.totalScore,
shotCount: provider.shotCount,
scoreResult: provider.scoreResult,
targetType: provider.targetType!,
),
const SizedBox(height: 12),
// Grouping stats
if (provider.groupingResult != null &&
provider.shotCount > 1)
GroupingStats(
groupingResult: provider.groupingResult!,
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
),
const SizedBox(height: 12),
_buildActionButtons(context, provider),
const SizedBox(height: 50),
],
),
)
else
// Mode Calibration actif : On affiche les flèches de micro-ajustement
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
const Text(
'Ajustement precis (pixel par pixel)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white70,
),
),
const SizedBox(height: 8),
Center(
child: Builder(
builder: (context) {
final size = MediaQuery.of(context).size;
return _calibrationKey.currentState?.buildDirectionalControls(context, size)
?? const SizedBox.shrink();
}
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Instructions de calibration',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 12),
_buildInstructionItem(
Icons.open_with,
'Glissez le centre pour positionner le centre de la cible',
),
_buildInstructionItem(
Icons.zoom_out_map,
'Pincez l\'ecran a 2 doigts ou utilisez la jauge pour la taille',
),
_buildInstructionItem(
Icons.visibility,
'Appuyez sur TERMINER en haut a droite pour valider',
),
const SizedBox(height: 16),
Row(
children: [
const Text('Centre: '),
Text(
'(${(provider.targetCenterX * 100).toStringAsFixed(1)}%, ${(provider.targetCenterY * 100).toStringAsFixed(1)}%)',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
Row(
children: [
const Text('Rayon: '),
Text(
'${(provider.targetRadius * 100).toStringAsFixed(1)}%',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
],
),
),
),
],
),
),
],
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Align(
alignment: _isAtBottom
? Alignment.bottomCenter
: Alignment.bottomRight,
child: Padding(
padding: _isAtBottom
? EdgeInsets.zero
: const EdgeInsets.all(16.0),
child: _isCalibrating
? const SizedBox.shrink()
: FloatingActionButton.extended(
onPressed: () => _showSaveSessionDialog(context, provider),
backgroundColor: AppTheme.primaryColor,
icon: const Icon(Icons.save),
label: const Text('TERMINER LA SESSION'),
),
),
),
),
],
),
);
}
Widget _buildInstructionItem(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(icon, size: 16, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 13),
),
),
],
),
);
}
Widget _buildZoomableImageWithOverlay(
BuildContext context,
AnalysisProvider provider,
) {
return InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 10.0,
boundaryMargin: const EdgeInsets.all(double.infinity),
child: GestureDetector(
// CORRECTION : Utilisation de onDoubleTapDown (compatible toutes versions Flutter)
onDoubleTapDown: (TapDownDetails details) {
final RenderBox? box = _imageKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
final localOffset = box.globalToLocal(details.globalPosition);
// Coordonnées relatives (entre 0.0 et 1.0) sous le doigt
final relX = localOffset.dx / box.size.width;
final relY = localOffset.dy / box.size.height;
if (provider.shots.isEmpty) return;
// Recherche de l'impact le plus proche du double-clic
Shot? closestShot;
double minDistance = double.infinity;
// Seuil de tolérance de détection (environ 5% de la taille de la cible)
const double clickTolerance = 0.05;
for (final shot in provider.shots) {
final dx = shot.x - relX;
final dy = shot.y - relY;
final distance = math.sqrt(dx * dx + dy * dy);
if (distance < minDistance && distance < clickTolerance) {
minDistance = distance;
closestShot = shot;
}
}
// Si un impact est détecté sous le double-clic, on ouvre ses options !
if (closestShot != null) {
_showShotDetails(context, provider, closestShot);
}
},
child: Stack(
children: [
Image.file(
File(provider.imagePath!),
key: _imageKey,
fit: BoxFit.fill,
),
TargetOverlay(
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius,
targetType: provider.targetType!,
shots: provider.shots,
showRings: true,
zoomScale: _currentZoomScale,
onShotTapped: (shot) => _showShotDetails(context, provider, shot),
onAddShot: (relX, relY) => provider.addShot(relX, relY),
),
],
),
),
);
}
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
return const Column(
children: [
Row(
children: [],
),
],
);
}
void _showShotDetails(
BuildContext context,
AnalysisProvider provider,
Shot shot,
) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Impact #${provider.shots.indexOf(shot) + 1}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
'ID: ${shot.id}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.score),
title: const Text('Modifier le score'),
trailing: DropdownButton<int>(
value: shot.score.clamp(0, 10),
items: List.generate(11, (index) => index)
.map((s) => DropdownMenuItem(
value: s,
child: Text('$s', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
))
.toList(),
onChanged: (newScore) {
if (newScore != null) {
provider.updateShotScore(shot.id, newScore);
Navigator.pop(context);
}
},
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
provider.removeShot(shot.id);
Navigator.pop(context);
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text('SUPPRIMER', style: TextStyle(color: Colors.red)),
),
),
],
),
],
),
),
);
}
void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Session Terminee'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Nombre de tirs: ${provider.shotCount}'),
Text('Score total: ${provider.totalScore}'),
const SizedBox(height: 16),
const Text('Voulez-vous enregistrer cette session ?'),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
final path = provider.imagePath!;
final type = provider.targetType!;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CropScreen(
imagePath: path,
targetType: type,
initialScale: widget.cropScale,
initialOffset: widget.cropOffset,
),
),
);
},
child: const Text('ANNULER'),
),
ElevatedButton(
onPressed: () async {
try {
final sessionProvider = context.read<SessionProvider>();
final analysis = await provider.saveSession(
sessionId: sessionProvider.activeSessionId,
weaponName: sessionProvider.currentWeapon,
weaponId: sessionProvider.currentWeaponId,
distance: sessionProvider.distance,
);
sessionProvider.addAnalysis(analysis);
if (context.mounted) {
Navigator.pop(context);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const CaptureScreen()),
(route) => route.isFirst,
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
);
}
}
},
child: const Text('AJOUTER UNE CIBLE'),
),
ElevatedButton(
onPressed: () async {
try {
final sessionProvider = context.read<SessionProvider>();
await provider.saveSession(
sessionId: sessionProvider.activeSessionId,
weaponName: sessionProvider.currentWeapon,
weaponId: sessionProvider.currentWeaponId,
distance: sessionProvider.distance,
);
if (context.mounted) {
sessionProvider.endSession();
Navigator.pop(context);
Navigator.of(context).popUntil((route) => route.isFirst);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
);
}
}
},
child: const Text('TERMINER TOUT'),
),
],
),
);
}
}