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. aligned, /// L'appareil est trop incliné. 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, }); 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. /// /// 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; 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; ParallelismService({ this.alignThreshold = 15.0, this.misalignThreshold = 22.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; _controller.add(const ParallelismData( status: ParallelismStatus.unknown, pitchDegrees: 0, rollDegrees: 0, )); } }, ); } void stop() { _subscription?.cancel(); _subscription = null; _currentStatus = ParallelismStatus.unknown; } void dispose() { stop(); _controller.close(); } void _onAccelerometerEvent(AccelerometerEvent event) { if (_controller.isClosed) return; 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) return; // Données aberrantes // Normalisation par la magnitude réelle (indépendant de g exact) final double nx = gx / magnitude; final double ny = gy / magnitude; final double nz = gz / magnitude; // Pitch : inclinaison avant/arrière (téléphone qui pointe vers le haut/bas) final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi); // Roll : inclinaison latérale (téléphone penché à gauche/droite) final double rollDeg = math.atan2(nx, -ny) * (180.0 / math.pi); // Angle combiné = le pire des deux axes final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs()); // ── 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 } // ──────────────────────────────────────────────────────────────────────── _controller.add(ParallelismData( status: _currentStatus, pitchDegrees: pitchDeg, rollDegrees: rollDeg, )); } }