mymuseum-visitapp/lib/Services/geo_beacon_trigger_service.dart
Thomas Fransolet e967678dd2 Émission des événements vocaux — le vocal est un attribut, pas un canal
StatisticsService.track porte isVoice, qui pose "voice": true dans
VisitEvent.Metadata. Le flux lunettes n'émettait aucun VisitEvent : une visite
vocale ne produisait ni session, ni section vue, ni durée.

Le sectionView vocal part après la synthèse, pas avant : un déclenchement dont le
TTS échoue n'a rien fait entendre, le compter serait faux. Aucun événement par
question posée — elles sont déjà dans VisitorQuestion et remontent dans l'onglet
Guide IA ; les compter ici serait le même fait dans deux écrans.

isAutoTriggered suit le renommage serveur en isVisitorQuestion.

flutter analyze lib sans erreur, flutter build apk --debug --flavor dev vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:32:56 +02:00

309 lines
12 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:beacon_scanner/beacon_scanner.dart';
import 'package:manager_api_new/api.dart';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:mymuseum_visitapp/Models/beaconSection.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/assistantService.dart';
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
import 'package:mymuseum_visitapp/constants.dart';
/// Point de déclenchement géographique pour le TTS automatique.
class GeoTriggerPoint {
final String id;
final double latitude;
final double longitude;
final double radiusMeters;
const GeoTriggerPoint({
required this.id,
required this.latitude,
required this.longitude,
this.radiusMeters = GeoBeaconTriggerService.defaultRadiusMeters,
});
}
/// Déclenche automatiquement une réponse TTS de l'assistant quand :
/// - Le visiteur entre dans le rayon d'un GeoTriggerPoint (GPS)
/// - Un beacon BLE est détecté à proximité (< [beaconProximityMeters])
///
/// Le mode proactif doit être activé dans [VisitAppContext.proactiveModeEnabled]
/// pour que les déclenchements soient actifs.
class GeoBeaconTriggerService {
GeoBeaconTriggerService._();
static final GeoBeaconTriggerService instance = GeoBeaconTriggerService._();
static const double beaconProximityMeters = 3.0;
/// Rayon d'une zone GPS qui n'a pas de `meterZoneGPS` configuré.
static const double defaultRadiusMeters = 20.0;
static const int cooldownMillis = 30000; // 30s entre deux triggers du même point
VisitAppContext? _visitAppContext;
AssistantService? _assistantService;
StreamSubscription<Position>? _geoSub;
StreamSubscription<ScanResult>? _beaconSub;
final List<GeoTriggerPoint> _geoPoints = [];
final Set<String> _triggeredIds = {};
final Map<String, int> _lastTriggerTime = {};
bool _running = false;
/// Lance le service.
/// [geoPoints] : points de déclenchement GPS (à peupler depuis la configuration).
Future<void> start({
required VisitAppContext visitAppContext,
List<GeoTriggerPoint> geoPoints = const [],
}) async {
if (_running) return;
if (!Platform.isAndroid && !Platform.isIOS) return;
_visitAppContext = visitAppContext;
// Conversation partagée : ce que le guide dit à l'approche d'une œuvre apparaît
// dans le chat, et la question suivante du visiteur en tient compte.
_assistantService = visitAppContext.assistant;
_geoPoints
..clear()
..addAll(geoPoints);
_running = true;
await _startGeoTracking();
await _startBeaconTracking();
debugPrint('[GeoBeaconTriggerService] Started — ${_geoPoints.length} geo points, beacons active');
}
Future<void> stop() async {
await _geoSub?.cancel();
await _beaconSub?.cancel();
_geoSub = null;
_beaconSub = null;
_running = false;
debugPrint('[GeoBeaconTriggerService] Stopped');
}
/// Recharge les GeoTriggerPoints (appeler après un changement de configuration).
void updateGeoPoints(List<GeoTriggerPoint> points) {
_geoPoints
..clear()
..addAll(points);
_triggeredIds.clear();
}
// ── GPS ─────────────────────────────────────────────────────────────────────
Future<void> _startGeoTracking() async {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
debugPrint('[GeoBeaconTriggerService] Location permission not granted');
return;
}
_geoSub = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 5,
),
).listen(_onPosition);
}
void _onPosition(Position position) {
if (!_isProactiveModeActive()) return;
for (final point in _geoPoints) {
final dist = Geolocator.distanceBetween(
position.latitude,
position.longitude,
point.latitude,
point.longitude,
);
if (dist <= point.radiusMeters && _canTrigger(point.id)) {
// L'id d'un point GPS **est** l'id de la section dont il vient (`buildGeoPoints`),
// donc la consultation est attribuable.
_trigger(
triggerId: point.id,
sectionId: point.id,
prompt: 'Tu es un guide audio de musée. '
'Le visiteur vient d\'entrer dans la zone "${point.id}". '
'Accueille-le et donne-lui une brève information de bienvenue en 2 phrases.',
);
}
}
}
// ── Beacons ──────────────────────────────────────────────────────────────────
Future<void> _startBeaconTracking() async {
final ctx = _visitAppContext;
if (ctx?.beaconSections == null || ctx!.beaconSections!.isEmpty) return;
final regions = <Region>[
if (Platform.isIOS)
Region(
identifier: 'GlassesBeacon',
beaconId: IBeaconId(proximityUUID: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825'),
)
else
Region(identifier: 'GlassesBeacon'),
];
try {
_beaconSub = BeaconScanner.instance
.ranging(regions)
.listen((ScanResult result) => _onBeaconResult(result));
} catch (e) {
debugPrint('[GeoBeaconTriggerService] Beacon scan error: $e');
}
}
void _onBeaconResult(ScanResult result) {
if (!_isProactiveModeActive()) return;
final ctx = _visitAppContext;
if (ctx?.beaconSections == null) return;
for (final beacon in result.beacons) {
if (beacon.accuracy > beaconProximityMeters) continue;
final match = ctx!.beaconSections!.firstWhere(
(bs) =>
bs != null &&
bs.minorBeaconId == beacon.id.minorId &&
bs.configurationId == ctx.configuration?.id,
orElse: () => null,
);
if (match?.sectionId != null && _canTrigger(match!.sectionId!)) {
_trigger(
triggerId: match.sectionId!,
sectionId: match.sectionId,
prompt: 'Tu es un guide audio de musée. '
'Le visiteur est juste devant l\'œuvre ou l\'espace lié au point d\'intérêt "${match.sectionId}". '
'Donne une présentation courte et engageante en 2-3 phrases.',
);
}
}
}
// ── Déclenchement commun ─────────────────────────────────────────────────────
bool _canTrigger(String id) {
final now = DateTime.now().millisecondsSinceEpoch;
final last = _lastTriggerTime[id] ?? 0;
return (now - last) >= cooldownMillis;
}
/// ⚠️ **`isRunning` n'est pas une précaution décorative.** `_trigger` appelle le LLM
/// d'abord et ne parle qu'ensuite, via `activeVoiceOrchestrator?.ttsEngine` — le `?.`
/// avale silencieusement le cas « aucun mode vocal actif ». Sans cette condition, un
/// visiteur qui traverse une zone avec l'app en poche et le vocal éteint **consomme des
/// jetons Gemini facturés au quota du client pour une phrase que personne n'entend**.
///
/// L'ancienne garde exigeait `glassesEnabled`, un drapeau jamais assigné : le service
/// était donc inerte. Le remplacer par `proactiveModeEnabled` seul aurait ouvert la
/// dépense ci-dessus ; le remplacer par l'orchestrateur dit la vraie condition — il y
/// a quelqu'un pour écouter, lunettes ou écouteurs, peu importe.
bool _isProactiveModeActive() {
return _visitAppContext?.proactiveModeEnabled == true &&
activeVoiceOrchestrator?.isRunning == true;
}
/// Construit les points de déclenchement GPS depuis les sections de la configuration.
///
/// ⚠️ Deux pièges du format, vérifiés plutôt que supposés : `currentSections` porte des
/// **maps JSON brutes**, pas des DTO (cf. `assistantSuggestions` et `ScannerDialog`), et
/// `latitude`/`longitude` y sont des **chaînes**, pas des nombres — c'est le type du
/// `SectionDTO` généré. D'où le `double.tryParse` plutôt qu'un cast.
///
/// `meterZoneGPS` est le rayon réglé par section dans manager-app. C'est ici que le bug
/// M3 se ferme : le rayon n'est plus une constante mais la valeur configurée, et
/// [defaultRadiusMeters] ne sert que pour une section qui n'en porte pas.
static List<GeoTriggerPoint> buildGeoPoints(List<dynamic>? sections) {
return (sections ?? [])
.whereType<Map<String, dynamic>>()
.where((s) => s['isActive'] != false)
.map((s) {
final lat = double.tryParse('${s['latitude']}');
final lng = double.tryParse('${s['longitude']}');
final id = s['id'] as String?;
if (lat == null || lng == null || id == null) return null;
return GeoTriggerPoint(
id: id,
latitude: lat,
longitude: lng,
radiusMeters: (s['meterZoneGPS'] as int?)?.toDouble() ?? defaultRadiusMeters,
);
})
.whereType<GeoTriggerPoint>()
.toList();
}
/// [sectionId] : renseigné quand le déclencheur correspond à une section (beacon ou
/// zone GPS d'un point d'intérêt). C'est lui qui permet de compter la consultation.
Future<void> _trigger({
required String triggerId,
required String prompt,
String? sectionId,
}) async {
_lastTriggerTime[triggerId] = DateTime.now().millisecondsSinceEpoch;
debugPrint('[GeoBeaconTriggerService] Trigger: $triggerId');
try {
// ⚠️ `isVisitorQuestion: false` n'est pas cosmétique : sans lui, ce prompt — une
// consigne que le système s'écrit à lui-même — atterrissait dans `VisitorQuestion`,
// donc dans l'onglet « Ce que demandent vos visiteurs », et gonflait le bloc des
// questions sans réponse. Les jetons, eux, restent comptés côté serveur.
final response = await _assistantService!.chat(
message: prompt,
configurationId: _visitAppContext?.configuration?.id,
isVoice: true,
isVisitorQuestion: false,
);
if (response.reply.isNotEmpty) {
final lang = _visitAppContext?.language ?? 'FR';
await activeVoiceOrchestrator?.ttsEngine.speak(
response.reply,
languageCode: _toLangCode(lang),
);
// La section a réellement été lue au visiteur : c'est une consultation, au même
// titre qu'un écran ouvert au doigt. Émis **après** la synthèse, pas avant — un
// déclenchement dont le TTS échoue n'a rien fait entendre.
//
// ⛔ Aucun événement par question posée : elles sont déjà dans `VisitorQuestion`
// et remontent dans l'onglet Guide IA. Les compter ici serait le même fait dans
// deux écrans.
if (sectionId != null) {
_visitAppContext?.statisticsService?.track(
VisitEventType.sectionView,
sectionId: sectionId,
isVoice: true,
);
}
}
} catch (e) {
debugPrint('[GeoBeaconTriggerService] Trigger error for $triggerId: $e');
}
}
String _toLangCode(String lang) {
switch (lang.toUpperCase()) {
case 'FR': return 'fr-FR';
case 'NL': return 'nl-NL';
case 'EN': return 'en-US';
case 'DE': return 'de-DE';
default: return 'fr-FR';
}
}
void dispose() {
stop();
}
}