import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; 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. aligned, /// L'appareil est trop incliné. misaligned, } /// Pose de prise de vue déduite de l'inclinaison de l'appareil. enum TargetPose { /// Cible accrochée verticalement : l'appareil est tenu droit. wall, /// Cible posée au sol : l'appareil est à plat, caméra vers le bas. ground, } /// Données de parallélisme calculées à chaque frame capteur. class ParallelismData { final ParallelismStatus status; /// Pose détectée automatiquement, qui sert de référence aux écarts. final TargetPose pose; /// Inclinaison avant/arrière brute en degrés (0° = appareil vertical, /// +90° = appareil à plat, caméra vers le sol). final double pitchDegrees; /// Inclinaison gauche/droite en degrés (0° = parfaitement droit). final double rollDegrees; /// Écart de tangage par rapport à la pose détectée. /// /// En pose [TargetPose.wall] il vaut exactement [pitchDegrees] ; en pose /// [TargetPose.ground] il mesure l'écart aux +90° de l'appareil à plat. /// C'est cette valeur qu'il faut afficher : le tangage brut vaudrait -90° /// alors que le cadrage est parfait. final double pitchDeviation; const ParallelismData({ required this.status, required this.pose, required this.pitchDegrees, required this.rollDegrees, required this.pitchDeviation, }); bool get isAligned => status == ParallelismStatus.aligned; /// Écart latéral. Le roulis se mesure de la même façon dans les deux poses. double get rollDeviation => rollDegrees; @override String toString() => 'ParallelismData(status: $status, pose: $pose, pitch: ${pitchDegrees.toStringAsFixed(1)}°, ' 'écart: ${pitchDeviation.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)'; } /// Service de détection du parallélisme par accéléromètre. /// /// Deux poses de prise de vue sont reconnues, et celle dont l'appareil est le /// plus proche est retenue automatiquement : /// /// [TargetPose.wall] — cible au mur, appareil vertical (tangage ≈ 0°) /// [TargetPose.ground] — cible au sol, appareil à plat (tangage ≈ +90°) /// /// Seul le tangage positif vaut pour la pose au sol : à plat écran vers le /// haut, la caméra vise le plafond et le vert n'aurait aucun sens. /// /// Implémente une hystérésis à deux seuils pour éviter le clignotement : /// /// État actuel = misaligned → passe à aligned si angle < [alignThreshold] /// État actuel = aligned → passe à misaligned si angle > [misalignThreshold] /// /// Cela crée une "zone de confort" entre les deux seuils où le statut /// ne change pas — le vert reste vert même si la main tremble légèrement. /// /// Valeurs par défaut recommandées : /// alignThreshold = 15° (seuil d'entrée dans le vert — assez souple) /// misalignThreshold = 22° (seuil de sortie du vert — tolérant au tremblement) class ParallelismService { /// Angle max pour passer de misaligned → aligned. final double alignThreshold; /// Angle à dépasser pour passer de aligned → misaligned. /// Doit être > alignThreshold pour créer la zone d'hystérésis. final double misalignThreshold; /// Écart minimum en faveur de l'autre pose pour basculer. /// /// Les deux poses sont séparées de 90°, donc la bascule se joue vers 45° — /// très loin des deux zones vertes. Cette marge évite seulement que /// l'étiquette de pose clignote pile à la frontière. static const double poseSwitchMargin = 5.0; StreamSubscription? _subscription; final StreamController _controller = StreamController.broadcast(); /// État interne mémorisé entre deux frames (cœur de l'hystérésis). ParallelismStatus _currentStatus = ParallelismStatus.unknown; /// Pose retenue à la frame précédente, pour l'hystérésis de pose. TargetPose? _currentPose; ParallelismService({ this.alignThreshold = 25.0, this.misalignThreshold = 32.0, }) : assert( misalignThreshold > alignThreshold, 'misalignThreshold doit être supérieur à alignThreshold', ); Stream get stream => _controller.stream; void start() { if (_subscription != null) return; _subscription = accelerometerEventStream( samplingPeriod: SensorInterval.normalInterval, // ~50 ms ).listen( _onAccelerometerEvent, onError: (_) { // Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI if (!_controller.isClosed) { _currentStatus = ParallelismStatus.unknown; _currentPose = null; _controller.add(const ParallelismData( status: ParallelismStatus.unknown, pose: TargetPose.wall, pitchDegrees: 0, rollDegrees: 0, pitchDeviation: 0, )); } }, ); } void stop() { _subscription?.cancel(); _subscription = null; _currentStatus = ParallelismStatus.unknown; _currentPose = null; } void dispose() { stop(); _controller.close(); } void _onAccelerometerEvent(AccelerometerEvent event) { if (_controller.isClosed) return; final data = evaluate(event.x, event.y, event.z); if (data != null) _controller.add(data); } /// Calcule la pose et le statut depuis une mesure d'accéléromètre. /// /// Met à jour l'état d'hystérésis, donc l'ordre des appels compte. /// Retourne null sur une mesure aberrante, qu'il faut alors ignorer. @visibleForTesting ParallelismData? evaluate(double gx, double gy, double gz) { final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz); if (magnitude < 1.0) return null; // Données aberrantes // Normalisation par la magnitude réelle (indépendant de g exact) final double nx = gx / magnitude; final double nz = gz / magnitude; // Pitch et Roll mesurent l'inclinaison autour de chaque axe. final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi); final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi); // ── Choix de la pose ──────────────────────────────────────────────────── // Cible au mur : le tangage idéal est 0°. Formule d'origine, inchangée. final double wallPitchDeviation = pitchDeg; final double wallWorst = math.max(pitchDeg.abs(), rollDeg.abs()); // Cible au sol : le tangage idéal est +90° (caméra vers le bas). Écran vers // le haut, le tangage vaut -90° et l'écart atteint 180° : la pose au sol ne // peut alors jamais gagner, ce qui est exactement le comportement voulu. final double groundPitchDeviation = pitchDeg - 90.0; final double groundWorst = math.max(groundPitchDeviation.abs(), rollDeg.abs()); if (_currentPose == null) { _currentPose = groundWorst < wallWorst ? TargetPose.ground : TargetPose.wall; } else if (_currentPose == TargetPose.wall) { if (groundWorst + poseSwitchMargin < wallWorst) { _currentPose = TargetPose.ground; } } else { if (wallWorst + poseSwitchMargin < groundWorst) { _currentPose = TargetPose.wall; } } final bool isGround = _currentPose == TargetPose.ground; final double pitchDeviation = isGround ? groundPitchDeviation : wallPitchDeviation; // Le critère de couleur = le PIRE des deux écarts affichés à l'écran. // Ainsi ce que voit l'utilisateur correspond exactement à la décision // vert/orange : à 2° d'écart, on est largement dans le vert. final double worstAngle = isGround ? groundWorst : wallWorst; // ── Hystérésis ────────────────────────────────────────────────────────── // Premier appel : on décide selon alignThreshold uniquement if (_currentStatus == ParallelismStatus.unknown) { _currentStatus = worstAngle <= alignThreshold ? ParallelismStatus.aligned : ParallelismStatus.misaligned; } // Déjà misaligned → devient aligned seulement si on passe sous alignThreshold else if (_currentStatus == ParallelismStatus.misaligned) { if (worstAngle <= alignThreshold) { _currentStatus = ParallelismStatus.aligned; } } // Déjà aligned → devient misaligned seulement si on dépasse misalignThreshold else if (_currentStatus == ParallelismStatus.aligned) { if (worstAngle > misalignThreshold) { _currentStatus = ParallelismStatus.misaligned; } // Entre alignThreshold et misalignThreshold → on garde le vert, on ne change rien } // ──────────────────────────────────────────────────────────────────────── return ParallelismData( status: _currentStatus, pose: _currentPose!, pitchDegrees: pitchDeg, rollDegrees: rollDeg, pitchDeviation: pitchDeviation, ); } }