import 'dart:io'; import 'dart:math' as math; import 'dart:async'; import 'dart:isolate'; import 'dart:typed_data'; 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'; 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/opencv_target_service.dart'; import '../../services/parallelism_service.dart'; // NOUVEAU import 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; /// Paramètres sérialisables pour convertir une frame caméra dans un Isolate. class _FrameConvertParams { final int width; final int height; final Uint8List yBytes; final Uint8List uBytes; final Uint8List vBytes; final int yRowStride; final int uvRowStride; final int uvPixelStride; final String outputPath; _FrameConvertParams({ required this.width, required this.height, required this.yBytes, required this.uBytes, required this.vBytes, required this.yRowStride, required this.uvRowStride, required this.uvPixelStride, required this.outputPath, }); } /// Conversion YUV420 → RGB (sous-échantillonnée /2) + encodage JPEG, exécutée /// dans un Isolate pour ne JAMAIS bloquer le thread UI pendant l'aperçu caméra /// (c'était la cause principale du lag à la prise de photo). Retourne true si /// le fichier d'analyse a bien été écrit. bool _convertAndEncodeFrameIsolate(_FrameConvertParams p) { try { final int targetW = p.width ~/ 2; final int targetH = p.height ~/ 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 * p.yRowStride + srcX; final int uvIndex = (srcY ~/ 2) * p.uvRowStride + (srcX ~/ 2) * p.uvPixelStride; final int yVal = p.yBytes[yIndex]; final int uVal = p.uBytes[uvIndex] - 128; final int vVal = p.vBytes[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); } } // PERF : qualité 50 pour la frame d'analyse temps réel (jetable). File(p.outputPath).writeAsBytesSync(img.encodeJpg(image, quality: 50)); return true; } catch (_) { return false; } } class CaptureScreen extends StatefulWidget { const CaptureScreen({super.key}); @override State createState() => _CaptureScreenState(); } class _CaptureScreenState extends State with SingleTickerProviderStateMixin { final ImagePicker _picker = ImagePicker(); final TargetType _selectedType = TargetType.concentric; final OpenCVTargetService _opencvService = OpenCVTargetService(); // NOUVEAU : Service IMU de parallélisme // L'angle passe à l'ORANGE dès 3° d'inclinaison (pitch ou roll). // Petite hystérésis : il faut revenir sous 2° pour repasser au vert, // ce qui évite le clignotement quand la main tremble autour de 3°. final ParallelismService _parallelismService = ParallelismService( alignThreshold: 2.0, // retour au vert : worstAngle ≤ 2° misalignThreshold: 3.0, // passage à l'orange : worstAngle > 3° ); String? _selectedImagePath; bool _isLoading = false; bool _isCapturing = false; // garde anti-double-capture pendant le traitement // Chemin de la photo tout juste capturée, affichée FIGÉE pendant le // traitement (downscale). Tant qu'il est null, la capture physique est // encore en cours → l'utilisateur doit rester immobile. Une fois rempli, // la photo est prise et l'utilisateur peut bouger. String? _capturedPreviewPath; // Caméra live CameraController? _cameraController; List? _cameras; bool _showLiveCamera = false; // Animation pulsation du viseur late AnimationController _scanAnimationController; // Détection OpenCV (cible circulaire) — on garde le résultat COMPLET TargetDetectionResult? _targetResult; // NOUVEAU : centre + rayon de la cible Timer? _detectionTimer; bool _isAnalyzingFrame = false; // NOUVEAU : Données IMU en temps réel ParallelismData? _parallelismData; StreamSubscription? _parallelismSubscription; @override void initState() { super.initState(); _scanAnimationController = AnimationController( duration: const Duration(seconds: 2), vsync: this, )..repeat(reverse: true); } @override void dispose() { _cameraController?.dispose(); _scanAnimationController.dispose(); _detectionTimer?.cancel(); _parallelismSubscription?.cancel(); // NOUVEAU _parallelismService.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) return; // Le capteur émet à ~20 Hz, mais l'affichage ne dépend que du statut // (couleur verte/orange) et des angles arrondis à 0,1° (message). On ne // reconstruit donc QUE quand l'un d'eux change réellement → bien moins de // rebuilds inutiles de la vue caméra. final prev = _parallelismData; final bool changed = prev == null || prev.status != data.status || prev.pitchDegrees.toStringAsFixed(1) != data.pitchDegrees.toStringAsFixed(1) || prev.rollDegrees.toStringAsFixed(1) != data.rollDegrees.toStringAsFixed(1); _parallelismData = data; if (changed) { setState(() {}); } }); } void _stopParallelismDetection() { _parallelismSubscription?.cancel(); _parallelismSubscription = null; _parallelismService.stop(); _parallelismData = null; } // ───────────────────────────────────────────────────────────────────────── // Calcule la couleur et le message depuis l'IMU + la détection de cible // ───────────────────────────────────────────────────────────────────────── /// La cible circulaire est-elle détectée et raisonnablement cadrée ? bool get _targetReady { final t = _targetResult; if (t == null || !t.success) return false; final bool centered = t.centerX > 0.15 && t.centerX < 0.85 && t.centerY > 0.15 && t.centerY < 0.85; final bool bigEnough = t.radius > 0.12; return centered && bigEnough; } /// Couleur du cadre : dépend UNIQUEMENT du parallélisme IMU. /// (La détection de cible sert seulement à dessiner le cercle, elle ne /// bloque jamais le passage au vert.) Color get _frameColor { final bool imuAligned = _parallelismData == null || _parallelismData!.isAligned; return imuAligned ? const Color(0xFF00FF00) : Colors.orange; } /// Message d'aide selon le parallélisme IMU. String get _alignmentMessage { if (_parallelismData == null || _parallelismData!.status == ParallelismStatus.unknown) { return 'ALIGNEZ LA CIBLE DANS LE CADRE'; } // Aligné → message de validation (avec bonus si la cible est détectée) if (_parallelismData!.isAligned) { return _targetReady ? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT' : 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER'; } // Mal aligné → 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; } _cameras = await availableCameras(); if (_cameras != null && _cameras!.isNotEmpty) { // PERF : on capture en `veryHigh` (2160p) et non en `max`. Le pipeline // redimensionne ensuite tout à 1080 px max → la résolution `max` // (12–48 MP) ne servait à rien et rallongeait fortement le décodage // post-capture (« délai de quelques secondes »). `veryHigh` reste très // au-dessus du 1080 px final : aucune perte de qualité, mais décodage // bien plus rapide. _cameraController = CameraController( _cameras![0], ResolutionPreset.veryHigh, enableAudio: false, imageFormatGroup: ImageFormatGroup.jpeg, ); await _cameraController!.initialize(); setState(() { _showLiveCamera = true; _targetResult = null; // reset détection cible _parallelismData = null; // NOUVEAU : reset IMU }); _startAlignmentDetection(); _startParallelismDetection(); // 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 { if (_isAnalyzingFrame) return; final now = DateTime.now(); // Cadence ~1 s : assez réactif pour suivre la cible sans saturer le CPU. if (lastAnalysis != null && now.difference(lastAnalysis!).inMilliseconds < 1000) return; lastAnalysis = now; _isAnalyzingFrame = true; try { final tempDir = await getTemporaryDirectory(); final tempPath = '${tempDir.path}/frame_analysis.jpg'; // On copie les octets des plans MAINTENANT (le buffer caméra est // recyclé dès la sortie du callback), puis on délègue TOUTE la // conversion YUV→RGB + l'encodage JPEG à un Isolate → l'aperçu et le // déclencheur restent fluides (plus de gel du thread UI). final params = _FrameConvertParams( width: cameraImage.width, height: cameraImage.height, yBytes: Uint8List.fromList(cameraImage.planes[0].bytes), uBytes: Uint8List.fromList(cameraImage.planes[1].bytes), vBytes: Uint8List.fromList(cameraImage.planes[2].bytes), yRowStride: cameraImage.planes[0].bytesPerRow, uvRowStride: cameraImage.planes[1].bytesPerRow, uvPixelStride: cameraImage.planes[1].bytesPerPixel ?? 1, outputPath: tempPath, ); final bool ok = await Isolate.run(() => _convertAndEncodeFrameIsolate(params)); if (!ok) { _isAnalyzingFrame = false; return; } final result = await _opencvService.detectTarget(tempPath); try { await File(tempPath).delete(); } catch (_) {} if (mounted && _showLiveCamera) { setState(() { // On garde le résultat complet pour dessiner le cercle. _targetResult = result.success ? result : null; }); } } 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 (_) {} _targetResult = 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 // On utilise le ratio RÉEL du capteur (et non un 3/4 codé en dur) : // sinon l'aperçu est étiré dès que la résolution choisie n'est pas // en 4:3 (ex. veryHigh en 16:9 écrasait la cible en rectangle). // `value.aspectRatio` est le ratio paysage du flux → on l'inverse // pour l'affichage portrait. Center( child: AspectRatio( aspectRatio: 1 / _cameraController!.value.aspectRatio, child: CameraPreview(_cameraController!), ), ), // 2. Coins du cadre et mire centrale (sans le cadre rectangulaire) Center( child: SizedBox( width: MediaQuery.of(context).size.width * 0.85, height: MediaQuery.of(context).size.width * 0.85, 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 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: _isCapturing ? null : _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, ), ), ), ), ), ), ), // 6. Overlay bloquant pendant la capture/traitement : empêche de // bouger l'UI ou de relancer une capture tant que la photo n'est pas // chargée (absorbe tous les gestes). Deux phases distinctes pour // lever la confusion « puis-je bouger ? » : // • _capturedPreviewPath == null → capture physique en cours : // voile sur l'aperçu vivant + « Ne bougez pas ». // • _capturedPreviewPath != null → photo prise, traitement en // cours : on affiche l'image FIGÉE (le flux vivant disparaît) // + « Photo prise ✓ — vous pouvez bouger ». if (_isCapturing) Positioned.fill( child: AbsorbPointer( child: _capturedPreviewPath == null ? _buildCapturingOverlay() : _buildProcessingOverlay(_capturedPreviewPath!), ), ), ], ), ); } // ───────────────────────────────────────────────────────────────────────── // Overlay PHASE 1 : capture physique en cours → l'utilisateur doit rester // immobile. Voile semi-transparent par-dessus l'aperçu vivant. // ───────────────────────────────────────────────────────────────────────── Widget _buildCapturingOverlay() { return Container( color: Colors.black.withValues(alpha: 0.45), child: const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: Colors.white), SizedBox(height: 16), Text( 'NE BOUGEZ PAS — CAPTURE…', style: TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w700, letterSpacing: 1.2, ), ), ], ), ), ); } // ───────────────────────────────────────────────────────────────────────── // Overlay PHASE 2 : photo capturée, traitement en cours. On affiche l'image // FIGÉE (l'aperçu vivant disparaît, plus d'ambiguïté) et on indique // clairement que l'utilisateur peut maintenant bouger. // ───────────────────────────────────────────────────────────────────────── Widget _buildProcessingOverlay(String imagePath) { return Stack( fit: StackFit.expand, children: [ // Image figée de la photo prise → recouvre totalement le flux caméra Image.file(File(imagePath), fit: BoxFit.cover), Container( color: Colors.black.withValues(alpha: 0.55), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.check_circle, color: Color(0xFF00FF00), size: 52, ), const SizedBox(height: 14), const Text( 'PHOTO PRISE', style: TextStyle( color: Color(0xFF00FF00), fontSize: 16, fontWeight: FontWeight.w700, letterSpacing: 1.2, ), ), const SizedBox(height: 20), const SizedBox( width: 22, height: 22, child: CircularProgressIndicator( color: Colors.white, strokeWidth: 2.5, ), ), const SizedBox(height: 14), Text( 'Traitement… vous pouvez bouger', style: TextStyle( color: Colors.white.withValues(alpha: 0.85), fontSize: 13, fontWeight: FontWeight.w500, ), ), ], ), ), ), ], ); } // ───────────────────────────────────────────────────────────────────────── // 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 (MODIFIÉE : ajout dégradation post-capture pour perf) // ───────────────────────────────────────────────────────────────────────── Future _takePictureManually() async { if (_cameraController == null || !_cameraController!.value.isInitialized) { return; } // Garde anti-double-capture : tout appui supplémentaire pendant le // traitement est ignoré (évite les photos multiples / états incohérents). if (_isCapturing) return; setState(() { _isCapturing = true; _isLoading = true; _capturedPreviewPath = null; // phase « capture en cours » }); try { // On stoppe le flux d'analyse (obligatoire avant takePicture) puis on // capture IMMÉDIATEMENT, sans étape intermédiaire. Le reset de zoom // inutile (aucun zoom possible sur cet écran) a été retiré pour que la // photo parte sans délai perceptible. _stopAlignmentDetection(); _stopParallelismDetection(); final XFile photo = await _cameraController!.takePicture(); // La photo est PHYSIQUEMENT capturée : on gèle l'aperçu en affichant // l'image prise (le flux caméra « vivant » disparaît) et on bascule le // message sur « vous pouvez bouger ». Tout ce qui suit n'est que du // traitement et n'exige plus l'immobilité de l'utilisateur. if (mounted) { setState(() => _capturedPreviewPath = photo.path); } // Pas de redressement perspective automatique ici : il déformait l'image // (écrasement en carré + rotation de l'axe de l'ellipse) et gelait le // thread UI. Le recentrage ET la rotation se font manuellement à l'écran // de centrage suivant, qui est précis et non destructeur. String finalPath = photo.path; // PERF : on dégrade fortement la photo juste après la capture. // Une résolution réduite suffit pour la calibration et le plotting, // et accélère nettement le décodage à chaque écran (chargement plotting). try { finalPath = await _downscaleForPipeline(finalPath); } catch (e) { debugPrint('Downscale ignoré: $e'); } if (!mounted) return; setState(() { _selectedImagePath = finalPath; _showLiveCamera = false; }); _analyzeImage(); } catch (e) { debugPrint('Erreur lors du clic photo: $e'); } finally { if (mounted) { setState(() { _isLoading = false; _isCapturing = false; _capturedPreviewPath = null; }); } } } /// Recadre la photo sur la zone du viseur PUIS réduit sa résolution, en un /// seul décodage dans un Isolate (thread UI fluide). /// /// 1) Recadrage viseur : carré centré à 85 % du petit côté de la photo. C'est /// EXACTEMENT le cadre des 4 coins verts/orange affiché à la capture /// (`SizedBox` de `0.85 * largeur écran`, centré, l'aperçu caméra remplissant /// la largeur). → l'écran de centrage ne reçoit QUE ce qui était visé entre /// les coins, sans le fond capturé autour : plus d'impression de « dézoom ». /// 2) Dégradation résolution : 1080 px de côté max + JPEG qualité 70, largement /// suffisant pour la détection visuelle et bien plus rapide à recharger. /// /// Baisser `maxSide` (ex. 900) ou `quality` (ex. 60) accélère encore au prix /// d'un peu de finesse à l'écran. Future _downscaleForPipeline(String sourcePath) async { // On résout le chemin de sortie sur le thread UI (path_provider a besoin // du canal de plateforme), puis tout le travail lourd (décodage, crop, // resize, réencodage JPEG) part dans un Isolate → aucun gel du thread UI. final tempDir = await getTemporaryDirectory(); final outPath = '${tempDir.path}/downscaled_${DateTime.now().millisecondsSinceEpoch}.jpg'; return Isolate.run(() { final bytes = File(sourcePath).readAsBytesSync(); final decoded = img.decodeImage(bytes); if (decoded == null) return sourcePath; // 1) Recadrage sur la zone du viseur (carré centré à 85 % du petit côté). final int side = (math.min(decoded.width, decoded.height) * 0.85).round(); final int cropX = ((decoded.width - side) / 2).round().clamp(0, decoded.width - 1); final int cropY = ((decoded.height - side) / 2).round().clamp(0, decoded.height - 1); img.Image result = img.copyCrop( decoded, x: cropX, y: cropY, width: math.min(side, decoded.width - cropX), height: math.min(side, decoded.height - cropY), ); // 2) Dégradation résolution si nécessaire. const int maxSide = 1080; final int longest = math.max(result.width, result.height); if (longest > maxSide) { final double ratio = maxSide / longest; result = img.copyResize( result, width: (result.width * ratio).round(), height: (result.height * ratio).round(), interpolation: img.Interpolation.linear, // rapide ); } File(outPath).writeAsBytesSync(img.encodeJpg(result, quality: 70)); return outPath; }); } // ───────────────────────────────────────────────────────────────────────── // 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, // PERF : limite d'import plus agressive pour accélérer le pipeline maxWidth: 1280, maxHeight: 1280, imageQuality: 75, ); 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, ), ), ); } }