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 = 20.0, }); } /// 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; static const int cooldownMillis = 30000; // 30s entre deux triggers du même point VisitAppContext? _visitAppContext; AssistantService? _assistantService; StreamSubscription? _geoSub; StreamSubscription? _beaconSub; final List _geoPoints = []; final Set _triggeredIds = {}; final Map _lastTriggerTime = {}; bool _running = false; /// Lance le service. /// [geoPoints] : points de déclenchement GPS (à peupler depuis la configuration). Future start({ required VisitAppContext visitAppContext, List 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 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 points) { _geoPoints ..clear() ..addAll(points); _triggeredIds.clear(); } // ── GPS ───────────────────────────────────────────────────────────────────── Future _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 _startBeaconTracking() async { final ctx = _visitAppContext; if (ctx?.beaconSections == null || ctx!.beaconSections!.isEmpty) return; final regions = [ 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; } bool _isProactiveModeActive() { return _visitAppContext?.proactiveModeEnabled == true && _visitAppContext?.glassesEnabled == true; } Future _trigger({ required String triggerId, required String prompt, }) async { _lastTriggerTime[triggerId] = DateTime.now().millisecondsSinceEpoch; debugPrint('[GeoBeaconTriggerService] Trigger: $triggerId'); try { final response = await _assistantService!.chat( message: prompt, configurationId: _visitAppContext?.configuration?.id, ); if (response.reply.isNotEmpty) { final lang = _visitAppContext?.language ?? 'FR'; await activeOrchestrator?.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(); } }