É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.
This commit is contained in:
Thomas Fransolet 2026-08-09 22:14:37 +02:00
parent 8073cced5e
commit bb4d4fe2d5
41 changed files with 6104 additions and 815 deletions

View File

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:manager_api_new/api.dart';
import 'package:provider/provider.dart';
import '../app_context.dart';
import '../Models/managerContext.dart';
/// Affiche la consommation IA du mois sous le bouton "Traduire via IA", pour que
/// l'utilisateur sache où il en est avant de lancer une traduction (le quota est
/// partagé avec l'assistant visiteur).
///
/// Pour forcer un rafraîchissement après une traduction, passer une `key` dont la
/// valeur change (ex. `ValueKey(compteur)`) : le State est recréé et refetch.
class AiQuotaHint extends StatefulWidget {
const AiQuotaHint({Key? key}) : super(key: key);
@override
State<AiQuotaHint> createState() => _AiQuotaHintState();
}
class _AiQuotaHintState extends State<AiQuotaHint> {
InstanceQuotaDTO? _quota;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _fetch());
}
Future<void> _fetch() async {
final ctx = Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext;
final instanceId = ctx.instanceId;
final client = ctx.clientAPI;
if (instanceId == null || client == null) return;
try {
final quota = await client.instanceApi!.instanceGetQuota(instanceId);
if (mounted) setState(() => _quota = quota);
} catch (_) {
// Silencieux : l'indication de quota ne doit jamais bloquer la traduction
}
}
static String _formatTokens(int tokens) {
if (tokens < 1000) return '$tokens';
if (tokens < 1000000) return '${(tokens / 1000).toStringAsFixed(0)}K';
return '${(tokens / 1000000).toStringAsFixed(1)}M';
}
@override
Widget build(BuildContext context) {
if (_quota == null) return const SizedBox.shrink();
final used = _quota!.aiTokensUsed ?? 0;
final quota = _quota!.aiTokensPerMonth ?? 0;
// 0 = pas de plafond côté backend (le test est `quota > 0`)
if (quota == 0) {
return Text(
'Quota IA : illimité',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
textAlign: TextAlign.center,
);
}
final ratio = used / quota;
final color = ratio >= 0.95
? Colors.red
: ratio >= 0.80
? Colors.orange
: Colors.grey[600];
return Column(
children: [
Text(
'Quota IA : ${_formatTokens(used)} / ${_formatTokens(quota)} utilisés ce mois',
style: TextStyle(fontSize: 12, color: color, fontWeight: ratio >= 0.80 ? FontWeight.w600 : FontWeight.normal),
textAlign: TextAlign.center,
),
if (ratio >= 0.80)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'Partagé avec l\'assistant visiteur',
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
textAlign: TextAlign.center,
),
),
],
);
}
}

View File

@ -11,6 +11,7 @@ import 'package:manager_app/app_context.dart';
import 'package:manager_app/constants.dart';
import 'package:provider/provider.dart';
import 'ai_quota_hint.dart';
import 'flag_decoration.dart';
import 'message_notification.dart';
@ -42,6 +43,7 @@ class _TranslationInputAndResourceContainerState
late Map<String, QuillController> _controllers;
bool _isEnforcingLimit = false;
bool _isTranslating = false;
int _quotaRefreshToken = 0;
@override
void initState() {
@ -149,9 +151,16 @@ class _TranslationInputAndResourceContainerState
showNotification(kSuccess, kWhite, 'Traduction appliquée', context, null);
} catch (e) {
showNotification(kError, kWhite, 'Erreur lors de la traduction IA', context, null);
// Le backend renvoie un message explicite (quota dépassé, assistant désactivé) :
// on l'affiche tel quel plutôt qu'une erreur générique.
final message = e is AiTranslateException ? e.message : 'Erreur lors de la traduction IA';
final color = (e is AiTranslateException && e.isQuotaExceeded) ? Colors.orange : kError;
showNotification(color, kWhite, message, context, null);
} finally {
setState(() => _isTranslating = false);
setState(() {
_isTranslating = false;
_quotaRefreshToken++;
});
}
}
@ -184,7 +193,9 @@ class _TranslationInputAndResourceContainerState
if (instance?.isAssistant != true) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Center(
child: Column(
children: [
Center(
child: SizedBox(
width: 370,
height: 70,
@ -199,6 +210,9 @@ class _TranslationInputAndResourceContainerState
),
),
),
AiQuotaHint(key: ValueKey(_quotaRefreshToken)),
],
),
);
}),
],

View File

@ -12,6 +12,7 @@ import 'package:manager_app/Services/ai_translate_service.dart';
import 'package:manager_app/constants.dart';
import 'package:provider/provider.dart';
import 'ai_quota_hint.dart';
import 'flag_decoration.dart';
import 'message_notification.dart';
import 'package:manager_app/app_context.dart';
@ -42,6 +43,7 @@ class _TranslationInputContainerState extends State<TranslationInputContainer> {
late Map<String, QuillController> _controllers;
bool _isEnforcingLimit = false;
bool _isTranslating = false;
int _quotaRefreshToken = 0;
@override
void initState() {
@ -148,9 +150,16 @@ class _TranslationInputContainerState extends State<TranslationInputContainer> {
showNotification(kSuccess, kWhite, 'Traduction appliquée', context, null);
} catch (e) {
showNotification(kError, kWhite, 'Erreur lors de la traduction IA', context, null);
// Le backend renvoie un message explicite (quota dépassé, assistant désactivé) :
// on l'affiche tel quel plutôt qu'une erreur générique.
final message = e is AiTranslateException ? e.message : 'Erreur lors de la traduction IA';
final color = (e is AiTranslateException && e.isQuotaExceeded) ? Colors.orange : kError;
showNotification(color, kWhite, message, context, null);
} finally {
setState(() => _isTranslating = false);
setState(() {
_isTranslating = false;
_quotaRefreshToken++;
});
}
}
@ -226,7 +235,9 @@ class _TranslationInputContainerState extends State<TranslationInputContainer> {
if (instance?.isAssistant != true) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Center(
child: Column(
children: [
Center(
child: SizedBox(
width: 370,
height: 70,
@ -241,6 +252,9 @@ class _TranslationInputContainerState extends State<TranslationInputContainer> {
),
),
),
AiQuotaHint(key: ValueKey(_quotaRefreshToken)),
],
),
);
}),
]

View File

@ -1,11 +1,18 @@
import 'package:manager_api_new/api.dart';
import 'package:manager_app/Models/managerContext.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'dart:html' as html;
class PDFHelper {
static void downloadBytes(Uint8List bytes, String fileName) {
html.AnchorElement(href: 'data:application/pdf;base64,${base64Encode(bytes)}')
..setAttribute('download', '$fileName.pdf')
..click();
}
static downloadPDF(ManagerAppContext managerAppContext, List<SectionDTO> sections) async {
var sectionsArticle = sections;
if(sectionsArticle.length > 0) {

View File

@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
import 'package:manager_app/Components/common_loader.dart';
import 'package:manager_app/Components/message_notification.dart';
import 'package:manager_app/Components/rounded_button.dart';
import 'package:manager_app/Components/rounded_input_field.dart';
import 'package:manager_app/client.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
class ForgotPasswordScreen extends StatefulWidget {
ForgotPasswordScreen({Key? key}) : super(key: key);
@override
_ForgotPasswordScreenState createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final String host = "http://localhost:5000";
String email = "";
bool isLoading = false;
bool isSent = false;
Future<void> submit() async {
if (email.trim().isEmpty) return;
setState(() => isLoading = true);
try {
final clientAPI = Client(host);
await clientAPI.authenticationApi!.authenticationForgotPassword(email.trim());
setState(() {
isSent = true;
isLoading = false;
});
} catch (e) {
setState(() => isLoading = false);
showNotification(Colors.orange, kWhite, AppLocalizations.of(context)!.loginError, context, null);
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
final isMobile = constraints.maxWidth < 550;
final cardWidth = isMobile ? constraints.maxWidth - 32.0 : 440.0;
return Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
kPrimaryColor.withValues(alpha: 0.12),
kBackgroundColor,
kBackgroundColor,
],
),
),
child: Center(
child: SingleChildScrollView(
padding: EdgeInsets.all(isMobile ? 16.0 : 24.0),
child: Container(
width: cardWidth,
decoration: BoxDecoration(
color: kWhite,
borderRadius: BorderRadius.circular(16.0),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
spreadRadius: 0,
blurRadius: 32,
offset: Offset(0, 8),
),
],
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: isMobile ? 24.0 : 40.0, vertical: 40.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
constraints: BoxConstraints(maxHeight: 64, minHeight: 40),
child: SvgPicture.asset('assets/images/MyInfoMate_logo_only.svg'),
),
SizedBox(height: 24),
if (!isSent) ...[
Text(
l.forgotPasswordTitle,
style: TextStyle(color: kPrimaryColor, fontSize: 22, fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Text(
l.forgotPasswordDesc,
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
textAlign: TextAlign.center,
),
SizedBox(height: 24),
RoundedInputField(
hintText: "E-mail",
autofill: "email",
isEmail: true,
initialValue: email,
onChanged: (value) => email = value,
),
SizedBox(height: 24),
!isLoading
? SizedBox(
width: double.infinity,
child: RoundedButton(
text: l.forgotPasswordSubmit,
fontSize: 16,
vertical: 15,
horizontal: 30,
press: submit,
),
)
: CommonLoader(iconSize: 40),
] else ...[
Icon(Icons.mark_email_read_outlined, color: kSuccess, size: 48),
SizedBox(height: 16),
Text(
l.forgotPasswordSuccess,
style: TextStyle(fontSize: 15),
textAlign: TextAlign.center,
),
],
SizedBox(height: 24),
TextButton(
onPressed: () => context.go('/login'),
child: Text(l.backToLogin, style: TextStyle(color: kPrimaryColor)),
),
],
),
),
),
),
),
);
},
),
);
}
}

View File

@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
import 'package:manager_app/Components/common_loader.dart';
import 'package:manager_app/Components/message_notification.dart';
import 'package:manager_app/Components/rounded_button.dart';
import 'package:manager_app/Components/rounded_password_field.dart';
import 'package:manager_app/client.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
/// Used by the 3 flows sharing the same generic token mechanism: onboarding
/// welcome step (not currently linked, password is set directly at sign-up),
/// user invitation, and forgot-password. The token is read from the URL query
/// param, e.g. manager-app.myinfomate.be/set-password?token=xxx
class SetPasswordScreen extends StatefulWidget {
final String? token;
SetPasswordScreen({Key? key, this.token}) : super(key: key);
@override
_SetPasswordScreenState createState() => _SetPasswordScreenState();
}
class _SetPasswordScreenState extends State<SetPasswordScreen> {
final String host = "http://localhost:5000";
String newPassword = "";
bool isLoading = false;
bool isDone = false;
Future<void> submit() async {
final l = AppLocalizations.of(context)!;
if (widget.token == null || widget.token!.isEmpty) {
showNotification(kError, kWhite, l.setPasswordMissingToken, context, null);
return;
}
if (newPassword.length < 8) {
showNotification(Colors.orange, kWhite, l.setPasswordTooShort, context, null);
return;
}
setState(() => isLoading = true);
try {
final clientAPI = Client(host);
await clientAPI.authenticationApi!.authenticationSetPassword(
token: widget.token!,
newPassword: newPassword,
);
setState(() {
isDone = true;
isLoading = false;
});
} catch (e) {
setState(() => isLoading = false);
showNotification(kError, kWhite, l.setPasswordError, context, null);
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
final isMobile = constraints.maxWidth < 550;
final cardWidth = isMobile ? constraints.maxWidth - 32.0 : 440.0;
return Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
kPrimaryColor.withValues(alpha: 0.12),
kBackgroundColor,
kBackgroundColor,
],
),
),
child: Center(
child: SingleChildScrollView(
padding: EdgeInsets.all(isMobile ? 16.0 : 24.0),
child: Container(
width: cardWidth,
decoration: BoxDecoration(
color: kWhite,
borderRadius: BorderRadius.circular(16.0),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
spreadRadius: 0,
blurRadius: 32,
offset: Offset(0, 8),
),
],
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: isMobile ? 24.0 : 40.0, vertical: 40.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
constraints: BoxConstraints(maxHeight: 64, minHeight: 40),
child: SvgPicture.asset('assets/images/MyInfoMate_logo_only.svg'),
),
SizedBox(height: 24),
if (!isDone) ...[
Text(
l.setPasswordTitle,
style: TextStyle(color: kPrimaryColor, fontSize: 22, fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Text(
l.setPasswordDesc,
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
textAlign: TextAlign.center,
),
SizedBox(height: 24),
RoundedPasswordField(
initialValue: newPassword,
onChanged: (value) => newPassword = value,
),
SizedBox(height: 24),
!isLoading
? SizedBox(
width: double.infinity,
child: RoundedButton(
text: l.setPasswordSubmit,
fontSize: 16,
vertical: 15,
horizontal: 30,
press: submit,
),
)
: CommonLoader(iconSize: 40),
] else ...[
Icon(Icons.check_circle_outline, color: kSuccess, size: 48),
SizedBox(height: 16),
Text(
l.setPasswordSuccess,
style: TextStyle(fontSize: 15),
textAlign: TextAlign.center,
),
SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: RoundedButton(
text: l.connect,
fontSize: 16,
vertical: 15,
horizontal: 30,
press: () => context.go('/login'),
),
),
],
],
),
),
),
),
),
);
},
),
);
}
}

View File

@ -0,0 +1,147 @@
import 'dart:html' as html;
import 'package:flutter/material.dart';
import 'package:manager_app/Components/common_loader.dart';
import 'package:manager_app/Components/message_notification.dart';
import 'package:manager_app/Components/rounded_button.dart';
import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
/// Subscription screen for the Essentiel plan only Pro/Premium/Enterprise
/// remain fully manual (contact commercial), so they have no self-service
/// screen here. Shows trial status + what's included, and lets the user
/// convert to a paid subscription via a Stripe-hosted Checkout Session.
///
/// The "Add-ons" section below is intentionally a placeholder for now it's
/// the future home of the paid AI request quota add-on, not implemented yet.
class SubscriptionScreen extends StatefulWidget {
const SubscriptionScreen({Key? key}) : super(key: key);
@override
_SubscriptionScreenState createState() => _SubscriptionScreenState();
}
class _SubscriptionScreenState extends State<SubscriptionScreen> {
bool isRedirecting = false;
static const _includedFeatures = [
"App visiteur web, ouverte par QR code — aucune installation",
"Contenus, agenda, plans et sections illimités",
"Multilingue — vos visiteurs choisissent leur langue",
"Vos couleurs et votre logo, votre adresse dédiée",
];
Future<void> _startCheckout(ManagerAppContext ctx) async {
setState(() => isRedirecting = true);
try {
final url = await ctx.clientAPI!.onboardingApi!.onboardingCreateCheckoutSession();
html.window.open(url, '_blank');
} catch (e) {
showNotification(kError, kWhite, AppLocalizations.of(context)!.subscriptionCheckoutError, context, null);
} finally {
if (mounted) setState(() => isRedirecting = false);
}
}
String _formatDate(DateTime date) {
final d = date.toLocal();
return "${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}";
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final managerCtx = Provider.of<AppContext>(context).getContext() as ManagerAppContext;
final instance = managerCtx.instanceDTO;
final isTrialActive = instance?.isTrialActive == true;
final trialEndsAt = instance?.trialEndsAt;
return SingleChildScrollView(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.subscriptionTitle, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: kPrimaryColor)),
const SizedBox(height: 16),
Card(
elevation: 0,
color: isTrialActive ? kPrimaryColor.withValues(alpha: 0.06) : kSuccess.withValues(alpha: 0.08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: (isTrialActive ? kPrimaryColor : kSuccess).withValues(alpha: 0.25)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Icon(
isTrialActive ? Icons.hourglass_top_rounded : Icons.check_circle_outline,
color: isTrialActive ? kPrimaryColor : kSuccess,
size: 32,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isTrialActive ? l.subscriptionTrialActive : l.subscriptionPlanActive,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
isTrialActive
? (trialEndsAt != null
? l.subscriptionTrialEndsAt(_formatDate(trialEndsAt))
: l.subscriptionTrialNoDate)
: l.subscriptionPlanActiveDesc,
style: TextStyle(fontSize: 13, color: Colors.grey[700]),
),
],
),
),
],
),
),
),
const SizedBox(height: 24),
Text(l.subscriptionIncludedTitle, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
..._includedFeatures.map((feature) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.check, color: kSuccess, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(feature, style: const TextStyle(fontSize: 14))),
],
),
)),
if (isTrialActive) ...[
const SizedBox(height: 16),
isRedirecting
? const CommonLoader(iconSize: 32)
: RoundedButton(
text: l.subscriptionUpgradeBtn,
fontSize: 15,
vertical: 14,
horizontal: 24,
press: () => _startCheckout(managerCtx),
),
],
const SizedBox(height: 32),
Text(l.subscriptionAddonsTitle, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Text(
l.subscriptionAddonsComingSoon,
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
],
),
);
}
}

View File

