import 'dart:io'; import 'dart:math' as math; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.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'; 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'; import '../../services/parallelism_service.dart'; // NOUVEAU import '../../services/object_detection_service.dart'; // NOUVEAU : ML Kit import 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; 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(); // NOUVEAU : Service IMU de parallélisme final ParallelismService _parallelismService = ParallelismService( alignThreshold: 25.0, // Seuil pour passer au vert (permissif) misalignThreshold: 32.0, // Seuil pour repasser à l'orange ); String? _selectedImagePath; bool _isLoading = false; // Caméra live CameraController? _cameraController; List? _cameras; bool _isCameraInitialized = false; bool _showLiveCamera = false; // Animation pulsation du viseur late AnimationController _scanAnimationController; late Animation _scanAnimation; // Détection OpenCV (inchangée) bool? _alignmentStatus; Timer? _detectionTimer; bool _isAnalyzingFrame = false; // NOUVEAU : Données IMU en temps réel ParallelismData? _parallelismData; StreamSubscription? _parallelismSubscription; // NOUVEAU : Détection d'objet ML Kit final ObjectDetectionService _objectService = ObjectDetectionService(); StreamSubscription>? _objectSubscription; List _detectedObjects = []; @override void initState() { super.initState(); _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(); _parallelismSubscription?.cancel(); // NOUVEAU _parallelismService.dispose(); // NOUVEAU _objectSubscription?.cancel(); // NOUVEAU _objectService.dispose(); // NOUVEAU super.dispose(); } // ───────────────────────────────────────────────────────────────────────── // NOUVEAU : Démarre l'écoute IMU et met à jour l'UI en temps réel // ───────────────────────────────────────────────────────────────────────── void _startParallelismDetection() { _parallelismSubscription?.cancel(); _parallelismService.start(); _parallelismSubscription = _parallelismService.stream.listen((data) { if (mounted && _showLiveCamera) { setState(() { _parallelismData = data; }); } }); } void _stopParallelismDetection() { _parallelismSubscription?.cancel(); _parallelismSubscription = null; _parallelismService.stop(); _parallelismData = null; } // ───────────────────────────────────────────────────────────────────────── // NOUVEAU : Démarre l'écoute de la détection d'objet ML Kit // ───────────────────────────────────────────────────────────────────────── void _startObjectDetection() { _objectSubscription?.cancel(); _objectService.start(); _objectSubscription = _objectService.stream.listen((objects) { if (mounted && _showLiveCamera) { setState(() { _detectedObjects = objects; }); } }); } void _stopObjectDetection() { _objectSubscription?.cancel(); _objectSubscription = null; _objectService.stop(); _detectedObjects = []; } // ───────────────────────────────────────────────────────────────────────── // NOUVEAU : Calcule la couleur et le message depuis les données IMU // ───────────────────────────────────────────────────────────────────────── /// Y a-t-il un objet détecté suffisamment grand et globalement centré ? DetectedObject2D? get _primaryObject { if (_detectedObjects.isEmpty) return null; // On prend l'objet le plus grand (le sujet principal le plus probable). final sorted = [..._detectedObjects] ..sort((a, b) => b.area.compareTo(a.area)); final obj = sorted.first; // Filtre : doit occuper une part raisonnable de l'image et être centré. final bool bigEnough = obj.area > 0.04; // ~20% × 20% final bool centered = obj.centerX > 0.2 && obj.centerX < 0.8 && obj.centerY > 0.2 && obj.centerY < 0.8; return (bigEnough && centered) ? obj : null; } bool get _objectReady => _primaryObject != null; /// Couleur du cadre selon le parallélisme détecté par l'IMU. Color get _frameColor { final bool imuAligned = _parallelismData == null || _parallelismData!.isAligned; // Vert UNIQUEMENT si l'appareil est parallèle ET un objet est bien cadré. if (imuAligned && _objectReady) return const Color(0xFF00FF00); return Colors.orange; } /// Message d'aide selon le parallélisme + détection d'objet. String get _alignmentMessage { final bool imuAligned = _parallelismData == null || _parallelismData!.isAligned; // Cas idéal : tout est bon if (imuAligned && _objectReady) { return 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER'; } // IMU bon mais pas d'objet correctement cadré if (imuAligned && !_objectReady) { return _detectedObjects.isEmpty ? 'AUCUN OBJET DÉTECTÉ — APPROCHEZ-VOUS' : 'CENTREZ L\'OBJET DANS LE CADRE'; } if (_parallelismData == null) { return 'ALIGNEZ LA CIBLE DANS LE CADRE'; } // Message directif selon l'axe le plus dévié final double pitch = _parallelismData!.pitchDegrees; final double roll = _parallelismData!.rollDegrees; if (pitch.abs() >= roll.abs()) { return pitch > 0 ? 'INCLINEZ VERS L\'AVANT (${pitch.toStringAsFixed(1)}°)' : 'INCLINEZ VERS L\'ARRIÈRE (${(-pitch).toStringAsFixed(1)}°)'; } else { return roll > 0 ? 'INCLINEZ VERS LA GAUCHE (${roll.toStringAsFixed(1)}°)' : 'INCLINEZ VERS LA DROITE (${(-roll).toStringAsFixed(1)}°)'; } } // ───────────────────────────────────────────────────────────────────────── // Initialisation de la caméra (inchangée, sauf ajout IMU) // ───────────────────────────────────────────────────────────────────────── 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 { if (_cameraController != null) { await _cameraController!.dispose(); _cameraController = null; _isCameraInitialized = false; } _cameras = await availableCameras(); if (_cameras != null && _cameras!.isNotEmpty) { _cameraController = CameraController( _cameras![0], ResolutionPreset.max, enableAudio: false, imageFormatGroup: ImageFormatGroup.jpeg, ); await _cameraController!.initialize(); setState(() { _isCameraInitialized = true; _showLiveCamera = true; _alignmentStatus = null; _parallelismData = null; // NOUVEAU : reset IMU }); _startAlignmentDetection(); _startParallelismDetection(); // NOUVEAU _startObjectDetection(); // NOUVEAU } } catch (e) { debugPrint('Erreur caméra: $e'); } finally { setState(() => _isLoading = false); } } // ───────────────────────────────────────────────────────────────────────── // Détection OpenCV périodique (inchangée) // ───────────────────────────────────────────────────────────────────────── void _startAlignmentDetection() { _detectionTimer?.cancel(); _detectionTimer = null; DateTime? _lastAnalysis; _cameraController!.startImageStream((CameraImage cameraImage) async { // NOUVEAU : on alimente ML Kit à chaque frame (le service gère son propre // throttle interne via _isBusy, donc pas de saturation). _objectService.processCameraImage( cameraImage, _cameras![0], DeviceOrientation.portraitUp, ); if (_isAnalyzingFrame) return; final now = DateTime.now(); if (_lastAnalysis != null && now.difference(_lastAnalysis!).inSeconds < 3) return; _lastAnalysis = now; _isAnalyzingFrame = true; try { final tempDir = await getTemporaryDirectory(); final tempPath = '${tempDir.path}/frame_analysis.jpg'; final img.Image? converted = _convertCameraImage(cameraImage); if (converted == null) { _isAnalyzingFrame = false; return; } await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 60)); final result = await _opencvService.detectTarget(tempPath); try { await File(tempPath).delete(); } catch (_) {} if (mounted && _showLiveCamera) { setState(() { if (!result.success) { _alignmentStatus = null; } else { 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; } }); } void _stopAlignmentDetection() { _detectionTimer?.cancel(); _detectionTimer = null; try { if (_cameraController != null && _cameraController!.value.isStreamingImages) { _cameraController!.stopImageStream(); } } catch (_) {} _alignmentStatus = null; } img.Image? _convertCameraImage(CameraImage cameraImage) { try { final int width = cameraImage.width; final int height = cameraImage.height; final int targetW = width ~/ 2; final int targetH = height ~/ 2; final yPlane = cameraImage.planes[0]; final uPlane = cameraImage.planes[1]; final vPlane = cameraImage.planes[2]; final image = img.Image(width: targetW, height: targetH); for (int y = 0; y < targetH; y++) { for (int x = 0; x < targetW; x++) { final int srcX = x * 2; final int srcY = y * 2; final int yIndex = srcY * yPlane.bytesPerRow + srcX; final int uvIndex = (srcY ~/ 2) * uPlane.bytesPerRow + (srcX ~/ 2) * uPlane.bytesPerPixel!; final int yVal = yPlane.bytes[yIndex]; final int uVal = uPlane.bytes[uvIndex] - 128; final int vVal = vPlane.bytes[uvIndex] - 128; final int r = (yVal + 1.402 * vVal).clamp(0, 255).toInt(); final int g = (yVal - 0.344136 * uVal - 0.714136 * vVal) .clamp(0, 255) .toInt(); final int b = (yVal + 1.772 * uVal).clamp(0, 255).toInt(); image.setPixelRgb(x, y, r, g, b); } } return image; } catch (e) { debugPrint('Erreur conversion frame: $e'); return null; } } // ───────────────────────────────────────────────────────────────────────── // Galerie (inchangée) // ───────────────────────────────────────────────────────────────────────── Future _handleGallerySelection() async { PermissionStatus status; if (Platform.isAndroid) { final deviceInfo = DeviceInfoPlugin(); final androidInfo = await deviceInfo.androidInfo; status = androidInfo.version.sdkInt >= 33 ? await Permission.photos.request() : 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'), ), ], ), ); } // ───────────────────────────────────────────────────────────────────────── // Build principal // ───────────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final sessionProvider = context.watch(); final title = sessionProvider.isSessionActive ? 'Cible ${sessionProvider.targetCount + 1}' : 'Source'; 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, ), ), 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(), ], ), ), ); } // ───────────────────────────────────────────────────────────────────────── // Vue caméra live — MODIFIÉE pour utiliser les données IMU // ───────────────────────────────────────────────────────────────────────── Widget _buildLiveCameraView() { // Priorité : IMU (parallélisme physique) prime sur OpenCV (centrage) // Si l'IMU dit "mal aligné" → orange, sinon → vert final Color frameColor = _frameColor; final String alignmentMessage = _alignmentMessage; return Scaffold( backgroundColor: Colors.black, body: Stack( fit: StackFit.expand, children: [ // 1. Flux vidéo caméra Center( child: AspectRatio( aspectRatio: 3 / 4, child: CameraPreview(_cameraController!), ), ), // NOUVEAU : 1.bis Cadre dessiné autour de l'objet détecté if (_primaryObject != null) Center( child: AspectRatio( aspectRatio: 3 / 4, child: CustomPaint( painter: _ObjectBoxPainter( object: _primaryObject!, color: frameColor, ), ), ), ), // 2. Cadre de visée avec coins et mire centrale 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, width: 2.0), borderRadius: BorderRadius.circular(16), ), child: Stack( children: [ _buildCameraCorner(TopLeft: true, color: frameColor), _buildCameraCorner(TopRight: true, color: frameColor), _buildCameraCorner(BottomLeft: true, color: frameColor), _buildCameraCorner(BottomRight: true, color: frameColor), Center( child: Container( width: 24, height: 24, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: frameColor, width: 1.2), ), child: Stack( children: [ Center( child: Container( width: 20, height: 1.2, color: frameColor, ), ), Center( child: Container( width: 1.2, height: 20, color: frameColor, ), ), ], ), ), ), ], ), ), ), // 3. Bouton retour Positioned( top: 40, left: 20, child: CircleAvatar( backgroundColor: Colors.black54, child: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () { _stopAlignmentDetection(); _stopParallelismDetection(); // NOUVEAU _stopObjectDetection(); // NOUVEAU setState(() { _showLiveCamera = false; }); }, ), ), ), // 4. Message d'alignement principal (texte dynamique IMU) 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, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1.2, ), ), ), ), ), // NOUVEAU : Indicateur visuel des angles en bas à gauche if (_parallelismData != null && _parallelismData!.status != ParallelismStatus.unknown) Positioned( bottom: 140, left: 16, child: _buildAngleIndicator(_parallelismData!), ), // Indicateur d'analyse en cours 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 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, ), ), ), ), ), ), ), ], ), ); } // ───────────────────────────────────────────────────────────────────────── // NOUVEAU : Widget affichant les angles pitch/roll en temps réel // ───────────────────────────────────────────────────────────────────────── Widget _buildAngleIndicator(ParallelismData data) { final Color color = data.isAligned ? const Color(0xFF00FF00) : Colors.orange; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(10), border: Border.all(color: color.withValues(alpha: 0.5), width: 1), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _buildAngleRow( label: 'Pitch', value: data.pitchDegrees, color: color, ), const SizedBox(height: 2), _buildAngleRow( label: 'Roll ', value: data.rollDegrees, color: color, ), ], ), ); } Widget _buildAngleRow({ required String label, required double value, required Color color, }) { final String sign = value >= 0 ? '+' : ''; return Row( mainAxisSize: MainAxisSize.min, children: [ Text( '$label: ', style: const TextStyle( color: Colors.white60, fontSize: 11, fontFamily: 'monospace', ), ), Text( '$sign${value.toStringAsFixed(1)}°', style: TextStyle( color: color, fontSize: 11, fontWeight: FontWeight.bold, fontFamily: 'monospace', ), ), ], ); } // ───────────────────────────────────────────────────────────────────────── // Coins du cadre (inchangés) // ───────────────────────────────────────────────────────────────────────── Widget _buildCameraCorner({ bool TopLeft = false, bool TopRight = false, bool BottomLeft = false, bool BottomRight = false, Color color = const Color(0xFF00FF00), }) { 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, ), ), ), ); } // ───────────────────────────────────────────────────────────────────────── // Capture manuelle (inchangée, sauf ajout stop IMU) // ───────────────────────────────────────────────────────────────────────── Future _takePictureManually() async { if (_cameraController == null || !_cameraController!.value.isInitialized) { return; } setState(() => _isLoading = true); try { try { await _cameraController!.setZoomLevel(1.0); } catch (_) {} _stopAlignmentDetection(); _stopParallelismDetection(); // NOUVEAU _stopObjectDetection(); // NOUVEAU final XFile photo = await _cameraController!.takePicture(); setState(() { _selectedImagePath = photo.path; _showLiveCamera = false; }); _analyzeImage(); } catch (e) { debugPrint('Erreur lors du clic photo: $e'); } finally { setState(() => _isLoading = false); } } // ───────────────────────────────────────────────────────────────────────── // Helpers UI (inchangés) // ───────────────────────────────────────────────────────────────────────── 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)), ], ), ); } 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, ), ), ); } } // ───────────────────────────────────────────────────────────────────────── // NOUVEAU : Painter qui dessine la boîte englobante de l'objet détecté. // Les coordonnées de l'objet sont normalisées (0..1) → on les multiplie // par la taille réelle de la zone de dessin (qui suit l'AspectRatio 3/4). // ───────────────────────────────────────────────────────────────────────── class _ObjectBoxPainter extends CustomPainter { final DetectedObject2D object; final Color color; _ObjectBoxPainter({required this.object, required this.color}); @override void paint(Canvas canvas, Size size) { final Paint stroke = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3.0 ..color = color; final Rect rect = Rect.fromLTRB( object.left * size.width, object.top * size.height, object.right * size.width, object.bottom * size.height, ); // Boîte à coins arrondis final RRect rrect = RRect.fromRectAndRadius(rect, const Radius.circular(8)); canvas.drawRRect(rrect, stroke); // Petit label si disponible if (object.label.isNotEmpty) { final textPainter = TextPainter( text: TextSpan( text: ' ${object.label} ' '${(object.confidence * 100).toStringAsFixed(0)}% ', style: TextStyle( color: Colors.black, fontSize: 12, fontWeight: FontWeight.bold, backgroundColor: color, ), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset(rect.left, (rect.top - 18).clamp(0.0, size.height)), ); } } @override bool shouldRepaint(_ObjectBoxPainter old) => old.object != object || old.color != color; }