diff --git a/lib/Screens/GuideIa/guide_ia_screen.dart b/lib/Screens/GuideIa/guide_ia_screen.dart index 26ba85c..034ecc4 100644 --- a/lib/Screens/GuideIa/guide_ia_screen.dart +++ b/lib/Screens/GuideIa/guide_ia_screen.dart @@ -1,9 +1,13 @@ +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 : @@ -27,6 +31,17 @@ class _GuideIaScreenState extends State { 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; @override void initState() { @@ -38,6 +53,7 @@ class _GuideIaScreenState extends State { void dispose() { _nameController.dispose(); _personaController.dispose(); + _previewController.dispose(); for (final c in _fallbackControllers) { c.dispose(); } @@ -80,11 +96,48 @@ class _GuideIaScreenState extends State { _instance = instance; _loading = false; }); + _loadKnowledge(); + _loadInsights(); } 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. + } + } + Future _save() async { final instance = _instance; if (instance == null) return; @@ -123,11 +176,125 @@ class _GuideIaScreenState extends State { } } + /// 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( @@ -136,25 +303,30 @@ class _GuideIaScreenState extends State { _usageCard(l), const SizedBox(height: 16), _channelsCard(l), + const SizedBox(height: 16), + _identityCard(l), ], ); final right = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _identityCard(l), + _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: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _header(l), - const SizedBox(height: 20), - if (wide) - Row( + child: wide + ? Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: left), @@ -162,13 +334,10 @@ class _GuideIaScreenState extends State { Expanded(child: right), ], ) - else ...[ - left, - const SizedBox(height: 16), - right, - ], - ], - ), + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [left, const SizedBox(height: 16), right], + ), ); } @@ -224,6 +393,34 @@ class _GuideIaScreenState extends State { ); } + /// 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; @@ -380,6 +577,208 @@ class _GuideIaScreenState extends State { ); } + /// 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, + )); + 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'; @@ -451,3 +850,10 @@ class _GuideIaScreenState extends State { ); } } + +class _PreviewTurn { + final String question; + final String? answer; + + const _PreviewTurn(this.question, this.answer); +} diff --git a/lib/Screens/GuideIa/visitor_questions_tab.dart b/lib/Screens/GuideIa/visitor_questions_tab.dart new file mode 100644 index 0000000..4085284 --- /dev/null +++ b/lib/Screens/GuideIa/visitor_questions_tab.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:manager_app/constants.dart'; +import 'package:manager_app/l10n/app_localizations.dart'; + +/// Forme des données attendue par l'onglet « Ce que demandent vos visiteurs ». +/// +/// Écrite avant le backend, volontairement : c'est l'affichage qui décide de ce que +/// le job de regroupement doit produire, pas l'inverse. L'endpoint à écrire côté +/// manager-service doit rendre exactement cette structure, alimentée par la table +/// `VisitorQuestion` (déjà en base) et son job de regroupement en thèmes. +class GuideIaInsights { + final int questions; + final int unanswered; + final int themes; + final int languages; + final List unansweredQuestions; + final List topics; + final List questionLanguages; + final List citedContents; + + const GuideIaInsights({ + required this.questions, + required this.unanswered, + required this.themes, + required this.languages, + required this.unansweredQuestions, + required this.topics, + required this.questionLanguages, + required this.citedContents, + }); + + factory GuideIaInsights.fromJson(Map json) => GuideIaInsights( + questions: (json['questions'] as num?)?.toInt() ?? 0, + unanswered: (json['unanswered'] as num?)?.toInt() ?? 0, + themes: (json['themes'] as num?)?.toInt() ?? 0, + languages: (json['languages'] as num?)?.toInt() ?? 0, + unansweredQuestions: CountedLabel.listFrom(json['unansweredQuestions']), + topics: CountedLabel.listFrom(json['topics']), + questionLanguages: CountedLabel.listFrom(json['questionLanguages']), + citedContents: CountedLabel.listFrom(json['citedContents']), + ); +} + +class CountedLabel { + final String label; + final int count; + + const CountedLabel(this.label, this.count); + + static List listFrom(dynamic raw) => (raw as List?) + ?.map((e) => CountedLabel( + (e['label'] ?? '').toString(), + (e['count'] as num?)?.toInt() ?? 0, + )) + .toList() ?? + const []; +} + +class VisitorQuestionsTab extends StatelessWidget { + final GuideIaInsights? insights; + + const VisitorQuestionsTab({super.key, required this.insights}); + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final data = insights; + if (data == null || data.questions == 0) return _empty(l); + + final wide = MediaQuery.of(context).size.width > 1100; + + // Une carte sans données n'est pas une carte vide à afficher : `topics` reste vide + // tant que le job de regroupement en thèmes n'a pas tourné, et un cadre avec un titre + // et rien dedans se lit comme une panne. + final columns = [ + if (data.topics.isNotEmpty) + _barsCard(l.guideIaTopicsTitle, l.guideIaInsightsSubCount, data.topics), + if (data.questionLanguages.isNotEmpty) + _barsCard(l.guideIaQuestionLanguagesTitle, l.guideIaInsightsSubCount, data.questionLanguages), + if (data.citedContents.isNotEmpty) + _barsCard(l.guideIaCitedTitle, l.guideIaCitedSub, data.citedContents), + ]; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _tiles(l, data), + const SizedBox(height: kSpace6), + if (data.unansweredQuestions.isNotEmpty) ...[ + _gapCard(l, data), + const SizedBox(height: kSpace6), + ], + if (wide) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final card in columns) ...[ + Expanded(child: card), + if (card != columns.last) const SizedBox(width: kSpace6), + ], + ], + ) + else + for (final card in columns) ...[ + card, + const SizedBox(height: kSpace6), + ], + const SizedBox(height: kSpace6), + _privacyNote(l), + ], + ), + ); + } + + Widget _empty(AppLocalizations l) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 64, horizontal: kSpace7), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.forum_outlined, size: 40, color: kInk3), + const SizedBox(height: kSpace4), + Text(l.guideIaInsightsEmpty, style: kTitleCard, textAlign: TextAlign.center), + const SizedBox(height: kSpace2), + SizedBox( + width: 460, + child: Text(l.guideIaInsightsEmptyHint, style: kSubtitleCard, textAlign: TextAlign.center), + ), + ], + ), + ), + ); + } + + Widget _tiles(AppLocalizations l, GuideIaInsights data) { + final tiles = [ + _Tile(l.guideIaKpiQuestions, data.questions.toString(), false), + _Tile(l.guideIaKpiUnanswered, data.unanswered.toString(), true), + _Tile(l.guideIaKpiThemes, data.themes.toString(), false), + _Tile(l.guideIaKpiLanguages, data.languages.toString(), false), + ]; + + return Container( + decoration: BoxDecoration( + border: Border.all(color: kLine), + borderRadius: BorderRadius.circular(kRadiusCard), + color: kSurface, + ), + child: Row( + children: [ + for (final tile in tiles) + Expanded( + child: Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 15), + decoration: BoxDecoration( + border: tile == tiles.first ? null : Border(left: BorderSide(color: kLine)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tile.value, + style: kMetricMedium.copyWith(color: tile.warn ? kWarning : kInk)), + const SizedBox(height: kSpace1), + Text(tile.label.toUpperCase(), style: kOverline), + ], + ), + ), + ), + ], + ), + ); + } + + /// Bordure ambre : c'est le seul bloc de l'écran qui appelle une action du client. + /// L'ambre n'est jamais réutilisé comme couleur de série dans les graphes. + Widget _gapCard(AppLocalizations l, GuideIaInsights data) { + return _shell( + borderColor: kWarning, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration(color: kWarning, shape: BoxShape.circle), + ), + const SizedBox(width: kSpace3), + Expanded(child: Text(l.guideIaGapTitle, style: kTitleCard)), + ], + ), + const SizedBox(height: kSpace1), + Text(l.guideIaGapSub(data.unanswered), style: kSubtitleCard), + const SizedBox(height: kSpace4), + for (final q in data.unansweredQuestions) + Container( + padding: const EdgeInsets.symmetric(vertical: 9), + decoration: BoxDecoration( + border: q == data.unansweredQuestions.first + ? null + : Border(top: BorderSide(color: kLineSoft)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Text(q.label, style: kTextBody)), + const SizedBox(width: kSpace4), + Text('${q.count} ×', + style: kTextSmall.copyWith( + fontFamily: kMonoFamily, color: kWarning)), + ], + ), + ), + ], + ), + ); + } + + /// Une seule mesure par carte, donc une seule teinte : c'est la longueur qui + /// porte l'information. Colorer chaque barre suggérerait une distinction qui n'existe pas. + Widget _barsCard(String title, String subtitle, List rows) { + final max = rows.isEmpty ? 1 : rows.map((r) => r.count).reduce((a, b) => a > b ? a : b); + + return _shell( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: kTitleCard), + const SizedBox(height: kSpace1), + Text(subtitle, style: kSubtitleCard), + const SizedBox(height: kSpace5), + for (final row in rows) + Padding( + padding: const EdgeInsets.only(bottom: 9), + child: Row( + children: [ + SizedBox( + width: 132, + child: Text(row.label, style: kTextSmall, overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 11), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(kRadiusTrack), + child: LinearProgressIndicator( + value: max == 0 ? 0 : row.count / max, + minHeight: 9, + backgroundColor: kSurface3, + color: kBrand, + ), + ), + ), + const SizedBox(width: 11), + SizedBox( + width: 46, + child: Text(row.count.toString(), + textAlign: TextAlign.right, + style: kTextSmall.copyWith(fontFamily: kMonoFamily)), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _privacyNote(AppLocalizations l) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: kSurface2, + border: Border.all(color: kLineSoft), + borderRadius: BorderRadius.circular(kRadiusInput), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline, size: 16, color: kBrand), + const SizedBox(width: 9), + Expanded(child: Text(l.guideIaInsightsPrivacy, style: kSubtitleCard.copyWith(color: kInk2))), + ], + ), + ); + } + + Widget _shell({required Widget child, Color? borderColor}) { + return Container( + padding: kCardPadding, + decoration: BoxDecoration( + color: kSurface, + border: Border.all(color: borderColor ?? kLine), + borderRadius: BorderRadius.circular(kRadiusCard), + ), + child: child, + ); + } +} + +class _Tile { + final String label; + final String value; + final bool warn; + + const _Tile(this.label, this.value, this.warn); +} diff --git a/lib/Screens/Resources/resources_screen.dart b/lib/Screens/Resources/resources_screen.dart index 21ee6ab..3d3e8cc 100644 --- a/lib/Screens/Resources/resources_screen.dart +++ b/lib/Screens/Resources/resources_screen.dart @@ -207,6 +207,7 @@ Future?> create(ResourceDTO resourceDTO, List? files, L print("Trying to create resource"); // ADD LOADING resourceDTO.dateCreation = DateTime.now(); + resourceDTO.sizeBytes = platformFile.size; ResourceDTO? newResource = await managerAppContext.clientAPI!.resourceApi!.resourceCreate(resourceDTO); print("created resource"); print(newResource); diff --git a/lib/client.dart b/lib/client.dart index 8b2fce1..c40d36c 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -59,6 +59,9 @@ class Client { OnboardingApi? _onboardingApi; OnboardingApi? get onboardingApi => _onboardingApi; + AIApi? _aiApi; + AIApi? get aiApi => _aiApi; + Client(String path) { _apiClient = ApiClient(basePath: path); //basePath: "https://192.168.31.140"); @@ -82,5 +85,6 @@ class Client { _notificationApi = NotificationApi(_apiClient); _subscriptionPlanApi = SubscriptionPlanApi(_apiClient); _onboardingApi = OnboardingApi(_apiClient); + _aiApi = AIApi(_apiClient); } } diff --git a/lib/constants.dart b/lib/constants.dart index 4cca4f8..0428292 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -44,6 +44,122 @@ const kSectionLabelStyle = TextStyle( color: kTitleTextColor, ); +// --------------------------------------------------------------------------- +// Socle visuel — l'échelle typographique et les espacements viennent du CSS des +// maquettes validées (DOCS/claude design/guide-ia-screen.html et +// statistics-screen.html, tokens identiques dans les deux). +// +// Les couleurs, elles, restent celles de l'app : un écran neuf doit se fondre +// dans manager-app, pas y ouvrir une seconde palette. Seules les valeurs sans +// équivalent existant sont reprises des maquettes — les remplissages discrets, +// les bordures, le gris atténué et l'ambre d'avertissement. +// Pas de thème sombre : l'app n'en a pas. +// --------------------------------------------------------------------------- + +const kGround = kBackgroundColor; +const kSurface = kWhite; +const kSurface2 = Color(0xFFF3F5F8); +const kSurface3 = Color(0xFFE7EBF1); + +const kInk = kTitleTextColor; +const kInk2 = kBodyTextColor; +const kInk3 = Color(0xFF7C8B9A); + +const kLine = Color(0xFFD6DDE5); +const kLineSoft = Color(0xFFE6EBF1); + +const kBrand = kPrimaryColor; +const kBrand2 = Color(0xFF3C6C90); +const kBrandSoft = kSecond; +const kOnBrand = kWhite; + +const kWarning = Color(0xFF9A6608); +const kSerious = kError; +const kGood = kSuccess; + +const kMonoFamily = 'monospace'; + +const kSpace1 = 4.0; +const kSpace2 = 6.0; +const kSpace3 = 8.0; +const kSpace4 = 12.0; +const kSpace5 = 16.0; +const kSpace6 = 18.0; +const kSpace7 = 24.0; +const kSpace8 = 32.0; + +const kRadiusInput = 5.0; +const kRadiusCard = 8.0; +const kRadiusShell = 10.0; +const kRadiusTrack = 4.0; +const kRadiusPill = 999.0; + +const kCardPadding = EdgeInsets.fromLTRB(18, 17, 18, 19); +const kPagePadding = EdgeInsets.fromLTRB(28, 24, 28, 30); + +const kCardShadow = [kDefaultShadow]; + +const kTitleScreen = TextStyle( + fontSize: 23, + fontWeight: FontWeight.w700, + letterSpacing: -0.41, + color: kInk, +); +const kTitleCard = TextStyle( + fontSize: 14.5, + fontWeight: FontWeight.w700, + color: kInk, +); +const kSubtitleCard = TextStyle( + fontSize: 12.5, + color: kInk3, +); +const kTextBody = TextStyle( + fontSize: 13.5, + color: kInk, +); +const kTextSmall = TextStyle( + fontSize: 13, + color: kInk2, +); +const kLabelField = TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: kInk2, +); +const kTextHint = TextStyle( + fontSize: 12, + color: kInk3, +); +const kOverline = TextStyle( + fontSize: 11.5, + letterSpacing: 0.69, + color: kInk3, +); +const kOverlineMono = TextStyle( + fontFamily: kMonoFamily, + fontSize: 10.5, + letterSpacing: 0.95, + color: kInk3, +); +const kMetricLarge = TextStyle( + fontFamily: kMonoFamily, + fontSize: 29, + fontWeight: FontWeight.w600, + letterSpacing: -0.73, + height: 1, + color: kInk, + fontFeatures: [FontFeature.tabularFigures()], +); +const kMetricMedium = TextStyle( + fontFamily: kMonoFamily, + fontSize: 26, + fontWeight: FontWeight.w600, + letterSpacing: -0.52, + color: kInk, + fontFeatures: [FontFeature.tabularFigures()], +); + const List section_types = ["Map", "Slider", "Video", "Web", "Menu", "Quiz", "Article", "PDF", "Game", "Agenda", "Weather", "Event", "Parcours"]; const List map_types = ["none", "normal", "satellite", "terrain", "hybrid"]; const List languages = ["FR", "NL", "EN", "DE", "IT", "ES", "CN", "PL", "AR", "UK"]; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 14000f0..c781437 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -88,6 +88,39 @@ "guideIaSave": "Save", "guideIaSaved": "Guide saved", "guideIaSaveError": "Saving failed. Please try again.", + "guideIaTabConfig": "Configuration", + "guideIaTabQuestions": "What your visitors ask", + "guideIaPreviewTitle": "Try your guide", + "guideIaPreviewSub": "Test the tone and the answers without reaching for a phone", + "guideIaPreviewPlaceholder": "Ask a question, the way a visitor would", + "guideIaPreviewSources": "Where does this answer come from?", + "guideIaPreviewSourcesInfo": "This source indicator only appears on this screen. Your visitors see the answer alone.", + "guideIaPreviewError": "The guide did not answer. Try again in a moment.", + "guideIaPreviewUnsaved": "Save your changes before testing them: the preview queries the guide as published.", + "guideIaKnowledgeTitle": "What your guide knows", + "guideIaKnowledgeSub": "Measured on what is actually indexed, not on what is published", + "guideIaKnowledgeSections": "Indexed sections", + "guideIaKnowledgeChunks": "Content chunks", + "guideIaKnowledgeLanguages": "Languages covered", + "guideIaKnowledgeLastIndexed": "Last indexed", + "guideIaKnowledgeHint": "An unpublished section drops out of the guide's answers within the minute.", + "guideIaKnowledgeMinutesAgo": "{count} min ago", + "guideIaKnowledgeHoursAgo": "{count} h ago", + "guideIaKnowledgeDaysAgo": "{count} d ago", + "guideIaKpiQuestions": "Questions this month", + "guideIaKpiUnanswered": "Unanswered", + "guideIaKpiThemes": "Themes", + "guideIaKpiLanguages": "Languages", + "guideIaGapTitle": "What your visitors look for and don't find", + "guideIaGapSub": "{count} questions found no answer in your content. Add it and your guide will answer from the next visit on.", + "guideIaTopicsTitle": "Question topics", + "guideIaQuestionLanguagesTitle": "Question languages", + "guideIaInsightsSubCount": "Number of questions over the period", + "guideIaCitedTitle": "Most used content", + "guideIaCitedSub": "Sections cited in answers", + "guideIaInsightsEmpty": "No questions yet", + "guideIaInsightsEmptyHint": "As soon as your visitors start asking the guide, their questions will be grouped here by topic — and the ones left unanswered will tell you what to add to your content.", + "guideIaInsightsPrivacy": "Questions are kept for 90 days, then deleted. Only the topic groupings are kept beyond that. No data identifying a visitor is recorded.", "menuNotifications": "Notifications", "menuUsers": "Users", "menuApiKeys": "API Keys", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ce8ef95..1ef366c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -88,6 +88,39 @@ "guideIaSave": "Enregistrer", "guideIaSaved": "Guide enregistré", "guideIaSaveError": "L'enregistrement a échoué. Réessayez.", + "guideIaTabConfig": "Configuration", + "guideIaTabQuestions": "Ce que demandent vos visiteurs", + "guideIaPreviewTitle": "Essayer votre guide", + "guideIaPreviewSub": "Testez le ton et les réponses sans passer par un téléphone", + "guideIaPreviewPlaceholder": "Posez une question, comme le ferait un visiteur", + "guideIaPreviewSources": "D'où vient cette réponse ?", + "guideIaPreviewSourcesInfo": "Cet indicateur de sources n'apparaît que sur cet écran. Vos visiteurs, eux, ne voient que la réponse.", + "guideIaPreviewError": "Le guide n'a pas répondu. Réessayez dans un instant.", + "guideIaPreviewUnsaved": "Enregistrez vos modifications pour les essayer : l'aperçu interroge le guide tel qu'il est publié.", + "guideIaKnowledgeTitle": "Ce que connaît votre guide", + "guideIaKnowledgeSub": "Mesuré sur ce qui est réellement indexé, pas sur ce qui est publié", + "guideIaKnowledgeSections": "Sections indexées", + "guideIaKnowledgeChunks": "Morceaux de contenu", + "guideIaKnowledgeLanguages": "Langues couvertes", + "guideIaKnowledgeLastIndexed": "Dernière indexation", + "guideIaKnowledgeHint": "Une section dépubliée disparaît des réponses du guide dans la minute.", + "guideIaKnowledgeMinutesAgo": "il y a {count} min", + "guideIaKnowledgeHoursAgo": "il y a {count} h", + "guideIaKnowledgeDaysAgo": "il y a {count} j", + "guideIaKpiQuestions": "Questions ce mois", + "guideIaKpiUnanswered": "Sans réponse", + "guideIaKpiThemes": "Thèmes", + "guideIaKpiLanguages": "Langues", + "guideIaGapTitle": "Ce que vos visiteurs cherchent sans le trouver", + "guideIaGapSub": "{count} questions n'ont trouvé aucune réponse dans vos contenus. Ajoutez-les et votre guide saura répondre dès la prochaine visite.", + "guideIaTopicsTitle": "Sujets des questions", + "guideIaQuestionLanguagesTitle": "Langues des questions", + "guideIaInsightsSubCount": "Nombre de questions sur la période", + "guideIaCitedTitle": "Contenus les plus utilisés", + "guideIaCitedSub": "Sections citées dans les réponses", + "guideIaInsightsEmpty": "Aucune question pour l'instant", + "guideIaInsightsEmptyHint": "Dès que vos visiteurs interrogeront le guide, leurs questions seront regroupées ici par sujet — et celles restées sans réponse vous diront quoi ajouter à vos contenus.", + "guideIaInsightsPrivacy": "Les questions sont conservées 90 jours puis supprimées. Seuls les regroupements par sujet sont gardés au-delà. Aucune donnée permettant d'identifier un visiteur n'est enregistrée.", "menuNotifications": "Notifications", "menuUsers": "Utilisateurs", "menuApiKeys": "Clés API", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f26ed47..1687d55 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -598,6 +598,204 @@ abstract class AppLocalizations { /// **'L\'enregistrement a échoué. Réessayez.'** String get guideIaSaveError; + /// No description provided for @guideIaTabConfig. + /// + /// In fr, this message translates to: + /// **'Configuration'** + String get guideIaTabConfig; + + /// No description provided for @guideIaTabQuestions. + /// + /// In fr, this message translates to: + /// **'Ce que demandent vos visiteurs'** + String get guideIaTabQuestions; + + /// No description provided for @guideIaPreviewTitle. + /// + /// In fr, this message translates to: + /// **'Essayer votre guide'** + String get guideIaPreviewTitle; + + /// No description provided for @guideIaPreviewSub. + /// + /// In fr, this message translates to: + /// **'Testez le ton et les réponses sans passer par un téléphone'** + String get guideIaPreviewSub; + + /// No description provided for @guideIaPreviewPlaceholder. + /// + /// In fr, this message translates to: + /// **'Posez une question, comme le ferait un visiteur'** + String get guideIaPreviewPlaceholder; + + /// No description provided for @guideIaPreviewSources. + /// + /// In fr, this message translates to: + /// **'D\'où vient cette réponse ?'** + String get guideIaPreviewSources; + + /// No description provided for @guideIaPreviewSourcesInfo. + /// + /// In fr, this message translates to: + /// **'Cet indicateur de sources n\'apparaît que sur cet écran. Vos visiteurs, eux, ne voient que la réponse.'** + String get guideIaPreviewSourcesInfo; + + /// No description provided for @guideIaPreviewError. + /// + /// In fr, this message translates to: + /// **'Le guide n\'a pas répondu. Réessayez dans un instant.'** + String get guideIaPreviewError; + + /// No description provided for @guideIaPreviewUnsaved. + /// + /// In fr, this message translates to: + /// **'Enregistrez vos modifications pour les essayer : l\'aperçu interroge le guide tel qu\'il est publié.'** + String get guideIaPreviewUnsaved; + + /// No description provided for @guideIaKnowledgeTitle. + /// + /// In fr, this message translates to: + /// **'Ce que connaît votre guide'** + String get guideIaKnowledgeTitle; + + /// No description provided for @guideIaKnowledgeSub. + /// + /// In fr, this message translates to: + /// **'Mesuré sur ce qui est réellement indexé, pas sur ce qui est publié'** + String get guideIaKnowledgeSub; + + /// No description provided for @guideIaKnowledgeSections. + /// + /// In fr, this message translates to: + /// **'Sections indexées'** + String get guideIaKnowledgeSections; + + /// No description provided for @guideIaKnowledgeChunks. + /// + /// In fr, this message translates to: + /// **'Morceaux de contenu'** + String get guideIaKnowledgeChunks; + + /// No description provided for @guideIaKnowledgeLanguages. + /// + /// In fr, this message translates to: + /// **'Langues couvertes'** + String get guideIaKnowledgeLanguages; + + /// No description provided for @guideIaKnowledgeLastIndexed. + /// + /// In fr, this message translates to: + /// **'Dernière indexation'** + String get guideIaKnowledgeLastIndexed; + + /// No description provided for @guideIaKnowledgeHint. + /// + /// In fr, this message translates to: + /// **'Une section dépubliée disparaît des réponses du guide dans la minute.'** + String get guideIaKnowledgeHint; + + /// No description provided for @guideIaKnowledgeMinutesAgo. + /// + /// In fr, this message translates to: + /// **'il y a {count} min'** + String guideIaKnowledgeMinutesAgo(Object count); + + /// No description provided for @guideIaKnowledgeHoursAgo. + /// + /// In fr, this message translates to: + /// **'il y a {count} h'** + String guideIaKnowledgeHoursAgo(Object count); + + /// No description provided for @guideIaKnowledgeDaysAgo. + /// + /// In fr, this message translates to: + /// **'il y a {count} j'** + String guideIaKnowledgeDaysAgo(Object count); + + /// No description provided for @guideIaKpiQuestions. + /// + /// In fr, this message translates to: + /// **'Questions ce mois'** + String get guideIaKpiQuestions; + + /// No description provided for @guideIaKpiUnanswered. + /// + /// In fr, this message translates to: + /// **'Sans réponse'** + String get guideIaKpiUnanswered; + + /// No description provided for @guideIaKpiThemes. + /// + /// In fr, this message translates to: + /// **'Thèmes'** + String get guideIaKpiThemes; + + /// No description provided for @guideIaKpiLanguages. + /// + /// In fr, this message translates to: + /// **'Langues'** + String get guideIaKpiLanguages; + + /// No description provided for @guideIaGapTitle. + /// + /// In fr, this message translates to: + /// **'Ce que vos visiteurs cherchent sans le trouver'** + String get guideIaGapTitle; + + /// No description provided for @guideIaGapSub. + /// + /// In fr, this message translates to: + /// **'{count} questions n\'ont trouvé aucune réponse dans vos contenus. Ajoutez-les et votre guide saura répondre dès la prochaine visite.'** + String guideIaGapSub(Object count); + + /// No description provided for @guideIaTopicsTitle. + /// + /// In fr, this message translates to: + /// **'Sujets des questions'** + String get guideIaTopicsTitle; + + /// No description provided for @guideIaQuestionLanguagesTitle. + /// + /// In fr, this message translates to: + /// **'Langues des questions'** + String get guideIaQuestionLanguagesTitle; + + /// No description provided for @guideIaInsightsSubCount. + /// + /// In fr, this message translates to: + /// **'Nombre de questions sur la période'** + String get guideIaInsightsSubCount; + + /// No description provided for @guideIaCitedTitle. + /// + /// In fr, this message translates to: + /// **'Contenus les plus utilisés'** + String get guideIaCitedTitle; + + /// No description provided for @guideIaCitedSub. + /// + /// In fr, this message translates to: + /// **'Sections citées dans les réponses'** + String get guideIaCitedSub; + + /// No description provided for @guideIaInsightsEmpty. + /// + /// In fr, this message translates to: + /// **'Aucune question pour l\'instant'** + String get guideIaInsightsEmpty; + + /// No description provided for @guideIaInsightsEmptyHint. + /// + /// In fr, this message translates to: + /// **'Dès que vos visiteurs interrogeront le guide, leurs questions seront regroupées ici par sujet — et celles restées sans réponse vous diront quoi ajouter à vos contenus.'** + String get guideIaInsightsEmptyHint; + + /// No description provided for @guideIaInsightsPrivacy. + /// + /// In fr, this message translates to: + /// **'Les questions sont conservées 90 jours puis supprimées. Seuls les regroupements par sujet sont gardés au-delà. Aucune donnée permettant d\'identifier un visiteur n\'est enregistrée.'** + String get guideIaInsightsPrivacy; + /// No description provided for @menuNotifications. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 158b036..85020dc 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -282,6 +282,122 @@ class AppLocalizationsEn extends AppLocalizations { @override String get guideIaSaveError => 'Saving failed. Please try again.'; + @override + String get guideIaTabConfig => 'Configuration'; + + @override + String get guideIaTabQuestions => 'What your visitors ask'; + + @override + String get guideIaPreviewTitle => 'Try your guide'; + + @override + String get guideIaPreviewSub => + 'Test the tone and the answers without reaching for a phone'; + + @override + String get guideIaPreviewPlaceholder => + 'Ask a question, the way a visitor would'; + + @override + String get guideIaPreviewSources => 'Where does this answer come from?'; + + @override + String get guideIaPreviewSourcesInfo => + 'This source indicator only appears on this screen. Your visitors see the answer alone.'; + + @override + String get guideIaPreviewError => + 'The guide did not answer. Try again in a moment.'; + + @override + String get guideIaPreviewUnsaved => + 'Save your changes before testing them: the preview queries the guide as published.'; + + @override + String get guideIaKnowledgeTitle => 'What your guide knows'; + + @override + String get guideIaKnowledgeSub => + 'Measured on what is actually indexed, not on what is published'; + + @override + String get guideIaKnowledgeSections => 'Indexed sections'; + + @override + String get guideIaKnowledgeChunks => 'Content chunks'; + + @override + String get guideIaKnowledgeLanguages => 'Languages covered'; + + @override + String get guideIaKnowledgeLastIndexed => 'Last indexed'; + + @override + String get guideIaKnowledgeHint => + 'An unpublished section drops out of the guide\'s answers within the minute.'; + + @override + String guideIaKnowledgeMinutesAgo(Object count) { + return '$count min ago'; + } + + @override + String guideIaKnowledgeHoursAgo(Object count) { + return '$count h ago'; + } + + @override + String guideIaKnowledgeDaysAgo(Object count) { + return '$count d ago'; + } + + @override + String get guideIaKpiQuestions => 'Questions this month'; + + @override + String get guideIaKpiUnanswered => 'Unanswered'; + + @override + String get guideIaKpiThemes => 'Themes'; + + @override + String get guideIaKpiLanguages => 'Languages'; + + @override + String get guideIaGapTitle => 'What your visitors look for and don\'t find'; + + @override + String guideIaGapSub(Object count) { + return '$count questions found no answer in your content. Add it and your guide will answer from the next visit on.'; + } + + @override + String get guideIaTopicsTitle => 'Question topics'; + + @override + String get guideIaQuestionLanguagesTitle => 'Question languages'; + + @override + String get guideIaInsightsSubCount => 'Number of questions over the period'; + + @override + String get guideIaCitedTitle => 'Most used content'; + + @override + String get guideIaCitedSub => 'Sections cited in answers'; + + @override + String get guideIaInsightsEmpty => 'No questions yet'; + + @override + String get guideIaInsightsEmptyHint => + 'As soon as your visitors start asking the guide, their questions will be grouped here by topic — and the ones left unanswered will tell you what to add to your content.'; + + @override + String get guideIaInsightsPrivacy => + 'Questions are kept for 90 days, then deleted. Only the topic groupings are kept beyond that. No data identifying a visitor is recorded.'; + @override String get menuNotifications => 'Notifications'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 71729dc..6f0191b 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -286,6 +286,123 @@ class AppLocalizationsFr extends AppLocalizations { @override String get guideIaSaveError => 'L\'enregistrement a échoué. Réessayez.'; + @override + String get guideIaTabConfig => 'Configuration'; + + @override + String get guideIaTabQuestions => 'Ce que demandent vos visiteurs'; + + @override + String get guideIaPreviewTitle => 'Essayer votre guide'; + + @override + String get guideIaPreviewSub => + 'Testez le ton et les réponses sans passer par un téléphone'; + + @override + String get guideIaPreviewPlaceholder => + 'Posez une question, comme le ferait un visiteur'; + + @override + String get guideIaPreviewSources => 'D\'où vient cette réponse ?'; + + @override + String get guideIaPreviewSourcesInfo => + 'Cet indicateur de sources n\'apparaît que sur cet écran. Vos visiteurs, eux, ne voient que la réponse.'; + + @override + String get guideIaPreviewError => + 'Le guide n\'a pas répondu. Réessayez dans un instant.'; + + @override + String get guideIaPreviewUnsaved => + 'Enregistrez vos modifications pour les essayer : l\'aperçu interroge le guide tel qu\'il est publié.'; + + @override + String get guideIaKnowledgeTitle => 'Ce que connaît votre guide'; + + @override + String get guideIaKnowledgeSub => + 'Mesuré sur ce qui est réellement indexé, pas sur ce qui est publié'; + + @override + String get guideIaKnowledgeSections => 'Sections indexées'; + + @override + String get guideIaKnowledgeChunks => 'Morceaux de contenu'; + + @override + String get guideIaKnowledgeLanguages => 'Langues couvertes'; + + @override + String get guideIaKnowledgeLastIndexed => 'Dernière indexation'; + + @override + String get guideIaKnowledgeHint => + 'Une section dépubliée disparaît des réponses du guide dans la minute.'; + + @override + String guideIaKnowledgeMinutesAgo(Object count) { + return 'il y a $count min'; + } + + @override + String guideIaKnowledgeHoursAgo(Object count) { + return 'il y a $count h'; + } + + @override + String guideIaKnowledgeDaysAgo(Object count) { + return 'il y a $count j'; + } + + @override + String get guideIaKpiQuestions => 'Questions ce mois'; + + @override + String get guideIaKpiUnanswered => 'Sans réponse'; + + @override + String get guideIaKpiThemes => 'Thèmes'; + + @override + String get guideIaKpiLanguages => 'Langues'; + + @override + String get guideIaGapTitle => + 'Ce que vos visiteurs cherchent sans le trouver'; + + @override + String guideIaGapSub(Object count) { + return '$count questions n\'ont trouvé aucune réponse dans vos contenus. Ajoutez-les et votre guide saura répondre dès la prochaine visite.'; + } + + @override + String get guideIaTopicsTitle => 'Sujets des questions'; + + @override + String get guideIaQuestionLanguagesTitle => 'Langues des questions'; + + @override + String get guideIaInsightsSubCount => 'Nombre de questions sur la période'; + + @override + String get guideIaCitedTitle => 'Contenus les plus utilisés'; + + @override + String get guideIaCitedSub => 'Sections citées dans les réponses'; + + @override + String get guideIaInsightsEmpty => 'Aucune question pour l\'instant'; + + @override + String get guideIaInsightsEmptyHint => + 'Dès que vos visiteurs interrogeront le guide, leurs questions seront regroupées ici par sujet — et celles restées sans réponse vous diront quoi ajouter à vos contenus.'; + + @override + String get guideIaInsightsPrivacy => + 'Les questions sont conservées 90 jours puis supprimées. Seuls les regroupements par sujet sont gardés au-delà. Aucune donnée permettant d\'identifier un visiteur n\'est enregistrée.'; + @override String get menuNotifications => 'Notifications'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 2cd12b7..336fbaf 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -286,6 +286,122 @@ class AppLocalizationsNl extends AppLocalizations { @override String get guideIaSaveError => 'Opslaan is mislukt. Probeer opnieuw.'; + @override + String get guideIaTabConfig => 'Configuratie'; + + @override + String get guideIaTabQuestions => 'Wat uw bezoekers vragen'; + + @override + String get guideIaPreviewTitle => 'Uw gids uitproberen'; + + @override + String get guideIaPreviewSub => + 'Test de toon en de antwoorden zonder een telefoon te nemen'; + + @override + String get guideIaPreviewPlaceholder => + 'Stel een vraag, zoals een bezoeker dat zou doen'; + + @override + String get guideIaPreviewSources => 'Waar komt dit antwoord vandaan?'; + + @override + String get guideIaPreviewSourcesInfo => + 'Deze bronvermelding verschijnt alleen op dit scherm. Uw bezoekers zien enkel het antwoord.'; + + @override + String get guideIaPreviewError => + 'De gids heeft niet geantwoord. Probeer het zo meteen opnieuw.'; + + @override + String get guideIaPreviewUnsaved => + 'Bewaar uw wijzigingen om ze te testen: het voorbeeld bevraagt de gids zoals hij gepubliceerd is.'; + + @override + String get guideIaKnowledgeTitle => 'Wat uw gids kent'; + + @override + String get guideIaKnowledgeSub => + 'Gemeten op wat werkelijk geïndexeerd is, niet op wat gepubliceerd is'; + + @override + String get guideIaKnowledgeSections => 'Geïndexeerde secties'; + + @override + String get guideIaKnowledgeChunks => 'Inhoudsfragmenten'; + + @override + String get guideIaKnowledgeLanguages => 'Gedekte talen'; + + @override + String get guideIaKnowledgeLastIndexed => 'Laatste indexering'; + + @override + String get guideIaKnowledgeHint => + 'Een gedepubliceerde sectie verdwijnt binnen de minuut uit de antwoorden van de gids.'; + + @override + String guideIaKnowledgeMinutesAgo(Object count) { + return '$count min geleden'; + } + + @override + String guideIaKnowledgeHoursAgo(Object count) { + return '$count u geleden'; + } + + @override + String guideIaKnowledgeDaysAgo(Object count) { + return '$count d geleden'; + } + + @override + String get guideIaKpiQuestions => 'Vragen deze maand'; + + @override + String get guideIaKpiUnanswered => 'Zonder antwoord'; + + @override + String get guideIaKpiThemes => 'Thema\'s'; + + @override + String get guideIaKpiLanguages => 'Talen'; + + @override + String get guideIaGapTitle => 'Wat uw bezoekers zoeken zonder het te vinden'; + + @override + String guideIaGapSub(Object count) { + return '$count vragen vonden geen antwoord in uw inhoud. Voeg ze toe en uw gids antwoordt vanaf het volgende bezoek.'; + } + + @override + String get guideIaTopicsTitle => 'Onderwerpen van de vragen'; + + @override + String get guideIaQuestionLanguagesTitle => 'Talen van de vragen'; + + @override + String get guideIaInsightsSubCount => 'Aantal vragen in de periode'; + + @override + String get guideIaCitedTitle => 'Meest gebruikte inhoud'; + + @override + String get guideIaCitedSub => 'Secties die in de antwoorden worden geciteerd'; + + @override + String get guideIaInsightsEmpty => 'Nog geen vragen'; + + @override + String get guideIaInsightsEmptyHint => + 'Zodra uw bezoekers de gids bevragen, worden hun vragen hier per onderwerp gegroepeerd — en de onbeantwoorde vragen tonen u wat u aan uw inhoud moet toevoegen.'; + + @override + String get guideIaInsightsPrivacy => + 'Vragen worden 90 dagen bewaard en daarna verwijderd. Alleen de groeperingen per onderwerp blijven langer bewaard. Er worden geen gegevens opgeslagen waarmee een bezoeker kan worden geïdentificeerd.'; + @override String get menuNotifications => 'Meldingen'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 00c6dca..12c814b 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -88,6 +88,39 @@ "guideIaSave": "Opslaan", "guideIaSaved": "Gids opgeslagen", "guideIaSaveError": "Opslaan is mislukt. Probeer opnieuw.", + "guideIaTabConfig": "Configuratie", + "guideIaTabQuestions": "Wat uw bezoekers vragen", + "guideIaPreviewTitle": "Uw gids uitproberen", + "guideIaPreviewSub": "Test de toon en de antwoorden zonder een telefoon te nemen", + "guideIaPreviewPlaceholder": "Stel een vraag, zoals een bezoeker dat zou doen", + "guideIaPreviewSources": "Waar komt dit antwoord vandaan?", + "guideIaPreviewSourcesInfo": "Deze bronvermelding verschijnt alleen op dit scherm. Uw bezoekers zien enkel het antwoord.", + "guideIaPreviewError": "De gids heeft niet geantwoord. Probeer het zo meteen opnieuw.", + "guideIaPreviewUnsaved": "Bewaar uw wijzigingen om ze te testen: het voorbeeld bevraagt de gids zoals hij gepubliceerd is.", + "guideIaKnowledgeTitle": "Wat uw gids kent", + "guideIaKnowledgeSub": "Gemeten op wat werkelijk geïndexeerd is, niet op wat gepubliceerd is", + "guideIaKnowledgeSections": "Geïndexeerde secties", + "guideIaKnowledgeChunks": "Inhoudsfragmenten", + "guideIaKnowledgeLanguages": "Gedekte talen", + "guideIaKnowledgeLastIndexed": "Laatste indexering", + "guideIaKnowledgeHint": "Een gedepubliceerde sectie verdwijnt binnen de minuut uit de antwoorden van de gids.", + "guideIaKnowledgeMinutesAgo": "{count} min geleden", + "guideIaKnowledgeHoursAgo": "{count} u geleden", + "guideIaKnowledgeDaysAgo": "{count} d geleden", + "guideIaKpiQuestions": "Vragen deze maand", + "guideIaKpiUnanswered": "Zonder antwoord", + "guideIaKpiThemes": "Thema's", + "guideIaKpiLanguages": "Talen", + "guideIaGapTitle": "Wat uw bezoekers zoeken zonder het te vinden", + "guideIaGapSub": "{count} vragen vonden geen antwoord in uw inhoud. Voeg ze toe en uw gids antwoordt vanaf het volgende bezoek.", + "guideIaTopicsTitle": "Onderwerpen van de vragen", + "guideIaQuestionLanguagesTitle": "Talen van de vragen", + "guideIaInsightsSubCount": "Aantal vragen in de periode", + "guideIaCitedTitle": "Meest gebruikte inhoud", + "guideIaCitedSub": "Secties die in de antwoorden worden geciteerd", + "guideIaInsightsEmpty": "Nog geen vragen", + "guideIaInsightsEmptyHint": "Zodra uw bezoekers de gids bevragen, worden hun vragen hier per onderwerp gegroepeerd — en de onbeantwoorde vragen tonen u wat u aan uw inhoud moet toevoegen.", + "guideIaInsightsPrivacy": "Vragen worden 90 dagen bewaard en daarna verwijderd. Alleen de groeperingen per onderwerp blijven langer bewaard. Er worden geen gegevens opgeslagen waarmee een bezoeker kan worden geïdentificeerd.", "menuNotifications": "Meldingen", "menuUsers": "Gebruikers", "menuApiKeys": "API-sleutels",