@ -14,6 +14,10 @@ class ParcoursConfig extends StatefulWidget {
final String parentId;
final bool isEvent;
final bool isParcours;
/// Parcours géolocalisé (`ShowMap`) : conditionne l'affichage des champs de
/// position et de zone sur les étapes. Un parcours en salle n'en a pas besoin.
final bool isGeolocated;
final ValueChanged<List<GuidedPathDTO>> onChanged;
const ParcoursConfig({
@ -22,6 +26,7 @@ class ParcoursConfig extends StatefulWidget {
required this.parentId,
required this.isEvent,
this.isParcours = false,
this.isGeolocated = true,
required this.onChanged,
}) : super(key: key);
@ -82,6 +87,7 @@ class _ParcoursConfigState extends State<ParcoursConfig> {
null,
widget.parentId,
widget.isEvent,
isGeolocated: widget.isGeolocated,
(newPath) async {
try {
newPath.order = paths.length;
@ -201,6 +207,7 @@ class _ParcoursConfigState extends State<ParcoursConfig> {
path,
widget.parentId,
widget.isEvent,
isGeolocated: widget.isGeolocated,
(updatedPath) async {
try {
final clientAPI = (appContext.getContext() as ManagerAppContext).clientAPI!;

View File

@ -0,0 +1,52 @@
import 'package:manager_api_new/api.dart';
/// Mode de progression d'un parcours, présenté au client comme un choix unique.
///
/// En base, la progression reste décrite par les booléens `isLinear` et
/// `requireSuccessToAdvance` du `GuidedPath` : cet enum n'est qu'une lecture
/// combinée de ces champs, pour éviter de faire assembler au client des cases à
/// cocher dont les interactions ne sont pas devinables.
enum ProgressionMode {
free(
label: "Libre",
description: "Le visiteur choisit ses étapes dans l'ordre qu'il veut.",
),
ordered(
label: "Dans l'ordre",
description: "Le visiteur suit la séquence ; il peut revenir sur une étape déjà vue.",
),
stepByStep(
label: "Étape par étape",
description: "Chaque étape se débloque en réussissant le défi de la précédente.",
);
const ProgressionMode({required this.label, required this.description});
final String label;
final String description;
}
ProgressionMode progressionModeOf(GuidedPathDTO path) {
if (path.isLinear == false) return ProgressionMode.free;
if (path.requireSuccessToAdvance == true) return ProgressionMode.stepByStep;
return ProgressionMode.ordered;
}
void applyProgressionMode(GuidedPathDTO path, ProgressionMode mode) {
switch (mode) {
case ProgressionMode.free:
path.isLinear = false;
path.requireSuccessToAdvance = false;
path.hideNextStepsUntilComplete = false;
break;
case ProgressionMode.ordered:
path.isLinear = true;
path.requireSuccessToAdvance = false;
path.hideNextStepsUntilComplete = false;
break;
case ProgressionMode.stepByStep:
path.isLinear = true;
path.requireSuccessToAdvance = true;
break;
}
}

View File

@ -12,6 +12,7 @@ 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(
@ -19,8 +20,9 @@ void showNewOrUpdateGuidedPath(
GuidedPathDTO? path,
String parentId,
bool isEvent,
FutureOr<void> Function(GuidedPathDTO) onSave,
) {
FutureOr<void> Function(GuidedPathDTO) onSave, {
bool isGeolocated = true,
}) {
GuidedPathDTO workingPath = path != null
? GuidedPathDTO.fromJson(jsonDecode(jsonEncode(path)))!
: GuidedPathDTO(
@ -28,6 +30,12 @@ void showNewOrUpdateGuidedPath(
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;
@ -116,66 +124,55 @@ void showNewOrUpdateGuidedPath(
],
),
SizedBox(height: 16),
// Options
SectionCard(
icon: Icons.settings,
title: "Options du parcours",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CheckInputContainer(
label: AppLocalizations.of(context)!.linearLabel,
subtitle: "Les étapes doivent être suivies dans l'ordre.",
isChecked: workingPath.isLinear ?? false,
onChanged: (val) => setState(
() => workingPath.isLinear = val),
),
CheckInputContainer(
label: AppLocalizations.of(context)!.requiredSuccessLabel,
subtitle: "Le quiz de chaque étape doit être validé pour débloquer la suivante.",
isChecked:
workingPath.requireSuccessToAdvance ??
false,
onChanged: (val) => setState(() => workingPath
.requireSuccessToAdvance = val),
),
CheckInputContainer(
label: "Cacher les suivantes :",
subtitle: "Seule l'étape en cours est visible sur la carte.",
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),
// Mode jeu
SectionCard(
icon: Icons.sports_esports,
title: "Mode jeu",
subtitle: "Escape game ou chasse au trésor",
title: "Ambiance",
subtitle: "Visite classique ou jeu",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CheckInputContainer(
label: "Mode jeu (escape game / chasse au trésor)",
subtitle: "Active les messages de jeu ci-dessous et le vocabulaire escape game sur les étapes.",
isChecked: workingPath.isGameMode ?? false,
onChanged: (val) =>
setState(() => workingPath.isGameMode = val),
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),
@ -249,6 +246,69 @@ void showNewOrUpdateGuidedPath(
),
),
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,
@ -267,6 +327,7 @@ void showNewOrUpdateGuidedPath(
null,
workingPath.id ?? "temp",
workingPath.isGameMode ?? false,
isGeolocated: isGeolocated,
(newStep) async {
setState(() {
newStep.order =
@ -336,6 +397,7 @@ void showNewOrUpdateGuidedPath(
step,
workingPath.id ?? "temp",
workingPath.isGameMode ?? false,
isGeolocated: isGeolocated,
(updatedStep) async {
setState(() {
updatedStep.order = step.order;
@ -395,9 +457,7 @@ void showNewOrUpdateGuidedPath(
workingPath.hideNextStepsUntilComplete ??= false;
// Initialise les booleans nuls dans chaque étape
for (final s in workingPath.steps ?? []) {
s.isHiddenInitially ??= false;
s.isStepTimer ??= false;
s.isStepLocked ??= false;
}
try {
await onSave(workingPath);

View File

@ -23,8 +23,9 @@ void showNewOrUpdateGuidedStep(
GuidedStepDTO? step,
String pathId,
bool isEscapeMode,
FutureOr<void> Function(GuidedStepDTO) onSave,
) {
FutureOr<void> Function(GuidedStepDTO) onSave, {
bool isGeolocated = true,
}) {
// Use jsonEncode/jsonDecode for a robust deep copy that handles nested DTOs correctly
GuidedStepDTO workingStep = step != null
? GuidedStepDTO.fromJson(jsonDecode(jsonEncode(step)))!
@ -115,7 +116,10 @@ void showNewOrUpdateGuidedStep(
],
),
SizedBox(height: 16),
// Emplacement Directement avec GeometryDTO
// Emplacement Directement avec GeometryDTO.
// Masqué pour un parcours en salle (ShowMap = false) : sans carte,
// une position et une zone de déclenchement n'ont aucun effet.
if (isGeolocated)
SectionCard(
icon: Icons.location_on,
title: "Emplacement",
@ -184,48 +188,11 @@ void showNewOrUpdateGuidedStep(
],
),
),
SizedBox(height: 16),
// Comportement de l'étape
SectionCard(
icon: Icons.tune,
title: "Comportement",
subtitle: "Visibilité et verrouillage de l'étape",
child: Row(
children: [
Expanded(
child: SwitchListTile(
dense: true,
title: Text(AppLocalizations.of(context)!.initiallyHiddenLabel),
subtitle: Text(
"N'apparaît pas sur la carte tant qu'elle n'a pas été débloquée.",
style: TextStyle(fontSize: 12),
),
value: workingStep.isHiddenInitially ?? false,
onChanged: (val) => setState(() => workingStep.isHiddenInitially = val),
activeThumbColor: kPrimaryColor,
),
),
Expanded(
child: SwitchListTile(
dense: true,
title: Text(AppLocalizations.of(context)!.lockedLabel),
subtitle: Text(
"Visible mais non accessible tant que les étapes précédentes ne sont pas terminées.",
style: TextStyle(fontSize: 12),
),
value: workingStep.isStepLocked ?? false,
onChanged: (val) => setState(() => workingStep.isStepLocked = val),
activeThumbColor: kPrimaryColor,
),
),
],
),
),
SizedBox(height: 16),
if (isGeolocated) SizedBox(height: 16),
SectionCard(
icon: Icons.perm_media,
title: "Contenu riche",
subtitle: "Audio guide et images affichés sur la fiche de l'étape",
subtitle: "Audio guide, images et vidéos affichés sur la fiche de l'étape",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -245,7 +212,7 @@ void showNewOrUpdateGuidedStep(
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Images :", style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
Text("Médias :", style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
IconButton(
icon: Icon(Icons.add_circle_outline, color: kSuccess),
onPressed: () async {
@ -264,7 +231,7 @@ void showNewOrUpdateGuidedStep(
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
"Aucune image — cliquez + pour en ajouter",
"Aucun média — cliquez + pour ajouter une image, une vidéo ou un audio",
style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey[600], fontSize: 13),
),
)
@ -513,9 +480,7 @@ void showNewOrUpdateGuidedStep(
if (isSaving) return;
setState(() => isSaving = true);
// Initialise les booleans null false
workingStep.isHiddenInitially ??= false;
workingStep.isStepTimer ??= false;
workingStep.isStepLocked ??= false;
workingStep.isGeoTriggered ??= false;
if (workingStep.isGeoTriggered != true) {
workingStep.zoneRadiusMeters = null;

View File

@ -179,6 +179,30 @@ void showNewOrUpdateQuizQuestion(
fontStyle: FontStyle.italic,
color: Colors.grey[600]),
),
SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: kPrimaryColor.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.dialpad, size: 18, color: kPrimaryColor),
SizedBox(width: 8),
Expanded(
child: Text(
"Si la réponse attendue ne contient que des chiffres, le visiteur "
"voit automatiquement un pavé numérique façon cadenas (digicode) "
"au lieu du champ texte. Rien à configurer.",
style: TextStyle(
fontSize: 12, color: Colors.grey[700]),
),
),
],
),
),
],
// =========================================

View File

@ -3,7 +3,7 @@ import 'package:manager_api_new/api.dart';
import 'package:provider/provider.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/Components/check_input_container.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/Screens/Configurations/Section/SubSection/Parcours/parcours_config.dart';
class SectionParcoursConfig extends StatefulWidget {
@ -52,22 +52,55 @@ class _SectionParcoursConfigState extends State<SectionParcoursConfig> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Options carte
// Où se déroule le parcours ?
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: CheckInputContainer(
label: "Afficher la carte",
isChecked: parcoursDTO.showMap ?? true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Où se déroule le parcours ?",
style: TextStyle(fontWeight: FontWeight.w600)),
RadioGroup<bool>(
groupValue: parcoursDTO.showMap ?? true,
onChanged: (val) {
setState(() => parcoursDTO.showMap = val);
widget.onChanged(parcoursDTO);
},
child: Column(
children: [
RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
activeColor: kPrimaryColor,
value: false,
title: Text("En salle", style: TextStyle(fontSize: 14)),
subtitle: Text(
"Une suite d'étapes dans un espace restreint, sans carte ni géolocalisation.",
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
),
if (parcoursDTO.showMap == true && availableMaps.isNotEmpty)
RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
activeColor: kPrimaryColor,
value: true,
title: Text("Sur le terrain", style: TextStyle(fontSize: 14)),
subtitle: Text(
"Le visiteur se déplace : carte et position sur chaque étape.",
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
),
],
),
),
],
),
),
if (parcoursDTO.showMap == true)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@ -86,11 +119,20 @@ class _SectionParcoursConfigState extends State<SectionParcoursConfig> {
value: m.id,
child: Text(m.label ?? m.id ?? ''))),
],
onChanged: (val) {
setState(() => parcoursDTO.baseSectionMapId = val);
onChanged: availableMaps.isEmpty
? null
: (val) {
setState(
() => parcoursDTO.baseSectionMapId = val);
widget.onChanged(parcoursDTO);
},
),
if (availableMaps.isEmpty)
Text(
"Aucune section Carte dans cette configuration — créez-en une pour pouvoir la réutiliser ici.",
style: TextStyle(
fontSize: 12, color: Colors.grey[600]),
),
],
),
),
@ -107,6 +149,7 @@ class _SectionParcoursConfigState extends State<SectionParcoursConfig> {
parentId: parcoursDTO.id!,
isEvent: false,
isParcours: true,
isGeolocated: parcoursDTO.showMap ?? true,
onChanged: (paths) {
setState(() => parcoursDTO.guidedPaths = paths);
widget.onChanged(parcoursDTO);

View File

@ -0,0 +1,453 @@
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))),
],
),
);
}
}

View File

@ -13,8 +13,10 @@ import 'package:manager_app/Screens/Configurations/configurations_screen.dart';
import 'package:manager_app/Screens/Kiosk_devices/kiosk_screen.dart';
import 'package:manager_app/Screens/Resources/resources_screen.dart';
import 'package:manager_app/Screens/Statistics/statistics_screen.dart';
import 'package:manager_app/Screens/GuideIa/guide_ia_screen.dart';
import 'package:manager_app/Screens/Applications/app_configuration_link_screen.dart';
import 'package:manager_app/Screens/Applications/web_app_screen.dart';
import 'package:manager_app/Screens/Billing/subscription_screen.dart';
import 'package:manager_app/Screens/Notifications/notifications_screen.dart';
import 'package:manager_app/Screens/Users/users_screen.dart';
import 'package:manager_app/l10n/app_localizations.dart';
@ -65,6 +67,9 @@ class _MainScreenState extends State<MainScreen> {
if (widget.instance.hasStats == true) {
menu.sections!.add(MenuSection(name: "Statistiques", type: "statistics", menuId: 7, subMenu: []));
}
if (widget.instance.isAssistant == true) {
menu.sections!.add(MenuSection(name: "Guide IA", type: "guide-ia", menuId: 12, subMenu: []));
}
if(currentPosition.value == null) {
@ -271,9 +276,11 @@ class _MainScreenState extends State<MainScreen> {
case 'configurations': return l.menuConfigurations;
case 'resources': return l.menuResources;
case 'statistics': return l.menuStatistics;
case 'guide-ia': return l.menuGuideIa;
case 'notifications': return l.menuNotifications;
case 'users': return l.menuUsers;
case 'apikeys': return l.menuApiKeys;
case 'subscription': return l.menuSubscription;
default: return type;
}
}
@ -284,9 +291,11 @@ class _MainScreenState extends State<MainScreen> {
case 'configurations': return Icons.settings_outlined;
case 'resources': return Icons.folder_open_outlined;
case 'statistics': return Icons.bar_chart;
case 'guide-ia': return Icons.record_voice_over_outlined;
case 'notifications': return Icons.notifications_none;
case 'users': return Icons.people_outline;
case 'apikeys': return Icons.vpn_key_outlined;
case 'subscription': return Icons.workspace_premium_outlined;
default: return Icons.circle_outlined;
}
}
@ -507,6 +516,12 @@ class _MainScreenState extends State<MainScreen> {
final role = managerAppContext.role;
final hasAdminItems = menu.sections!.any((s) => s.menuId == 8);
final hasNotifItem = menu.sections!.any((s) => s.menuId == 10);
final hasSubscriptionItem = menu.sections!.any((s) => s.menuId == 11);
if (managerAppContext.instanceDTO?.subscriptionPlanId == "plan-essentiel" && !hasSubscriptionItem) {
menu.sections!.add(MenuSection(name: "Abonnement", type: "subscription", menuId: 11, subMenu: []));
} else if (managerAppContext.instanceDTO?.subscriptionPlanId != "plan-essentiel" && hasSubscriptionItem) {
menu.sections!.removeWhere((s) => s.menuId == 11);
}
if (role != null && role.value <= 1 && managerAppContext.instanceDTO?.isPushNotification == true && !hasNotifItem) {
menu.sections!.add(MenuSection(name: "Notifications", type: "notifications", menuId: 10, subMenu: []));
} else if ((role == null || role.value > 1 || managerAppContext.instanceDTO?.isPushNotification != true) && hasNotifItem) {
@ -602,6 +617,12 @@ class _MainScreenState extends State<MainScreen> {
case "notifications":
currentPosition = 10;
break;
case "subscription":
currentPosition = 11;
break;
case "guide-ia":
currentPosition = 12;
break;
}
}
@ -661,6 +682,11 @@ class _MainScreenState extends State<MainScreen> {
padding: EdgeInsets.all(8.0),
child: StatisticsScreen()
);
case 'guide-ia' :
return const Padding(
padding: EdgeInsets.all(8.0),
child: GuideIaScreen()
);
case 'users':
return const Padding(
padding: EdgeInsets.all(8.0),
@ -676,6 +702,11 @@ class _MainScreenState extends State<MainScreen> {
padding: EdgeInsets.all(8.0),
child: NotificationsScreen()
);
case 'subscription':
return const Padding(
padding: EdgeInsets.all(8.0),
child: SubscriptionScreen()
);
default:
return Text('Hellow default');
}

View File

@ -0,0 +1,474 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
/// Rapport de fréquentation, généré côté client à partir des agrégats déjà
/// chargés par l'écran. L'envoi mensuel automatique, lui, devra être fait côté
/// backend (QuestPDF + Hangfire) voir `plan-import-ia-stats-subsides.md §1`.
class ReportKpi {
const ReportKpi(this.label, this.value, this.trend);
final String label;
final String value;
final String? trend;
}
class ReportBar {
const ReportBar(this.label, this.value, this.fraction);
final String label;
final String value;
final double fraction;
}
class ReportBarGroup {
const ReportBarGroup(this.title, this.subtitle, this.bars);
final String title;
final String subtitle;
final List<ReportBar> bars;
}
class ReportTable {
const ReportTable(this.title, this.headers, this.rows);
final String title;
final List<String> headers;
final List<List<String>> rows;
}
class ReportDayPoint {
const ReportDayPoint(this.label, this.visits);
final String label;
final int visits;
}
class StatisticsReportData {
const StatisticsReportData({
required this.instanceName,
required this.title,
required this.periodLabel,
required this.takeaways,
required this.kpis,
required this.chartTitle,
required this.chartSubtitle,
required this.series,
required this.axisLabels,
required this.peakLabel,
required this.barGroups,
required this.tables,
required this.generatedLabel,
required this.brandArgb,
required this.logo,
});
final String instanceName;
final String title;
final String periodLabel;
final List<String> takeaways;
final List<ReportKpi> kpis;
final String chartTitle;
final String chartSubtitle;
final List<ReportDayPoint> series;
/// Index des points de la série qui portent une étiquette sur l'axe des X.
final List<int> axisLabels;
final String? peakLabel;
final List<ReportBarGroup> barGroups;
final List<ReportTable> tables;
final String generatedLabel;
/// Couleur principale du canal de référence, en ARGB l'écran n'a pas à
/// connaître les types du paquet `pdf`.
final int brandArgb;
final Uint8List? logo;
PdfColor get brandColor => PdfColor.fromInt(brandArgb);
}
const _ink = PdfColor.fromInt(0xFF14202B);
const _ink2 = PdfColor.fromInt(0xFF4A5A6B);
const _ink3 = PdfColor.fromInt(0xFF7C8B9A);
const _line = PdfColor.fromInt(0xFFD6DDE5);
const _track = PdfColor.fromInt(0xFFE7EBF1);
const _muted = PdfColor.fromInt(0xFFF3F5F8);
/// Le logo du client vit sur Firebase Storage. Sans en-tête CORS sur le bucket,
/// le navigateur refuse la lecture des octets : on retombe alors sur une page de
/// garde au seul nom de l'instance plutôt que de faire échouer le rapport.
Future<Uint8List?> fetchReportLogo(String? url) async {
if (url == null || url.isEmpty) return null;
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) return null;
return response.bodyBytes;
} catch (_) {
return null;
}
}
Future<Uint8List> buildStatisticsReport(StatisticsReportData data) async {
final fontData = await rootBundle.load('assets/fonts/OpenSans-Medium.ttf');
final font = pw.Font.ttf(fontData);
final document = pw.Document(
theme: pw.ThemeData.withFont(base: font, bold: font, italic: font),
);
document.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.fromLTRB(38, 34, 38, 34),
header: (context) =>
context.pageNumber == 1 ? pw.SizedBox() : _runningHeader(data),
footer: (context) => _footer(data, context),
build: (context) => [
_cover(data),
if (data.takeaways.isNotEmpty) ...[
pw.SizedBox(height: 22),
_takeaways(data),
],
pw.SizedBox(height: 18),
_kpiRow(data),
// Sous deux points il n'y a pas de courbe à tracer, et l'axe des X
// dégénère en division par zéro.
if (data.series.length >= 2) ...[
pw.SizedBox(height: 22),
_chart(data),
],
for (final group in data.barGroups) ...[
pw.SizedBox(height: 22),
_barGroup(data, group),
],
for (final table in data.tables) ...[
pw.SizedBox(height: 22),
_table(data, table),
],
],
),
);
return document.save();
}
pw.Widget _cover(StatisticsReportData data) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
data.instanceName,
style: pw.TextStyle(
fontSize: 11, color: data.brandColor, letterSpacing: 1.2),
),
pw.SizedBox(height: 6),
pw.Text(data.title,
style: const pw.TextStyle(fontSize: 25, color: _ink)),
pw.SizedBox(height: 5),
pw.Text(data.periodLabel,
style: const pw.TextStyle(fontSize: 12, color: _ink3)),
],
),
),
if (data.logo != null)
pw.Container(
height: 52,
constraints: const pw.BoxConstraints(maxWidth: 130),
child:
pw.Image(pw.MemoryImage(data.logo!), fit: pw.BoxFit.contain),
),
],
),
pw.SizedBox(height: 14),
pw.Container(height: 2, color: data.brandColor),
],
);
}
pw.Widget _runningHeader(StatisticsReportData data) {
return pw.Container(
margin: const pw.EdgeInsets.only(bottom: 16),
padding: const pw.EdgeInsets.only(bottom: 6),
decoration: const pw.BoxDecoration(
border: pw.Border(bottom: pw.BorderSide(color: _line)),
),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(data.instanceName,
style: const pw.TextStyle(fontSize: 9, color: _ink3)),
pw.Text(data.periodLabel,
style: const pw.TextStyle(fontSize: 9, color: _ink3)),
],
),
);
}
pw.Widget _footer(StatisticsReportData data, pw.Context context) {
return pw.Container(
margin: const pw.EdgeInsets.only(top: 14),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(data.generatedLabel,
style: const pw.TextStyle(fontSize: 8.5, color: _ink3)),
pw.Text('${context.pageNumber} / ${context.pagesCount}',
style: const pw.TextStyle(fontSize: 8.5, color: _ink3)),
],
),
);
}
pw.Widget _takeaways(StatisticsReportData data) {
return pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.symmetric(horizontal: 15, vertical: 13),
decoration: pw.BoxDecoration(
color: _muted,
border: pw.Border.all(color: _line),
borderRadius: pw.BorderRadius.circular(5),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
for (final sentence in data.takeaways)
pw.Padding(
padding: pw.EdgeInsets.only(
top: sentence == data.takeaways.first ? 0 : 5),
child: pw.Text(sentence,
style: const pw.TextStyle(
fontSize: 11, color: _ink, lineSpacing: 2.5)),
),
],
),
);
}
/// Hauteur fixe assumée : `MultiPage` pose ses enfants sans contrainte
/// verticale, et `CrossAxisAlignment.stretch` y donnerait une hauteur infinie.
/// Le paquet `pdf` n'a pas d'`IntrinsicHeight` pour égaliser autrement.
pw.Widget _kpiRow(StatisticsReportData data) {
return pw.SizedBox(
height: 84,
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
for (final kpi in data.kpis) ...[
pw.Expanded(
child: pw.Container(
padding: const pw.EdgeInsets.fromLTRB(12, 11, 12, 12),
decoration: pw.BoxDecoration(border: pw.Border.all(color: _line)),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(kpi.label.toUpperCase(),
maxLines: 2,
style: const pw.TextStyle(
fontSize: 7.5, color: _ink3, letterSpacing: 0.4)),
pw.SizedBox(height: 6),
pw.Text(kpi.value,
maxLines: 1,
style:
pw.TextStyle(fontSize: 18, color: data.brandColor)),
if (kpi.trend != null) ...[
pw.SizedBox(height: 4),
pw.Text(kpi.trend!,
maxLines: 1,
style: const pw.TextStyle(fontSize: 8, color: _ink2)),
],
],
),
),
),
if (kpi != data.kpis.last) pw.SizedBox(width: 8),
],
],
),
);
}
pw.Widget _chart(StatisticsReportData data) {
final peak =
data.series.map((point) => point.visits).reduce((a, b) => a > b ? a : b);
final axisMax = _roundedAxisMax(peak);
return _card(
data,
title: data.chartTitle,
subtitle: data.chartSubtitle,
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.SizedBox(
height: 150,
child: pw.Chart(
grid: pw.CartesianGrid(
xAxis: pw.FixedAxis<int>(
data.axisLabels,
format: (value) => data.series[value as int].label,
textStyle: const pw.TextStyle(fontSize: 7.5, color: _ink3),
color: _line,
),
yAxis: pw.FixedAxis<int>(
[0, (axisMax / 2).round(), axisMax.round()],
divisions: true,
divisionsColor: _line,
textStyle: const pw.TextStyle(fontSize: 7.5, color: _ink3),
color: _line,
),
),
datasets: [
pw.LineDataSet(
data: [
for (var i = 0; i < data.series.length; i++)
pw.PointChartValue(
i.toDouble(), data.series[i].visits.toDouble()),
],
color: data.brandColor,
lineWidth: 1.4,
drawPoints: false,
drawSurface: true,
surfaceColor: data.brandColor,
surfaceOpacity: 0.16,
),
],
),
),
if (data.peakLabel != null) ...[
pw.SizedBox(height: 8),
pw.Text(data.peakLabel!,
style: const pw.TextStyle(fontSize: 9, color: _ink3)),
],
],
),
);
}
pw.Widget _barGroup(StatisticsReportData data, ReportBarGroup group) {
return _card(
data,
title: group.title,
subtitle: group.subtitle,
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
for (final bar in group.bars)
pw.Padding(
padding: pw.EdgeInsets.only(bottom: bar == group.bars.last ? 0 : 9),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
pw.Row(
children: [
pw.Expanded(
child: pw.Text(bar.label,
maxLines: 1,
style: const pw.TextStyle(fontSize: 10, color: _ink)),
),
pw.SizedBox(width: 10),
pw.Text(bar.value,
style: const pw.TextStyle(fontSize: 9.5, color: _ink2)),
],
),
pw.SizedBox(height: 4),
_barTrack(bar.fraction, data.brandColor),
],
),
),
],
),
);
}
pw.Widget _table(StatisticsReportData data, ReportTable table) {
return _card(
data,
title: table.title,
subtitle: null,
child: pw.TableHelper.fromTextArray(
headers: table.headers,
data: table.rows,
border: null,
headerStyle: const pw.TextStyle(fontSize: 9, color: _ink3),
headerDecoration: const pw.BoxDecoration(
border: pw.Border(bottom: pw.BorderSide(color: _line)),
),
cellStyle: const pw.TextStyle(fontSize: 10, color: _ink),
cellAlignment: pw.Alignment.centerLeft,
cellHeight: 20,
headerPadding: const pw.EdgeInsets.only(bottom: 6),
cellPadding: const pw.EdgeInsets.symmetric(vertical: 3),
),
);
}
pw.Widget _card(
StatisticsReportData data, {
required String title,
required String? subtitle,
required pw.Widget child,
}) {
return pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.fromLTRB(15, 13, 15, 15),
decoration: pw.BoxDecoration(border: pw.Border.all(color: _line)),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(title,
style: pw.TextStyle(fontSize: 12, color: data.brandColor)),
if (subtitle != null) ...[
pw.SizedBox(height: 2),
pw.Text(subtitle,
style: const pw.TextStyle(fontSize: 9, color: _ink3)),
],
pw.SizedBox(height: 12),
child,
],
),
);
}
/// Le paquet `pdf` n'a pas d'équivalent de `FractionallySizedBox` : la
/// proportion se rend avec deux `Expanded` sur un millième près.
pw.Widget _barTrack(double fraction, PdfColor color) {
const steps = 1000;
final filled = (fraction.clamp(0.0, 1.0) * steps).round();
if (filled >= steps) return pw.Container(height: 7, color: color);
return pw.Container(
height: 7,
color: _track,
child: pw.Row(
children: [
if (filled > 0)
pw.Expanded(flex: filled, child: pw.Container(color: color)),
pw.Expanded(flex: steps - filled, child: pw.SizedBox()),
],
),
);
}
double _roundedAxisMax(int peak) {
if (peak <= 4) return 4;
var magnitude = 1.0;
while (magnitude * 10 <= peak) {
magnitude *= 10;
}
for (final step in const [1.0, 2.0, 4.0, 10.0]) {
final candidate = magnitude * step;
if (candidate >= peak) return candidate;
}
return magnitude * 10;
}

