manager-app/lib/Screens/GuideIa/guide_ia_screen.dart
Thomas Fransolet bb4d4fe2d5 Écran Statistiques refondu + Guide IA + onboarding (auth, abonnement) + i18n FR/EN/NL
Statistiques — refonte complète, aucun changement backend
  statistics_screen.dart réécrit (+1832) : barres horizontales monochromes à la place
  des barres verticales tronquées et des deux anneaux, barre de filtres unique avec les
  volumes par canal, règle mono-canal, 4 KPI portant chacun leur variation, bandeau
  « à retenir », courbe en aire avec bandes de week-end. La période précédente s'obtient
  en rappelant le même endpoint.
  statistics_report.dart : export PDF généré côté client (paquet pdf Dart), il partage
  les valeurs calculées de l'écran — un chiffre ne peut pas diverger entre l'écran et le
  document envoyé à la commune. Deux puces du sommaire promettaient des données
  inexistantes (parcours terminés, questions au guide IA), retirées.
  ⚠️ Jamais ouvert dans un navigateur. Cases de test : test-plan.md §8bis / §8ter.

Guide IA
  Screens/GuideIa/guide_ia_screen.dart — onglet Configuration. Menu conditionné à
  isAssistant, le même drapeau que la garde d'AiController. L'onglet « Ce que demandent
  vos visiteurs » n'est pas dans ce commit : le schéma backend est prêt, l'UI non.

Onboarding self-service
  Screens/Auth/ (mot de passe oublié, définition du mot de passe),
  Screens/Billing/subscription_screen.dart, ai_quota_hint.dart.
  ⚠️ Aucun parcours joué de bout en bout — test-plan.md §18.

Parcours guidés
  progression_mode.dart : 9 booléens sur 3 niveaux remplacés par 3 questions.
  Popups GuidedPath / GuidedStep / QuizQuestion mises à jour en conséquence.

Client API (manager_api_new) — édité À LA MAIN, ne pas relancer la génération
  onboarding_api.dart, authentication_api.dart (+80), instance_dto (champs Guide*),
  guided_step / quiz_question_guided_step (flags morts retirés).
  Le // @dart=2.18 manquant dans onboarding_api.dart cassait les 3 apps Flutter d'un
  coup — corrigé ici.

i18n : ~180 clés par langue (FR/EN/NL) + fichiers générés.
Tests : progression_mode_test, statistics_report_test (le second a attrapé deux
plantages qui seraient sortis au premier clic).

flutter build web . flutter analyze : 68 erreurs, toutes dans les fichiers modèle
orphelins de manager_api_new — dette connue, pas une régression, ces fichiers ne sont
pas dans le graphe de compilation.
2026-08-09 22:14:37 +02:00

454 lines
15 KiB
Dart

