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