D5 — les échecs de téléchargement ne disparaissent plus dans un print : ils sont collectés et la visite n'est déclarée téléchargée que si elle l'est vraiment. Nouvelle clé downloadIncomplete dans les 10 langues, sans quoi getFromLocale aurait rendu une chaîne vide. Trouvé en câblant l'écran, et c'est pire que le bug d'origine : sans état d'échec, une visite incomplète restait sur « téléchargement en cours » indéfiniment. Le compteur d'avancement n'est incrémenté qu'en cas de succès, donc il n'atteignait jamais le total et l'écran ne concluait jamais. Relancer ne re-télécharge que ce qui manque. J4 — mention d'information accessible depuis l'assistant lui-même, là où la collecte a lieu (§8.3 des CGU). Texte repris mot pour mot de DOCS/mention-information-visiteurs.md. FR/NL/EN seulement, repli sur l'anglais : traduire une mention de protection des données sans relecture humaine serait pire que la servir en anglais. Les 7 autres langues attendent une traduction relue, à demander avec la relecture juridique. flutter analyze lib sans erreur, flutter build apk --debug --flavor dev vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
778 lines
29 KiB
Dart
778 lines
29 KiB
Dart
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<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(),
|
|
// §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)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |