Assistant IA aligné sur le web + parcours guidés + centrage de carte

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.
This commit is contained in:
Thomas Fransolet 2026-08-09 22:15:26 +02:00
parent 0f2b4ed6bd
commit af44927d4a
12 changed files with 521 additions and 53 deletions

View File

@ -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<AssistantChatSheet> {
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<AssistantChatSheet> {
});
}
/// 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<AssistantChatSheet> {
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<GlassesState>(
@ -232,10 +277,38 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
? 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<AssistantChatSheet> {
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<AssistantChatSheet> {
}
}
/// 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;

View File

@ -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<String, Map<String, String>> _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 unora?',
},
'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<String, dynamic> 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<String> build(VisitAppContext context, {String? currentSectionId}) {
final lang = (context.language ?? 'FR').toUpperCase();
final sections = (context.currentSections ?? [])
.whereType<Map<String, dynamic>>()
.where((s) => s['isActive'] != false && s['isSubSection'] != true)
.toList();
bool has(String type) => sections.any((s) => s['type'] == type);
final suggestions = <String>[];
// 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: () => <String, dynamic>{},
);
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();
}
}

View File

@ -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);
}
}

View File

@ -32,6 +32,7 @@ class _GuidedPathContentProgressionPageState
extends State<GuidedPathContentProgressionPage> {
late List<GuidedStepDTO> _steps;
int _index = 0;
int _maxReached = 0;
final Set<String> _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()
: <ResourceModel>[];
@ -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(

View File

@ -35,6 +35,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
with SingleTickerProviderStateMixin {
late List<GuidedStepDTO> _steps;
int _index = 0;
int _maxReached = 0;
final Set<String> _completedStepIds = {};
GuidedStepChallengeController? _challenge;
@ -134,9 +135,27 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
step.geometry != null &&
(step.zoneRadiusMeters ?? 0) > 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<GuidedPathMapProgressionP
if (_index < _steps.length - 1) {
setState(() {
_index++;
if (_index > _maxReached) _maxReached = _index;
_initStepState();
});
} else {
@ -159,7 +179,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
}
void _goBack() {
if (_index > 0 && !(widget.path.isLinear ?? false)) {
if (_index > 0) {
setState(() {
_index--;
_initStepState();
@ -406,7 +426,9 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
final completed = step.id != null && _completedStepIds.contains(step.id);
final current = i == _index;
final locked = step.isStepLocked == true;
// Une étape est verrouillée tant que la progression ne l'a pas atteinte,
// sauf en navigation libre le visiteur va il veut.
final locked = !_isFreeNavigation && i > _maxReached;
Widget pin;
if (current) {
@ -425,11 +447,18 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
pin = _numberedPin('$visibleOrder', Colors.grey.shade500, false);
}
final stepIndex = i;
markers.add(Marker(
point: center,
width: 44,
height: 44,
child: pin,
child: _canJumpTo(stepIndex)
? GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _jumpTo(stepIndex),
child: pin,
)
: pin,
));
}
@ -627,7 +656,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
padding: const EdgeInsets.fromLTRB(18, 12, 18, 0),
child: Row(
children: [
if (_index > 0 && !(widget.path.isLinear ?? false)) ...[
if (_index > 0) ...[
Expanded(
child: OutlinedButton(
onPressed: _goBack,

View File

@ -68,7 +68,7 @@ class GuidedStepChallengeController extends ChangeNotifier {
/// Construit la liste combinée et triée des questions valides d'une étape.
static List<QuizQuestion> buildQuestions(GuidedStepDTO step) {
final all = [...(step.quizQuestions ?? [])];
final all = <QuizQuestion>[...?step.quizQuestions];
final valid = all.where((q) {
switch (_kindOf(q)) {
case _Kind.puzzle:

View File

@ -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<FlutterMapView> {
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),

View File

@ -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<GoogleMapView> {
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<GoogleMapView> {
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) {

View File

@ -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<MapBoxView> {
break;
}
}
final mapCenter = MapCenter.of(widget.mapDTO);
return Stack(
children: [
Center(
@ -199,7 +202,9 @@ class _MapBoxViewState extends State<MapBoxView> {
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),
)
),

View File

@ -275,6 +275,16 @@ class _MarkerInfoWidget extends State<MarkerViewWidget> {
)
],
),
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: [

View File

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

View File

@ -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');