diff --git a/android/app/build.gradle b/android/app/build.gradle index acc0477..11d6a82 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -124,6 +124,7 @@ android { signingConfig signingConfigs.release minifyEnabled true shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' debuggable false } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..0341318 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,8 @@ +# Le SDK Meta Wearables (meta_wearables_dat) référence des annotations et utilitaires +# Facebook internes qui ne sont pas publiés dans ses artefacts. R8 les signale comme +# classes manquantes et échoue le minify du build release. +-dontwarn com.facebook.annotations.DoNotOptimize +-dontwarn com.facebook.common.preconditions.Preconditions +-dontwarn com.facebook.infer.annotation.Nullsafe +-dontwarn com.facebook.infer.annotation.NullsafeStrict +-dontwarn com.facebook.secure.sanitizer.intf.DataSanitizer diff --git a/lib/Components/AssistantChatSheet.dart b/lib/Components/AssistantChatSheet.dart index 3da9fcd..72235b0 100644 --- a/lib/Components/AssistantChatSheet.dart +++ b/lib/Components/AssistantChatSheet.dart @@ -12,7 +12,6 @@ import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart'; import 'package:mymuseum_visitapp/constants.dart'; import 'package:speech_to_text/speech_to_text.dart'; -String _stripHtml(String html) => html.replaceAll(RegExp(r'<[^>]*>'), '').trim(); class AssistantChatSheet extends StatefulWidget { final VisitAppContext visitAppContext; @@ -172,7 +171,7 @@ class _AssistantChatSheetState extends State { String get _venueName { final configuration = widget.visitAppContext.configuration; if (configuration == null) return ''; - final title = _stripHtml( + final title = TranslationHelper.stripHtml( TranslationHelper.get(configuration.title, widget.visitAppContext)); return title.isNotEmpty ? title : (configuration.label ?? ''); } @@ -653,7 +652,7 @@ class _AssistantMessage extends StatelessWidget { Navigator.of(context).pop(); onNavigate!( response.navigation!.sectionId, - _stripHtml(response.navigation!.sectionTitle), + TranslationHelper.stripHtml(response.navigation!.sectionTitle), ); }, child: Container( @@ -700,7 +699,7 @@ class _AssistantMessage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _stripHtml(response.navigation!.sectionTitle), + TranslationHelper.stripHtml(response.navigation!.sectionTitle), style: TextStyle( fontWeight: FontWeight.w600, fontSize: 13, diff --git a/lib/Components/GlassesStatusWidget.dart b/lib/Components/GlassesStatusWidget.dart index 9d7835e..9799b52 100644 --- a/lib/Components/GlassesStatusWidget.dart +++ b/lib/Components/GlassesStatusWidget.dart @@ -8,8 +8,12 @@ import 'package:provider/provider.dart'; /// Icône circulaire positionnée en haut à droite de l'écran d'accueil. /// /// Visible si : -/// - Les lunettes sont connectées (quel que soit le mode) -/// - OU un mode vocal est actif (voiceOnly ou glasses) +/// - La fonctionnalité est activée sur l'instance +/// - OU un mode vocal est encore actif — porte de sortie pour le couper si +/// l'instance a été désactivée entre-temps. +/// +/// Des lunettes appairées ne suffisent pas : sans la fonctionnalité sur +/// l'instance, il n'y a rien à en faire. /// /// Tap → ouvre VoiceModeSheet. class GlassesStatusWidget extends StatelessWidget { @@ -29,7 +33,7 @@ class GlassesStatusWidget extends StatelessWidget { final modeActive = controller.mode != VoiceMode.none; final featureAvailable = controller.isFeatureAvailable(visitAppContext); - if (!featureAvailable && !isConnected && !modeActive) return const SizedBox.shrink(); + if (!featureAvailable && !modeActive) return const SizedBox.shrink(); final Color iconColor = switch ((isConnected, controller.mode)) { (true, VoiceMode.glasses) => Colors.greenAccent, diff --git a/lib/Components/HtmlText.dart b/lib/Components/HtmlText.dart new file mode 100644 index 0000000..97c64c3 --- /dev/null +++ b/lib/Components/HtmlText.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; + +/// Rendu d'un champ traduit. Tous les champs saisis dans le manager passent par +/// l'éditeur riche : leur valeur est du HTML, titres compris. +/// +/// La marge par défaut des `

` est neutralisée pour que le bloc s'aligne comme +/// un `Text` à la place duquel il est posé. +class HtmlText extends StatelessWidget { + const HtmlText(this.html, {super.key, this.style, this.textAlign}); + + final String html; + + /// Par défaut, le style ambiant — celui que `ListTile` applique à son titre, + /// par exemple. + final TextStyle? style; + final TextAlign? textAlign; + + @override + Widget build(BuildContext context) { + return HtmlWidget( + html, + textStyle: style ?? DefaultTextStyle.of(context).style, + customStylesBuilder: (_) => { + 'margin': '0', + if (textAlign != null) 'text-align': _cssAlign(textAlign!), + }, + ); + } + + static String _cssAlign(TextAlign align) => switch (align) { + TextAlign.center => 'center', + TextAlign.right || TextAlign.end => 'right', + TextAlign.justify => 'justify', + _ => 'left', + }; +} diff --git a/lib/Components/VoiceModeSheet.dart b/lib/Components/VoiceModeSheet.dart index 463ffaf..d246339 100644 --- a/lib/Components/VoiceModeSheet.dart +++ b/lib/Components/VoiceModeSheet.dart @@ -63,12 +63,12 @@ class VoiceModeSheet extends StatelessWidget { ), const SizedBox(height: 16), - // Section lunettes — toujours visible - _GlassesConnectionTile(), - const SizedBox(height: 20), - - // Modes — uniquement si isAssistant - if (controller.isFeatureAvailable(visitAppContext)) ...[ + // Lunettes et modes — uniquement si la fonctionnalité est activée + // sur l'instance, ou si un mode est resté actif (pour le couper). + if (controller.isFeatureAvailable(visitAppContext) || + controller.mode != VoiceMode.none) ...[ + _GlassesConnectionTile(), + const SizedBox(height: 20), const Text( 'Mode', style: TextStyle(color: Colors.white54, fontSize: 12, letterSpacing: 1), diff --git a/lib/Helpers/assistantSuggestions.dart b/lib/Helpers/assistantSuggestions.dart index 5f99940..fbcb78e 100644 --- a/lib/Helpers/assistantSuggestions.dart +++ b/lib/Helpers/assistantSuggestions.dart @@ -1,3 +1,4 @@ +import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; /// Questions proposées au visiteur quand il ouvre l'assistant. @@ -69,8 +70,6 @@ class AssistantSuggestions { return name == null ? template : template.replaceAll('{name}', name); } - static String _stripHtml(String value) => - value.replaceAll(RegExp(r'<[^>]*>'), '').trim(); static String _titleOf(Map section, String lang) { final titles = section['title']; @@ -80,7 +79,7 @@ class AssistantSuggestions { orElse: () => titles.first, ); if (match is! Map) return ''; - return _stripHtml((match['value'] as String?) ?? ''); + return TranslationHelper.stripHtml((match['value'] as String?) ?? ''); } static List build(VisitAppContext context, {String? currentSectionId}) { diff --git a/lib/Helpers/modelsHelper.dart b/lib/Helpers/modelsHelper.dart index 14db4d3..2accfee 100644 --- a/lib/Helpers/modelsHelper.dart +++ b/lib/Helpers/modelsHelper.dart @@ -20,7 +20,13 @@ class ModelsHelper { }; } - static Map sectionToMap(SectionDTO section) { + /// [rawSectionJson] est la réponse brute de `sectionGetDetail`, encodée en JSON. + /// `SectionDTO` ne porte que l'en-tête commun : depuis la v3, le payload typé + /// (contenus d'article, questions de quiz, points de carte) vit dans les sous-DTO + /// et n'existe que dans cette réponse brute. Sans elle, une section relue hors + /// ligne n'a rien à afficher — et la colonne `data`, déclarée NOT NULL, faisait + /// échouer l'insertion, donc la table restait vide. + static Map sectionToMap(SectionDTO section, String rawSectionJson) { return { 'id': section.id, 'instanceId': section.instanceId, @@ -33,7 +39,7 @@ class ModelsHelper { 'isSubSection': section.isSubSection, 'parentId': section.parentId, 'type': section.type!.value, - //'data': section.data, // TODO section data + 'data': rawSectionJson, 'dateCreation': section.dateCreation!.toUtc().toIso8601String(), 'orderOfElement': section.order, }; diff --git a/lib/Helpers/translationHelper.dart b/lib/Helpers/translationHelper.dart index 4672655..14262f8 100644 --- a/lib/Helpers/translationHelper.dart +++ b/lib/Helpers/translationHelper.dart @@ -19,6 +19,21 @@ class TranslationHelper { } } + /// Tous les champs traduits sont saisis dans l'éditeur riche du manager : + /// leur valeur est du HTML. À utiliser quand le rendu ne peut pas être un + /// HtmlWidget — texte interpolé dans une phrase, comparaison, recherche. + static String stripHtml(String html) => html + .replaceAll(RegExp(r'<[^>]*>', multiLine: true), ' ') + .replaceAll(' ', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + static String getPlain(List? translationDTO, VisitAppContext visitAppContext) => + stripHtml(get(translationDTO, visitAppContext)); + + static String getWithResourcePlain(List? translationAndResourceDTO, VisitAppContext visitAppContext) => + stripHtml(getWithResource(translationAndResourceDTO, visitAppContext)); + static String getFromLocale(String valueToGet, VisitAppContext visitAppContext) { try { return translations.where((element) => element.language == visitAppContext.language).first.data![valueToGet]!; diff --git a/lib/Screens/ConfigurationPage/components/body.dart b/lib/Screens/ConfigurationPage/components/body.dart index b32b702..ef3b80f 100644 --- a/lib/Screens/ConfigurationPage/components/body.dart +++ b/lib/Screens/ConfigurationPage/components/body.dart @@ -284,7 +284,7 @@ class _BodyState extends State { if (searchValue != null && searchValue!.isNotEmpty) { result = result.where((s) => - removeDiacritics(TranslationHelper.get(s.title, visitAppContext).toLowerCase()) + removeDiacritics(TranslationHelper.getPlain(s.title, visitAppContext).toLowerCase()) .contains(removeDiacritics(searchValue!.toLowerCase())) ).toList(); } else if (searchNumberValue != null) { diff --git a/lib/Screens/ConfigurationPage/configuration_page.dart b/lib/Screens/ConfigurationPage/configuration_page.dart index e49ec63..037314d 100644 --- a/lib/Screens/ConfigurationPage/configuration_page.dart +++ b/lib/Screens/ConfigurationPage/configuration_page.dart @@ -207,7 +207,7 @@ class _ConfigurationPageState extends State with WidgetsBindi ); if (matches.isNotEmpty) { - matches = matches.where((bs) => bs!.configurationId == visitAppContext.configuration!.id!); + matches = matches.where((bs) => bs!.configurationId == widget.configuration.id); } if (matches.isNotEmpty && !modeDebugBeacon) { @@ -374,7 +374,7 @@ class _ConfigurationPageState extends State with WidgetsBindi ), ), ), - visitAppContext.beaconSections != null && visitAppContext.beaconSections!.where((bs) => bs!.configurationId == visitAppContext.configuration!.id).isNotEmpty ? Align( + visitAppContext.beaconSections != null && visitAppContext.beaconSections!.where((bs) => bs!.configurationId == widget.configuration.id).isNotEmpty ? Align( alignment: Alignment.bottomRight, child: Padding( padding: const EdgeInsets.only(right: 90, bottom: 1), diff --git a/lib/Screens/Home/home_3.0.dart b/lib/Screens/Home/home_3.0.dart index f5fedc9..b44f705 100644 --- a/lib/Screens/Home/home_3.0.dart +++ b/lib/Screens/Home/home_3.0.dart @@ -140,36 +140,40 @@ class _HomePage3State extends State with WidgetsBindingObserver { ), ), ), - // Title at bottom-left — hauteur bornée + clip : empêche le débordement - // même si le moteur HTML natif ignore -webkit-line-clamp (contenu Quill). + // Titre + chevron sur une même ligne : le chevron reste centré + // verticalement sur le bloc de titre, quel que soit son nombre de lignes. Positioned( bottom: 10, left: 10, - right: 28, - child: ClipRect( - child: SizedBox( - height: 40, - child: HtmlWidget( - cleanedTitle, - textStyle: const TextStyle( - color: Colors.white, - fontFamily: 'Roboto', - fontSize: 14, - fontWeight: FontWeight.w600, - ), - customStylesBuilder: (_) => { - 'font-family': 'Roboto', - '-webkit-line-clamp': '2', - }, - ), - ), - ), - ), - // Chevron - const Positioned( - bottom: 10, right: 8, - child: Icon(Icons.chevron_right, size: 18, color: Colors.white), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 40), + child: HtmlWidget( + cleanedTitle, + textStyle: const TextStyle( + color: Colors.white, + fontFamily: 'Roboto', + fontSize: 14, + fontWeight: FontWeight.w600, + ), + customStylesBuilder: (_) => { + 'font-family': 'Roboto', + 'margin': '0', + '-webkit-line-clamp': '2', + }, + ), + ), + ), + ), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 18, color: Colors.white), + ], + ), ), ], ), @@ -211,23 +215,33 @@ class _HomePage3State extends State with WidgetsBindingObserver { const SizedBox(height: 20), // Entrée assistant vocal (si feature disponible) if (visitAppContext.applicationInstanceDTO?.isAssistant == true) ...[ - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.mic, color: kMainColor1), - title: const Text('Assistant vocal', style: TextStyle(fontWeight: FontWeight.w600)), - subtitle: const Text('Lunettes ou micro téléphone'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.pop(ctx); - VoiceModeSheet.show(ctx, visitAppContext: visitAppContext); - }, + Material( + type: MaterialType.transparency, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.mic, color: kMainColor1), + title: Text( + TranslationHelper.getFromLocale('settings.voiceAssistant', ctx2), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text(TranslationHelper.getFromLocale('settings.voiceAssistantSubtitle', ctx2)), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.pop(ctx); + VoiceModeSheet.show(ctx, visitAppContext: visitAppContext); + }, + ), ), const Divider(), ], - const Text('Langue', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), + Text( + TranslationHelper.getFromLocale('settings.language', ctx2), + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), const SizedBox(height: 12), Wrap( - spacing: 10, + spacing: 16, + runSpacing: 16, children: configLanguages.map((lang) { final isSelected = ctx2.language == lang; return GestureDetector( @@ -245,7 +259,7 @@ class _HomePage3State extends State with WidgetsBindingObserver { shape: BoxShape.circle, border: isSelected ? Border.all(color: kMainColor, width: 2.5) : null, image: DecorationImage( - fit: BoxFit.contain, + fit: BoxFit.cover, image: AssetImage('assets/images/old/${lang.toLowerCase()}.png'), ), boxShadow: const [BoxShadow(color: kSecondGrey, spreadRadius: 0.5, blurRadius: 5, offset: Offset(0, 1.5))], @@ -261,7 +275,10 @@ class _HomePage3State extends State with WidgetsBindingObserver { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Notifications push', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), + Text( + TranslationHelper.getFromLocale('settings.notifications', ctx2), + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), Switch( value: ctx2.notificationsEnabled, activeThumbColor: kMainColor, @@ -398,16 +415,25 @@ class _HomePage3State extends State with WidgetsBindingObserver { mainAxisSize: MainAxisSize.min, children: [ if (titleEntry != null) - Text( - titleEntry.value ?? '', - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - height: 1.2, + ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 52), + child: HtmlWidget( + (titleEntry.value ?? '') + .replaceAll('\n', ' ') + .replaceAll('
', ' '), + textStyle: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + height: 1.2, + ), + customStylesBuilder: (_) => { + 'margin': '0', + '-webkit-line-clamp': '2', + }, + ), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), Row( @@ -443,7 +469,7 @@ class _HomePage3State extends State with WidgetsBindingObserver { borderRadius: BorderRadius.circular(12), ), child: Text( - 'Découvrir', + TranslationHelper.getFromLocale('event.discover', visitAppContext), style: TextStyle( color: kMainColor, fontSize: 12, diff --git a/lib/Screens/Sections/Event/event_page.dart b/lib/Screens/Sections/Event/event_page.dart index db115a2..1f64212 100644 --- a/lib/Screens/Sections/Event/event_page.dart +++ b/lib/Screens/Sections/Event/event_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:manager_api_new/api.dart'; +import 'package:mymuseum_visitapp/Components/HtmlText.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Event/event_map_full_page.dart'; @@ -108,6 +109,7 @@ class _EventPageState extends State { Widget _buildHero(BuildContext context) { final title = _safeTranslate(widget.section.title); + final titlePlain = _safeTranslatePlain(widget.section.title); final dateRange = _formatDateRange(); final expandedHeight = MediaQuery.of(context).size.height * 0.52; @@ -117,7 +119,7 @@ class _EventPageState extends State { pinned: true, backgroundColor: kMainColor, title: Text( - title, + titlePlain, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -163,7 +165,7 @@ class _EventPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( + HtmlText( title, style: const TextStyle( color: Colors.white, @@ -171,8 +173,6 @@ class _EventPageState extends State { fontWeight: FontWeight.bold, height: 1.2, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), if (dateRange.isNotEmpty) ...[ const SizedBox(height: 8), @@ -292,7 +292,7 @@ class _EventPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + HtmlText( title, style: TextStyle( color: isActive ? Colors.white : Colors.white.withOpacity(0.9), @@ -370,7 +370,7 @@ class _EventPageState extends State { ), ), const SizedBox(height: 16), - Text( + HtmlText( title, style: const TextStyle( color: Colors.white, @@ -380,7 +380,7 @@ class _EventPageState extends State { ), if (desc.isNotEmpty) ...[ const SizedBox(height: 10), - Text( + HtmlText( desc, style: TextStyle(color: Colors.grey[400], fontSize: 14, height: 1.6), ), @@ -399,7 +399,7 @@ class _EventPageState extends State { Icon(Icons.place, color: kMainColor, size: 16), const SizedBox(width: 8), Expanded( - child: Text( + child: HtmlText( label, style: const TextStyle(color: Colors.white, fontSize: 13), ), @@ -614,7 +614,7 @@ class _EventPageState extends State { } Widget _buildParcoursChip(BuildContext context, GuidedPathDTO path) { - final title = _safeTranslate(path.title); + final title = _safeTranslatePlain(path.title); final stepCount = path.steps?.length ?? 0; return GestureDetector( @@ -700,7 +700,7 @@ class _EventPageState extends State { const SizedBox(height: 8), ..._paths.map((path) { final title = _safeTranslate(path.title); - final desc = _safeTranslate(path.description); + final desc = _safeTranslatePlain(path.description); final stepCount = path.steps?.length ?? 0; return ListTile( contentPadding: EdgeInsets.zero, @@ -713,7 +713,7 @@ class _EventPageState extends State { ), child: Icon(Icons.map_outlined, color: kMainColor, size: 20), ), - title: Text( + title: HtmlText( title, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500), ), @@ -766,4 +766,9 @@ class _EventPageState extends State { return list.first.value ?? ''; } } + + /// Version sans balises, pour les emplacements qui exigent un ellipsis strict. + String _safeTranslatePlain(List? list) => + TranslationHelper.stripHtml(_safeTranslate(list)); + } diff --git a/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart b/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart index 25d5b4a..a53a934 100644 --- a/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart +++ b/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Components/SliderImages.dart'; +import 'package:mymuseum_visitapp/Components/HtmlText.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/resourceModel.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; @@ -124,6 +125,9 @@ class _GuidedPathContentProgressionPageState String _translate(List? list) => TranslationHelper.get(list, widget.visitAppContext); + String _translatePlain(List? list) => + TranslationHelper.stripHtml(_translate(list)); + bool get _canAdvance => _challenge?.canAdvance ?? true; void _advance() { @@ -213,13 +217,13 @@ class _GuidedPathContentProgressionPageState } void _showEnd() { - final outro = _stripHtml( + final outro = TranslationHelper.stripHtml( TranslationHelper.getWithResource(widget.path.gameMessageFin, widget.visitAppContext)); Navigator.of(context).push(MaterialPageRoute( builder: (_) => GuidedPathEndView( isGame: _isGame, primaryColor: kMainColor, - pathTitle: _translate(widget.path.title), + pathTitle: _translatePlain(widget.path.title), stepsCount: _steps.length, estimatedDurationMinutes: widget.path.estimatedDurationMinutes, gameOutro: outro, @@ -231,12 +235,6 @@ class _GuidedPathContentProgressionPageState )); } - String _stripHtml(String html) => html - .replaceAll(RegExp(r'<[^>]*>'), ' ') - .replaceAll(' ', ' ') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - @override Widget build(BuildContext context) { final step = _currentStep; @@ -389,7 +387,7 @@ class _GuidedPathContentProgressionPageState height: 200, width: double.infinity, fit: BoxFit.cover), ), if (hasContents || step.imageUrl != null) const SizedBox(height: 16), - Text(title, + HtmlText(title, style: TextStyle( color: _ink, fontSize: 22, fontWeight: FontWeight.w600, height: 1.15)), const SizedBox(height: 12), @@ -399,7 +397,7 @@ class _GuidedPathContentProgressionPageState borderRadius: BorderRadius.circular(8), color: _isGame ? _gold.withOpacity(0.12) : _accent.withOpacity(0.1), ), - child: Text(_translate(widget.path.title).toUpperCase(), + child: Text(_translatePlain(widget.path.title).toUpperCase(), style: TextStyle( color: _accent, fontSize: 11.5, diff --git a/lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart b/lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart index 4c6cc9f..d03e9ea 100644 --- a/lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart +++ b/lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:manager_api_new/api.dart'; +import 'package:mymuseum_visitapp/Components/HtmlText.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart'; @@ -67,9 +68,9 @@ class GuidedPathListSheet extends StatelessWidget { ), child: const Icon(Icons.map_outlined, color: Colors.blue, size: 20), ), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + title: HtmlText(title, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: desc.isNotEmpty - ? Text(desc, maxLines: 1, overflow: TextOverflow.ellipsis) + ? Text(TranslationHelper.stripHtml(desc), maxLines: 1, overflow: TextOverflow.ellipsis) : Text('$stepCount étape${stepCount > 1 ? 's' : ''}', style: TextStyle(color: Colors.grey[600])), trailing: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart b/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart index 1985bcb..4d2b04c 100644 --- a/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart +++ b/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:manager_api_new/api.dart'; +import 'package:mymuseum_visitapp/Components/HtmlText.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_audio_player.dart'; @@ -130,6 +131,9 @@ class _GuidedPathMapProgressionPageState extends State? list) => TranslationHelper.get(list, widget.visitAppContext); + String _translatePlain(List? list) => + TranslationHelper.stripHtml(_translate(list)); + bool _hasGeoTrigger(GuidedStepDTO step) => step.isGeoTriggered == true && step.geometry != null && @@ -201,13 +205,13 @@ class _GuidedPathMapProgressionPageState extends State GuidedPathEndView( isGame: _isGame, primaryColor: kMainColor, - pathTitle: _translate(widget.path.title), + pathTitle: _translatePlain(widget.path.title), stepsCount: _steps.length, estimatedDurationMinutes: widget.path.estimatedDurationMinutes, gameOutro: outro, @@ -264,7 +268,7 @@ class _GuidedPathMapProgressionPageState extends State html - .replaceAll(RegExp(r'<[^>]*>'), ' ') - .replaceAll(' ', ' ') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - // ─── Build ──────────────────────────────────────────────────────────────── @override @@ -548,7 +546,7 @@ class _GuidedPathMapProgressionPageState extends State html - .replaceAll(RegExp(r'<[^>]*>'), ' ') - .replaceAll(' ', ' ') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - String _normalize(String text) { const accents = 'àâäáãåçèéêëìîïíòôöóõùûüúÿñ'; const plain = 'aaaaaaceeeeiiiiooooouuuuyn'; - var s = _stripHtml(text).toLowerCase(); + var s = TranslationHelper.stripHtml(text).toLowerCase(); final buffer = StringBuffer(); for (final ch in s.split('')) { final idx = accents.indexOf(ch); @@ -650,7 +644,7 @@ class _StepQuiz extends StatelessWidget { Widget _buildSimple( QuizQuestion current, bool hasAnswer, bool checked, bool revealImmediately) { - final expectedRaw = _stripHtml(_label(current.responses.first.label)); + final expectedRaw = TranslationHelper.stripHtml(_label(current.responses.first.label)); if (_isDigicode(expectedRaw)) { return _DigicodeField( key: ValueKey('digicode-${current.id}'), diff --git a/lib/Screens/Sections/Map/map_page.dart b/lib/Screens/Sections/Map/map_page.dart index 2d1e4aa..c824a56 100644 --- a/lib/Screens/Sections/Map/map_page.dart +++ b/lib/Screens/Sections/Map/map_page.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; //import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; +import 'package:mymuseum_visitapp/Components/HtmlText.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Map/flutter_map_view.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Map/geo_point_filter.dart'; @@ -95,9 +96,11 @@ class _MapPage extends State { ), ) : const Icon(Icons.place, color: kMainColor, size: 32), - title: Text(title.isNotEmpty ? title : 'Point d\'intérêt'), + title: title.isNotEmpty + ? HtmlText(title) + : const Text('Point d\'intérêt'), subtitle: desc.isNotEmpty - ? Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis) + ? Text(TranslationHelper.stripHtml(desc), maxLines: 2, overflow: TextOverflow.ellipsis) : null, ); }, @@ -145,7 +148,12 @@ class _MapPage extends State { case MapProvider.MapBox: return MapBoxView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO, icons: widget.icons); default: - return GoogleMapView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO!, icons: widget.icons); + // Mapbox par défaut : c'est le seul fournisseur dont le SDK sait + // empaqueter ses tuiles pour un usage hors ligne (OfflineManager / + // TileStore). Google n'expose aucune API de ce genre, et le fond + // utilisé ici passe par un endpoint de tuiles non documenté qu'on + // n'a de toute façon pas le droit de mettre en cache. + return MapBoxView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO, icons: widget.icons); } } ), @@ -153,7 +161,7 @@ class _MapPage extends State { language: visitAppContext.language!, geoPoints: mapDTO!.points!, categories: mapDTO!.categories!, - provider: mapDTO!.mapProvider == null ? MapProvider.Google : mapDTO!.mapProvider!, + provider: mapDTO!.mapProvider ?? MapProvider.MapBox, filteredPoints: (value) { _geoPoints.value = value!; }), diff --git a/lib/Screens/Sections/Menu/menu_page.dart b/lib/Screens/Sections/Menu/menu_page.dart index bdaabd4..86e1c60 100644 --- a/lib/Screens/Sections/Menu/menu_page.dart +++ b/lib/Screens/Sections/Menu/menu_page.dart @@ -317,7 +317,7 @@ class _MenuPageState extends State { if (searchValue != null && searchValue!.isNotEmpty) { result = result.where((s) { final rawTitle = TranslationHelper.get(s.title, visitAppContext); - final plainText = stripHtmlTags(rawTitle); + final plainText = TranslationHelper.stripHtml(rawTitle); final normalizedTitle = removeDiacritics(plainText.toLowerCase()); final normalizedSearch = removeDiacritics(searchValue!.toLowerCase()); return normalizedTitle.contains(normalizedSearch); @@ -328,11 +328,6 @@ class _MenuPageState extends State { filteredSections.value = result; } - - String stripHtmlTags(String htmlText) { - final exp = RegExp(r'<[^>]*>', multiLine: true, caseSensitive: false); - return htmlText.replaceAll(exp, ''); - } } boxDecoration(AppContext appContext, SectionDTO section, bool isSelected, Object rawSubSectionData) { diff --git a/lib/Screens/Sections/Parcours/parcours_page.dart b/lib/Screens/Sections/Parcours/parcours_page.dart index 41b9468..6decd13 100644 --- a/lib/Screens/Sections/Parcours/parcours_page.dart +++ b/lib/Screens/Sections/Parcours/parcours_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:manager_api_new/api.dart'; +import 'package:mymuseum_visitapp/Components/HtmlText.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart'; @@ -312,7 +313,7 @@ class _ParcoursPageState extends State { ), ), const SizedBox(height: 6), - Text( + HtmlText( title, style: const TextStyle( color: _inkDark, @@ -324,7 +325,7 @@ class _ParcoursPageState extends State { if (desc.isNotEmpty) ...[ const SizedBox(height: 3), Text( - desc, + TranslationHelper.stripHtml(desc), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -432,7 +433,8 @@ class _ParcoursPathStartSheet extends StatelessWidget { (m) => m.language == visitAppContext.language, orElse: () => msg.first, ); - return found.value ?? ''; + // Interpolé dans « … » côté affichage : pas de rendu HTML possible. + return TranslationHelper.stripHtml(found.value ?? ''); } @override @@ -512,7 +514,7 @@ class _ParcoursPathStartSheet extends StatelessWidget { left: 16, right: 16, bottom: 14, - child: Text( + child: HtmlText( _title, style: const TextStyle( color: Colors.white, @@ -547,7 +549,7 @@ class _ParcoursPathStartSheet extends StatelessWidget { if (_desc.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), - child: Text( + child: HtmlText( _desc, style: const TextStyle( color: _inkMid, @@ -645,7 +647,7 @@ class _ParcoursPathStartSheet extends StatelessWidget { const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 28), - child: Text( + child: HtmlText( _title, textAlign: TextAlign.center, style: const TextStyle( diff --git a/lib/Screens/section_page.dart b/lib/Screens/section_page.dart index dde9719..5ef2681 100644 --- a/lib/Screens/section_page.dart +++ b/lib/Screens/section_page.dart @@ -187,6 +187,12 @@ class _SectionPageState extends State { List> sectionTest = await DatabaseHelper.instance.queryWithColumnId(DatabaseTableType.sections, sectionId); if(sectionTest.isNotEmpty) { sectionDTO = DatabaseHelper.instance.getSectionFromDB(sectionTest.first); + // Le `switch` plus bas construit les sous-DTO à partir de `rawSectionData`. + // Hors ligne, il n'était jamais renseigné — `late dynamic` non initialisé, + // donc toute ouverture de section levait une LateInitializationError avant + // même d'atteindre le rendu. La colonne `data` porte le JSON que la branche + // en ligne reçoit de `sectionGetDetail` : c'est exactement la même forme. + rawSectionData = jsonDecode(sectionTest.first[DatabaseHelper.columnData] as String); try { SectionRead sectionRead = SectionRead(id: sectionDTO!.id!, readTime: DateTime.now().millisecondsSinceEpoch); await DatabaseHelper.instance.insert(DatabaseTableType.articleRead, sectionRead.toMap()); diff --git a/lib/Services/downloadConfiguration.dart b/lib/Services/downloadConfiguration.dart index 5447905..f319011 100644 --- a/lib/Services/downloadConfiguration.dart +++ b/lib/Services/downloadConfiguration.dart @@ -16,6 +16,21 @@ import 'package:provider/provider.dart'; import 'package:http/http.dart' as http; import 'package:fluttertoast/fluttertoast.dart'; +/// Types dont l'écran ne fait aucun appel réseau au rendu : ils savent s'afficher +/// à partir du seul payload stocké en base locale. Les autres restent volontairement +/// dehors — Web (webview), Weather (API météo) et Agenda (JSON distant) ne peuvent +/// pas fonctionner sans réseau ; Parcours et Event appellent encore l'API au rendu ; +/// Map attend un fond de carte hors ligne, sinon le visiteur ouvre une carte grise. +const List offlineCapableSectionTypes = [ + SectionType.Article, + SectionType.Quiz, + SectionType.Menu, + SectionType.Slider, + SectionType.Pdf, + SectionType.Video, + SectionType.Game, +]; + class DownloadConfigurationWidget extends StatefulWidget { DownloadConfigurationWidget({Key? key, required this.configuration}) : super(key: key); final ConfigurationDTO configuration; @@ -114,6 +129,9 @@ class _DownloadConfigurationWidgetState extends State failedResourceIds = []; + // Sections dont le payload n'a pas pu être récupéré ou enregistré. + final List failedSectionIds = []; + 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; @@ -167,17 +185,39 @@ class _DownloadConfigurationWidgetState extends State sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, widget.configuration.id!); - List sectionsToKeep = sections.where((s) => s.type == SectionType.Article || s.type == SectionType.Quiz).toList(); // TODO: supporter tous les types de sections (Game, Menu, Map, PDF, Video, Slider, Web, Weather, Agenda) — actuellement limité à Article et Quiz + List sectionsToKeep = sections.where((s) => offlineCapableSectionTypes.contains(s.type)).toList(); sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!)); int newOrder = 0; // Update local DB - Sections for(var section in sectionsToKeep) { section.order = newOrder; + + // Le payload typé n'est pas dans l'export : `ExportConfigurationDTO.sections` + // est déserialisé en `SectionDTO`, qui ne porte que l'en-tête commun. C'est + // `sectionGetDetail` qui renvoie le JSON complet du sous-type — le même que + // celui dont `section_page` se sert en ligne. + String? rawSectionJson; try { - await DatabaseHelper.instance.insert(DatabaseTableType.sections, ModelsHelper.sectionToMap(section)); + rawSectionJson = jsonEncode(await visitAppContext.clientAPI.sectionApi!.sectionGetDetail(section.id!)); } catch (e) { - print("We got an issue inserting section data ${section.id}"); + debugPrint("[download] détail indisponible pour la section ${section.id} : $e"); + } + + // Une section sans son payload ne sait rien afficher hors ligne. On préfère + // ne pas l'enregistrer et faire échouer la visite, plutôt que laisser le + // visiteur ouvrir un article vide au milieu du site. + if (rawSectionJson == null) { + failedSectionIds.add(section.id ?? '?'); + newOrder = newOrder + 1; + continue; + } + + try { + await DatabaseHelper.instance.insert(DatabaseTableType.sections, ModelsHelper.sectionToMap(section, rawSectionJson)); + } catch (e) { + failedSectionIds.add(section.id ?? '?'); + debugPrint("[download] échec d'insertion de la section ${section.id} : $e"); } // Download section image @@ -250,10 +290,12 @@ class _DownloadConfigurationWidgetState extends State translations = [ "voice.noQrFound": "Je n'ai pas trouvé de QR code.", "voice.photoCaptured": "Photo prise.", "voice.photoFailed": "Je n'ai pas pu prendre la photo.", - "voice.cameraUnavailable": "Je ne peux pas accéder à la caméra." + "voice.cameraUnavailable": "Je ne peux pas accéder à la caméra.", + "settings.language": "Langue", + "settings.notifications": "Notifications push", + "settings.voiceAssistant": "Assistant vocal", + "settings.voiceAssistantSubtitle": "Lunettes ou micro téléphone", + "event.discover": "Découvrir" }), Translation(language: "EN", data: { "visitTitle": "List of tours", @@ -87,7 +92,12 @@ List translations = [ "voice.noQrFound": "I didn't find any QR code.", "voice.photoCaptured": "Photo taken.", "voice.photoFailed": "I couldn't take the photo.", - "voice.cameraUnavailable": "I can't access the camera." + "voice.cameraUnavailable": "I can't access the camera.", + "settings.language": "Language", + "settings.notifications": "Push notifications", + "settings.voiceAssistant": "Voice assistant", + "settings.voiceAssistantSubtitle": "Glasses or phone microphone", + "event.discover": "Discover" }), Translation(language: "DE", data: { "visitTitle": "Liste der Touren", @@ -131,7 +141,12 @@ List translations = [ "voice.noQrFound": "Ich habe keinen QR-Code gefunden.", "voice.photoCaptured": "Foto aufgenommen.", "voice.photoFailed": "Ich konnte das Foto nicht aufnehmen.", - "voice.cameraUnavailable": "Ich kann nicht auf die Kamera zugreifen." + "voice.cameraUnavailable": "Ich kann nicht auf die Kamera zugreifen.", + "settings.language": "Sprache", + "settings.notifications": "Push-Benachrichtigungen", + "settings.voiceAssistant": "Sprachassistent", + "settings.voiceAssistantSubtitle": "Brille oder Telefonmikrofon", + "event.discover": "Entdecken" }), Translation(language: "NL", data: { "visitTitle": "Lijst met rondleidingen", @@ -175,7 +190,12 @@ List translations = [ "voice.noQrFound": "Ik heb geen QR-code gevonden.", "voice.photoCaptured": "Foto genomen.", "voice.photoFailed": "Ik kon de foto niet nemen.", - "voice.cameraUnavailable": "Ik heb geen toegang tot de camera." + "voice.cameraUnavailable": "Ik heb geen toegang tot de camera.", + "settings.language": "Taal", + "settings.notifications": "Pushmeldingen", + "settings.voiceAssistant": "Spraakassistent", + "settings.voiceAssistantSubtitle": "Bril of telefoonmicrofoon", + "event.discover": "Ontdekken" }), Translation(language: "IT", data: { "visitTitle": "Elenco dei tour", @@ -219,7 +239,12 @@ List translations = [ "voice.noQrFound": "Non ho trovato nessun codice QR.", "voice.photoCaptured": "Foto scattata.", "voice.photoFailed": "Non sono riuscito a scattare la foto.", - "voice.cameraUnavailable": "Non riesco ad accedere alla fotocamera." + "voice.cameraUnavailable": "Non riesco ad accedere alla fotocamera.", + "settings.language": "Lingua", + "settings.notifications": "Notifiche push", + "settings.voiceAssistant": "Assistente vocale", + "settings.voiceAssistantSubtitle": "Occhiali o microfono del telefono", + "event.discover": "Scopri" }), Translation(language: "ES", data: { "visitTitle": "Lista de recorridos", @@ -263,7 +288,12 @@ List translations = [ "voice.noQrFound": "No he encontrado ningún código QR.", "voice.photoCaptured": "Foto tomada.", "voice.photoFailed": "No he podido tomar la foto.", - "voice.cameraUnavailable": "No puedo acceder a la cámara." + "voice.cameraUnavailable": "No puedo acceder a la cámara.", + "settings.language": "Idioma", + "settings.notifications": "Notificaciones push", + "settings.voiceAssistant": "Asistente de voz", + "settings.voiceAssistantSubtitle": "Gafas o micrófono del teléfono", + "event.discover": "Descubrir" }), Translation(language: "PL", data: { "visitTitle": "Lista wycieczek", @@ -307,7 +337,12 @@ List translations = [ "voice.noQrFound": "Nie znalazłem kodu QR.", "voice.photoCaptured": "Zdjęcie zrobione.", "voice.photoFailed": "Nie udało mi się zrobić zdjęcia.", - "voice.cameraUnavailable": "Nie mam dostępu do aparatu." + "voice.cameraUnavailable": "Nie mam dostępu do aparatu.", + "settings.language": "Język", + "settings.notifications": "Powiadomienia push", + "settings.voiceAssistant": "Asystent głosowy", + "settings.voiceAssistantSubtitle": "Okulary lub mikrofon telefonu", + "event.discover": "Odkryj" }), Translation(language: "CN", data: { "visitTitle": "旅游清单", @@ -351,7 +386,12 @@ List translations = [ "voice.noQrFound": "我没有找到任何二维码。", "voice.photoCaptured": "照片已拍摄。", "voice.photoFailed": "我无法拍摄照片。", - "voice.cameraUnavailable": "我无法访问摄像头。" + "voice.cameraUnavailable": "我无法访问摄像头。", + "settings.language": "语言", + "settings.notifications": "推送通知", + "settings.voiceAssistant": "语音助手", + "settings.voiceAssistantSubtitle": "眼镜或手机麦克风", + "event.discover": "探索" }), Translation(language: "UK", data: { "visitTitle": "Список турів", @@ -395,7 +435,12 @@ List translations = [ "voice.noQrFound": "Я не знайшов жодного QR-коду.", "voice.photoCaptured": "Фото зроблено.", "voice.photoFailed": "Мені не вдалося зробити фото.", - "voice.cameraUnavailable": "Я не можу отримати доступ до камери." + "voice.cameraUnavailable": "Я не можу отримати доступ до камери.", + "settings.language": "Мова", + "settings.notifications": "Push-сповіщення", + "settings.voiceAssistant": "Голосовий помічник", + "settings.voiceAssistantSubtitle": "Окуляри або мікрофон телефона", + "event.discover": "Дізнатися більше" }), Translation(language: "AR", data: { "visitTitle": "قائمة الجولات", @@ -439,6 +484,11 @@ List translations = [ "voice.noQrFound": "لم أجد أي رمز QR.", "voice.photoCaptured": "تم التقاط الصورة.", "voice.photoFailed": "لم أتمكن من التقاط الصورة.", - "voice.cameraUnavailable": "لا يمكنني الوصول إلى الكاميرا." + "voice.cameraUnavailable": "لا يمكنني الوصول إلى الكاميرا.", + "settings.language": "اللغة", + "settings.notifications": "الإشعارات الفورية", + "settings.voiceAssistant": "المساعد الصوتي", + "settings.voiceAssistantSubtitle": "النظارات أو ميكروفون الهاتف", + "event.discover": "اكتشف" }), ]; \ No newline at end of file