Déclenchement proactif (lot F) — quatre branchements : la garde, le cycle de vie accroché à VoiceController, les points GPS peuplés depuis la configuration, et un réglage visiteur. ⚠️ La garde recommandée par le plan aurait coûté de l'argent. _trigger appelle le LLM d'abord et ne parle qu'ensuite via activeVoiceOrchestrator?.ttsEngine, dont le ?. avale le cas « aucun mode vocal actif ». Avec proactiveModeEnabled seul, un visiteur traversant une zone avec l'app en poche et le vocal éteint consommait des jetons Gemini facturés au client pour une phrase que personne n'entend. La garde interroge l'orchestrateur : la vraie condition n'est pas « quel matériel » mais « y a-t-il quelqu'un pour écouter ». Il n'y avait pas de troisième mode à inventer : VoiceMode.voiceOnly existe déjà et construit le même orchestrateur que le mode lunettes. Le proactif est donc un sous-réglage, pas une tuile. glassesEnabled est supprimé, ses trois usages étaient morts — dont une pastille « Lunettes / Déconnecté » jamais affichée. M3 — meterZoneGPS devient le rayon par section, la constante n'étant plus qu'un défaut. Deux pièges de format vérifiés : currentSections porte des maps JSON brutes, pas des DTO, et latitude/longitude y sont des chaînes. Voix du guide — le sélecteur Viva/Marco de manager-app écrivait dans le vide : Instance.GuideVoiceId n'était lu nulle part ici, l'app utilisant une constante de build dont le défaut était Algieba, une voix que personne n'a écoutée. Même forme que W1. Et aucun APK ne parlait avec la voix du produit : GeminiTtsEngine n'est choisi que si GEMINI_API_KEY est injectée, ce qui n'était fait nulle part, donc repli silencieux sur la voix système. Le repli reste le défaut — les tests ne coûtent alors ni jetons ni argent — mais il s'annonce dans les logs. Code mort supprimé : wake_word_service.dart, glasses_qr_scanner_service.dart et glasses_tts_service.dart, les ancêtres de Services/Glasses/. Ils portaient les 4 erreurs kElevenLabs* et l'APK se construisait quand même, personne ne les important. flutter analyze lib rend désormais zéro erreur. ⚠️ android/.../WakeWordService.kt est un homonyme bien vivant, non touché. impl/elevenlabs_tts_engine.dart est conservé comme option. flutter build apk --debug --flavor dev : vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
722 lines
26 KiB
Dart
722 lines
26 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();
|
|
final List<Widget> _bubbles = [];
|
|
bool _isLoading = false;
|
|
|
|
final SpeechToText _speech = SpeechToText();
|
|
bool _speechAvailable = false;
|
|
bool _isListening = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_assistantService = AssistantService(visitAppContext: widget.visitAppContext);
|
|
_initSpeech();
|
|
}
|
|
|
|
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() {
|
|
_speech.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _send() async {
|
|
final text = _controller.text.trim();
|
|
if (text.isEmpty || _isLoading) return;
|
|
|
|
_controller.clear();
|
|
setState(() {
|
|
_bubbles.add(_ChatBubble(text: text, isUser: true));
|
|
_isLoading = true;
|
|
});
|
|
_scrollToBottom();
|
|
|
|
try {
|
|
final response = await _assistantService.chat(
|
|
message: text,
|
|
configurationId: widget.configurationId,
|
|
);
|
|
setState(() {
|
|
_bubbles.add(_AssistantMessage(
|
|
response: response,
|
|
onNavigate: widget.onNavigateToSection,
|
|
));
|
|
});
|
|
|
|
// 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.
|
|
setState(() {
|
|
_bubbles.add(_ChatBubble(
|
|
text: "Le guide se repose pour aujourd'hui. Revenez demain.",
|
|
isUser: false,
|
|
));
|
|
});
|
|
} catch (e) {
|
|
debugPrint('AssistantChatSheet error: $e');
|
|
setState(() {
|
|
_bubbles.add(_ChatBubble(text: "Une erreur est survenue, réessayez.", isUser: false));
|
|
});
|
|
} 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
|
|
Expanded(
|
|
child: _bubbles.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: _bubbles.length,
|
|
itemBuilder: (_, i) => _bubbles[i],
|
|
),
|
|
),
|
|
// 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;
|
|
|
|
const _ChatBubble({required this.text, required this.isUser});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Align(
|
|
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: 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 void Function(String sectionId, String sectionTitle)? onNavigate;
|
|
|
|
const _AssistantMessage({required this.response, 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),
|
|
|
|
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)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |