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

836 lines
31 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 '../../services/wallet_identity_service.dart';
import '../session/session_provider.dart';
import 'analysis_provider.dart';
import 'impact_editor_screen.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 String? originalImagePath; // AJOUT : image source avant le crop à 85%
final TargetType targetType;
final double? initialCenterX;
final double? initialCenterY;
final double? cropScale;
final Offset? cropOffset;
final double? cropRotation; // Reçu proprement depuis le CropScreen
const AnalysisScreen({
super.key,
required this.imagePath,
this.originalImagePath, // AJOUT
required this.targetType,
this.initialCenterX,
this.initialCenterY,
this.cropScale,
this.cropOffset,
this.cropRotation,
});
@override
Widget build(BuildContext context) {
// Reconstitution de l'Offset pour le traitement métier en arrière-plan
final manualCenterOffset =
(initialCenterX != null && initialCenterY != null)
? Offset(initialCenterX!, initialCenterY!)
: null;
return ChangeNotifierProvider(
create: (context) {
final p = AnalysisProvider(
detectionService: context.read<TargetDetectionService>(),
scoreCalculatorService: context.read<ScoreCalculatorService>(),
groupingAnalyzerService: context.read<GroupingAnalyzerService>(),
sessionRepository: context.read<SessionRepository>(),
);
// Sauvegarde de l'angle de rotation d'origine directement dans l'état global
if (cropRotation != null) {
p.setCropRotation(cropRotation!);
}
p.analyzeImage(
imagePath,
targetType,
autoAnalyze: false,
manualCenter: manualCenterOffset,
);
return p;
},
child: _AnalysisScreenContent(
originalImagePath: originalImagePath, // AJOUT
cropScale: cropScale,
cropOffset: cropOffset,
cropRotation: cropRotation, // Envoyé à la structure d'affichage
),
);
}
}
class _AnalysisScreenContent extends StatefulWidget {
final String? originalImagePath; // AJOUT
final double? cropScale;
final Offset? cropOffset;
final double? cropRotation;
const _AnalysisScreenContent({
this.originalImagePath, // AJOUT
this.cropScale,
this.cropOffset,
this.cropRotation,
});
@override
State<_AnalysisScreenContent> createState() => _AnalysisScreenContentState();
}
class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
final GlobalKey<TargetCalibrationState> _calibrationKey =
GlobalKey<TargetCalibrationState>();
// Forcé à TRUE pour démarrer sur l'ajustement des cercles
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;
});
}
}
/// Repasse en mode calibration en réinitialisant le zoom de l'InteractiveViewer.
///
/// Sans cette remise à zéro, le facteur de zoom accumulé en mode Plotting
/// persiste dans le TransformationController et se réapplique au retour,
/// ce qui faisait "zoomer" légèrement la photo. On repart donc toujours
/// d'une transformation identité (zoom 1.0).
void _enterCalibration() {
_transformationController.value = Matrix4.identity();
_currentZoomScale = 1.0;
setState(() => _isCalibrating = true);
}
/// Ouvre l'éditeur d'impacts (plein écran) en PARTAGEANT le provider courant.
///
/// On utilise ChangeNotifierProvider.value pour que l'éditeur lise et modifie
/// exactement le même AnalysisProvider que cet écran : les impacts ajoutés,
/// déplacés ou supprimés sont donc immédiatement répercutés ici.
///
/// Au retour : si l'utilisateur a validé (résultat true) on bascule en mode
/// Plotting (lecture seule) ; sinon on repasse en calibration.
Future<void> _openImpactEditor(AnalysisProvider provider) async {
final validated = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
value: provider,
child: const ImpactEditorScreen(),
),
),
);
if (!mounted) return;
if (validated == true) {
setState(() {
_isCalibrating = false;
_isSelectingReferences = false;
});
} else {
_enterCalibration();
}
}
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
///
/// On privilégie TOUJOURS l'image source non rognée (originalImagePath).
/// Repartir de l'image déjà croppée à 85% provoquait un rognage cumulatif
/// (0.85 x 0.85 x ...) qui re-zoomait la photo à chaque aller-retour
/// entre la capture/crop et la calibration.
String _backCropImagePath(AnalysisProvider provider) {
return widget.originalImagePath ?? provider.imagePath!;
}
@override
Widget build(BuildContext context) {
final provider = context.watch<AnalysisProvider>();
final sessionProvider = context.watch<SessionProvider>();
final targetNumber = sessionProvider.isSessionActive
? sessionProvider.targetCount + 1
: null;
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) {
final provider = context.read<AnalysisProvider>();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CropScreen(
// CORRECTION : on repart de l'image SOURCE (non rognée) pour
// éviter le rognage cumulatif à 85% qui re-zoomait à chaque retour.
imagePath: _backCropImagePath(provider),
targetType: provider.targetType!,
initialScale: widget.cropScale,
initialOffset: widget.cropOffset,
),
),
);
} else {
// Retour Plotting -> Calibration : on réinitialise le zoom.
_enterCalibration();
}
},
),
actions: [
if (!_isCalibrating && !_isSelectingReferences)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => provider.analyzeImage(
context.read<AnalysisProvider>().imagePath!,
context.read<AnalysisProvider>().targetType!,
),
),
// Nuage d'export vers le backend IA : visible uniquement si l'analyse
// a réussi ET que l'utilisateur a activé l'option dans les Paramètres.
if (!_isCalibrating && !_isSelectingReferences)
FutureBuilder<bool>(
future: WalletIdentityService().isUploadEnabled(),
builder: (context, snapshot) {
final isEnabled = snapshot.data ?? false;
if (!isEnabled ||
provider.state != AnalysisState.success) {
return const SizedBox.shrink();
}
return IconButton(
icon: const Icon(Icons.cloud_upload),
tooltip: 'Exporter pour IA',
onPressed: () async {
final p = context.read<AnalysisProvider>();
if (p.state != AnalysisState.success) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Exportation en cours...')),
);
final success = await p.exportToAiBackend();
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Export réussi vers le backend IA !'),
backgroundColor: AppTheme.successColor,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(p.errorMessage ?? 'Erreur d\'export'),
backgroundColor: AppTheme.errorColor,
),
);
}
},
);
},
),
if (_isCalibrating)
TextButton(
onPressed: () {
// On fige la calibration courante AVANT d'ouvrir l'éditeur,
// puis on passe sur l'écran d'édition d'impacts plein écran
// (zoom fiable + placement). Le mode Plotting (lecture seule)
// s'affichera au retour si l'utilisateur valide.
_calibrationKey.currentState?.commitCalibration();
_openImpactEditor(context.read<AnalysisProvider>());
},
child: const Text(
'VALIDER',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
body: Stack(
children: [
SingleChildScrollView(
controller: _scrollController,
child: Column(
children: [
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(),
),
),
],
),
),
AspectRatio(
aspectRatio: provider.imageAspectRatio,
child: _isCalibrating
? Stack(
fit: StackFit.expand,
children: [
ClipRect(
child: Builder(
builder: (context) {
return Transform(
transform: Matrix4.identity()
..setTranslationRaw(
widget.cropOffset?.dx ?? 0.0,
widget.cropOffset?.dy ?? 0.0,
0.0,
)
..scale(1.0, 1.0)
..rotateZ(
(provider.cropRotation) *
(math.pi / 180),
),
alignment: Alignment.center,
child: Image.file(
File(provider.imagePath!),
fit: BoxFit.contain,
),
);
},
),
),
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, {
ringRadii,
}) {
provider.adjustTargetPosition(
centerX,
centerY,
innerRadius,
radius,
ringCount: ringCount,
ringRadii: ringRadii,
zoomScale: 1.0,
);
},
),
],
)
: _buildReadOnlyPlotImage(context, provider),
),
if (!_isCalibrating)
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
Card(
color: AppTheme.primaryColor.withValues(alpha: 0.18),
child: ListTile(
leading: const Icon(
Icons.edit_location_alt,
color: AppTheme.primaryColor,
),
title: const Text('Modifier les impacts'),
subtitle: const Text(
'Ajouter, déplacer ou supprimer des impacts (plein écran)',
),
trailing: const Icon(
Icons.open_in_full,
size: 16,
),
// Rouvre l'éditeur plein écran en partageant le provider.
onTap: () =>
_openImpactEditor(context.read<AnalysisProvider>()),
),
),
const SizedBox(height: 12),
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,
),
// Retour vers la calibration : on réinitialise le zoom.
onTap: () => _enterCalibration(),
),
),
const SizedBox(height: 12),
ScoreCard(
totalScore: provider.totalScore,
shotCount: provider.shotCount,
scoreResult: provider.scoreResult,
targetType: provider.targetType!,
),
const SizedBox(height: 12),
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
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))),
],
),
);
}
/// Affichage du plotting en LECTURE SEULE.
///
/// L'édition (ajout / déplacement / suppression) se fait désormais
/// exclusivement dans l'éditeur plein écran (ImpactEditorScreen). Ici on se
/// contente d'afficher l'image + l'overlay, avec un zoom de consultation.
/// Le tap sur un impact ouvre simplement ses détails.
Widget _buildReadOnlyPlotImage(
BuildContext context,
AnalysisProvider provider,
) {
return InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 10.0,
boundaryMargin: const EdgeInsets.all(80),
panEnabled: true,
child: Stack(
children: [
Image.file(
File(provider.imagePath!),
key: _imageKey,
fit: BoxFit.contain,
),
Positioned.fill(
child: TargetOverlay(
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius,
targetType: provider.targetType!,
shots: provider.shots,
showRings: true,
zoomScale: _currentZoomScale,
// Lecture seule : tap sur impact -> détails (consultation).
onShotTapped: (shot) =>
_showShotDetails(context, provider, shot),
),
),
],
),
);
}
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);
// CORRECTION : on repart aussi de l'image SOURCE non rognée ici.
final path = _backCropImagePath(provider);
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,
date: sessionProvider.sessionDate,
);
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,
date: sessionProvider.sessionDate,
);
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'),
),
],
),
);
}
}