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
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
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: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<AssistantChatSheet> {
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,

View File

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

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),
// 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),

View File

@ -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<String, dynamic> 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<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 {
'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,
};

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) {
try {
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) {
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) {

View File

@ -207,7 +207,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> 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<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,
child: Padding(
padding: const EdgeInsets.only(right: 90, bottom: 1),

View File

@ -140,36 +140,40 @@ class _HomePage3State extends State<HomePage3> 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<HomePage3> 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<HomePage3> 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<HomePage3> 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<HomePage3> 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('<br>', ' '),
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<HomePage3> with WidgetsBindingObserver {
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Découvrir',
TranslationHelper.getFromLocale('event.discover', visitAppContext),
style: TextStyle(
color: kMainColor,
fontSize: 12,

View File

@ -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<EventPage> {
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<EventPage> {
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<EventPage> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
HtmlText(
title,
style: const TextStyle(
color: Colors.white,
@ -171,8 +173,6 @@ class _EventPageState extends State<EventPage> {
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<EventPage> {
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<EventPage> {
),
),
const SizedBox(height: 16),
Text(
HtmlText(
title,
style: const TextStyle(
color: Colors.white,
@ -380,7 +380,7 @@ class _EventPageState extends State<EventPage> {
),
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<EventPage> {
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<EventPage> {
}
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<EventPage> {
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<EventPage> {
),
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<EventPage> {
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: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<TranslationDTO>? list) =>
TranslationHelper.get(list, widget.visitAppContext);
String _translatePlain(List<TranslationDTO>? 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('&nbsp;', ' ')
.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,

View File

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

View File

@ -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<GuidedPathMapProgressionP
String _translate(List<TranslationDTO>? list) =>
TranslationHelper.get(list, widget.visitAppContext);
String _translatePlain(List<TranslationDTO>? list) =>
TranslationHelper.stripHtml(_translate(list));
bool _hasGeoTrigger(GuidedStepDTO step) =>
step.isGeoTriggered == true &&
step.geometry != null &&
@ -201,13 +205,13 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
}
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,
@ -264,7 +268,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
final dist = const Distance().distance(position, center);
final inZone = dist <= radius;
if (inZone && !_inGeoZone) {
PushNotificationService.showGeoZoneNotification(stepTitle: _translate(step.title));
PushNotificationService.showGeoZoneNotification(stepTitle: _translatePlain(step.title));
}
setState(() {
_distanceMeters = dist;
@ -312,12 +316,6 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
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
@override
@ -548,7 +546,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
child: Row(
children: [
Expanded(
child: Text(_translate(widget.path.title),
child: Text(_translatePlain(widget.path.title),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@ -833,7 +831,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
height: 200, width: double.infinity, fit: BoxFit.cover),
),
if (step.imageUrl != null) const SizedBox(height: 16),
Text(title,
HtmlText(title,
style: TextStyle(
color: _ink,
fontSize: 22,

View File

@ -17,16 +17,10 @@ _Kind _kindOf(QuizQuestion q) {
return _Kind.mcq;
}
String _stripHtml(String html) => html
.replaceAll(RegExp(r'<[^>]*>'), ' ')
.replaceAll('&nbsp;', ' ')
.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}'),

View File

@ -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<MapPage> {
),
)
: 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<MapPage> {
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<MapPage> {
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!;
}),

View File

@ -317,7 +317,7 @@ class _MenuPageState extends State<MenuPage> {
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<MenuPage> {
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) {

View File

@ -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<ParcoursPage> {
),
),
const SizedBox(height: 6),
Text(
HtmlText(
title,
style: const TextStyle(
color: _inkDark,
@ -324,7 +325,7 @@ class _ParcoursPageState extends State<ParcoursPage> {
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(

View File

@ -187,6 +187,12 @@ class _SectionPageState extends State<SectionPage> {
List<Map<String, dynamic>> 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());

View File

@ -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<SectionType> 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<DownloadConfigurationWidge
// Ressources dont le téléchargement a échoué. Vide = visite complète.
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]));
currentResourceNbr.value = resourcesToDownload.length;
@ -167,17 +185,39 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
if(sections!.isNotEmpty) {
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!));
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<DownloadConfigurationWidge
// D5 la visite n'est déclarée téléchargée que si elle l'est vraiment. Le
// contenu déjà récupéré reste sur le device : relancer ne re-télécharge que
// ce qui manque, `isResourceOutdated` voyant les fichiers présents.
if (failedResourceIds.isNotEmpty) {
if (failedResourceIds.isNotEmpty || failedSectionIds.isNotEmpty) {
debugPrint("[download] ${failedResourceIds.length} ressource(s) en échec : "
"${failedResourceIds.join(', ')}");
downloadFailureCount.value = failedResourceIds.length;
debugPrint("[download] ${failedSectionIds.length} section(s) en échec : "
"${failedSectionIds.join(', ')}");
downloadFailureCount.value = failedResourceIds.length + failedSectionIds.length;
return false;
}

View File

@ -43,7 +43,12 @@ List<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> translations = [
"voice.noQrFound": "لم أجد أي رمز QR.",
"voice.photoCaptured": "تم التقاط الصورة.",
"voice.photoFailed": "لم أتمكن من التقاط الصورة.",
"voice.cameraUnavailable": "لا يمكنني الوصول إلى الكاميرا."
"voice.cameraUnavailable": "لا يمكنني الوصول إلى الكاميرا.",
"settings.language": "اللغة",
"settings.notifications": "الإشعارات الفورية",
"settings.voiceAssistant": "المساعد الصوتي",
"settings.voiceAssistantSubtitle": "النظارات أو ميكروفون الهاتف",
"event.discover": "اكتشف"
}),
];