/// Écran principal de Synthèse 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 (Synthèse), et /// c'est de là que l'on termine la session. L'ajout des impacts, lui, se fait /// dans l'éditeur d'impacts plein écran. library; import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../main_navigation_holder.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/score_calculator_service.dart'; import '../../services/grouping_analyzer_service.dart'; import '../../services/wallet_identity_service.dart'; import '../../services/ai_export_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( scoreCalculatorService: context.read(), groupingAnalyzerService: context.read(), sessionRepository: context.read(), ); // Sauvegarde de l'angle de rotation d'origine directement dans l'état global if (cropRotation != null) { p.setCropRotation(cropRotation!); } p.analyzeImage( imagePath, targetType, 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 _calibrationKey = GlobalKey(); // Forcé à TRUE pour démarrer sur l'ajustement des cercles bool _isCalibrating = true; bool _isAtBottom = false; // Affichage du réglage manuel de l'espacement des anneaux. bool _showSpacing = false; final ScrollController _scrollController = ScrollController(); final GlobalKey _imageKey = GlobalKey(); @override void initState() { super.initState(); _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() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } /// Repasse en mode calibration. /// /// La cible du mode Synthèse est désormais un élément fixe (aucun zoom à /// réinitialiser) : on se contente donc de rebasculer l'état. void _enterCalibration() { setState(() { _isCalibrating = true; // La calibration est reconstruite à neuf (espacement manuel désactivé) : // on aligne l'état du panneau pour ne pas afficher un mode inactif. _showSpacing = false; }); } /// 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, quel que soit le résultat (validation OU retour arrière), on /// revient TOUJOURS sur la Synthèse. L'éditeur d'impacts n'est ouvert que /// depuis la Synthèse : il doit donc y ramener, jamais sur la calibration. Future _openImpactEditor(AnalysisProvider provider) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ChangeNotifierProvider.value( value: provider, child: const ImpactEditorScreen(), ), ), ); if (!mounted) return; setState(() { _isCalibrating = false; }); } /// 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!; } /// Panneau de réglages de la calibration (taille + espacement). /// /// Rendu AU-DESSUS de l'image (et non plus en surimpression) pour ne pas /// masquer la cible. Les valeurs affichées viennent du provider ; les /// modifications sont poussées dans l'état de [TargetCalibration] via sa clé. Widget _buildCalibrationSettings(AnalysisProvider provider) { final radius = provider.targetRadius.clamp( TargetCalibrationState.minRadius, TargetCalibrationState.maxRadius, ); final spacing = (provider.targetRadius > 0 ? provider.targetInnerRadius / provider.targetRadius : 0.1) .clamp( TargetCalibrationState.minSpacing, TargetCalibrationState.maxSpacing, ); return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 36, child: Row( children: [ const Text( 'Taille', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), ), const SizedBox(width: 4), const Icon(Icons.zoom_out, size: 16), Expanded( child: Slider( value: radius, min: TargetCalibrationState.minRadius, max: TargetCalibrationState.maxRadius, activeColor: AppTheme.primaryColor, onChanged: (value) => _calibrationKey.currentState?.setRadius(value), ), ), const Icon(Icons.zoom_in, size: 16), ], ), ), SizedBox( height: 32, child: Row( children: [ const Expanded( child: Text( 'Options d\'espacement avancées', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500), ), ), Switch( value: _showSpacing, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, onChanged: (value) { setState(() => _showSpacing = value); _calibrationKey.currentState?.setSpacingMode(value); }, ), ], ), ), if (_showSpacing) SizedBox( height: 36, child: Row( children: [ const Text( 'Espacement', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), ), const SizedBox(width: 4), const Icon(Icons.compress, size: 16), Expanded( child: Slider( value: spacing, min: TargetCalibrationState.minSpacing, max: TargetCalibrationState.maxSpacing, activeColor: Colors.orange, onChanged: (value) => _calibrationKey.currentState?.setSpacingRatio(value), ), ), const Icon(Icons.expand, size: 16), IconButton( icon: const Icon(Icons.refresh, size: 20), tooltip: 'Réinitialiser l\'espacement', constraints: const BoxConstraints(), padding: const EdgeInsets.symmetric(horizontal: 8), onPressed: () => _calibrationKey.currentState?.resetSpacing(), ), ], ), ), ], ), ); } /// Indication affichée sous le titre en mode Synthèse. /// /// Rien n'indiquait comment ajouter un impact une fois la calibration /// validée : ce rappel pointe vers le geste (toucher la cible). Widget _buildSyntheseHint(AnalysisProvider provider) { final hasShots = provider.shotCount > 0; return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( hasShots ? Icons.touch_app : Icons.add_location_alt, size: 16, color: AppTheme.primaryColor, ), const SizedBox(width: 6), Flexible( child: Text( hasShots ? 'Touchez la cible pour modifier vos impacts' : 'Touchez la cible pour placer vos impacts', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), ), ), ], ), ); } /// Bouton flottant du bas de l'écran. /// /// En calibration : VALIDER. En synthèse : tant qu'aucun impact n'est placé, /// on ne propose pas de terminer la session mais de placer un impact (le /// bouton ouvre l'éditeur, comme un tap sur la cible). Widget _buildBottomAction(BuildContext context, AnalysisProvider provider) { if (_isCalibrating) { return FloatingActionButton.extended( // Même bouton bleu flottant que « TERMINER LA SESSION » : // on fige la calibration puis on bascule sur la Synthèse. onPressed: () { _calibrationKey.currentState?.commitCalibration(); setState(() => _isCalibrating = false); }, backgroundColor: AppTheme.primaryColor, icon: const Icon(Icons.check), label: const Text('VALIDER'), ); } if (provider.shotCount == 0) { return FloatingActionButton.extended( onPressed: () => _openImpactEditor(provider), backgroundColor: AppTheme.primaryColor, icon: const Icon(Icons.add_location_alt), label: const Text('PLACER UN IMPACT'), ); } return FloatingActionButton.extended( onPressed: () => _showSaveSessionDialog(context, provider), backgroundColor: AppTheme.primaryColor, icon: const Icon(Icons.save), label: const Text('TERMINER LA SESSION'), ); } @override Widget build(BuildContext context) { final provider = context.watch(); final sessionProvider = context.watch(); final targetNumber = sessionProvider.isSessionActive ? sessionProvider.targetCount + 1 : null; final titlePrefix = _isCalibrating ? 'Calibration' : 'Synthèse'; final title = targetNumber != null ? '$titlePrefix - Cible $targetNumber' : (_isCalibrating ? 'Calibration' : 'Synthèse du Tir'); return Scaffold( appBar: AppBar( title: Text(title), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { if (_isCalibrating) { final provider = context.read(); 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 Synthèse -> Calibration : on réinitialise le zoom. _enterCalibration(); } }, ), actions: const [], ), body: Stack( children: [ SingleChildScrollView( controller: _scrollController, child: Column( children: [ // Plus de bloc vide au-dessus de l'image : l'indicateur n'occupe // de la place que pendant le chargement. if (provider.state == AnalysisState.loading) const Padding( padding: EdgeInsets.symmetric(vertical: 8.0), child: Center(child: CircularProgressIndicator()), ), // Réglages de calibration : au-dessus de la photo pour ne rien // masquer de la cible. if (_isCalibrating) _buildCalibrationSettings(provider) else _buildSyntheseHint(provider), 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, ) ..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.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!, // Cumul de la session : cibles déjà validées + cible // en cours (absent hors session). sessionTotalScore: sessionProvider.isSessionActive ? sessionProvider.totalSessionScore + provider.totalScore : null, sessionTargetCount: sessionProvider.targetCount + 1, ), const SizedBox(height: 12), if (provider.groupingResult != null && provider.shotCount > 1) GroupingStats( groupingResult: provider.groupingResult!, targetCenterX: provider.targetCenterX, targetCenterY: provider.targetCenterY, ), 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: _buildBottomAction(context, provider), ), ), ), ], ), ); } 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 de la synthèse en LECTURE SEULE. /// /// La cible est un élément d'écran FIXE : elle ne se déplace pas et ne se /// zoome pas (plus d'InteractiveViewer). Un tap n'importe où sur la cible /// ouvre directement l'éditeur d'impacts plein écran (ImpactEditorScreen), /// exactement comme le faisait l'ancien bouton « Modifier les impacts ». /// C'est là que se fait toute l'édition (ajout / déplacement / suppression), /// avec un zoom fiable. Widget _buildReadOnlyPlotImage( BuildContext context, AnalysisProvider provider, ) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _openImpactEditor(context.read()), 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: 1.0, ), ), ], ), ); } Future _showSaveSessionDialog( BuildContext context, AnalysisProvider provider, ) async { // L'option « Participer à l'entraînement IA » (Paramètres) est lue AVANT // d'ouvrir la popup : elle décide de la présence du bouton d'export. final canExport = await WalletIdentityService().isUploadEnabled() && provider.state == AnalysisState.success; if (!context.mounted) return; showDialog( context: context, builder: (context) => AlertDialog( // Protège des petits écrans / grandes polices : la popup défile au // lieu de déborder. scrollable: true, // En-tête bleu pleine largeur : le titre occupe toute la bande, d'où // les paddings mis à zéro. titlePadding: EdgeInsets.zero, contentPadding: const EdgeInsets.fromLTRB(20, 16, 20, 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), clipBehavior: Clip.antiAlias, title: Container( width: double.infinity, color: AppTheme.primaryColor, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: const Row( children: [ Icon(Icons.flag, color: Colors.white), SizedBox(width: 10), Text( 'Session terminée', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), ], ), ), // Les boutons sont dans le contenu (et non dans `actions`) pour être // tous à la même largeur, alignés les uns sous les autres. content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildDialogRecap(context, provider), const SizedBox(height: 16), const Text('Voulez-vous enregistrer cette session ?'), const SizedBox(height: 16), _buildDialogButton( icon: const Icon(Icons.add_a_photo, color: Colors.white), label: 'AJOUTER UNE CIBLE', color: AppTheme.secondaryColor, onPressed: () => _saveAndAddTarget(context, provider), ), const SizedBox(height: 8), _buildDialogButton( icon: const Icon(Icons.save, color: Colors.white), label: 'TERMINER TOUT', color: AppTheme.primaryColor, onPressed: () => _finishSession(context, provider), ), // Bouton d'export : uniquement si l'entraînement IA est autorisé. if (canExport) ...[ const SizedBox(height: 8), _buildDialogButton( icon: Image.asset( 'assets/icons/cloud_save.png', width: 24, height: 24, // L'icône est un trait noir : on la recolore en blanc pour // qu'elle ressorte sur le bouton. color: Colors.white, ), label: 'TERMINER TOUT ET EXPORTER', color: AppTheme.warningColor, onPressed: () => _finishSession(context, provider, export: true), ), ], const SizedBox(height: 4), 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'), ), ], ), ), ); } /// Rappel chiffré (tirs / score) en tête de la popup de fin de session. Widget _buildDialogRecap(BuildContext context, AnalysisProvider provider) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: AppTheme.primaryColor.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildRecapValue(context, '${provider.shotCount}', 'Tirs'), _buildRecapValue(context, '${provider.totalScore}', 'Score total'), ], ), ); } Widget _buildRecapValue(BuildContext context, String value, String label) { return Column( children: [ Text( value, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, color: AppTheme.primaryColor, ), ), Text(label, style: Theme.of(context).textTheme.bodySmall), ], ); } /// Bouton pleine largeur de la popup, aux couleurs du thème. Widget _buildDialogButton({ required Widget icon, required String label, required Color color, required VoidCallback onPressed, }) { return ElevatedButton.icon( icon: icon, label: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), style: ElevatedButton.styleFrom( backgroundColor: color, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), onPressed: onPressed, ); } /// Enregistre la cible courante et enchaîne sur une nouvelle capture. Future _saveAndAddTarget( BuildContext context, AnalysisProvider provider, ) async { try { final sessionProvider = context.read(); 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, ), ); } } } /// Clôture la session et revient sur l'onglet Statistiques. /// /// Avec [export], la cible est en plus envoyée au backend d'entraînement IA. /// L'enregistrement local reste prioritaire : un échec d'export n'empêche /// jamais la session d'être sauvegardée. Future _finishSession( BuildContext context, AnalysisProvider provider, { bool export = false, }) async { // Messenger et session capturés AVANT les await : le contexte de la popup // ne sera plus valide ensuite. final messenger = ScaffoldMessenger.of(context); final sessionProvider = context.read(); try { await provider.saveSession( sessionId: sessionProvider.activeSessionId, weaponName: sessionProvider.currentWeapon, weaponId: sessionProvider.currentWeaponId, distance: sessionProvider.distance, date: sessionProvider.sessionDate, ); AiExportResult? exportResult; if (export) { messenger.showSnackBar( const SnackBar(content: Text('Exportation vers le serveur IA en cours...')), ); exportResult = await provider.exportToAiBackend( sessionId: sessionProvider.activeSessionId, distance: sessionProvider.distance, caliber: sessionProvider.currentWeaponCaliber, ); messenger.hideCurrentSnackBar(); } if (context.mounted) { sessionProvider.endSession(); Navigator.pop(context); Navigator.of(context).popUntil((route) => route.isFirst); // Fin de session : on atterrit sur les statistiques. openMainTab(mainTabStats); } if (exportResult != null) { if (exportResult.isBanned) { messenger.showSnackBar( SnackBar( content: Row( children: [ const Icon(Icons.block, color: Colors.white), const SizedBox(width: 12), Expanded( child: Text( 'Participation IA suspendue : ${exportResult.reason ?? "Non-respect des règles"}', style: const TextStyle(fontWeight: FontWeight.bold), ), ), ], ), backgroundColor: AppTheme.errorColor, duration: const Duration(seconds: 6), ), ); } else if (exportResult.isSuccess) { final targetStatus = exportResult.targetValidation?['status']; final isCertified = targetStatus == 'VALID'; messenger.showSnackBar( SnackBar( content: Row( children: [ Icon( isCertified ? Icons.verified : Icons.cloud_done, color: Colors.white, ), const SizedBox(width: 12), Expanded( child: Text( isCertified ? 'Export réussi ! Cible certifiée par l\'IA.' : exportResult.message, ), ), ], ), backgroundColor: AppTheme.successColor, duration: const Duration(seconds: 4), ), ); } else { messenger.showSnackBar( SnackBar( content: Row( children: [ const Icon(Icons.warning_amber_rounded, color: Colors.white), const SizedBox(width: 12), Expanded( child: Text('Échec de l\'export : ${exportResult.message}'), ), ], ), backgroundColor: AppTheme.errorColor, duration: const Duration(seconds: 4), ), ); } } } catch (e) { messenger.showSnackBar( SnackBar( content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor, ), ); } } }