import 'package:flutter/material.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:mymuseum_visitapp/Components/VisitorPrivacyNotice.dart'; import 'package:mymuseum_visitapp/Helpers/assistantSuggestions.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/AssistantResponse.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/Services/meta_glasses_service.dart'; import 'package:mymuseum_visitapp/constants.dart'; import 'package:speech_to_text/speech_to_text.dart'; String _stripHtml(String html) => html.replaceAll(RegExp(r'<[^>]*>'), '').trim(); class AssistantChatSheet extends StatefulWidget { final VisitAppContext visitAppContext; final String? configurationId; final void Function(String sectionId, String sectionTitle)? onNavigateToSection; const AssistantChatSheet({ Key? key, required this.visitAppContext, this.configurationId, this.onNavigateToSection, }) : super(key: key); static void show( BuildContext context, { required VisitAppContext visitAppContext, String? configurationId, void Function(String sectionId, String sectionTitle)? onNavigateToSection, }) { showGeneralDialog( context: context, barrierDismissible: true, barrierLabel: '', barrierColor: Colors.black54, transitionDuration: const Duration(milliseconds: 280), pageBuilder: (dialogContext, _, __) => AssistantChatSheet( visitAppContext: visitAppContext, configurationId: configurationId, onNavigateToSection: onNavigateToSection, ), transitionBuilder: (_, animation, __, child) => SlideTransition( position: Tween(begin: const Offset(0, 1), end: Offset.zero) .animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)), child: child, ), ); } @override State createState() => _AssistantChatSheetState(); } class _AssistantChatSheetState extends State { late AssistantService _assistantService; final TextEditingController _controller = TextEditingController(); final ScrollController _scrollController = ScrollController(); bool _isLoading = false; final SpeechToText _speech = SpeechToText(); bool _speechAvailable = false; bool _isListening = false; @override void initState() { super.initState(); // Conversation partagée avec le vocal et le proactif : à l'ouverture, la feuille // affiche déjà ce qui s'est dit aux lunettes, et se met à jour en direct si le // guide parle pendant qu'elle est ouverte. _assistantService = widget.visitAppContext.assistant; _assistantService.addListener(_onConversationChanged); _initSpeech(); } void _onConversationChanged() { if (!mounted) return; setState(() {}); _scrollToBottom(); } Future _initSpeech() async { final available = await _speech.initialize(); if (mounted) setState(() => _speechAvailable = available); } Future _toggleListening() async { if (_isListening) { await _speech.stop(); setState(() => _isListening = false); } else { final locale = widget.visitAppContext.language?.toLowerCase() ?? 'fr'; setState(() => _isListening = true); await _speech.listen( localeId: locale, onResult: (result) { setState(() => _controller.text = result.recognizedWords); if (result.finalResult) { setState(() => _isListening = false); } }, listenOptions: SpeechListenOptions(partialResults: true), ); } } @override void dispose() { // Le service survit à la feuille — il porte la conversation, pas l'écran. On se // désabonne, on ne le dispose pas. _assistantService.removeListener(_onConversationChanged); _speech.cancel(); super.dispose(); } Future _send() async { final text = _controller.text.trim(); if (text.isEmpty || _isLoading) return; _controller.clear(); // Le tour du visiteur est ajouté par le service, qui prévient ses écouteurs : // rien à empiler ici, sinon il s'afficherait deux fois. setState(() => _isLoading = true); _scrollToBottom(); try { final response = await _assistantService.chat( message: text, configurationId: widget.configurationId, ); // Pipe TTS vers les lunettes si connectées if (MetaGlassesService.instance.isConnected && response.reply.isNotEmpty) { final lang = widget.visitAppContext.language ?? 'FR'; activeVoiceOrchestrator?.ttsEngine.speak( response.reply, languageCode: _toLangCode(lang), ); } } on AssistantUnavailableException { // Quota épuisé : surtout ne pas inviter à réessayer, le visiteur boucterait. _assistantService.addServiceMessage( "Le guide se repose pour aujourd'hui. Revenez demain."); } catch (e) { debugPrint('AssistantChatSheet error: $e'); _assistantService.addServiceMessage("Une erreur est survenue, réessayez."); } finally { setState(() => _isLoading = false); _scrollToBottom(); } } 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 _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } /// Nom du lieu affiché sous le titre — le visiteur sait à qui il parle. String get _venueName { final configuration = widget.visitAppContext.configuration; if (configuration == null) return ''; final title = _stripHtml( TranslationHelper.get(configuration.title, widget.visitAppContext)); return title.isNotEmpty ? title : (configuration.label ?? ''); } @override Widget build(BuildContext context) { final height = MediaQuery.of(context).size.height * 0.9; return Align( alignment: Alignment.bottomCenter, child: Material( borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), clipBehavior: Clip.antiAlias, child: SizedBox( height: height, child: Column( children: [ // Header Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ Container( width: 32, height: 32, decoration: BoxDecoration( color: kMainColor1, shape: BoxShape.circle, ), child: const Icon(Icons.chat_bubble_outline, color: Colors.white, size: 17), ), const SizedBox(width: 10), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text("Votre guide", style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, height: 1.15, color: kSecondGrey)), if (_venueName.isNotEmpty) Text(_venueName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5, height: 1.2, color: Colors.grey[500])), ], ), ), const SizedBox(width: 8), ValueListenableBuilder( valueListenable: MetaGlassesService.instance.state, builder: (_, glassesState, __) { final connected = glassesState == GlassesState.connected || glassesState == GlassesState.streaming; // Rien à montrer tant qu'aucune paire n'est entrée en scène : // l'ancienne garde `glassesEnabled` n'était jamais vraie, donc // cette pastille n'a jamais été affichée à personne. if (glassesState == GlassesState.disconnected) { return const SizedBox.shrink(); } return Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.smart_toy_outlined, size: 14, color: connected ? Colors.green : Colors.grey[400], ), const SizedBox(width: 3), Text( connected ? 'Lunettes' : 'Déconnecté', style: TextStyle( fontSize: 11, color: connected ? Colors.green : Colors.grey[400], ), ), ], ); }, ), const Spacer(), // §8.3 des CGU : la mention doit être accessible depuis l'assistant // lui-même, c'est-à-dire là où la collecte a lieu — pas seulement dans // la politique de confidentialité du lieu. IconButton( icon: const Icon(Icons.privacy_tip_outlined, size: 20), tooltip: VisitorPrivacyNotice.textFor(widget.visitAppContext.language).title, onPressed: () => VisitorPrivacyNotice.show(context, widget.visitAppContext), ), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), ], ), ), const Divider(height: 1), // Messages — lus depuis la conversation partagée, donc ce qui a été dit // aux lunettes est déjà là à l'ouverture. Expanded( child: _assistantService.turns.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( "Bonjour ! Posez-moi vos questions sur cette visite.", textAlign: TextAlign.center, style: TextStyle(color: Colors.grey[500], fontSize: 15), ), const SizedBox(height: 16), // Suggestions dérivées du contenu réel de la configuration : // face à un champ vide, peu de visiteurs savent quoi demander. Wrap( alignment: WrapAlignment.center, spacing: 8, runSpacing: 8, children: AssistantSuggestions .build(widget.visitAppContext) .map((suggestion) => ActionChip( label: Text( suggestion, style: const TextStyle(fontSize: 12.5), ), backgroundColor: Colors.white, side: BorderSide(color: Colors.grey[300]!), onPressed: () { _controller.text = suggestion; _send(); }, )) .toList(), ), ], ), ), ) : ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: _assistantService.turns.length, itemBuilder: (_, i) { final turn = _assistantService.turns[i]; if (turn.response != null) { return _AssistantMessage( response: turn.response!, isVoice: turn.isVoice, onNavigate: widget.onNavigateToSection, ); } return _ChatBubble( text: turn.text, isUser: turn.isUser, isVoice: turn.isVoice, ); }, ), ), // Le guide rédige — trois points plutôt qu'un spinner : la réponse // arrive, elle ne « charge » pas. if (_isLoading) Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), child: Semantics( label: 'Le guide rédige', child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: Colors.grey[100], borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(16), ), ), child: const _TypingDots(), ), ), ), ), // Écoute en cours — le micro rouge seul ne dit pas qu'on enregistre. if (_isListening) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.09), borderRadius: BorderRadius.circular(999), ), child: Row( children: [ const Icon(Icons.mic, color: Colors.red, size: 15), const SizedBox(width: 8), const Text( "Je vous écoute…", style: TextStyle( color: Colors.red, fontSize: 13, fontWeight: FontWeight.w600), ), const Spacer(), const _ListeningWave(), ], ), ), ), // Input Padding( padding: EdgeInsets.only( left: 12, right: 12, bottom: MediaQuery.of(context).viewInsets.bottom + 8, top: 8), child: Row( children: [ Expanded( child: TextField( controller: _controller, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( hintText: "Votre question...", filled: true, fillColor: Colors.grey[100], border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), onSubmitted: (_) => _send(), ), ), const SizedBox(width: 8), if (_speechAvailable) AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( color: _isListening ? Colors.red : Colors.grey[200], shape: BoxShape.circle, ), child: IconButton( icon: Icon( _isListening ? Icons.mic : Icons.mic_none, color: _isListening ? Colors.white : Colors.grey[600], size: 20, ), onPressed: _toggleListening, ), ), const SizedBox(width: 8), CircleAvatar( backgroundColor: kMainColor1, child: IconButton( icon: const Icon(Icons.send, color: Colors.white, size: 18), onPressed: _send, ), ), ], ), ), ], ), ), ), ); } } /// Trois points qui pulsent en décalé, pendant que la réponse se prépare. class _TypingDots extends StatefulWidget { const _TypingDots(); @override State<_TypingDots> createState() => _TypingDotsState(); } class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1300), )..repeat(); @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (_, __) => Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (i) { // Chaque point est décalé d'un sixième de cycle sur le précédent. final phase = (_controller.value - i * 0.14) % 1.0; final lift = phase < 0.3 ? Curves.easeInOut.transform(phase / 0.3) : 0.0; return Padding( padding: EdgeInsets.only(right: i < 2 ? 4 : 0), child: Transform.translate( offset: Offset(0, -3 * lift), child: Container( width: 6, height: 6, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.grey[500]!.withValues(alpha: 0.3 + 0.7 * lift), ), ), ), ); }), ), ); } } /// Onde sonore de la dictée — cinq barres qui respirent. class _ListeningWave extends StatefulWidget { const _ListeningWave(); @override State<_ListeningWave> createState() => _ListeningWaveState(); } class _ListeningWaveState extends State<_ListeningWave> with SingleTickerProviderStateMixin { static const _heights = [8.0, 14.0, 20.0, 11.0, 16.0]; late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1000), )..repeat(); @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (_, __) => Row( mainAxisSize: MainAxisSize.min, children: List.generate(_heights.length, (i) { final phase = (_controller.value + i * 0.12) % 1.0; final scale = 0.5 + 0.5 * (1 - (phase * 2 - 1).abs()); return Padding( padding: EdgeInsets.only(right: i < _heights.length - 1 ? 2 : 0), child: Container( width: 3, height: _heights[i] * scale, decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(2), ), ), ); }), ), ); } } class _ChatBubble extends StatelessWidget { final String text; final bool isUser; /// Le tour est passé par la voix. Une petite icône le signale — sans elle, le /// visiteur qui ouvre le chat ne comprend pas d'où sortent des messages qu'il /// n'a jamais tapés. final bool isVoice; const _ChatBubble({required this.text, required this.isUser, this.isVoice = false}); @override Widget build(BuildContext context) { return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Column( crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (isVoice) Padding( padding: const EdgeInsets.only(top: 4, left: 6, right: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.graphic_eq, size: 11, color: Colors.grey[500]), const SizedBox(width: 3), Text('À voix haute', style: TextStyle(fontSize: 10.5, color: Colors.grey[500])), ], ), ), _bubble(context), ], ), ); } Widget _bubble(BuildContext context) { return Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78), decoration: BoxDecoration( color: isUser ? kMainColor1 : Colors.grey[100], borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: isUser ? const Radius.circular(16) : const Radius.circular(4), bottomRight: isUser ? const Radius.circular(4) : const Radius.circular(16), ), ), child: isUser ? Text( text, style: const TextStyle(color: Colors.white, fontSize: 14), ) : HtmlWidget( text, textStyle: TextStyle(color: kSecondGrey, fontSize: 14), ), ); } } class _AssistantMessage extends StatelessWidget { final AssistantResponse response; final bool isVoice; final void Function(String sectionId, String sectionTitle)? onNavigate; const _AssistantMessage({ required this.response, this.isVoice = false, this.onNavigate, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (response.reply.isNotEmpty) _ChatBubble(text: response.reply, isUser: false, isVoice: isVoice), if (response.cards != null && response.cards!.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 6, left: 4, right: 24), child: Column( children: response.cards!.map((card) => _AiCardWidget(card: card)).toList(), ), ), if (response.navigation != null && onNavigate != null) GestureDetector( onTap: () { Navigator.of(context).pop(); onNavigate!( response.navigation!.sectionId, _stripHtml(response.navigation!.sectionTitle), ); }, child: Container( margin: const EdgeInsets.only(top: 8, left: 4, right: 24), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: kMainColor1.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), border: Border.all(color: kMainColor1.withValues(alpha: 0.35)), ), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: response.navigation!.imageUrl != null ? Image.network( response.navigation!.imageUrl!, width: 48, height: 48, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( width: 48, height: 48, decoration: BoxDecoration( color: kMainColor1, borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.place_outlined, color: Colors.white, size: 22), ), ) : Container( width: 48, height: 48, decoration: BoxDecoration( color: kMainColor1, borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.place_outlined, color: Colors.white, size: 22), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _stripHtml(response.navigation!.sectionTitle), style: TextStyle( fontWeight: FontWeight.w600, fontSize: 13, color: kSecondGrey, ), ), Text( "Voir cette section", style: TextStyle(fontSize: 11, color: Colors.grey[500]), ), ], ), ), Icon(Icons.chevron_right, color: kMainColor1, size: 20), ], ), ), ), ], ), ); } } class _AiCardWidget extends StatelessWidget { final AiCard card; const _AiCardWidget({required this.card}); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.grey[200]!), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 3, offset: const Offset(0, 1)), ], ), child: Row( children: [ if (card.icon != null) ...[ Text(card.icon!, style: const TextStyle(fontSize: 18)), const SizedBox(width: 8), ], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(card.title, style: TextStyle( fontWeight: FontWeight.w600, fontSize: 13, color: kSecondGrey)), if (card.subtitle.isNotEmpty) Text(card.subtitle, style: const TextStyle(fontSize: 12, color: Colors.grey)), ], ), ), ], ), ); } }