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>
309 lines
12 KiB
Dart
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();
|
|
}
|
|
}
|