La fonction était recopiée dans VoiceOrchestrator, AssistantChatSheet et GeoBeaconTriggerService. La limite « FR/NL/EN/DE seulement, le reste retombe sur le français » est une décision assumée, mais elle n'était documentée que dans la première : les deux autres ressemblaient à un oubli qu'on aurait envie de « corriger » en ajoutant des langues, ce qui aurait produit un support partiel silencieux — les listes de commandes vocales, elles, ne couvrent que ces quatre langues. Une seule copie dans Helpers/voiceLanguage.dart, une seule décision. flutter analyze lib sans erreur, flutter build apk --debug --flavor dev vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
772 lines
29 KiB
Dart
772 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/voiceLanguage.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();
|
|
}
|
|
}
|
|
|
|
/// Voir `Helpers/voiceLanguage.dart` : limite assumée à FR/NL/EN/DE, pas un oubli.
|
|
String _toLangCode(String lang) => toVoiceLangCode(lang);
|
|
|
|
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)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |