diff --git a/lib/features/capture/capture_screen.dart b/lib/features/capture/capture_screen.dart index b1fa3d8e..2b6ae3cb 100644 --- a/lib/features/capture/capture_screen.dart +++ b/lib/features/capture/capture_screen.dart @@ -1,11 +1,11 @@ import 'dart:io'; import 'dart:math' as math; -import 'dart:async'; // AJOUT : Pour le Timer de détection périodique +import 'dart:async'; 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:camera/camera.dart'; import 'package:provider/provider.dart'; import '../../core/constants/app_constants.dart'; @@ -15,9 +15,11 @@ 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 +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'; + class CaptureScreen extends StatefulWidget { const CaptureScreen({super.key}); @@ -25,34 +27,43 @@ class CaptureScreen extends StatefulWidget { State createState() => _CaptureScreenState(); } -class _CaptureScreenState extends State with SingleTickerProviderStateMixin { +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 + final OpenCVTargetService _opencvService = OpenCVTargetService(); + + // NOUVEAU : Service IMU de parallélisme + final ParallelismService _parallelismService = ParallelismService( + thresholdDegrees: 5.0, // ±5° max autorisés + ); + String? _selectedImagePath; bool _isLoading = false; - // AJOUT : Variables de gestion du flux caméra en direct + // Caméra live CameraController? _cameraController; List? _cameras; bool _isCameraInitialized = false; bool _showLiveCamera = false; - // AJOUT : Animation pour l'effet "pulsation de détection" du cadre vert + // Animation pulsation du viseur late AnimationController _scanAnimationController; late Animation _scanAnimation; - // AJOUT : Variables pour le parallélisme - // _alignmentStatus : null = pas encore analysé, true = bien aligné, false = mal aligné + // Détection OpenCV (inchangée) bool? _alignmentStatus; Timer? _detectionTimer; - bool _isAnalyzingFrame = false; // Verrou pour éviter les appels OpenCV simultanés + bool _isAnalyzingFrame = false; + + // NOUVEAU : Données IMU en temps réel + ParallelismData? _parallelismData; + StreamSubscription? _parallelismSubscription; @override void initState() { super.initState(); - // Initialisation de l'animation du viseur vert (effet pulsation) _scanAnimationController = AnimationController( duration: const Duration(seconds: 2), vsync: this, @@ -67,11 +78,72 @@ class _CaptureScreenState extends State with SingleTickerProvider void dispose() { _cameraController?.dispose(); _scanAnimationController.dispose(); - _detectionTimer?.cancel(); // AJOUT : On stoppe le timer proprement + _detectionTimer?.cancel(); + _parallelismSubscription?.cancel(); // NOUVEAU + _parallelismService.dispose(); // NOUVEAU super.dispose(); } - // AJOUT : Initialisation de la caméra embarquée + // ───────────────────────────────────────────────────────────────────────── + // 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 : Calcule la couleur et le message depuis les données IMU + // ───────────────────────────────────────────────────────────────────────── + + /// Couleur du cadre selon le parallélisme détecté par l'IMU. + Color get _frameColor { + if (_parallelismData == null) return const Color(0xFF00FF00); + return _parallelismData!.isAligned ? const Color(0xFF00FF00) : Colors.orange; + } + + /// Message d'aide selon le parallélisme détecté. + String get _alignmentMessage { + if (_parallelismData == null) { + return 'ALIGNEZ LA CIBLE DANS LE CADRE'; + } + if (_parallelismData!.status == ParallelismStatus.aligned) { + return 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER'; + } + + // 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) { @@ -85,8 +157,6 @@ class _CaptureScreenState extends State with SingleTickerProvider 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; @@ -97,22 +167,22 @@ class _CaptureScreenState extends State with SingleTickerProvider if (_cameras != null && _cameras!.isNotEmpty) { _cameraController = CameraController( _cameras![0], - ResolutionPreset.max, // Force le 4:3 natif au maximum du capteur + ResolutionPreset.max, 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 + _alignmentStatus = null; + _parallelismData = null; // NOUVEAU : reset IMU }); - // AJOUT : Démarrage du timer de détection périodique (toutes les 2 secondes) _startAlignmentDetection(); + _startParallelismDetection(); // NOUVEAU } } catch (e) { debugPrint('Erreur caméra: $e'); @@ -121,12 +191,13 @@ class _CaptureScreenState extends State with SingleTickerProvider } } + // ───────────────────────────────────────────────────────────────────────── + // Détection OpenCV périodique (inchangée) + // ───────────────────────────────────────────────────────────────────────── void _startAlignmentDetection() { _detectionTimer?.cancel(); _detectionTimer = null; - // OPTIMISATION : startImageStream au lieu de takePicture - // On analyse 1 frame toutes les 3 secondes via le stream natif DateTime? _lastAnalysis; _cameraController!.startImageStream((CameraImage cameraImage) async { @@ -138,24 +209,22 @@ class _CaptureScreenState extends State with SingleTickerProvider _isAnalyzingFrame = true; try { - // Convertir la CameraImage en fichier temporaire pour OpenCV final tempDir = await getTemporaryDirectory(); - final tempPath = - '${tempDir.path}/frame_analysis.jpg'; + final tempPath = '${tempDir.path}/frame_analysis.jpg'; - // Conversion YUV420 → JPEG via le package image final img.Image? converted = _convertCameraImage(cameraImage); if (converted == null) { _isAnalyzingFrame = false; return; } - await File(tempPath) - .writeAsBytes(img.encodeJpg(converted, quality: 60)); + await File(tempPath).writeAsBytes(img.encodeJpg(converted, quality: 60)); final result = await _opencvService.detectTarget(tempPath); - try { await File(tempPath).delete(); } catch (_) {} + try { + await File(tempPath).delete(); + } catch (_) {} if (mounted && _showLiveCamera) { setState(() { @@ -178,11 +247,9 @@ class _CaptureScreenState extends State with SingleTickerProvider }); } - // AJOUT : Stoppe le timer de détection void _stopAlignmentDetection() { _detectionTimer?.cancel(); _detectionTimer = null; - // AJOUT : Stopper le stream proprement try { if (_cameraController != null && _cameraController!.value.isStreamingImages) { @@ -192,14 +259,10 @@ class _CaptureScreenState extends State with SingleTickerProvider _alignmentStatus = null; } - // AJOUT : Conversion CameraImage (YUV420) vers img.Image img.Image? _convertCameraImage(CameraImage cameraImage) { try { final int width = cameraImage.width; final int height = cameraImage.height; - - // Réduction de résolution pour l'analyse : on prend 1 pixel sur 2 - // (suffisant pour détecter les cercles, beaucoup plus rapide) final int targetW = width ~/ 2; final int targetH = height ~/ 2; @@ -215,16 +278,17 @@ class _CaptureScreenState extends State with SingleTickerProvider 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 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; - // Conversion YUV → RGB 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 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); @@ -238,63 +302,18 @@ class _CaptureScreenState extends State with SingleTickerProvider } } - // 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; - }*/ - } - + // ───────────────────────────────────────────────────────────────────────── + // Galerie (inchangée) + // ───────────────────────────────────────────────────────────────────────── 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(); - } + status = androidInfo.version.sdkInt >= 33 + ? await Permission.photos.request() + : await Permission.storage.request(); } else { status = await Permission.photos.request(); } @@ -336,6 +355,9 @@ class _CaptureScreenState extends State with SingleTickerProvider ); } + // ───────────────────────────────────────────────────────────────────────── + // Build principal + // ───────────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final sessionProvider = context.watch(); @@ -343,8 +365,9 @@ class _CaptureScreenState extends State with SingleTickerProvider ? '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) { + if (_showLiveCamera && + _cameraController != null && + _cameraController!.value.isInitialized) { return _buildLiveCameraView(); } @@ -356,7 +379,6 @@ class _CaptureScreenState extends State with SingleTickerProvider crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: AppConstants.largePadding), - _buildSectionTitle('Source de l\'Image'), const SizedBox(height: 12), Row( @@ -365,7 +387,7 @@ class _CaptureScreenState extends State with SingleTickerProvider child: ImageSourceButton( icon: Icons.camera_alt, label: 'Scanner', - onPressed: _isLoading ? null : _initLiveCamera, // Ouvre notre caméra intégrée + onPressed: _isLoading ? null : _initLiveCamera, ), ), const SizedBox(width: 12), @@ -379,7 +401,6 @@ class _CaptureScreenState extends State with SingleTickerProvider ], ), const SizedBox(height: AppConstants.largePadding), - if (_isLoading) const Center( child: Padding( @@ -395,84 +416,65 @@ class _CaptureScreenState extends State with SingleTickerProvider ); } - // 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 + // ───────────────────────────────────────────────────────────────────────── + // Vue caméra live — MODIFIÉE pour utiliser les données IMU + // ───────────────────────────────────────────────────────────────────────── 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'; + // 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. Le flux vidéo de la caméra + // 1. Flux vidéo caméra Center( child: AspectRatio( - aspectRatio: 3 / 4, // Aligné sur le format natif du capteur photo + aspectRatio: 3 / 4, child: CameraPreview(_cameraController!), ), ), - // 2. L'ASSISTANT DE CADRAGE VIRTUEL (Avec le grand cadre complet et la petite cible) + // 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, // MODIFIÉ : Couleur dynamique selon l'alignement - width: 2.0, - ), + border: Border.all(color: frameColor, 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 + width: 24, height: 24, decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all( - color: frameColor, // MODIFIÉ : Couleur dynamique - width: 1.2, // Ligne fine - ), + border: Border.all(color: frameColor, width: 1.2), ), child: Stack( children: [ - // Trait horizontal fin confiné Center( child: Container( width: 20, height: 1.2, - color: frameColor, // MODIFIÉ : Couleur dynamique + color: frameColor, ), ), - // Trait vertical fin confiné Center( child: Container( width: 1.2, height: 20, - color: frameColor, // MODIFIÉ : Couleur dynamique + color: frameColor, ), ), ], @@ -484,7 +486,7 @@ class _CaptureScreenState extends State with SingleTickerProvider ), ), - // 3. Barre supérieure avec bouton Retour + // 3. Bouton retour Positioned( top: 40, left: 20, @@ -493,8 +495,8 @@ class _CaptureScreenState extends State with SingleTickerProvider 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(); + _stopParallelismDetection(); // NOUVEAU setState(() { _showLiveCamera = false; }); @@ -503,7 +505,7 @@ class _CaptureScreenState extends State with SingleTickerProvider ), ), - // 4. Consigne de scan textuelle — MODIFIÉ : Message dynamique selon l'alignement + // 4. Message d'alignement principal (texte dynamique IMU) Positioned( top: 50, left: 0, @@ -518,7 +520,7 @@ class _CaptureScreenState extends State with SingleTickerProvider child: Text( alignmentMessage, style: TextStyle( - color: frameColor, // MODIFIÉ : Couleur dynamique + color: frameColor, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1.2, @@ -528,7 +530,16 @@ class _CaptureScreenState extends State with SingleTickerProvider ), ), - // AJOUT : Indicateur visuel de l'analyse en cours (petit point clignotant) + // 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, @@ -543,7 +554,7 @@ class _CaptureScreenState extends State with SingleTickerProvider ), ), - // 5. Bouton déclencheur Manuel en bas + // 5. Bouton déclencheur Positioned( bottom: 40, left: 0, @@ -577,13 +588,78 @@ class _CaptureScreenState extends State with SingleTickerProvider ); } - // MODIFIÉ : Ajout du paramètre color pour la couleur dynamique des coins + // ───────────────────────────────────────────────────────────────────────── + // 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), // AJOUT : Couleur dynamique + Color color = const Color(0xFF00FF00), }) { return Positioned( top: (TopLeft || TopRight) ? 10 : null, @@ -595,34 +671,41 @@ class _CaptureScreenState extends State with SingleTickerProvider 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, + 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 + // ───────────────────────────────────────────────────────────────────────── + // Capture manuelle (inchangée, sauf ajout stop IMU) + // ───────────────────────────────────────────────────────────────────────── Future _takePictureManually() async { - if (_cameraController == null || !_cameraController!.value.isInitialized) return; + 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 - } + } catch (_) {} - // AJOUT : On stoppe la détection périodique avant de prendre la photo _stopAlignmentDetection(); + _stopParallelismDetection(); // NOUVEAU - // 1. On prend la photo brute normale (sans y toucher) final XFile photo = await _cameraController!.takePicture(); setState(() { @@ -630,7 +713,6 @@ class _CaptureScreenState extends State with SingleTickerProvider _showLiveCamera = false; }); - // 2. On l'envoie directement à l'analyse _analyzeImage(); } catch (e) { debugPrint('Erreur lors du clic photo: $e'); @@ -639,12 +721,16 @@ class _CaptureScreenState extends State with SingleTickerProvider } } + // ───────────────────────────────────────────────────────────────────────── + // Helpers UI (inchangés) + // ───────────────────────────────────────────────────────────────────────── Widget _buildSectionTitle(String title) { return Text( title, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), ); } @@ -658,9 +744,10 @@ class _CaptureScreenState extends State with SingleTickerProvider const SizedBox(height: 12), Text( 'Conseils pour une bonne analyse', - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), _buildGuideItem( @@ -693,7 +780,6 @@ class _CaptureScreenState extends State with SingleTickerProvider ); } - // Capture depuis la galerie d'images Future _captureImageFromGallery() async { setState(() => _isLoading = true); try { diff --git a/lib/services/parallelism_service.dart b/lib/services/parallelism_service.dart new file mode 100644 index 00000000..ffa6dcc4 --- /dev/null +++ b/lib/services/parallelism_service.dart @@ -0,0 +1,156 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:sensors_plus/sensors_plus.dart'; + +/// Statut de parallélisme retourné en temps réel. +enum ParallelismStatus { + /// Pas encore de données capteur disponibles. + unknown, + + /// L'appareil est bien parallèle à la cible (pitch et roll dans les seuils). + aligned, + + /// L'appareil est trop incliné — l'utilisateur doit repositionner. + misaligned, +} + +/// Données de parallélisme calculées à chaque frame capteur. +class ParallelismData { + final ParallelismStatus status; + + /// Inclinaison avant/arrière en degrés (0° = parfaitement vertical). + final double pitchDegrees; + + /// Inclinaison gauche/droite en degrés (0° = parfaitement droit). + final double rollDegrees; + + const ParallelismData({ + required this.status, + required this.pitchDegrees, + required this.rollDegrees, + }); + + /// Raccourci : vrai si l'appareil est bien aligné. + bool get isAligned => status == ParallelismStatus.aligned; + + @override + String toString() => + 'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)'; +} + +/// Service de détection du parallélisme par accéléromètre. +/// +/// Utilise le package sensors_plus pour lire l'accéléromètre en continu. +/// Calcule le pitch (inclinaison avant/arrière) et le roll (inclinaison latérale). +/// +/// Un appareil tenu **à la verticale face à la cible** a : +/// - pitch ≈ 0° (l'axe X est horizontal) +/// - roll ≈ 0° (l'axe Y est vertical, gravité vers le bas) +/// +/// En mode **paysage** (appareil à l'horizontale), les axes sont permutés — +/// le service gère les deux orientations automatiquement. +class ParallelismService { + /// Seuil d'angle maximum autorisé en degrés. + /// En dessous → aligned, au-dessus → misaligned. + final double thresholdDegrees; + + StreamSubscription? _subscription; + final StreamController _controller = + StreamController.broadcast(); + + ParallelismService({this.thresholdDegrees = 5.0}); + + /// Stream de données de parallélisme mis à jour en temps réel. + Stream get stream => _controller.stream; + + /// Démarre l'écoute de l'accéléromètre. + /// Appeler au moment d'afficher la caméra live. + void start() { + if (_subscription != null) return; // Déjà démarré + + _subscription = accelerometerEventStream( + samplingPeriod: SensorInterval.normalInterval, // ~50 ms + ).listen( + _onAccelerometerEvent, + onError: (e) { + // En cas d'erreur capteur (simulateur sans IMU par exemple), + // on émet un statut "unknown" pour ne pas bloquer l'UI. + if (!_controller.isClosed) { + _controller.add( + const ParallelismData( + status: ParallelismStatus.unknown, + pitchDegrees: 0, + rollDegrees: 0, + ), + ); + } + }, + ); + } + + /// Stoppe l'écoute de l'accéléromètre. + /// Appeler quand l'écran caméra est fermé pour économiser la batterie. + void stop() { + _subscription?.cancel(); + _subscription = null; + } + + /// Libère toutes les ressources. + void dispose() { + stop(); + _controller.close(); + } + + void _onAccelerometerEvent(AccelerometerEvent event) { + if (_controller.isClosed) return; + + // L'accéléromètre retourne (x, y, z) en m/s². + // Au repos, la magnitude vaut ~9.81 (gravité terrestre). + // + // Convention capteur Android/iOS (portrait) : + // x → axe horizontal (positif = droite) + // y → axe vertical (positif = haut) + // z → axe profondeur (positif = vers l'utilisateur) + // + // On normalise pour être indépendant de la gravité exacte. + final double gx = event.x; + final double gy = event.y; + final double gz = event.z; + + final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz); + if (magnitude < 1.0) { + // Données aberrantes (quasi-nulles) — on ignore + return; + } + + // Normalisation + final double nx = gx / magnitude; + final double ny = gy / magnitude; + final double nz = gz / magnitude; + + // Pitch : inclinaison avant/arrière + // En portrait vertical face à l'utilisateur : ny ≈ -1, nz ≈ 0 → pitch ≈ 0° + // On calcule l'angle entre le vecteur gravité projeté et l'axe Y pur. + final double pitchRad = math.asin(nz.clamp(-1.0, 1.0)); + final double pitchDeg = pitchRad * (180.0 / math.pi); + + // Roll : inclinaison latérale gauche/droite + // En portrait droit : nx ≈ 0 → roll ≈ 0° + final double rollRad = math.atan2(nx, -ny); + final double rollDeg = rollRad * (180.0 / math.pi); + + final bool isAligned = + pitchDeg.abs() <= thresholdDegrees && + rollDeg.abs() <= thresholdDegrees; + + _controller.add( + ParallelismData( + status: isAligned + ? ParallelismStatus.aligned + : ParallelismStatus.misaligned, + pitchDegrees: pitchDeg, + rollDegrees: rollDeg, + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 40771b10..398eded0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -701,6 +701,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + sensors_plus: + dependency: "direct main" + description: + name: sensors_plus + sha256: "8e7fa79b4940442bb595bfc0ee9da4af5a22a0fe6ebacc74998245ee9496a82d" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + sensors_plus_platform_interface: + dependency: transitive + description: + name: sensors_plus_platform_interface + sha256: bc472d6cfd622acb4f020e726433ee31788b038056691ba433fec80e448a094f + url: "https://pub.dev" + source: hosted + version: "1.2.0" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0a19a2fe..51fc128f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - + sensors_plus: ^4.0.2 opencv_dart: ^2.1.0 # Image capture from camera/gallery