From af44927d4afeedae53c18483071710647be6096c Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Sun, 9 Aug 2026 22:15:26 +0200 Subject: [PATCH] =?UTF-8?q?Assistant=20IA=20align=C3=A9=20sur=20le=20web?= =?UTF-8?q?=20+=20parcours=20guid=C3=A9s=20+=20centrage=20de=20carte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assistant AssistantChatSheet.dart (+252), Helpers/assistantSuggestions.dart, Services/assistantService.dart — même comportement et mêmes suggestions que visitapp-web, dérivées du contenu réel. Parcours guidés guided_path_content_progression_page (+72), guided_path_map_progression_page (+39), parcours_page : alignés sur les 3 questions de progression qui remplacent les 9 booléens côté manager-app. Carte Helpers/mapCenter.dart + les trois vues (flutter_map, google_map, map_box) et marker_view : centrage et icônes honorés. Fix du build guided_step_challenge.dart:83 — typage explicite. Avec le // @dart=2.18 de manager_api_new côté manager-app, c'est ce qui débloque flutter build apk. flutter build apk ✅. Reste ouvert : M3 — meterZoneGPS toujours ignoré, une constante en dur à 100 m remplace le rayon configuré par section. --- lib/Components/AssistantChatSheet.dart | 252 ++++++++++++++++-- lib/Helpers/assistantSuggestions.dart | 120 +++++++++ lib/Helpers/mapCenter.dart | 37 +++ .../guided_path_content_progression_page.dart | 72 +++-- .../guided_path_map_progression_page.dart | 39 ++- .../GuidedPath/guided_step_challenge.dart | 2 +- .../Sections/Map/flutter_map_view.dart | 5 +- lib/Screens/Sections/Map/google_map_view.dart | 7 +- lib/Screens/Sections/Map/map_box_view.dart | 7 +- lib/Screens/Sections/Map/marker_view.dart | 10 + .../Sections/Parcours/parcours_page.dart | 6 +- lib/Services/assistantService.dart | 17 +- 12 files changed, 521 insertions(+), 53 deletions(-) create mode 100644 lib/Helpers/assistantSuggestions.dart create mode 100644 lib/Helpers/mapCenter.dart diff --git a/lib/Components/AssistantChatSheet.dart b/lib/Components/AssistantChatSheet.dart index 0157537..cbabb9a 100644 --- a/lib/Components/AssistantChatSheet.dart +++ b/lib/Components/AssistantChatSheet.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; +import 'package:mymuseum_visitapp/Helpers/assistantSuggestions.dart'; +import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/AssistantResponse.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Services/assistantService.dart'; @@ -133,6 +135,14 @@ class _AssistantChatSheetState extends State { languageCode: _toLangCode(lang), ); } + } on AssistantUnavailableException { + // Quota épuisé : surtout ne pas inviter à réessayer, le visiteur boucterait. + setState(() { + _bubbles.add(_ChatBubble( + text: "Le guide se repose pour aujourd'hui. Revenez demain.", + isUser: false, + )); + }); } catch (e) { debugPrint('AssistantChatSheet error: $e'); setState(() { @@ -166,6 +176,15 @@ class _AssistantChatSheetState extends State { }); } + /// Nom du lieu affiché sous le titre — le visiteur sait à qui il parle. + String get _venueName { + final configuration = widget.visitAppContext.configuration; + if (configuration == null) return ''; + final title = _stripHtml( + TranslationHelper.get(configuration.title, widget.visitAppContext)); + return title.isNotEmpty ? title : (configuration.label ?? ''); + } + @override Widget build(BuildContext context) { final height = MediaQuery.of(context).size.height * 0.9; @@ -183,13 +202,39 @@ class _AssistantChatSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ - Icon(Icons.chat_bubble_outline, color: kMainColor1), - const SizedBox(width: 8), - Text("Assistant", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: kSecondGrey)), + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: kMainColor1, + shape: BoxShape.circle, + ), + child: const Icon(Icons.chat_bubble_outline, + color: Colors.white, size: 17), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text("Votre guide", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.15, + color: kSecondGrey)), + if (_venueName.isNotEmpty) + Text(_venueName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5, + height: 1.2, + color: Colors.grey[500])), + ], + ), + ), const SizedBox(width: 8), if (widget.visitAppContext.glassesEnabled) ValueListenableBuilder( @@ -232,10 +277,38 @@ class _AssistantChatSheetState extends State { ? Center( child: Padding( padding: const EdgeInsets.all(24), - child: Text( - "Bonjour ! Posez-moi vos questions sur cette visite.", - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey[500], fontSize: 15), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Bonjour ! Posez-moi vos questions sur cette visite.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[500], fontSize: 15), + ), + const SizedBox(height: 16), + // Suggestions dérivées du contenu réel de la configuration : + // face à un champ vide, peu de visiteurs savent quoi demander. + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: AssistantSuggestions + .build(widget.visitAppContext) + .map((suggestion) => ActionChip( + label: Text( + suggestion, + style: const TextStyle(fontSize: 12.5), + ), + backgroundColor: Colors.white, + side: BorderSide(color: Colors.grey[300]!), + onPressed: () { + _controller.text = suggestion; + _send(); + }, + )) + .toList(), + ), + ], ), ), ) @@ -246,20 +319,55 @@ class _AssistantChatSheetState extends State { itemBuilder: (_, i) => _bubbles[i], ), ), - // Loading indicator + // Le guide rédige — trois points plutôt qu'un spinner : la réponse + // arrive, elle ne « charge » pas. if (_isLoading) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), + child: Semantics( + label: 'Le guide rédige', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(16), + ), + ), + child: const _TypingDots(), + ), + ), + ), + ), + + // Écoute en cours — le micro rouge seul ne dit pas qu'on enregistre. + if (_isListening) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Row( - children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: kMainColor1), - ), - const SizedBox(width: 8), - Text("...", style: TextStyle(color: Colors.grey[400])), - ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.09), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + children: [ + const Icon(Icons.mic, color: Colors.red, size: 15), + const SizedBox(width: 8), + const Text( + "Je vous écoute…", + style: TextStyle( + color: Colors.red, fontSize: 13, fontWeight: FontWeight.w600), + ), + const Spacer(), + const _ListeningWave(), + ], + ), ), ), // Input @@ -325,6 +433,106 @@ class _AssistantChatSheetState extends State { } } +/// Trois points qui pulsent en décalé, pendant que la réponse se prépare. +class _TypingDots extends StatefulWidget { + const _TypingDots(); + + @override + State<_TypingDots> createState() => _TypingDotsState(); +} + +class _TypingDotsState extends State<_TypingDots> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1300), + )..repeat(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (_, __) => Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + // Chaque point est décalé d'un sixième de cycle sur le précédent. + final phase = (_controller.value - i * 0.14) % 1.0; + final lift = phase < 0.3 ? Curves.easeInOut.transform(phase / 0.3) : 0.0; + return Padding( + padding: EdgeInsets.only(right: i < 2 ? 4 : 0), + child: Transform.translate( + offset: Offset(0, -3 * lift), + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey[500]!.withValues(alpha: 0.3 + 0.7 * lift), + ), + ), + ), + ); + }), + ), + ); + } +} + +/// Onde sonore de la dictée — cinq barres qui respirent. +class _ListeningWave extends StatefulWidget { + const _ListeningWave(); + + @override + State<_ListeningWave> createState() => _ListeningWaveState(); +} + +class _ListeningWaveState extends State<_ListeningWave> + with SingleTickerProviderStateMixin { + static const _heights = [8.0, 14.0, 20.0, 11.0, 16.0]; + + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + )..repeat(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (_, __) => Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(_heights.length, (i) { + final phase = (_controller.value + i * 0.12) % 1.0; + final scale = 0.5 + 0.5 * (1 - (phase * 2 - 1).abs()); + return Padding( + padding: EdgeInsets.only(right: i < _heights.length - 1 ? 2 : 0), + child: Container( + width: 3, + height: _heights[i] * scale, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + }), + ), + ); + } +} + class _ChatBubble extends StatelessWidget { final String text; final bool isUser; diff --git a/lib/Helpers/assistantSuggestions.dart b/lib/Helpers/assistantSuggestions.dart new file mode 100644 index 0000000..5f99940 --- /dev/null +++ b/lib/Helpers/assistantSuggestions.dart @@ -0,0 +1,120 @@ +import 'package:mymuseum_visitapp/Models/visitContext.dart'; + +/// Questions proposées au visiteur quand il ouvre l'assistant. +/// +/// Dérivées du contenu réel de la configuration : les sections sont déjà en +/// mémoire (`VisitAppContext.currentSections`, alimenté au chargement de la +/// configuration) et leurs titres déjà traduits par le CMS. Un lieu sans agenda +/// ne propose donc pas de question sur l'agenda. +/// +/// Miroir de `visitapp-web/src/lib/assistantSuggestions.ts` — garder les deux +/// listes de phrases alignées. +class AssistantSuggestions { + static const int _max = 3; + + static const Map> _phrases = { + 'aboutSection': { + 'FR': 'Parlez-moi de « {name} »', + 'NL': 'Vertel me over "{name}"', + 'EN': 'Tell me about "{name}"', + 'DE': 'Erzählen Sie mir von „{name}"', + 'ES': 'Háblame de «{name}»', + 'IT': 'Parlami di "{name}"', + }, + 'whatsOn': { + 'FR': "Qu'est-ce qu'il y a cette semaine ?", + 'NL': 'Wat is er deze week te doen?', + 'EN': "What's on this week?", + 'DE': 'Was gibt es diese Woche?', + 'ES': '¿Qué hay esta semana?', + 'IT': 'Cosa c’è questa settimana?', + }, + 'withKids': { + 'FR': 'Que faire avec des enfants ?', + 'NL': 'Wat kunnen we doen met kinderen?', + 'EN': 'What can we do with children?', + 'DE': 'Was können wir mit Kindern machen?', + 'ES': '¿Qué hacer con niños?', + 'IT': 'Cosa fare con i bambini?', + }, + 'highlights': { + 'FR': "Qu'est-ce qu'il ne faut pas manquer ?", + 'NL': 'Wat mag ik niet missen?', + 'EN': "What shouldn't I miss?", + 'DE': 'Was sollte ich nicht verpassen?', + 'ES': '¿Qué no me puedo perder?', + 'IT': 'Cosa non devo perdere?', + }, + 'inOneHour': { + 'FR': "Que voir si je n'ai qu'une heure ?", + 'NL': 'Wat zie ik als ik maar één uur heb?', + 'EN': 'What should I see in one hour?', + 'DE': 'Was sollte ich in einer Stunde sehen?', + 'ES': '¿Qué ver si solo tengo una hora?', + 'IT': 'Cosa vedere se ho solo un’ora?', + }, + 'openingHours': { + 'FR': 'Quels sont les horaires ?', + 'NL': 'Wat zijn de openingstijden?', + 'EN': 'What are the opening hours?', + 'DE': 'Wie sind die Öffnungszeiten?', + 'ES': '¿Cuál es el horario?', + 'IT': 'Quali sono gli orari?', + }, + }; + + static String _phrase(String key, String lang, [String? name]) { + final table = _phrases[key]!; + final template = table[lang] ?? table['FR']!; + return name == null ? template : template.replaceAll('{name}', name); + } + + static String _stripHtml(String value) => + value.replaceAll(RegExp(r'<[^>]*>'), '').trim(); + + static String _titleOf(Map section, String lang) { + final titles = section['title']; + if (titles is! List || titles.isEmpty) return ''; + final match = titles.firstWhere( + (t) => t is Map && t['language'] == lang && (t['value'] as String?)?.isNotEmpty == true, + orElse: () => titles.first, + ); + if (match is! Map) return ''; + return _stripHtml((match['value'] as String?) ?? ''); + } + + static List build(VisitAppContext context, {String? currentSectionId}) { + final lang = (context.language ?? 'FR').toUpperCase(); + + final sections = (context.currentSections ?? []) + .whereType>() + .where((s) => s['isActive'] != false && s['isSubSection'] != true) + .toList(); + + bool has(String type) => sections.any((s) => s['type'] == type); + + final suggestions = []; + + // La section ouverte passe en premier : c'est ce que le visiteur a sous les yeux. + if (currentSectionId != null) { + final current = sections.firstWhere( + (s) => s['id'] == currentSectionId, + orElse: () => {}, + ); + if (current.isNotEmpty) { + final name = _titleOf(current, lang); + if (name.isNotEmpty) suggestions.add(_phrase('aboutSection', lang, name)); + } + } + + if (has('Agenda')) suggestions.add(_phrase('whatsOn', lang)); + if (has('Parcours') || has('Game')) suggestions.add(_phrase('withKids', lang)); + + // Génériques — trois, pour qu'un lieu minimal ait quand même le compte complet. + suggestions.add(_phrase('highlights', lang)); + suggestions.add(_phrase('inOneHour', lang)); + suggestions.add(_phrase('openingHours', lang)); + + return suggestions.toSet().take(_max).toList(); + } +} diff --git a/lib/Helpers/mapCenter.dart b/lib/Helpers/mapCenter.dart new file mode 100644 index 0000000..9cc5190 --- /dev/null +++ b/lib/Helpers/mapCenter.dart @@ -0,0 +1,37 @@ +import 'package:manager_api_new/api.dart'; + +/// Centre d'affichage d'une SectionMap. +/// +/// Priorité : le point « Centrer sur » configuré dans manager-app +/// (`centerLatitude`/`centerLongitude`, `map_config.dart`) — puis, à défaut, le +/// point GPS de la section (`latitude`/`longitude`, qui sert au beacon et à la +/// zone de déclenchement, pas au cadrage) — puis un centre par défaut. +/// +/// Miroir de `MapSection.tsx` côté visitapp-web. +class MapCenter { + static const double defaultLatitude = 50.465503; + static const double defaultLongitude = 4.865105; + + final double latitude; + final double longitude; + + const MapCenter(this.latitude, this.longitude); + + static MapCenter of(MapDTO? map) { + final center = _parse(map?.centerLatitude, map?.centerLongitude); + if (center != null) return center; + + final sectionPoint = _parse(map?.latitude, map?.longitude); + if (sectionPoint != null) return sectionPoint; + + return const MapCenter(defaultLatitude, defaultLongitude); + } + + static MapCenter? _parse(String? latitude, String? longitude) { + if (latitude == null || longitude == null) return null; + final lat = double.tryParse(latitude); + final lng = double.tryParse(longitude); + if (lat == null || lng == null) return null; + return MapCenter(lat, lng); + } +} diff --git a/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart b/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart index fae7fab..25d5b4a 100644 --- a/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart +++ b/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart @@ -32,6 +32,7 @@ class _GuidedPathContentProgressionPageState extends State { late List _steps; int _index = 0; + int _maxReached = 0; final Set _completedStepIds = {}; GuidedStepChallengeController? _challenge; @@ -46,6 +47,30 @@ class _GuidedPathContentProgressionPageState GuidedStepDTO? get _currentStep => _steps.isEmpty ? null : _steps[_index]; + /// `isLinear = false` : le visiteur choisit ses étapes dans l'ordre qu'il veut. + bool get _isFreeNavigation => widget.path.isLinear == false; + + /// Étapes futures masquées tant que la progression ne les a pas atteintes. + bool get _hideNextSteps => widget.path.hideNextStepsUntilComplete == true; + + /// Nombre d'étapes affichées dans l'indicateur de progression. + int get _visibleCount => + _hideNextSteps ? (_maxReached + 1).clamp(1, _steps.length) : _steps.length; + + /// En navigation libre on atteint n'importe quelle étape ; sinon seulement + /// celles déjà parcourues (relire une étape vue n'est pas de la triche). + bool _canJumpTo(int i) => + i >= 0 && i < _steps.length && (_isFreeNavigation || i <= _maxReached); + + void _jumpTo(int i) { + if (i == _index || !_canJumpTo(i)) return; + setState(() { + _index = i; + if (i > _maxReached) _maxReached = i; + _initStepState(); + }); + } + @override void initState() { super.initState(); @@ -108,6 +133,7 @@ class _GuidedPathContentProgressionPageState if (_index < _steps.length - 1) { setState(() { _index++; + if (_index > _maxReached) _maxReached = _index; _initStepState(); }); } else { @@ -116,7 +142,7 @@ class _GuidedPathContentProgressionPageState } void _goBack() { - if (_index > 0 && !(widget.path.isLinear ?? false)) { + if (_index > 0) { setState(() { _index--; _initStepState(); @@ -268,23 +294,31 @@ class _GuidedPathContentProgressionPageState const SizedBox(width: 10), Expanded( child: Row( - children: List.generate(_steps.length, (i) { - return Expanded( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 2), - height: 5, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(3), - color: i < _index - ? (_isGame ? _gold : const Color(0xFF2E9E6B)) - : i == _index - ? _accent - : (_isGame - ? Colors.white.withOpacity(0.1) - : const Color(0xFFE2DCD2)), - ), + children: List.generate(_visibleCount, (i) { + final reachable = _canJumpTo(i); + final segment = Container( + margin: const EdgeInsets.symmetric(horizontal: 2), + height: 5, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: i < _index + ? (_isGame ? _gold : const Color(0xFF2E9E6B)) + : i == _index + ? _accent + : (_isGame + ? Colors.white.withOpacity(0.1) + : const Color(0xFFE2DCD2)), ), ); + return Expanded( + child: reachable + ? GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _jumpTo(i), + child: segment, + ) + : segment, + ); }), ), ), @@ -325,7 +359,9 @@ class _GuidedPathContentProgressionPageState final contentResources = hasContents ? step.contents! .map((c) => ResourceModel( - id: c.resourceId, source: c.resource?.url, type: ResourceType.Image)) + id: c.resourceId, + source: c.resource?.url, + type: c.resource?.type ?? ResourceType.Image)) .toList() : []; @@ -392,7 +428,7 @@ class _GuidedPathContentProgressionPageState color: _bg, child: Row( children: [ - if (_index > 0 && !(widget.path.isLinear ?? false)) ...[ + if (_index > 0) ...[ GestureDetector( onTap: _goBack, child: Container( diff --git a/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart b/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart index 99f40e2..1985bcb 100644 --- a/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart +++ b/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart @@ -35,6 +35,7 @@ class _GuidedPathMapProgressionPageState extends State _steps; int _index = 0; + int _maxReached = 0; final Set _completedStepIds = {}; GuidedStepChallengeController? _challenge; @@ -134,9 +135,27 @@ class _GuidedPathMapProgressionPageState extends State 0; + /// `isLinear = false` : le visiteur choisit ses étapes dans l'ordre qu'il veut. + bool get _isFreeNavigation => widget.path.isLinear == false; + + /// En navigation libre on atteint n'importe quelle étape ; sinon seulement + /// celles déjà parcourues (relire une étape vue n'est pas de la triche). + bool _canJumpTo(int i) => + i >= 0 && i < _steps.length && (_isFreeNavigation || i <= _maxReached); + + void _jumpTo(int i) { + if (i == _index || !_canJumpTo(i)) return; + setState(() { + _index = i; + if (i > _maxReached) _maxReached = i; + _initStepState(); + }); + _centerOnCurrent(); + } + bool get _canAdvance { final step = _currentStep; - if (step == null || step.isStepLocked == true) return false; + if (step == null) return false; final requireSuccess = widget.path.requireSuccessToAdvance ?? false; final zoneOk = !_hasGeoTrigger(step) || _inGeoZone || !requireSuccess || _geoUnavailable; return (_challenge?.canAdvance ?? true) && zoneOk; @@ -151,6 +170,7 @@ class _GuidedPathMapProgressionPageState extends State _maxReached) _maxReached = _index; _initStepState(); }); } else { @@ -159,7 +179,7 @@ class _GuidedPathMapProgressionPageState extends State 0 && !(widget.path.isLinear ?? false)) { + if (_index > 0) { setState(() { _index--; _initStepState(); @@ -406,7 +426,9 @@ class _GuidedPathMapProgressionPageState extends State _maxReached; Widget pin; if (current) { @@ -425,11 +447,18 @@ class _GuidedPathMapProgressionPageState extends State _jumpTo(stepIndex), + child: pin, + ) + : pin, )); } @@ -627,7 +656,7 @@ class _GuidedPathMapProgressionPageState extends State 0 && !(widget.path.isLinear ?? false)) ...[ + if (_index > 0) ...[ Expanded( child: OutlinedButton( onPressed: _goBack, diff --git a/lib/Screens/Sections/GuidedPath/guided_step_challenge.dart b/lib/Screens/Sections/GuidedPath/guided_step_challenge.dart index fe7c2d4..1a928c9 100644 --- a/lib/Screens/Sections/GuidedPath/guided_step_challenge.dart +++ b/lib/Screens/Sections/GuidedPath/guided_step_challenge.dart @@ -68,7 +68,7 @@ class GuidedStepChallengeController extends ChangeNotifier { /// Construit la liste combinée et triée des questions valides d'une étape. static List buildQuestions(GuidedStepDTO step) { - final all = [...(step.quizQuestions ?? [])]; + final all = [...?step.quizQuestions]; final valid = all.where((q) { switch (_kindOf(q)) { case _Kind.puzzle: diff --git a/lib/Screens/Sections/Map/flutter_map_view.dart b/lib/Screens/Sections/Map/flutter_map_view.dart index ae392e8..43aa53a 100644 --- a/lib/Screens/Sections/Map/flutter_map_view.dart +++ b/lib/Screens/Sections/Map/flutter_map_view.dart @@ -1,3 +1,4 @@ +import 'package:mymuseum_visitapp/Helpers/mapCenter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -97,12 +98,14 @@ class _FlutterMapViewState extends State { mapContext.setSelectedPointForNavigate(null); // Reset after navigation } + final mapCenter = MapCenter.of(widget.mapDTO); + return Stack( children: [ FlutterMap( mapController: mapController, options: MapOptions( - initialCenter: widget.mapDTO!.longitude != null && widget.mapDTO!.latitude != null ? ll.LatLng(double.tryParse(widget.mapDTO!.latitude!)!, double.tryParse(widget.mapDTO!.longitude!)!) : ll.LatLng(4.865105, 50.465503), //.toJson() + initialCenter: ll.LatLng(mapCenter.latitude, mapCenter.longitude), initialZoom: widget.mapDTO!.zoom != null ? widget.mapDTO!.zoom!.toDouble() : 12, onTap: (Tap, lnt) => { mapContext.setSelectedPointForNavigate(null), diff --git a/lib/Screens/Sections/Map/google_map_view.dart b/lib/Screens/Sections/Map/google_map_view.dart index 01b12b2..7f939a6 100644 --- a/lib/Screens/Sections/Map/google_map_view.dart +++ b/lib/Screens/Sections/Map/google_map_view.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:mymuseum_visitapp/Helpers/mapCenter.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -106,6 +107,8 @@ class _GoogleMapViewState extends State { pointsToShow = widget.geoPoints; getMarkers(widget.language, mapContext); + final mapCenter = MapCenter.of(widget.mapDTO); + MapType type = MapType.hybrid; if(widget.mapDTO.mapType != null) { switch(widget.mapDTO.mapType!.value) { @@ -125,9 +128,7 @@ class _GoogleMapViewState extends State { mapToolbarEnabled: false, indoorViewEnabled: false, initialCameraPosition: CameraPosition( - target: widget.mapDTO.longitude != null && widget.mapDTO.latitude != null - ? LatLng(double.tryParse(widget.mapDTO.latitude!)!, double.tryParse(widget.mapDTO.longitude!)!) - : LatLng(50.465503, 4.865105), + target: LatLng(mapCenter.latitude, mapCenter.longitude), zoom: widget.mapDTO.zoom != null ? widget.mapDTO.zoom!.toDouble() : 18, ), onMapCreated: (GoogleMapController controller) { diff --git a/lib/Screens/Sections/Map/map_box_view.dart b/lib/Screens/Sections/Map/map_box_view.dart index 36b5741..364ec4a 100644 --- a/lib/Screens/Sections/Map/map_box_view.dart +++ b/lib/Screens/Sections/Map/map_box_view.dart @@ -1,4 +1,5 @@ +import 'package:mymuseum_visitapp/Helpers/mapCenter.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -184,6 +185,8 @@ class _MapBoxViewState extends State { break; } } + final mapCenter = MapCenter.of(widget.mapDTO); + return Stack( children: [ Center( @@ -199,7 +202,9 @@ class _MapBoxViewState extends State { mapContext.setSelectedPointForNavigate(null); }, cameraOptions: mapBox.CameraOptions( - center: mapBox.Point(coordinates: widget.mapDTO!.longitude != null && widget.mapDTO!.latitude != null ? mapBox.Position(double.tryParse(widget.mapDTO!.longitude!)!, double.tryParse(widget.mapDTO!.latitude!)!) : mapBox.Position(4.865105, 50.465503)), //.toJson() + center: mapBox.Point( + coordinates: mapBox.Position( + mapCenter.longitude, mapCenter.latitude)), zoom: widget.mapDTO!.zoom != null ? widget.mapDTO!.zoom!.toDouble() : 12), ) ), diff --git a/lib/Screens/Sections/Map/marker_view.dart b/lib/Screens/Sections/Map/marker_view.dart index 82e5ecf..8bd739b 100644 --- a/lib/Screens/Sections/Map/marker_view.dart +++ b/lib/Screens/Sections/Map/marker_view.dart @@ -275,6 +275,16 @@ class _MarkerInfoWidget extends State { ) ], ), + selectedPoint.schedules != null && selectedPoint.schedules!.isNotEmpty && selectedPoint.schedules!.any((d) => d.language == language) && selectedPoint.schedules!.firstWhere((d) => d.language == language).value != null && selectedPoint.schedules!.firstWhere((d) => d.language == language).value!.trim().isNotEmpty ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.schedule, color: primaryColor, size: 13), + Padding( + padding: const EdgeInsets.all(4.0), + child: AutoSizeText(parse(selectedPoint.schedules!.firstWhere((p) => p.language == language).value!).documentElement!.text, style: const TextStyle(fontSize: 18), maxLines: 3), + ) + ], + ): const SizedBox(), selectedPoint.phone != null && selectedPoint.phone!.isNotEmpty && selectedPoint.phone!.any((d) => d.language == language) && selectedPoint.phone!.firstWhere((d) => d.language == language).value != null && selectedPoint.phone!.firstWhere((d) => d.language == language).value!.trim().isNotEmpty ? Row( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/lib/Screens/Sections/Parcours/parcours_page.dart b/lib/Screens/Sections/Parcours/parcours_page.dart index 0756e33..41b9468 100644 --- a/lib/Screens/Sections/Parcours/parcours_page.dart +++ b/lib/Screens/Sections/Parcours/parcours_page.dart @@ -416,7 +416,11 @@ class _ParcoursPathStartSheet extends StatelessWidget { }) : super(key: key); bool get _isGame => path.isGameMode == true; - bool get _showMap => section.showMap == true && baseMap != null; + + /// `ShowMap` est le commutateur « parcours géolocalisé ou pas » — il décide seul + /// du mode d'affichage, comme dans visitapp-web. `baseSectionMapId` n'est qu'un + /// fond de carte optionnel et ne conditionne rien. + bool get _showMap => section.showMap == true; String get _title => TranslationHelper.get(path.title, visitAppContext); String get _desc => TranslationHelper.get(path.description, visitAppContext); diff --git a/lib/Services/assistantService.dart b/lib/Services/assistantService.dart index 8b20efa..0fb6af8 100644 --- a/lib/Services/assistantService.dart +++ b/lib/Services/assistantService.dart @@ -4,6 +4,15 @@ import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/AssistantResponse.dart'; +/// Levée quand l'instance a épuisé son quota IA du mois (HTTP 429). +/// +/// Distincte d'une panne : le visiteur ne doit pas être invité à réessayer, ça +/// le ferait boucler sur un mur. L'appelant affiche un message neutre, sans +/// jamais mentionner le motif — c'est une affaire entre le client et nous. +class AssistantUnavailableException implements Exception { + const AssistantUnavailableException(); +} + class AssistantService { final VisitAppContext visitAppContext; @@ -47,7 +56,13 @@ class AssistantService { isVoice: isVoice, ); - final response = await visitAppContext.clientAPI.aiApi!.aiChat(request); + final AiChatResponse? response; + try { + response = await visitAppContext.clientAPI.aiApi!.aiChat(request); + } on ApiException catch (e) { + if (e.code == 429) throw const AssistantUnavailableException(); + rethrow; + } if (response == null) { throw Exception('Empty response from assistant');