File diff suppressed because it is too large Load Diff

View File

@ -56,12 +56,13 @@ class _UsersScreenState extends State<UsersScreen> {
}
Future<void> _createUser(ManagerAppContext ctx, String email,
String firstName, String lastName, String password, int roleValue) async {
String firstName, String lastName, int roleValue) async {
// No password sent: the backend generates an invitation token and emails
// a "set your password" link to the new user instead.
final body = {
'email': email,
'firstName': firstName,
'lastName': lastName,
'password': password,
'role': roleValue,
};
await ctx.clientAPI!.apiApi!.invokeAPI(
@ -93,7 +94,6 @@ class _UsersScreenState extends State<UsersScreen> {
final emailCtrl = TextEditingController();
final firstCtrl = TextEditingController();
final lastCtrl = TextEditingController();
final passCtrl = TextEditingController();
int selectedRole = callerRole;
showDialog(
@ -106,7 +106,8 @@ class _UsersScreenState extends State<UsersScreen> {
TextField(controller: emailCtrl, decoration: InputDecoration(labelText: l.email)),
TextField(controller: firstCtrl, decoration: InputDecoration(labelText: l.firstName)),
TextField(controller: lastCtrl, decoration: InputDecoration(labelText: l.lastName)),
TextField(controller: passCtrl, obscureText: true, decoration: InputDecoration(labelText: l.password)),
const SizedBox(height: 4),
Text(l.inviteUserHint, style: const TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -130,7 +131,7 @@ class _UsersScreenState extends State<UsersScreen> {
onPressed: () async {
Navigator.pop(ctx2);
await _createUser(ctx, emailCtrl.text, firstCtrl.text,
lastCtrl.text, passCtrl.text, selectedRole);
lastCtrl.text, selectedRole);
},
child: Text(l.create),
),

View File

@ -361,6 +361,14 @@ class _LoginScreenState extends State<LoginScreen> {
),
)
: CommonLoader(iconSize: 40),
SizedBox(height: 8),
TextButton(
onPressed: () => context.go('/forgot-password'),
child: Text(
AppLocalizations.of(context)!.forgotPasswordLink,
style: TextStyle(color: kPrimaryColor, fontSize: 13),
),
),
],
),
),

View File

@ -1,6 +1,21 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Erreur remontée par l'API de traduction IA, en conservant le message du backend
/// (ex. "Quota IA mensuel dépassé") pour pouvoir l'afficher tel quel à l'utilisateur.
class AiTranslateException implements Exception {
final String message;
final int statusCode;
AiTranslateException(this.message, this.statusCode);
/// true si l'échec est dû à un quota atteint (mensuel ou période d'essai)
bool get isQuotaExceeded => statusCode == 429;
@override
String toString() => message;
}
class AiTranslateService {
static Future<Map<String, String>> translate({
required String host,
@ -25,11 +40,33 @@ class AiTranslateService {
);
if (response.statusCode != 200) {
throw Exception('Erreur traduction IA : ${response.statusCode}');
throw AiTranslateException(_extractMessage(response), response.statusCode);
}
final data = jsonDecode(response.body);
final data = jsonDecode(utf8.decode(response.bodyBytes));
final translations = data['translations'] as Map<String, dynamic>;
return translations.map((k, v) => MapEntry(k, v.toString()));
}
/// Le backend renvoie ses messages d'erreur en texte via StatusCode(429, "..."),
/// ce qui donne une chaîne JSON. On décode en UTF-8 explicitement : le package http
/// retombe sur latin1 quand le charset n'est pas précisé, ce qui casse les accents.
static String _extractMessage(http.Response response) {
final body = utf8.decode(response.bodyBytes, allowMalformed: true).trim();
if (body.isNotEmpty) {
try {
final decoded = jsonDecode(body);
if (decoded is String && decoded.trim().isNotEmpty) return decoded.trim();
if (decoded is Map && decoded['title'] is String) return decoded['title'] as String;
} catch (_) {
return body;
}
}
if (response.statusCode == 403) {
return "L'assistant IA n'est pas activé pour cette instance";
}
return 'Erreur lors de la traduction IA (${response.statusCode})';
}
}

View File

@ -56,6 +56,9 @@ class Client {
SubscriptionPlanApi? _subscriptionPlanApi;
SubscriptionPlanApi? get subscriptionPlanApi => _subscriptionPlanApi;
OnboardingApi? _onboardingApi;
OnboardingApi? get onboardingApi => _onboardingApi;
Client(String path) {
_apiClient = ApiClient(basePath: path);
//basePath: "https://192.168.31.140");
@ -78,5 +81,6 @@ class Client {
_apiKeyApi = ApiKeyApi(_apiClient);
_notificationApi = NotificationApi(_apiClient);
_subscriptionPlanApi = SubscriptionPlanApi(_apiClient);
_onboardingApi = OnboardingApi(_apiClient);
}
}

View File

@ -18,11 +18,76 @@
"loginError": "An error occurred during login",
"rememberMe": "Remember me",
"connect": "LOG IN",
"forgotPasswordLink": "Forgot your password?",
"forgotPasswordTitle": "Forgot password",
"forgotPasswordDesc": "Enter your email and we'll send you a link to set a new password.",
"forgotPasswordSubmit": "SEND LINK",
"forgotPasswordSuccess": "If this email exists, a reset link has just been sent.",
"backToLogin": "Back to login",
"setPasswordTitle": "Set your password",
"setPasswordDesc": "Choose a password to access your workspace.",
"setPasswordNewLabel": "New password",
"setPasswordSubmit": "SET PASSWORD",
"setPasswordSuccess": "Password set — you can now log in.",
"setPasswordError": "This link is invalid or has expired",
"setPasswordTooShort": "Password must be at least 8 characters",
"setPasswordMissingToken": "Invalid link — no token provided",
"inviteUserHint": "An invitation email will be sent to set the password.",
"menuSubscription": "Subscription",
"subscriptionTitle": "Subscription",
"subscriptionTrialActive": "Free trial in progress",
"subscriptionTrialEndsAt": "Your trial ends on {date}",
"@subscriptionTrialEndsAt": { "placeholders": { "date": {} } },
"subscriptionTrialNoDate": "Your free trial is active.",
"subscriptionPlanActive": "Essentiel plan active",
"subscriptionPlanActiveDesc": "Your subscription is active and renews automatically.",
"subscriptionIncludedTitle": "What's included",
"subscriptionUpgradeBtn": "Upgrade to a paid subscription",
"subscriptionCheckoutError": "Could not start checkout, please try again later.",
"subscriptionAddonsTitle": "Add-ons",
"subscriptionAddonsComingSoon": "Coming soon: extra AI requests and other options.",
"menuApplications": "Applications",
"menuConfigurations": "Configurations",
"menuResources": "Resources",
"menuStatistics": "Statistics",
"menuGuideIa": "AI Guide",
"guideIaSubtitle": "Your guide answers visitor questions using your own content. It never answers from anything else.",
"guideIaUsageTitle": "This month's usage",
"guideIaUsageQuestions": "{count} questions asked",
"guideIaUsageNoQuota": "No cap set on your plan.",
"guideIaChannelsTitle": "Where the guide is available",
"guideIaChannelsSub": "Enabled per channel, from each application's settings.",
"guideIaChannelOn": "Enabled",
"guideIaChannelOff": "Disabled",
"guideIaIdentityTitle": "Guide identity",
"guideIaIdentitySub": "This is what gives the answers their tone",
"guideIaNameLabel": "Guide name",
"guideIaNameHint": "The name visitors see and hear. E.g. Leon",
"guideIaPersonaLabel": "Personality",
"guideIaPersonaHint": "Describe it the way you would introduce a guide to a new colleague. To get started, pick an example:",
"guideIaExampleWarden": "Passionate warden",
"guideIaExampleWardenText": "Your name is Leon, you are the site's former warden. You speak plainly and warmly, and you happily slip in an anecdote. Address visitors politely.",
"guideIaExampleSober": "Measured mediator",
"guideIaExampleSoberText": "You are a calm, precise cultural mediator. You answer in two or three sentences, without familiarity, citing established facts. If something is uncertain, you say so.",
"guideIaExampleKids": "For children",
"guideIaExampleKidsText": "You are an enthusiastic explorer speaking to children aged 7 to 12. Use informal language, ask questions, use simple comparisons and short words. Tell stories.",
"guideIaFallbackLabel": "When the guide doesn't know",
"guideIaFallbackHint": "The guide picks one at random. With a single sentence it repeats it word for word, and that shows immediately.",
"guideIaFallbackAdd": "Add a wording",
"guideIaFallbackTranslated": "Visitors see these sentences: they will be translated into their language, like the rest of your content.",
"guideIaVoiceTitle": "Your guide out loud",
"guideIaVoiceSub": "Visitors ask out loud and hear the answer — in the app, or in their connected glasses.",
"guideIaVoiceFemale": "Female voice, warm",
"guideIaVoiceMale": "Male voice, steady",
"guideIaVoiceWakeword": "Wake word: \"{word}\"",
"guideIaVoiceInfoName": "Visitors say \"{word}\" to wake the guide, even if it goes by another name. A voice and wake word of your own are available as an option.",
"guideIaVoiceInfoGlasses": "Connected glasses work with the mobile app installed on the visitor's phone.",
"guideIaVoiceInfoMultilang": "The same voice speaks all your languages: your guide keeps one identity across them.",
"guideIaSave": "Save",
"guideIaSaved": "Guide saved",
"guideIaSaveError": "Saving failed. Please try again.",
"menuNotifications": "Notifications",
"menuUsers": "Users",
"menuApiKeys": "API Keys",
@ -175,6 +240,121 @@
"statsInvalid": "Invalid",
"statsViews": "Views",
"statsAttendanceTitle": "Attendance",
"statsPeriodRange": "From {from} to {to}",
"@statsPeriodRange": {
"placeholders": {
"from": { "type": "String" },
"to": { "type": "String" }
}
},
"statsFilterPeriod": "Period",
"statsFilterChannel": "Channel",
"statsPeriodDays": "{days} days",
"@statsPeriodDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"statsPeriodYear": "Year",
"statsChannelMobile": "Mobile app",
"statsChannelTablet": "Kiosk",
"statsChannelWeb": "Website",
"statsChannelVR": "VR headset",
"statsChannelVoice": "Voice guide",
"statsTakeawayUp": "Attendance is up {percent} % compared to the previous period.",
"@statsTakeawayUp": {
"placeholders": {
"percent": { "type": "String" }
}
},
"statsTakeawayDown": "Attendance is down {percent} % compared to the previous period.",
"@statsTakeawayDown": {
"placeholders": {
"percent": { "type": "String" }
}
},
"statsTakeawayStable": "Attendance is stable compared to the previous period.",
"statsTakeawayVolume": "{visits} visits over the period, or {perDay} a day on average.",
"@statsTakeawayVolume": {
"placeholders": {
"visits": { "type": "String" },
"perDay": { "type": "String" }
}
},
"statsTakeawayVoice": "The voice guide accounts for {percent} % of visits — a strong argument for your funding bodies.",
"@statsTakeawayVoice": {
"placeholders": {
"percent": { "type": "String" }
}
},
"statsTakeawayChannel": "The « {channel} » channel accounts for {percent} % of visits.",
"@statsTakeawayChannel": {
"placeholders": {
"channel": { "type": "String" },
"percent": { "type": "String" }
}
},
"statsTakeawayContent": "« {title} » alone accounts for {percent} % of all views: it is your front door, keep it up to date first.",
"@statsTakeawayContent": {
"placeholders": {
"title": { "type": "String" },
"percent": { "type": "String" }
}
},
"statsKpiVisits": "Visits",
"statsKpiAvgDuration": "Average duration",
"statsKpiContentsPerVisit": "Contents per visit",
"statsKpiVoiceShare": "Voice guide share",
"statsKpiTotalViews": "Contents viewed",
"statsVsPrevious": "vs previous period",
"statsTrendStable": "stable",
"statsDurationMinSec": "{minutes} min {seconds} s",
"@statsDurationMinSec": {
"placeholders": {
"minutes": { "type": "String" },
"seconds": { "type": "String" }
}
},
"statsDurationSec": "{seconds} s",
"@statsDurationSec": {
"placeholders": {
"seconds": { "type": "String" }
}
},
"statsVisitsByDaySub": "Light bands mark the weekends",
"statsWeekends": "Saturdays and Sundays",
"statsPeakDay": "Peak on {date} — {visits} visits",
"@statsPeakDay": {
"placeholders": {
"date": { "type": "String" },
"visits": { "type": "String" }
}
},
"statsTopContents": "Most viewed contents",
"statsTopContentsSub": "Number of views over the period",
"statsChannels": "Channels",
"statsChannelsSub": "Visits per channel",
"statsLanguagesSub": "Visits per language",
"statsReportTitle": "Your attendance report",
"statsReportBody": "A document in your own colours, ready to send to your municipality, your board or your funding bodies.",
"statsReportItemAttendance": "Attendance, visit durations and trend",
"statsReportItemContents": "Most viewed contents and their trend",
"statsReportItemChannels": "Breakdown per channel, including the voice guide, and per language",
"statsReportItemAdvanced": "Points of interest, quizzes, games and QR scans",
"statsReportGenerated": "Report generated on {date} with MyInfoMate",
"@statsReportGenerated": {
"placeholders": {
"date": { "type": "String" }
}
},
"statsReportError": "The report could not be generated",
"statsReportDownload": "Download the PDF",
"statsAdvancedTitle": "Advanced statistics",
"statsAdvancedBody": "Detailed statistics (POI, quizzes, games, articles, QR…) are available with the Premium plan.",
"statsUnavailableTitle": "Statistics not included",
"statsUnavailableBody": "Your current plan does not include attendance statistics.",
"noData": "No data",
"errorOccurred": "An error occurred",
"yes": "Yes",

View File

@ -18,11 +18,76 @@
"loginError": "Un problème est survenu lors de la connexion",
"rememberMe": "Se souvenir de moi",
"connect": "SE CONNECTER",
"forgotPasswordLink": "Mot de passe oublié ?",
"forgotPasswordTitle": "Mot de passe oublié",
"forgotPasswordDesc": "Indiquez votre e-mail, nous vous envoyons un lien pour définir un nouveau mot de passe.",
"forgotPasswordSubmit": "ENVOYER LE LIEN",
"forgotPasswordSuccess": "Si cet e-mail existe, un lien de réinitialisation vient d'être envoyé.",
"backToLogin": "Retour à la connexion",
"setPasswordTitle": "Définir votre mot de passe",
"setPasswordDesc": "Choisissez un mot de passe pour accéder à votre espace.",
"setPasswordNewLabel": "Nouveau mot de passe",
"setPasswordSubmit": "DÉFINIR LE MOT DE PASSE",
"setPasswordSuccess": "Mot de passe défini — vous pouvez vous connecter.",
"setPasswordError": "Ce lien est invalide ou a expiré",
"setPasswordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"setPasswordMissingToken": "Lien invalide — aucun jeton fourni",
"inviteUserHint": "Un e-mail d'invitation sera envoyé pour définir le mot de passe.",
"menuSubscription": "Abonnement",
"subscriptionTitle": "Abonnement",
"subscriptionTrialActive": "Essai gratuit en cours",
"subscriptionTrialEndsAt": "Votre essai se termine le {date}",
"@subscriptionTrialEndsAt": { "placeholders": { "date": {} } },
"subscriptionTrialNoDate": "Votre essai gratuit est actif.",
"subscriptionPlanActive": "Plan Essentiel actif",
"subscriptionPlanActiveDesc": "Votre abonnement est actif et se renouvelle automatiquement.",
"subscriptionIncludedTitle": "Ce qui est inclus",
"subscriptionUpgradeBtn": "Passer à un abonnement payant",
"subscriptionCheckoutError": "Impossible de démarrer le paiement, réessayez plus tard.",
"subscriptionAddonsTitle": "Add-ons",
"subscriptionAddonsComingSoon": "Bientôt disponible : requêtes IA supplémentaires et autres options.",
"menuApplications": "Applications",
"menuConfigurations": "Configurations",
"menuResources": "Ressources",
"menuStatistics": "Statistiques",
"menuGuideIa": "Guide IA",
"guideIaSubtitle": "Votre guide répond aux questions des visiteurs à partir de vos propres contenus. Il ne répond jamais à partir d'autre chose.",
"guideIaUsageTitle": "Consommation du mois",
"guideIaUsageQuestions": "{count} questions posées",
"guideIaUsageNoQuota": "Aucun plafond défini sur votre offre.",
"guideIaChannelsTitle": "Où le guide est disponible",
"guideIaChannelsSub": "Activable canal par canal, depuis la configuration de chaque application.",
"guideIaChannelOn": "Activé",
"guideIaChannelOff": "Désactivé",
"guideIaIdentityTitle": "Identité du guide",
"guideIaIdentitySub": "C'est ce qui donne son ton aux réponses",
"guideIaNameLabel": "Nom du guide",
"guideIaNameHint": "Le nom que le visiteur voit et entend. Ex : Léon",
"guideIaPersonaLabel": "Personnalité",
"guideIaPersonaHint": "Décrivez-le comme vous présenteriez un guide à un nouveau collègue. Pour démarrer, partez d'un exemple :",
"guideIaExampleWarden": "Gardien passionné",
"guideIaExampleWardenText": "Tu t'appelles Léon, tu es l'ancien gardien du lieu. Tu parles simplement, avec chaleur, et tu glisses volontiers une anecdote. Tu vouvoies les visiteurs.",
"guideIaExampleSober": "Médiateur sobre",
"guideIaExampleSoberText": "Tu es un médiateur culturel calme et précis. Tu réponds en deux ou trois phrases, sans familiarité, en citant les faits établis. Si une information n'est pas certaine, tu le dis. Tu vouvoies les visiteurs.",
"guideIaExampleKids": "Pour les enfants",
"guideIaExampleKidsText": "Tu es une exploratrice enthousiaste qui s'adresse à des enfants de 7 à 12 ans. Tu tutoies, tu poses des questions, tu utilises des comparaisons simples et des mots courts. Tu racontes des histoires.",
"guideIaFallbackLabel": "Quand le guide ne sait pas répondre",
"guideIaFallbackHint": "Le guide en choisit une au hasard. Avec une seule phrase, il la répète à l'identique et ça se remarque tout de suite.",
"guideIaFallbackAdd": "Ajouter une formulation",
"guideIaFallbackTranslated": "Ces phrases sont vues par vos visiteurs : elles seront traduites dans leur langue, comme le reste de vos contenus.",
"guideIaVoiceTitle": "Votre guide à voix haute",
"guideIaVoiceSub": "Le visiteur pose sa question à voix haute et entend la réponse — dans l'application, ou dans ses lunettes connectées.",
"guideIaVoiceFemale": "Voix féminine, chaleureuse",
"guideIaVoiceMale": "Voix masculine, posée",
"guideIaVoiceWakeword": "Réveil : « {word} »",
"guideIaVoiceInfoName": "Le visiteur dit « {word} » pour réveiller le guide, même s'il porte un autre nom. Une voix et un mot de réveil à votre nom sont possibles en option.",
"guideIaVoiceInfoGlasses": "Les lunettes connectées fonctionnent avec l'application mobile installée sur le téléphone du visiteur.",
"guideIaVoiceInfoMultilang": "La même voix parle toutes vos langues : votre guide garde la même identité d'une langue à l'autre.",
"guideIaSave": "Enregistrer",
"guideIaSaved": "Guide enregistré",
"guideIaSaveError": "L'enregistrement a échoué. Réessayez.",
"menuNotifications": "Notifications",
"menuUsers": "Utilisateurs",
"menuApiKeys": "Clés API",
@ -175,6 +240,121 @@
"statsInvalid": "Invalides",
"statsViews": "Vues",
"statsAttendanceTitle": "Fréquentation",
"statsPeriodRange": "Du {from} au {to}",
"@statsPeriodRange": {
"placeholders": {
"from": { "type": "String" },
"to": { "type": "String" }
}
},
"statsFilterPeriod": "Période",
"statsFilterChannel": "Canal",
"statsPeriodDays": "{days} jours",
"@statsPeriodDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"statsPeriodYear": "Année",
"statsChannelMobile": "Application mobile",
"statsChannelTablet": "Borne d'accueil",
"statsChannelWeb": "Site web",
"statsChannelVR": "Casque VR",
"statsChannelVoice": "Guide vocal",
"statsTakeawayUp": "La fréquentation progresse de {percent} % par rapport à la période précédente.",
"@statsTakeawayUp": {
"placeholders": {
"percent": { "type": "String" }
}
},
"statsTakeawayDown": "La fréquentation recule de {percent} % par rapport à la période précédente.",
"@statsTakeawayDown": {
"placeholders": {
"percent": { "type": "String" }
}
},
"statsTakeawayStable": "La fréquentation est stable par rapport à la période précédente.",
"statsTakeawayVolume": "{visits} visites sur la période, soit {perDay} par jour en moyenne.",
"@statsTakeawayVolume": {
"placeholders": {
"visits": { "type": "String" },
"perDay": { "type": "String" }
}
},
"statsTakeawayVoice": "Le guide vocal représente {percent} % des visites : c'est un argument à faire valoir auprès de vos subsidiants.",
"@statsTakeawayVoice": {
"placeholders": {
"percent": { "type": "String" }
}
},
"statsTakeawayChannel": "Le canal « {channel} » concentre {percent} % des visites.",
"@statsTakeawayChannel": {
"placeholders": {
"channel": { "type": "String" },
"percent": { "type": "String" }
}
},
"statsTakeawayContent": "Le contenu « {title} » concentre {percent} % des consultations : c'est votre porte d'entrée, il mérite d'être tenu à jour en priorité.",
"@statsTakeawayContent": {
"placeholders": {
"title": { "type": "String" },
"percent": { "type": "String" }
}
},
"statsKpiVisits": "Visites",
"statsKpiAvgDuration": "Durée moyenne",
"statsKpiContentsPerVisit": "Contenus par visite",
"statsKpiVoiceShare": "Part du guide vocal",
"statsKpiTotalViews": "Contenus consultés",
"statsVsPrevious": "vs période précédente",
"statsTrendStable": "stable",
"statsDurationMinSec": "{minutes} min {seconds} s",
"@statsDurationMinSec": {
"placeholders": {
"minutes": { "type": "String" },
"seconds": { "type": "String" }
}
},
"statsDurationSec": "{seconds} s",
"@statsDurationSec": {
"placeholders": {
"seconds": { "type": "String" }
}
},
"statsVisitsByDaySub": "Les bandes claires signalent les week-ends",
"statsWeekends": "Samedis et dimanches",
"statsPeakDay": "Pic le {date} — {visits} visites",
"@statsPeakDay": {
"placeholders": {
"date": { "type": "String" },
"visits": { "type": "String" }
}
},
"statsTopContents": "Contenus les plus consultés",
"statsTopContentsSub": "Nombre de consultations sur la période",
"statsChannels": "Canaux",
"statsChannelsSub": "Visites par canal",
"statsLanguagesSub": "Visites par langue",
"statsReportTitle": "Votre rapport de fréquentation",
"statsReportBody": "Un document à vos couleurs, prêt à transmettre à votre commune, votre conseil d'administration ou vos subsidiants.",
"statsReportItemAttendance": "Fréquentation, durées de visite et évolution",
"statsReportItemContents": "Contenus les plus consultés et leur évolution",
"statsReportItemChannels": "Répartition par canal, dont le guide vocal, et par langue",
"statsReportItemAdvanced": "Points d'intérêt, quiz, jeux et scans QR",
"statsReportGenerated": "Rapport généré le {date} avec MyInfoMate",
"@statsReportGenerated": {
"placeholders": {
"date": { "type": "String" }
}
},
"statsReportError": "Le rapport n'a pas pu être généré",
"statsReportDownload": "Télécharger le PDF",
"statsAdvancedTitle": "Statistiques avancées",
"statsAdvancedBody": "Les statistiques détaillées (POI, quiz, jeux, articles, QR…) sont disponibles avec le plan Premium.",
"statsUnavailableTitle": "Statistiques non incluses",
"statsUnavailableBody": "Votre plan actuel n'inclut pas les statistiques de fréquentation.",
"noData": "Aucune donnée",
"errorOccurred": "Une erreur est survenue",
"yes": "Oui",

View File

@ -196,6 +196,168 @@ abstract class AppLocalizations {
/// **'SE CONNECTER'**
String get connect;
/// No description provided for @forgotPasswordLink.
///
/// In fr, this message translates to:
/// **'Mot de passe oublié ?'**
String get forgotPasswordLink;
/// No description provided for @forgotPasswordTitle.
///
/// In fr, this message translates to:
/// **'Mot de passe oublié'**
String get forgotPasswordTitle;
/// No description provided for @forgotPasswordDesc.
///
/// In fr, this message translates to:
/// **'Indiquez votre e-mail, nous vous envoyons un lien pour définir un nouveau mot de passe.'**
String get forgotPasswordDesc;
/// No description provided for @forgotPasswordSubmit.
///
/// In fr, this message translates to:
/// **'ENVOYER LE LIEN'**
String get forgotPasswordSubmit;
/// No description provided for @forgotPasswordSuccess.
///
/// In fr, this message translates to:
/// **'Si cet e-mail existe, un lien de réinitialisation vient d\'être envoyé.'**
String get forgotPasswordSuccess;
/// No description provided for @backToLogin.
///
/// In fr, this message translates to:
/// **'Retour à la connexion'**
String get backToLogin;
/// No description provided for @setPasswordTitle.
///
/// In fr, this message translates to:
/// **'Définir votre mot de passe'**
String get setPasswordTitle;
/// No description provided for @setPasswordDesc.
///
/// In fr, this message translates to:
/// **'Choisissez un mot de passe pour accéder à votre espace.'**
String get setPasswordDesc;
/// No description provided for @setPasswordNewLabel.
///
/// In fr, this message translates to:
/// **'Nouveau mot de passe'**
String get setPasswordNewLabel;
/// No description provided for @setPasswordSubmit.
///
/// In fr, this message translates to:
/// **'DÉFINIR LE MOT DE PASSE'**
String get setPasswordSubmit;
/// No description provided for @setPasswordSuccess.
///
/// In fr, this message translates to:
/// **'Mot de passe défini — vous pouvez vous connecter.'**
String get setPasswordSuccess;
/// No description provided for @setPasswordError.
///
/// In fr, this message translates to:
/// **'Ce lien est invalide ou a expiré'**
String get setPasswordError;
/// No description provided for @setPasswordTooShort.
///
/// In fr, this message translates to:
/// **'Le mot de passe doit contenir au moins 8 caractères'**
String get setPasswordTooShort;
/// No description provided for @setPasswordMissingToken.
///
/// In fr, this message translates to:
/// **'Lien invalide — aucun jeton fourni'**
String get setPasswordMissingToken;
/// No description provided for @inviteUserHint.
///
/// In fr, this message translates to:
/// **'Un e-mail d\'invitation sera envoyé pour définir le mot de passe.'**
String get inviteUserHint;
/// No description provided for @menuSubscription.
///
/// In fr, this message translates to:
/// **'Abonnement'**
String get menuSubscription;
/// No description provided for @subscriptionTitle.
///
/// In fr, this message translates to:
/// **'Abonnement'**
String get subscriptionTitle;
/// No description provided for @subscriptionTrialActive.
///
/// In fr, this message translates to:
/// **'Essai gratuit en cours'**
String get subscriptionTrialActive;
/// No description provided for @subscriptionTrialEndsAt.
///
/// In fr, this message translates to:
/// **'Votre essai se termine le {date}'**
String subscriptionTrialEndsAt(Object date);
/// No description provided for @subscriptionTrialNoDate.
///
/// In fr, this message translates to:
/// **'Votre essai gratuit est actif.'**
String get subscriptionTrialNoDate;
/// No description provided for @subscriptionPlanActive.
///
/// In fr, this message translates to:
/// **'Plan Essentiel actif'**
String get subscriptionPlanActive;
/// No description provided for @subscriptionPlanActiveDesc.
///
/// In fr, this message translates to:
/// **'Votre abonnement est actif et se renouvelle automatiquement.'**
String get subscriptionPlanActiveDesc;
/// No description provided for @subscriptionIncludedTitle.
///
/// In fr, this message translates to:
/// **'Ce qui est inclus'**
String get subscriptionIncludedTitle;
/// No description provided for @subscriptionUpgradeBtn.
///
/// In fr, this message translates to:
/// **'Passer à un abonnement payant'**
String get subscriptionUpgradeBtn;
/// No description provided for @subscriptionCheckoutError.
///
/// In fr, this message translates to:
/// **'Impossible de démarrer le paiement, réessayez plus tard.'**
String get subscriptionCheckoutError;
/// No description provided for @subscriptionAddonsTitle.
///
/// In fr, this message translates to:
/// **'Add-ons'**
String get subscriptionAddonsTitle;
/// No description provided for @subscriptionAddonsComingSoon.
///
/// In fr, this message translates to:
/// **'Bientôt disponible : requêtes IA supplémentaires et autres options.'**
String get subscriptionAddonsComingSoon;
/// No description provided for @menuApplications.
///
/// In fr, this message translates to:
@ -220,6 +382,222 @@ abstract class AppLocalizations {
/// **'Statistiques'**
String get menuStatistics;
/// No description provided for @menuGuideIa.
///
/// In fr, this message translates to:
/// **'Guide IA'**
String get menuGuideIa;
/// No description provided for @guideIaSubtitle.
///
/// In fr, this message translates to:
/// **'Votre guide répond aux questions des visiteurs à partir de vos propres contenus. Il ne répond jamais à partir d\'autre chose.'**
String get guideIaSubtitle;
/// No description provided for @guideIaUsageTitle.
///
/// In fr, this message translates to:
/// **'Consommation du mois'**
String get guideIaUsageTitle;
/// No description provided for @guideIaUsageQuestions.
///
/// In fr, this message translates to:
/// **'{count} questions posées'**
String guideIaUsageQuestions(Object count);
/// No description provided for @guideIaUsageNoQuota.
///
/// In fr, this message translates to:
/// **'Aucun plafond défini sur votre offre.'**
String get guideIaUsageNoQuota;
/// No description provided for @guideIaChannelsTitle.
///
/// In fr, this message translates to:
/// **'Où le guide est disponible'**
String get guideIaChannelsTitle;
/// No description provided for @guideIaChannelsSub.
///
/// In fr, this message translates to:
/// **'Activable canal par canal, depuis la configuration de chaque application.'**
String get guideIaChannelsSub;
/// No description provided for @guideIaChannelOn.
///
/// In fr, this message translates to:
/// **'Activé'**
String get guideIaChannelOn;
/// No description provided for @guideIaChannelOff.
///
/// In fr, this message translates to:
/// **'Désactivé'**
String get guideIaChannelOff;
/// No description provided for @guideIaIdentityTitle.
///
/// In fr, this message translates to:
/// **'Identité du guide'**
String get guideIaIdentityTitle;
/// No description provided for @guideIaIdentitySub.
///
/// In fr, this message translates to:
/// **'C\'est ce qui donne son ton aux réponses'**
String get guideIaIdentitySub;
/// No description provided for @guideIaNameLabel.
///
/// In fr, this message translates to:
/// **'Nom du guide'**
String get guideIaNameLabel;
/// No description provided for @guideIaNameHint.
///
/// In fr, this message translates to:
/// **'Le nom que le visiteur voit et entend. Ex : Léon'**
String get guideIaNameHint;
/// No description provided for @guideIaPersonaLabel.
///
/// In fr, this message translates to:
/// **'Personnalité'**
String get guideIaPersonaLabel;
/// No description provided for @guideIaPersonaHint.
///
/// In fr, this message translates to:
/// **'Décrivez-le comme vous présenteriez un guide à un nouveau collègue. Pour démarrer, partez d\'un exemple :'**
String get guideIaPersonaHint;
/// No description provided for @guideIaExampleWarden.
///
/// In fr, this message translates to:
/// **'Gardien passionné'**
String get guideIaExampleWarden;
/// No description provided for @guideIaExampleWardenText.
///
/// In fr, this message translates to:
/// **'Tu t\'appelles Léon, tu es l\'ancien gardien du lieu. Tu parles simplement, avec chaleur, et tu glisses volontiers une anecdote. Tu vouvoies les visiteurs.'**
String get guideIaExampleWardenText;
/// No description provided for @guideIaExampleSober.
///
/// In fr, this message translates to:
/// **'Médiateur sobre'**
String get guideIaExampleSober;
/// No description provided for @guideIaExampleSoberText.
///
/// In fr, this message translates to:
/// **'Tu es un médiateur culturel calme et précis. Tu réponds en deux ou trois phrases, sans familiarité, en citant les faits établis. Si une information n\'est pas certaine, tu le dis. Tu vouvoies les visiteurs.'**
String get guideIaExampleSoberText;
/// No description provided for @guideIaExampleKids.
///
/// In fr, this message translates to:
/// **'Pour les enfants'**
String get guideIaExampleKids;
/// No description provided for @guideIaExampleKidsText.
///
/// In fr, this message translates to:
/// **'Tu es une exploratrice enthousiaste qui s\'adresse à des enfants de 7 à 12 ans. Tu tutoies, tu poses des questions, tu utilises des comparaisons simples et des mots courts. Tu racontes des histoires.'**
String get guideIaExampleKidsText;
/// No description provided for @guideIaFallbackLabel.
///
/// In fr, this message translates to:
/// **'Quand le guide ne sait pas répondre'**
String get guideIaFallbackLabel;
/// No description provided for @guideIaFallbackHint.
///
/// In fr, this message translates to:
/// **'Le guide en choisit une au hasard. Avec une seule phrase, il la répète à l\'identique et ça se remarque tout de suite.'**
String get guideIaFallbackHint;
/// No description provided for @guideIaFallbackAdd.
///
/// In fr, this message translates to:
/// **'Ajouter une formulation'**
String get guideIaFallbackAdd;
/// No description provided for @guideIaFallbackTranslated.
///
/// In fr, this message translates to:
/// **'Ces phrases sont vues par vos visiteurs : elles seront traduites dans leur langue, comme le reste de vos contenus.'**
String get guideIaFallbackTranslated;
/// No description provided for @guideIaVoiceTitle.
///
/// In fr, this message translates to:
/// **'Votre guide à voix haute'**
String get guideIaVoiceTitle;
/// No description provided for @guideIaVoiceSub.
///
/// In fr, this message translates to:
/// **'Le visiteur pose sa question à voix haute et entend la réponse — dans l\'application, ou dans ses lunettes connectées.'**
String get guideIaVoiceSub;
/// No description provided for @guideIaVoiceFemale.
///
/// In fr, this message translates to:
/// **'Voix féminine, chaleureuse'**
String get guideIaVoiceFemale;
/// No description provided for @guideIaVoiceMale.
///
/// In fr, this message translates to:
/// **'Voix masculine, posée'**
String get guideIaVoiceMale;
/// No description provided for @guideIaVoiceWakeword.
///
/// In fr, this message translates to:
/// **'Réveil : « {word} »'**
String guideIaVoiceWakeword(Object word);
/// No description provided for @guideIaVoiceInfoName.
///
/// In fr, this message translates to:
/// **'Le visiteur dit « {word} » pour réveiller le guide, même s\'il porte un autre nom. Une voix et un mot de réveil à votre nom sont possibles en option.'**
String guideIaVoiceInfoName(Object word);
/// No description provided for @guideIaVoiceInfoGlasses.
///
/// In fr, this message translates to:
/// **'Les lunettes connectées fonctionnent avec l\'application mobile installée sur le téléphone du visiteur.'**
String get guideIaVoiceInfoGlasses;
/// No description provided for @guideIaVoiceInfoMultilang.
///
/// In fr, this message translates to:
/// **'La même voix parle toutes vos langues : votre guide garde la même identité d\'une langue à l\'autre.'**
String get guideIaVoiceInfoMultilang;
/// No description provided for @guideIaSave.
///
/// In fr, this message translates to:
/// **'Enregistrer'**
String get guideIaSave;
/// No description provided for @guideIaSaved.
///
/// In fr, this message translates to:
/// **'Guide enregistré'**
String get guideIaSaved;
/// No description provided for @guideIaSaveError.
///
/// In fr, this message translates to:
/// **'L\'enregistrement a échoué. Réessayez.'**
String get guideIaSaveError;
/// No description provided for @menuNotifications.
///
/// In fr, this message translates to:
@ -856,6 +1234,294 @@ abstract class AppLocalizations {
/// **'Vues'**
String get statsViews;
/// No description provided for @statsAttendanceTitle.
///
/// In fr, this message translates to:
/// **'Fréquentation'**
String get statsAttendanceTitle;
/// No description provided for @statsPeriodRange.
///
/// In fr, this message translates to:
/// **'Du {from} au {to}'**
String statsPeriodRange(String from, String to);
/// No description provided for @statsFilterPeriod.
///
/// In fr, this message translates to:
/// **'Période'**
String get statsFilterPeriod;
/// No description provided for @statsFilterChannel.
///
/// In fr, this message translates to:
/// **'Canal'**
String get statsFilterChannel;
/// No description provided for @statsPeriodDays.
///
/// In fr, this message translates to:
/// **'{days} jours'**
String statsPeriodDays(int days);
/// No description provided for @statsPeriodYear.
///
/// In fr, this message translates to:
/// **'Année'**
String get statsPeriodYear;
/// No description provided for @statsChannelMobile.
///
/// In fr, this message translates to:
/// **'Application mobile'**
String get statsChannelMobile;
/// No description provided for @statsChannelTablet.
///
/// In fr, this message translates to:
/// **'Borne d\'accueil'**
String get statsChannelTablet;
/// No description provided for @statsChannelWeb.
///
/// In fr, this message translates to:
/// **'Site web'**
String get statsChannelWeb;
/// No description provided for @statsChannelVR.
///
/// In fr, this message translates to:
/// **'Casque VR'**
String get statsChannelVR;
/// No description provided for @statsChannelVoice.
///
/// In fr, this message translates to:
/// **'Guide vocal'**
String get statsChannelVoice;
/// No description provided for @statsTakeawayUp.
///
/// In fr, this message translates to:
/// **'La fréquentation progresse de {percent} % par rapport à la période précédente.'**
String statsTakeawayUp(String percent);
/// No description provided for @statsTakeawayDown.
///
/// In fr, this message translates to:
/// **'La fréquentation recule de {percent} % par rapport à la période précédente.'**
String statsTakeawayDown(String percent);
/// No description provided for @statsTakeawayStable.
///
/// In fr, this message translates to:
/// **'La fréquentation est stable par rapport à la période précédente.'**
String get statsTakeawayStable;
/// No description provided for @statsTakeawayVolume.
///
/// In fr, this message translates to:
/// **'{visits} visites sur la période, soit {perDay} par jour en moyenne.'**
String statsTakeawayVolume(String visits, String perDay);
/// No description provided for @statsTakeawayVoice.
///
/// In fr, this message translates to:
/// **'Le guide vocal représente {percent} % des visites : c\'est un argument à faire valoir auprès de vos subsidiants.'**
String statsTakeawayVoice(String percent);
/// No description provided for @statsTakeawayChannel.
///
/// In fr, this message translates to:
/// **'Le canal « {channel} » concentre {percent} % des visites.'**
String statsTakeawayChannel(String channel, String percent);
/// No description provided for @statsTakeawayContent.
///
/// In fr, this message translates to:
/// **'Le contenu « {title} » concentre {percent} % des consultations : c\'est votre porte d\'entrée, il mérite d\'être tenu à jour en priorité.'**
String statsTakeawayContent(String title, String percent);
/// No description provided for @statsKpiVisits.
///
/// In fr, this message translates to:
/// **'Visites'**
String get statsKpiVisits;
/// No description provided for @statsKpiAvgDuration.
///
/// In fr, this message translates to:
/// **'Durée moyenne'**
String get statsKpiAvgDuration;
/// No description provided for @statsKpiContentsPerVisit.
///
/// In fr, this message translates to:
/// **'Contenus par visite'**
String get statsKpiContentsPerVisit;
/// No description provided for @statsKpiVoiceShare.
///
/// In fr, this message translates to:
/// **'Part du guide vocal'**
String get statsKpiVoiceShare;
/// No description provided for @statsKpiTotalViews.
///
/// In fr, this message translates to:
/// **'Contenus consultés'**
String get statsKpiTotalViews;
/// No description provided for @statsVsPrevious.
///
/// In fr, this message translates to:
/// **'vs période précédente'**
String get statsVsPrevious;
/// No description provided for @statsTrendStable.
///
/// In fr, this message translates to:
/// **'stable'**
String get statsTrendStable;
/// No description provided for @statsDurationMinSec.
///
/// In fr, this message translates to:
/// **'{minutes} min {seconds} s'**
String statsDurationMinSec(String minutes, String seconds);
/// No description provided for @statsDurationSec.
///
/// In fr, this message translates to:
/// **'{seconds} s'**
String statsDurationSec(String seconds);
/// No description provided for @statsVisitsByDaySub.
///
/// In fr, this message translates to:
/// **'Les bandes claires signalent les week-ends'**
String get statsVisitsByDaySub;
/// No description provided for @statsWeekends.
///
/// In fr, this message translates to:
/// **'Samedis et dimanches'**
String get statsWeekends;
/// No description provided for @statsPeakDay.
///
/// In fr, this message translates to:
/// **'Pic le {date} — {visits} visites'**
String statsPeakDay(String date, String visits);
/// No description provided for @statsTopContents.
///
/// In fr, this message translates to:
/// **'Contenus les plus consultés'**
String get statsTopContents;
/// No description provided for @statsTopContentsSub.
///
/// In fr, this message translates to:
/// **'Nombre de consultations sur la période'**
String get statsTopContentsSub;
/// No description provided for @statsChannels.
///
/// In fr, this message translates to:
/// **'Canaux'**
String get statsChannels;
/// No description provided for @statsChannelsSub.
///
/// In fr, this message translates to:
/// **'Visites par canal'**
String get statsChannelsSub;
/// No description provided for @statsLanguagesSub.
///
/// In fr, this message translates to:
/// **'Visites par langue'**
String get statsLanguagesSub;
/// No description provided for @statsReportTitle.
///
/// In fr, this message translates to:
/// **'Votre rapport de fréquentation'**
String get statsReportTitle;
/// No description provided for @statsReportBody.
///
/// In fr, this message translates to:
/// **'Un document à vos couleurs, prêt à transmettre à votre commune, votre conseil d\'administration ou vos subsidiants.'**
String get statsReportBody;
/// No description provided for @statsReportItemAttendance.
///
/// In fr, this message translates to:
/// **'Fréquentation, durées de visite et évolution'**
String get statsReportItemAttendance;
/// No description provided for @statsReportItemContents.
///
/// In fr, this message translates to:
/// **'Contenus les plus consultés et leur évolution'**
String get statsReportItemContents;
/// No description provided for @statsReportItemChannels.
///
/// In fr, this message translates to:
/// **'Répartition par canal, dont le guide vocal, et par langue'**
String get statsReportItemChannels;
/// No description provided for @statsReportItemAdvanced.
///
/// In fr, this message translates to:
/// **'Points d\'intérêt, quiz, jeux et scans QR'**
String get statsReportItemAdvanced;
/// No description provided for @statsReportGenerated.
///
/// In fr, this message translates to:
/// **'Rapport généré le {date} avec MyInfoMate'**
String statsReportGenerated(String date);
/// No description provided for @statsReportError.
///
/// In fr, this message translates to:
/// **'Le rapport n\'a pas pu être généré'**
String get statsReportError;
/// No description provided for @statsReportDownload.
///
/// In fr, this message translates to:
/// **'Télécharger le PDF'**
String get statsReportDownload;
/// No description provided for @statsAdvancedTitle.
///
/// In fr, this message translates to:
/// **'Statistiques avancées'**
String get statsAdvancedTitle;
/// No description provided for @statsAdvancedBody.
///
/// In fr, this message translates to:
/// **'Les statistiques détaillées (POI, quiz, jeux, articles, QR…) sont disponibles avec le plan Premium.'**
String get statsAdvancedBody;
/// No description provided for @statsUnavailableTitle.
///
/// In fr, this message translates to:
/// **'Statistiques non incluses'**
String get statsUnavailableTitle;
/// No description provided for @statsUnavailableBody.
///
/// In fr, this message translates to:
/// **'Votre plan actuel n\'inclut pas les statistiques de fréquentation.'**
String get statsUnavailableBody;
/// No description provided for @noData.
///
/// In fr, this message translates to:

View File

@ -56,6 +56,95 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get connect => 'LOG IN';
@override
String get forgotPasswordLink => 'Forgot your password?';
@override
String get forgotPasswordTitle => 'Forgot password';
@override
String get forgotPasswordDesc =>
'Enter your email and we\'ll send you a link to set a new password.';
@override
String get forgotPasswordSubmit => 'SEND LINK';
@override
String get forgotPasswordSuccess =>
'If this email exists, a reset link has just been sent.';
@override
String get backToLogin => 'Back to login';
@override
String get setPasswordTitle => 'Set your password';
@override
String get setPasswordDesc => 'Choose a password to access your workspace.';
@override
String get setPasswordNewLabel => 'New password';
@override
String get setPasswordSubmit => 'SET PASSWORD';
@override
String get setPasswordSuccess => 'Password set — you can now log in.';
@override
String get setPasswordError => 'This link is invalid or has expired';
@override
String get setPasswordTooShort => 'Password must be at least 8 characters';
@override
String get setPasswordMissingToken => 'Invalid link — no token provided';
@override
String get inviteUserHint =>
'An invitation email will be sent to set the password.';
@override
String get menuSubscription => 'Subscription';
@override
String get subscriptionTitle => 'Subscription';
@override
String get subscriptionTrialActive => 'Free trial in progress';
@override
String subscriptionTrialEndsAt(Object date) {
return 'Your trial ends on $date';
}
@override
String get subscriptionTrialNoDate => 'Your free trial is active.';
@override
String get subscriptionPlanActive => 'Essentiel plan active';
@override
String get subscriptionPlanActiveDesc =>
'Your subscription is active and renews automatically.';
@override
String get subscriptionIncludedTitle => 'What\'s included';
@override
String get subscriptionUpgradeBtn => 'Upgrade to a paid subscription';
@override
String get subscriptionCheckoutError =>
'Could not start checkout, please try again later.';
@override
String get subscriptionAddonsTitle => 'Add-ons';
@override
String get subscriptionAddonsComingSoon =>
'Coming soon: extra AI requests and other options.';
@override
String get menuApplications => 'Applications';
@ -68,6 +157,131 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get menuStatistics => 'Statistics';
@override
String get menuGuideIa => 'AI Guide';
@override
String get guideIaSubtitle =>
'Your guide answers visitor questions using your own content. It never answers from anything else.';
@override
String get guideIaUsageTitle => 'This month\'s usage';
@override
String guideIaUsageQuestions(Object count) {
return '$count questions asked';
}
@override
String get guideIaUsageNoQuota => 'No cap set on your plan.';
@override
String get guideIaChannelsTitle => 'Where the guide is available';
@override
String get guideIaChannelsSub =>
'Enabled per channel, from each application\'s settings.';
@override
String get guideIaChannelOn => 'Enabled';
@override
String get guideIaChannelOff => 'Disabled';
@override
String get guideIaIdentityTitle => 'Guide identity';
@override
String get guideIaIdentitySub => 'This is what gives the answers their tone';
@override
String get guideIaNameLabel => 'Guide name';
@override
String get guideIaNameHint => 'The name visitors see and hear. E.g. Leon';
@override
String get guideIaPersonaLabel => 'Personality';
@override
String get guideIaPersonaHint =>
'Describe it the way you would introduce a guide to a new colleague. To get started, pick an example:';
@override
String get guideIaExampleWarden => 'Passionate warden';
@override
String get guideIaExampleWardenText =>
'Your name is Leon, you are the site\'s former warden. You speak plainly and warmly, and you happily slip in an anecdote. Address visitors politely.';
@override
String get guideIaExampleSober => 'Measured mediator';
@override
String get guideIaExampleSoberText =>
'You are a calm, precise cultural mediator. You answer in two or three sentences, without familiarity, citing established facts. If something is uncertain, you say so.';
@override
String get guideIaExampleKids => 'For children';
@override
String get guideIaExampleKidsText =>
'You are an enthusiastic explorer speaking to children aged 7 to 12. Use informal language, ask questions, use simple comparisons and short words. Tell stories.';
@override
String get guideIaFallbackLabel => 'When the guide doesn\'t know';
@override
String get guideIaFallbackHint =>
'The guide picks one at random. With a single sentence it repeats it word for word, and that shows immediately.';
@override
String get guideIaFallbackAdd => 'Add a wording';
@override
String get guideIaFallbackTranslated =>
'Visitors see these sentences: they will be translated into their language, like the rest of your content.';
@override
String get guideIaVoiceTitle => 'Your guide out loud';
@override
String get guideIaVoiceSub =>
'Visitors ask out loud and hear the answer — in the app, or in their connected glasses.';
@override
String get guideIaVoiceFemale => 'Female voice, warm';
@override
String get guideIaVoiceMale => 'Male voice, steady';
@override
String guideIaVoiceWakeword(Object word) {
return 'Wake word: \"$word\"';
}
@override
String guideIaVoiceInfoName(Object word) {
return 'Visitors say \"$word\" to wake the guide, even if it goes by another name. A voice and wake word of your own are available as an option.';
}
@override
String get guideIaVoiceInfoGlasses =>
'Connected glasses work with the mobile app installed on the visitor\'s phone.';
@override
String get guideIaVoiceInfoMultilang =>
'The same voice speaks all your languages: your guide keeps one identity across them.';
@override
String get guideIaSave => 'Save';
@override
String get guideIaSaved => 'Guide saved';
@override
String get guideIaSaveError => 'Saving failed. Please try again.';
@override
String get menuNotifications => 'Notifications';
@ -408,6 +622,181 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get statsViews => 'Views';
@override
String get statsAttendanceTitle => 'Attendance';
@override
String statsPeriodRange(String from, String to) {
return 'From $from to $to';
}
@override
String get statsFilterPeriod => 'Period';
@override
String get statsFilterChannel => 'Channel';
@override
String statsPeriodDays(int days) {
return '$days days';
}
@override
String get statsPeriodYear => 'Year';
@override
String get statsChannelMobile => 'Mobile app';
@override
String get statsChannelTablet => 'Kiosk';
@override
String get statsChannelWeb => 'Website';
@override
String get statsChannelVR => 'VR headset';
@override
String get statsChannelVoice => 'Voice guide';
@override
String statsTakeawayUp(String percent) {
return 'Attendance is up $percent % compared to the previous period.';
}
@override
String statsTakeawayDown(String percent) {
return 'Attendance is down $percent % compared to the previous period.';
}
@override
String get statsTakeawayStable =>
'Attendance is stable compared to the previous period.';
@override
String statsTakeawayVolume(String visits, String perDay) {
return '$visits visits over the period, or $perDay a day on average.';
}
@override
String statsTakeawayVoice(String percent) {
return 'The voice guide accounts for $percent % of visits — a strong argument for your funding bodies.';
}
@override
String statsTakeawayChannel(String channel, String percent) {
return 'The « $channel » channel accounts for $percent % of visits.';
}
@override
String statsTakeawayContent(String title, String percent) {
return '« $title » alone accounts for $percent % of all views: it is your front door, keep it up to date first.';
}
@override
String get statsKpiVisits => 'Visits';
@override
String get statsKpiAvgDuration => 'Average duration';
@override
String get statsKpiContentsPerVisit => 'Contents per visit';
@override
String get statsKpiVoiceShare => 'Voice guide share';
@override
String get statsKpiTotalViews => 'Contents viewed';
@override
String get statsVsPrevious => 'vs previous period';
@override
String get statsTrendStable => 'stable';
@override
String statsDurationMinSec(String minutes, String seconds) {
return '$minutes min $seconds s';
}
@override
String statsDurationSec(String seconds) {
return '$seconds s';
}
@override
String get statsVisitsByDaySub => 'Light bands mark the weekends';
@override
String get statsWeekends => 'Saturdays and Sundays';
@override
String statsPeakDay(String date, String visits) {
return 'Peak on $date$visits visits';
}
@override
String get statsTopContents => 'Most viewed contents';
@override
String get statsTopContentsSub => 'Number of views over the period';
@override
String get statsChannels => 'Channels';
@override
String get statsChannelsSub => 'Visits per channel';
@override
String get statsLanguagesSub => 'Visits per language';
@override
String get statsReportTitle => 'Your attendance report';
@override
String get statsReportBody =>
'A document in your own colours, ready to send to your municipality, your board or your funding bodies.';
@override
String get statsReportItemAttendance =>
'Attendance, visit durations and trend';
@override
String get statsReportItemContents => 'Most viewed contents and their trend';
@override
String get statsReportItemChannels =>
'Breakdown per channel, including the voice guide, and per language';
@override
String get statsReportItemAdvanced =>
'Points of interest, quizzes, games and QR scans';
@override
String statsReportGenerated(String date) {
return 'Report generated on $date with MyInfoMate';
}
@override
String get statsReportError => 'The report could not be generated';
@override
String get statsReportDownload => 'Download the PDF';
@override
String get statsAdvancedTitle => 'Advanced statistics';
@override
String get statsAdvancedBody =>
'Detailed statistics (POI, quizzes, games, articles, QR…) are available with the Premium plan.';
@override
String get statsUnavailableTitle => 'Statistics not included';
@override
String get statsUnavailableBody =>
'Your current plan does not include attendance statistics.';
@override
String get noData => 'No data';

View File

@ -56,6 +56,98 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get connect => 'SE CONNECTER';
@override
String get forgotPasswordLink => 'Mot de passe oublié ?';
@override
String get forgotPasswordTitle => 'Mot de passe oublié';
@override
String get forgotPasswordDesc =>
'Indiquez votre e-mail, nous vous envoyons un lien pour définir un nouveau mot de passe.';
@override
String get forgotPasswordSubmit => 'ENVOYER LE LIEN';
@override
String get forgotPasswordSuccess =>
'Si cet e-mail existe, un lien de réinitialisation vient d\'être envoyé.';
@override
String get backToLogin => 'Retour à la connexion';
@override
String get setPasswordTitle => 'Définir votre mot de passe';
@override
String get setPasswordDesc =>
'Choisissez un mot de passe pour accéder à votre espace.';
@override
String get setPasswordNewLabel => 'Nouveau mot de passe';
@override
String get setPasswordSubmit => 'DÉFINIR LE MOT DE PASSE';
@override
String get setPasswordSuccess =>
'Mot de passe défini — vous pouvez vous connecter.';
@override
String get setPasswordError => 'Ce lien est invalide ou a expiré';
@override
String get setPasswordTooShort =>
'Le mot de passe doit contenir au moins 8 caractères';
@override
String get setPasswordMissingToken => 'Lien invalide — aucun jeton fourni';
@override
String get inviteUserHint =>
'Un e-mail d\'invitation sera envoyé pour définir le mot de passe.';
@override
String get menuSubscription => 'Abonnement';
@override
String get subscriptionTitle => 'Abonnement';
@override
String get subscriptionTrialActive => 'Essai gratuit en cours';
@override
String subscriptionTrialEndsAt(Object date) {
return 'Votre essai se termine le $date';
}
@override
String get subscriptionTrialNoDate => 'Votre essai gratuit est actif.';
@override
String get subscriptionPlanActive => 'Plan Essentiel actif';
@override
String get subscriptionPlanActiveDesc =>
'Votre abonnement est actif et se renouvelle automatiquement.';
@override
String get subscriptionIncludedTitle => 'Ce qui est inclus';
@override
String get subscriptionUpgradeBtn => 'Passer à un abonnement payant';
@override
String get subscriptionCheckoutError =>
'Impossible de démarrer le paiement, réessayez plus tard.';
@override
String get subscriptionAddonsTitle => 'Add-ons';
@override
String get subscriptionAddonsComingSoon =>
'Bientôt disponible : requêtes IA supplémentaires et autres options.';
@override
String get menuApplications => 'Applications';
@ -68,6 +160,132 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get menuStatistics => 'Statistiques';
@override
String get menuGuideIa => 'Guide IA';
@override
String get guideIaSubtitle =>
'Votre guide répond aux questions des visiteurs à partir de vos propres contenus. Il ne répond jamais à partir d\'autre chose.';
@override
String get guideIaUsageTitle => 'Consommation du mois';
@override
String guideIaUsageQuestions(Object count) {
return '$count questions posées';
}
@override
String get guideIaUsageNoQuota => 'Aucun plafond défini sur votre offre.';
@override
String get guideIaChannelsTitle => 'Où le guide est disponible';
@override
String get guideIaChannelsSub =>
'Activable canal par canal, depuis la configuration de chaque application.';
@override
String get guideIaChannelOn => 'Activé';
@override
String get guideIaChannelOff => 'Désactivé';
@override
String get guideIaIdentityTitle => 'Identité du guide';
@override
String get guideIaIdentitySub => 'C\'est ce qui donne son ton aux réponses';
@override
String get guideIaNameLabel => 'Nom du guide';
@override
String get guideIaNameHint =>
'Le nom que le visiteur voit et entend. Ex : Léon';
@override
String get guideIaPersonaLabel => 'Personnalité';
@override
String get guideIaPersonaHint =>
'Décrivez-le comme vous présenteriez un guide à un nouveau collègue. Pour démarrer, partez d\'un exemple :';
@override
String get guideIaExampleWarden => 'Gardien passionné';
@override
String get guideIaExampleWardenText =>
'Tu t\'appelles Léon, tu es l\'ancien gardien du lieu. Tu parles simplement, avec chaleur, et tu glisses volontiers une anecdote. Tu vouvoies les visiteurs.';
@override
String get guideIaExampleSober => 'Médiateur sobre';
@override
String get guideIaExampleSoberText =>
'Tu es un médiateur culturel calme et précis. Tu réponds en deux ou trois phrases, sans familiarité, en citant les faits établis. Si une information n\'est pas certaine, tu le dis. Tu vouvoies les visiteurs.';
@override
String get guideIaExampleKids => 'Pour les enfants';
@override
String get guideIaExampleKidsText =>
'Tu es une exploratrice enthousiaste qui s\'adresse à des enfants de 7 à 12 ans. Tu tutoies, tu poses des questions, tu utilises des comparaisons simples et des mots courts. Tu racontes des histoires.';
@override
String get guideIaFallbackLabel => 'Quand le guide ne sait pas répondre';
@override
String get guideIaFallbackHint =>
'Le guide en choisit une au hasard. Avec une seule phrase, il la répète à l\'identique et ça se remarque tout de suite.';
@override
String get guideIaFallbackAdd => 'Ajouter une formulation';
@override
String get guideIaFallbackTranslated =>
'Ces phrases sont vues par vos visiteurs : elles seront traduites dans leur langue, comme le reste de vos contenus.';
@override
String get guideIaVoiceTitle => 'Votre guide à voix haute';
@override
String get guideIaVoiceSub =>
'Le visiteur pose sa question à voix haute et entend la réponse — dans l\'application, ou dans ses lunettes connectées.';
@override
String get guideIaVoiceFemale => 'Voix féminine, chaleureuse';
@override
String get guideIaVoiceMale => 'Voix masculine, posée';
@override
String guideIaVoiceWakeword(Object word) {
return 'Réveil : « $word »';
}
@override
String guideIaVoiceInfoName(Object word) {
return 'Le visiteur dit « $word » pour réveiller le guide, même s\'il porte un autre nom. Une voix et un mot de réveil à votre nom sont possibles en option.';
}
@override
String get guideIaVoiceInfoGlasses =>
'Les lunettes connectées fonctionnent avec l\'application mobile installée sur le téléphone du visiteur.';
@override
String get guideIaVoiceInfoMultilang =>
'La même voix parle toutes vos langues : votre guide garde la même identité d\'une langue à l\'autre.';
@override
String get guideIaSave => 'Enregistrer';
@override
String get guideIaSaved => 'Guide enregistré';
@override
String get guideIaSaveError => 'L\'enregistrement a échoué. Réessayez.';
@override
String get menuNotifications => 'Notifications';
@ -410,6 +628,183 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get statsViews => 'Vues';
@override
String get statsAttendanceTitle => 'Fréquentation';
@override
String statsPeriodRange(String from, String to) {
return 'Du $from au $to';
}
@override
String get statsFilterPeriod => 'Période';
@override
String get statsFilterChannel => 'Canal';
@override
String statsPeriodDays(int days) {
return '$days jours';
}
@override
String get statsPeriodYear => 'Année';
@override
String get statsChannelMobile => 'Application mobile';
@override
String get statsChannelTablet => 'Borne d\'accueil';
@override
String get statsChannelWeb => 'Site web';
@override
String get statsChannelVR => 'Casque VR';
@override
String get statsChannelVoice => 'Guide vocal';
@override
String statsTakeawayUp(String percent) {
return 'La fréquentation progresse de $percent % par rapport à la période précédente.';
}
@override
String statsTakeawayDown(String percent) {
return 'La fréquentation recule de $percent % par rapport à la période précédente.';
}
@override
String get statsTakeawayStable =>
'La fréquentation est stable par rapport à la période précédente.';
@override
String statsTakeawayVolume(String visits, String perDay) {
return '$visits visites sur la période, soit $perDay par jour en moyenne.';
}
@override
String statsTakeawayVoice(String percent) {
return 'Le guide vocal représente $percent % des visites : c\'est un argument à faire valoir auprès de vos subsidiants.';
}
@override
String statsTakeawayChannel(String channel, String percent) {
return 'Le canal « $channel » concentre $percent % des visites.';
}
@override
String statsTakeawayContent(String title, String percent) {
return 'Le contenu « $title » concentre $percent % des consultations : c\'est votre porte d\'entrée, il mérite d\'être tenu à jour en priorité.';
}
@override
String get statsKpiVisits => 'Visites';
@override
String get statsKpiAvgDuration => 'Durée moyenne';
@override
String get statsKpiContentsPerVisit => 'Contenus par visite';
@override
String get statsKpiVoiceShare => 'Part du guide vocal';
@override
String get statsKpiTotalViews => 'Contenus consultés';
@override
String get statsVsPrevious => 'vs période précédente';
@override
String get statsTrendStable => 'stable';
@override
String statsDurationMinSec(String minutes, String seconds) {
return '$minutes min $seconds s';
}
@override
String statsDurationSec(String seconds) {
return '$seconds s';
}
@override
String get statsVisitsByDaySub =>
'Les bandes claires signalent les week-ends';
@override
String get statsWeekends => 'Samedis et dimanches';
@override
String statsPeakDay(String date, String visits) {
return 'Pic le $date$visits visites';
}
@override
String get statsTopContents => 'Contenus les plus consultés';
@override
String get statsTopContentsSub => 'Nombre de consultations sur la période';
@override
String get statsChannels => 'Canaux';
@override
String get statsChannelsSub => 'Visites par canal';
@override
String get statsLanguagesSub => 'Visites par langue';
@override
String get statsReportTitle => 'Votre rapport de fréquentation';
@override
String get statsReportBody =>
'Un document à vos couleurs, prêt à transmettre à votre commune, votre conseil d\'administration ou vos subsidiants.';
@override
String get statsReportItemAttendance =>
'Fréquentation, durées de visite et évolution';
@override
String get statsReportItemContents =>
'Contenus les plus consultés et leur évolution';
@override
String get statsReportItemChannels =>
'Répartition par canal, dont le guide vocal, et par langue';
@override
String get statsReportItemAdvanced =>
'Points d\'intérêt, quiz, jeux et scans QR';
@override
String statsReportGenerated(String date) {
return 'Rapport généré le $date avec MyInfoMate';
}
@override
String get statsReportError => 'Le rapport n\'a pas pu être généré';
@override
String get statsReportDownload => 'Télécharger le PDF';
@override
String get statsAdvancedTitle => 'Statistiques avancées';
@override
String get statsAdvancedBody =>
'Les statistiques détaillées (POI, quiz, jeux, articles, QR…) sont disponibles avec le plan Premium.';
@override
String get statsUnavailableTitle => 'Statistiques non incluses';
@override
String get statsUnavailableBody =>
'Votre plan actuel n\'inclut pas les statistiques de fréquentation.';
@override
String get noData => 'Aucune donnée';

View File

@ -56,6 +56,98 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get connect => 'INLOGGEN';
@override
String get forgotPasswordLink => 'Wachtwoord vergeten?';
@override
String get forgotPasswordTitle => 'Wachtwoord vergeten';
@override
String get forgotPasswordDesc =>
'Vul uw e-mail in, we sturen u een link om een nieuw wachtwoord in te stellen.';
@override
String get forgotPasswordSubmit => 'LINK VERSTUREN';
@override
String get forgotPasswordSuccess =>
'Als dit e-mailadres bestaat, is er zojuist een resetlink verstuurd.';
@override
String get backToLogin => 'Terug naar inloggen';
@override
String get setPasswordTitle => 'Wachtwoord instellen';
@override
String get setPasswordDesc =>
'Kies een wachtwoord om toegang te krijgen tot uw omgeving.';
@override
String get setPasswordNewLabel => 'Nieuw wachtwoord';
@override
String get setPasswordSubmit => 'WACHTWOORD INSTELLEN';
@override
String get setPasswordSuccess => 'Wachtwoord ingesteld — u kunt nu inloggen.';
@override
String get setPasswordError => 'Deze link is ongeldig of verlopen';
@override
String get setPasswordTooShort =>
'Wachtwoord moet minstens 8 tekens bevatten';
@override
String get setPasswordMissingToken => 'Ongeldige link — geen token opgegeven';
@override
String get inviteUserHint =>
'Er wordt een uitnodigingsmail verstuurd om het wachtwoord in te stellen.';
@override
String get menuSubscription => 'Abonnement';
@override
String get subscriptionTitle => 'Abonnement';
@override
String get subscriptionTrialActive => 'Gratis proefperiode actief';
@override
String subscriptionTrialEndsAt(Object date) {
return 'Uw proefperiode eindigt op $date';
}
@override
String get subscriptionTrialNoDate => 'Uw gratis proefperiode is actief.';
@override
String get subscriptionPlanActive => 'Essentiel-abonnement actief';
@override
String get subscriptionPlanActiveDesc =>
'Uw abonnement is actief en wordt automatisch verlengd.';
@override
String get subscriptionIncludedTitle => 'Wat is inbegrepen';
@override
String get subscriptionUpgradeBtn =>
'Overstappen naar een betaald abonnement';
@override
String get subscriptionCheckoutError =>
'Kon de betaling niet starten, probeer het later opnieuw.';
@override
String get subscriptionAddonsTitle => 'Add-ons';
@override
String get subscriptionAddonsComingSoon =>
'Binnenkort beschikbaar: extra AI-verzoeken en andere opties.';
@override
String get menuApplications => 'Applicaties';
@ -68,6 +160,132 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get menuStatistics => 'Statistieken';
@override
String get menuGuideIa => 'AI-gids';
@override
String get guideIaSubtitle =>
'Uw gids beantwoordt vragen van bezoekers op basis van uw eigen inhoud. Nooit op basis van iets anders.';
@override
String get guideIaUsageTitle => 'Verbruik deze maand';
@override
String guideIaUsageQuestions(Object count) {
return '$count gestelde vragen';
}
@override
String get guideIaUsageNoQuota => 'Geen limiet ingesteld op uw abonnement.';
@override
String get guideIaChannelsTitle => 'Waar de gids beschikbaar is';
@override
String get guideIaChannelsSub =>
'Per kanaal in te schakelen, vanuit de instellingen van elke toepassing.';
@override
String get guideIaChannelOn => 'Ingeschakeld';
@override
String get guideIaChannelOff => 'Uitgeschakeld';
@override
String get guideIaIdentityTitle => 'Identiteit van de gids';
@override
String get guideIaIdentitySub => 'Dit bepaalt de toon van de antwoorden';
@override
String get guideIaNameLabel => 'Naam van de gids';
@override
String get guideIaNameHint =>
'De naam die de bezoeker ziet en hoort. Bv. Leon';
@override
String get guideIaPersonaLabel => 'Persoonlijkheid';
@override
String get guideIaPersonaHint =>
'Beschrijf hem zoals u een gids aan een nieuwe collega zou voorstellen. Begin met een voorbeeld:';
@override
String get guideIaExampleWarden => 'Bevlogen bewaker';
@override
String get guideIaExampleWardenText =>
'Je heet Leon, je bent de voormalige bewaker van de site. Je spreekt eenvoudig en warm, en je vertelt graag een anekdote. Spreek bezoekers beleefd aan.';
@override
String get guideIaExampleSober => 'Sobere bemiddelaar';
@override
String get guideIaExampleSoberText =>
'Je bent een rustige, precieze cultuurbemiddelaar. Je antwoordt in twee of drie zinnen, zonder familiariteit, met vaststaande feiten. Bij onzekerheid zeg je dat.';
@override
String get guideIaExampleKids => 'Voor kinderen';
@override
String get guideIaExampleKidsText =>
'Je bent een enthousiaste ontdekkingsreiziger die kinderen van 7 tot 12 jaar aanspreekt. Je tutoyeert, stelt vragen, gebruikt eenvoudige vergelijkingen en korte woorden. Je vertelt verhalen.';
@override
String get guideIaFallbackLabel => 'Wanneer de gids het niet weet';
@override
String get guideIaFallbackHint =>
'De gids kiest er willekeurig een. Met één zin herhaalt hij die letterlijk, en dat valt meteen op.';
@override
String get guideIaFallbackAdd => 'Formulering toevoegen';
@override
String get guideIaFallbackTranslated =>
'Bezoekers zien deze zinnen: ze worden vertaald naar hun taal, net als de rest van uw inhoud.';
@override
String get guideIaVoiceTitle => 'Uw gids hardop';
@override
String get guideIaVoiceSub =>
'De bezoeker stelt zijn vraag hardop en hoort het antwoord — in de app of in zijn verbonden bril.';
@override
String get guideIaVoiceFemale => 'Vrouwelijke stem, warm';
@override
String get guideIaVoiceMale => 'Mannelijke stem, bedaard';
@override
String guideIaVoiceWakeword(Object word) {
return 'Wekwoord: “$word';
}
@override
String guideIaVoiceInfoName(Object word) {
return 'De bezoeker zegt “$word” om de gids te wekken, ook als die een andere naam draagt. Een eigen stem en wekwoord zijn optioneel beschikbaar.';
}
@override
String get guideIaVoiceInfoGlasses =>
'De verbonden bril werkt met de mobiele app op de telefoon van de bezoeker.';
@override
String get guideIaVoiceInfoMultilang =>
'Dezelfde stem spreekt al uw talen: uw gids behoudt één identiteit.';
@override
String get guideIaSave => 'Opslaan';
@override
String get guideIaSaved => 'Gids opgeslagen';
@override
String get guideIaSaveError => 'Opslaan is mislukt. Probeer opnieuw.';
@override
String get menuNotifications => 'Meldingen';
@ -410,6 +628,182 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get statsViews => 'Weergaven';
@override
String get statsAttendanceTitle => 'Bezoekcijfers';
@override
String statsPeriodRange(String from, String to) {
return 'Van $from tot $to';
}
@override
String get statsFilterPeriod => 'Periode';
@override
String get statsFilterChannel => 'Kanaal';
@override
String statsPeriodDays(int days) {
return '$days dagen';
}
@override
String get statsPeriodYear => 'Jaar';
@override
String get statsChannelMobile => 'Mobiele app';
@override
String get statsChannelTablet => 'Kiosk';
@override
String get statsChannelWeb => 'Website';
@override
String get statsChannelVR => 'VR-bril';
@override
String get statsChannelVoice => 'Spraakgids';
@override
String statsTakeawayUp(String percent) {
return 'De bezoekcijfers stijgen met $percent % ten opzichte van de vorige periode.';
}
@override
String statsTakeawayDown(String percent) {
return 'De bezoekcijfers dalen met $percent % ten opzichte van de vorige periode.';
}
@override
String get statsTakeawayStable =>
'De bezoekcijfers blijven stabiel ten opzichte van de vorige periode.';
@override
String statsTakeawayVolume(String visits, String perDay) {
return '$visits bezoeken in deze periode, gemiddeld $perDay per dag.';
}
@override
String statsTakeawayVoice(String percent) {
return 'De spraakgids is goed voor $percent % van de bezoeken — een sterk argument voor uw subsidiënten.';
}
@override
String statsTakeawayChannel(String channel, String percent) {
return 'Het kanaal « $channel » is goed voor $percent % van de bezoeken.';
}
@override
String statsTakeawayContent(String title, String percent) {
return '« $title » alleen al is goed voor $percent % van alle weergaven: dat is uw voordeur, houd die als eerste actueel.';
}
@override
String get statsKpiVisits => 'Bezoeken';
@override
String get statsKpiAvgDuration => 'Gemiddelde duur';
@override
String get statsKpiContentsPerVisit => 'Content per bezoek';
@override
String get statsKpiVoiceShare => 'Aandeel spraakgids';
@override
String get statsKpiTotalViews => 'Bekeken content';
@override
String get statsVsPrevious => 't.o.v. vorige periode';
@override
String get statsTrendStable => 'stabiel';
@override
String statsDurationMinSec(String minutes, String seconds) {
return '$minutes min $seconds s';
}
@override
String statsDurationSec(String seconds) {
return '$seconds s';
}
@override
String get statsVisitsByDaySub => 'De lichte banden geven de weekends aan';
@override
String get statsWeekends => 'Zaterdagen en zondagen';
@override
String statsPeakDay(String date, String visits) {
return 'Piek op $date$visits bezoeken';
}
@override
String get statsTopContents => 'Meest bekeken content';
@override
String get statsTopContentsSub => 'Aantal weergaven in deze periode';
@override
String get statsChannels => 'Kanalen';
@override
String get statsChannelsSub => 'Bezoeken per kanaal';
@override
String get statsLanguagesSub => 'Bezoeken per taal';
@override
String get statsReportTitle => 'Uw bezoekersrapport';
@override
String get statsReportBody =>
'Een document in uw eigen huisstijl, klaar om te bezorgen aan uw gemeente, uw raad van bestuur of uw subsidiënten.';
@override
String get statsReportItemAttendance =>
'Bezoekcijfers, bezoekduur en evolutie';
@override
String get statsReportItemContents =>
'Meest bekeken content en de evolutie ervan';
@override
String get statsReportItemChannels =>
'Verdeling per kanaal, inclusief de spraakgids, en per taal';
@override
String get statsReportItemAdvanced =>
'Bezienswaardigheden, quizzen, spellen en QR-scans';
@override
String statsReportGenerated(String date) {
return 'Rapport gegenereerd op $date met MyInfoMate';
}
@override
String get statsReportError => 'Het rapport kon niet worden gegenereerd';
@override
String get statsReportDownload => 'De pdf downloaden';
@override
String get statsAdvancedTitle => 'Geavanceerde statistieken';
@override
String get statsAdvancedBody =>
'Gedetailleerde statistieken (POI, quizzen, spellen, artikels, QR…) zijn beschikbaar met het Premium-plan.';
@override
String get statsUnavailableTitle => 'Statistieken niet inbegrepen';
@override
String get statsUnavailableBody =>
'Uw huidige plan bevat geen bezoekstatistieken.';
@override
String get noData => 'Geen gegevens';

View File

@ -18,11 +18,76 @@
"loginError": "Er is een probleem opgetreden bij het inloggen",
"rememberMe": "Onthoud mij",
"connect": "INLOGGEN",
"forgotPasswordLink": "Wachtwoord vergeten?",
"forgotPasswordTitle": "Wachtwoord vergeten",
"forgotPasswordDesc": "Vul uw e-mail in, we sturen u een link om een nieuw wachtwoord in te stellen.",
"forgotPasswordSubmit": "LINK VERSTUREN",
"forgotPasswordSuccess": "Als dit e-mailadres bestaat, is er zojuist een resetlink verstuurd.",
"backToLogin": "Terug naar inloggen",
"setPasswordTitle": "Wachtwoord instellen",
"setPasswordDesc": "Kies een wachtwoord om toegang te krijgen tot uw omgeving.",
"setPasswordNewLabel": "Nieuw wachtwoord",
"setPasswordSubmit": "WACHTWOORD INSTELLEN",
"setPasswordSuccess": "Wachtwoord ingesteld — u kunt nu inloggen.",
"setPasswordError": "Deze link is ongeldig of verlopen",
"setPasswordTooShort": "Wachtwoord moet minstens 8 tekens bevatten",
"setPasswordMissingToken": "Ongeldige link — geen token opgegeven",
"inviteUserHint": "Er wordt een uitnodigingsmail verstuurd om het wachtwoord in te stellen.",
"menuSubscription": "Abonnement",
"subscriptionTitle": "Abonnement",
"subscriptionTrialActive": "Gratis proefperiode actief",
"subscriptionTrialEndsAt": "Uw proefperiode eindigt op {date}",
"@subscriptionTrialEndsAt": { "placeholders": { "date": {} } },
"subscriptionTrialNoDate": "Uw gratis proefperiode is actief.",
"subscriptionPlanActive": "Essentiel-abonnement actief",
"subscriptionPlanActiveDesc": "Uw abonnement is actief en wordt automatisch verlengd.",
"subscriptionIncludedTitle": "Wat is inbegrepen",
"subscriptionUpgradeBtn": "Overstappen naar een betaald abonnement",
"subscriptionCheckoutError": "Kon de betaling niet starten, probeer het later opnieuw.",
"subscriptionAddonsTitle": "Add-ons",
"subscriptionAddonsComingSoon": "Binnenkort beschikbaar: extra AI-verzoeken en andere opties.",
"menuApplications": "Applicaties",
"menuConfigurations": "Configuraties",
"menuResources": "Bronnen",
"menuStatistics": "Statistieken",
"menuGuideIa": "AI-gids",
"guideIaSubtitle": "Uw gids beantwoordt vragen van bezoekers op basis van uw eigen inhoud. Nooit op basis van iets anders.",
"guideIaUsageTitle": "Verbruik deze maand",
"guideIaUsageQuestions": "{count} gestelde vragen",
"guideIaUsageNoQuota": "Geen limiet ingesteld op uw abonnement.",
"guideIaChannelsTitle": "Waar de gids beschikbaar is",
"guideIaChannelsSub": "Per kanaal in te schakelen, vanuit de instellingen van elke toepassing.",
"guideIaChannelOn": "Ingeschakeld",
"guideIaChannelOff": "Uitgeschakeld",
"guideIaIdentityTitle": "Identiteit van de gids",
"guideIaIdentitySub": "Dit bepaalt de toon van de antwoorden",
"guideIaNameLabel": "Naam van de gids",
"guideIaNameHint": "De naam die de bezoeker ziet en hoort. Bv. Leon",
"guideIaPersonaLabel": "Persoonlijkheid",
"guideIaPersonaHint": "Beschrijf hem zoals u een gids aan een nieuwe collega zou voorstellen. Begin met een voorbeeld:",
"guideIaExampleWarden": "Bevlogen bewaker",
"guideIaExampleWardenText": "Je heet Leon, je bent de voormalige bewaker van de site. Je spreekt eenvoudig en warm, en je vertelt graag een anekdote. Spreek bezoekers beleefd aan.",
"guideIaExampleSober": "Sobere bemiddelaar",
"guideIaExampleSoberText": "Je bent een rustige, precieze cultuurbemiddelaar. Je antwoordt in twee of drie zinnen, zonder familiariteit, met vaststaande feiten. Bij onzekerheid zeg je dat.",
"guideIaExampleKids": "Voor kinderen",
"guideIaExampleKidsText": "Je bent een enthousiaste ontdekkingsreiziger die kinderen van 7 tot 12 jaar aanspreekt. Je tutoyeert, stelt vragen, gebruikt eenvoudige vergelijkingen en korte woorden. Je vertelt verhalen.",
"guideIaFallbackLabel": "Wanneer de gids het niet weet",
"guideIaFallbackHint": "De gids kiest er willekeurig een. Met één zin herhaalt hij die letterlijk, en dat valt meteen op.",
"guideIaFallbackAdd": "Formulering toevoegen",
"guideIaFallbackTranslated": "Bezoekers zien deze zinnen: ze worden vertaald naar hun taal, net als de rest van uw inhoud.",
"guideIaVoiceTitle": "Uw gids hardop",
"guideIaVoiceSub": "De bezoeker stelt zijn vraag hardop en hoort het antwoord — in de app of in zijn verbonden bril.",
"guideIaVoiceFemale": "Vrouwelijke stem, warm",
"guideIaVoiceMale": "Mannelijke stem, bedaard",
"guideIaVoiceWakeword": "Wekwoord: “{word}”",
"guideIaVoiceInfoName": "De bezoeker zegt “{word}” om de gids te wekken, ook als die een andere naam draagt. Een eigen stem en wekwoord zijn optioneel beschikbaar.",
"guideIaVoiceInfoGlasses": "De verbonden bril werkt met de mobiele app op de telefoon van de bezoeker.",
"guideIaVoiceInfoMultilang": "Dezelfde stem spreekt al uw talen: uw gids behoudt één identiteit.",
"guideIaSave": "Opslaan",
"guideIaSaved": "Gids opgeslagen",
"guideIaSaveError": "Opslaan is mislukt. Probeer opnieuw.",
"menuNotifications": "Meldingen",
"menuUsers": "Gebruikers",
"menuApiKeys": "API-sleutels",
@ -175,6 +240,121 @@
"statsInvalid": "Ongeldig",
"statsViews": "Weergaven",
"statsAttendanceTitle": "Bezoekcijfers",
"statsPeriodRange": "Van {from} tot {to}",
"@statsPeriodRange": {
"placeholders": {
"from": { "type": "String" },
"to": { "type": "String" }
}
},
"statsFilterPeriod": "Periode",
"statsFilterChannel": "Kanaal",
"statsPeriodDays": "{days} dagen",
"@statsPeriodDays": {
"placeholders": {
"days": { "type": "int" }
}
},
"statsPeriodYear": "Jaar",
"statsChannelMobile": "Mobiele app",
"statsChannelTablet": "Kiosk",
"statsChannelWeb": "Website",
"statsChannelVR": "VR-bril",
"statsChannelVoice": "Spraakgids",
"statsTakeawayUp": "De bezoekcijfers stijgen met {percent} % ten opzichte van de vorige periode.",
"@statsTakeawayUp": {
"placeholders": {
"percent": { "type": "String" }
}
},
"statsTakeawayDown": "De bezoekcijfers dalen met {percent} % ten opzichte van de vorige periode.",
"@statsTakeawayDown": {
"placeholders": {
"percent": { "type": "String" }
}
},
"statsTakeawayStable": "De bezoekcijfers blijven stabiel ten opzichte van de vorige periode.",
"statsTakeawayVolume": "{visits} bezoeken in deze periode, gemiddeld {perDay} per dag.",
"@statsTakeawayVolume": {
"placeholders": {
"visits": { "type": "String" },
"perDay": { "type": "String" }
}
},
"statsTakeawayVoice": "De spraakgids is goed voor {percent} % van de bezoeken — een sterk argument voor uw subsidiënten.",
"@statsTakeawayVoice": {
"placeholders": {
"percent": { "type": "String" }
}
},
"statsTakeawayChannel": "Het kanaal « {channel} » is goed voor {percent} % van de bezoeken.",
"@statsTakeawayChannel": {
"placeholders": {
"channel": { "type": "String" },
"percent": { "type": "String" }
}
},
"statsTakeawayContent": "« {title} » alleen al is goed voor {percent} % van alle weergaven: dat is uw voordeur, houd die als eerste actueel.",
"@statsTakeawayContent": {
"placeholders": {
"title": { "type": "String" },
"percent": { "type": "String" }
}
},
"statsKpiVisits": "Bezoeken",
"statsKpiAvgDuration": "Gemiddelde duur",
"statsKpiContentsPerVisit": "Content per bezoek",
"statsKpiVoiceShare": "Aandeel spraakgids",
"statsKpiTotalViews": "Bekeken content",
"statsVsPrevious": "t.o.v. vorige periode",
"statsTrendStable": "stabiel",
"statsDurationMinSec": "{minutes} min {seconds} s",
"@statsDurationMinSec": {
"placeholders": {
"minutes": { "type": "String" },
"seconds": { "type": "String" }
}
},
"statsDurationSec": "{seconds} s",
"@statsDurationSec": {
"placeholders": {
"seconds": { "type": "String" }
}
},
"statsVisitsByDaySub": "De lichte banden geven de weekends aan",
"statsWeekends": "Zaterdagen en zondagen",
"statsPeakDay": "Piek op {date} — {visits} bezoeken",
"@statsPeakDay": {
"placeholders": {
"date": { "type": "String" },
"visits": { "type": "String" }
}
},
"statsTopContents": "Meest bekeken content",
"statsTopContentsSub": "Aantal weergaven in deze periode",
"statsChannels": "Kanalen",
"statsChannelsSub": "Bezoeken per kanaal",
"statsLanguagesSub": "Bezoeken per taal",
"statsReportTitle": "Uw bezoekersrapport",
"statsReportBody": "Een document in uw eigen huisstijl, klaar om te bezorgen aan uw gemeente, uw raad van bestuur of uw subsidiënten.",
"statsReportItemAttendance": "Bezoekcijfers, bezoekduur en evolutie",
"statsReportItemContents": "Meest bekeken content en de evolutie ervan",
"statsReportItemChannels": "Verdeling per kanaal, inclusief de spraakgids, en per taal",
"statsReportItemAdvanced": "Bezienswaardigheden, quizzen, spellen en QR-scans",
"statsReportGenerated": "Rapport gegenereerd op {date} met MyInfoMate",
"@statsReportGenerated": {
"placeholders": {
"date": { "type": "String" }
}
},
"statsReportError": "Het rapport kon niet worden gegenereerd",
"statsReportDownload": "De pdf downloaden",
"statsAdvancedTitle": "Geavanceerde statistieken",
"statsAdvancedBody": "Gedetailleerde statistieken (POI, quizzen, spellen, artikels, QR…) zijn beschikbaar met het Premium-plan.",
"statsUnavailableTitle": "Statistieken niet inbegrepen",
"statsUnavailableBody": "Uw huidige plan bevat geen bezoekstatistieken.",
"noData": "Geen gegevens",
"errorOccurred": "Er is een fout opgetreden",
"yes": "Ja",

View File

@ -16,6 +16,8 @@ import 'Helpers/FileHelper.dart';
import 'Models/session.dart';
import 'Screens/Main/main_screen.dart';
import 'Screens/login_screen.dart';
import 'Screens/Auth/forgot_password_screen.dart';
import 'Screens/Auth/set_password_screen.dart';
import 'Screens/Policy/policy_screen.dart';
import 'app_context.dart';
import 'client.dart';
@ -77,7 +79,8 @@ Future<void> main() async {
initialLocation: initialRoute,
redirect: (context, state) {
var instanceId = managerAppContext.instanceId;
if (instanceId == null && state.fullPath != '/login') {
const publicPaths = ['/login', '/forgot-password', '/set-password'];
if (instanceId == null && !publicPaths.contains(state.fullPath)) {
return '/login';
}
@ -107,6 +110,16 @@ Future<void> main() async {
path: '/login',
builder: (context, state) => LoginScreen(),
),
GoRoute(
path: '/forgot-password',
builder: (context, state) => ForgotPasswordScreen(),
),
GoRoute(
path: '/set-password',
builder: (context, state) => SetPasswordScreen(
token: state.uri.queryParameters['token'],
),
),
GoRoute(
path: '/main/:view',
builder: (context, state) {

View File

@ -35,6 +35,7 @@ part 'api/authentication_api.dart';
part 'api/configuration_api.dart';
part 'api/device_api.dart';
part 'api/instance_api.dart';
part 'api/onboarding_api.dart';
part 'api/subscription_plan_api.dart';
part 'api/resource_api.dart';
part 'api/section_api.dart';

View File

@ -179,4 +179,84 @@ class AuthenticationApi {
}
return null;
}
/// Manually added (not generated) mirrors AuthenticationController.ForgotPassword.
/// Performs an HTTP 'POST /api/Authentication/forgot-password' operation.
Future<Response> authenticationForgotPasswordWithHttpInfo(
String email,
) async {
// ignore: prefer_const_declarations
final path = r'/api/Authentication/forgot-password';
// ignore: prefer_final_locals
Object? postBody = <String, dynamic>{'email': email};
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<void> authenticationForgotPassword(String email) async {
final response = await authenticationForgotPasswordWithHttpInfo(email);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Manually added (not generated) mirrors AuthenticationController.SetPassword.
/// Performs an HTTP 'POST /api/Authentication/set-password' operation.
Future<Response> authenticationSetPasswordWithHttpInfo({
required String token,
required String newPassword,
}) async {
// ignore: prefer_const_declarations
final path = r'/api/Authentication/set-password';
// ignore: prefer_final_locals
Object? postBody = <String, dynamic>{
'token': token,
'newPassword': newPassword,
};
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<void> authenticationSetPassword({
required String token,
required String newPassword,
}) async {
final response = await authenticationSetPasswordWithHttpInfo(
token: token,
newPassword: newPassword,
);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@ -0,0 +1,46 @@
//
// Manually added (not generated) mirrors OnboardingController.CreateCheckoutSession.
// Do not run the OpenAPI generator on this file, it would be overwritten.
//
// @dart=2.18
part of openapi.api;
class OnboardingApi {
OnboardingApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'POST /api/onboarding/checkout-session' operation and returns the [Response].
Future<Response> onboardingCreateCheckoutSessionWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/api/onboarding/checkout-session';
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
null,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Returns the Stripe Checkout Session URL to redirect the user to.
Future<String> onboardingCreateCheckoutSession() async {
final response = await onboardingCreateCheckoutSessionWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
final body = await _decodeBodyBytes(response);
final decoded = jsonDecode(body) as Map<String, dynamic>;
return decoded['url'] as String;
}
}

View File

@ -15,6 +15,8 @@ extension AppTypeName on AppType {
return 'Web';
case 3:
return 'VR';
case 4:
return 'Voice';
default:
return value.toString();
}

View File

@ -23,10 +23,8 @@ class GuidedStep {
this.isGeoTriggered,
this.zoneRadiusMeters,
this.imageUrl,
this.isHiddenInitially,
this.quizQuestions = const [],
this.isStepTimer,
this.isStepLocked,
this.timerSeconds,
this.timerExpiredMessage = const [],
});
@ -63,14 +61,6 @@ class GuidedStep {
String? imageUrl;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isHiddenInitially;
List<QuizQuestion>? quizQuestions;
///
@ -81,14 +71,6 @@ class GuidedStep {
///
bool? isStepTimer;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isStepLocked;
int? timerSeconds;
List<TranslationDTO>? timerExpiredMessage;
@ -107,10 +89,8 @@ class GuidedStep {
other.isGeoTriggered == isGeoTriggered &&
other.zoneRadiusMeters == zoneRadiusMeters &&
other.imageUrl == imageUrl &&
other.isHiddenInitially == isHiddenInitially &&
_deepEquality.equals(other.quizQuestions, quizQuestions) &&
other.isStepTimer == isStepTimer &&
other.isStepLocked == isStepLocked &&
other.timerSeconds == timerSeconds &&
_deepEquality.equals(other.timerExpiredMessage, timerExpiredMessage);
@ -127,16 +107,14 @@ class GuidedStep {
(isGeoTriggered == null ? 0 : isGeoTriggered!.hashCode) +
(zoneRadiusMeters == null ? 0 : zoneRadiusMeters!.hashCode) +
(imageUrl == null ? 0 : imageUrl!.hashCode) +
(isHiddenInitially == null ? 0 : isHiddenInitially!.hashCode) +
(quizQuestions == null ? 0 : quizQuestions!.hashCode) +
(isStepTimer == null ? 0 : isStepTimer!.hashCode) +
(isStepLocked == null ? 0 : isStepLocked!.hashCode) +
(timerSeconds == null ? 0 : timerSeconds!.hashCode) +
(timerExpiredMessage == null ? 0 : timerExpiredMessage!.hashCode);
@override
String toString() =>
'GuidedStep[guidedPathId=$guidedPathId, title=$title, id=$id, guidedPath=$guidedPath, order=$order, description=$description, geometry=$geometry, isGeoTriggered=$isGeoTriggered, zoneRadiusMeters=$zoneRadiusMeters, imageUrl=$imageUrl, isHiddenInitially=$isHiddenInitially, quizQuestions=$quizQuestions, isStepTimer=$isStepTimer, isStepLocked=$isStepLocked, timerSeconds=$timerSeconds, timerExpiredMessage=$timerExpiredMessage]';
'GuidedStep[guidedPathId=$guidedPathId, title=$title, id=$id, guidedPath=$guidedPath, order=$order, description=$description, geometry=$geometry, isGeoTriggered=$isGeoTriggered, zoneRadiusMeters=$zoneRadiusMeters, imageUrl=$imageUrl, quizQuestions=$quizQuestions, isStepTimer=$isStepTimer, timerSeconds=$timerSeconds, timerExpiredMessage=$timerExpiredMessage]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -182,11 +160,6 @@ class GuidedStep {
} else {
json[r'imageUrl'] = null;
}
if (this.isHiddenInitially != null) {
json[r'isHiddenInitially'] = this.isHiddenInitially;
} else {
json[r'isHiddenInitially'] = null;
}
if (this.quizQuestions != null) {
json[r'quizQuestions'] = this.quizQuestions;
} else {
@ -197,11 +170,6 @@ class GuidedStep {
} else {
json[r'isStepTimer'] = null;
}
if (this.isStepLocked != null) {
json[r'isStepLocked'] = this.isStepLocked;
} else {
json[r'isStepLocked'] = null;
}
if (this.timerSeconds != null) {
json[r'timerSeconds'] = this.timerSeconds;
} else {
@ -246,10 +214,8 @@ class GuidedStep {
isGeoTriggered: mapValueOfType<bool>(json, r'isGeoTriggered'),
zoneRadiusMeters: mapValueOfType<double>(json, r'zoneRadiusMeters'),
imageUrl: mapValueOfType<String>(json, r'imageUrl'),
isHiddenInitially: mapValueOfType<bool>(json, r'isHiddenInitially'),
quizQuestions: QuizQuestion.listFromJson(json[r'quizQuestions']),
isStepTimer: mapValueOfType<bool>(json, r'isStepTimer'),
isStepLocked: mapValueOfType<bool>(json, r'isStepLocked'),
timerSeconds: mapValueOfType<int>(json, r'timerSeconds'),
timerExpiredMessage:
TranslationDTO.listFromJson(json[r'timerExpiredMessage']),

View File

@ -22,14 +22,11 @@ class GuidedStepDTO {
this.isGeoTriggered,
this.zoneRadiusMeters,
this.imageUrl,
this.isHiddenInitially,
this.isStepTimer,
this.isStepLocked,
this.timerSeconds,
this.timerExpiredMessage = const [],
this.audioIds = const [],
this.contents = const [],
this.factContent = const [],
this.quizQuestions = const [],
});
@ -63,14 +60,6 @@ class GuidedStepDTO {
String? imageUrl;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isHiddenInitially;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@ -79,14 +68,6 @@ class GuidedStepDTO {
///
bool? isStepTimer;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isStepLocked;
int? timerSeconds;
List<TranslationDTO>? timerExpiredMessage;
@ -95,8 +76,6 @@ class GuidedStepDTO {
List<ContentDTO>? contents;
List<TranslationDTO>? factContent;
List<QuizQuestion>? quizQuestions;
@override
@ -112,15 +91,12 @@ class GuidedStepDTO {
other.isGeoTriggered == isGeoTriggered &&
other.zoneRadiusMeters == zoneRadiusMeters &&
other.imageUrl == imageUrl &&
other.isHiddenInitially == isHiddenInitially &&
other.isStepTimer == isStepTimer &&
other.isStepLocked == isStepLocked &&
other.timerSeconds == timerSeconds &&
_deepEquality.equals(
other.timerExpiredMessage, timerExpiredMessage) &&
_deepEquality.equals(other.audioIds, audioIds) &&
_deepEquality.equals(other.contents, contents) &&
_deepEquality.equals(other.factContent, factContent) &&
_deepEquality.equals(other.quizQuestions, quizQuestions);
@override
@ -135,19 +111,16 @@ class GuidedStepDTO {
(isGeoTriggered == null ? 0 : isGeoTriggered!.hashCode) +
(zoneRadiusMeters == null ? 0 : zoneRadiusMeters!.hashCode) +
(imageUrl == null ? 0 : imageUrl!.hashCode) +
(isHiddenInitially == null ? 0 : isHiddenInitially!.hashCode) +
(isStepTimer == null ? 0 : isStepTimer!.hashCode) +
(isStepLocked == null ? 0 : isStepLocked!.hashCode) +
(timerSeconds == null ? 0 : timerSeconds!.hashCode) +
(timerExpiredMessage == null ? 0 : timerExpiredMessage!.hashCode) +
(audioIds == null ? 0 : audioIds!.hashCode) +
(contents == null ? 0 : contents!.hashCode) +
(factContent == null ? 0 : factContent!.hashCode) +
(quizQuestions == null ? 0 : quizQuestions!.hashCode);
@override
String toString() =>
'GuidedStepDTO[id=$id, guidedPathId=$guidedPathId, order=$order, title=$title, description=$description, geometry=$geometry, isGeoTriggered=$isGeoTriggered, zoneRadiusMeters=$zoneRadiusMeters, imageUrl=$imageUrl, isHiddenInitially=$isHiddenInitially, isStepTimer=$isStepTimer, isStepLocked=$isStepLocked, timerSeconds=$timerSeconds, timerExpiredMessage=$timerExpiredMessage, audioIds=$audioIds, contents=$contents, factContent=$factContent, quizQuestions=$quizQuestions]';
'GuidedStepDTO[id=$id, guidedPathId=$guidedPathId, order=$order, title=$title, description=$description, geometry=$geometry, isGeoTriggered=$isGeoTriggered, zoneRadiusMeters=$zoneRadiusMeters, imageUrl=$imageUrl, isStepTimer=$isStepTimer, timerSeconds=$timerSeconds, timerExpiredMessage=$timerExpiredMessage, audioIds=$audioIds, contents=$contents, quizQuestions=$quizQuestions]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -196,21 +169,11 @@ class GuidedStepDTO {
} else {
json[r'imageUrl'] = null;
}
if (this.isHiddenInitially != null) {
json[r'isHiddenInitially'] = this.isHiddenInitially;
} else {
json[r'isHiddenInitially'] = null;
}
if (this.isStepTimer != null) {
json[r'isStepTimer'] = this.isStepTimer;
} else {
json[r'isStepTimer'] = null;
}
if (this.isStepLocked != null) {
json[r'isStepLocked'] = this.isStepLocked;
} else {
json[r'isStepLocked'] = null;
}
if (this.timerSeconds != null) {
json[r'timerSeconds'] = this.timerSeconds;
} else {
@ -232,11 +195,6 @@ class GuidedStepDTO {
} else {
json[r'contents'] = null;
}
if (this.factContent != null) {
json[r'factContent'] = this.factContent!.map((v) => v.toJson()).toList();
} else {
json[r'factContent'] = null;
}
if (this.quizQuestions != null) {
json[r'quizQuestions'] =
this.quizQuestions!.map((v) => v.toJson()).toList();
@ -276,15 +234,12 @@ class GuidedStepDTO {
isGeoTriggered: mapValueOfType<bool>(json, r'isGeoTriggered'),
zoneRadiusMeters: mapValueOfType<double>(json, r'zoneRadiusMeters'),
imageUrl: mapValueOfType<String>(json, r'imageUrl'),
isHiddenInitially: mapValueOfType<bool>(json, r'isHiddenInitially'),
isStepTimer: mapValueOfType<bool>(json, r'isStepTimer'),
isStepLocked: mapValueOfType<bool>(json, r'isStepLocked'),
timerSeconds: mapValueOfType<int>(json, r'timerSeconds'),
timerExpiredMessage:
TranslationDTO.listFromJson(json[r'timerExpiredMessage']),
audioIds: TranslationDTO.listFromJson(json[r'audioIds']),
contents: ContentDTO.listFromJson(json[r'contents']),
factContent: TranslationDTO.listFromJson(json[r'factContent']),
quizQuestions: QuizQuestion.listFromJson(json[r'quizQuestions']),
);
}

View File

@ -23,6 +23,10 @@ class InstanceDTO {
this.isWeb,
this.isVR,
this.isAssistant,
this.guideName,
this.guidePersonaPrompt,
this.guideVoiceId,
this.guideFallbackMessages = const [],
this.subscriptionPlanId,
this.subscriptionPlan,
this.aiTokensThisMonth,
@ -35,6 +39,8 @@ class InstanceDTO {
this.applicationInstanceDTOs = const [],
this.webSlug,
this.publicApiKey,
this.isTrialActive,
this.trialEndsAt,
});
String? id;
@ -94,6 +100,18 @@ class InstanceDTO {
bool? isAssistant;
/// Nom du guide affiché au visiteur, libre.
String? guideName;
/// Personnalité du guide : instruction au modèle, jamais affichée au visiteur.
String? guidePersonaPrompt;
/// Voix Gemini TTS : "Sulafat" (Viva) ou "Umbriel" (Marco).
String? guideVoiceId;
/// Formulations de repli. Liste à plat : plusieurs entrées peuvent partager la même langue.
List<TranslationDTO> guideFallbackMessages;
String? subscriptionPlanId;
SubscriptionPlanDTO? subscriptionPlan;
@ -118,6 +136,10 @@ class InstanceDTO {
String? publicApiKey;
bool? isTrialActive;
DateTime? trialEndsAt;
@override
bool operator ==(Object other) =>
identical(this, other) ||
@ -132,6 +154,10 @@ class InstanceDTO {
other.isWeb == isWeb &&
other.isVR == isVR &&
other.isAssistant == isAssistant &&
other.guideName == guideName &&
other.guidePersonaPrompt == guidePersonaPrompt &&
other.guideVoiceId == guideVoiceId &&
_deepEquality.equals(other.guideFallbackMessages, guideFallbackMessages) &&
other.subscriptionPlanId == subscriptionPlanId &&
other.subscriptionPlan == subscriptionPlan &&
other.aiTokensThisMonth == aiTokensThisMonth &&
@ -157,6 +183,10 @@ class InstanceDTO {
(isWeb == null ? 0 : isWeb!.hashCode) +
(isVR == null ? 0 : isVR!.hashCode) +
(isAssistant == null ? 0 : isAssistant!.hashCode) +
(guideName == null ? 0 : guideName!.hashCode) +
(guidePersonaPrompt == null ? 0 : guidePersonaPrompt!.hashCode) +
(guideVoiceId == null ? 0 : guideVoiceId!.hashCode) +
(guideFallbackMessages.hashCode) +
(subscriptionPlanId == null ? 0 : subscriptionPlanId!.hashCode) +
(subscriptionPlan == null ? 0 : subscriptionPlan!.hashCode) +
(aiTokensThisMonth == null ? 0 : aiTokensThisMonth!.hashCode) +
@ -165,7 +195,7 @@ class InstanceDTO {
@override
String toString() =>
'InstanceDTO[id=$id, name=$name, dateCreation=$dateCreation, pinCode=$pinCode, isPushNotification=$isPushNotification, isMobile=$isMobile, isTablet=$isTablet, isWeb=$isWeb, isVR=$isVR, isAssistant=$isAssistant, subscriptionPlanId=$subscriptionPlanId, subscriptionPlan=$subscriptionPlan, aiTokensThisMonth=$aiTokensThisMonth, aiUsageMonthKey=$aiUsageMonthKey, applicationInstanceDTOs=$applicationInstanceDTOs]';
'InstanceDTO[id=$id, name=$name, dateCreation=$dateCreation, pinCode=$pinCode, isPushNotification=$isPushNotification, isMobile=$isMobile, isTablet=$isTablet, isWeb=$isWeb, isVR=$isVR, isAssistant=$isAssistant, guideName=$guideName, guideVoiceId=$guideVoiceId, subscriptionPlanId=$subscriptionPlanId, subscriptionPlan=$subscriptionPlan, aiTokensThisMonth=$aiTokensThisMonth, aiUsageMonthKey=$aiUsageMonthKey, applicationInstanceDTOs=$applicationInstanceDTOs]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -219,6 +249,22 @@ class InstanceDTO {
} else {
json[r'isAssistant'] = null;
}
if (this.guideName != null) {
json[r'guideName'] = this.guideName;
} else {
json[r'guideName'] = null;
}
if (this.guidePersonaPrompt != null) {
json[r'guidePersonaPrompt'] = this.guidePersonaPrompt;
} else {
json[r'guidePersonaPrompt'] = null;
}
if (this.guideVoiceId != null) {
json[r'guideVoiceId'] = this.guideVoiceId;
} else {
json[r'guideVoiceId'] = null;
}
json[r'guideFallbackMessages'] = this.guideFallbackMessages;
if (this.subscriptionPlanId != null) {
json[r'subscriptionPlanId'] = this.subscriptionPlanId;
} else {
@ -279,6 +325,16 @@ class InstanceDTO {
} else {
json[r'publicApiKey'] = null;
}
if (this.isTrialActive != null) {
json[r'isTrialActive'] = this.isTrialActive;
} else {
json[r'isTrialActive'] = null;
}
if (this.trialEndsAt != null) {
json[r'trialEndsAt'] = this.trialEndsAt!.toUtc().toIso8601String();
} else {
json[r'trialEndsAt'] = null;
}
return json;
}
@ -313,6 +369,10 @@ class InstanceDTO {
isWeb: mapValueOfType<bool>(json, r'isWeb'),
isVR: mapValueOfType<bool>(json, r'isVR'),
isAssistant: mapValueOfType<bool>(json, r'isAssistant'),
guideName: mapValueOfType<String>(json, r'guideName'),
guidePersonaPrompt: mapValueOfType<String>(json, r'guidePersonaPrompt'),
guideVoiceId: mapValueOfType<String>(json, r'guideVoiceId'),
guideFallbackMessages: TranslationDTO.listFromJson(json[r'guideFallbackMessages']),
subscriptionPlanId: mapValueOfType<String>(json, r'subscriptionPlanId'),
subscriptionPlan: SubscriptionPlanDTO.fromJson(json[r'subscriptionPlan']),
aiTokensThisMonth: mapValueOfType<int>(json, r'aiTokensThisMonth'),
@ -326,6 +386,8 @@ class InstanceDTO {
json[r'applicationInstanceDTOs']),
webSlug: mapValueOfType<String>(json, r'webSlug'),
publicApiKey: mapValueOfType<String>(json, r'publicApiKey'),
isTrialActive: mapValueOfType<bool>(json, r'isTrialActive'),
trialEndsAt: mapDateTime(json, r'trialEndsAt', r''),
);
}
return null;

View File

@ -23,10 +23,8 @@ class QuizQuestionGuidedStep {
this.isGeoTriggered,
this.zoneRadiusMeters,
this.imageUrl,
this.isHiddenInitially,
this.quizQuestions = const [],
this.isStepTimer,
this.isStepLocked,
this.timerSeconds,
this.timerExpiredMessage = const [],
});
@ -63,14 +61,6 @@ class QuizQuestionGuidedStep {
String? imageUrl;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isHiddenInitially;
List<QuizQuestion>? quizQuestions;
///
@ -81,14 +71,6 @@ class QuizQuestionGuidedStep {
///
bool? isStepTimer;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isStepLocked;
int? timerSeconds;
List<TranslationDTO>? timerExpiredMessage;
@ -107,10 +89,8 @@ class QuizQuestionGuidedStep {
other.isGeoTriggered == isGeoTriggered &&
other.zoneRadiusMeters == zoneRadiusMeters &&
other.imageUrl == imageUrl &&
other.isHiddenInitially == isHiddenInitially &&
_deepEquality.equals(other.quizQuestions, quizQuestions) &&
other.isStepTimer == isStepTimer &&
other.isStepLocked == isStepLocked &&
other.timerSeconds == timerSeconds &&
_deepEquality.equals(other.timerExpiredMessage, timerExpiredMessage);
@ -127,16 +107,14 @@ class QuizQuestionGuidedStep {
(isGeoTriggered == null ? 0 : isGeoTriggered!.hashCode) +
(zoneRadiusMeters == null ? 0 : zoneRadiusMeters!.hashCode) +
(imageUrl == null ? 0 : imageUrl!.hashCode) +
(isHiddenInitially == null ? 0 : isHiddenInitially!.hashCode) +
(quizQuestions == null ? 0 : quizQuestions!.hashCode) +
(isStepTimer == null ? 0 : isStepTimer!.hashCode) +
(isStepLocked == null ? 0 : isStepLocked!.hashCode) +
(timerSeconds == null ? 0 : timerSeconds!.hashCode) +
(timerExpiredMessage == null ? 0 : timerExpiredMessage!.hashCode);
@override
String toString() =>
'QuizQuestionGuidedStep[guidedPathId=$guidedPathId, title=$title, id=$id, guidedPath=$guidedPath, order=$order, description=$description, geometry=$geometry, isGeoTriggered=$isGeoTriggered, zoneRadiusMeters=$zoneRadiusMeters, imageUrl=$imageUrl, isHiddenInitially=$isHiddenInitially, quizQuestions=$quizQuestions, isStepTimer=$isStepTimer, isStepLocked=$isStepLocked, timerSeconds=$timerSeconds, timerExpiredMessage=$timerExpiredMessage]';
'QuizQuestionGuidedStep[guidedPathId=$guidedPathId, title=$title, id=$id, guidedPath=$guidedPath, order=$order, description=$description, geometry=$geometry, isGeoTriggered=$isGeoTriggered, zoneRadiusMeters=$zoneRadiusMeters, imageUrl=$imageUrl, quizQuestions=$quizQuestions, isStepTimer=$isStepTimer, timerSeconds=$timerSeconds, timerExpiredMessage=$timerExpiredMessage]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -182,11 +160,6 @@ class QuizQuestionGuidedStep {
} else {
json[r'imageUrl'] = null;
}
if (this.isHiddenInitially != null) {
json[r'isHiddenInitially'] = this.isHiddenInitially;
} else {
json[r'isHiddenInitially'] = null;
}
if (this.quizQuestions != null) {
json[r'quizQuestions'] = this.quizQuestions;
} else {
@ -197,11 +170,6 @@ class QuizQuestionGuidedStep {
} else {
json[r'isStepTimer'] = null;
}
if (this.isStepLocked != null) {
json[r'isStepLocked'] = this.isStepLocked;
} else {
json[r'isStepLocked'] = null;
}
if (this.timerSeconds != null) {
json[r'timerSeconds'] = this.timerSeconds;
} else {
@ -246,10 +214,8 @@ class QuizQuestionGuidedStep {
isGeoTriggered: mapValueOfType<bool>(json, r'isGeoTriggered'),
zoneRadiusMeters: mapValueOfType<double>(json, r'zoneRadiusMeters'),
imageUrl: mapValueOfType<String>(json, r'imageUrl'),
isHiddenInitially: mapValueOfType<bool>(json, r'isHiddenInitially'),
quizQuestions: QuizQuestion.listFromJson(json[r'quizQuestions']),
isStepTimer: mapValueOfType<bool>(json, r'isStepTimer'),
isStepLocked: mapValueOfType<bool>(json, r'isStepLocked'),
timerSeconds: mapValueOfType<int>(json, r'timerSeconds'),
timerExpiredMessage:
TranslationDTO.listFromJson(json[r'timerExpiredMessage']),

View File

@ -790,7 +790,7 @@ packages:
source: hosted
version: "2.0.0"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"

View File

@ -84,6 +84,7 @@ dependencies:
firebase_core: ^3.1.0
fl_chart: ^0.69.0
toastification: ^2.3.0
http: ^1.4.0
#another_flushbar: ^1.12.30
dependency_overrides:
@ -116,6 +117,7 @@ flutter:
- assets/files/
- assets/files/policy_text.text
- assets/config/
- assets/fonts/
msix_config:
display_name: Manager

View File

@ -0,0 +1,102 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/Screens/Configurations/Section/SubSection/Parcours/progression_mode.dart';
/// Le client ne choisit qu'un mode de progression ; ces tests verrouillent la
/// traduction de ce choix vers les booléens réellement stockés sur le GuidedPath,
/// et surtout le fait que l'aller-retour choix → booléens → choix soit stable.
void main() {
GuidedPathDTO pathWith({bool? isLinear, bool? requireSuccess, bool? hideNext}) =>
GuidedPathDTO(
title: [],
description: [],
steps: [],
order: 0,
isLinear: isLinear,
requireSuccessToAdvance: requireSuccess,
hideNextStepsUntilComplete: hideNext,
);
group('progressionModeOf', () {
test('isLinear = false → Libre', () {
expect(progressionModeOf(pathWith(isLinear: false)), ProgressionMode.free);
});
test('isLinear = true sans validation → Dans l\'ordre', () {
expect(
progressionModeOf(pathWith(isLinear: true, requireSuccess: false)),
ProgressionMode.ordered,
);
});
test('isLinear = true avec validation → Étape par étape', () {
expect(
progressionModeOf(pathWith(isLinear: true, requireSuccess: true)),
ProgressionMode.stepByStep,
);
});
test('parcours neuf (tout à null) → Dans l\'ordre', () {
// Défaut le plus sûr : une séquence ordonnée, sans rien verrouiller.
expect(progressionModeOf(pathWith()), ProgressionMode.ordered);
});
test('validation demandée sans isLinear explicite → Étape par étape', () {
expect(
progressionModeOf(pathWith(requireSuccess: true)),
ProgressionMode.stepByStep,
);
});
});
group('applyProgressionMode', () {
test('Libre efface séquencement, validation et masquage', () {
final path = pathWith(isLinear: true, requireSuccess: true, hideNext: true);
applyProgressionMode(path, ProgressionMode.free);
expect(path.isLinear, isFalse);
expect(path.requireSuccessToAdvance, isFalse);
expect(path.hideNextStepsUntilComplete, isFalse);
});
test('Dans l\'ordre impose la séquence sans rien verrouiller', () {
final path = pathWith(isLinear: false, requireSuccess: true, hideNext: true);
applyProgressionMode(path, ProgressionMode.ordered);
expect(path.isLinear, isTrue);
expect(path.requireSuccessToAdvance, isFalse);
expect(path.hideNextStepsUntilComplete, isFalse);
});
test('Étape par étape impose séquence + validation', () {
final path = pathWith(isLinear: false, requireSuccess: false);
applyProgressionMode(path, ProgressionMode.stepByStep);
expect(path.isLinear, isTrue);
expect(path.requireSuccessToAdvance, isTrue);
});
test('Étape par étape préserve le masquage choisi par le client', () {
// `hideNextStepsUntilComplete` est une case à cocher propre à ce mode :
// la sélectionner à nouveau ne doit pas la réinitialiser.
final path = pathWith(isLinear: true, requireSuccess: true, hideNext: true);
applyProgressionMode(path, ProgressionMode.stepByStep);
expect(path.hideNextStepsUntilComplete, isTrue);
});
});
group('aller-retour', () {
for (final mode in ProgressionMode.values) {
test('$mode se relit à l\'identique après application', () {
final path = pathWith();
applyProgressionMode(path, mode);
expect(progressionModeOf(path), mode);
});
}
});
}

View File

@ -0,0 +1,100 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:manager_app/Screens/Statistics/statistics_report.dart';
StatisticsReportData _data({
List<ReportDayPoint> series = const [],
List<ReportBarGroup> barGroups = const [],
List<ReportTable> tables = const [],
List<int> axisLabels = const [],
}) {
return StatisticsReportData(
instanceName: 'Fort Saint Héribert',
title: 'Votre rapport de fréquentation',
periodLabel: 'Du 1 août au 31 août 2026',
takeaways: const [
'La fréquentation progresse de 18 % par rapport à la période précédente.',
'Le guide vocal représente 12 % des visites.',
],
kpis: const [
ReportKpi('Visites', '3 847', '18 % vs période précédente'),
ReportKpi('Durée moyenne', '14 min 20 s', '2 min 10 s'),
ReportKpi('Contenus par visite', '5,2', null),
ReportKpi('Part du guide vocal', '12 %', 'stable'),
],
chartTitle: 'Visites par jour',
chartSubtitle: 'Nombre de consultations sur la période',
series: series,
axisLabels: axisLabels,
peakLabel: 'Pic le 22 août — 288 visites',
barGroups: barGroups,
tables: tables,
generatedLabel: 'Rapport généré le 7 août avec MyInfoMate',
brandArgb: 0xFF264863,
logo: null,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('le rapport complet produit un PDF', () async {
final series = [
for (var day = 1; day <= 31; day++)
ReportDayPoint('$day août', day % 7 == 0 ? 288 : 80 + day * 3),
];
final bytes = await buildStatisticsReport(_data(
series: series,
axisLabels: const [0, 6, 12, 18, 24, 30],
barGroups: const [
ReportBarGroup('Contenus les plus consultés', 'Nombre de consultations', [
ReportBar('Histoire du fort', '1 284', 1),
ReportBar('La vie quotidienne des soldats', '731', 0.569),
ReportBar('Quiz « Connais-tu le fort ? »', '337', 0.262),
]),
ReportBarGroup('Canaux', 'Visites par canal', [
ReportBar('Site web', '1 642', 1),
ReportBar('Guide vocal', '463', 0.282),
]),
],
tables: const [
ReportTable('Top POI', ['POI', 'Taps'], [
['Casemate nord', '212'],
['Poudrière', '96'],
]),
],
));
expect(bytes.length, greaterThan(1000));
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
});
test('le rapport tient sans courbe, sans barres et sans tableaux', () async {
final bytes = await buildStatisticsReport(_data());
expect(bytes.length, greaterThan(1000));
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
});
test('une série d\'un seul point sort le rapport sans courbe', () async {
final bytes = await buildStatisticsReport(_data(
series: const [ReportDayPoint('7 août', 3)],
axisLabels: const [0],
));
expect(bytes.length, greaterThan(1000));
});
test('une série entièrement à zéro reste traçable', () async {
final bytes = await buildStatisticsReport(_data(
series: const [
ReportDayPoint('1 août', 0),
ReportDayPoint('2 août', 0),
ReportDayPoint('3 août', 0),
],
axisLabels: const [0, 2],
));
expect(bytes.length, greaterThan(1000));
});
}