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>
310 lines
11 KiB
Dart
310 lines
11 KiB
Dart
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);
|
||
}
|