D5 et mention RGPD aux visiteurs

D5 — les échecs de téléchargement ne disparaissent plus dans un print : ils sont
collectés et la visite n'est déclarée téléchargée que si elle l'est vraiment.
Nouvelle clé downloadIncomplete dans les 10 langues, sans quoi getFromLocale
aurait rendu une chaîne vide.

Trouvé en câblant l'écran, et c'est pire que le bug d'origine : sans état
d'échec, une visite incomplète restait sur « téléchargement en cours »
indéfiniment. Le compteur d'avancement n'est incrémenté qu'en cas de succès, donc
il n'atteignait jamais le total et l'écran ne concluait jamais. Relancer ne
re-télécharge que ce qui manque.

J4 — mention d'information accessible depuis l'assistant lui-même, là où la
collecte a lieu (§8.3 des CGU). Texte repris mot pour mot de
DOCS/mention-information-visiteurs.md.
FR/NL/EN seulement, repli sur l'anglais : traduire une mention de protection des
données sans relecture humaine serait pire que la servir en anglais. Les 7 autres
langues attendent une traduction relue, à demander avec la relecture juridique.

flutter analyze lib sans erreur, flutter build apk --debug --flavor dev vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-13 16:01:00 +02:00
parent e967678dd2
commit f013d33bba
4 changed files with 186 additions and 17 deletions

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:mymuseum_visitapp/Components/VisitorPrivacyNotice.dart';
import 'package:mymuseum_visitapp/Helpers/assistantSuggestions.dart'; import 'package:mymuseum_visitapp/Helpers/assistantSuggestions.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/AssistantResponse.dart'; import 'package:mymuseum_visitapp/Models/AssistantResponse.dart';
@ -265,6 +266,15 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
}, },
), ),
const Spacer(), const Spacer(),
// §8.3 des CGU : la mention doit être accessible depuis l'assistant
// lui-même, c'est-à-dire là où la collecte a lieu — pas seulement dans
// la politique de confidentialité du lieu.
IconButton(
icon: const Icon(Icons.privacy_tip_outlined, size: 20),
tooltip: VisitorPrivacyNotice.textFor(widget.visitAppContext.language).title,
onPressed: () =>
VisitorPrivacyNotice.show(context, widget.visitAppContext),
),
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),

