Le chat écrit, le vocal et le déclenchement proactif partagent désormais une seule conversation, portée par VisitAppContext.assistant. Les trois AssistantService séparés ont disparu : poser une question aux lunettes puis ouvrir le chat donnait un guide qui ne savait rien de ce qu'on venait de demander, et produisait deux lignes VisitorQuestion sans lien pour un visiteur qui avait simplement changé de surface. Le maxHistory du vocal (6) s'aligne sur 10 : deux surfaces qui partagent une conversation ne peuvent pas la tronquer différemment selon le point d'entrée. La concision vocale vient d'isVoice, qui change le prompt côté serveur. Le vrai obstacle n'était pas l'affichage mais la forme du chat : AssistantChatSheet gardait ses messages en List<Widget>, des bulles déjà construites. On ne rejoue pas une conversation à partir de widgets, et un tour vocal survenu pendant que la feuille était fermée n'aurait jamais pu y entrer. Le service porte maintenant les tours en données et notifie ses écouteurs. Deux listes, délibérément : celle envoyée au modèle et celle affichée ne coïncident pas. Un message d'erreur se montre sans repartir au guide, et le prompt d'un déclenchement proactif part au guide sans jamais s'afficher — seule sa réponse apparaît, marquée « À voix haute ». Sans ce marquage, le visiteur trouverait dans son chat des messages qu'il n'a jamais tapés. conversationId est enfin envoyé, et renouvelé quand la conversation est vidée : sinon la visite entière d'un visiteur n'en formerait qu'une. flutter analyze lib sans erreur, flutter build apk --debug --flavor dev vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
768 lines
28 KiB
Dart
768 lines
28 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_widget_from_html/flutter_widget_from_html.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<Offset>(begin: const Offset(0, 1), end: Offset.zero)
|
|
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
State<AssistantChatSheet> createState() => _AssistantChatSheetState();
|
|
}
|
|
|
|
class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
|
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<void> _initSpeech() async {
|
|
final available = await _speech.initialize();
|
|
if (mounted) setState(() => _speechAvailable = available);
|
|
}
|
|
|
|
Future<void> _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<void> _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<GlassesState>(
|
|
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(),
|
|
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)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |