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:
parent
0f2b4ed6bd
commit
af44927d4a
@ -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;
|
||||
|
||||
120
lib/Helpers/assistantSuggestions.dart
Normal file
120
lib/Helpers/assistantSuggestions.dart
Normal 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 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<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();
|
||||
}
|
||||
}
|
||||
37
lib/Helpers/mapCenter.dart
Normal file
37
lib/Helpers/mapCenter.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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 où le visiteur va où 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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
)
|
||||
),
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user