View File

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
/// Mention d'information aux visiteurs sur le traitement de leurs questions (§8.3 des CGU).
///
/// **Texte repris mot pour mot de `DOCS/mention-information-visiteurs.md`**, qui est le
/// document de référence remis au client. Toute correction se fait -bas d'abord : c'est lui
/// que le client publie dans sa politique de confidentialité, et les deux ne doivent pas
/// diverger. Il est **rédigé côté produit et non validé juridiquement** (lot J, J5).
///
/// **Trois langues seulement, repli sur l'anglais — c'est un choix, pas un oubli.** L'app
/// en porte dix, mais traduire une mention de protection des données sans relecture humaine
/// serait pire que la servir en anglais : une nuance perdue sur « nous n'enregistrons pas
/// votre adresse IP » n'est pas une coquille d'interface. Les sept autres langues attendent
/// une traduction relue, à demander en même temps que la relecture juridique.
class VisitorPrivacyNotice extends StatelessWidget {
final VisitAppContext visitAppContext;
const VisitorPrivacyNotice({Key? key, required this.visitAppContext}) : super(key: key);
static void show(BuildContext context, VisitAppContext visitAppContext) {
showDialog(
context: context,
builder: (_) => VisitorPrivacyNotice(visitAppContext: visitAppContext),
);
}
static const Map<String, ({String title, String body})> _texts = {
'FR': (
title: 'Vos questions au guide',
body:
"Lorsque vous posez une question à notre guide, votre question et la réponse qui vous "
"est donnée sont enregistrées. Cela nous sert à repérer ce que nos visiteurs cherchent "
"sans le trouver, et à compléter nos contenus en conséquence.\n\n"
"Nous n'enregistrons ni votre nom, ni votre compte, ni votre adresse IP. Un identifiant "
"de session tiré au hasard permet seulement de relier entre elles les questions d'une "
"même conversation ; il disparaît avec elle.\n\n"
"Votre question est transmise à notre fournisseur d'intelligence artificielle (Google) "
"pour produire la réponse. Le texte de vos questions est supprimé au bout de 90 jours ; "
"seuls des regroupements par sujet, sans le texte de vos questions, sont conservés "
"au-delà.\n\n"
"Le champ de question est libre : nous vous invitons à ne pas y saisir d'informations "
"personnelles."
),
'NL': (
title: 'Uw vragen aan de gids',
body:
"Wanneer u onze gids een vraag stelt, worden uw vraag en het gegeven antwoord "
"opgeslagen. Zo zien we wat onze bezoekers zoeken zonder het te vinden, en vullen we "
"onze inhoud aan.\n\n"
"Wij registreren noch uw naam, noch een account, noch uw IP-adres. Een willekeurig "
"gegenereerde sessie-identificatie dient enkel om de vragen van eenzelfde gesprek aan "
"elkaar te koppelen; ze verdwijnt samen met dat gesprek.\n\n"
"Uw vraag wordt doorgestuurd naar onze aanbieder van kunstmatige intelligentie (Google) "
"om het antwoord op te stellen. De tekst van uw vragen wordt na 90 dagen verwijderd; "
"daarna blijven enkel groeperingen per onderwerp bewaard, zonder de tekst van uw "
"vragen.\n\n"
"Het vraagveld is vrij in te vullen: wij raden u aan er geen persoonsgegevens in te "
"typen."
),
'EN': (
title: 'Your questions to the guide',
body:
"When you ask our guide a question, your question and the answer you are given are "
"recorded. This helps us see what our visitors look for without finding it, and improve "
"our content accordingly.\n\n"
"We record neither your name, nor an account, nor your IP address. A randomly generated "
"session identifier only links together the questions of a single conversation; it "
"disappears with it.\n\n"
"Your question is sent to our artificial intelligence provider (Google) to produce the "
"answer. The text of your questions is deleted after 90 days; beyond that, only "
"groupings by topic are kept, without the text of your questions.\n\n"
"The question field is free text: we invite you not to enter personal information in it."
),
};
static ({String title, String body}) textFor(String? language) =>
_texts[(language ?? 'FR').toUpperCase()] ?? _texts['EN']!;
@override
Widget build(BuildContext context) {
final text = textFor(visitAppContext.language);
return AlertDialog(
title: Text(text.title, style: const TextStyle(fontSize: 17)),
content: SingleChildScrollView(
child: Text(text.body, style: const TextStyle(fontSize: 13.5, height: 1.45)),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
);
}
}

View File

@ -27,6 +27,11 @@ class DownloadConfigurationWidget extends StatefulWidget {
class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidget> { class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidget> {
ValueNotifier<int> currentResourceIndex = ValueNotifier<int>(0); ValueNotifier<int> currentResourceIndex = ValueNotifier<int>(0);
ValueNotifier<int> currentResourceNbr = ValueNotifier<int>(-1); ValueNotifier<int> currentResourceNbr = ValueNotifier<int>(-1);
/// D5 nombre de ressources dont le téléchargement a échoué. Alimente le message
/// d'échec : « 3 fichiers n'ont pas pu être téléchargés » dit au visiteur ce qui
/// s'est passé, là où l'écran d'erreur générique le laissait supposer une panne.
ValueNotifier<int> downloadFailureCount = ValueNotifier<int>(0);
bool isAlreadyDownloading = false; bool isAlreadyDownloading = false;
//OtaEvent? currentEvent; //OtaEvent? currentEvent;
@ -106,6 +111,9 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
Map<String, DateTime?> localResourceDates = await readLocalResourceDates(); Map<String, DateTime?> localResourceDates = await readLocalResourceDates();
// Ressources dont le téléchargement a échoué. Vide = visite complète.
final List<String> failedResourceIds = [];
var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) => resource.type != ResourceType.ImageUrl && resource.type != ResourceType.VideoUrl && resource.type != ResourceType.JsonUrl && resource.url != null && isResourceOutdated(resource, fileList, localResourceDates[resource.id])); var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) => resource.type != ResourceType.ImageUrl && resource.type != ResourceType.VideoUrl && resource.type != ResourceType.JsonUrl && resource.url != null && isResourceOutdated(resource, fileList, localResourceDates[resource.id]));
currentResourceNbr.value = resourcesToDownload.length; currentResourceNbr.value = resourcesToDownload.length;
@ -127,7 +135,12 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
currentResourceIndex.value++; currentResourceIndex.value++;
} else { } else {
print("NOT SUCCESSS"); // D5 un fichier raté ne disparaît plus dans un `print`. Une visite dont
// des ressources manquent ne doit pas s'annoncer téléchargée : hors ligne,
// le visiteur trouverait des images vides ou un audio muet sans comprendre
// pourquoi, et sans rien pouvoir y faire.
failedResourceIds.add(resource.id ?? '?');
debugPrint("[download] échec de la ressource ${resource.id} (${resource.url})");
} }
} }
@ -234,6 +247,16 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
// `CachedCustomResource` retrouve le fichier en listant le répertoire. // `CachedCustomResource` retrouve le fichier en listant le répertoire.
} }
// D5 la visite n'est déclarée téléchargée que si elle l'est vraiment. Le
// contenu déjà récupéré reste sur le device : relancer ne re-télécharge que
// ce qui manque, `isResourceOutdated` voyant les fichiers présents.
if (failedResourceIds.isNotEmpty) {
debugPrint("[download] ${failedResourceIds.length} ressource(s) en échec : "
"${failedResourceIds.join(', ')}");
downloadFailureCount.value = failedResourceIds.length;
return false;
}
} catch(e) { } catch(e) {
print("ERRORRRR"); print("ERRORRRR");
print(e); print(e);
@ -290,22 +313,51 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Center( // D5 l'état d'échec passe avant les autres. Sans lui, une visite
child: Padding( // incomplète restait sur « téléchargement en cours » indéfiniment :
padding: const EdgeInsets.all(8.0), // le compteur d'avancement n'est incrémenté qu'en cas de succès, donc
child: Text( // il n'atteignait jamais le total et l'écran ne concluait jamais.
textAlign: TextAlign.center, ValueListenableBuilder<int>(
valueIndex == valueNbr && valueNbr != -1 ? valueNbr == 0 ? valueListenable: downloadFailureCount,
TranslationHelper.getFromLocale( builder: (context, failures, _) {
"upToDate", if (failures == 0) {
appContext.getContext()) : TranslationHelper.getFromLocale( return Center(
"downloadFinish", child: Padding(
appContext.getContext()) : TranslationHelper.getFromLocale( padding: const EdgeInsets.all(8.0),
"downloadInProgress", child: Text(
appContext.getContext()), textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20), valueIndex == valueNbr && valueNbr != -1 ? valueNbr == 0 ?
), TranslationHelper.getFromLocale(
), "upToDate",
appContext.getContext()) : TranslationHelper.getFromLocale(
"downloadFinish",
appContext.getContext()) : TranslationHelper.getFromLocale(
"downloadInProgress",
appContext.getContext()),
style: const TextStyle(fontSize: 20),
),
),
);
}
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 34, color: Colors.orangeAccent),
const SizedBox(height: 10),
Text(
TranslationHelper.getFromLocale(
"downloadIncomplete", appContext.getContext()),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
],
),
),
);
},
), ),
if(valueNbr != -1 && valueNbr != 0) if(valueNbr != -1 && valueNbr != 0)
Center( Center(

View File

@ -25,6 +25,7 @@ List<Translation> translations = [
"restart": "Recommencer", "restart": "Recommencer",
"downloadInProgress": "Téléchargement en cours", "downloadInProgress": "Téléchargement en cours",
"downloadFinish": "Téléchargement terminé", "downloadFinish": "Téléchargement terminé",
"downloadIncomplete": "Téléchargement incomplet : certains fichiers manquent. Relancez le téléchargement, seuls les fichiers manquants seront récupérés.",
"upToDate": "Tout est à jour", "upToDate": "Tout est à jour",
"weather.hourly": "Prochaines heures", "weather.hourly": "Prochaines heures",
"weather.nextdays": "Prochains jours", "weather.nextdays": "Prochains jours",
@ -68,6 +69,7 @@ List<Translation> translations = [
"restart": "Restart", "restart": "Restart",
"downloadInProgress": "Download in progress", "downloadInProgress": "Download in progress",
"downloadFinish": "Download complete", "downloadFinish": "Download complete",
"downloadIncomplete": "Download incomplete: some files are missing. Start the download again — only the missing files will be fetched.",
"upToDate": "Up to date", "upToDate": "Up to date",
"weather.hourly": "Hourly", "weather.hourly": "Hourly",
"weather.nextdays": "Next days", "weather.nextdays": "Next days",
@ -111,6 +113,7 @@ List<Translation> translations = [
"restart": "Neu starten", "restart": "Neu starten",
"downloadInProgress": "Download läuft", "downloadInProgress": "Download läuft",
"downloadFinish": "Download abgeschlossen", "downloadFinish": "Download abgeschlossen",
"downloadIncomplete": "Download unvollständig: Einige Dateien fehlen. Starten Sie den Download erneut — es werden nur die fehlenden Dateien geladen.",
"upToDate": "Alles ist auf dem neuesten Stand", "upToDate": "Alles ist auf dem neuesten Stand",
"weather.hourly": "Nächste Stunden", "weather.hourly": "Nächste Stunden",
"weather.nextdays": "Nächsten Tage", "weather.nextdays": "Nächsten Tage",
@ -154,6 +157,7 @@ List<Translation> translations = [
"restart": "Herstarten", "restart": "Herstarten",
"downloadInProgress": "Download bezig", "downloadInProgress": "Download bezig",
"downloadFinish": "Download voltooid", "downloadFinish": "Download voltooid",
"downloadIncomplete": "Download onvolledig: sommige bestanden ontbreken. Start de download opnieuw — alleen de ontbrekende bestanden worden opgehaald.",
"upToDate": "Alles is up-to-date", "upToDate": "Alles is up-to-date",
"weather.hourly": "Volgende uren", "weather.hourly": "Volgende uren",
"weather.nextdays": "Volgende dagen", "weather.nextdays": "Volgende dagen",
@ -197,6 +201,7 @@ List<Translation> translations = [
"restart": "Ricomincia", "restart": "Ricomincia",
"downloadInProgress": "Download in corso", "downloadInProgress": "Download in corso",
"downloadFinish": "Download completato", "downloadFinish": "Download completato",
"downloadIncomplete": "Download incompleto: alcuni file mancano. Riavvia il download — verranno recuperati solo i file mancanti.",
"upToDate": "Tutto è aggiornato", "upToDate": "Tutto è aggiornato",
"weather.hourly": "Le prossime ore", "weather.hourly": "Le prossime ore",
"weather.nextdays": "Prossimi giorni", "weather.nextdays": "Prossimi giorni",
@ -240,6 +245,7 @@ List<Translation> translations = [
"restart": "Reanudar", "restart": "Reanudar",
"downloadInProgress": "Descarga en curso", "downloadInProgress": "Descarga en curso",
"downloadFinish": "Descarga completada", "downloadFinish": "Descarga completada",
"downloadIncomplete": "Descarga incompleta: faltan algunos archivos. Reinicie la descarga — solo se recuperarán los archivos que faltan.",
"upToDate": "Todo está al día", "upToDate": "Todo está al día",
"weather.hourly": "Próximas horas", "weather.hourly": "Próximas horas",
"weather.nextdays": "Proximos dias", "weather.nextdays": "Proximos dias",
@ -283,6 +289,7 @@ List<Translation> translations = [
"restart": "Uruchom ponownie", "restart": "Uruchom ponownie",
"downloadInProgress": "Pobieranie w toku", "downloadInProgress": "Pobieranie w toku",
"downloadFinish": "Pobieranie zakończone", "downloadFinish": "Pobieranie zakończone",
"downloadIncomplete": "Pobieranie niekompletne: brakuje niektórych plików. Uruchom pobieranie ponownie — zostaną pobrane tylko brakujące pliki.",
"upToDate": "Wszystko jest aktualne", "upToDate": "Wszystko jest aktualne",
"weather.hourly": "Następne godziny", "weather.hourly": "Następne godziny",
"weather.nextdays": "Następne dni", "weather.nextdays": "Następne dni",
@ -326,6 +333,7 @@ List<Translation> translations = [
"restart": "重新开始", "restart": "重新开始",
"downloadInProgress": "下载中", "downloadInProgress": "下载中",
"downloadFinish": "下载完成", "downloadFinish": "下载完成",
"downloadIncomplete": "下载不完整:部分文件缺失。请重新开始下载,系统只会获取缺失的文件。",
"upToDate": "已是最新", "upToDate": "已是最新",
"weather.hourly": "接下来的几个小时", "weather.hourly": "接下来的几个小时",
"weather.nextdays": "未来几天", "weather.nextdays": "未来几天",
@ -369,6 +377,7 @@ List<Translation> translations = [
"restart": "Перезапустіть", "restart": "Перезапустіть",
"downloadInProgress": "Завантаження триває", "downloadInProgress": "Завантаження триває",
"downloadFinish": "Завантаження завершено", "downloadFinish": "Завантаження завершено",
"downloadIncomplete": "Завантаження неповне: деяких файлів бракує. Запустіть завантаження ще раз — буде отримано лише відсутні файли.",
"upToDate": "Все актуально", "upToDate": "Все актуально",
"weather.hourly": "Наступні години", "weather.hourly": "Наступні години",
"weather.nextdays": "Наступні дні", "weather.nextdays": "Наступні дні",
@ -412,6 +421,7 @@ List<Translation> translations = [
"restart": "إعادة تشغيل", "restart": "إعادة تشغيل",
"downloadInProgress": "جارٍ التنزيل", "downloadInProgress": "جارٍ التنزيل",
"downloadFinish": "اكتمل التنزيل", "downloadFinish": "اكتمل التنزيل",
"downloadIncomplete": "التنزيل غير مكتمل: بعض الملفات مفقودة. أعد بدء التنزيل — سيتم جلب الملفات المفقودة فقط.",
"upToDate": "كل شيء محدث", "upToDate": "كل شيء محدث",
"weather.hourly": "الساعات القادمة", "weather.hourly": "الساعات القادمة",
"weather.nextdays": "الايام القادمة", "weather.nextdays": "الايام القادمة",