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.
485 lines
26 KiB
Dart
485 lines
26 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
|
import 'package:manager_api_new/api.dart';
|
|
import 'package:manager_app/constants.dart';
|
|
import 'package:manager_app/l10n/app_localizations.dart';
|
|
import 'package:manager_app/Components/rounded_button.dart';
|
|
import 'package:manager_app/Components/multi_string_input_container.dart';
|
|
import 'package:manager_app/Components/check_input_container.dart';
|
|
import 'package:manager_app/Components/number_stepper_field.dart';
|
|
import 'package:manager_app/Components/resource_input_container.dart';
|
|
import 'package:manager_app/Components/reorderable_custom_list.dart';
|
|
import 'package:manager_app/Components/section_card.dart';
|
|
import 'progression_mode.dart';
|
|
import 'showNewOrUpdateGuidedStep.dart';
|
|
|
|
void showNewOrUpdateGuidedPath(
|
|
BuildContext context,
|
|
GuidedPathDTO? path,
|
|
String parentId,
|
|
bool isEvent,
|
|
FutureOr<void> Function(GuidedPathDTO) onSave, {
|
|
bool isGeolocated = true,
|
|
}) {
|
|
GuidedPathDTO workingPath = path != null
|
|
? GuidedPathDTO.fromJson(jsonDecode(jsonEncode(path)))!
|
|
: GuidedPathDTO(
|
|
title: [],
|
|
description: [],
|
|
steps: [],
|
|
order: 0,
|
|
// Doivent correspondre au mode que `progressionModeOf` affiche par
|
|
// défaut (Dans l'ordre) : laissés nuls, la radio montrait « Dans
|
|
// l'ordre » et la sauvegarde enregistrait « Libre ».
|
|
isLinear: true,
|
|
requireSuccessToAdvance: false,
|
|
hideNextStepsUntilComplete: false,
|
|
);
|
|
|
|
bool isSaving = false;
|
|
int stepsRevision = 0;
|
|
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (BuildContext context) {
|
|
return StatefulBuilder(
|
|
builder: (context, setState) {
|
|
final double screenWidth = MediaQuery.of(context).size.width;
|
|
final double screenHeight = MediaQuery.of(context).size.height;
|
|
final double dialogWidth = screenWidth * 0.82;
|
|
// contentWidth = dialogWidth minus the 24px padding on each side
|
|
final double contentWidth = dialogWidth - 48;
|
|
final double halfWidth = (contentWidth - 20) / 2;
|
|
|
|
return Dialog(
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
child: Container(
|
|
width: dialogWidth,
|
|
constraints: BoxConstraints(maxHeight: screenHeight * 0.88),
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// -- Titre du dialog --
|
|
Text(
|
|
path == null ? "Nouveau Parcours" : "Modifier le Parcours",
|
|
style: TextStyle(
|
|
color: kPrimaryColor,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold),
|
|
),
|
|
SizedBox(height: 16),
|
|
// -- Corps scrollable --
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Image du parcours
|
|
ResourceInputContainer(
|
|
label: "Image :",
|
|
color: kPrimaryColor,
|
|
initialValue: workingPath.imageResourceId,
|
|
onChanged: (resourceDTO) => setState(
|
|
() => workingPath.imageResourceId = resourceDTO.id),
|
|
),
|
|
SizedBox(height: 16),
|
|
// Titre + Description côte à côte
|
|
Row(
|
|
children: [
|
|
SizedBox(
|
|
width: halfWidth,
|
|
child: MultiStringInputContainer(
|
|
label: "Titre :",
|
|
modalLabel: "Titre du parcours",
|
|
initialValue: workingPath.title ?? [],
|
|
onGetResult: (val) =>
|
|
setState(() => workingPath.title = val),
|
|
maxLines: 1,
|
|
isTitle: true,
|
|
isHTML: true,
|
|
showPreview: true,
|
|
),
|
|
),
|
|
SizedBox(width: 20),
|
|
SizedBox(
|
|
width: halfWidth,
|
|
child: MultiStringInputContainer(
|
|
label: "Description :",
|
|
modalLabel: "Description du parcours",
|
|
initialValue: workingPath.description ?? [],
|
|
onGetResult: (val) => setState(
|
|
() => workingPath.description = val),
|
|
maxLines: 1,
|
|
isTitle: false,
|
|
isHTML: true,
|
|
showPreview: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 16),
|
|
// Mode jeu
|
|
SectionCard(
|
|
icon: Icons.sports_esports,
|
|
title: "Ambiance",
|
|
subtitle: "Visite classique ou jeu",
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
"Quelle ambiance ?",
|
|
style: TextStyle(
|
|
fontSize: 14, fontWeight: FontWeight.w600),
|
|
),
|
|
RadioGroup<bool>(
|
|
groupValue: workingPath.isGameMode ?? false,
|
|
onChanged: (val) => setState(
|
|
() => workingPath.isGameMode = val),
|
|
child: Column(
|
|
children: [
|
|
RadioListTile<bool>(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
activeColor: kPrimaryColor,
|
|
value: false,
|
|
title: Text("Visite",
|
|
style: TextStyle(fontSize: 14)),
|
|
subtitle: Text(
|
|
"Parcours de découverte : le visiteur avance à son rythme.",
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600]),
|
|
),
|
|
),
|
|
RadioListTile<bool>(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
activeColor: kPrimaryColor,
|
|
value: true,
|
|
title: Text("Jeu",
|
|
style: TextStyle(fontSize: 14)),
|
|
subtitle: Text(
|
|
"Escape game ou chasse au trésor : messages de début et de fin, vocabulaire de jeu sur les étapes.",
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600]),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (workingPath.isGameMode == true) ...[
|
|
SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: MultiStringInputContainer(
|
|
label: "Message de début :",
|
|
modalLabel: "Message d'introduction du jeu",
|
|
color: kPrimaryColor,
|
|
initialValue: (workingPath.gameMessageDebut ?? [])
|
|
.map((t) => TranslationDTO(language: t.language, value: t.value))
|
|
.toList(),
|
|
onGetResult: (val) {
|
|
setState(() {
|
|
workingPath.gameMessageDebut = val.map((t) {
|
|
final prev = (workingPath.gameMessageDebut ?? []).firstWhere(
|
|
(e) => e.language == t.language,
|
|
orElse: () => TranslationAndResourceDTO(),
|
|
);
|
|
return TranslationAndResourceDTO(
|
|
language: t.language,
|
|
value: t.value,
|
|
resourceId: prev.resourceId,
|
|
resource: prev.resource,
|
|
);
|
|
}).toList();
|
|
});
|
|
},
|
|
maxLines: 2,
|
|
isTitle: false,
|
|
isHTML: true,
|
|
showPreview: true,
|
|
),
|
|
),
|
|
SizedBox(width: 20),
|
|
Expanded(
|
|
child: MultiStringInputContainer(
|
|
label: "Message de fin :",
|
|
modalLabel: "Message de félicitations du jeu",
|
|
color: kPrimaryColor,
|
|
initialValue: (workingPath.gameMessageFin ?? [])
|
|
.map((t) => TranslationDTO(language: t.language, value: t.value))
|
|
.toList(),
|
|
onGetResult: (val) {
|
|
setState(() {
|
|
workingPath.gameMessageFin = val.map((t) {
|
|
final prev = (workingPath.gameMessageFin ?? []).firstWhere(
|
|
(e) => e.language == t.language,
|
|
orElse: () => TranslationAndResourceDTO(),
|
|
);
|
|
return TranslationAndResourceDTO(
|
|
language: t.language,
|
|
value: t.value,
|
|
resourceId: prev.resourceId,
|
|
resource: prev.resource,
|
|
);
|
|
}).toList();
|
|
});
|
|
},
|
|
maxLines: 2,
|
|
isTitle: false,
|
|
isHTML: true,
|
|
showPreview: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 16),
|
|
// Options
|
|
SectionCard(
|
|
icon: Icons.settings,
|
|
title: "Options du parcours",
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
"Comment le visiteur progresse-t-il ?",
|
|
style: TextStyle(
|
|
fontSize: 14, fontWeight: FontWeight.w600),
|
|
),
|
|
RadioGroup<ProgressionMode>(
|
|
groupValue: progressionModeOf(workingPath),
|
|
onChanged: (val) => setState(() =>
|
|
applyProgressionMode(workingPath, val!)),
|
|
child: Column(
|
|
children: [
|
|
for (final mode in ProgressionMode.values)
|
|
RadioListTile<ProgressionMode>(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
activeColor: kPrimaryColor,
|
|
value: mode,
|
|
title: Text(mode.label,
|
|
style: TextStyle(fontSize: 14)),
|
|
subtitle: Text(mode.description,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600])),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (progressionModeOf(workingPath) ==
|
|
ProgressionMode.stepByStep)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 16),
|
|
child: CheckInputContainer(
|
|
label: "Masquer les étapes pas encore atteintes",
|
|
subtitle:
|
|
"Le visiteur ne découvre les étapes suivantes qu'au fur et à mesure.",
|
|
isChecked:
|
|
workingPath.hideNextStepsUntilComplete ??
|
|
false,
|
|
onChanged: (val) => setState(() => workingPath
|
|
.hideNextStepsUntilComplete = val),
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
NumberStepperField(
|
|
label: "Durée estimée",
|
|
value: workingPath.estimatedDurationMinutes,
|
|
min: 0,
|
|
max: 600,
|
|
unit: "min",
|
|
onChanged: (val) => setState(() =>
|
|
workingPath.estimatedDurationMinutes = val.toInt()),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: 16),
|
|
// Étapes
|
|
SectionCard(
|
|
icon: Icons.list_alt,
|
|
title: AppLocalizations.of(context)!.pathStepsLabel,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: IconButton(
|
|
icon: Icon(Icons.add_circle_outline,
|
|
color: kSuccess),
|
|
onPressed: () {
|
|
showNewOrUpdateGuidedStep(
|
|
context,
|
|
null,
|
|
workingPath.id ?? "temp",
|
|
workingPath.isGameMode ?? false,
|
|
isGeolocated: isGeolocated,
|
|
(newStep) async {
|
|
setState(() {
|
|
newStep.order =
|
|
workingPath.steps?.length ?? 0;
|
|
workingPath.steps = [
|
|
...(workingPath.steps ?? []),
|
|
newStep
|
|
];
|
|
stepsRevision++;
|
|
});
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (workingPath.steps?.isEmpty ?? true)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(
|
|
AppLocalizations.of(context)!.noStepConfigured,
|
|
style: TextStyle(
|
|
fontStyle: FontStyle.italic,
|
|
color: Colors.grey[600]),
|
|
),
|
|
)
|
|
else
|
|
ReorderableCustomList<GuidedStepDTO>(
|
|
key: ValueKey(stepsRevision),
|
|
items: workingPath.steps!,
|
|
shrinkWrap: true,
|
|
onChanged: (updatedList) {
|
|
setState(() {
|
|
for (var i = 0; i < updatedList.length; i++) {
|
|
updatedList[i].order = i;
|
|
}
|
|
workingPath.steps = List.from(updatedList);
|
|
});
|
|
},
|
|
itemBuilder: (context, index, step) {
|
|
return ListTile(
|
|
leading:
|
|
CircleAvatar(child: Text("${index + 1}")),
|
|
title: HtmlWidget(
|
|
step.title != null && step.title!.isNotEmpty
|
|
? step.title!
|
|
.firstWhere(
|
|
(t) => t.language == 'FR',
|
|
orElse: () =>
|
|
step.title![0])
|
|
.value ??
|
|
"${AppLocalizations.of(context)!.stepFallback} $index"
|
|
: "${AppLocalizations.of(context)!.stepFallback} $index",
|
|
),
|
|
subtitle: workingPath.isGameMode ?? false
|
|
? Text(
|
|
"${step.quizQuestions?.length ?? 0} question(s)")
|
|
: null,
|
|
);
|
|
},
|
|
actions: [
|
|
(context, index, step) => IconButton(
|
|
icon: Icon(Icons.edit,
|
|
color: kPrimaryColor),
|
|
onPressed: () {
|
|
showNewOrUpdateGuidedStep(
|
|
context,
|
|
step,
|
|
workingPath.id ?? "temp",
|
|
workingPath.isGameMode ?? false,
|
|
isGeolocated: isGeolocated,
|
|
(updatedStep) async {
|
|
setState(() {
|
|
updatedStep.order = step.order;
|
|
workingPath.steps![index] =
|
|
updatedStep;
|
|
stepsRevision++;
|
|
});
|
|
},
|
|
);
|
|
},
|
|
),
|
|
(context, index, step) => IconButton(
|
|
icon: Icon(Icons.delete, color: kError),
|
|
onPressed: () {
|
|
setState(() {
|
|
workingPath.steps!.removeAt(index);
|
|
stepsRevision++;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 16),
|
|
// -- Boutons --
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
SizedBox(
|
|
height: 46,
|
|
child: RoundedButton(
|
|
text: "Annuler",
|
|
press: () => Navigator.pop(context),
|
|
color: kSecond,
|
|
fontSize: 15,
|
|
horizontal: 24,
|
|
),
|
|
),
|
|
SizedBox(width: 12),
|
|
SizedBox(
|
|
height: 46,
|
|
child: RoundedButton(
|
|
text: isSaving ? "Sauvegarde..." : "Sauvegarder",
|
|
icon: isSaving ? Icons.hourglass_empty : null,
|
|
press: () async {
|
|
if (isSaving) return;
|
|
setState(() => isSaving = true);
|
|
// Initialise les booleans null → false
|
|
workingPath.isLinear ??= false;
|
|
workingPath.requireSuccessToAdvance ??= false;
|
|
workingPath.hideNextStepsUntilComplete ??= false;
|
|
// Initialise les booleans nuls dans chaque étape
|
|
for (final s in workingPath.steps ?? []) {
|
|
s.isStepTimer ??= false;
|
|
}
|
|
try {
|
|
await onSave(workingPath);
|
|
if (context.mounted) Navigator.pop(context);
|
|
} catch (e) {
|
|
setState(() => isSaving = false);
|
|
}
|
|
},
|
|
color: kPrimaryColor,
|
|
fontSize: 15,
|
|
horizontal: 24,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|