manager-app/lib/Screens/GuideIa/visitor_questions_tab.dart
Thomas Fransolet eb8e8497c8 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>
2026-08-11 10:48:40 +02:00

310 lines
11 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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