import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:manager_api_new/api.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Screens/GuideIa/visitor_questions_tab.dart'; import 'package:provider/provider.dart'; /// Voix Gemini TTS retenues. Voix et mot de réveil forment un couple indissociable : /// les mots de réveil sont des modèles OpenWakeWord pré-entraînés, pas des chaînes saisies. const kGuideVoiceViva = 'Sulafat'; const kGuideVoiceMarco = 'Umbriel'; class GuideIaScreen extends StatefulWidget { const GuideIaScreen({super.key}); @override State createState() => _GuideIaScreenState(); } class _GuideIaScreenState extends State { final _nameController = TextEditingController(); final _personaController = TextEditingController(); final List _fallbackControllers = []; String _voiceId = kGuideVoiceViva; InstanceDTO? _instance; bool _loading = true; bool _saving = false; bool _reindexing = false; int _tab = 0; final _previewController = TextEditingController(); final List<_PreviewTurn> _previewTurns = []; bool _previewPending = false; GuideIaInsights? _insights; Map? _knowledge; /// Nul tant que le serveur n'a pas répondu : la ligne « ~N questions » ne s'affiche pas. int? _tokensPerQuestion; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } @override void dispose() { _nameController.dispose(); _personaController.dispose(); _previewController.dispose(); for (final c in _fallbackControllers) { c.dispose(); } super.dispose(); } ManagerAppContext get _managerContext => Provider.of(context, listen: false).getContext() as ManagerAppContext; /// Langue dans laquelle le gestionnaire saisit ses messages de repli. /// Les autres langues sont produites par la traduction automatique, comme le reste du contenu. String get _editingLanguage => 'FR'; Future _load() async { final ctx = _managerContext; final instanceId = ctx.instanceId; if (instanceId == null || ctx.clientAPI == null) { setState(() => _loading = false); return; } try { final instance = await ctx.clientAPI!.instanceApi!.instanceGetDetail(instanceId); if (!mounted) return; _nameController.text = instance?.guideName ?? ''; _personaController.text = instance?.guidePersonaPrompt ?? ''; _voiceId = instance?.guideVoiceId ?? kGuideVoiceViva; final ownLanguage = (instance?.guideFallbackMessages ?? []) .where((m) => m.language == _editingLanguage && (m.value ?? '').isNotEmpty); for (final m in ownLanguage) { _fallbackControllers.add(TextEditingController(text: m.value)); } if (_fallbackControllers.isEmpty) { _fallbackControllers.add(TextEditingController()); } setState(() { _instance = instance; _loading = false; }); _loadKnowledge(); _loadInsights(); _loadTokensPerQuestion(); } catch (_) { if (mounted) setState(() => _loading = false); } } /// Appel http direct, comme la relance d'indexation plus bas : un agrégat en lecture /// seule ne justifie pas d'étendre `manager_api_new`, qui s'édite à la main. /// Un échec est silencieux — la carte disparaît, elle n'affiche pas un chiffre faux. Future _loadKnowledge() async { final ctx = _managerContext; try { final response = await http.get( Uri.parse('${ctx.host}/api/Ai/knowledge/${ctx.instanceId}'), headers: {'Authorization': 'Bearer ${ctx.accessToken}'}, ); if (!mounted || response.statusCode != 200) return; setState(() => _knowledge = jsonDecode(response.body) as Map); } catch (_) { // La carte reste masquée. } } /// Même raison que `_loadKnowledge` pour l'appel direct. Un échec laisse `_insights` /// nul, donc l'état vide — jamais un onglet à moitié rempli qui laisserait croire /// que les visiteurs n'ont rien demandé. Future _loadInsights() async { final ctx = _managerContext; try { final response = await http.get( Uri.parse('${ctx.host}/api/Ai/insights/${ctx.instanceId}'), headers: {'Authorization': 'Bearer ${ctx.accessToken}'}, ); if (!mounted || response.statusCode != 200) return; setState(() => _insights = GuideIaInsights.fromJson(jsonDecode(response.body) as Map)); } catch (_) { // L'onglet reste sur son état vide. } } /// Même appel direct que les deux ci-dessus. On ne lit qu'une chose : combien de jetons /// coûte une question **chez ce client**, mesuré par le serveur sur ses propres questions. /// /// ⚠️ Un échec laisse `_tokensPerQuestion` nul et la ligne « ~N questions » disparaît. /// C'est délibéré : le diviseur codé en dur qui vivait ici valait 1 000, alors que la grille /// vend 20 M de jetons pour ~2 000 questions — soit 10 000. Le gestionnaire lisait un crédit /// **dix fois trop généreux**. Pas de chiffre vaut mieux qu'un chiffre faux sur une jauge /// qui sert à décider d'un achat. Future _loadTokensPerQuestion() async { final ctx = _managerContext; try { final response = await http.get( Uri.parse('${ctx.host}/api/Instance/${ctx.instanceId}/quota'), headers: {'Authorization': 'Bearer ${ctx.accessToken}'}, ); if (!mounted || response.statusCode != 200) return; final decoded = jsonDecode(response.body) as Map; final value = (decoded['aiTokensPerQuestion'] as num?)?.toInt() ?? 0; if (value > 0) setState(() => _tokensPerQuestion = value); } catch (_) { // La ligne « ~N questions » reste masquée. } } Future _save() async { final instance = _instance; if (instance == null) return; final l = AppLocalizations.of(context)!; setState(() => _saving = true); // Les messages des autres langues sont préservés tels quels : on ne réécrit que la langue d'édition. final otherLanguages = (instance.guideFallbackMessages) .where((m) => m.language != _editingLanguage) .toList(); final edited = _fallbackControllers .map((c) => c.text.trim()) .where((t) => t.isNotEmpty) .map((t) => TranslationDTO(language: _editingLanguage, value: t)) .toList(); instance.guideName = _nameController.text.trim(); instance.guidePersonaPrompt = _personaController.text.trim(); instance.guideVoiceId = _voiceId; instance.guideFallbackMessages = [...otherLanguages, ...edited]; try { await _managerContext.clientAPI!.instanceApi!.instanceUpdateinstance(instance); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.guideIaSaved), backgroundColor: kPrimaryColor), ); } catch (_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.guideIaSaveError), backgroundColor: Colors.redAccent), ); } finally { if (mounted) setState(() => _saving = false); } } /// Appel direct plutôt que via le client généré : l'endpoint est réservé au support, /// et `manager_api_new` s'édite à la main — on ne l'alourdit pas pour un bouton interne. /// Textes en français en dur, pour la même raison : aucun client ne voit cet écran. Future _reindex() async { final ctx = _managerContext; final instanceId = ctx.instanceId; if (instanceId == null) return; final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Réindexer tout le contenu ?'), content: const Text( 'Chaque section sera revectorisée, ce qui consomme du budget d\'embedding.\n\n' 'Inutile si le guide répond mal : neuf fois sur dix le contenu est trop maigre, ' 'pas l\'index périmé. À réserver aux cas où l\'index est réellement incomplet.', ), actions: [ TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Annuler')), TextButton(onPressed: () => Navigator.pop(dialogContext, true), child: const Text('Réindexer')), ], ), ); if (confirmed != true) return; setState(() => _reindexing = true); try { final response = await http.post( Uri.parse('${ctx.host}/api/Ai/reindex/$instanceId'), headers: {'Authorization': 'Bearer ${ctx.accessToken}'}, ); if (!mounted) return; if (response.statusCode == 202) { final sections = (jsonDecode(response.body)['sectionsQueued'] as num?)?.toInt() ?? 0; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Réindexation lancée — $sections section(s) en file. ' 'Le détail par section part dans les logs.'), backgroundColor: kPrimaryColor, )); } else { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Échec (${response.statusCode}) : ${response.body}'), backgroundColor: Colors.redAccent, )); } } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Échec de la réindexation : $e'), backgroundColor: Colors.redAccent), ); } finally { if (mounted) setState(() => _reindexing = false); } } @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; if (_loading) return const Center(child: CircularProgressIndicator()); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _header(l), const SizedBox(height: kSpace5), _tabs(l), const SizedBox(height: kSpace7), Expanded( child: _tab == 0 ? _configurationTab(l) : VisitorQuestionsTab(insights: _insights), ), ], ); } /// Deux onglets, pas deux écrans : la configuration du guide et ce que les visiteurs /// lui demandent sont les deux faces d'un même objet. Les séparer obligerait le client /// à faire le lien lui-même entre une question sans réponse et le contenu à écrire. Widget _tabs(AppLocalizations l) { return Container( decoration: BoxDecoration(border: Border(bottom: BorderSide(color: kLine))), child: Row( children: [ _tabButton(l.guideIaTabConfig, 0), _tabButton(l.guideIaTabQuestions, 1), ], ), ); } Widget _tabButton(String label, int index) { final selected = _tab == index; return InkWell( onTap: () => setState(() => _tab = index), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: selected ? kBrand : Colors.transparent, width: 2, ), ), ), child: Text( label, style: TextStyle( fontSize: 14, color: selected ? kInk : kInk3, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), ); } Widget _configurationTab(AppLocalizations l) { final wide = MediaQuery.of(context).size.width > 1100; final left = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _usageCard(l), const SizedBox(height: 16), _channelsCard(l), const SizedBox(height: 16), _identityCard(l), ], ); final right = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _previewCard(l), const SizedBox(height: 16), _voiceCard(l), if (_knowledge != null) ...[ const SizedBox(height: 16), _knowledgeCard(l), ], if (_managerContext.role == UserRole.SuperAdmin) ...[ const SizedBox(height: 16), _reindexCard(), ], ], ); return SingleChildScrollView( child: wide ? Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: left), const SizedBox(width: 16), Expanded(child: right), ], ) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [left, const SizedBox(height: 16), right], ), ); } Widget _header(AppLocalizations l) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l.menuGuideIa, style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600, color: kPrimaryColor)), const SizedBox(height: 4), Text(l.guideIaSubtitle, style: TextStyle(fontSize: 14, color: kBodyTextColor)), ], ), ), const SizedBox(width: 16), FilledButton( style: FilledButton.styleFrom(backgroundColor: kPrimaryColor), onPressed: _saving ? null : _save, child: _saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: kWhite)) : Text(l.guideIaSave), ), ], ); } Widget _card({required String title, String? subtitle, required Widget child}) { return Card( elevation: 0, color: kWhite, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kPrimaryColor)), if (subtitle != null) ...[ const SizedBox(height: 3), Text(subtitle, style: TextStyle(fontSize: 13, color: kBodyTextColor.withValues(alpha: 0.75))), ], const SizedBox(height: 16), child, ], ), ), ); } /// Visible pour le SuperAdmin seulement — c'est un outil de réparation, pas une /// fonctionnalité. Exposé au client, il serait cliqué à chaque réponse décevante du guide, /// pour un coût d'embedding complet à chaque fois et sans rien améliorer. Widget _reindexCard() { return _card( title: 'Réindexer le contenu (support)', subtitle: 'Visible par le support Unov uniquement.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Le rattrapage est automatique au passage à un plan avec IA. Ce bouton ne sert ' 'que si l\'index est resté incomplet — un job d\'indexation en échec, par exemple.', style: TextStyle(fontSize: 12, color: kBodyTextColor.withValues(alpha: 0.7)), ), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _reindexing ? null : _reindex, icon: _reindexing ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.refresh), label: Text(_reindexing ? 'Lancement…' : 'Réindexer tout le contenu'), ), ], ), ); } Widget _usageCard(AppLocalizations l) { final used = _instance?.aiTokensThisMonth ?? 0; final quota = _instance?.aiTokensPerMonth ?? 0; // Le gestionnaire raisonne en questions, pas en jetons de traitement. Le diviseur vient // du serveur (`aiTokensPerQuestion`), qui le mesure sur les questions réellement posées // par cette instance — il n'est pas codé ici. final tokensPerQuestion = _tokensPerQuestion; final questions = tokensPerQuestion != null ? (used / tokensPerQuestion).round() : null; final ratio = quota > 0 ? (used / quota).clamp(0.0, 1.0) : 0.0; return _card( title: l.guideIaUsageTitle, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (quota > 0) ...[ Text('${(ratio * 100).round()} %', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: kPrimaryColor)), const SizedBox(height: 10), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: ratio.toDouble(), minHeight: 8, backgroundColor: kSecond.withValues(alpha: 0.4), color: kPrimaryColor, ), ), const SizedBox(height: 10), if (questions != null) Text(l.guideIaUsageQuestions(questions), style: TextStyle(fontSize: 13, color: kBodyTextColor)), ] else Text(l.guideIaUsageNoQuota, style: TextStyle(fontSize: 13, color: kBodyTextColor)), ], ), ); } Widget _channelsCard(AppLocalizations l) { final apps = _instance?.applicationInstanceDTOs ?? []; return _card( title: l.guideIaChannelsTitle, subtitle: l.guideIaChannelsSub, child: Column( children: apps.map((app) { final on = app.isAssistant == true; return Padding( padding: const EdgeInsets.symmetric(vertical: 7), child: Row( children: [ Icon(on ? Icons.check_circle : Icons.remove_circle_outline, size: 18, color: on ? kPrimaryColor : kSecond), const SizedBox(width: 10), Expanded( child: Text((app.appType?.value ?? '—').toString(), style: const TextStyle(fontSize: 14))), Text(on ? l.guideIaChannelOn : l.guideIaChannelOff, style: TextStyle(fontSize: 12.5, color: kBodyTextColor.withValues(alpha: 0.7))), ], ), ); }).toList(), ), ); } Widget _identityCard(AppLocalizations l) { return _card( title: l.guideIaIdentityTitle, subtitle: l.guideIaIdentitySub, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _nameController, decoration: InputDecoration( labelText: l.guideIaNameLabel, helperText: l.guideIaNameHint, border: const OutlineInputBorder(), ), ), const SizedBox(height: 20), TextField( controller: _personaController, maxLines: 5, decoration: InputDecoration( labelText: l.guideIaPersonaLabel, alignLabelWithHint: true, border: const OutlineInputBorder(), ), ), const SizedBox(height: 8), Text(l.guideIaPersonaHint, style: TextStyle(fontSize: 12, color: kBodyTextColor.withValues(alpha: 0.7))), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ _exampleChip(l.guideIaExampleWarden, l.guideIaExampleWardenText), _exampleChip(l.guideIaExampleSober, l.guideIaExampleSoberText), _exampleChip(l.guideIaExampleKids, l.guideIaExampleKidsText), ], ), const SizedBox(height: 24), Text(l.guideIaFallbackLabel, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: kBodyTextColor)), const SizedBox(height: 8), ...List.generate(_fallbackControllers.length, (i) => _fallbackRow(i)), const SizedBox(height: 4), TextButton.icon( onPressed: () => setState(() => _fallbackControllers.add(TextEditingController())), icon: const Icon(Icons.add, size: 18), label: Text(l.guideIaFallbackAdd), style: TextButton.styleFrom(foregroundColor: kPrimaryColor), ), const SizedBox(height: 4), Text(l.guideIaFallbackHint, style: TextStyle(fontSize: 12, color: kBodyTextColor.withValues(alpha: 0.7))), const SizedBox(height: 10), _info(l.guideIaFallbackTranslated), ], ), ); } Widget _fallbackRow(int index) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ Expanded( child: TextField( controller: _fallbackControllers[index], decoration: const InputDecoration( isDense: true, border: OutlineInputBorder(), ), ), ), IconButton( onPressed: _fallbackControllers.length <= 1 ? null : () => setState(() => _fallbackControllers.removeAt(index).dispose()), icon: const Icon(Icons.close, size: 18), color: kBodyTextColor, ), ], ), ); } Widget _exampleChip(String label, String text) { return ActionChip( label: Text(label, style: const TextStyle(fontSize: 12.5)), onPressed: () => setState(() => _personaController.text = text), side: BorderSide(color: kSecond), backgroundColor: kWhite, ); } /// Interroge le guide réellement publié, pas une simulation : c'est le seul moyen de /// vérifier que la personnalité saisie produit le ton attendu. D'où l'avertissement /// quand des modifications ne sont pas encore enregistrées — sinon le client teste /// l'ancienne configuration en croyant tester la nouvelle. Future _sendPreview() async { final message = _previewController.text.trim(); if (message.isEmpty || _previewPending) return; final ctx = _managerContext; setState(() { _previewTurns.add(_PreviewTurn(message, null)); _previewController.clear(); _previewPending = true; }); try { final response = await ctx.clientAPI!.aiApi!.aiChat(AiChatRequest( message: message, instanceId: ctx.instanceId, appType: AppType.Web, language: _editingLanguage, // ⚠️ C'est le gestionnaire qui teste sa personnalité, pas un visiteur qui pose une // question : sans ce drapeau, chaque essai atterrissait dans son propre onglet // « Ce que demandent vos visiteurs » et gonflait le bloc des questions sans réponse. isVisitorQuestion: false, )); if (!mounted) return; setState(() { _previewTurns.last = _PreviewTurn(message, response?.reply ?? ''); _previewPending = false; }); } catch (_) { if (!mounted) return; setState(() { _previewTurns.last = _PreviewTurn(message, null); _previewPending = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.guideIaPreviewError), backgroundColor: Colors.redAccent, ), ); } } Widget _previewCard(AppLocalizations l) { return _card( title: l.guideIaPreviewTitle, subtitle: l.guideIaPreviewSub, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: kSurface2, border: Border.all(color: kLine), borderRadius: BorderRadius.circular(7), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_previewTurns.isEmpty) Text(l.guideIaPreviewUnsaved, style: kTextHint) else for (final turn in _previewTurns) ..._previewBubbles(turn), if (_previewPending) ...[ const SizedBox(height: kSpace3), const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ], ], ), ), const SizedBox(height: kSpace4), Row( children: [ Expanded( child: TextField( controller: _previewController, onSubmitted: (_) => _sendPreview(), decoration: InputDecoration( isDense: true, hintText: l.guideIaPreviewPlaceholder, border: const OutlineInputBorder(), ), ), ), const SizedBox(width: kSpace3), IconButton( onPressed: _previewPending ? null : _sendPreview, icon: const Icon(Icons.send), color: kBrand, ), ], ), const SizedBox(height: kSpace4), _info(l.guideIaPreviewSourcesInfo), ], ), ); } List _previewBubbles(_PreviewTurn turn) { return [ Align( alignment: Alignment.centerRight, child: Container( margin: const EdgeInsets.only(bottom: 9), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), constraints: const BoxConstraints(maxWidth: 340), decoration: BoxDecoration( color: kBrand, borderRadius: const BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(12), bottomLeft: Radius.circular(12), bottomRight: Radius.circular(4), ), ), child: Text(turn.question, style: kTextBody.copyWith(color: kOnBrand)), ), ), if (turn.answer != null) Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 9), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), constraints: const BoxConstraints(maxWidth: 340), decoration: BoxDecoration( color: kSurface, border: Border.all(color: kLine), borderRadius: const BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(12), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(12), ), ), child: Text(turn.answer!, style: kTextBody), ), ), ]; } /// Mesuré sur l'index vectoriel, pas sur les tables de contenu : une section /// désactivée est purgée de l'index, une section sans texte exploitable n'y entre pas. /// Compter les sections publiées donnerait un chiffre plus flatteur et faux. Widget _knowledgeCard(AppLocalizations l) { final k = _knowledge!; final languages = (k['languages'] as List?)?.cast() ?? const []; final lastIndexed = DateTime.tryParse((k['lastIndexedAt'] ?? '').toString()); return _card( title: l.guideIaKnowledgeTitle, subtitle: l.guideIaKnowledgeSub, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _knowledgeRow(l.guideIaKnowledgeSections, '${k['indexedSections'] ?? 0}', first: true), _knowledgeRow(l.guideIaKnowledgeChunks, '${k['chunks'] ?? 0}'), _knowledgeRow( l.guideIaKnowledgeLanguages, languages.isEmpty ? '—' : languages.join(' · '), ), _knowledgeRow( l.guideIaKnowledgeLastIndexed, lastIndexed == null ? '—' : _relativeTime(l, lastIndexed), ), const SizedBox(height: kSpace3), Text(l.guideIaKnowledgeHint, style: kTextHint), ], ), ); } Widget _knowledgeRow(String label, String value, {bool first = false}) { return Container( padding: const EdgeInsets.symmetric(vertical: 7), decoration: BoxDecoration( border: first ? null : Border(top: BorderSide(color: kLineSoft)), ), child: Row( children: [ Expanded(child: Text(label, style: kTextBody)), const SizedBox(width: kSpace4), Text(value, style: kTextBody.copyWith( fontFamily: kMonoFamily, fontWeight: FontWeight.w600)), ], ), ); } String _relativeTime(AppLocalizations l, DateTime utc) { final minutes = DateTime.now().toUtc().difference(utc).inMinutes; if (minutes < 60) return l.guideIaKnowledgeMinutesAgo(minutes < 1 ? 1 : minutes); if (minutes < 60 * 24) return l.guideIaKnowledgeHoursAgo(minutes ~/ 60); return l.guideIaKnowledgeDaysAgo(minutes ~/ (60 * 24)); } Widget _voiceCard(AppLocalizations l) { final wakeword = _voiceId == kGuideVoiceMarco ? 'Marco' : 'Viva'; return _card( title: l.guideIaVoiceTitle, subtitle: l.guideIaVoiceSub, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded(child: _voiceOption('Viva', l.guideIaVoiceFemale, kGuideVoiceViva, l)), const SizedBox(width: 10), Expanded(child: _voiceOption('Marco', l.guideIaVoiceMale, kGuideVoiceMarco, l)), ], ), const SizedBox(height: 14), _info(l.guideIaVoiceInfoName(wakeword)), const SizedBox(height: 8), _info(l.guideIaVoiceInfoGlasses), const SizedBox(height: 8), _info(l.guideIaVoiceInfoMultilang), ], ), ); } Widget _voiceOption(String name, String description, String voiceId, AppLocalizations l) { final selected = _voiceId == voiceId; return InkWell( onTap: () => setState(() => _voiceId = voiceId), borderRadius: BorderRadius.circular(8), child: Container( padding: const EdgeInsets.all(13), decoration: BoxDecoration( border: Border.all(color: selected ? kPrimaryColor : kSecond, width: selected ? 2 : 1), borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kPrimaryColor)), const SizedBox(height: 2), Text(description, style: TextStyle(fontSize: 12.5, color: kBodyTextColor)), const SizedBox(height: 8), Text(l.guideIaVoiceWakeword(name), style: TextStyle(fontSize: 11.5, color: kBodyTextColor.withValues(alpha: 0.7))), ], ), ), ); } Widget _info(String text) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: kBackgroundColor, borderRadius: BorderRadius.circular(6), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.info_outline, size: 16, color: kPrimaryColor), const SizedBox(width: 9), Expanded(child: Text(text, style: TextStyle(fontSize: 12.5, color: kBodyTextColor))), ], ), ); } } class _PreviewTurn { final String question; final String? answer; const _PreviewTurn(this.question, this.answer); }