import 'dart:io'; import 'dart:math' as math; import 'dart:async'; // AJOUT : Pour le Timer de détection périodique import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:camera/camera.dart'; // AJOUT : Pour piloter le flux caméra en direct import 'package:provider/provider.dart'; import '../../core/constants/app_constants.dart'; import '../../core/theme/app_theme.dart'; import '../../data/models/target_type.dart'; import '../crop/crop_screen.dart'; import '../session/session_provider.dart'; import 'widgets/image_source_button.dart'; import '../../services/image_crop_service.dart'; import '../../services/opencv_target_service.dart'; // AJOUT : Pour la détection périodique class CaptureScreen extends StatefulWidget { const CaptureScreen({super.key}); @override State createState() => _CaptureScreenState(); } class _CaptureScreenState extends State with SingleTickerProviderStateMixin { final ImagePicker _picker = ImagePicker(); final ImageCropService _cropService = ImageCropService(); final TargetType _selectedType = TargetType.concentric; final OpenCVTargetService _opencvService = OpenCVTargetService(); // AJOUT : Service de détection String? _selectedImagePath; bool _isLoading = false; // AJOUT : Variables de gestion du flux caméra en direct CameraController? _cameraController; List? _cameras; bool _isCameraInitialized = false; bool _showLiveCamera = false; // AJOUT : Animation pour l'effet "pulsation de détection" du cadre vert late AnimationController _scanAnimationController; late Animation _scanAnimation; // AJOUT : Variables pour le parallélisme // _alignmentStatus : null = pas encore analysé, true = bien aligné, false = mal aligné bool? _alignmentStatus; Timer? _detectionTimer; bool _isAnalyzingFrame = false; // Verrou pour éviter les appels OpenCV simultanés @override void initState() { super.initState(); // Initialisation de l'animation du viseur vert (effet pulsation) _scanAnimationController = AnimationController( duration: const Duration(seconds: 2), vsync: this, )..repeat(reverse: true); _scanAnimation = Tween(begin: 0.8, end: 1.0).animate( CurvedAnimation(parent: _scanAnimationController, curve: Curves.easeInOut), ); } @override void dispose() { _cameraController?.dispose(); _scanAnimationController.dispose(); _detectionTimer?.cancel(); // AJOUT : On stoppe le timer proprement super.dispose(); } // AJOUT : Initialisation de la caméra embarquée Future _initLiveCamera() async { final status = await Permission.camera.request(); if (!status.isGranted) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Accès à l\'appareil photo requis')), ); } return; } setState(() => _isLoading = true); try { // CORRECTIF ZOOM : On dispose l'ancien contrôleur avant d'en créer un nouveau // pour garantir un zoom à 1.0 à chaque ouverture de la caméra if (_cameraController != null) { await _cameraController!.dispose(); _cameraController = null; _isCameraInitialized = false; } _cameras = await availableCameras(); if (_cameras != null && _cameras!.isNotEmpty) { _cameraController = CameraController( _cameras![0], ResolutionPreset.max, // Force le 4:3 natif au maximum du capteur enableAudio: false, imageFormatGroup: ImageFormatGroup.jpeg, ); // INITIALISATION UNIQUE ET PROPRE : await _cameraController!.initialize(); setState(() { _isCameraInitialized = true; _showLiveCamera = true; _alignmentStatus = null; // AJOUT : Reset du statut d'alignement à chaque ouverture }); // AJOUT : Démarrage du timer de détection périodique (toutes les 2 secondes) _startAlignmentDetection(); } } catch (e) { debugPrint('Erreur caméra: $e'); } finally { setState(() => _isLoading = false); } } // AJOUT : Démarre la détection périodique du parallélisme toutes les 2 secondes void _startAlignmentDetection() { _detectionTimer?.cancel(); _detectionTimer = Timer.periodic(const Duration(seconds: 2), (_) { _analyzeCurrentFrame(); }); } // AJOUT : Stoppe le timer de détection void _stopAlignmentDetection() { _detectionTimer?.cancel(); _detectionTimer = null; _alignmentStatus = null; } // AJOUT : Capture une frame et analyse le parallélisme via OpenCV Future _analyzeCurrentFrame() async { // Verrou : on n'analyse pas si une analyse est déjà en cours if (_isAnalyzingFrame) return; if (_cameraController == null || !_cameraController!.value.isInitialized) return; if (!_showLiveCamera) return; _isAnalyzingFrame = true; try { // 1. Capture d'une frame temporaire (sans déclencher la prise de photo) final XFile frame = await _cameraController!.takePicture(); // 2. Analyse OpenCV de la frame capturée final result = await _opencvService.detectTarget(frame.path); // 3. Nettoyage du fichier temporaire try { await File(frame.path).delete(); } catch (_) {} // 4. Mise à jour du statut d'alignement si on est toujours sur l'écran caméra if (mounted && _showLiveCamera) { setState(() { if (!result.success) { // Aucune cible détectée → statut indéfini _alignmentStatus = null; } else { // PARALLÉLISME : On vérifie que le centre détecté est proche du centre de l'image // et que le rayon est suffisamment grand (cible bien cadrée) // Un centre entre 0.3 et 0.7 en X et Y = bien centré = parallélisme OK final bool isCentered = result.centerX > 0.25 && result.centerX < 0.75 && result.centerY > 0.25 && result.centerY < 0.75; final bool isSizedCorrectly = result.radius > 0.15; _alignmentStatus = isCentered && isSizedCorrectly; } }); } } catch (e) { debugPrint('Erreur analyse frame: $e'); } finally { _isAnalyzingFrame = false; } } Future _handleGallerySelection() async { PermissionStatus status; if (Platform.isAndroid) { final deviceInfo = DeviceInfoPlugin(); final androidInfo = await deviceInfo.androidInfo; if (androidInfo.version.sdkInt >= 33) { status = await Permission.photos.request(); } else { status = await Permission.storage.request(); } } else { status = await Permission.photos.request(); } if (status.isGranted) { _captureImageFromGallery(); } else if (status.isPermanentlyDenied) { _showSettingsDialog(); } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Accès à la galerie requis pour continuer'), ), ); } } } void _showSettingsDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Permission requise'), content: const Text( 'L\'accès aux photos est nécessaire. Veuillez l\'activer dans les paramètres.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Annuler'), ), TextButton( onPressed: () => openAppSettings(), child: const Text('Paramètres'), ), ], ), ); } @override Widget build(BuildContext context) { final sessionProvider = context.watch(); final title = sessionProvider.isSessionActive ? 'Cible ${sessionProvider.targetCount + 1}' : 'Source'; // AJOUT : Si l'utilisateur clique sur scanner, on affiche la caméra plein écran avec l'overlay vert if (_showLiveCamera && _cameraController != null && _cameraController!.value.isInitialized) { return _buildLiveCameraView(); } return Scaffold( appBar: AppBar(title: Text(title)), body: SingleChildScrollView( padding: const EdgeInsets.all(AppConstants.defaultPadding), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: AppConstants.largePadding), _buildSectionTitle('Source de l\'Image'), const SizedBox(height: 12), Row( children: [ Expanded( child: ImageSourceButton( icon: Icons.camera_alt, label: 'Scanner', onPressed: _isLoading ? null : _initLiveCamera, // Ouvre notre caméra intégrée ), ), const SizedBox(width: 12), Expanded( child: ImageSourceButton( icon: Icons.photo_library, label: 'Galerie', onPressed: _isLoading ? null : _handleGallerySelection, ), ), ], ), const SizedBox(height: AppConstants.largePadding), if (_isLoading) const Center( child: Padding( padding: EdgeInsets.all(32), child: CircularProgressIndicator(), ), ) else _buildGuide(), ], ), ), ); } // AJOUT : Interface de la caméra live avec l'assistant de cadrage vert // INTERFACE CORRIGÉE : Cadre fixe et cible ronde verte au centre // INTERFACE : Restauration de TON ancienne cible centrale verte d'origine Widget _buildLiveCameraView() { // AJOUT : Couleur du cadre selon le statut d'alignement // null = vert (pas encore analysé), true = vert (bien aligné), false = orange (mal aligné) final Color frameColor = _alignmentStatus == false ? Colors.orange : const Color(0xFF00FF00); // AJOUT : Message d'aide selon le statut d'alignement final String alignmentMessage = _alignmentStatus == null ? 'ALIGNEZ LA CIBLE DANS LE CADRE' : _alignmentStatus == true ? 'CIBLE BIEN ALIGNÉE — PRÊT À TIRER' : 'REPOSITIONNEZ — ANGLE TROP FORT'; return Scaffold( backgroundColor: Colors.black, body: Stack( fit: StackFit.expand, children: [ // 1. Le flux vidéo de la caméra Center( child: AspectRatio( aspectRatio: 3 / 4, // Aligné sur le format natif du capteur photo child: CameraPreview(_cameraController!), ), ), // 2. L'ASSISTANT DE CADRAGE VIRTUEL (Avec le grand cadre complet et la petite cible) Center( child: Container( width: MediaQuery.of(context).size.width * 0.85, height: MediaQuery.of(context).size.width * 0.85, decoration: BoxDecoration( border: Border.all( color: frameColor, // MODIFIÉ : Couleur dynamique selon l'alignement width: 2.0, ), borderRadius: BorderRadius.circular(16), ), child: Stack( children: [ // SÉCURITÉ : On réintègre les 4 coins renforcés sur les côtés ! _buildCameraCorner(TopLeft: true, color: frameColor), _buildCameraCorner(TopRight: true, color: frameColor), _buildCameraCorner(BottomLeft: true, color: frameColor), _buildCameraCorner(BottomRight: true, color: frameColor), // TA MIRE CENTRALE HISTORIQUE FINALE Center( child: Container( width: 24, // Diamètre global de la petite cible height: 24, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: frameColor, // MODIFIÉ : Couleur dynamique width: 1.2, // Ligne fine ), ), child: Stack( children: [ // Trait horizontal fin confiné Center( child: Container( width: 20, height: 1.2, color: frameColor, // MODIFIÉ : Couleur dynamique ), ), // Trait vertical fin confiné Center( child: Container( width: 1.2, height: 20, color: frameColor, // MODIFIÉ : Couleur dynamique ), ), ], ), ), ), ], ), ), ), // 3. Barre supérieure avec bouton Retour Positioned( top: 40, left: 20, child: CircleAvatar( backgroundColor: Colors.black54, child: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () { // AJOUT : On stoppe la détection quand on quitte la caméra _stopAlignmentDetection(); setState(() { _showLiveCamera = false; }); }, ), ), ), // 4. Consigne de scan textuelle — MODIFIÉ : Message dynamique selon l'alignement Positioned( top: 50, left: 0, right: 0, child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(20), ), child: Text( alignmentMessage, style: TextStyle( color: frameColor, // MODIFIÉ : Couleur dynamique fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1.2, ), ), ), ), ), // AJOUT : Indicateur visuel de l'analyse en cours (petit point clignotant) if (_isAnalyzingFrame) Positioned( top: 45, right: 20, child: Container( width: 10, height: 10, decoration: const BoxDecoration( color: Colors.white54, shape: BoxShape.circle, ), ), ), // 5. Bouton déclencheur Manuel en bas Positioned( bottom: 40, left: 0, right: 0, child: Center( child: GestureDetector( onTap: _takePictureManually, child: Container( height: 80, width: 80, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 4), ), child: Center( child: Container( height: 64, width: 64, decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), ), ), ), ), ), ), ], ), ); } // MODIFIÉ : Ajout du paramètre color pour la couleur dynamique des coins Widget _buildCameraCorner({ bool TopLeft = false, bool TopRight = false, bool BottomLeft = false, bool BottomRight = false, Color color = const Color(0xFF00FF00), // AJOUT : Couleur dynamique }) { return Positioned( top: (TopLeft || TopRight) ? 10 : null, bottom: (BottomLeft || BottomRight) ? 10 : null, left: (TopLeft || BottomLeft) ? 10 : null, right: (TopRight || BottomRight) ? 10 : null, child: Container( width: 20, height: 20, decoration: BoxDecoration( border: Border( top: (TopLeft || TopRight) ? BorderSide(color: color, width: 4) : BorderSide.none, bottom: (BottomLeft || BottomRight) ? BorderSide(color: color, width: 4) : BorderSide.none, left: (TopLeft || BottomLeft) ? BorderSide(color: color, width: 4) : BorderSide.none, right: (TopRight || BottomRight) ? BorderSide(color: color, width: 4) : BorderSide.none, ), ), ), ); } // AJOUT : Capture manuelle via notre bouton déclencheur Future _takePictureManually() async { if (_cameraController == null || !_cameraController!.value.isInitialized) return; setState(() => _isLoading = true); try { // CORRECTIF ZOOM : On remet le zoom à 1.0 avant la capture pour ne pas // propager le zoom optique dans les écrans suivants try { await _cameraController!.setZoomLevel(1.0); } catch (_) { // Certains appareils ne supportent pas setZoomLevel, on ignore l'erreur } // AJOUT : On stoppe la détection périodique avant de prendre la photo _stopAlignmentDetection(); // 1. On prend la photo brute normale (sans y toucher) final XFile photo = await _cameraController!.takePicture(); setState(() { _selectedImagePath = photo.path; _showLiveCamera = false; }); // 2. On l'envoie directement à l'analyse _analyzeImage(); } catch (e) { debugPrint('Erreur lors du clic photo: $e'); } finally { setState(() => _isLoading = false); } } Widget _buildSectionTitle(String title) { return Text( title, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ); } Widget _buildGuide() { return Card( child: Padding( padding: const EdgeInsets.all(AppConstants.defaultPadding), child: Column( children: [ Icon(Icons.help_outline, size: 48, color: Colors.grey[400]), const SizedBox(height: 12), Text( 'Conseils pour une bonne analyse', style: Theme.of( context, ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), _buildGuideItem( Icons.crop_free, 'Cadrez la cible entière dans l\'image', ), _buildGuideItem(Icons.wb_sunny, 'Utilisez un bon éclairage'), _buildGuideItem(Icons.straighten, 'Prenez la photo de face'), _buildGuideItem(Icons.blur_off, 'Évitez les images floues'), _buildGuideItem( Icons.cleaning_services, 'Nettoyer votre objectif avec une chiffonnette', ), ], ), ), ); } Widget _buildGuideItem(IconData icon, String text) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Icon(icon, size: 20, color: AppTheme.primaryColor), const SizedBox(width: 12), Expanded(child: Text(text)), ], ), ); } // Capture depuis la galerie d'images Future _captureImageFromGallery() async { setState(() => _isLoading = true); try { final XFile? image = await _picker.pickImage( source: ImageSource.gallery, maxWidth: 2048, maxHeight: 2048, imageQuality: 90, ); if (image != null) { setState(() { _selectedImagePath = image.path; }); _analyzeImage(); } } catch (e) { debugPrint('Erreur galerie: $e'); } finally { if (mounted) setState(() => _isLoading = false); } } void _analyzeImage() { if (_selectedImagePath == null) return; Navigator.push( context, MaterialPageRoute( builder: (_) => CropScreen( imagePath: _selectedImagePath!, targetType: _selectedType, ), ), ); } }