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

1332 lines
49 KiB
Dart

/// Écran principal d'analyse - Interface centrale de traitement des cibles.
///
/// Affiche la cible avec overlay des anneaux et impacts détectés.
/// Permet la calibration, l'ajout manuel d'impacts, la détection automatique,
/// et le calcul des scores et statistiques de groupement.
library;
import 'dart:io';
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/repositories/session_repository.dart';
import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
import 'analysis_provider.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;
const AnalysisScreen({
super.key,
required this.imagePath,
required this.targetType,
});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => AnalysisProvider(
detectionService: context.read<TargetDetectionService>(),
scoreCalculatorService: context.read<ScoreCalculatorService>(),
groupingAnalyzerService: context.read<GroupingAnalyzerService>(),
sessionRepository: context.read<SessionRepository>(),
)..analyzeImage(imagePath, targetType),
child: const _AnalysisScreenContent(),
);
}
}
class _AnalysisScreenContent extends StatefulWidget {
const _AnalysisScreenContent();
@override
State<_AnalysisScreenContent> createState() => _AnalysisScreenContentState();
}
class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
bool _isCalibrating = false;
bool _isSelectingReferences = false;
bool _isFullscreenEditMode = false;
final TransformationController _transformationController = TransformationController();
final GlobalKey _imageKey = GlobalKey();
double _currentZoomScale = 1.0;
@override
void initState() {
super.initState();
_transformationController.addListener(_onTransformChanged);
}
@override
void dispose() {
_transformationController.removeListener(_onTransformChanged);
_transformationController.dispose();
super.dispose();
}
void _onTransformChanged() {
final newScale = _transformationController.value.getMaxScaleOnAxis();
if (newScale != _currentZoomScale) {
setState(() {
_currentZoomScale = newScale;
});
}
}
void _toggleFullscreenEditMode() {
setState(() {
_isFullscreenEditMode = !_isFullscreenEditMode;
if (!_isFullscreenEditMode) {
// Reset zoom when exiting fullscreen mode
_transformationController.value = Matrix4.identity();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isCalibrating ? 'Calibration' : 'Analyse'),
actions: [
Consumer<AnalysisProvider>(
builder: (context, provider, _) {
if (provider.state != AnalysisState.success) return const SizedBox.shrink();
return IconButton(
icon: Icon(_isCalibrating ? Icons.check : Icons.tune),
onPressed: () {
setState(() => _isCalibrating = !_isCalibrating);
},
tooltip: _isCalibrating ? 'Terminer calibration' : 'Calibrer la cible',
color: _isCalibrating ? AppTheme.successColor : null,
);
},
),
IconButton(
icon: const Icon(Icons.help_outline),
onPressed: () => _showHelpDialog(context),
tooltip: 'Aide',
),
],
),
body: Consumer<AnalysisProvider>(
builder: (context, provider, _) {
switch (provider.state) {
case AnalysisState.initial:
case AnalysisState.loading:
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Analyse en cours...'),
],
),
);
case AnalysisState.error:
return Center(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: AppTheme.errorColor),
const SizedBox(height: 16),
Text(
provider.errorMessage ?? 'Une erreur est survenue',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('Retour'),
),
],
),
),
);
case AnalysisState.success:
return _buildSuccessContent(context, provider);
}
},
),
floatingActionButton: Consumer<AnalysisProvider>(
builder: (context, provider, _) {
if (provider.state != AnalysisState.success) return const SizedBox.shrink();
if (_isCalibrating) {
return FloatingActionButton.extended(
onPressed: () {
setState(() => _isCalibrating = false);
},
backgroundColor: AppTheme.successColor,
icon: const Icon(Icons.check),
label: const Text('Valider'),
);
}
return FloatingActionButton.extended(
onPressed: () => _saveSession(context, provider),
icon: const Icon(Icons.save),
label: const Text('Sauvegarder'),
);
},
),
);
}
Widget _buildSuccessContent(BuildContext context, AnalysisProvider provider) {
// Mode plein écran pour l'édition des impacts
if (_isFullscreenEditMode) {
return _buildFullscreenEditContent(context, provider);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Calibration mode indicator
if (_isCalibrating)
Container(
color: AppTheme.warningColor,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: const Row(
children: [
Icon(Icons.tune, color: Colors.white, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
'Mode Calibration - Ajustez le centre et la taille de la cible',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
),
),
// Reference selection mode indicator
if (_isSelectingReferences)
Container(
color: Colors.deepPurple,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
children: [
const Icon(Icons.touch_app, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Selectionnez ${provider.referenceImpacts.length}/3-4 impacts de reference',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
if (provider.referenceImpacts.isNotEmpty)
TextButton(
onPressed: () => provider.clearReferenceImpacts(),
child: const Text('Effacer', style: TextStyle(color: Colors.white)),
),
],
),
),
// Calibration sliders (shown only in calibration mode)
if (_isCalibrating)
Container(
color: Colors.black87,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
// Ring count slider
Row(
children: [
const Icon(Icons.radio_button_unchecked, color: Colors.white, size: 20),
const SizedBox(width: 8),
const Text('Anneaux:', style: TextStyle(color: Colors.white)),
Expanded(
child: Slider(
value: provider.ringCount.toDouble(),
min: 3,
max: 12,
divisions: 9,
label: '${provider.ringCount}',
activeColor: AppTheme.primaryColor,
onChanged: (value) {
provider.adjustTargetPosition(
provider.targetCenterX,
provider.targetCenterY,
provider.targetRadius,
ringCount: value.round(),
);
},
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.primaryColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${provider.ringCount}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
),
// Target size slider
Row(
children: [
const Icon(Icons.zoom_out_map, color: Colors.white, size: 20),
const SizedBox(width: 8),
const Text('Taille:', style: TextStyle(color: Colors.white)),
Expanded(
child: Slider(
value: provider.targetRadius.clamp(0.05, 3.0),
min: 0.05,
max: 3.0,
label: '${(provider.targetRadius * 100).toStringAsFixed(0)}%',
activeColor: AppTheme.warningColor,
onChanged: (value) {
provider.adjustTargetPosition(
provider.targetCenterX,
provider.targetCenterY,
value,
ringCount: provider.ringCount,
);
},
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.warningColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${(provider.targetRadius * 100).toStringAsFixed(0)}%',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
),
const Divider(color: Colors.white24, height: 16),
// Distortion correction row
Row(
children: [
const Icon(Icons.lens_blur, color: Colors.white, size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text('Correction distorsion:', style: TextStyle(color: Colors.white)),
),
if (provider.distortionParams == null)
ElevatedButton.icon(
onPressed: () {
provider.calculateDistortion();
},
icon: const Icon(Icons.calculate, size: 16),
label: const Text('Calculer'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
),
)
else ...[
if (provider.correctedImagePath == null)
ElevatedButton.icon(
onPressed: () {
provider.applyDistortionCorrection();
},
icon: const Icon(Icons.auto_fix_high, size: 16),
label: const Text('Appliquer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
),
)
else
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle, color: Colors.green, size: 16),
const SizedBox(width: 4),
const Text('Corrigée', style: TextStyle(color: Colors.green, fontSize: 12)),
const SizedBox(width: 8),
Switch(
value: provider.distortionCorrectionEnabled,
onChanged: (value) => provider.setDistortionCorrectionEnabled(value),
activeTrackColor: AppTheme.primaryColor.withValues(alpha: 0.5),
activeThumbColor: AppTheme.primaryColor,
),
],
),
],
],
),
],
),
),
// Target image with overlay or calibration
AspectRatio(
aspectRatio: provider.imageAspectRatio,
child: _isCalibrating
? Stack(
fit: StackFit.expand,
children: [
Image.file(
File(provider.imagePath!),
fit: BoxFit.fill,
),
TargetCalibration(
initialCenterX: provider.targetCenterX,
initialCenterY: provider.targetCenterY,
initialRadius: provider.targetRadius,
initialRingCount: provider.ringCount,
initialRingRadii: provider.ringRadii,
targetType: provider.targetType!,
onCalibrationChanged: (centerX, centerY, radius, ringCount, {List<double>? ringRadii}) {
provider.adjustTargetPosition(centerX, centerY, radius, ringCount: ringCount, ringRadii: ringRadii);
},
),
],
)
: _buildZoomableImageWithOverlay(context, provider),
),
// Info cards (hidden during calibration)
if (!_isCalibrating)
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
// Calibration button
Card(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: ListTile(
leading: const Icon(Icons.tune, color: AppTheme.primaryColor),
title: const Text('Calibrer la cible'),
subtitle: const Text('Ajustez le centre et la taille'),
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),
// Action buttons
_buildActionButtons(context, provider),
],
),
)
else
// Calibration info
Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: 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 (croix bleue) pour positionner le centre de la cible',
),
_buildInstructionItem(
Icons.zoom_out_map,
'Glissez le bord (cercle orange) pour ajuster la taille de la cible',
),
_buildInstructionItem(
Icons.visibility,
'Les zones de score sont affichees en transparence',
),
const SizedBox(height: 12),
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),
),
],
),
],
),
),
),
),
],
),
);
}
Widget _buildZoomableImageWithOverlay(BuildContext context, AnalysisProvider provider) {
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
// Image zoomable avec InteractiveViewer
InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 5.0,
panEnabled: true,
scaleEnabled: true,
constrained: true,
boundaryMargin: EdgeInsets.zero,
interactionEndFrictionCoefficient: 0.0000135,
child: Stack(
fit: StackFit.expand,
children: [
Image.file(
File(provider.displayImagePath!),
fit: BoxFit.fill,
key: _imageKey,
),
// Overlay qui se transforme avec l'image
TargetOverlay(
shots: provider.shots,
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius,
targetType: provider.targetType!,
ringCount: provider.ringCount,
ringRadii: provider.ringRadii,
zoomScale: _currentZoomScale,
onShotTapped: (shot) => _isSelectingReferences
? null
: _showShotOptions(context, provider, shot.id),
onAddShot: (x, y) {
if (_isSelectingReferences) {
provider.addReferenceImpact(x, y);
} else {
provider.addShot(x, y);
}
},
groupingCenterX: provider.groupingResult?.centerX,
groupingCenterY: provider.groupingResult?.centerY,
groupingDiameter: provider.groupingResult?.diameter,
referenceImpacts: _isSelectingReferences ? provider.referenceImpacts : null,
),
],
),
),
// Bouton pour passer en mode plein écran d'édition
Positioned(
right: 8,
bottom: 8,
child: FloatingActionButton.small(
heroTag: 'fullscreenEdit',
onPressed: _toggleFullscreenEditMode,
backgroundColor: Colors.black54,
child: const Icon(Icons.fullscreen, color: Colors.white),
),
),
],
),
);
}
Widget _buildFullscreenEditContent(BuildContext context, AnalysisProvider provider) {
return Stack(
fit: StackFit.expand,
children: [
// Image zoomable en plein écran
InteractiveViewer(
transformationController: _transformationController,
minScale: 1.0,
maxScale: 5.0,
panEnabled: true,
scaleEnabled: true,
constrained: true,
boundaryMargin: EdgeInsets.zero,
interactionEndFrictionCoefficient: 0.0000135,
child: Center(
child: AspectRatio(
aspectRatio: provider.imageAspectRatio,
child: Stack(
fit: StackFit.expand,
children: [
Image.file(
File(provider.displayImagePath!),
fit: BoxFit.fill,
key: _imageKey,
),
// Overlay qui se transforme avec l'image
TargetOverlay(
shots: provider.shots,
targetCenterX: provider.targetCenterX,
targetCenterY: provider.targetCenterY,
targetRadius: provider.targetRadius,
targetType: provider.targetType!,
ringCount: provider.ringCount,
ringRadii: provider.ringRadii,
zoomScale: _currentZoomScale,
onShotTapped: (shot) => _showShotOptions(context, provider, shot.id),
onAddShot: (x, y) {
provider.addShot(x, y);
},
groupingCenterX: provider.groupingResult?.centerX,
groupingCenterY: provider.groupingResult?.centerY,
groupingDiameter: provider.groupingResult?.diameter,
),
],
),
),
),
),
// Barre d'info en haut
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
color: Colors.black87,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SafeArea(
bottom: false,
child: Row(
children: [
const Icon(Icons.edit, color: Colors.white, size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Mode édition - Touchez pour ajouter des impacts',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
Text(
'${provider.shotCount} impacts',
style: TextStyle(color: Colors.white.withValues(alpha: 0.8)),
),
],
),
),
),
),
// Bouton pour quitter le mode plein écran
Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton(
heroTag: 'exitFullscreen',
onPressed: _toggleFullscreenEditMode,
backgroundColor: AppTheme.primaryColor,
child: const Icon(Icons.fullscreen_exit, color: Colors.white),
),
),
// Indicateur de zoom en bas à gauche
Positioned(
left: 16,
bottom: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.zoom_in, color: Colors.white, size: 18),
const SizedBox(width: 6),
Text(
'${(_currentZoomScale * 100).toStringAsFixed(0)}%',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
);
}
Widget _buildInstructionItem(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Expanded(child: Text(text)),
],
),
);
}
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
return Column(
children: [
// Reference-based detection section
if (_isSelectingReferences) ...[
Card(
color: Colors.deepPurple.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.touch_app, color: Colors.deepPurple),
const SizedBox(width: 8),
Text(
'${provider.referenceImpacts.length} reference(s) selectionnee(s)',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 8),
const Text(
'Touchez l\'image pour marquer 3-4 impacts de reference. '
'L\'algorithme apprendra leurs caracteristiques pour detecter les autres.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
setState(() => _isSelectingReferences = false);
provider.clearReferenceImpacts();
},
child: const Text('Annuler'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: provider.referenceImpacts.length >= 2
? () => _showCalibratedDetectionDialog(context, provider)
: null,
icon: const Icon(Icons.auto_fix_high),
label: const Text('Detecter'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
),
),
],
),
],
),
),
),
const SizedBox(height: 12),
] else ...[
// Auto-detect buttons row
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showAutoDetectDialog(context, provider),
icon: const Icon(Icons.auto_fix_high),
label: const Text('Auto-Detection'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () => setState(() => _isSelectingReferences = true),
icon: const Icon(Icons.touch_app),
label: const Text('Par Reference'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
const SizedBox(height: 12),
],
// Manual actions
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isSelectingReferences ? null : () => _showAddShotHint(context),
icon: const Icon(Icons.add_circle_outline),
label: const Text('Ajouter'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: provider.shotCount > 0 && !_isSelectingReferences
? () => _showClearConfirmation(context, provider)
: null,
icon: const Icon(Icons.clear_all),
label: const Text('Effacer'),
),
),
],
),
],
);
}
void _showHelpDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Aide'),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('1. Calibrez d\'abord la cible en utilisant le bouton de calibration'),
SizedBox(height: 8),
Text('2. Appuyez sur l\'image pour ajouter un impact manuellement'),
SizedBox(height: 8),
Text('3. Appuyez sur un impact pour le modifier ou le supprimer'),
SizedBox(height: 8),
Text('4. Les cercles bleus indiquent le groupement de vos tirs'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
void _showShotOptions(BuildContext context, AnalysisProvider provider, String shotId) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.delete, color: AppTheme.errorColor),
title: const Text('Supprimer cet impact'),
onTap: () {
provider.removeShot(shotId);
Navigator.pop(context);
},
),
],
),
),
);
}
void _showAddShotHint(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Appuyez sur l\'image pour ajouter un impact'),
duration: Duration(seconds: 2),
),
);
}
void _showClearConfirmation(BuildContext context, AnalysisProvider provider) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer'),
content: const Text('Voulez-vous effacer tous les impacts?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () {
for (final shot in List.from(provider.shots)) {
provider.removeShot(shot.id);
}
Navigator.pop(context);
},
child: const Text('Effacer', style: TextStyle(color: AppTheme.errorColor)),
),
],
),
);
}
void _showAutoDetectDialog(BuildContext context, AnalysisProvider provider) {
int darkThreshold = 80;
int minImpactSize = 20;
int maxImpactSize = 500;
double minCircularity = 0.6;
double minFillRatio = 0.5;
bool clearExisting = true;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Row(
children: [
Icon(Icons.auto_fix_high, color: AppTheme.primaryColor),
SizedBox(width: 8),
Text('Auto-Detection'),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ajustez les parametres de detection:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Dark threshold slider
Text('Seuil de detection (zones sombres): $darkThreshold'),
Slider(
value: darkThreshold.toDouble(),
min: 20,
max: 150,
divisions: 13,
label: '$darkThreshold',
onChanged: (value) {
setState(() => darkThreshold = value.round());
},
),
const Text(
'Plus bas = detecte uniquement les zones tres sombres',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
// Circularity slider
Text('Circularite minimum: ${(minCircularity * 100).toStringAsFixed(0)}%'),
Slider(
value: minCircularity,
min: 0.3,
max: 0.9,
divisions: 12,
label: '${(minCircularity * 100).toStringAsFixed(0)}%',
onChanged: (value) {
setState(() => minCircularity = value);
},
),
const Text(
'Plus haut = detecte uniquement les formes rondes',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
// Fill ratio slider
Text('Remplissage minimum: ${(minFillRatio * 100).toStringAsFixed(0)}%'),
Slider(
value: minFillRatio,
min: 0.3,
max: 0.9,
divisions: 12,
label: '${(minFillRatio * 100).toStringAsFixed(0)}%',
onChanged: (value) {
setState(() => minFillRatio = value);
},
),
const Text(
'Plus haut = detecte les trous pleins (evite les cercles creux des chiffres)',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
// Min impact size slider
Text('Taille minimum (pixels): $minImpactSize'),
Slider(
value: minImpactSize.toDouble(),
min: 5,
max: 100,
divisions: 19,
label: '$minImpactSize',
onChanged: (value) {
setState(() => minImpactSize = value.round());
},
),
const SizedBox(height: 12),
// Max impact size slider
Text('Taille maximum (pixels): $maxImpactSize'),
Slider(
value: maxImpactSize.toDouble(),
min: 100,
max: 1000,
divisions: 18,
label: '$maxImpactSize',
onChanged: (value) {
setState(() => maxImpactSize = value.round());
},
),
const SizedBox(height: 12),
// Clear existing checkbox
CheckboxListTile(
title: const Text('Effacer les impacts existants'),
value: clearExisting,
onChanged: (value) {
setState(() => clearExisting = value ?? true);
},
contentPadding: EdgeInsets.zero,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: () async {
Navigator.pop(context);
// Show loading indicator
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
SizedBox(width: 12),
Text('Detection en cours...'),
],
),
duration: Duration(seconds: 10),
),
);
// Run detection
final count = await provider.autoDetectImpacts(
darkThreshold: darkThreshold,
minImpactSize: minImpactSize,
maxImpactSize: maxImpactSize,
minCircularity: minCircularity,
minFillRatio: minFillRatio,
clearExisting: clearExisting,
);
// Hide loading and show result
if (context.mounted) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
count > 0
? '$count impact(s) detecte(s)'
: 'Aucun impact detecte. Essayez d\'ajuster les parametres.',
),
backgroundColor: count > 0 ? AppTheme.successColor : AppTheme.warningColor,
),
);
}
},
icon: const Icon(Icons.search),
label: const Text('Detecter'),
),
],
),
),
);
}
void _showCalibratedDetectionDialog(BuildContext context, AnalysisProvider provider) {
double tolerance = 2.0;
bool clearExisting = true;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Row(
children: [
Icon(Icons.touch_app, color: Colors.deepPurple),
SizedBox(width: 8),
Text('Detection par Reference'),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
color: Colors.deepPurple.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.deepPurple),
const SizedBox(width: 8),
Expanded(
child: Text(
'${provider.referenceImpacts.length} impacts de reference selectionnes',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
),
),
const SizedBox(height: 16),
const Text(
'L\'algorithme va analyser les caracteristiques des impacts '
'de reference (luminosite, taille, forme) et chercher des '
'impacts similaires sur toute l\'image.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
// Tolerance slider
Text('Tolerance: ${tolerance.toStringAsFixed(1)}x'),
Slider(
value: tolerance,
min: 1.0,
max: 5.0,
divisions: 8,
label: '${tolerance.toStringAsFixed(1)}x',
activeColor: Colors.deepPurple,
onChanged: (value) {
setState(() => tolerance = value);
},
),
const Text(
'Tolerance basse = detection stricte (moins de faux positifs)\n'
'Tolerance haute = detection large (plus de detections)',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
// Clear existing checkbox
CheckboxListTile(
title: const Text('Effacer les impacts existants'),
value: clearExisting,
activeColor: Colors.deepPurple,
onChanged: (value) {
setState(() => clearExisting = value ?? true);
},
contentPadding: EdgeInsets.zero,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: () async {
Navigator.pop(context);
// Learn from references
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
SizedBox(width: 12),
Text('Apprentissage des references...'),
],
),
duration: Duration(seconds: 10),
),
);
final learned = provider.learnFromReferences();
if (!learned) {
if (context.mounted) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Impossible d\'analyser les references. Essayez de selectionner d\'autres impacts.'),
backgroundColor: AppTheme.errorColor,
),
);
}
return;
}
// Run calibrated detection
if (context.mounted) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
SizedBox(width: 12),
Text('Detection en cours...'),
],
),
duration: Duration(seconds: 10),
),
);
}
final count = await provider.detectFromReferences(
tolerance: tolerance,
clearExisting: clearExisting,
);
// Exit reference mode
if (mounted) {
setState(() {});
}
_isSelectingReferences = false;
provider.clearReferenceImpacts();
// Show result
if (context.mounted) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
count > 0
? '$count impact(s) detecte(s) a partir des references'
: 'Aucun impact detecte. Essayez d\'augmenter la tolerance.',
),
backgroundColor: count > 0 ? AppTheme.successColor : AppTheme.warningColor,
),
);
}
// Trigger rebuild
if (mounted) {
this.setState(() {});
}
},
icon: const Icon(Icons.search),
label: const Text('Detecter'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
),
],
),
),
);
}
Future<void> _saveSession(BuildContext context, AnalysisProvider provider) async {
final notesController = TextEditingController();
final shouldSave = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sauvegarder la session'),
content: TextField(
controller: notesController,
decoration: const InputDecoration(
labelText: 'Notes (optionnel)',
hintText: 'Ex: Entrainement, 10m, debout...',
),
maxLines: 2,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Sauvegarder'),
),
],
),
);
if (shouldSave == true) {
try {
await provider.saveSession(notes: notesController.text.isEmpty ? null : notesController.text);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Session sauvegardee'),
backgroundColor: AppTheme.successColor,
),
);
Navigator.pop(context);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
}
}
}