AssistantService.chat porte isAutoTriggered, et geo_beacon_trigger_service le passe à true. Sans lui, le prompt que le service s'écrit à lui-même atterrissait dans VisitorQuestion, donc dans « Ce que demandent vos visiteurs », et gonflait le bloc des questions sans réponse. Les jetons restent comptés côté serveur. C'était le dernier bloquant avant d'activer le mode chez un client. flutter analyze lib sans erreur, flutter build apk --debug --flavor dev vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
284 lines
10 KiB
Dart
284 lines
10 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:beacon_scanner/beacon_scanner.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;
|
|
_assistantService = AssistantService(visitAppContext: visitAppContext);
|
|
_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)) {
|
|
_trigger(
|
|
triggerId: 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!,
|
|
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();
|
|
}
|
|
|
|
Future<void> _trigger({
|
|
required String triggerId,
|
|
required String prompt,
|
|
}) async {
|
|
_lastTriggerTime[triggerId] = DateTime.now().millisecondsSinceEpoch;
|
|
debugPrint('[GeoBeaconTriggerService] Trigger: $triggerId');
|
|
|
|
try {
|
|
// ⚠️ `isAutoTriggered` 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,
|
|
isAutoTriggered: true,
|
|
);
|
|
|
|
if (response.reply.isNotEmpty) {
|
|
final lang = _visitAppContext?.language ?? 'FR';
|
|
await activeVoiceOrchestrator?.ttsEngine.speak(
|
|
response.reply,
|
|
languageCode: _toLangCode(lang),
|
|
);
|
|
}
|
|
} 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();
|
|
}
|
|
}
|