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/material.dart';
|
||||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.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/AssistantResponse.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
import 'package:mymuseum_visitapp/Services/assistantService.dart';
|
import 'package:mymuseum_visitapp/Services/assistantService.dart';
|
||||||
@ -133,6 +135,14 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
|||||||
languageCode: _toLangCode(lang),
|
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) {
|
} catch (e) {
|
||||||
debugPrint('AssistantChatSheet error: $e');
|
debugPrint('AssistantChatSheet error: $e');
|
||||||
setState(() {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final height = MediaQuery.of(context).size.height * 0.9;
|
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),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.chat_bubble_outline, color: kMainColor1),
|
Container(
|
||||||
const SizedBox(width: 8),
|
width: 32,
|
||||||
Text("Assistant",
|
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(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.15,
|
||||||
color: kSecondGrey)),
|
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),
|
const SizedBox(width: 8),
|
||||||
if (widget.visitAppContext.glassesEnabled)
|
if (widget.visitAppContext.glassesEnabled)
|
||||||
ValueListenableBuilder<GlassesState>(
|
ValueListenableBuilder<GlassesState>(
|
||||||
@ -232,11 +277,39 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
|||||||
? Center(
|
? Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Text(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
"Bonjour ! Posez-moi vos questions sur cette visite.",
|
"Bonjour ! Posez-moi vos questions sur cette visite.",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.grey[500], fontSize: 15),
|
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
@ -246,22 +319,57 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
|||||||
itemBuilder: (_, i) => _bubbles[i],
|
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)
|
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(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
const Icon(Icons.mic, color: Colors.red, size: 15),
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: kMainColor1),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text("...", style: TextStyle(color: Colors.grey[400])),
|
const Text(
|
||||||
|
"Je vous écoute…",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.red, fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
const _ListeningWave(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
// Input
|
// Input
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
@ -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 {
|
class _ChatBubble extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
final bool isUser;
|
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> {
|
extends State<GuidedPathContentProgressionPage> {
|
||||||
late List<GuidedStepDTO> _steps;
|
late List<GuidedStepDTO> _steps;
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
|
int _maxReached = 0;
|
||||||
final Set<String> _completedStepIds = {};
|
final Set<String> _completedStepIds = {};
|
||||||
|
|
||||||
GuidedStepChallengeController? _challenge;
|
GuidedStepChallengeController? _challenge;
|
||||||
@ -46,6 +47,30 @@ class _GuidedPathContentProgressionPageState
|
|||||||
|
|
||||||
GuidedStepDTO? get _currentStep => _steps.isEmpty ? null : _steps[_index];
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -108,6 +133,7 @@ class _GuidedPathContentProgressionPageState
|
|||||||
if (_index < _steps.length - 1) {
|
if (_index < _steps.length - 1) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_index++;
|
_index++;
|
||||||
|
if (_index > _maxReached) _maxReached = _index;
|
||||||
_initStepState();
|
_initStepState();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -116,7 +142,7 @@ class _GuidedPathContentProgressionPageState
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _goBack() {
|
void _goBack() {
|
||||||
if (_index > 0 && !(widget.path.isLinear ?? false)) {
|
if (_index > 0) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_index--;
|
_index--;
|
||||||
_initStepState();
|
_initStepState();
|
||||||
@ -268,9 +294,9 @@ class _GuidedPathContentProgressionPageState
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: List.generate(_steps.length, (i) {
|
children: List.generate(_visibleCount, (i) {
|
||||||
return Expanded(
|
final reachable = _canJumpTo(i);
|
||||||
child: Container(
|
final segment = Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
height: 5,
|
height: 5,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -283,7 +309,15 @@ class _GuidedPathContentProgressionPageState
|
|||||||
? Colors.white.withOpacity(0.1)
|
? Colors.white.withOpacity(0.1)
|
||||||
: const Color(0xFFE2DCD2)),
|
: 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
|
final contentResources = hasContents
|
||||||
? step.contents!
|
? step.contents!
|
||||||
.map((c) => ResourceModel(
|
.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()
|
.toList()
|
||||||
: <ResourceModel>[];
|
: <ResourceModel>[];
|
||||||
|
|
||||||
@ -392,7 +428,7 @@ class _GuidedPathContentProgressionPageState
|
|||||||
color: _bg,
|
color: _bg,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (_index > 0 && !(widget.path.isLinear ?? false)) ...[
|
if (_index > 0) ...[
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _goBack,
|
onTap: _goBack,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|||||||
@ -35,6 +35,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
|
|||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late List<GuidedStepDTO> _steps;
|
late List<GuidedStepDTO> _steps;
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
|
int _maxReached = 0;
|
||||||
final Set<String> _completedStepIds = {};
|
final Set<String> _completedStepIds = {};
|
||||||
|
|
||||||
GuidedStepChallengeController? _challenge;
|
GuidedStepChallengeController? _challenge;
|
||||||
@ -134,9 +135,27 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
|
|||||||
step.geometry != null &&
|
step.geometry != null &&
|
||||||
(step.zoneRadiusMeters ?? 0) > 0;
|
(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 {
|
bool get _canAdvance {
|
||||||
final step = _currentStep;
|
final step = _currentStep;
|
||||||
if (step == null || step.isStepLocked == true) return false;
|
if (step == null) return false;
|
||||||
final requireSuccess = widget.path.requireSuccessToAdvance ?? false;
|
final requireSuccess = widget.path.requireSuccessToAdvance ?? false;
|
||||||
final zoneOk = !_hasGeoTrigger(step) || _inGeoZone || !requireSuccess || _geoUnavailable;
|
final zoneOk = !_hasGeoTrigger(step) || _inGeoZone || !requireSuccess || _geoUnavailable;
|
||||||
return (_challenge?.canAdvance ?? true) && zoneOk;
|
return (_challenge?.canAdvance ?? true) && zoneOk;
|
||||||
@ -151,6 +170,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
|
|||||||
if (_index < _steps.length - 1) {
|
if (_index < _steps.length - 1) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_index++;
|
_index++;
|
||||||
|
if (_index > _maxReached) _maxReached = _index;
|
||||||
_initStepState();
|
_initStepState();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -159,7 +179,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _goBack() {
|
void _goBack() {
|
||||||
if (_index > 0 && !(widget.path.isLinear ?? false)) {
|
if (_index > 0) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_index--;
|
_index--;
|
||||||
_initStepState();
|
_initStepState();
|
||||||
@ -406,7 +426,9 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
|
|||||||
|
|
||||||
final completed = step.id != null && _completedStepIds.contains(step.id);
|
final completed = step.id != null && _completedStepIds.contains(step.id);
|
||||||
final current = i == _index;
|
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;
|
Widget pin;
|
||||||
if (current) {
|
if (current) {
|
||||||
@ -425,11 +447,18 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
|
|||||||
pin = _numberedPin('$visibleOrder', Colors.grey.shade500, false);
|
pin = _numberedPin('$visibleOrder', Colors.grey.shade500, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final stepIndex = i;
|
||||||
markers.add(Marker(
|
markers.add(Marker(
|
||||||
point: center,
|
point: center,
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
|
child: _canJumpTo(stepIndex)
|
||||||
|
? GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => _jumpTo(stepIndex),
|
||||||
child: pin,
|
child: pin,
|
||||||
|
)
|
||||||
|
: pin,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -627,7 +656,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
|
|||||||
padding: const EdgeInsets.fromLTRB(18, 12, 18, 0),
|
padding: const EdgeInsets.fromLTRB(18, 12, 18, 0),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (_index > 0 && !(widget.path.isLinear ?? false)) ...[
|
if (_index > 0) ...[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: _goBack,
|
onPressed: _goBack,
|
||||||
|
|||||||
@ -68,7 +68,7 @@ class GuidedStepChallengeController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Construit la liste combinée et triée des questions valides d'une étape.
|
/// Construit la liste combinée et triée des questions valides d'une étape.
|
||||||
static List<QuizQuestion> buildQuestions(GuidedStepDTO step) {
|
static List<QuizQuestion> buildQuestions(GuidedStepDTO step) {
|
||||||
final all = [...(step.quizQuestions ?? [])];
|
final all = <QuizQuestion>[...?step.quizQuestions];
|
||||||
final valid = all.where((q) {
|
final valid = all.where((q) {
|
||||||
switch (_kindOf(q)) {
|
switch (_kindOf(q)) {
|
||||||
case _Kind.puzzle:
|
case _Kind.puzzle:
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:mymuseum_visitapp/Helpers/mapCenter.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@ -97,12 +98,14 @@ class _FlutterMapViewState extends State<FlutterMapView> {
|
|||||||
mapContext.setSelectedPointForNavigate(null); // Reset after navigation
|
mapContext.setSelectedPointForNavigate(null); // Reset after navigation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final mapCenter = MapCenter.of(widget.mapDTO);
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
FlutterMap(
|
FlutterMap(
|
||||||
mapController: mapController,
|
mapController: mapController,
|
||||||
options: MapOptions(
|
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,
|
initialZoom: widget.mapDTO!.zoom != null ? widget.mapDTO!.zoom!.toDouble() : 12,
|
||||||
onTap: (Tap, lnt) => {
|
onTap: (Tap, lnt) => {
|
||||||
mapContext.setSelectedPointForNavigate(null),
|
mapContext.setSelectedPointForNavigate(null),
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:mymuseum_visitapp/Helpers/mapCenter.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||||
@ -106,6 +107,8 @@ class _GoogleMapViewState extends State<GoogleMapView> {
|
|||||||
pointsToShow = widget.geoPoints;
|
pointsToShow = widget.geoPoints;
|
||||||
getMarkers(widget.language, mapContext);
|
getMarkers(widget.language, mapContext);
|
||||||
|
|
||||||
|
final mapCenter = MapCenter.of(widget.mapDTO);
|
||||||
|
|
||||||
MapType type = MapType.hybrid;
|
MapType type = MapType.hybrid;
|
||||||
if(widget.mapDTO.mapType != null) {
|
if(widget.mapDTO.mapType != null) {
|
||||||
switch(widget.mapDTO.mapType!.value) {
|
switch(widget.mapDTO.mapType!.value) {
|
||||||
@ -125,9 +128,7 @@ class _GoogleMapViewState extends State<GoogleMapView> {
|
|||||||
mapToolbarEnabled: false,
|
mapToolbarEnabled: false,
|
||||||
indoorViewEnabled: false,
|
indoorViewEnabled: false,
|
||||||
initialCameraPosition: CameraPosition(
|
initialCameraPosition: CameraPosition(
|
||||||
target: widget.mapDTO.longitude != null && widget.mapDTO.latitude != null
|
target: LatLng(mapCenter.latitude, mapCenter.longitude),
|
||||||
? LatLng(double.tryParse(widget.mapDTO.latitude!)!, double.tryParse(widget.mapDTO.longitude!)!)
|
|
||||||
: LatLng(50.465503, 4.865105),
|
|
||||||
zoom: widget.mapDTO.zoom != null ? widget.mapDTO.zoom!.toDouble() : 18,
|
zoom: widget.mapDTO.zoom != null ? widget.mapDTO.zoom!.toDouble() : 18,
|
||||||
),
|
),
|
||||||
onMapCreated: (GoogleMapController controller) {
|
onMapCreated: (GoogleMapController controller) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
|
|
||||||
|
import 'package:mymuseum_visitapp/Helpers/mapCenter.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||||
@ -184,6 +185,8 @@ class _MapBoxViewState extends State<MapBoxView> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
final mapCenter = MapCenter.of(widget.mapDTO);
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Center(
|
Center(
|
||||||
@ -199,7 +202,9 @@ class _MapBoxViewState extends State<MapBoxView> {
|
|||||||
mapContext.setSelectedPointForNavigate(null);
|
mapContext.setSelectedPointForNavigate(null);
|
||||||
},
|
},
|
||||||
cameraOptions: mapBox.CameraOptions(
|
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),
|
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(
|
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,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@ -416,7 +416,11 @@ class _ParcoursPathStartSheet extends StatelessWidget {
|
|||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
bool get _isGame => path.isGameMode == true;
|
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 _title => TranslationHelper.get(path.title, visitAppContext);
|
||||||
String get _desc => TranslationHelper.get(path.description, 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/visitContext.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/AssistantResponse.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 {
|
class AssistantService {
|
||||||
final VisitAppContext visitAppContext;
|
final VisitAppContext visitAppContext;
|
||||||
|
|
||||||
@ -47,7 +56,13 @@ class AssistantService {
|
|||||||
isVoice: isVoice,
|
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) {
|
if (response == null) {
|
||||||
throw Exception('Empty response from assistant');
|
throw Exception('Empty response from assistant');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user