Socle visuel et écran Guide IA à deux onglets

Socle visuel (constants.dart)
- 11 rôles typographiques, 8 espacements, 5 rayons, paddings de carte et de page,
  extraits du CSS des maquettes validées (DOCS/claude design/).
- Les couleurs restent celles de l'app : un écran neuf se fond dans manager-app,
  il n'y ouvre pas une seconde palette. Seules les valeurs sans équivalent
  existant sont reprises — remplissages discrets, bordures, gris atténué, ambre.
- Mesuré avant : 11 tailles de police différentes dans lib/Components/, de 9 à 25 px.

Écran Guide IA
- Coquille à deux onglets : Configuration / Ce que demandent vos visiteurs.
- Aperçu de conversation branché sur le vrai POST /api/AI/chat, pas une simulation.
- Carte « Ce que connaît votre guide » sur GET /api/Ai/knowledge/{id} : mesuré sur
  l'index vectoriel, pas sur les tables de contenu — une section désactivée en est
  purgée. Les « points d'intérêt » de la maquette deviennent « morceaux de contenu » :
  les points d'une carte sont indexés dans le texte de leur SectionMap.
- Onglet des questions visiteurs alimenté par GET /api/Ai/insights/{id}. Une carte
  sans données se masque au lieu d'afficher un cadre creux.

Client API
- AIApi était dans le client généré mais exposé nulle part dans client.dart, où
  les 18 autres façades le sont. Câblé.

34 clés i18n FR/EN/NL. flutter analyze propre, flutter build web .

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-11 10:48:40 +02:00
parent bb4d4fe2d5
commit eb8e8497c8
12 changed files with 1497 additions and 15 deletions

View File

@ -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<GuideIaScreen> {
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<String, dynamic>? _knowledge;
@override
void initState() {
@ -38,6 +53,7 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
void dispose() {
_nameController.dispose();
_personaController.dispose();
_previewController.dispose();
for (final c in _fallbackControllers) {
c.dispose();
}
@ -80,11 +96,48 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
_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<void> _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<String, dynamic>);
} 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<void> _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<String, dynamic>));
} catch (_) {
// L'onglet reste sur son état vide.
}
}
Future<void> _save() async {
final instance = _instance;
if (instance == null) return;
@ -123,11 +176,125 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
}
}
/// 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<void> _reindex() async {
final ctx = _managerContext;
final instanceId = ctx.instanceId;
if (instanceId == null) return;
final confirmed = await showDialog<bool>(
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<GuideIaScreen> {
_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,12 +334,9 @@ class _GuideIaScreenState extends State<GuideIaScreen> {
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<GuideIaScreen> {
);
}
/// 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<GuideIaScreen> {
);
}
/// 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<void> _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<Widget> _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<String>() ?? const <String>[];
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<GuideIaScreen> {
);
}
}
class _PreviewTurn {
final String question;
final String? answer;
const _PreviewTurn(this.question, this.answer);
}

View File

@ -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<CountedLabel> unansweredQuestions;
final List<CountedLabel> topics;
final List<CountedLabel> questionLanguages;
final List<CountedLabel> 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<String, dynamic> 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<CountedLabel> 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<CountedLabel> 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);
}

View File

@ -207,6 +207,7 @@ Future<List<ResourceDTO?>?> create(ResourceDTO resourceDTO, List<File>? 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);

View File

@ -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);
}
}

View File

@ -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<String> section_types = ["Map", "Slider", "Video", "Web", "Menu", "Quiz", "Article", "PDF", "Game", "Agenda", "Weather", "Event", "Parcours"];
const List<String> map_types = ["none", "normal", "satellite", "terrain", "hybrid"];
const List<String> languages = ["FR", "NL", "EN", "DE", "IT", "ES", "CN", "PL", "AR", "UK"];

View File

@ -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",

View File

@ -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",

View File

@ -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:

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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",