import 'package:flutter/material.dart';
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:provider/provider.dart';
/// Voix Gemini TTS retenues. Voix et mot de réveil forment un couple indissociable :
/// les mots de réveil sont des modèles OpenWakeWord pré-entraînés, pas des chaînes saisies.
const kGuideVoiceViva = 'Sulafat';
const kGuideVoiceMarco = 'Umbriel';
class GuideIaScreen extends StatefulWidget {
const GuideIaScreen({super.key});
@override
State<GuideIaScreen> createState() => _GuideIaScreenState();
}
class _GuideIaScreenState extends State<GuideIaScreen> {
final _nameController = TextEditingController();
final _personaController = TextEditingController();
final List<TextEditingController> _fallbackControllers = [];
String _voiceId = kGuideVoiceViva;
InstanceDTO? _instance;
bool _loading = true;
bool _saving = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
@override
void dispose() {
_nameController.dispose();
_personaController.dispose();
for (final c in _fallbackControllers) {
c.dispose();
}
super.dispose();
}
ManagerAppContext get _managerContext =>
Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext;
/// Langue dans laquelle le gestionnaire saisit ses messages de repli.
/// Les autres langues sont produites par la traduction automatique, comme le reste du contenu.
String get _editingLanguage => 'FR';
Future<void> _load() async {
final ctx = _managerContext;
final instanceId = ctx.instanceId;
if (instanceId == null || ctx.clientAPI == null) {
setState(() => _loading = false);
return;
}
try {
final instance = await ctx.clientAPI!.instanceApi!.instanceGetDetail(instanceId);
if (!mounted) return;
_nameController.text = instance?.guideName ?? '';
_personaController.text = instance?.guidePersonaPrompt ?? '';
_voiceId = instance?.guideVoiceId ?? kGuideVoiceViva;
final ownLanguage = (instance?.guideFallbackMessages ?? [])
.where((m) => m.language == _editingLanguage && (m.value ?? '').isNotEmpty);
for (final m in ownLanguage) {
_fallbackControllers.add(TextEditingController(text: m.value));
}
if (_fallbackControllers.isEmpty) {
_fallbackControllers.add(TextEditingController());
}
setState(() {
_instance = instance;
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _save() async {
final instance = _instance;
if (instance == null) return;
final l = AppLocalizations.of(context)!;
setState(() => _saving = true);
// Les messages des autres langues sont préservés tels quels : on ne réécrit que la langue d'édition.
final otherLanguages = (instance.guideFallbackMessages)
.where((m) => m.language != _editingLanguage)
.toList();
final edited = _fallbackControllers
.map((c) => c.text.trim())
.where((t) => t.isNotEmpty)
.map((t) => TranslationDTO(language: _editingLanguage, value: t))
.toList();
instance.guideName = _nameController.text.trim();
instance.guidePersonaPrompt = _personaController.text.trim();
instance.guideVoiceId = _voiceId;
instance.guideFallbackMessages = [...otherLanguages, ...edited];
try {
await _managerContext.clientAPI!.instanceApi!.instanceUpdateinstance(instance);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.guideIaSaved), backgroundColor: kPrimaryColor),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.guideIaSaveError), backgroundColor: Colors.redAccent),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
if (_loading) return const Center(child: CircularProgressIndicator());
final wide = MediaQuery.of(context).size.width > 1100;
final left = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_usageCard(l),
const SizedBox(height: 16),
_channelsCard(l),
],
);
final right = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_identityCard(l),
const SizedBox(height: 16),
_voiceCard(l),
],
);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(l),
const SizedBox(height: 20),
if (wide)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: left),
const SizedBox(width: 16),
Expanded(child: right),
],
)
else ...[
left,
const SizedBox(height: 16),
right,
],
],
),
);
}
Widget _header(AppLocalizations l) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.menuGuideIa,
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 4),
Text(l.guideIaSubtitle,
style: TextStyle(fontSize: 14, color: kBodyTextColor)),
],
),
),
const SizedBox(width: 16),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: kPrimaryColor),
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: kWhite))
: Text(l.guideIaSave),
),
],
);
}
Widget _card({required String title, String? subtitle, required Widget child}) {
return Card(
elevation: 0,
color: kWhite,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: kPrimaryColor)),
if (subtitle != null) ...[
const SizedBox(height: 3),
Text(subtitle, style: TextStyle(fontSize: 13, color: kBodyTextColor.withValues(alpha: 0.75))),
],
const SizedBox(height: 16),
child,
],
),
),
);
}
Widget _usageCard(AppLocalizations l) {
final used = _instance?.aiTokensThisMonth ?? 0;
final quota = _instance?.aiTokensPerMonth ?? 0;
// Le gestionnaire raisonne en questions, pas en jetons de traitement.
final questions = (used / 1000).round();
final ratio = quota > 0 ? (used / quota).clamp(0.0, 1.0) : 0.0;
return _card(
title: l.guideIaUsageTitle,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (quota > 0) ...[
Text('${(ratio * 100).round()} %',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: kPrimaryColor)),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: ratio.toDouble(),
minHeight: 8,
backgroundColor: kSecond.withValues(alpha: 0.4),
color: kPrimaryColor,
),
),
const SizedBox(height: 10),
Text(l.guideIaUsageQuestions(questions), style: TextStyle(fontSize: 13, color: kBodyTextColor)),
] else
Text(l.guideIaUsageNoQuota, style: TextStyle(fontSize: 13, color: kBodyTextColor)),
],
),
);
}
Widget _channelsCard(AppLocalizations l) {
final apps = _instance?.applicationInstanceDTOs ?? [];
return _card(
title: l.guideIaChannelsTitle,
subtitle: l.guideIaChannelsSub,
child: Column(
children: apps.map((app) {
final on = app.isAssistant == true;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
children: [
Icon(on ? Icons.check_circle : Icons.remove_circle_outline,
size: 18, color: on ? kPrimaryColor : kSecond),
const SizedBox(width: 10),
Expanded(
child: Text((app.appType?.value ?? '').toString(),
style: const TextStyle(fontSize: 14))),
Text(on ? l.guideIaChannelOn : l.guideIaChannelOff,
style: TextStyle(fontSize: 12.5, color: kBodyTextColor.withValues(alpha: 0.7))),
],
),
);
}).toList(),
),
);
}
Widget _identityCard(AppLocalizations l) {
return _card(
title: l.guideIaIdentityTitle,
subtitle: l.guideIaIdentitySub,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: l.guideIaNameLabel,
helperText: l.guideIaNameHint,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 20),
TextField(
controller: _personaController,
maxLines: 5,
decoration: InputDecoration(
labelText: l.guideIaPersonaLabel,
alignLabelWithHint: true,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
Text(l.guideIaPersonaHint, style: TextStyle(fontSize: 12, color: kBodyTextColor.withValues(alpha: 0.7))),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_exampleChip(l.guideIaExampleWarden, l.guideIaExampleWardenText),
_exampleChip(l.guideIaExampleSober, l.guideIaExampleSoberText),
_exampleChip(l.guideIaExampleKids, l.guideIaExampleKidsText),
],
),
const SizedBox(height: 24),
Text(l.guideIaFallbackLabel,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: kBodyTextColor)),
const SizedBox(height: 8),
...List.generate(_fallbackControllers.length, (i) => _fallbackRow(i)),
const SizedBox(height: 4),
TextButton.icon(
onPressed: () => setState(() => _fallbackControllers.add(TextEditingController())),
icon: const Icon(Icons.add, size: 18),
label: Text(l.guideIaFallbackAdd),
style: TextButton.styleFrom(foregroundColor: kPrimaryColor),
),
const SizedBox(height: 4),
Text(l.guideIaFallbackHint, style: TextStyle(fontSize: 12, color: kBodyTextColor.withValues(alpha: 0.7))),
const SizedBox(height: 10),
_info(l.guideIaFallbackTranslated),
],
),
);
}
Widget _fallbackRow(int index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _fallbackControllers[index],
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
),
),
IconButton(
onPressed: _fallbackControllers.length <= 1
? null
: () => setState(() => _fallbackControllers.removeAt(index).dispose()),
icon: const Icon(Icons.close, size: 18),
color: kBodyTextColor,
),
],
),
);
}
Widget _exampleChip(String label, String text) {
return ActionChip(
label: Text(label, style: const TextStyle(fontSize: 12.5)),
onPressed: () => setState(() => _personaController.text = text),
side: BorderSide(color: kSecond),
backgroundColor: kWhite,
);
}
Widget _voiceCard(AppLocalizations l) {
final wakeword = _voiceId == kGuideVoiceMarco ? 'Marco' : 'Viva';
return _card(
title: l.guideIaVoiceTitle,
subtitle: l.guideIaVoiceSub,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: _voiceOption('Viva', l.guideIaVoiceFemale, kGuideVoiceViva, l)),
const SizedBox(width: 10),
Expanded(child: _voiceOption('Marco', l.guideIaVoiceMale, kGuideVoiceMarco, l)),
],
),
const SizedBox(height: 14),
_info(l.guideIaVoiceInfoName(wakeword)),
const SizedBox(height: 8),
_info(l.guideIaVoiceInfoGlasses),
const SizedBox(height: 8),
_info(l.guideIaVoiceInfoMultilang),
],
),
);
}
Widget _voiceOption(String name, String description, String voiceId, AppLocalizations l) {
final selected = _voiceId == voiceId;
return InkWell(
onTap: () => setState(() => _voiceId = voiceId),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(13),
decoration: BoxDecoration(
border: Border.all(color: selected ? kPrimaryColor : kSecond, width: selected ? 2 : 1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 2),
Text(description, style: TextStyle(fontSize: 12.5, color: kBodyTextColor)),
const SizedBox(height: 8),
Text(l.guideIaVoiceWakeword(name),
style: TextStyle(fontSize: 11.5, color: kBodyTextColor.withValues(alpha: 0.7))),
],
),
),
);
}
Widget _info(String text) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: kBackgroundColor,
borderRadius: BorderRadius.circular(6),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, size: 16, color: kPrimaryColor),
const SizedBox(width: 9),
Expanded(child: Text(text, style: TextStyle(fontSize: 12.5, color: kBodyTextColor))),
],
),
);
}
}