1074 lines
40 KiB
Dart
1074 lines
40 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;
|
|
|
|
@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) {
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Target image with overlay or calibration
|
|
AspectRatio(
|
|
aspectRatio: provider.imageAspectRatio,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Image.file(
|
|
File(provider.imagePath!),
|
|
fit: BoxFit.fill,
|
|
),
|
|
if (_isCalibrating)
|
|
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);
|
|
},
|
|
)
|
|
else
|
|
TargetOverlay(
|
|
shots: provider.shots,
|
|
targetCenterX: provider.targetCenterX,
|
|
targetCenterY: provider.targetCenterY,
|
|
targetRadius: provider.targetRadius,
|
|
targetType: provider.targetType!,
|
|
ringCount: provider.ringCount,
|
|
ringRadii: provider.ringRadii,
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// 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 _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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|