172 lines
6.2 KiB
Dart
172 lines
6.2 KiB
Dart
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<AccelerometerEvent>? _subscription;
|
|
final StreamController<ParallelismData> _controller =
|
|
StreamController<ParallelismData>.broadcast();
|
|
|
|
/// État interne mémorisé entre deux frames (cœur de l'hystérésis).
|
|
ParallelismStatus _currentStatus = ParallelismStatus.unknown;
|
|
|
|
ParallelismService({
|
|
this.alignThreshold = 25.0,
|
|
this.misalignThreshold = 32.0,
|
|
}) : assert(
|
|
misalignThreshold > alignThreshold,
|
|
'misalignThreshold doit être supérieur à alignThreshold',
|
|
);
|
|
|
|
Stream<ParallelismData> 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;
|
|
|
|
// ── Mesure de l'écart par rapport à la VERTICALE (cible sur un mur) ──
|
|
// Quand le téléphone est vertical face au mur, la gravité est portée par
|
|
// l'axe -Y (ny ≈ -1) et l'axe Z est proche de 0 (nz ≈ 0).
|
|
//
|
|
// Pitch = bascule avant/arrière (le téléphone "se couche") → vient de nz.
|
|
// Roll = penché à gauche/droite → vient de nx.
|
|
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);
|
|
|
|
// Écart angulaire TOTAL par rapport à la verticale parfaite, calculé en 3D.
|
|
// C'est l'angle entre l'axe -Y du téléphone et le vecteur gravité réel.
|
|
// -> Vaut 0° quand l'écran est parfaitement parallèle au mur,
|
|
// indépendamment d'une rotation autour de l'axe de visée.
|
|
final double tiltAngle =
|
|
math.acos((-ny).clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
|
|
|
// On utilise l'écart 3D comme critère principal (plus permissif et stable).
|
|
final double worstAngle = tiltAngle;
|
|
|
|
// ── 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,
|
|
));
|
|
}
|
|
} |