From f013d33bbacbca9d216fc47bfb76b6a330ceecca Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Thu, 13 Aug 2026 16:01:00 +0200 Subject: [PATCH] D5 et mention RGPD aux visiteurs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/Components/AssistantChatSheet.dart | 10 +++ lib/Components/VisitorPrivacyNotice.dart | 97 ++++++++++++++++++++++++ lib/Services/downloadConfiguration.dart | 86 ++++++++++++++++----- lib/translations.dart | 10 +++ 4 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 lib/Components/VisitorPrivacyNotice.dart diff --git a/lib/Components/AssistantChatSheet.dart b/lib/Components/AssistantChatSheet.dart index 634f999..79d14dd 100644 --- a/lib/Components/AssistantChatSheet.dart +++ b/lib/Components/AssistantChatSheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.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/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/AssistantResponse.dart'; @@ -265,6 +266,15 @@ class _AssistantChatSheetState extends State { }, ), 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( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), diff --git a/lib/Components/VisitorPrivacyNotice.dart b/lib/Components/VisitorPrivacyNotice.dart new file mode 100644 index 0000000..0e2254b --- /dev/null +++ b/lib/Components/VisitorPrivacyNotice.dart @@ -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 là-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 _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'), + ), + ], + ); + } +} diff --git a/lib/Services/downloadConfiguration.dart b/lib/Services/downloadConfiguration.dart index 28f91fe..5447905 100644 --- a/lib/Services/downloadConfiguration.dart +++ b/lib/Services/downloadConfiguration.dart @@ -27,6 +27,11 @@ class DownloadConfigurationWidget extends StatefulWidget { class _DownloadConfigurationWidgetState extends State { ValueNotifier currentResourceIndex = ValueNotifier(0); ValueNotifier currentResourceNbr = ValueNotifier(-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 downloadFailureCount = ValueNotifier(0); bool isAlreadyDownloading = false; //OtaEvent? currentEvent; @@ -106,6 +111,9 @@ class _DownloadConfigurationWidgetState extends State localResourceDates = await readLocalResourceDates(); + // Ressources dont le téléchargement a échoué. Vide = visite complète. + final List 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])); currentResourceNbr.value = resourcesToDownload.length; @@ -127,7 +135,12 @@ class _DownloadConfigurationWidgetState extends State( + valueListenable: downloadFailureCount, + builder: (context, failures, _) { + if (failures == 0) { + return Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + textAlign: TextAlign.center, + 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) Center( diff --git a/lib/translations.dart b/lib/translations.dart index cf91a4c..359c469 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -25,6 +25,7 @@ List translations = [ "restart": "Recommencer", "downloadInProgress": "Téléchargement en cours", "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", "weather.hourly": "Prochaines heures", "weather.nextdays": "Prochains jours", @@ -68,6 +69,7 @@ List translations = [ "restart": "Restart", "downloadInProgress": "Download in progress", "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", "weather.hourly": "Hourly", "weather.nextdays": "Next days", @@ -111,6 +113,7 @@ List translations = [ "restart": "Neu starten", "downloadInProgress": "Download läuft", "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", "weather.hourly": "Nächste Stunden", "weather.nextdays": "Nächsten Tage", @@ -154,6 +157,7 @@ List translations = [ "restart": "Herstarten", "downloadInProgress": "Download bezig", "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", "weather.hourly": "Volgende uren", "weather.nextdays": "Volgende dagen", @@ -197,6 +201,7 @@ List translations = [ "restart": "Ricomincia", "downloadInProgress": "Download in corso", "downloadFinish": "Download completato", + "downloadIncomplete": "Download incompleto: alcuni file mancano. Riavvia il download — verranno recuperati solo i file mancanti.", "upToDate": "Tutto è aggiornato", "weather.hourly": "Le prossime ore", "weather.nextdays": "Prossimi giorni", @@ -240,6 +245,7 @@ List translations = [ "restart": "Reanudar", "downloadInProgress": "Descarga en curso", "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", "weather.hourly": "Próximas horas", "weather.nextdays": "Proximos dias", @@ -283,6 +289,7 @@ List translations = [ "restart": "Uruchom ponownie", "downloadInProgress": "Pobieranie w toku", "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", "weather.hourly": "Następne godziny", "weather.nextdays": "Następne dni", @@ -326,6 +333,7 @@ List translations = [ "restart": "重新开始", "downloadInProgress": "下载中", "downloadFinish": "下载完成", + "downloadIncomplete": "下载不完整:部分文件缺失。请重新开始下载,系统只会获取缺失的文件。", "upToDate": "已是最新", "weather.hourly": "接下来的几个小时", "weather.nextdays": "未来几天", @@ -369,6 +377,7 @@ List translations = [ "restart": "Перезапустіть", "downloadInProgress": "Завантаження триває", "downloadFinish": "Завантаження завершено", + "downloadIncomplete": "Завантаження неповне: деяких файлів бракує. Запустіть завантаження ще раз — буде отримано лише відсутні файли.", "upToDate": "Все актуально", "weather.hourly": "Наступні години", "weather.nextdays": "Наступні дні", @@ -412,6 +421,7 @@ List translations = [ "restart": "إعادة تشغيل", "downloadInProgress": "جارٍ التنزيل", "downloadFinish": "اكتمل التنزيل", + "downloadIncomplete": "التنزيل غير مكتمل: بعض الملفات مفقودة. أعد بدء التنزيل — سيتم جلب الملفات المفقودة فقط.", "upToDate": "كل شيء محدث", "weather.hourly": "الساعات القادمة", "weather.nextdays": "الايام القادمة",