Misc translate html + use mapbox default (offline mode) + fix offline mode + small fix home 3 layout

This commit is contained in:
Thomas Fransolet 2026-09-04 16:51:21 +02:00
parent 2fd2132b31
commit 84ccaffcb5
23 changed files with 338 additions and 144 deletions

View File

@ -124,6 +124,7 @@ android {
signingConfig signingConfigs.release signingConfig signingConfigs.release
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
debuggable false debuggable false
} }
} }

8
android/app/proguard-rules.pro vendored Normal file
View File

@ -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

View File

@ -12,7 +12,6 @@ import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/constants.dart'; import 'package:mymuseum_visitapp/constants.dart';
import 'package:speech_to_text/speech_to_text.dart'; import 'package:speech_to_text/speech_to_text.dart';
String _stripHtml(String html) => html.replaceAll(RegExp(r'<[^>]*>'), '').trim();
class AssistantChatSheet extends StatefulWidget { class AssistantChatSheet extends StatefulWidget {
final VisitAppContext visitAppContext; final VisitAppContext visitAppContext;
@ -172,7 +171,7 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
String get _venueName { String get _venueName {
final configuration = widget.visitAppContext.configuration; final configuration = widget.visitAppContext.configuration;
if (configuration == null) return ''; if (configuration == null) return '';
final title = _stripHtml( final title = TranslationHelper.stripHtml(
TranslationHelper.get(configuration.title, widget.visitAppContext)); TranslationHelper.get(configuration.title, widget.visitAppContext));
return title.isNotEmpty ? title : (configuration.label ?? ''); return title.isNotEmpty ? title : (configuration.label ?? '');
} }
@ -653,7 +652,7 @@ class _AssistantMessage extends StatelessWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
onNavigate!( onNavigate!(
response.navigation!.sectionId, response.navigation!.sectionId,
_stripHtml(response.navigation!.sectionTitle), TranslationHelper.stripHtml(response.navigation!.sectionTitle),
); );
}, },
child: Container( child: Container(
@ -700,7 +699,7 @@ class _AssistantMessage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
_stripHtml(response.navigation!.sectionTitle), TranslationHelper.stripHtml(response.navigation!.sectionTitle),
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 13, fontSize: 13,

View File

@ -8,8 +8,12 @@ import 'package:provider/provider.dart';
/// Icône circulaire positionnée en haut à droite de l'écran d'accueil. /// Icône circulaire positionnée en haut à droite de l'écran d'accueil.
/// ///
/// Visible si : /// Visible si :
/// - Les lunettes sont connectées (quel que soit le mode) /// - La fonctionnalité est activée sur l'instance
/// - OU un mode vocal est actif (voiceOnly ou glasses) /// - 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. /// Tap ouvre VoiceModeSheet.
class GlassesStatusWidget extends StatelessWidget { class GlassesStatusWidget extends StatelessWidget {
@ -29,7 +33,7 @@ class GlassesStatusWidget extends StatelessWidget {
final modeActive = controller.mode != VoiceMode.none; final modeActive = controller.mode != VoiceMode.none;
final featureAvailable = controller.isFeatureAvailable(visitAppContext); 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)) { final Color iconColor = switch ((isConnected, controller.mode)) {
(true, VoiceMode.glasses) => Colors.greenAccent, (true, VoiceMode.glasses) => Colors.greenAccent,

View File

@ -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 `<p>` 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',
};
}

View File

@ -63,12 +63,12 @@ class VoiceModeSheet extends StatelessWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Section lunettes toujours visible // 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(), _GlassesConnectionTile(),
const SizedBox(height: 20), const SizedBox(height: 20),
// Modes uniquement si isAssistant
if (controller.isFeatureAvailable(visitAppContext)) ...[
const Text( const Text(
'Mode', 'Mode',
style: TextStyle(color: Colors.white54, fontSize: 12, letterSpacing: 1), style: TextStyle(color: Colors.white54, fontSize: 12, letterSpacing: 1),

View File

@ -1,3 +1,4 @@
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
/// Questions proposées au visiteur quand il ouvre l'assistant. /// Questions proposées au visiteur quand il ouvre l'assistant.
@ -69,8 +70,6 @@ class AssistantSuggestions {
return name == null ? template : template.replaceAll('{name}', name); return name == null ? template : template.replaceAll('{name}', name);
} }
static String _stripHtml(String value) =>
value.replaceAll(RegExp(r'<[^>]*>'), '').trim();
static String _titleOf(Map<String, dynamic> section, String lang) { static String _titleOf(Map<String, dynamic> section, String lang) {
final titles = section['title']; final titles = section['title'];
@ -80,7 +79,7 @@ class AssistantSuggestions {
orElse: () => titles.first, orElse: () => titles.first,
); );
if (match is! Map) return ''; if (match is! Map) return '';
return _stripHtml((match['value'] as String?) ?? ''); return TranslationHelper.stripHtml((match['value'] as String?) ?? '');
} }
static List<String> build(VisitAppContext context, {String? currentSectionId}) { static List<String> build(VisitAppContext context, {String? currentSectionId}) {

View File

@ -20,7 +20,13 @@ class ModelsHelper {
}; };
} }
static Map<String, dynamic> 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<String, dynamic> sectionToMap(SectionDTO section, String rawSectionJson) {
return { return {
'id': section.id, 'id': section.id,
'instanceId': section.instanceId, 'instanceId': section.instanceId,
@ -33,7 +39,7 @@ class ModelsHelper {
'isSubSection': section.isSubSection, 'isSubSection': section.isSubSection,
'parentId': section.parentId, 'parentId': section.parentId,
'type': section.type!.value, 'type': section.type!.value,
//'data': section.data, // TODO section data 'data': rawSectionJson,
'dateCreation': section.dateCreation!.toUtc().toIso8601String(), 'dateCreation': section.dateCreation!.toUtc().toIso8601String(),
'orderOfElement': section.order, 'orderOfElement': section.order,
}; };

View File

@ -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('&nbsp;', ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
static String getPlain(List<TranslationDTO>? translationDTO, VisitAppContext visitAppContext) =>
stripHtml(get(translationDTO, visitAppContext));
static String getWithResourcePlain(List<TranslationAndResourceDTO>? translationAndResourceDTO, VisitAppContext visitAppContext) =>
stripHtml(getWithResource(translationAndResourceDTO, visitAppContext));
static String getFromLocale(String valueToGet, VisitAppContext visitAppContext) { static String getFromLocale(String valueToGet, VisitAppContext visitAppContext) {
try { try {
return translations.where((element) => element.language == visitAppContext.language).first.data![valueToGet]!; return translations.where((element) => element.language == visitAppContext.language).first.data![valueToGet]!;

View File

@ -284,7 +284,7 @@ class _BodyState extends State<Body> {
if (searchValue != null && searchValue!.isNotEmpty) { if (searchValue != null && searchValue!.isNotEmpty) {
result = result.where((s) => result = result.where((s) =>
removeDiacritics(TranslationHelper.get(s.title, visitAppContext).toLowerCase()) removeDiacritics(TranslationHelper.getPlain(s.title, visitAppContext).toLowerCase())
.contains(removeDiacritics(searchValue!.toLowerCase())) .contains(removeDiacritics(searchValue!.toLowerCase()))
).toList(); ).toList();
} else if (searchNumberValue != null) { } else if (searchNumberValue != null) {

View File

@ -207,7 +207,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
); );
if (matches.isNotEmpty) { 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) { if (matches.isNotEmpty && !modeDebugBeacon) {
@ -374,7 +374,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> 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, alignment: Alignment.bottomRight,
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: 90, bottom: 1), padding: const EdgeInsets.only(right: 90, bottom: 1),

View File

@ -140,15 +140,19 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
), ),
), ),
), ),
// Title at bottom-left hauteur bornée + clip : empêche le débordement // Titre + chevron sur une même ligne : le chevron reste centré
// même si le moteur HTML natif ignore -webkit-line-clamp (contenu Quill). // verticalement sur le bloc de titre, quel que soit son nombre de lignes.
Positioned( Positioned(
bottom: 10, bottom: 10,
left: 10, left: 10,
right: 28, right: 8,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: ClipRect( child: ClipRect(
child: SizedBox( child: ConstrainedBox(
height: 40, constraints: const BoxConstraints(maxHeight: 40),
child: HtmlWidget( child: HtmlWidget(
cleanedTitle, cleanedTitle,
textStyle: const TextStyle( textStyle: const TextStyle(
@ -159,17 +163,17 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
), ),
customStylesBuilder: (_) => { customStylesBuilder: (_) => {
'font-family': 'Roboto', 'font-family': 'Roboto',
'margin': '0',
'-webkit-line-clamp': '2', '-webkit-line-clamp': '2',
}, },
), ),
), ),
), ),
), ),
// Chevron const SizedBox(width: 4),
const Positioned( const Icon(Icons.chevron_right, size: 18, color: Colors.white),
bottom: 10, ],
right: 8, ),
child: Icon(Icons.chevron_right, size: 18, color: Colors.white),
), ),
], ],
), ),
@ -211,23 +215,33 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
const SizedBox(height: 20), const SizedBox(height: 20),
// Entrée assistant vocal (si feature disponible) // Entrée assistant vocal (si feature disponible)
if (visitAppContext.applicationInstanceDTO?.isAssistant == true) ...[ if (visitAppContext.applicationInstanceDTO?.isAssistant == true) ...[
ListTile( Material(
type: MaterialType.transparency,
child: ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.mic, color: kMainColor1), leading: const Icon(Icons.mic, color: kMainColor1),
title: const Text('Assistant vocal', style: TextStyle(fontWeight: FontWeight.w600)), title: Text(
subtitle: const Text('Lunettes ou micro téléphone'), TranslationHelper.getFromLocale('settings.voiceAssistant', ctx2),
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(TranslationHelper.getFromLocale('settings.voiceAssistantSubtitle', ctx2)),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
Navigator.pop(ctx); Navigator.pop(ctx);
VoiceModeSheet.show(ctx, visitAppContext: visitAppContext); VoiceModeSheet.show(ctx, visitAppContext: visitAppContext);
}, },
), ),
),
const Divider(), 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), const SizedBox(height: 12),
Wrap( Wrap(
spacing: 10, spacing: 16,
runSpacing: 16,
children: configLanguages.map((lang) { children: configLanguages.map((lang) {
final isSelected = ctx2.language == lang; final isSelected = ctx2.language == lang;
return GestureDetector( return GestureDetector(
@ -245,7 +259,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
shape: BoxShape.circle, shape: BoxShape.circle,
border: isSelected ? Border.all(color: kMainColor, width: 2.5) : null, border: isSelected ? Border.all(color: kMainColor, width: 2.5) : null,
image: DecorationImage( image: DecorationImage(
fit: BoxFit.contain, fit: BoxFit.cover,
image: AssetImage('assets/images/old/${lang.toLowerCase()}.png'), image: AssetImage('assets/images/old/${lang.toLowerCase()}.png'),
), ),
boxShadow: const [BoxShadow(color: kSecondGrey, spreadRadius: 0.5, blurRadius: 5, offset: Offset(0, 1.5))], boxShadow: const [BoxShadow(color: kSecondGrey, spreadRadius: 0.5, blurRadius: 5, offset: Offset(0, 1.5))],
@ -261,7 +275,10 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ 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( Switch(
value: ctx2.notificationsEnabled, value: ctx2.notificationsEnabled,
activeThumbColor: kMainColor, activeThumbColor: kMainColor,
@ -398,16 +415,25 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (titleEntry != null) if (titleEntry != null)
Text( ClipRect(
titleEntry.value ?? '', child: ConstrainedBox(
style: const TextStyle( constraints: const BoxConstraints(maxHeight: 52),
child: HtmlWidget(
(titleEntry.value ?? '')
.replaceAll('\n', ' ')
.replaceAll('<br>', ' '),
textStyle: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
height: 1.2, height: 1.2,
), ),
maxLines: 2, customStylesBuilder: (_) => {
overflow: TextOverflow.ellipsis, 'margin': '0',
'-webkit-line-clamp': '2',
},
),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@ -443,7 +469,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Text( child: Text(
'Découvrir', TranslationHelper.getFromLocale('event.discover', visitAppContext),
style: TextStyle( style: TextStyle(
color: kMainColor, color: kMainColor,
fontSize: 12, fontSize: 12,

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:manager_api_new/api.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/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Event/event_map_full_page.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Event/event_map_full_page.dart';
@ -108,6 +109,7 @@ class _EventPageState extends State<EventPage> {
Widget _buildHero(BuildContext context) { Widget _buildHero(BuildContext context) {
final title = _safeTranslate(widget.section.title); final title = _safeTranslate(widget.section.title);
final titlePlain = _safeTranslatePlain(widget.section.title);
final dateRange = _formatDateRange(); final dateRange = _formatDateRange();
final expandedHeight = MediaQuery.of(context).size.height * 0.52; final expandedHeight = MediaQuery.of(context).size.height * 0.52;
@ -117,7 +119,7 @@ class _EventPageState extends State<EventPage> {
pinned: true, pinned: true,
backgroundColor: kMainColor, backgroundColor: kMainColor,
title: Text( title: Text(
title, titlePlain,
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -163,7 +165,7 @@ class _EventPageState extends State<EventPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( HtmlText(
title, title,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
@ -171,8 +173,6 @@ class _EventPageState extends State<EventPage> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
height: 1.2, height: 1.2,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
if (dateRange.isNotEmpty) ...[ if (dateRange.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
@ -292,7 +292,7 @@ class _EventPageState extends State<EventPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( HtmlText(
title, title,
style: TextStyle( style: TextStyle(
color: isActive ? Colors.white : Colors.white.withOpacity(0.9), color: isActive ? Colors.white : Colors.white.withOpacity(0.9),
@ -370,7 +370,7 @@ class _EventPageState extends State<EventPage> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( HtmlText(
title, title,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
@ -380,7 +380,7 @@ class _EventPageState extends State<EventPage> {
), ),
if (desc.isNotEmpty) ...[ if (desc.isNotEmpty) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Text( HtmlText(
desc, desc,
style: TextStyle(color: Colors.grey[400], fontSize: 14, height: 1.6), style: TextStyle(color: Colors.grey[400], fontSize: 14, height: 1.6),
), ),
@ -399,7 +399,7 @@ class _EventPageState extends State<EventPage> {
Icon(Icons.place, color: kMainColor, size: 16), Icon(Icons.place, color: kMainColor, size: 16),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: HtmlText(
label, label,
style: const TextStyle(color: Colors.white, fontSize: 13), style: const TextStyle(color: Colors.white, fontSize: 13),
), ),
@ -614,7 +614,7 @@ class _EventPageState extends State<EventPage> {
} }
Widget _buildParcoursChip(BuildContext context, GuidedPathDTO path) { Widget _buildParcoursChip(BuildContext context, GuidedPathDTO path) {
final title = _safeTranslate(path.title); final title = _safeTranslatePlain(path.title);
final stepCount = path.steps?.length ?? 0; final stepCount = path.steps?.length ?? 0;
return GestureDetector( return GestureDetector(
@ -700,7 +700,7 @@ class _EventPageState extends State<EventPage> {
const SizedBox(height: 8), const SizedBox(height: 8),
..._paths.map((path) { ..._paths.map((path) {
final title = _safeTranslate(path.title); final title = _safeTranslate(path.title);
final desc = _safeTranslate(path.description); final desc = _safeTranslatePlain(path.description);
final stepCount = path.steps?.length ?? 0; final stepCount = path.steps?.length ?? 0;
return ListTile( return ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@ -713,7 +713,7 @@ class _EventPageState extends State<EventPage> {
), ),
child: Icon(Icons.map_outlined, color: kMainColor, size: 20), child: Icon(Icons.map_outlined, color: kMainColor, size: 20),
), ),
title: Text( title: HtmlText(
title, title,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
), ),
@ -766,4 +766,9 @@ class _EventPageState extends State<EventPage> {
return list.first.value ?? ''; return list.first.value ?? '';
} }
} }
/// Version sans balises, pour les emplacements qui exigent un ellipsis strict.
String _safeTranslatePlain(List<TranslationDTO>? list) =>
TranslationHelper.stripHtml(_safeTranslate(list));
} }

View File

@ -3,6 +3,7 @@ import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:manager_api_new/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/SliderImages.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/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/resourceModel.dart'; import 'package:mymuseum_visitapp/Models/resourceModel.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
@ -124,6 +125,9 @@ class _GuidedPathContentProgressionPageState
String _translate(List<TranslationDTO>? list) => String _translate(List<TranslationDTO>? list) =>
TranslationHelper.get(list, widget.visitAppContext); TranslationHelper.get(list, widget.visitAppContext);
String _translatePlain(List<TranslationDTO>? list) =>
TranslationHelper.stripHtml(_translate(list));
bool get _canAdvance => _challenge?.canAdvance ?? true; bool get _canAdvance => _challenge?.canAdvance ?? true;
void _advance() { void _advance() {
@ -213,13 +217,13 @@ class _GuidedPathContentProgressionPageState
} }
void _showEnd() { void _showEnd() {
final outro = _stripHtml( final outro = TranslationHelper.stripHtml(
TranslationHelper.getWithResource(widget.path.gameMessageFin, widget.visitAppContext)); TranslationHelper.getWithResource(widget.path.gameMessageFin, widget.visitAppContext));
Navigator.of(context).push(MaterialPageRoute( Navigator.of(context).push(MaterialPageRoute(
builder: (_) => GuidedPathEndView( builder: (_) => GuidedPathEndView(
isGame: _isGame, isGame: _isGame,
primaryColor: kMainColor, primaryColor: kMainColor,
pathTitle: _translate(widget.path.title), pathTitle: _translatePlain(widget.path.title),
stepsCount: _steps.length, stepsCount: _steps.length,
estimatedDurationMinutes: widget.path.estimatedDurationMinutes, estimatedDurationMinutes: widget.path.estimatedDurationMinutes,
gameOutro: outro, gameOutro: outro,
@ -231,12 +235,6 @@ class _GuidedPathContentProgressionPageState
)); ));
} }
String _stripHtml(String html) => html
.replaceAll(RegExp(r'<[^>]*>'), ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final step = _currentStep; final step = _currentStep;
@ -389,7 +387,7 @@ class _GuidedPathContentProgressionPageState
height: 200, width: double.infinity, fit: BoxFit.cover), height: 200, width: double.infinity, fit: BoxFit.cover),
), ),
if (hasContents || step.imageUrl != null) const SizedBox(height: 16), if (hasContents || step.imageUrl != null) const SizedBox(height: 16),
Text(title, HtmlText(title,
style: TextStyle( style: TextStyle(
color: _ink, fontSize: 22, fontWeight: FontWeight.w600, height: 1.15)), color: _ink, fontSize: 22, fontWeight: FontWeight.w600, height: 1.15)),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -399,7 +397,7 @@ class _GuidedPathContentProgressionPageState
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: _isGame ? _gold.withOpacity(0.12) : _accent.withOpacity(0.1), 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( style: TextStyle(
color: _accent, color: _accent,
fontSize: 11.5, fontSize: 11.5,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:manager_api_new/api.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/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_map_progression_page.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), 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 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])), : Text('$stepCount étape${stepCount > 1 ? 's' : ''}', style: TextStyle(color: Colors.grey[600])),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@ -5,6 +5,7 @@ import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:manager_api_new/api.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/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_audio_player.dart'; import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_audio_player.dart';
@ -130,6 +131,9 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
String _translate(List<TranslationDTO>? list) => String _translate(List<TranslationDTO>? list) =>
TranslationHelper.get(list, widget.visitAppContext); TranslationHelper.get(list, widget.visitAppContext);
String _translatePlain(List<TranslationDTO>? list) =>
TranslationHelper.stripHtml(_translate(list));
bool _hasGeoTrigger(GuidedStepDTO step) => bool _hasGeoTrigger(GuidedStepDTO step) =>
step.isGeoTriggered == true && step.isGeoTriggered == true &&
step.geometry != null && step.geometry != null &&
@ -201,13 +205,13 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
} }
void _showEnd() { void _showEnd() {
final outro = _stripHtml( final outro = TranslationHelper.stripHtml(
TranslationHelper.getWithResource(widget.path.gameMessageFin, widget.visitAppContext)); TranslationHelper.getWithResource(widget.path.gameMessageFin, widget.visitAppContext));
Navigator.of(context).push(MaterialPageRoute( Navigator.of(context).push(MaterialPageRoute(
builder: (_) => GuidedPathEndView( builder: (_) => GuidedPathEndView(
isGame: _isGame, isGame: _isGame,
primaryColor: kMainColor, primaryColor: kMainColor,
pathTitle: _translate(widget.path.title), pathTitle: _translatePlain(widget.path.title),
stepsCount: _steps.length, stepsCount: _steps.length,
estimatedDurationMinutes: widget.path.estimatedDurationMinutes, estimatedDurationMinutes: widget.path.estimatedDurationMinutes,
gameOutro: outro, gameOutro: outro,
@ -264,7 +268,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
final dist = const Distance().distance(position, center); final dist = const Distance().distance(position, center);
final inZone = dist <= radius; final inZone = dist <= radius;
if (inZone && !_inGeoZone) { if (inZone && !_inGeoZone) {
PushNotificationService.showGeoZoneNotification(stepTitle: _translate(step.title)); PushNotificationService.showGeoZoneNotification(stepTitle: _translatePlain(step.title));
} }
setState(() { setState(() {
_distanceMeters = dist; _distanceMeters = dist;
@ -312,12 +316,6 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
return '${(m / 1000).toStringAsFixed(m < 10000 ? 1 : 0)} km'; return '${(m / 1000).toStringAsFixed(m < 10000 ? 1 : 0)} km';
} }
String _stripHtml(String html) => html
.replaceAll(RegExp(r'<[^>]*>'), ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
// Build // Build
@override @override
@ -548,7 +546,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text(_translate(widget.path.title), child: Text(_translatePlain(widget.path.title),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
@ -833,7 +831,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
height: 200, width: double.infinity, fit: BoxFit.cover), height: 200, width: double.infinity, fit: BoxFit.cover),
), ),
if (step.imageUrl != null) const SizedBox(height: 16), if (step.imageUrl != null) const SizedBox(height: 16),
Text(title, HtmlText(title,
style: TextStyle( style: TextStyle(
color: _ink, color: _ink,
fontSize: 22, fontSize: 22,

View File

@ -17,16 +17,10 @@ _Kind _kindOf(QuizQuestion q) {
return _Kind.mcq; return _Kind.mcq;
} }
String _stripHtml(String html) => html
.replaceAll(RegExp(r'<[^>]*>'), ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
String _normalize(String text) { String _normalize(String text) {
const accents = 'àâäáãåçèéêëìîïíòôöóõùûüúÿñ'; const accents = 'àâäáãåçèéêëìîïíòôöóõùûüúÿñ';
const plain = 'aaaaaaceeeeiiiiooooouuuuyn'; const plain = 'aaaaaaceeeeiiiiooooouuuuyn';
var s = _stripHtml(text).toLowerCase(); var s = TranslationHelper.stripHtml(text).toLowerCase();
final buffer = StringBuffer(); final buffer = StringBuffer();
for (final ch in s.split('')) { for (final ch in s.split('')) {
final idx = accents.indexOf(ch); final idx = accents.indexOf(ch);
@ -650,7 +644,7 @@ class _StepQuiz extends StatelessWidget {
Widget _buildSimple( Widget _buildSimple(
QuizQuestion current, bool hasAnswer, bool checked, bool revealImmediately) { 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)) { if (_isDigicode(expectedRaw)) {
return _DigicodeField( return _DigicodeField(
key: ValueKey('digicode-${current.id}'), key: ValueKey('digicode-${current.id}'),

View File

@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
//import 'package:google_maps_flutter/google_maps_flutter.dart'; //import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:manager_api_new/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/visitContext.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/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Map/flutter_map_view.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Map/flutter_map_view.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Map/geo_point_filter.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Map/geo_point_filter.dart';
@ -95,9 +96,11 @@ class _MapPage extends State<MapPage> {
), ),
) )
: const Icon(Icons.place, color: kMainColor, size: 32), : 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 subtitle: desc.isNotEmpty
? Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis) ? Text(TranslationHelper.stripHtml(desc), maxLines: 2, overflow: TextOverflow.ellipsis)
: null, : null,
); );
}, },
@ -145,7 +148,12 @@ class _MapPage extends State<MapPage> {
case MapProvider.MapBox: case MapProvider.MapBox:
return MapBoxView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO, icons: widget.icons); return MapBoxView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO, icons: widget.icons);
default: 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<MapPage> {
language: visitAppContext.language!, language: visitAppContext.language!,
geoPoints: mapDTO!.points!, geoPoints: mapDTO!.points!,
categories: mapDTO!.categories!, categories: mapDTO!.categories!,
provider: mapDTO!.mapProvider == null ? MapProvider.Google : mapDTO!.mapProvider!, provider: mapDTO!.mapProvider ?? MapProvider.MapBox,
filteredPoints: (value) { filteredPoints: (value) {
_geoPoints.value = value!; _geoPoints.value = value!;
}), }),

View File

@ -317,7 +317,7 @@ class _MenuPageState extends State<MenuPage> {
if (searchValue != null && searchValue!.isNotEmpty) { if (searchValue != null && searchValue!.isNotEmpty) {
result = result.where((s) { result = result.where((s) {
final rawTitle = TranslationHelper.get(s.title, visitAppContext); final rawTitle = TranslationHelper.get(s.title, visitAppContext);
final plainText = stripHtmlTags(rawTitle); final plainText = TranslationHelper.stripHtml(rawTitle);
final normalizedTitle = removeDiacritics(plainText.toLowerCase()); final normalizedTitle = removeDiacritics(plainText.toLowerCase());
final normalizedSearch = removeDiacritics(searchValue!.toLowerCase()); final normalizedSearch = removeDiacritics(searchValue!.toLowerCase());
return normalizedTitle.contains(normalizedSearch); return normalizedTitle.contains(normalizedSearch);
@ -328,11 +328,6 @@ class _MenuPageState extends State<MenuPage> {
filteredSections.value = result; 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) { boxDecoration(AppContext appContext, SectionDTO section, bool isSelected, Object rawSubSectionData) {

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:manager_api_new/api.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/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart'; import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart';
@ -312,7 +313,7 @@ class _ParcoursPageState extends State<ParcoursPage> {
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( HtmlText(
title, title,
style: const TextStyle( style: const TextStyle(
color: _inkDark, color: _inkDark,
@ -324,7 +325,7 @@ class _ParcoursPageState extends State<ParcoursPage> {
if (desc.isNotEmpty) ...[ if (desc.isNotEmpty) ...[
const SizedBox(height: 3), const SizedBox(height: 3),
Text( Text(
desc, TranslationHelper.stripHtml(desc),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
@ -432,7 +433,8 @@ class _ParcoursPathStartSheet extends StatelessWidget {
(m) => m.language == visitAppContext.language, (m) => m.language == visitAppContext.language,
orElse: () => msg.first, orElse: () => msg.first,
); );
return found.value ?? ''; // Interpolé dans « » côté affichage : pas de rendu HTML possible.
return TranslationHelper.stripHtml(found.value ?? '');
} }
@override @override
@ -512,7 +514,7 @@ class _ParcoursPathStartSheet extends StatelessWidget {
left: 16, left: 16,
right: 16, right: 16,
bottom: 14, bottom: 14,
child: Text( child: HtmlText(
_title, _title,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
@ -547,7 +549,7 @@ class _ParcoursPathStartSheet extends StatelessWidget {
if (_desc.isNotEmpty) if (_desc.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: Text( child: HtmlText(
_desc, _desc,
style: const TextStyle( style: const TextStyle(
color: _inkMid, color: _inkMid,
@ -645,7 +647,7 @@ class _ParcoursPathStartSheet extends StatelessWidget {
const SizedBox(height: 10), const SizedBox(height: 10),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 28), padding: const EdgeInsets.symmetric(horizontal: 28),
child: Text( child: HtmlText(
_title, _title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(

View File

@ -187,6 +187,12 @@ class _SectionPageState extends State<SectionPage> {
List<Map<String, dynamic>> sectionTest = await DatabaseHelper.instance.queryWithColumnId(DatabaseTableType.sections, sectionId); List<Map<String, dynamic>> sectionTest = await DatabaseHelper.instance.queryWithColumnId(DatabaseTableType.sections, sectionId);
if(sectionTest.isNotEmpty) { if(sectionTest.isNotEmpty) {
sectionDTO = DatabaseHelper.instance.getSectionFromDB(sectionTest.first); 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 { try {
SectionRead sectionRead = SectionRead(id: sectionDTO!.id!, readTime: DateTime.now().millisecondsSinceEpoch); SectionRead sectionRead = SectionRead(id: sectionDTO!.id!, readTime: DateTime.now().millisecondsSinceEpoch);
await DatabaseHelper.instance.insert(DatabaseTableType.articleRead, sectionRead.toMap()); await DatabaseHelper.instance.insert(DatabaseTableType.articleRead, sectionRead.toMap());

View File

@ -16,6 +16,21 @@ import 'package:provider/provider.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:fluttertoast/fluttertoast.dart'; 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<SectionType> offlineCapableSectionTypes = [
SectionType.Article,
SectionType.Quiz,
SectionType.Menu,
SectionType.Slider,
SectionType.Pdf,
SectionType.Video,
SectionType.Game,
];
class DownloadConfigurationWidget extends StatefulWidget { class DownloadConfigurationWidget extends StatefulWidget {
DownloadConfigurationWidget({Key? key, required this.configuration}) : super(key: key); DownloadConfigurationWidget({Key? key, required this.configuration}) : super(key: key);
final ConfigurationDTO configuration; final ConfigurationDTO configuration;
@ -114,6 +129,9 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
// Ressources dont le téléchargement a échoué. Vide = visite complète. // Ressources dont le téléchargement a échoué. Vide = visite complète.
final List<String> failedResourceIds = []; final List<String> failedResourceIds = [];
// Sections dont le payload n'a pas pu être récupéré ou enregistré.
final List<String> 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])); 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;
@ -167,17 +185,39 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
if(sections!.isNotEmpty) { if(sections!.isNotEmpty) {
List<SectionDTO> sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, widget.configuration.id!); List<SectionDTO> sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, widget.configuration.id!);
List<SectionDTO> 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<SectionDTO> sectionsToKeep = sections.where((s) => offlineCapableSectionTypes.contains(s.type)).toList();
sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!)); sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!));
int newOrder = 0; int newOrder = 0;
// Update local DB - Sections // Update local DB - Sections
for(var section in sectionsToKeep) { for(var section in sectionsToKeep) {
section.order = newOrder; 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 { try {
await DatabaseHelper.instance.insert(DatabaseTableType.sections, ModelsHelper.sectionToMap(section)); rawSectionJson = jsonEncode(await visitAppContext.clientAPI.sectionApi!.sectionGetDetail(section.id!));
} catch (e) { } 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 // Download section image
@ -250,10 +290,12 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
// D5 la visite n'est déclarée téléchargée que si elle l'est vraiment. Le // 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 // 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. // ce qui manque, `isResourceOutdated` voyant les fichiers présents.
if (failedResourceIds.isNotEmpty) { if (failedResourceIds.isNotEmpty || failedSectionIds.isNotEmpty) {
debugPrint("[download] ${failedResourceIds.length} ressource(s) en échec : " debugPrint("[download] ${failedResourceIds.length} ressource(s) en échec : "
"${failedResourceIds.join(', ')}"); "${failedResourceIds.join(', ')}");
downloadFailureCount.value = failedResourceIds.length; debugPrint("[download] ${failedSectionIds.length} section(s) en échec : "
"${failedSectionIds.join(', ')}");
downloadFailureCount.value = failedResourceIds.length + failedSectionIds.length;
return false; return false;
} }

View File

@ -43,7 +43,12 @@ List<Translation> translations = [
"voice.noQrFound": "Je n'ai pas trouvé de QR code.", "voice.noQrFound": "Je n'ai pas trouvé de QR code.",
"voice.photoCaptured": "Photo prise.", "voice.photoCaptured": "Photo prise.",
"voice.photoFailed": "Je n'ai pas pu prendre la photo.", "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: { Translation(language: "EN", data: {
"visitTitle": "List of tours", "visitTitle": "List of tours",
@ -87,7 +92,12 @@ List<Translation> translations = [
"voice.noQrFound": "I didn't find any QR code.", "voice.noQrFound": "I didn't find any QR code.",
"voice.photoCaptured": "Photo taken.", "voice.photoCaptured": "Photo taken.",
"voice.photoFailed": "I couldn't take the photo.", "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: { Translation(language: "DE", data: {
"visitTitle": "Liste der Touren", "visitTitle": "Liste der Touren",
@ -131,7 +141,12 @@ List<Translation> translations = [
"voice.noQrFound": "Ich habe keinen QR-Code gefunden.", "voice.noQrFound": "Ich habe keinen QR-Code gefunden.",
"voice.photoCaptured": "Foto aufgenommen.", "voice.photoCaptured": "Foto aufgenommen.",
"voice.photoFailed": "Ich konnte das Foto nicht aufnehmen.", "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: { Translation(language: "NL", data: {
"visitTitle": "Lijst met rondleidingen", "visitTitle": "Lijst met rondleidingen",
@ -175,7 +190,12 @@ List<Translation> translations = [
"voice.noQrFound": "Ik heb geen QR-code gevonden.", "voice.noQrFound": "Ik heb geen QR-code gevonden.",
"voice.photoCaptured": "Foto genomen.", "voice.photoCaptured": "Foto genomen.",
"voice.photoFailed": "Ik kon de foto niet nemen.", "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: { Translation(language: "IT", data: {
"visitTitle": "Elenco dei tour", "visitTitle": "Elenco dei tour",
@ -219,7 +239,12 @@ List<Translation> translations = [
"voice.noQrFound": "Non ho trovato nessun codice QR.", "voice.noQrFound": "Non ho trovato nessun codice QR.",
"voice.photoCaptured": "Foto scattata.", "voice.photoCaptured": "Foto scattata.",
"voice.photoFailed": "Non sono riuscito a scattare la foto.", "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: { Translation(language: "ES", data: {
"visitTitle": "Lista de recorridos", "visitTitle": "Lista de recorridos",
@ -263,7 +288,12 @@ List<Translation> translations = [
"voice.noQrFound": "No he encontrado ningún código QR.", "voice.noQrFound": "No he encontrado ningún código QR.",
"voice.photoCaptured": "Foto tomada.", "voice.photoCaptured": "Foto tomada.",
"voice.photoFailed": "No he podido tomar la foto.", "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: { Translation(language: "PL", data: {
"visitTitle": "Lista wycieczek", "visitTitle": "Lista wycieczek",
@ -307,7 +337,12 @@ List<Translation> translations = [
"voice.noQrFound": "Nie znalazłem kodu QR.", "voice.noQrFound": "Nie znalazłem kodu QR.",
"voice.photoCaptured": "Zdjęcie zrobione.", "voice.photoCaptured": "Zdjęcie zrobione.",
"voice.photoFailed": "Nie udało mi się zrobić zdjęcia.", "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: { Translation(language: "CN", data: {
"visitTitle": "旅游清单", "visitTitle": "旅游清单",
@ -351,7 +386,12 @@ List<Translation> translations = [
"voice.noQrFound": "我没有找到任何二维码。", "voice.noQrFound": "我没有找到任何二维码。",
"voice.photoCaptured": "照片已拍摄。", "voice.photoCaptured": "照片已拍摄。",
"voice.photoFailed": "我无法拍摄照片。", "voice.photoFailed": "我无法拍摄照片。",
"voice.cameraUnavailable": "我无法访问摄像头。" "voice.cameraUnavailable": "我无法访问摄像头。",
"settings.language": "语言",
"settings.notifications": "推送通知",
"settings.voiceAssistant": "语音助手",
"settings.voiceAssistantSubtitle": "眼镜或手机麦克风",
"event.discover": "探索"
}), }),
Translation(language: "UK", data: { Translation(language: "UK", data: {
"visitTitle": "Список турів", "visitTitle": "Список турів",
@ -395,7 +435,12 @@ List<Translation> translations = [
"voice.noQrFound": "Я не знайшов жодного QR-коду.", "voice.noQrFound": "Я не знайшов жодного QR-коду.",
"voice.photoCaptured": "Фото зроблено.", "voice.photoCaptured": "Фото зроблено.",
"voice.photoFailed": "Мені не вдалося зробити фото.", "voice.photoFailed": "Мені не вдалося зробити фото.",
"voice.cameraUnavailable": "Я не можу отримати доступ до камери." "voice.cameraUnavailable": "Я не можу отримати доступ до камери.",
"settings.language": "Мова",
"settings.notifications": "Push-сповіщення",
"settings.voiceAssistant": "Голосовий помічник",
"settings.voiceAssistantSubtitle": "Окуляри або мікрофон телефона",
"event.discover": "Дізнатися більше"
}), }),
Translation(language: "AR", data: { Translation(language: "AR", data: {
"visitTitle": "قائمة الجولات", "visitTitle": "قائمة الجولات",
@ -439,6 +484,11 @@ List<Translation> translations = [
"voice.noQrFound": "لم أجد أي رمز QR.", "voice.noQrFound": "لم أجد أي رمز QR.",
"voice.photoCaptured": "تم التقاط الصورة.", "voice.photoCaptured": "تم التقاط الصورة.",
"voice.photoFailed": "لم أتمكن من التقاط الصورة.", "voice.photoFailed": "لم أتمكن من التقاط الصورة.",
"voice.cameraUnavailable": "لا يمكنني الوصول إلى الكاميرا." "voice.cameraUnavailable": "لا يمكنني الوصول إلى الكاميرا.",
"settings.language": "اللغة",
"settings.notifications": "الإشعارات الفورية",
"settings.voiceAssistant": "المساعد الصوتي",
"settings.voiceAssistantSubtitle": "النظارات أو ميكروفون الهاتف",
"event.discover": "اكتشف"
}), }),
]; ];