multi wakeword + misc
This commit is contained in:
parent
c6526046c8
commit
0f2b4ed6bd
@ -61,13 +61,12 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
when (call.method) {
|
||||
"start" -> {
|
||||
requestBatteryOptimizationExemption()
|
||||
val modelName = call.argument<String>("modelName")
|
||||
val modelNames = call.argument<String>("modelNames")
|
||||
?: WakeWordService.DEFAULT_MODEL
|
||||
// activeInstance dans WakeWordService gère le cleanup de l'instance précédente
|
||||
startService(
|
||||
Intent(this, WakeWordService::class.java)
|
||||
.setAction(WakeWordService.ACTION_START)
|
||||
.putExtra(WakeWordService.EXTRA_MODEL_NAME, modelName)
|
||||
.putExtra(WakeWordService.EXTRA_MODEL_NAMES, modelNames)
|
||||
.putExtra(WakeWordService.EXTRA_CACHE_DIR, wakeWordCacheDir)
|
||||
)
|
||||
result.success(null)
|
||||
|
||||
@ -50,9 +50,9 @@ class WakeWordService : Service() {
|
||||
private var activeInstance: WakeWordService? = null
|
||||
const val EVENT_ACTION = "be.unov.mymuseum.WAKE_WORD_EVENT"
|
||||
const val EXTRA_EVENT = "event"
|
||||
const val EXTRA_MODEL_NAME = "modelName"
|
||||
const val DEFAULT_MODEL = "hey_visit"
|
||||
const val EXTRA_CACHE_DIR = "cacheDir"
|
||||
const val EXTRA_MODEL_NAMES = "modelNames" // virgule-séparé : "hey_alba,hey_marco,hey_vasco"
|
||||
const val DEFAULT_MODEL = "hey_visit"
|
||||
const val EXTRA_CACHE_DIR = "cacheDir"
|
||||
|
||||
private const val CHANNEL_ID = "wake_word_channel_v2" // v2 force recréation du canal
|
||||
private const val NOTIFICATION_ID = 1338
|
||||
@ -65,7 +65,7 @@ class WakeWordService : Service() {
|
||||
private const val MEL_STRIDE = 8
|
||||
private const val EMBEDDING_DIM = 96
|
||||
private const val N_EMBEDDING_FRAMES = 16
|
||||
private const val DETECTION_THRESHOLD = 0.1f // Calibrer après re-entraînement avec 50k exemples
|
||||
private const val DETECTION_THRESHOLD = 0.05f // Abaissé pour meilleure détection prononciation FR
|
||||
private const val COOLDOWN_MS = 2000L
|
||||
}
|
||||
|
||||
@ -75,12 +75,12 @@ class WakeWordService : Service() {
|
||||
private var ortEnv: OrtEnvironment? = null
|
||||
private var melSession: OrtSession? = null
|
||||
private var embeddingInterpreter: Interpreter? = null
|
||||
private var classifierInterpreter: Interpreter? = null
|
||||
private val classifiers = mutableMapOf<String, Interpreter>() // modelName → interpréteur
|
||||
private val lastDetectionTimes = mutableMapOf<String, Long>() // cooldown par modèle
|
||||
private var isRunning = false
|
||||
@Volatile private var requestedPause = false // demande depuis main thread
|
||||
private var isPaused = false // état réel — géré uniquement par le thread capture
|
||||
private var lastDetectionTime = 0L
|
||||
private var modelName = DEFAULT_MODEL
|
||||
@Volatile private var requestedPause = false
|
||||
private var isPaused = false
|
||||
private var modelNames: List<String> = listOf(DEFAULT_MODEL)
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
private val melBuffer = ArrayDeque<FloatArray>()
|
||||
@ -104,7 +104,8 @@ class WakeWordService : Service() {
|
||||
}
|
||||
}
|
||||
activeInstance = this
|
||||
modelName = intent.getStringExtra(EXTRA_MODEL_NAME) ?: DEFAULT_MODEL
|
||||
modelNames = (intent.getStringExtra(EXTRA_MODEL_NAMES) ?: DEFAULT_MODEL)
|
||||
.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
modelCacheDir = intent.getStringExtra(EXTRA_CACHE_DIR) ?: ""
|
||||
startDetection()
|
||||
}
|
||||
@ -175,7 +176,9 @@ class WakeWordService : Service() {
|
||||
melSession?.close(); melSession = null
|
||||
ortEnv?.close(); ortEnv = null
|
||||
embeddingInterpreter?.close(); embeddingInterpreter = null
|
||||
classifierInterpreter?.close(); classifierInterpreter = null
|
||||
classifiers.values.forEach { it.close() }
|
||||
classifiers.clear()
|
||||
lastDetectionTimes.clear()
|
||||
melBuffer.clear(); embeddingBuffer.clear()
|
||||
audioManager = null
|
||||
if (wakeLock?.isHeld == true) wakeLock?.release()
|
||||
@ -192,9 +195,11 @@ class WakeWordService : Service() {
|
||||
|
||||
// embedding + classifieur : TFLite (formes statiques, pas de problème)
|
||||
embeddingInterpreter = loadTflite("embedding_model.tflite")
|
||||
classifierInterpreter = loadTflite("$modelName.tflite")
|
||||
|
||||
Log.d(TAG, "Models loaded — mel=ONNX, embedding+classifier=TFLite, model=$modelName")
|
||||
classifiers.clear()
|
||||
for (name in modelNames) {
|
||||
classifiers[name] = loadTflite("$name.tflite")
|
||||
}
|
||||
Log.d(TAG, "Models loaded — mel=ONNX, embedding=TFLite, classifiers=${modelNames}")
|
||||
}
|
||||
|
||||
private fun loadTflite(assetName: String): Interpreter {
|
||||
@ -326,6 +331,8 @@ class WakeWordService : Service() {
|
||||
// ── Main loop ──────────────────────────────────────────────────────────────
|
||||
|
||||
private var debugFrameCount = 0
|
||||
private val scoreHistory = mutableMapOf<String, FloatArray>() // 50 derniers scores par modèle
|
||||
private val scoreHistoryPos = mutableMapOf<String, Int>()
|
||||
|
||||
private fun runLoop() {
|
||||
val pcm = ShortArray(CHUNK_SAMPLES)
|
||||
@ -367,15 +374,34 @@ class WakeWordService : Service() {
|
||||
}
|
||||
|
||||
if (embeddingBuffer.size == N_EMBEDDING_FRAMES) {
|
||||
val score = runClassifier()
|
||||
if (debugFrameCount % 50 == 0) {
|
||||
Log.d(TAG, "DEBUG — classifier score=$score (threshold=$DETECTION_THRESHOLD)")
|
||||
}
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (score >= DETECTION_THRESHOLD && (now - lastDetectionTime) > COOLDOWN_MS) {
|
||||
lastDetectionTime = now
|
||||
Log.d(TAG, "Wake word detected! score=$score model=$modelName")
|
||||
broadcast("detected")
|
||||
for ((name, interpreter) in classifiers) {
|
||||
val score = runClassifier(interpreter)
|
||||
|
||||
// Accumule les scores pour les stats
|
||||
val hist = scoreHistory.getOrPut(name) { FloatArray(50) }
|
||||
val pos = scoreHistoryPos.getOrDefault(name, 0)
|
||||
hist[pos % 50] = score
|
||||
scoreHistoryPos[name] = pos + 1
|
||||
|
||||
val lastTime = lastDetectionTimes[name] ?: 0L
|
||||
if (score >= DETECTION_THRESHOLD && (now - lastTime) > COOLDOWN_MS) {
|
||||
lastDetectionTimes[name] = now
|
||||
Log.d(TAG, "Wake word detected! score=$score model=$name")
|
||||
broadcast("detected:$name")
|
||||
}
|
||||
}
|
||||
|
||||
// Stats toutes les 50 itérations (~4s)
|
||||
if (debugFrameCount % 50 == 0) {
|
||||
for ((name, hist) in scoreHistory) {
|
||||
val filled = minOf(scoreHistoryPos[name] ?: 0, 50)
|
||||
if (filled == 0) continue
|
||||
val window = hist.take(filled)
|
||||
val avg = window.average()
|
||||
val max = window.max()
|
||||
Log.d(TAG, "STATS $name — avg=${"%.4f".format(avg)} max=${"%.4f".format(max)} threshold=$DETECTION_THRESHOLD")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -454,12 +480,12 @@ class WakeWordService : Service() {
|
||||
|
||||
// ── Step 3 : classifier TFLite ────────────────────────────────────────────
|
||||
|
||||
private fun runClassifier(): Float {
|
||||
private fun runClassifier(interpreter: Interpreter): Float {
|
||||
val inputBuf = ByteBuffer.allocateDirect(4 * N_EMBEDDING_FRAMES * EMBEDDING_DIM).order(ByteOrder.nativeOrder())
|
||||
for (embedding in embeddingBuffer) for (v in embedding) inputBuf.putFloat(v)
|
||||
inputBuf.rewind()
|
||||
val output = Array(1) { FloatArray(1) }
|
||||
classifierInterpreter!!.run(inputBuf, output)
|
||||
interpreter.run(inputBuf, output)
|
||||
return output[0][0]
|
||||
}
|
||||
|
||||
|
||||
BIN
assets/files/hey_alba.onnx
Normal file
BIN
assets/files/hey_alba.onnx
Normal file
Binary file not shown.
BIN
assets/files/hey_alba.tflite
Normal file
BIN
assets/files/hey_alba.tflite
Normal file
Binary file not shown.
BIN
assets/files/hey_marco.onnx
Normal file
BIN
assets/files/hey_marco.onnx
Normal file
Binary file not shown.
BIN
assets/files/hey_marco.tflite
Normal file
BIN
assets/files/hey_marco.tflite
Normal file
Binary file not shown.
BIN
assets/files/hey_vasco.onnx
Normal file
BIN
assets/files/hey_vasco.onnx
Normal file
Binary file not shown.
BIN
assets/files/hey_vasco.tflite
Normal file
BIN
assets/files/hey_vasco.tflite
Normal file
Binary file not shown.
@ -128,7 +128,7 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
||||
MetaGlassesService.instance.isConnected &&
|
||||
response.reply.isNotEmpty) {
|
||||
final lang = widget.visitAppContext.language ?? 'FR';
|
||||
activeOrchestrator?.ttsEngine.speak(
|
||||
activeVoiceOrchestrator?.ttsEngine.speak(
|
||||
response.reply,
|
||||
languageCode: _toLangCode(lang),
|
||||
);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meta_wearables_dat/meta_wearables_dat.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
|
||||
@ -179,7 +180,7 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
|
||||
label: 'Test TTS',
|
||||
icon: Icons.volume_up,
|
||||
onTap: () => _run('TTS test', () async {
|
||||
final o = activeOrchestrator;
|
||||
final o = activeVoiceOrchestrator;
|
||||
if (o == null) {
|
||||
_addLog('⚠ Orchestrateur non initialisé');
|
||||
return;
|
||||
@ -227,13 +228,13 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
|
||||
),
|
||||
const Divider(color: Colors.grey),
|
||||
// Transcription en direct
|
||||
if (activeOrchestrator != null)
|
||||
if (activeVoiceOrchestrator != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: activeOrchestrator!.isListeningForCommand,
|
||||
valueListenable: activeVoiceOrchestrator!.isListeningForCommand,
|
||||
builder: (_, listening, __) => ValueListenableBuilder<String>(
|
||||
valueListenable: activeOrchestrator!.lastTranscription,
|
||||
valueListenable: activeVoiceOrchestrator!.lastTranscription,
|
||||
builder: (_, text, __) => Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
@ -282,11 +283,11 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
|
||||
),
|
||||
),
|
||||
// Dernier texte TTS
|
||||
if (activeOrchestrator != null)
|
||||
if (activeVoiceOrchestrator != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: activeOrchestrator!.lastTtsText,
|
||||
valueListenable: activeVoiceOrchestrator!.lastTtsText,
|
||||
builder: (_, text, __) => text.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Container(
|
||||
@ -316,6 +317,84 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Photos QR scan
|
||||
if (activeVoiceOrchestrator != null)
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: activeVoiceOrchestrator!.lastQrScanPhoto,
|
||||
builder: (_, path, __) {
|
||||
final exists = path != null && File(path).existsSync();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.qr_code_scanner, color: Colors.orange, size: 14),
|
||||
const SizedBox(width: 6),
|
||||
const Text('Dernier scan QR',
|
||||
style: TextStyle(color: Colors.orange, fontSize: 11)),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
exists
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Image.file(File(path!), height: 120, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Text('Image non disponible',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11))),
|
||||
)
|
||||
: const Text('Aucun scan effectué',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Photos visite
|
||||
if (activeVoiceOrchestrator != null)
|
||||
ValueListenableBuilder<List<String>>(
|
||||
valueListenable: activeVoiceOrchestrator!.visitPhotosNotifier,
|
||||
builder: (_, photos, __) {
|
||||
if (photos.isEmpty) return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.photo_library, color: Colors.green, size: 14),
|
||||
const SizedBox(width: 6),
|
||||
const Text('Photos visite — aucune photo',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11)),
|
||||
]),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.photo_library, color: Colors.green, size: 14),
|
||||
const SizedBox(width: 6),
|
||||
Text('Photos visite (${photos.length})',
|
||||
style: const TextStyle(color: Colors.green, fontSize: 11)),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: photos.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 4),
|
||||
itemBuilder: (_, i) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.file(File(photos[i]), height: 80, width: 80,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 80, height: 80, color: Colors.grey[800])),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(color: Colors.grey),
|
||||
// Log
|
||||
Expanded(
|
||||
|
||||
86
lib/Components/GlassesStatusWidget.dart
Normal file
86
lib/Components/GlassesStatusWidget.dart
Normal file
@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||
import 'package:mymuseum_visitapp/Services/voice_controller.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Icône circulaire positionnée en haut à droite de l'écran d'accueil.
|
||||
///
|
||||
/// Visible si :
|
||||
/// - Les lunettes sont connectées (quel que soit le mode)
|
||||
/// - OU un mode vocal est actif (voiceOnly ou glasses)
|
||||
///
|
||||
/// Tap → ouvre VoiceModeSheet.
|
||||
class GlassesStatusWidget extends StatelessWidget {
|
||||
final VisitAppContext visitAppContext;
|
||||
|
||||
const GlassesStatusWidget({super.key, required this.visitAppContext});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<VoiceController>(
|
||||
builder: (context, controller, _) {
|
||||
return ValueListenableBuilder<GlassesState>(
|
||||
valueListenable: MetaGlassesService.instance.state,
|
||||
builder: (context, glassesState, _) {
|
||||
final isConnected = glassesState == GlassesState.connected ||
|
||||
glassesState == GlassesState.streaming;
|
||||
final modeActive = controller.mode != VoiceMode.none;
|
||||
final featureAvailable = controller.isFeatureAvailable(visitAppContext);
|
||||
|
||||
if (!featureAvailable && !isConnected && !modeActive) return const SizedBox.shrink();
|
||||
|
||||
final Color iconColor = switch ((isConnected, controller.mode)) {
|
||||
(true, VoiceMode.glasses) => Colors.greenAccent,
|
||||
(_, VoiceMode.voiceOnly) => Colors.lightBlueAccent,
|
||||
(true, _) => Colors.white70,
|
||||
_ => Colors.white38,
|
||||
};
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => VoiceModeSheet.show(context, visitAppContext: visitAppContext),
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
controller.mode == VoiceMode.voiceOnly
|
||||
? Icons.mic
|
||||
: Icons.remove_red_eye_outlined,
|
||||
color: iconColor,
|
||||
size: 22,
|
||||
),
|
||||
// Point vert en bas à droite si mode actif
|
||||
if (modeActive)
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: controller.mode == VoiceMode.glasses
|
||||
? Colors.greenAccent
|
||||
: Colors.lightBlueAccent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black54, width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -67,11 +67,19 @@ class _SliderImagesWidget extends State<SliderImagesWidget> {
|
||||
enlargeCenterPage: true,
|
||||
reverse: false,
|
||||
),
|
||||
items: resourcesInWidget.map<Widget>((i) {
|
||||
items: resourcesInWidget.asMap().entries.map<Widget>((entry) {
|
||||
int idx = entry.key;
|
||||
ResourceModel? i = entry.value;
|
||||
return Builder(
|
||||
builder: (BuildContext context) {
|
||||
AppContext appContext = Provider.of<AppContext>(context);
|
||||
ContentDTO contentDTO = ContentDTO(resourceId: i!.id, resource: ResourceDTO(id: i.id, type: i.type, label: i.label, url: i.source));
|
||||
ContentDTO? originalContentDTO = idx < widget.contentsDTO.length ? widget.contentsDTO[idx] : null;
|
||||
ContentDTO contentDTO = ContentDTO(
|
||||
resourceId: i!.id,
|
||||
title: originalContentDTO?.title,
|
||||
description: originalContentDTO?.description,
|
||||
resource: ResourceDTO(id: i.id, type: i.type, label: i.label, url: i.source),
|
||||
);
|
||||
var resourcetoShow = getElementForResource(context, appContext, contentDTO, true);
|
||||
return resourcetoShow;
|
||||
//print(widget.imagesDTO[currentIndex-1]);
|
||||
|
||||
247
lib/Components/VoiceModeSheet.dart
Normal file
247
lib/Components/VoiceModeSheet.dart
Normal file
@ -0,0 +1,247 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||
import 'package:mymuseum_visitapp/Services/voice_controller.dart';
|
||||
import 'package:mymuseum_visitapp/constants.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Bottom sheet de sélection et gestion du mode vocal.
|
||||
///
|
||||
/// Accessible depuis GlassesStatusWidget (icône top-right) ou depuis
|
||||
/// les settings. Affiche :
|
||||
/// - État de connexion des lunettes (avec bouton connect/disconnect)
|
||||
/// - Tuiles de sélection de mode (vocal seul / lunettes)
|
||||
/// - Bouton de désactivation si un mode est actif
|
||||
class VoiceModeSheet extends StatelessWidget {
|
||||
final VisitAppContext visitAppContext;
|
||||
|
||||
const VoiceModeSheet({super.key, required this.visitAppContext});
|
||||
|
||||
static Future<void> show(BuildContext context, {required VisitAppContext visitAppContext}) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => VoiceModeSheet(visitAppContext: visitAppContext),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<VoiceController>(
|
||||
builder: (context, controller, _) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1E1E2E),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Handle
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Titre
|
||||
const Text(
|
||||
'Assistant vocal',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Section lunettes — toujours visible
|
||||
_GlassesConnectionTile(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Modes — uniquement si isAssistant
|
||||
if (controller.isFeatureAvailable(visitAppContext)) ...[
|
||||
const Text(
|
||||
'Mode',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12, letterSpacing: 1),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ModeTile(
|
||||
icon: Icons.mic,
|
||||
label: 'Mode vocal',
|
||||
subtitle: 'Micro du téléphone',
|
||||
targetMode: VoiceMode.voiceOnly,
|
||||
controller: controller,
|
||||
visitAppContext: visitAppContext,
|
||||
enabled: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ValueListenableBuilder<GlassesState>(
|
||||
valueListenable: MetaGlassesService.instance.state,
|
||||
builder: (context, glassesState, _) {
|
||||
final connected = glassesState == GlassesState.connected ||
|
||||
glassesState == GlassesState.streaming;
|
||||
return _ModeTile(
|
||||
icon: Icons.remove_red_eye_outlined,
|
||||
label: 'Mode lunettes',
|
||||
subtitle: connected ? 'Ray-Ban Meta connectées' : 'Lunettes non connectées',
|
||||
targetMode: VoiceMode.glasses,
|
||||
controller: controller,
|
||||
visitAppContext: visitAppContext,
|
||||
enabled: connected,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bouton désactiver
|
||||
if (controller.mode != VoiceMode.none)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: controller.isActivating ? null : () => controller.deactivate(),
|
||||
icon: const Icon(Icons.stop_circle_outlined, color: Colors.redAccent),
|
||||
label: const Text('Désactiver', style: TextStyle(color: Colors.redAccent)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.redAccent),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
"L'assistant vocal n'est pas activé pour cette instance.",
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlassesConnectionTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<GlassesState>(
|
||||
valueListenable: MetaGlassesService.instance.state,
|
||||
builder: (context, state, _) {
|
||||
final label = switch (state) {
|
||||
GlassesState.connected => 'Connectées',
|
||||
GlassesState.streaming => 'Connectées (streaming)',
|
||||
GlassesState.connecting => 'Connexion...',
|
||||
GlassesState.disconnected => 'Non connectées',
|
||||
};
|
||||
final color = (state == GlassesState.connected || state == GlassesState.streaming)
|
||||
? Colors.greenAccent
|
||||
: state == GlassesState.connecting
|
||||
? Colors.orangeAccent
|
||||
: Colors.white38;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.remove_red_eye_outlined, color: color, size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Ray-Ban Meta', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
|
||||
Text(label, style: TextStyle(color: color, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state == GlassesState.disconnected)
|
||||
TextButton(
|
||||
onPressed: () => MetaGlassesService.instance.connect(),
|
||||
child: const Text('Connecter', style: TextStyle(color: kMainColor1)),
|
||||
)
|
||||
else if (state == GlassesState.connected || state == GlassesState.streaming)
|
||||
TextButton(
|
||||
onPressed: () => MetaGlassesService.instance.disconnect(),
|
||||
child: const Text('Déconnecter', style: TextStyle(color: Colors.white54)),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String subtitle;
|
||||
final VoiceMode targetMode;
|
||||
final VoiceController controller;
|
||||
final VisitAppContext visitAppContext;
|
||||
final bool enabled;
|
||||
|
||||
const _ModeTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.subtitle,
|
||||
required this.targetMode,
|
||||
required this.controller,
|
||||
required this.visitAppContext,
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isActive = controller.mode == targetMode;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? kMainColor1.withValues(alpha: 0.2) : Colors.white10,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isActive ? kMainColor1 : Colors.transparent,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: isActive ? kMainColor1 : enabled ? Colors.white70 : Colors.white30),
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: enabled ? Colors.white : Colors.white38,
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: enabled ? Colors.white54 : Colors.white24, fontSize: 12)),
|
||||
trailing: isActive
|
||||
? const Icon(Icons.check_circle, color: kMainColor1)
|
||||
: enabled
|
||||
? const Icon(Icons.chevron_right, color: Colors.white38)
|
||||
: const Icon(Icons.lock_outline, color: Colors.white24, size: 18),
|
||||
onTap: (enabled && !controller.isActivating && !isActive)
|
||||
? () => controller.activate(targetMode, visitAppContext)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ enum DatabaseTableType {
|
||||
|
||||
class DatabaseHelper {
|
||||
static const _databaseName = "visit_database.db";
|
||||
static const _databaseVersion = 1;
|
||||
static const _databaseVersion = 3;
|
||||
|
||||
static const mainTable = 'visitAppContext';
|
||||
static const columnLanguage = 'language';
|
||||
@ -38,6 +38,9 @@ class DatabaseHelper {
|
||||
static const columnLanguages = 'languages';
|
||||
static const columnPrimaryColor = 'primaryColor';
|
||||
static const columnSecondaryColor = 'secondaryColor';
|
||||
static const columnConfigOrder = 'configOrder';
|
||||
static const columnGridColSpan = 'gridColSpan';
|
||||
static const columnGridRowSpan = 'gridRowSpan';
|
||||
|
||||
static const sectionsTable = 'sections';
|
||||
static const columnTitle = 'title';
|
||||
@ -78,7 +81,21 @@ class DatabaseHelper {
|
||||
Future<Database> _initDatabase() async {
|
||||
String path = join(await getDatabasesPath(), _databaseName);
|
||||
return await openDatabase(path,
|
||||
version: _databaseVersion, onCreate: _onCreate);
|
||||
version: _databaseVersion, onCreate: _onCreate, onUpgrade: _onUpgrade);
|
||||
}
|
||||
|
||||
Future _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
if (oldVersion < 2) {
|
||||
await db.execute('ALTER TABLE $configurationsTable ADD COLUMN $columnConfigOrder INT');
|
||||
// v2 avait une colonne 'weightMasonryGrid' (poids 1D) — abandonnée en v3
|
||||
// au profit des spans bento. On ne la supprime pas (SQLite ne le permet pas
|
||||
// simplement), elle reste inutilisée.
|
||||
await db.execute('ALTER TABLE $configurationsTable ADD COLUMN weightMasonryGrid REAL');
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await db.execute('ALTER TABLE $configurationsTable ADD COLUMN $columnGridColSpan INT');
|
||||
await db.execute('ALTER TABLE $configurationsTable ADD COLUMN $columnGridRowSpan INT');
|
||||
}
|
||||
}
|
||||
|
||||
// SQL code to create the database table
|
||||
@ -178,6 +195,9 @@ class DatabaseHelper {
|
||||
$columnDateCreation TEXT NOT NULL,
|
||||
$columnPrimaryColor TEXT,
|
||||
$columnSecondaryColor TEXT,
|
||||
$columnConfigOrder INT,
|
||||
$columnGridColSpan INT,
|
||||
$columnGridRowSpan INT,
|
||||
$columnIsOffline BOOLEAN NOT NULL CHECK ($columnIsOffline IN (0,1))
|
||||
)
|
||||
''');
|
||||
|
||||
@ -38,11 +38,13 @@ class AssistantResponse {
|
||||
final String reply;
|
||||
final List<AiCard>? cards;
|
||||
final AssistantNavigationAction? navigation;
|
||||
final bool expectsReply;
|
||||
|
||||
const AssistantResponse({
|
||||
required this.reply,
|
||||
this.cards,
|
||||
this.navigation,
|
||||
this.expectsReply = true,
|
||||
});
|
||||
|
||||
factory AssistantResponse.fromJson(Map<String, dynamic> json) =>
|
||||
@ -55,5 +57,6 @@ class AssistantResponse {
|
||||
? AssistantNavigationAction.fromJson(
|
||||
json['navigation'] as Map<String, dynamic>)
|
||||
: null,
|
||||
expectsReply: json['expectsReply'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,11 +3,13 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:myinfomate_layout/myinfomate_layout.dart';
|
||||
import 'package:mymuseum_visitapp/Components/AssistantChatSheet.dart';
|
||||
import 'package:mymuseum_visitapp/Components/CustomAppBar.dart';
|
||||
import 'package:mymuseum_visitapp/Components/GlassesStatusWidget.dart';
|
||||
import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart';
|
||||
import 'package:mymuseum_visitapp/Components/AdminPopup.dart';
|
||||
import 'package:mymuseum_visitapp/Components/ScannerBouton.dart';
|
||||
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
|
||||
@ -29,6 +31,24 @@ import 'package:mymuseum_visitapp/client.dart';
|
||||
import 'package:mymuseum_visitapp/constants.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class _WeightedConfig {
|
||||
final ConfigurationDTO configuration;
|
||||
final int colSpan;
|
||||
final int rowSpan;
|
||||
final int? order;
|
||||
|
||||
const _WeightedConfig({required this.configuration, this.colSpan = 1, this.rowSpan = 1, this.order});
|
||||
}
|
||||
|
||||
Color? _parseStoredColor(String? raw) {
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return Color(int.parse(raw.split('(0x')[1].split(')')[0], radix: 16));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class HomePage3 extends StatefulWidget {
|
||||
const HomePage3({Key? key}) : super(key: key);
|
||||
|
||||
@ -40,10 +60,11 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
int currentIndex = 0;
|
||||
|
||||
late List<ConfigurationDTO> configurations = [];
|
||||
List<_WeightedConfig> weightedConfigs = [];
|
||||
List<String?> alreadyDownloaded = [];
|
||||
late VisitAppContext visitAppContext;
|
||||
|
||||
late Future<List<ConfigurationDTO>?> _futureConfigurations;
|
||||
late Future<List<_WeightedConfig>?> _futureConfigurations;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -53,15 +74,19 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
_futureConfigurations = getConfigurationsCall(appContext);
|
||||
}
|
||||
|
||||
Widget _buildCard(BuildContext context, int index) {
|
||||
Widget _buildCard(BuildContext context, ConfigurationDTO config) {
|
||||
final lang = visitAppContext.language ?? "FR";
|
||||
final config = configurations[index];
|
||||
final titleEntry = config.title?.firstWhere(
|
||||
(t) => t.language == lang,
|
||||
orElse: () => config.title!.first,
|
||||
);
|
||||
final cleanedTitle = (titleEntry?.value ?? '').replaceAll('\n', ' ').replaceAll('<br>', ' ');
|
||||
|
||||
final fallbackBaseColor = _parseStoredColor(config.primaryColor) ??
|
||||
_parseStoredColor(visitAppContext.applicationInstanceDTO?.primaryColor) ??
|
||||
kMainColor1;
|
||||
final fallbackColor = Color.lerp(fallbackBaseColor, Colors.white, 0.35)!;
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
@ -80,7 +105,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: kSecondGrey,
|
||||
color: fallbackColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
@ -115,23 +140,29 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Title at bottom-left
|
||||
// Title at bottom-left — hauteur bornée + clip : empêche le débordement
|
||||
// même si le moteur HTML natif ignore -webkit-line-clamp (contenu Quill).
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
right: 28,
|
||||
child: HtmlWidget(
|
||||
cleanedTitle,
|
||||
textStyle: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontFamily: 'Roboto',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
child: ClipRect(
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: HtmlWidget(
|
||||
cleanedTitle,
|
||||
textStyle: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontFamily: 'Roboto',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
customStylesBuilder: (_) => {
|
||||
'font-family': 'Roboto',
|
||||
'-webkit-line-clamp': '2',
|
||||
},
|
||||
),
|
||||
),
|
||||
customStylesBuilder: (_) => {
|
||||
'font-family': 'Roboto',
|
||||
'-webkit-line-clamp': '2',
|
||||
},
|
||||
),
|
||||
),
|
||||
// Chevron
|
||||
@ -178,6 +209,21 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Entrée assistant vocal (si feature disponible)
|
||||
if (visitAppContext.applicationInstanceDTO?.isAssistant == true) ...[
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.mic, color: kMainColor1),
|
||||
title: const Text('Assistant vocal', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: const Text('Lunettes ou micro téléphone'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
VoiceModeSheet.show(ctx, visitAppContext: visitAppContext);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
const Text('Langue', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
@ -256,13 +302,10 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
final mobileConfigIds = visitAppContext.applicationInstanceDTO
|
||||
?.configurations?.map((c) => c.configurationId).toSet() ?? {};
|
||||
configurations = List<ConfigurationDTO>.from(snapshot.data)
|
||||
.where((c) => mobileConfigIds.isEmpty || mobileConfigIds.contains(c.id))
|
||||
weightedConfigs = List<_WeightedConfig>.from(snapshot.data)
|
||||
.where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id))
|
||||
.toList();
|
||||
|
||||
final layoutType = visitAppContext.applicationInstanceDTO?.layoutMainPage;
|
||||
final isMasonry = layoutType == null ||
|
||||
layoutType.value == LayoutMainPageType.MasonryGrid.value;
|
||||
configurations = weightedConfigs.map((w) => w.configuration).toList();
|
||||
|
||||
final lang = visitAppContext.language ?? "FR";
|
||||
final headerTitleEntry = configurations.isNotEmpty
|
||||
@ -415,6 +458,12 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
);
|
||||
}),
|
||||
),
|
||||
// Icône lunettes / mode vocal — à gauche du bouton settings
|
||||
Positioned(
|
||||
top: 35,
|
||||
right: 68,
|
||||
child: GlassesStatusWidget(visitAppContext: visitAppContext),
|
||||
),
|
||||
Positioned(
|
||||
top: 35,
|
||||
right: 10,
|
||||
@ -477,30 +526,50 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0, right: 8.0, top: 8.0, bottom: 20.0),
|
||||
sliver: isMasonry
|
||||
? SliverMasonryGrid.count(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childCount: configurations.length,
|
||||
itemBuilder: (context, index) => SizedBox(
|
||||
height: 160 + (index % 3) * 50,
|
||||
child: _buildCard(context, index),
|
||||
),
|
||||
)
|
||||
: SliverGrid(
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.82,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
_buildCard,
|
||||
childCount: configurations.length,
|
||||
),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Builder(builder: (context) {
|
||||
const columns = 2; // grille mobile
|
||||
const gap = 12.0;
|
||||
final gridWidth = size.width - 16.0;
|
||||
final cellWidth = (gridWidth - (columns - 1) * gap) / columns;
|
||||
final cellHeight = cellWidth * 0.9;
|
||||
|
||||
final result = weightedConfigs.isNotEmpty
|
||||
? bentoLayout(
|
||||
weightedConfigs
|
||||
.map((w) => BentoItem(
|
||||
id: w.configuration.id!,
|
||||
colSpan: w.colSpan.clamp(1, columns),
|
||||
rowSpan: w.rowSpan.clamp(1, 2),
|
||||
))
|
||||
.toList(),
|
||||
columns,
|
||||
)
|
||||
: const BentoResult(placements: [], rowCount: 0);
|
||||
final totalHeight = result.rowCount <= 0
|
||||
? 0.0
|
||||
: result.rowCount * cellHeight + (result.rowCount - 1) * gap;
|
||||
final configById = {
|
||||
for (final w in weightedConfigs) w.configuration.id!: w.configuration,
|
||||
};
|
||||
|
||||
return SizedBox(
|
||||
height: totalHeight,
|
||||
child: Stack(
|
||||
children: result.placements.map((p) {
|
||||
final config = configById[p.id]!;
|
||||
return Positioned(
|
||||
left: p.col * (cellWidth + gap),
|
||||
top: p.row * (cellHeight + gap),
|
||||
width: p.colSpan * cellWidth + (p.colSpan - 1) * gap,
|
||||
height: p.rowSpan * cellHeight + (p.rowSpan - 1) * gap,
|
||||
child: _buildCard(context, config),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -555,7 +624,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ConfigurationDTO>?> getConfigurationsCall(AppContext appContext) async {
|
||||
Future<List<_WeightedConfig>?> getConfigurationsCall(AppContext appContext) async {
|
||||
bool isOnline = await hasNetwork();
|
||||
VisitAppContext visitAppContext = appContext.getContext();
|
||||
|
||||
@ -588,7 +657,21 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
visitAppContext.beaconSections = beaconSections;
|
||||
//appContext.setContext(visitAppContext);
|
||||
|
||||
return configurations;
|
||||
// Récupère order/spans en brut (pas exposés sur ConfigurationDTO, qui
|
||||
// vivent sur le lien section↔instance) pour que le layout hors-ligne
|
||||
// corresponde à ce que le visiteur verrait en ligne.
|
||||
final rawRows = await DatabaseHelper.instance.queryAllRows(DatabaseTableType.configurations);
|
||||
final rawById = {for (final row in rawRows) row[DatabaseHelper.columnId] as String: row};
|
||||
|
||||
final cached = configurations.map((c) {
|
||||
final raw = rawById[c.id];
|
||||
final colSpan = (raw?[DatabaseHelper.columnGridColSpan] as int?) ?? 1;
|
||||
final rowSpan = (raw?[DatabaseHelper.columnGridRowSpan] as int?) ?? 1;
|
||||
final order = raw?[DatabaseHelper.columnConfigOrder] as int?;
|
||||
return _WeightedConfig(configuration: c, colSpan: colSpan, rowSpan: rowSpan, order: order);
|
||||
}).toList()
|
||||
..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||
return cached;
|
||||
}
|
||||
|
||||
if(visitAppContext.beaconSections == null) {
|
||||
@ -646,12 +729,34 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
||||
final links = await visitAppContext.clientAPI.applicationInstanceApi!
|
||||
.applicationInstanceGetAllApplicationLinkFromApplicationInstance(
|
||||
visitAppContext.applicationInstanceDTO!.id!);
|
||||
final configs = links
|
||||
final weightedConfigs = links
|
||||
?.where((l) => l.configuration != null)
|
||||
.map((l) => l.configuration!)
|
||||
.map((l) => _WeightedConfig(
|
||||
configuration: l.configuration!,
|
||||
colSpan: l.gridColSpan ?? 1,
|
||||
rowSpan: l.gridRowSpan ?? 1,
|
||||
order: l.order,
|
||||
))
|
||||
.toList() ??
|
||||
[];
|
||||
return configs;
|
||||
weightedConfigs.sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||
|
||||
// Best-effort : garde order/spans en cache local pour que le rendu
|
||||
// hors-ligne reste cohérent avec ce qui vient d'être vu en ligne.
|
||||
for (final w in weightedConfigs) {
|
||||
try {
|
||||
await DatabaseHelper.instance.insert(DatabaseTableType.configurations, {
|
||||
DatabaseHelper.columnId: w.configuration.id,
|
||||
DatabaseHelper.columnConfigOrder: w.order,
|
||||
DatabaseHelper.columnGridColSpan: w.colSpan,
|
||||
DatabaseHelper.columnGridRowSpan: w.rowSpan,
|
||||
});
|
||||
} catch (e) {
|
||||
print("Could not cache order/spans for configuration ${w.configuration.id}: $e");
|
||||
}
|
||||
}
|
||||
|
||||
return weightedConfigs;
|
||||
} catch (e) {
|
||||
print("Could not load configurations from app instance links: $e");
|
||||
}
|
||||
|
||||
@ -83,7 +83,6 @@ class _EventPageState extends State<EventPage> {
|
||||
widget.section.baseSectionMapId != null ||
|
||||
widget.section.latitude != null ||
|
||||
_paths.isNotEmpty;
|
||||
final hasDescription = widget.section.description?.isNotEmpty ?? false;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
@ -96,7 +95,6 @@ class _EventPageState extends State<EventPage> {
|
||||
children: [
|
||||
if (hasProgramme) _buildProgrammeSection(),
|
||||
if (hasMapSection) _buildMapParcoursSection(context, annotations),
|
||||
if (hasDescription) _buildDescriptionSection(),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
@ -744,34 +742,6 @@ class _EventPageState extends State<EventPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Description ───────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildDescriptionSection() {
|
||||
final desc = _safeTranslate(widget.section.description);
|
||||
if (desc.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 28, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: kMainColor, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'À propos',
|
||||
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(desc, style: TextStyle(color: Colors.grey[300], fontSize: 14, height: 1.6)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _sectionHeader(IconData icon, String label) => Padding(
|
||||
|
||||
@ -9,7 +9,6 @@ import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/Game/message_dialog.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart';
|
||||
import 'package:mymuseum_visitapp/app_context.dart';
|
||||
import 'package:mymuseum_visitapp/constants.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@ -277,55 +276,6 @@ class _GamePage extends State<GamePage> {
|
||||
}
|
||||
|
||||
Widget _buildContent(VisitAppContext visitAppContext) {
|
||||
if (gameDTO.gameType == GameTypes.Escape) {
|
||||
final paths = gameDTO.guidedPaths ?? [];
|
||||
if (paths.isEmpty) {
|
||||
return Center(
|
||||
child: Text('Aucun parcours disponible', style: TextStyle(color: Colors.grey[500], fontSize: 15)),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: paths.length,
|
||||
itemBuilder: (_, i) {
|
||||
final path = paths[i];
|
||||
final title = TranslationHelper.get(path.title, visitAppContext);
|
||||
final desc = TranslationHelper.get(path.description, visitAppContext);
|
||||
final stepCount = path.steps?.length ?? 0;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
leading: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: kMainColor.withOpacity(0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.explore, color: kMainColor, size: 22),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
subtitle: Text(
|
||||
desc.isNotEmpty ? desc : '$stepCount étape${stepCount > 1 ? 's' : ''}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => GuidedPathContentProgressionPage(
|
||||
path: path,
|
||||
visitAppContext: visitAppContext,
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (gameDTO.gameType == GameTypes.SlidingPuzzle) {
|
||||
if (slidingTileIndices.isEmpty) return Center(child: LoadingCommon());
|
||||
|
||||
|
||||
181
lib/Screens/Sections/GuidedPath/guided_path_audio_player.dart
Normal file
181
lib/Screens/Sections/GuidedPath/guided_path_audio_player.dart
Normal file
@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
/// Lecteur audio inline (play/pause, waveform cliquable, durée), équivalent du
|
||||
/// lecteur custom de visitapp-web (`ParcoursSection` / `ProgressView`).
|
||||
class GuidedPathAudioPlayer extends StatefulWidget {
|
||||
final String url;
|
||||
final Color accentColor;
|
||||
final bool isGame;
|
||||
|
||||
const GuidedPathAudioPlayer({
|
||||
Key? key,
|
||||
required this.url,
|
||||
required this.accentColor,
|
||||
required this.isGame,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<GuidedPathAudioPlayer> createState() => _GuidedPathAudioPlayerState();
|
||||
}
|
||||
|
||||
class _GuidedPathAudioPlayerState extends State<GuidedPathAudioPlayer> {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
Duration _position = Duration.zero;
|
||||
Duration _duration = Duration.zero;
|
||||
bool _playing = false;
|
||||
|
||||
static const _bars = [4, 8, 13, 10, 16, 11, 7, 14, 9, 5, 12, 8, 10, 15, 7, 11, 13, 6, 14, 9, 5, 12, 10, 7, 13];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
await _player.setUrl(widget.url);
|
||||
} catch (_) {}
|
||||
_player.durationStream.listen((d) {
|
||||
if (d != null && mounted) setState(() => _duration = d);
|
||||
});
|
||||
_player.positionStream.listen((p) {
|
||||
if (mounted) setState(() => _position = p);
|
||||
});
|
||||
_player.playerStateStream.listen((s) {
|
||||
if (!mounted) return;
|
||||
if (s.processingState == ProcessingState.completed) {
|
||||
_player.pause();
|
||||
_player.seek(Duration.zero);
|
||||
setState(() => _playing = false);
|
||||
} else {
|
||||
setState(() => _playing = s.playing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
if (_playing) {
|
||||
_player.pause();
|
||||
} else {
|
||||
_player.play();
|
||||
}
|
||||
}
|
||||
|
||||
void _seekFraction(double f) {
|
||||
if (_duration.inMilliseconds > 0) {
|
||||
_player.seek(Duration(milliseconds: (f * _duration.inMilliseconds).round()));
|
||||
}
|
||||
}
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes;
|
||||
final s = d.inSeconds % 60;
|
||||
return '$m:${s.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isGame = widget.isGame;
|
||||
final progress = _duration.inMilliseconds > 0
|
||||
? _position.inMilliseconds / _duration.inMilliseconds
|
||||
: 0.0;
|
||||
final muted = isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: isGame ? Colors.white.withOpacity(0.06) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isGame
|
||||
? const Color(0xFFD4AF37).withOpacity(0.2)
|
||||
: const Color(0xFFE8E3DA)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _toggle,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isGame ? const Color(0xFFD4AF37) : const Color(0xFF1E2A33),
|
||||
),
|
||||
child: Icon(
|
||||
_playing ? Icons.pause : Icons.play_arrow,
|
||||
color: isGame ? const Color(0xFF1a1305) : Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return GestureDetector(
|
||||
onTapDown: (d) =>
|
||||
_seekFraction((d.localPosition.dx / constraints.maxWidth).clamp(0.0, 1.0)),
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
for (int i = 0; i < _bars.length; i++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||
child: Container(
|
||||
height: _bars[i].toDouble(),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(1.5),
|
||||
color: (i / _bars.length) <= progress
|
||||
? widget.accentColor
|
||||
: const Color(0x33646E78),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_duration.inMilliseconds > 0 ? _fmt(_duration) : '--:--',
|
||||
style: TextStyle(color: muted, fontSize: 12.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_duration.inMilliseconds > 0)
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress.clamp(0.0, 1.0),
|
||||
minHeight: 3,
|
||||
backgroundColor:
|
||||
isGame ? Colors.white.withOpacity(0.06) : const Color(0xFFF0EBE3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(widget.accentColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:mymuseum_visitapp/Components/SliderImages.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||
import 'package:mymuseum_visitapp/Models/ResponseSubDTO.dart';
|
||||
import 'package:mymuseum_visitapp/Models/resourceModel.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart';
|
||||
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_audio_player.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_end_view.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_challenge.dart';
|
||||
import 'package:mymuseum_visitapp/constants.dart';
|
||||
|
||||
/// Vue de progression centrée contenu (escape game, ou parcours sans carte).
|
||||
/// Miroir de `ProgressView` (non-carte) de visitapp-web.
|
||||
class GuidedPathContentProgressionPage extends StatefulWidget {
|
||||
final GuidedPathDTO path;
|
||||
final VisitAppContext visitAppContext;
|
||||
@ -30,15 +31,20 @@ class GuidedPathContentProgressionPage extends StatefulWidget {
|
||||
class _GuidedPathContentProgressionPageState
|
||||
extends State<GuidedPathContentProgressionPage> {
|
||||
late List<GuidedStepDTO> _steps;
|
||||
int _currentStepIndex = 0;
|
||||
int _index = 0;
|
||||
final Set<String> _completedStepIds = {};
|
||||
|
||||
List<QuestionSubDTO> _stepQuestions = [];
|
||||
bool _quizCompleted = false;
|
||||
bool _quizPassed = false;
|
||||
GuidedStepChallengeController? _challenge;
|
||||
bool _expiredShown = false;
|
||||
|
||||
bool _inGeoZone = false;
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
static const _gold = Color(0xFFD4AF37);
|
||||
bool get _isGame => widget.path.isGameMode == true;
|
||||
Color get _accent => _isGame ? _gold : kMainColor;
|
||||
Color get _bg => _isGame ? const Color(0xFF0B1018) : const Color(0xFFF6F3EE);
|
||||
Color get _ink => _isGame ? const Color(0xFFF4ECD8) : const Color(0xFF1E2A33);
|
||||
Color get _muted => _isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86);
|
||||
|
||||
GuidedStepDTO? get _currentStep => _steps.isEmpty ? null : _steps[_index];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -46,406 +52,402 @@ class _GuidedPathContentProgressionPageState
|
||||
_steps = [...(widget.path.steps ?? [])]
|
||||
..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||
_initStepState();
|
||||
_startLocationTracking();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_positionSub?.cancel();
|
||||
_challenge?.removeListener(_onChallengeChanged);
|
||||
_challenge?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
void _initStepState() {
|
||||
_challenge?.removeListener(_onChallengeChanged);
|
||||
_challenge?.dispose();
|
||||
_expiredShown = false;
|
||||
|
||||
final step = _currentStep;
|
||||
if (step == null) return;
|
||||
_quizCompleted = false;
|
||||
_quizPassed = false;
|
||||
_inGeoZone = false;
|
||||
|
||||
if (step.quizQuestions?.isNotEmpty == true) {
|
||||
_stepQuestions = step.quizQuestions!
|
||||
.map((q) => QuestionSubDTO(
|
||||
chosen: null,
|
||||
label: q.label,
|
||||
responsesSubDTO: ResponseSubDTO().fromJSON(q.responses),
|
||||
resourceId: q.resourceId,
|
||||
resourceUrl: q.resource?.url,
|
||||
order: q.order,
|
||||
))
|
||||
.toList();
|
||||
} else {
|
||||
_stepQuestions = [];
|
||||
}
|
||||
final questions = GuidedStepChallengeController.buildQuestions(step);
|
||||
final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0;
|
||||
|
||||
_challenge = GuidedStepChallengeController(
|
||||
questions: questions,
|
||||
isGame: _isGame,
|
||||
requireSuccess: widget.path.requireSuccessToAdvance ?? false,
|
||||
hasTimer: hasTimer,
|
||||
timerSeconds: step.timerSeconds ?? 0,
|
||||
visitAppContext: widget.visitAppContext,
|
||||
)..addListener(_onChallengeChanged);
|
||||
}
|
||||
|
||||
GuidedStepDTO? get _currentStep =>
|
||||
_steps.isEmpty ? null : _steps[_currentStepIndex];
|
||||
void _onChallengeChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
final c = _challenge;
|
||||
final step = _currentStep;
|
||||
if (c != null && c.expired && !_expiredShown) {
|
||||
final msg = TranslationHelper.get(step?.timerExpiredMessage, widget.visitAppContext);
|
||||
if (msg.isNotEmpty) {
|
||||
_expiredShown = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _showExpiredModal(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _translate(List<TranslationDTO>? list) =>
|
||||
TranslationHelper.get(list, widget.visitAppContext);
|
||||
|
||||
bool _hasGeoTrigger(GuidedStepDTO step) =>
|
||||
(step.geometry?.type == 'Point') ||
|
||||
(step.triggerGeoPointId != null && step.triggerGeoPoint != null);
|
||||
|
||||
bool get _canAdvance {
|
||||
final step = _currentStep;
|
||||
if (step == null || step.isStepLocked == true) return false;
|
||||
if (_hasGeoTrigger(step) && !_inGeoZone) return false;
|
||||
if ((widget.path.requireSuccessToAdvance ?? false) &&
|
||||
step.quizQuestions?.isNotEmpty == true &&
|
||||
!_quizPassed) return false;
|
||||
return true;
|
||||
}
|
||||
bool get _canAdvance => _challenge?.canAdvance ?? true;
|
||||
|
||||
void _advance() {
|
||||
final step = _currentStep;
|
||||
if (step == null || !_canAdvance) return;
|
||||
if (step.id != null) _completedStepIds.add(step.id!);
|
||||
if (_currentStepIndex < _steps.length - 1) {
|
||||
if (_index < _steps.length - 1) {
|
||||
setState(() {
|
||||
_currentStepIndex++;
|
||||
_index++;
|
||||
_initStepState();
|
||||
});
|
||||
} else {
|
||||
_showCompletionDialog();
|
||||
_showEnd();
|
||||
}
|
||||
}
|
||||
|
||||
void _goBack() {
|
||||
if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false)) {
|
||||
if (_index > 0 && !(widget.path.isLinear ?? false)) {
|
||||
setState(() {
|
||||
_currentStepIndex--;
|
||||
_index--;
|
||||
_initStepState();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showCompletionDialog() {
|
||||
void _openChallenge() {
|
||||
final started = _challenge?.openChallenge() ?? false;
|
||||
if (started) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Le chrono a démarré !'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: _isGame ? const Color(0xFF141a24) : const Color(0xFF1E2A33),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showExpiredModal(String html) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Parcours terminé !'),
|
||||
content: const Text('Vous avez complété toutes les étapes.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Fermer'),
|
||||
builder: (_) => Dialog(
|
||||
backgroundColor: _isGame ? const Color(0xFF0B1018) : Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFFD14343).withOpacity(0.14),
|
||||
),
|
||||
child: const Icon(Icons.timer_off, size: 30, color: Color(0xFFD14343)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Temps écoulé !',
|
||||
style: TextStyle(color: _ink, fontSize: 19, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 10),
|
||||
HtmlWidget(html, textStyle: TextStyle(color: _muted, fontSize: 14.5, height: 1.6)),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _accent,
|
||||
foregroundColor: _isGame ? const Color(0xFF1a1305) : Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text('Fermer',
|
||||
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEnd() {
|
||||
final outro = _stripHtml(
|
||||
TranslationHelper.getWithResource(widget.path.gameMessageFin, widget.visitAppContext));
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => GuidedPathEndView(
|
||||
isGame: _isGame,
|
||||
primaryColor: kMainColor,
|
||||
pathTitle: _translate(widget.path.title),
|
||||
stepsCount: _steps.length,
|
||||
estimatedDurationMinutes: widget.path.estimatedDurationMinutes,
|
||||
gameOutro: outro,
|
||||
onBack: () {
|
||||
Navigator.of(context).pop(); // ferme l'écran de fin
|
||||
Navigator.of(context).pop(); // revient à la liste des parcours
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
String _stripHtml(String html) => html
|
||||
.replaceAll(RegExp(r'<[^>]*>'), ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final step = _currentStep;
|
||||
if (step == null) {
|
||||
return const Scaffold(body: Center(child: Text('Aucune étape')));
|
||||
}
|
||||
|
||||
final challenge = _challenge!;
|
||||
final hasQuiz = challenge.hasQuiz;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _bg,
|
||||
body: Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(step),
|
||||
Expanded(child: _buildContent(step)),
|
||||
_buildBottomBar(hasQuiz),
|
||||
],
|
||||
),
|
||||
),
|
||||
GuidedStepChallengeOverlay(
|
||||
controller: challenge,
|
||||
stepIndex: _index,
|
||||
accentColor: _accent,
|
||||
isGame: _isGame,
|
||||
visitAppContext: widget.visitAppContext,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Géoloc ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _startLocationTracking() async {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) return;
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) return;
|
||||
}
|
||||
if (permission == LocationPermission.deniedForever) return;
|
||||
|
||||
_positionSub = Geolocator.getPositionStream(
|
||||
locationSettings:
|
||||
const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5),
|
||||
).listen((pos) => _checkGeoZone(LatLng(pos.latitude, pos.longitude)));
|
||||
Widget _buildHeader(GuidedStepDTO step) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||
color: _isGame ? const Color(0xFF0B1018) : const Color(0xFFF6F3EE),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: _isGame ? Colors.white.withOpacity(0.08) : Colors.white,
|
||||
),
|
||||
child: Icon(Icons.arrow_back_ios_new,
|
||||
size: 16, color: _isGame ? _gold : const Color(0xFF1E2A33)),
|
||||
),
|
||||
),
|
||||
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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
color: _isGame ? Colors.white.withOpacity(0.08) : Colors.white,
|
||||
),
|
||||
child: Text('${_index + 1} / ${_steps.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _isGame ? _gold : const Color(0xFF46555F))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _checkGeoZone(LatLng position) {
|
||||
if (!mounted) return;
|
||||
final step = _currentStep;
|
||||
if (step == null || !_hasGeoTrigger(step)) return;
|
||||
|
||||
LatLng? center;
|
||||
double radius = step.zoneRadiusMeters ?? 30;
|
||||
|
||||
if (step.geometry?.type == 'Point') {
|
||||
final c = step.geometry!.coordinates as List;
|
||||
center = LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble());
|
||||
} else if (step.triggerGeoPoint?.geometry?.type == 'Point') {
|
||||
final c = step.triggerGeoPoint!.geometry!.coordinates as List;
|
||||
center = LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble());
|
||||
}
|
||||
|
||||
if (center == null) return;
|
||||
final dist = const Distance().distance(position, center);
|
||||
final inZone = dist <= radius;
|
||||
if (inZone && !_inGeoZone) {
|
||||
PushNotificationService.showGeoZoneNotification(
|
||||
stepTitle: _translate(_currentStep!.title),
|
||||
);
|
||||
}
|
||||
if (inZone != _inGeoZone) {
|
||||
setState(() => _inGeoZone = inZone);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Build ────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final step = _currentStep;
|
||||
if (step == null) return const Scaffold(body: Center(child: Text('Aucune étape')));
|
||||
|
||||
Widget _buildContent(GuidedStepDTO step) {
|
||||
final title = _translate(step.title);
|
||||
final desc = _translate(step.description);
|
||||
final hasQuiz = step.quizQuestions?.isNotEmpty == true;
|
||||
final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0;
|
||||
final hasGeo = _hasGeoTrigger(step);
|
||||
final isLocked = step.isStepLocked == true;
|
||||
final hasContents = step.contents?.isNotEmpty == true;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Header ──────────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Progress indicator
|
||||
Text(
|
||||
'Étape ${_currentStepIndex + 1} / ${_steps.length}',
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
final audioUrl = () {
|
||||
final list = step.audioIds ?? [];
|
||||
if (list.isEmpty) return null;
|
||||
final match = list.firstWhere(
|
||||
(a) => a.language == widget.visitAppContext.language,
|
||||
orElse: () => list.first,
|
||||
);
|
||||
final v = match.value;
|
||||
return (v != null && v.startsWith('http')) ? v : null;
|
||||
}();
|
||||
|
||||
// Dot progress
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(_steps.length, (i) {
|
||||
final done = _completedStepIds.contains(_steps[i].id);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: done
|
||||
? Colors.green
|
||||
: i == _currentStepIndex
|
||||
? Colors.blue
|
||||
: Colors.grey[300],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
final contentResources = hasContents
|
||||
? step.contents!
|
||||
.map((c) => ResourceModel(
|
||||
id: c.resourceId, source: c.resource?.url, type: ResourceType.Image))
|
||||
.toList()
|
||||
: <ResourceModel>[];
|
||||
|
||||
// ── Contenu scrollable ───────────────────────────────────────
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Image
|
||||
if (step.imageUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
step.imageUrl!,
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
if (step.imageUrl != null) const SizedBox(height: 16),
|
||||
|
||||
// Locked
|
||||
if (isLocked)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.lock, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Text('Étape verrouillée', style: TextStyle(color: Colors.grey[600])),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Description
|
||||
if (desc.isNotEmpty && !isLocked)
|
||||
Text(desc, style: const TextStyle(fontSize: 15, height: 1.6)),
|
||||
|
||||
if (desc.isNotEmpty) const SizedBox(height: 16),
|
||||
|
||||
// Timer (barre)
|
||||
if (hasTimer && !isLocked)
|
||||
GuidedStepTimer(
|
||||
key: ValueKey(step.id),
|
||||
seconds: step.timerSeconds!,
|
||||
expiredMessage: _translate(step.timerExpiredMessage),
|
||||
showAsBar: true,
|
||||
onExpired: () => setState(() {}),
|
||||
),
|
||||
|
||||
if (hasTimer) const SizedBox(height: 16),
|
||||
|
||||
// Géo
|
||||
if (hasGeo && !isLocked)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _inGeoZone
|
||||
? Colors.green.withOpacity(0.1)
|
||||
: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _inGeoZone ? Colors.green : Colors.orange),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_inGeoZone ? Icons.location_on : Icons.location_searching,
|
||||
color: _inGeoZone ? Colors.green : Colors.orange,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_inGeoZone
|
||||
? 'Vous êtes dans la zone ✓'
|
||||
: 'Approchez-vous du point indiqué',
|
||||
style: TextStyle(
|
||||
color: _inGeoZone ? Colors.green[700] : Colors.orange[700],
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (hasGeo) const SizedBox(height: 16),
|
||||
|
||||
// Quiz
|
||||
if (hasQuiz && !isLocked && !_quizCompleted)
|
||||
SizedBox(
|
||||
height: 340,
|
||||
child: QuestionsListWidget(
|
||||
questionsSubDTO: _stepQuestions,
|
||||
isShowResponse: false,
|
||||
onShowResponse: () {
|
||||
final good = _stepQuestions
|
||||
.where((q) =>
|
||||
q.chosen != null &&
|
||||
q.chosen ==
|
||||
q.responsesSubDTO!
|
||||
.indexWhere((r) => r.isGood == true))
|
||||
.length;
|
||||
setState(() {
|
||||
_quizCompleted = true;
|
||||
_quizPassed = good == _stepQuestions.length;
|
||||
});
|
||||
},
|
||||
orientation: MediaQuery.of(context).orientation,
|
||||
),
|
||||
),
|
||||
|
||||
if (hasQuiz && _quizCompleted)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_quizPassed ? Icons.check_circle : Icons.cancel,
|
||||
color: _quizPassed ? Colors.green : Colors.red,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_quizPassed ? 'Quiz réussi !' : 'Quiz échoué',
|
||||
style: TextStyle(
|
||||
color: _quizPassed ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (!_quizPassed) ...[
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => setState(() {
|
||||
_quizCompleted = false;
|
||||
_quizPassed = false;
|
||||
_stepQuestions = _currentStep!.quizQuestions!
|
||||
.map((q) => QuestionSubDTO(
|
||||
chosen: null,
|
||||
label: q.label,
|
||||
responsesSubDTO:
|
||||
ResponseSubDTO().fromJSON(q.responses),
|
||||
resourceId: q.resourceId,
|
||||
resourceUrl: q.resource?.url,
|
||||
order: q.order,
|
||||
))
|
||||
.toList();
|
||||
}),
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasContents)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: SliderImagesWidget(
|
||||
resources: contentResources,
|
||||
height: 200,
|
||||
contentsDTO: step.contents!,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (step.imageUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.network(step.imageUrl!,
|
||||
height: 200, width: double.infinity, fit: BoxFit.cover),
|
||||
),
|
||||
if (hasContents || step.imageUrl != null) const SizedBox(height: 16),
|
||||
Text(title,
|
||||
style: TextStyle(
|
||||
color: _ink, fontSize: 22, fontWeight: FontWeight.w600, height: 1.15)),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: _isGame ? _gold.withOpacity(0.12) : _accent.withOpacity(0.1),
|
||||
),
|
||||
child: Text(_translate(widget.path.title).toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: _accent,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3)),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (audioUrl != null)
|
||||
GuidedPathAudioPlayer(
|
||||
key: ValueKey('audio-${step.id}'),
|
||||
url: audioUrl,
|
||||
accentColor: _accent,
|
||||
isGame: _isGame,
|
||||
),
|
||||
if (desc.isNotEmpty)
|
||||
HtmlWidget(desc,
|
||||
textStyle: TextStyle(color: _muted, fontSize: 15.5, height: 1.65)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false))
|
||||
OutlinedButton.icon(
|
||||
onPressed: _goBack,
|
||||
icon: const Icon(Icons.arrow_back, size: 16),
|
||||
label: const Text('Précédent'),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: _canAdvance ? _advance : null,
|
||||
icon: Icon(
|
||||
_currentStepIndex < _steps.length - 1
|
||||
? Icons.arrow_forward
|
||||
: Icons.flag,
|
||||
size: 16,
|
||||
),
|
||||
label: Text(
|
||||
_currentStepIndex < _steps.length - 1 ? 'Suivant' : 'Terminer',
|
||||
),
|
||||
),
|
||||
],
|
||||
Widget _buildBottomBar(bool hasQuiz) {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(18, 12, 18, 18 + MediaQuery.of(context).padding.bottom),
|
||||
color: _bg,
|
||||
child: Row(
|
||||
children: [
|
||||
if (_index > 0 && !(widget.path.isLinear ?? false)) ...[
|
||||
GestureDetector(
|
||||
onTap: _goBack,
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: _isGame ? Colors.white.withOpacity(0.06) : Colors.white,
|
||||
border: Border.all(
|
||||
color: _isGame ? _gold.withOpacity(0.3) : const Color(0xFFE2DCD2),
|
||||
width: 1.5),
|
||||
),
|
||||
child: Icon(Icons.chevron_left,
|
||||
color: _isGame ? _gold : const Color(0xFF54636E)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
),
|
||||
if (hasQuiz) ...[
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _openChallenge,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _accent,
|
||||
backgroundColor: _isGame ? Colors.white.withOpacity(0.04) : Colors.white,
|
||||
side: BorderSide(
|
||||
color: _isGame ? _gold.withOpacity(0.4) : const Color(0xFFD4C9B8),
|
||||
width: 1.5),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
||||
),
|
||||
icon: Icon(Icons.star, size: 16, color: _accent),
|
||||
label: const Text('Voir le défi',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ElevatedButton(
|
||||
onPressed: _canAdvance ? _advance : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _accent,
|
||||
foregroundColor: _isGame ? const Color(0xFF1a1305) : Colors.white,
|
||||
disabledBackgroundColor: _accent.withOpacity(0.4),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: Text(_index < _steps.length - 1 ? 'Étape suivante' : 'Terminer',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
198
lib/Screens/Sections/GuidedPath/guided_path_end_view.dart
Normal file
198
lib/Screens/Sections/GuidedPath/guided_path_end_view.dart
Normal file
@ -0,0 +1,198 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Écran de fin de parcours (équivalent `EndView` de visitapp-web) : version
|
||||
/// escape game (badge + message de fin) et version découverte (stats).
|
||||
class GuidedPathEndView extends StatelessWidget {
|
||||
final bool isGame;
|
||||
final Color primaryColor;
|
||||
final String pathTitle;
|
||||
final int stepsCount;
|
||||
final int? estimatedDurationMinutes;
|
||||
final String gameOutro;
|
||||
final VoidCallback onBack;
|
||||
|
||||
const GuidedPathEndView({
|
||||
Key? key,
|
||||
required this.isGame,
|
||||
required this.primaryColor,
|
||||
required this.pathTitle,
|
||||
required this.stepsCount,
|
||||
required this.estimatedDurationMinutes,
|
||||
required this.gameOutro,
|
||||
required this.onBack,
|
||||
}) : super(key: key);
|
||||
|
||||
static const _gold = Color(0xFFD4AF37);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: isGame
|
||||
? const RadialGradient(
|
||||
center: Alignment(0, -0.6),
|
||||
radius: 1.1,
|
||||
colors: [Color(0xFF1a2436), Color(0xFF0B1018), Color(0xFF05070b)],
|
||||
stops: [0.0, 0.6, 1.0],
|
||||
)
|
||||
: const RadialGradient(
|
||||
center: Alignment(0, -1),
|
||||
radius: 1.1,
|
||||
colors: [Colors.white, Color(0xFFF6F3EE)],
|
||||
stops: [0.0, 0.6],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 34, vertical: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: isGame ? _game(context) : _normal(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _game(BuildContext context) => [
|
||||
Container(
|
||||
width: 110,
|
||||
height: 110,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: _gold.withOpacity(0.5), width: 1.5),
|
||||
boxShadow: [BoxShadow(color: _gold.withOpacity(0.35), blurRadius: 60, spreadRadius: 4)],
|
||||
),
|
||||
child: const Icon(Icons.star, size: 50, color: Color(0xFFE7C765)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('ÉNIGME RÉSOLUE',
|
||||
style: TextStyle(
|
||||
color: _gold, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 4)),
|
||||
const SizedBox(height: 14),
|
||||
const Text('Le secret est à vous',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Color(0xFFF4ECD8),
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.1)),
|
||||
if (gameOutro.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
Text('« $gameOutro »',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFb9ac8c),
|
||||
fontSize: 15,
|
||||
fontStyle: FontStyle.italic,
|
||||
height: 1.6)),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(color: _gold.withOpacity(0.4)),
|
||||
color: _gold.withOpacity(0.08),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.emoji_events, size: 16, color: Color(0xFFE7C765)),
|
||||
SizedBox(width: 8),
|
||||
Text('Badge obtenu',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFE7C765), fontSize: 13.5, fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_button('Terminer l\'aventure'),
|
||||
];
|
||||
|
||||
List<Widget> _normal(BuildContext context) => [
|
||||
Container(
|
||||
width: 110,
|
||||
height: 110,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFF2E9E6B),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF2E9E6B).withOpacity(0.4),
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 16))
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.check, size: 44, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('Parcours terminé',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Color(0xFF1E2A33),
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.4)),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Vous avez exploré $stepsCount étape${stepsCount > 1 ? 's' : ''} de « $pathTitle ».',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0xFF6B7B86), fontSize: 15, height: 1.55),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_statCard('$stepsCount', 'étapes'),
|
||||
if (estimatedDurationMinutes != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_statCard('$estimatedDurationMinutes min', 'estimé'),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_button('Retour à la section'),
|
||||
];
|
||||
|
||||
Widget _statCard(String value, String label) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: const Color(0xFFEEE9E0)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.w600, color: primaryColor)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF8A969F))),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _button(String label) => SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: onBack,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isGame ? _gold : primaryColor,
|
||||
foregroundColor: isGame ? const Color(0xFF1a1305) : Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 17),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: Text(label,
|
||||
style: TextStyle(fontSize: 16, fontWeight: isGame ? FontWeight.w800 : FontWeight.w700)),
|
||||
),
|
||||
);
|
||||
}
|
||||
61
lib/Screens/Sections/GuidedPath/guided_path_geo.dart
Normal file
61
lib/Screens/Sections/GuidedPath/guided_path_geo.dart
Normal file
@ -0,0 +1,61 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
|
||||
class StepShape {
|
||||
final String type; // 'LineString' | 'Polygon'
|
||||
final List<LatLng> positions;
|
||||
const StepShape(this.type, this.positions);
|
||||
}
|
||||
|
||||
/// Géométrie des GuidedStep : les coordonnées sont stockées en `[lat, lng]`
|
||||
/// (et NON en GeoJSON `[lng, lat]`), en miroir du `map_geometry_picker` de
|
||||
/// manager-app et de visitapp-web (voir `visitapp-web/src/lib/geo.ts`).
|
||||
class GuidedPathGeo {
|
||||
static List<LatLng> _pairs(dynamic points) {
|
||||
if (points is! List) return const [];
|
||||
final result = <LatLng>[];
|
||||
for (final p in points) {
|
||||
if (p is List && p.length >= 2 && p[0] is num && p[1] is num) {
|
||||
result.add(LatLng((p[0] as num).toDouble(), (p[1] as num).toDouble()));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static LatLng? _average(List<LatLng> pts) {
|
||||
if (pts.isEmpty) return null;
|
||||
final lat = pts.map((p) => p.latitude).reduce((a, b) => a + b) / pts.length;
|
||||
final lng = pts.map((p) => p.longitude).reduce((a, b) => a + b) / pts.length;
|
||||
return LatLng(lat, lng);
|
||||
}
|
||||
|
||||
/// Point représentatif : Point → lui-même ; LineString/Polygon → centroïde.
|
||||
static LatLng? center(GeometryDTO? geometry) {
|
||||
final coords = geometry?.coordinates;
|
||||
if (coords is! List || coords.isEmpty) return null;
|
||||
if (geometry?.type == 'LineString') return _average(_pairs(coords));
|
||||
if (geometry?.type == 'Polygon') return _average(_pairs(coords[0]));
|
||||
if (coords[0] is num && coords[1] is num) {
|
||||
return LatLng((coords[0] as num).toDouble(), (coords[1] as num).toDouble());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Tracé réel d'un LineString/Polygon (pour dessiner sur la carte) ;
|
||||
/// null pour un Point.
|
||||
static StepShape? shape(GeometryDTO? geometry) {
|
||||
final coords = geometry?.coordinates;
|
||||
if (coords is! List) return null;
|
||||
if (geometry?.type == 'LineString') {
|
||||
final pos = _pairs(coords);
|
||||
return pos.length > 1 ? StepShape('LineString', pos) : null;
|
||||
}
|
||||
if (geometry?.type == 'Polygon') {
|
||||
final pos = _pairs(coords.isNotEmpty ? coords[0] : null);
|
||||
return pos.length > 2 ? StepShape('Polygon', pos) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool hasPoint(GeometryDTO? geometry) => center(geometry) != null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
246
lib/Screens/Sections/GuidedPath/guided_path_puzzle.dart
Normal file
246
lib/Screens/Sections/GuidedPath/guided_path_puzzle.dart
Normal file
@ -0,0 +1,246 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Puzzle en pièces éparpillées, déplacées par glisser-déposer avec
|
||||
/// accrochage ("snap") à la bonne position. Équivalent de `PuzzleGame.tsx`
|
||||
/// (web) — la variante non-sliding (`isSlidingPuzzle == false`).
|
||||
class GuidedPathPuzzle extends StatefulWidget {
|
||||
final String imageUrl;
|
||||
final int rows;
|
||||
final int cols;
|
||||
final Color accentColor;
|
||||
final bool isGame;
|
||||
final VoidCallback onSolved;
|
||||
|
||||
const GuidedPathPuzzle({
|
||||
Key? key,
|
||||
required this.imageUrl,
|
||||
required this.rows,
|
||||
required this.cols,
|
||||
required this.accentColor,
|
||||
required this.isGame,
|
||||
required this.onSolved,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<GuidedPathPuzzle> createState() => _GuidedPathPuzzleState();
|
||||
}
|
||||
|
||||
class _Piece {
|
||||
final int row;
|
||||
final int col;
|
||||
Offset pos; // position courante (px depuis le coin haut-gauche du board)
|
||||
bool placed = false;
|
||||
double z;
|
||||
|
||||
_Piece({required this.row, required this.col, required this.pos, this.z = 0});
|
||||
|
||||
int get id => row * 1000 + col;
|
||||
}
|
||||
|
||||
class _GuidedPathPuzzleState extends State<GuidedPathPuzzle> {
|
||||
List<_Piece> _pieces = [];
|
||||
bool _showHint = false;
|
||||
bool _solved = false;
|
||||
double _topZ = 1;
|
||||
int? _draggingId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _initPieces(double board) {
|
||||
if (_pieces.isNotEmpty) return;
|
||||
final pw = board / widget.cols;
|
||||
final ph = board / widget.rows;
|
||||
final rand = Random();
|
||||
final list = <_Piece>[];
|
||||
var z = 1.0;
|
||||
for (int r = 0; r < widget.rows; r++) {
|
||||
for (int c = 0; c < widget.cols; c++) {
|
||||
list.add(_Piece(
|
||||
row: r,
|
||||
col: c,
|
||||
pos: Offset(rand.nextDouble() * (board - pw), rand.nextDouble() * (board - ph)),
|
||||
z: z++,
|
||||
));
|
||||
}
|
||||
}
|
||||
_pieces = list;
|
||||
_topZ = z;
|
||||
}
|
||||
|
||||
void _onPanStart(_Piece piece) {
|
||||
if (piece.placed || _solved) return;
|
||||
setState(() {
|
||||
_draggingId = piece.id;
|
||||
piece.z = _topZ++;
|
||||
});
|
||||
}
|
||||
|
||||
void _onPanUpdate(_Piece piece, DragUpdateDetails details, double board) {
|
||||
if (piece.placed || _solved) return;
|
||||
setState(() {
|
||||
final pw = board / widget.cols;
|
||||
final ph = board / widget.rows;
|
||||
piece.pos = Offset(
|
||||
(piece.pos.dx + details.delta.dx).clamp(0.0, board - pw),
|
||||
(piece.pos.dy + details.delta.dy).clamp(0.0, board - ph),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onPanEnd(_Piece piece, double board) {
|
||||
if (piece.placed || _solved) return;
|
||||
final pw = board / widget.cols;
|
||||
final ph = board / widget.rows;
|
||||
final targetX = piece.col * pw;
|
||||
final targetY = piece.row * ph;
|
||||
final dx = piece.pos.dx - targetX;
|
||||
final dy = piece.pos.dy - targetY;
|
||||
final threshold = min(pw, ph) * 0.35;
|
||||
|
||||
setState(() {
|
||||
_draggingId = null;
|
||||
if (dx.abs() < threshold && dy.abs() < threshold) {
|
||||
piece.pos = Offset(targetX, targetY);
|
||||
piece.placed = true;
|
||||
piece.z = 0;
|
||||
}
|
||||
if (_pieces.every((p) => p.placed)) {
|
||||
_solved = true;
|
||||
Future.delayed(const Duration(milliseconds: 400), () {
|
||||
if (mounted) widget.onSolved();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final board = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.of(context).size.width - 64;
|
||||
_initPieces(board);
|
||||
final pw = board / widget.cols;
|
||||
final ph = board / widget.rows;
|
||||
final sorted = [..._pieces]..sort((a, b) => a.z.compareTo(b.z));
|
||||
|
||||
return SizedBox(
|
||||
width: board,
|
||||
height: board,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Cadre du plateau (toujours visible)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.black.withOpacity(0.18), width: 1.5),
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
if (_showHint && !_solved)
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.55,
|
||||
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
for (final piece in sorted)
|
||||
Positioned(
|
||||
left: piece.pos.dx,
|
||||
top: piece.pos.dy,
|
||||
width: pw,
|
||||
height: ph,
|
||||
child: GestureDetector(
|
||||
onPanStart: (_) => _onPanStart(piece),
|
||||
onPanUpdate: (d) => _onPanUpdate(piece, d, board),
|
||||
onPanEnd: (_) => _onPanEnd(piece, board),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.black.withOpacity(0.15)),
|
||||
boxShadow: piece.placed
|
||||
? []
|
||||
: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(
|
||||
_draggingId == piece.id ? 0.35 : 0.2),
|
||||
blurRadius: _draggingId == piece.id ? 12 : 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
transform: _draggingId == piece.id
|
||||
? (Matrix4.identity()..scaleByDouble(1.06, 1.06, 1.06, 1.0))
|
||||
: Matrix4.identity(),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: OverflowBox(
|
||||
maxWidth: board,
|
||||
maxHeight: board,
|
||||
alignment: Alignment(
|
||||
widget.cols == 1 ? 0.0 : -1.0 + (piece.col * 2 / (widget.cols - 1)),
|
||||
widget.rows == 1 ? 0.0 : -1.0 + (piece.row * 2 / (widget.rows - 1)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: board,
|
||||
height: board,
|
||||
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_solved)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'Puzzle résolu !',
|
||||
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_solved)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _showHint = !_showHint),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _showHint
|
||||
? widget.accentColor
|
||||
: (widget.isGame ? Colors.white.withOpacity(0.1) : Colors.white),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 8)],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.remove_red_eye,
|
||||
size: 18,
|
||||
color: _showHint
|
||||
? (widget.isGame ? const Color(0xFF1a1305) : Colors.white)
|
||||
: (widget.isGame ? const Color(0xFFD4AF37) : const Color(0xFF54636E)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
232
lib/Screens/Sections/GuidedPath/guided_path_sliding_puzzle.dart
Normal file
232
lib/Screens/Sections/GuidedPath/guided_path_sliding_puzzle.dart
Normal file
@ -0,0 +1,232 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Taquin classique (15-puzzle) : une case vide, seule une tuile adjacente à
|
||||
/// la case vide peut glisser dedans. Équivalent de `SlidingPuzzle.tsx` (web).
|
||||
class GuidedPathSlidingPuzzle extends StatefulWidget {
|
||||
final String imageUrl;
|
||||
final int rows;
|
||||
final int cols;
|
||||
final Color accentColor;
|
||||
final bool isGame;
|
||||
final VoidCallback onSolved;
|
||||
|
||||
const GuidedPathSlidingPuzzle({
|
||||
Key? key,
|
||||
required this.imageUrl,
|
||||
required this.rows,
|
||||
required this.cols,
|
||||
required this.accentColor,
|
||||
required this.isGame,
|
||||
required this.onSolved,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<GuidedPathSlidingPuzzle> createState() => _GuidedPathSlidingPuzzleState();
|
||||
}
|
||||
|
||||
class _GuidedPathSlidingPuzzleState extends State<GuidedPathSlidingPuzzle> {
|
||||
// tiles[slot] = pièce d'origine occupant ce slot ; -1 = case vide.
|
||||
late List<int> _tiles;
|
||||
bool _won = false;
|
||||
bool _showHint = false;
|
||||
|
||||
int get _total => widget.rows * widget.cols;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shuffle();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(GuidedPathSlidingPuzzle old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.rows != widget.rows || old.cols != widget.cols) _shuffle();
|
||||
}
|
||||
|
||||
List<int> _neighboursOf(int idx) {
|
||||
final row = idx ~/ widget.cols;
|
||||
final col = idx % widget.cols;
|
||||
final list = <int>[];
|
||||
if (row > 0) list.add(idx - widget.cols);
|
||||
if (row < widget.rows - 1) list.add(idx + widget.cols);
|
||||
if (col > 0) list.add(idx - 1);
|
||||
if (col < widget.cols - 1) list.add(idx + 1);
|
||||
return list;
|
||||
}
|
||||
|
||||
void _shuffle() {
|
||||
final arr = List.generate(_total - 1, (i) => i)..add(-1);
|
||||
var emptyIdx = _total - 1;
|
||||
final rand = Random();
|
||||
for (int n = 0; n < 200; n++) {
|
||||
final neighbours = _neighboursOf(emptyIdx);
|
||||
final pick = neighbours[rand.nextInt(neighbours.length)];
|
||||
final tmp = arr[emptyIdx];
|
||||
arr[emptyIdx] = arr[pick];
|
||||
arr[pick] = tmp;
|
||||
emptyIdx = pick;
|
||||
}
|
||||
_tiles = arr;
|
||||
_won = false;
|
||||
}
|
||||
|
||||
void _onTapSlot(int slotIdx) {
|
||||
if (_won) return;
|
||||
final emptyIdx = _tiles.indexOf(-1);
|
||||
if (!_neighboursOf(emptyIdx).contains(slotIdx)) return;
|
||||
setState(() {
|
||||
final tmp = _tiles[emptyIdx];
|
||||
_tiles[emptyIdx] = _tiles[slotIdx];
|
||||
_tiles[slotIdx] = tmp;
|
||||
final win = _tiles.asMap().entries.every(
|
||||
(e) => e.key == _tiles.length - 1 ? e.value == -1 : e.value == e.key);
|
||||
if (win) {
|
||||
_won = true;
|
||||
Future.delayed(const Duration(milliseconds: 400), () {
|
||||
if (mounted) widget.onSolved();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final board = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.of(context).size.width - 64;
|
||||
final tileW = board / widget.cols;
|
||||
final tileH = board / widget.rows;
|
||||
|
||||
return SizedBox(
|
||||
width: board,
|
||||
height: board,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_showHint && !_won)
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.25,
|
||||
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
for (int slot = 0; slot < _total; slot++)
|
||||
if (_tiles[slot] != -1)
|
||||
AnimatedPositioned(
|
||||
key: ValueKey('slide-${_tiles[slot]}'),
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOut,
|
||||
left: (slot % widget.cols) * tileW,
|
||||
top: (slot ~/ widget.cols) * tileH,
|
||||
width: tileW,
|
||||
height: tileH,
|
||||
child: _SlidingTile(
|
||||
piece: _tiles[slot],
|
||||
rows: widget.rows,
|
||||
cols: widget.cols,
|
||||
boardSize: board,
|
||||
imageUrl: widget.imageUrl,
|
||||
onTap: () => _onTapSlot(slot),
|
||||
),
|
||||
),
|
||||
if (_won)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'Puzzle résolu !',
|
||||
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_won)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _showHint = !_showHint),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _showHint
|
||||
? widget.accentColor
|
||||
: (widget.isGame ? Colors.white.withOpacity(0.1) : Colors.white),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 8)],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.remove_red_eye,
|
||||
size: 18,
|
||||
color: _showHint
|
||||
? (widget.isGame ? const Color(0xFF1a1305) : Colors.white)
|
||||
: (widget.isGame ? const Color(0xFFD4AF37) : const Color(0xFF54636E)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlidingTile extends StatelessWidget {
|
||||
final int piece;
|
||||
final int rows;
|
||||
final int cols;
|
||||
final double boardSize;
|
||||
final String imageUrl;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SlidingTile({
|
||||
required this.piece,
|
||||
required this.rows,
|
||||
required this.cols,
|
||||
required this.boardSize,
|
||||
required this.imageUrl,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final row = piece ~/ cols;
|
||||
final col = piece % cols;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(1.5),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 6)],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: OverflowBox(
|
||||
maxWidth: boardSize,
|
||||
maxHeight: boardSize,
|
||||
alignment: Alignment(
|
||||
cols == 1 ? 0.0 : -1.0 + (col * 2 / (cols - 1)),
|
||||
rows == 1 ? 0.0 : -1.0 + (row * 2 / (rows - 1)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: boardSize,
|
||||
height: boardSize,
|
||||
child: Image.network(imageUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1016
lib/Screens/Sections/GuidedPath/guided_step_challenge.dart
Normal file
1016
lib/Screens/Sections/GuidedPath/guided_step_challenge.dart
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,108 +1,70 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GuidedStepTimer extends StatefulWidget {
|
||||
final int seconds;
|
||||
final String expiredMessage;
|
||||
final VoidCallback? onExpired;
|
||||
/// true = barre de progression + texte MM:SS (mode escape)
|
||||
/// false = texte MM:SS seul (mode peek carte)
|
||||
final bool showAsBar;
|
||||
/// Barre de temps présentational — le décompte est piloté par le parent
|
||||
/// (GuidedStepChallengeController), en miroir de `StepTimer` de visitapp-web.
|
||||
class GuidedStepTimer extends StatelessWidget {
|
||||
final int totalSeconds;
|
||||
final int remainingSeconds;
|
||||
final bool isGame;
|
||||
|
||||
const GuidedStepTimer({
|
||||
Key? key,
|
||||
required this.seconds,
|
||||
required this.expiredMessage,
|
||||
this.onExpired,
|
||||
this.showAsBar = false,
|
||||
required this.totalSeconds,
|
||||
required this.remainingSeconds,
|
||||
this.isGame = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<GuidedStepTimer> createState() => _GuidedStepTimerState();
|
||||
}
|
||||
|
||||
class _GuidedStepTimerState extends State<GuidedStepTimer> {
|
||||
late int _remaining;
|
||||
Timer? _timer;
|
||||
bool _expired = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_remaining = widget.seconds;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
}
|
||||
|
||||
void _tick(Timer t) {
|
||||
if (!mounted) return;
|
||||
if (_remaining <= 0) {
|
||||
_timer?.cancel();
|
||||
setState(() => _expired = true);
|
||||
widget.onExpired?.call();
|
||||
return;
|
||||
}
|
||||
setState(() => _remaining--);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
static String _format(int s) {
|
||||
final m = s ~/ 60;
|
||||
final r = s % 60;
|
||||
return '$m:${r.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_expired) {
|
||||
return Text(
|
||||
widget.expiredMessage,
|
||||
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||
);
|
||||
}
|
||||
final pct = totalSeconds > 0 ? remainingSeconds / totalSeconds : 0.0;
|
||||
final color = pct > 0.5
|
||||
? const Color(0xFF16A34A)
|
||||
: pct > 0.25
|
||||
? const Color(0xFFF59E0B)
|
||||
: const Color(0xFFDC2626);
|
||||
|
||||
final timeText = _formatTime(_remaining);
|
||||
|
||||
if (widget.showAsBar) {
|
||||
final progress = widget.seconds > 0 ? _remaining / widget.seconds : 0.0;
|
||||
final color = progress > 0.5
|
||||
? Colors.green
|
||||
: progress > 0.25
|
||||
? Colors.orange
|
||||
: Colors.red;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.timer, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text(timeText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: Colors.grey[300],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.timer, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(timeText, style: const TextStyle(fontSize: 13)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Temps restant',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_format(remainingSeconds),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: pct.clamp(0.0, 1.0),
|
||||
minHeight: 8,
|
||||
backgroundColor:
|
||||
isGame ? Colors.white.withOpacity(0.1) : const Color(0xFFE2DCD2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(int s) {
|
||||
final m = s ~/ 60;
|
||||
final sec = s % 60;
|
||||
return '${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ import 'package:manager_api_new/api.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/Map/flutter_map_view.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_list_sheet.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/Map/geo_point_filter.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/Map/map_box_view.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/Map/marker_view.dart';
|
||||
@ -179,41 +178,6 @@ class _MapPage extends State<MapPage> {
|
||||
)
|
||||
),
|
||||
),
|
||||
if (mapDTO?.guidedPaths?.isNotEmpty == true)
|
||||
Positioned(
|
||||
top: 35,
|
||||
right: 10,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => GuidedPathListSheet(
|
||||
paths: mapDTO!.guidedPaths!,
|
||||
mapDTO: mapDTO!,
|
||||
visitAppContext: visitAppContext,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor,
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.route, size: 18, color: Colors.white),
|
||||
SizedBox(width: 6),
|
||||
Text('Parcours', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mapDTO?.isListViewEnabled == true)
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
|
||||
@ -9,6 +9,7 @@ import 'package:manager_api_new/api.dart';
|
||||
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
||||
import 'package:mymuseum_visitapp/Components/show_element_for_resource.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/ImageCustomProvider.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/app_context.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
@ -518,23 +519,49 @@ getElementForResource(BuildContext context, AppContext appContext, ContentDTO i,
|
||||
borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0)),
|
||||
color: kBackgroundColor,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 250, minHeight: 250, maxHeight: 350),
|
||||
height: size.height * 0.6,
|
||||
constraints: const BoxConstraints(minWidth: 250, minHeight: 250, maxHeight: 550),
|
||||
height: size.height * 0.75,
|
||||
width: size.width * 0.85,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
//color: Colors.yellow,
|
||||
borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 15.0)),
|
||||
),
|
||||
child: PhotoView(
|
||||
imageProvider: ImageCustomProvider.getImageProvider(appContext, i.resourceId!, i.resource!.url!),
|
||||
minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
maxScale: PhotoViewComputedScale.contained * 3.0,
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: kBackgroundGrey,
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: BorderRadius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 15.0),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (TranslationHelper.get(i.title, appContext.getContext()).isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: HtmlWidget(
|
||||
TranslationHelper.get(i.title, appContext.getContext()),
|
||||
textStyle: const TextStyle(fontSize: kArticleContentSize, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
height: size.height * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
//color: Colors.yellow,
|
||||
borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 15.0)),
|
||||
),
|
||||
child: PhotoView(
|
||||
imageProvider: ImageCustomProvider.getImageProvider(appContext, i.resourceId!, i.resource!.url!),
|
||||
minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
maxScale: PhotoViewComputedScale.contained * 3.0,
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: kBackgroundGrey,
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: BorderRadius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 15.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (TranslationHelper.get(i.description, appContext.getContext()).isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: HtmlWidget(
|
||||
TranslationHelper.get(i.description, appContext.getContext()),
|
||||
textStyle: const TextStyle(fontSize: kArticleContentSize, fontWeight: FontWeight.w400),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
816
lib/Screens/Sections/Parcours/parcours_page.dart
Normal file
816
lib/Screens/Sections/Parcours/parcours_page.dart
Normal file
@ -0,0 +1,816 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart';
|
||||
import 'package:mymuseum_visitapp/constants.dart';
|
||||
|
||||
class ParcoursPage extends StatefulWidget {
|
||||
final ParcoursDTO section;
|
||||
final VisitAppContext visitAppContextIn;
|
||||
|
||||
const ParcoursPage({
|
||||
Key? key,
|
||||
required this.section,
|
||||
required this.visitAppContextIn,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ParcoursPage> createState() => _ParcoursPageState();
|
||||
}
|
||||
|
||||
class _ParcoursPageState extends State<ParcoursPage> {
|
||||
List<GuidedPathDTO> _paths = [];
|
||||
bool _loading = true;
|
||||
MapDTO? _baseMap;
|
||||
|
||||
static const _bg = Color(0xFFF6F3EE);
|
||||
static const _inkDark = Color(0xFF1E2A33);
|
||||
static const _inkMid = Color(0xFF46555F);
|
||||
static const _inkMuted = Color(0xFF6B7B86);
|
||||
static const _borderLight = Color(0xFFEFEAE2);
|
||||
static const _gold = Color(0xFFD4AF37);
|
||||
|
||||
Color get _primary => kMainColor;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPaths();
|
||||
}
|
||||
|
||||
Future<void> _loadPaths() async {
|
||||
if (widget.section.id == null) {
|
||||
setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final api = widget.visitAppContextIn.clientAPI.sectionParcoursApi!;
|
||||
final paths = await api.sectionParcoursGetAllGuidedPathFromSection(widget.section.id!);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_paths = (paths ?? [])..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||
_loading = false;
|
||||
});
|
||||
if (widget.section.showMap == true && widget.section.baseSectionMapId != null) {
|
||||
_loadBaseMap();
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadBaseMap() async {
|
||||
try {
|
||||
final rawMap = await widget.visitAppContextIn.clientAPI.sectionApi!
|
||||
.sectionGetDetail(widget.section.baseSectionMapId!);
|
||||
if (rawMap != null && mounted) {
|
||||
setState(() => _baseMap = MapDTO.fromJson(rawMap));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String get _sectionTitle =>
|
||||
TranslationHelper.get(widget.section.title, widget.visitAppContextIn);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _bg,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHero(context),
|
||||
Expanded(child: _buildPathList()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHero(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
_primary.withOpacity(0.85),
|
||||
_primary,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: widget.section.imageSource != null
|
||||
? Image.network(
|
||||
widget.section.imageSource!,
|
||||
width: double.infinity,
|
||||
height: 220,
|
||||
fit: BoxFit.cover,
|
||||
color: _primary.withOpacity(0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
errorBuilder: (_, __, ___) => const SizedBox(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
Container(
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black.withOpacity(0.45)],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 16,
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 20,
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_paths.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.92),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${_paths.length} parcours',
|
||||
style: TextStyle(
|
||||
color: _primary,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_sectionTitle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPathList() {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_paths.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.route_outlined, size: 48, color: _inkMuted),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Aucun parcours disponible',
|
||||
style: TextStyle(color: _inkMuted, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Text(
|
||||
'Choisir un parcours',
|
||||
style: TextStyle(
|
||||
color: _inkDark,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
itemCount: _paths.length,
|
||||
itemBuilder: (_, i) => _buildPathCard(_paths[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPathCard(GuidedPathDTO path) {
|
||||
final title = TranslationHelper.get(path.title, widget.visitAppContextIn);
|
||||
final desc = TranslationHelper.get(path.description, widget.visitAppContextIn);
|
||||
final stepCount = path.steps?.length ?? 0;
|
||||
final isGame = path.isGameMode == true;
|
||||
final durationMin = path.estimatedDurationMinutes;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onPathTap(path),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: _borderLight),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(19),
|
||||
bottomLeft: Radius.circular(19),
|
||||
),
|
||||
gradient: isGame
|
||||
? const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF1a2436), Color(0xFF0B1018)],
|
||||
)
|
||||
: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
_primary.withOpacity(0.8),
|
||||
_primary,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
isGame ? Icons.lock_outlined : Icons.route_outlined,
|
||||
color: isGame ? _gold : Colors.white.withOpacity(0.9),
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(13, 12, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isGame
|
||||
? _gold.withOpacity(0.15)
|
||||
: _primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
isGame ? 'ESCAPE GAME' : 'DÉCOUVERTE',
|
||||
style: TextStyle(
|
||||
color: isGame ? _gold : _primary,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: _inkDark,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
if (desc.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
desc,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: _inkMuted,
|
||||
fontSize: 12.5,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.flag_outlined, size: 12, color: _inkMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$stepCount étape${stepCount > 1 ? 's' : ''}',
|
||||
style: const TextStyle(
|
||||
color: _inkMuted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (durationMin != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
Icon(Icons.access_time_outlined, size: 12, color: _inkMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'~$durationMin min',
|
||||
style: const TextStyle(
|
||||
color: _inkMuted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 14),
|
||||
child: Icon(Icons.arrow_forward_ios,
|
||||
size: 14, color: _inkMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPathTap(GuidedPathDTO path) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _ParcoursPathStartSheet(
|
||||
path: path,
|
||||
section: widget.section,
|
||||
baseMap: _baseMap,
|
||||
visitAppContext: widget.visitAppContextIn,
|
||||
primaryColor: _primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParcoursPathStartSheet extends StatelessWidget {
|
||||
final GuidedPathDTO path;
|
||||
final ParcoursDTO section;
|
||||
final MapDTO? baseMap;
|
||||
final VisitAppContext visitAppContext;
|
||||
final Color primaryColor;
|
||||
|
||||
static const _bg = Color(0xFFF6F3EE);
|
||||
static const _inkDark = Color(0xFF1E2A33);
|
||||
static const _inkMid = Color(0xFF46555F);
|
||||
static const _inkMuted = Color(0xFF6B7B86);
|
||||
static const _gold = Color(0xFFD4AF37);
|
||||
|
||||
const _ParcoursPathStartSheet({
|
||||
Key? key,
|
||||
required this.path,
|
||||
required this.section,
|
||||
required this.baseMap,
|
||||
required this.visitAppContext,
|
||||
required this.primaryColor,
|
||||
}) : super(key: key);
|
||||
|
||||
bool get _isGame => path.isGameMode == true;
|
||||
bool get _showMap => section.showMap == true && baseMap != null;
|
||||
|
||||
String get _title => TranslationHelper.get(path.title, visitAppContext);
|
||||
String get _desc => TranslationHelper.get(path.description, visitAppContext);
|
||||
|
||||
String get _gameIntro {
|
||||
final msg = path.gameMessageDebut;
|
||||
if (msg == null || msg.isEmpty) return '';
|
||||
final found = msg.firstWhere(
|
||||
(m) => m.language == visitAppContext.language,
|
||||
orElse: () => msg.first,
|
||||
);
|
||||
return found.value ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _isGame ? _buildGameStart(context) : _buildNormalStart(context);
|
||||
}
|
||||
|
||||
Widget _buildNormalStart(BuildContext context) {
|
||||
final stepCount = path.steps?.length ?? 0;
|
||||
final durationMin = path.estimatedDurationMinutes;
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: _bg,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(26)),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 5,
|
||||
margin: const EdgeInsets.only(top: 11, bottom: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD8D2C8),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 180,
|
||||
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [primaryColor.withOpacity(0.7), primaryColor],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: CustomPaint(
|
||||
painter: _MapSketchPainter(primaryColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 14,
|
||||
top: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.92),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'DÉCOUVERTE LIBRE',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.4,
|
||||
color: Color(0xFF264863),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 14,
|
||||
child: Text(
|
||||
_title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (durationMin != null) ...[
|
||||
Icon(Icons.access_time, size: 15, color: primaryColor),
|
||||
const SizedBox(width: 5),
|
||||
Text('~$durationMin min',
|
||||
style: TextStyle(color: const Color(0xFF54636E), fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 18),
|
||||
],
|
||||
Icon(Icons.map_outlined, size: 15, color: primaryColor),
|
||||
const SizedBox(width: 5),
|
||||
Text('$stepCount étape${stepCount > 1 ? 's' : ''}',
|
||||
style: const TextStyle(color: Color(0xFF54636E), fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_desc.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||
child: Text(
|
||||
_desc,
|
||||
style: const TextStyle(
|
||||
color: _inkMid,
|
||||
fontSize: 15,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_startPath(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 17),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'Commencer le parcours',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGameStart(BuildContext context) {
|
||||
final stepCount = path.steps?.length ?? 0;
|
||||
final durationMin = path.estimatedDurationMinutes;
|
||||
final intro = _gameIntro;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: const Alignment(0, -0.4),
|
||||
radius: 1.2,
|
||||
colors: [const Color(0xFF16202e), const Color(0xFF0B1018)],
|
||||
),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(26)),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 34,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 5,
|
||||
margin: const EdgeInsets.only(top: 11, bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: _gold.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: _gold.withOpacity(0.5), width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _gold.withOpacity(0.2),
|
||||
blurRadius: 30,
|
||||
spreadRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.lock_open_outlined, color: _gold, size: 40),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const Text(
|
||||
'ESCAPE GAME',
|
||||
style: TextStyle(
|
||||
color: _gold,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Text(
|
||||
_title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFF4ECD8),
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (intro.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Text(
|
||||
'« $intro »',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFb9ac8c),
|
||||
fontSize: 15,
|
||||
fontStyle: FontStyle.italic,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('$stepCount énigme${stepCount > 1 ? 's' : ''}',
|
||||
style: const TextStyle(color: Color(0xFF8a8068), fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
if (durationMin != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Text('·', style: TextStyle(color: Color(0xFF8a8068))),
|
||||
const SizedBox(width: 8),
|
||||
Text('~$durationMin min',
|
||||
style: const TextStyle(color: Color(0xFF8a8068), fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_startPath(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _gold,
|
||||
foregroundColor: const Color(0xFF1a1305),
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
"Démarrer l'aventure",
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800, letterSpacing: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_isGame)
|
||||
const Text(
|
||||
'Activez le son pour une immersion totale',
|
||||
style: TextStyle(color: Color(0xFF6f6655), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _startPath(BuildContext context) {
|
||||
if (_showMap) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => GuidedPathMapProgressionPage(
|
||||
path: path,
|
||||
mapDTO: baseMap!,
|
||||
visitAppContext: visitAppContext,
|
||||
),
|
||||
));
|
||||
} else {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => GuidedPathContentProgressionPage(
|
||||
path: path,
|
||||
visitAppContext: visitAppContext,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MapSketchPainter extends CustomPainter {
|
||||
final Color primaryColor;
|
||||
_MapSketchPainter(this.primaryColor);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final bgPaint = Paint()
|
||||
..color = Colors.white.withOpacity(0.06)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final roadPaint = Paint()
|
||||
..color = Colors.white.withOpacity(0.12)
|
||||
..strokeWidth = 8
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final routePaint = Paint()
|
||||
..color = Colors.white.withOpacity(0.7)
|
||||
..strokeWidth = 2
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final road1 = Path()
|
||||
..moveTo(-10, size.height * 0.45)
|
||||
..lineTo(size.width * 0.35, size.height * 0.45)
|
||||
..lineTo(size.width * 0.55, size.height * 0.2)
|
||||
..lineTo(size.width + 10, size.height * 0.2);
|
||||
|
||||
final road2 = Path()
|
||||
..moveTo(size.width * 0.2, -10)
|
||||
..lineTo(size.width * 0.2, size.height * 0.55)
|
||||
..lineTo(size.width * 0.4, size.height + 10);
|
||||
|
||||
canvas.drawPath(road1, roadPaint);
|
||||
canvas.drawPath(road2, roadPaint);
|
||||
|
||||
final route = Path()
|
||||
..moveTo(size.width * 0.15, size.height * 0.65)
|
||||
..lineTo(size.width * 0.35, size.height * 0.35)
|
||||
..lineTo(size.width * 0.65, size.height * 0.55)
|
||||
..lineTo(size.width * 0.82, size.height * 0.35);
|
||||
|
||||
final dashPaint = Paint()
|
||||
..color = Colors.white.withOpacity(0.8)
|
||||
..strokeWidth = 2
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
_drawDashedPath(canvas, route, dashPaint, dashLength: 4, gapLength: 8);
|
||||
|
||||
final dotPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(Offset(size.width * 0.15, size.height * 0.65), 5, dotPaint);
|
||||
canvas.drawCircle(Offset(size.width * 0.82, size.height * 0.35), 5,
|
||||
dotPaint..color = const Color(0xFF2E9E6B));
|
||||
}
|
||||
|
||||
void _drawDashedPath(Canvas canvas, Path path, Paint paint,
|
||||
{required double dashLength, required double gapLength}) {
|
||||
final metrics = path.computeMetrics();
|
||||
for (final metric in metrics) {
|
||||
double distance = 0;
|
||||
while (distance < metric.length) {
|
||||
final start = distance;
|
||||
final end = (start + dashLength).clamp(0.0, metric.length);
|
||||
canvas.drawPath(metric.extractPath(start, end), paint);
|
||||
distance += dashLength + gapLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_MapSketchPainter old) => old.primaryColor != primaryColor;
|
||||
}
|
||||
@ -25,6 +25,7 @@ import 'package:mymuseum_visitapp/Screens/Sections/Video/video_page.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/Weather/weather_page.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/Event/event_page.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/Web/web_page.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Sections/Parcours/parcours_page.dart';
|
||||
import 'package:mymuseum_visitapp/app_context.dart';
|
||||
import 'package:mymuseum_visitapp/client.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@ -159,6 +160,9 @@ class _SectionPageState extends State<SectionPage> {
|
||||
case SectionType.Event:
|
||||
SectionEventDTO eventDTO = SectionEventDTO.fromJson(sectionResult)!;
|
||||
return EventPage(section: eventDTO, visitAppContextIn: visitAppContext);
|
||||
case SectionType.Parcours:
|
||||
ParcoursDTO parcoursDTO = ParcoursDTO.fromJson(sectionResult)!;
|
||||
return ParcoursPage(section: parcoursDTO, visitAppContextIn: visitAppContext);
|
||||
default:
|
||||
return const Center(child: Text("Unsupported type"));
|
||||
}
|
||||
|
||||
@ -15,11 +15,15 @@ class MyInfoMateLlmClient implements LlmClient {
|
||||
late final AssistantService _service;
|
||||
|
||||
MyInfoMateLlmClient({required this.visitAppContext}) {
|
||||
_service = AssistantService(visitAppContext: visitAppContext);
|
||||
_service = AssistantService(
|
||||
visitAppContext: visitAppContext,
|
||||
maxHistory: 6,
|
||||
inactivityTimeout: const Duration(minutes: 5),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> chat(
|
||||
Future<({String reply, bool expectsReply})> chat(
|
||||
String message, {
|
||||
String? configurationId,
|
||||
String languageCode = 'FR',
|
||||
@ -30,15 +34,13 @@ class MyInfoMateLlmClient implements LlmClient {
|
||||
'configId=$cfgId lang=$languageCode apiKey=${visitAppContext.apiKey != null ? "set" : "null"}');
|
||||
|
||||
try {
|
||||
// AppType.Mobile + isVoice:true → backend adapte le prompt (audio-friendly)
|
||||
// sans navigation, sans markdown, dates en toutes lettres
|
||||
final response = await _service.chatWithAppType(
|
||||
message: message,
|
||||
configurationId: cfgId,
|
||||
appType: AppType.Mobile,
|
||||
isVoice: true,
|
||||
);
|
||||
return response.reply;
|
||||
return (reply: response.reply, expectsReply: response.expectsReply);
|
||||
} catch (e) {
|
||||
debugPrint('[MyInfoMateLlmClient] error: $e');
|
||||
rethrow;
|
||||
|
||||
@ -7,12 +7,9 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
|
||||
|
||||
/// Wake word via OpenWakeWord — pipeline TFLite+ONNX natif Android.
|
||||
///
|
||||
/// Les modèles sont extraits des assets Flutter vers le cache natif
|
||||
/// avant de démarrer le foreground service (les assets Flutter ne sont
|
||||
/// pas accessibles directement depuis un Service Android en debug).
|
||||
/// Supporte plusieurs modèles en parallèle (même pipeline mel→embedding partagé).
|
||||
class NativeWakeWordEngine implements WakeWordEngine {
|
||||
final String modelName;
|
||||
final List<String> modelNames;
|
||||
|
||||
static const MethodChannel _channel =
|
||||
MethodChannel('be.unov.mymuseum/wake_word');
|
||||
@ -21,82 +18,86 @@ class NativeWakeWordEngine implements WakeWordEngine {
|
||||
|
||||
StreamSubscription? _subscription;
|
||||
bool _running = false;
|
||||
bool _nativeServiceAlive = false; // vrai si le foreground service tourne (même en pause)
|
||||
bool _nativeServiceAlive = false;
|
||||
|
||||
NativeWakeWordEngine({this.modelName = 'hey_visit'});
|
||||
NativeWakeWordEngine({
|
||||
List<String>? modelNames,
|
||||
String? modelName,
|
||||
}) : modelNames = modelNames ?? (modelName != null ? [modelName] : ['hey_visit']);
|
||||
|
||||
@override
|
||||
Future<void> start({
|
||||
required void Function() onDetected,
|
||||
void Function(String command)? onDetectedWithCommand,
|
||||
}) async {
|
||||
if (_subscription != null) {
|
||||
// Déjà abonné — rien à faire
|
||||
return;
|
||||
}
|
||||
if (_subscription != null) return;
|
||||
|
||||
if (_nativeServiceAlive) {
|
||||
// Service natif en pause — reprendre l'AudioRecord et se re-souscrire
|
||||
debugPrint('[NativeWakeWordEngine] Resuming native AudioRecord');
|
||||
try { await _channel.invokeMethod('resume'); } catch (_) {}
|
||||
_running = true;
|
||||
_subscription = _events.receiveBroadcastStream().listen((event) {
|
||||
if (event == 'detected') {
|
||||
debugPrint('[NativeWakeWordEngine] "$modelName" detected!');
|
||||
onDetected();
|
||||
}
|
||||
_handleEvent(event as String, onDetected, onDetectedWithCommand);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Permission micro requise avant de démarrer le foreground service (Android 14+)
|
||||
final micStatus = await Permission.microphone.request();
|
||||
if (!micStatus.isGranted) {
|
||||
debugPrint('[NativeWakeWordEngine] Microphone permission denied');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extraire les modèles vers le cache natif (accessible par le Service)
|
||||
await _extractModels();
|
||||
|
||||
try {
|
||||
await _channel.invokeMethod('start', {'modelName': modelName});
|
||||
await _channel.invokeMethod('start', {'modelNames': modelNames.join(',')});
|
||||
_running = true;
|
||||
_nativeServiceAlive = true;
|
||||
|
||||
_subscription = _events.receiveBroadcastStream().listen((event) {
|
||||
if (event == 'detected') {
|
||||
debugPrint('[NativeWakeWordEngine] "$modelName" detected!');
|
||||
onDetected();
|
||||
} else if (event == 'error') {
|
||||
debugPrint('[NativeWakeWordEngine] Service error — restarting listener');
|
||||
// Tente de relancer après une erreur
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (_running) start(onDetected: onDetected, onDetectedWithCommand: onDetectedWithCommand);
|
||||
});
|
||||
}
|
||||
_handleEvent(event as String, onDetected, onDetectedWithCommand);
|
||||
}, onError: (e) {
|
||||
debugPrint('[NativeWakeWordEngine] Event error: $e');
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (_running) start(onDetected: onDetected, onDetectedWithCommand: onDetectedWithCommand);
|
||||
});
|
||||
});
|
||||
|
||||
debugPrint('[NativeWakeWordEngine] Started — model: $modelName');
|
||||
debugPrint('[NativeWakeWordEngine] Started — models: $modelNames');
|
||||
} catch (e) {
|
||||
debugPrint('[NativeWakeWordEngine] start error: $e');
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause l'écoute côté Flutter ET libère l'AudioRecord natif pour que le STT puisse accéder au micro.
|
||||
/// Le foreground service reste vivant → le silent AudioTrack continue, MIUI ne mute pas.
|
||||
void _handleEvent(
|
||||
String event,
|
||||
void Function() onDetected,
|
||||
void Function(String)? onDetectedWithCommand,
|
||||
) {
|
||||
if (event.startsWith('detected:')) {
|
||||
final name = event.substring('detected:'.length);
|
||||
debugPrint('[NativeWakeWordEngine] "$name" detected!');
|
||||
if (onDetectedWithCommand != null) {
|
||||
onDetectedWithCommand(name);
|
||||
} else {
|
||||
onDetected();
|
||||
}
|
||||
} else if (event == 'error') {
|
||||
debugPrint('[NativeWakeWordEngine] Service error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
_running = false;
|
||||
// Libère l'AudioRecord pour le STT — service et silent track restent vivants
|
||||
try { await _channel.invokeMethod('pause'); } catch (_) {}
|
||||
debugPrint('[NativeWakeWordEngine] Paused — AudioRecord released for STT');
|
||||
}
|
||||
|
||||
/// Arrêt complet du service natif (appeler uniquement à la fermeture de l'app).
|
||||
Future<void> stopNativeService() async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
@ -106,30 +107,27 @@ class NativeWakeWordEngine implements WakeWordEngine {
|
||||
debugPrint('[NativeWakeWordEngine] Native service stopped');
|
||||
}
|
||||
|
||||
/// Copie les modèles depuis les assets Flutter vers le cache de l'app.
|
||||
/// Le Service Android peut y accéder via getCacheDir().
|
||||
Future<void> _extractModels() async {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final models = [
|
||||
final assets = [
|
||||
'assets/files/melspectrogram.onnx',
|
||||
'assets/files/embedding_model.tflite',
|
||||
'assets/files/$modelName.tflite',
|
||||
...modelNames.map((n) => 'assets/files/$n.tflite'),
|
||||
];
|
||||
|
||||
for (final assetPath in models) {
|
||||
for (final assetPath in assets) {
|
||||
final fileName = assetPath.split('/').last;
|
||||
final dest = File('${dir.path}/$fileName');
|
||||
if (dest.existsSync()) continue; // déjà extrait
|
||||
if (dest.existsSync()) continue;
|
||||
try {
|
||||
final data = await rootBundle.load(assetPath);
|
||||
await dest.writeAsBytes(data.buffer.asUint8List(), flush: true);
|
||||
debugPrint('[NativeWakeWordEngine] Extracted: $fileName → ${dir.path}');
|
||||
debugPrint('[NativeWakeWordEngine] Extracted: $fileName');
|
||||
} catch (e) {
|
||||
debugPrint('[NativeWakeWordEngine] Failed to extract $fileName: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Passe le chemin du cache au service natif
|
||||
await _channel.invokeMethod('setCacheDir', {'path': dir.path});
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,23 +4,30 @@ import 'package:flutter/services.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
|
||||
|
||||
/// Wake word via OpenWakeWord — service Android natif (foreground service).
|
||||
/// Utilise ONNX Runtime pour l'inférence on-device du modèle hey_visit.onnx.
|
||||
/// Survit au background Android contrairement au STT continu.
|
||||
/// Supporte plusieurs modèles en parallèle : un seul AudioRecord + pipeline
|
||||
/// mel→embedding partagée, N classifieurs TFLite tournant simultanément.
|
||||
///
|
||||
/// iOS non supporté — fallback sur SpeechToTextWakeWordEngine côté iOS.
|
||||
/// Événements reçus : "detected:hey_marco", "detected:hey_alba", etc.
|
||||
/// → onDetectedWithCommand("hey_marco") si fourni, sinon onDetected()
|
||||
///
|
||||
/// iOS non supporté — fallback sur SpeechToTextWakeWordEngine.
|
||||
class OpenWakeWordEngine implements WakeWordEngine {
|
||||
static const _methodChannel = MethodChannel('be.unov.mymuseum/wake_word');
|
||||
static const _eventChannel = EventChannel('be.unov.mymuseum/wake_word_events');
|
||||
|
||||
/// Nom du fichier .tflite dans assets/files (sans extension).
|
||||
/// Ex: "hey_visit", "hey_museum", "hey_guide"
|
||||
final String modelName;
|
||||
/// Un ou plusieurs modèles .tflite dans assets/files (sans extension).
|
||||
/// Ex: ['hey_marco'] ou ['hey_alba', 'hey_marco', 'hey_vasco']
|
||||
final List<String> modelNames;
|
||||
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
void Function()? _onDetected;
|
||||
void Function(String modelName)? _onModelDetected;
|
||||
bool _running = false;
|
||||
|
||||
OpenWakeWordEngine({this.modelName = 'hey_visit'});
|
||||
OpenWakeWordEngine({
|
||||
List<String>? modelNames,
|
||||
String? modelName,
|
||||
}) : modelNames = modelNames ?? (modelName != null ? [modelName] : ['hey_visit']);
|
||||
|
||||
@override
|
||||
Future<void> start({
|
||||
@ -34,22 +41,29 @@ class OpenWakeWordEngine implements WakeWordEngine {
|
||||
}
|
||||
|
||||
_onDetected = onDetected;
|
||||
_onModelDetected = onDetectedWithCommand;
|
||||
_running = true;
|
||||
|
||||
_subscription = _eventChannel.receiveBroadcastStream().listen(
|
||||
(event) {
|
||||
if (event == 'detected' && _running) {
|
||||
debugPrint('[OpenWakeWordEngine] Wake word detected');
|
||||
// OpenWakeWord ne capture pas la commande inline — l'orchestrateur
|
||||
// lance un cycle STT séparé via onDetected()
|
||||
_onDetected?.call();
|
||||
if (!_running || event is! String) return;
|
||||
if (event.startsWith('detected:')) {
|
||||
final name = event.substring('detected:'.length);
|
||||
debugPrint('[OpenWakeWordEngine] Wake word detected: $name');
|
||||
if (_onModelDetected != null) {
|
||||
_onModelDetected!(name);
|
||||
} else {
|
||||
_onDetected?.call();
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (e) => debugPrint('[OpenWakeWordEngine] Event error: $e'),
|
||||
);
|
||||
|
||||
await _methodChannel.invokeMethod('start', {'modelName': modelName});
|
||||
debugPrint('[OpenWakeWordEngine] Service started (model: $modelName)');
|
||||
await _methodChannel.invokeMethod('start', {
|
||||
'modelNames': modelNames.join(','),
|
||||
});
|
||||
debugPrint('[OpenWakeWordEngine] Service started (models: $modelNames)');
|
||||
}
|
||||
|
||||
@override
|
||||
@ -58,9 +72,7 @@ class OpenWakeWordEngine implements WakeWordEngine {
|
||||
_running = false;
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
if (_isAndroid) {
|
||||
await _methodChannel.invokeMethod('stop');
|
||||
}
|
||||
if (_isAndroid) await _methodChannel.invokeMethod('stop');
|
||||
debugPrint('[OpenWakeWordEngine] Service stopped');
|
||||
}
|
||||
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
/// - ClaudeClient (Anthropic API — upgrade futur)
|
||||
/// - OpenAiClient (upgrade futur)
|
||||
abstract class LlmClient {
|
||||
/// Envoie un message et retourne la réponse complète.
|
||||
Future<String> chat(
|
||||
/// Envoie un message et retourne la réponse avec un flag indiquant si le
|
||||
/// visiteur est attendu à répondre (false = info pure, fin de conversation).
|
||||
Future<({String reply, bool expectsReply})> chat(
|
||||
String message, {
|
||||
String? configurationId,
|
||||
String languageCode = 'FR',
|
||||
|
||||
@ -1,502 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/llm_client.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/stt_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/tts_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||
// Renommé en VoiceOrchestrator — ce fichier reste pour la compatibilité des imports existants.
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/voice_orchestrator.dart';
|
||||
export 'voice_orchestrator.dart';
|
||||
|
||||
/// Instance active de l'orchestrateur, accessible globalement.
|
||||
/// Initialisée dans main.dart après la connexion lunettes.
|
||||
GlassesOrchestrator? activeOrchestrator;
|
||||
|
||||
/// Orchestre le pipeline complet mains-libres :
|
||||
/// WakeWord → STT → dispatch → LLM ou QR scan → TTS
|
||||
///
|
||||
/// Toutes les dépendances sont injectées via les interfaces abstraites
|
||||
/// pour pouvoir swapper chaque maillon indépendamment.
|
||||
class GlassesOrchestrator {
|
||||
final VisitAppContext visitAppContext;
|
||||
final WakeWordEngine wakeWordEngine;
|
||||
final SttEngine sttEngine;
|
||||
final TtsEngine ttsEngine;
|
||||
final LlmClient llmClient;
|
||||
|
||||
bool _running = false;
|
||||
bool _inConversation = false;
|
||||
|
||||
/// Dernière transcription capturée — observable depuis l'UI.
|
||||
final ValueNotifier<String> lastTranscription = ValueNotifier('');
|
||||
final ValueNotifier<bool> isListeningForCommand = ValueNotifier(false);
|
||||
/// Dernier texte envoyé au TTS — pour détecter les astérisques et autres artefacts.
|
||||
final ValueNotifier<String> lastTtsText = ValueNotifier('');
|
||||
|
||||
// Photos de visite (V2 — stockées pour un résumé en fin de visite)
|
||||
final List<String> visitPhotos = [];
|
||||
|
||||
// Sons de feedback — fichiers courts dans assets/sounds/
|
||||
// wake_detected.mp3 : ~0.3s — "je t'écoute"
|
||||
// thinking.mp3 : ~0.5s — "je réfléchis"
|
||||
// done.mp3 : ~0.3s — "réponse prête" (optionnel)
|
||||
static const String _wakeSound = 'assets/sounds/wake_detected.mp3';
|
||||
static const String _thinkingSound = 'assets/sounds/thinking.mp3';
|
||||
static const String _doneSound = 'assets/sounds/done.mp3';
|
||||
final AudioPlayer _soundPlayer = AudioPlayer(); // sons one-shot
|
||||
final AudioPlayer _thinkingPlayer = AudioPlayer(); // thinking loop
|
||||
|
||||
// Anti-spam QR
|
||||
final Map<String, int> _lastQrTime = {};
|
||||
static const int _qrCooldownMs = 10000;
|
||||
|
||||
static final RegExp _urlPattern1 =
|
||||
RegExp(r'https://web\.mymuseum\.be/([^/]+)/([^/]+)/([^/\s]+)');
|
||||
static final RegExp _urlPattern2 =
|
||||
RegExp(r'https://web\.myinfomate\.be/([^/]+)/([^/]+)/([^/\s]+)');
|
||||
|
||||
GlassesOrchestrator({
|
||||
required this.visitAppContext,
|
||||
required this.wakeWordEngine,
|
||||
required this.sttEngine,
|
||||
required this.ttsEngine,
|
||||
required this.llmClient,
|
||||
});
|
||||
|
||||
Future<void> start() async {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
|
||||
await wakeWordEngine.start(
|
||||
onDetected: _onWakeWord,
|
||||
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||
);
|
||||
debugPrint('[GlassesOrchestrator] Started');
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await wakeWordEngine.stop();
|
||||
await sttEngine.cancel();
|
||||
await ttsEngine.stop();
|
||||
_running = false;
|
||||
debugPrint('[GlassesOrchestrator] Stopped');
|
||||
}
|
||||
|
||||
bool get isRunning => _running;
|
||||
bool get isInConversation => _inConversation;
|
||||
bool get isListening => _running && !_inConversation;
|
||||
|
||||
/// Relance l'écoute wake word en utilisant les callbacks internes
|
||||
/// (avec son de détection). À appeler depuis le lifecycle observer.
|
||||
Future<void> restartWakeWord() async {
|
||||
if (!_running || _inConversation) return;
|
||||
await wakeWordEngine.start(
|
||||
onDetected: _onWakeWord,
|
||||
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||
);
|
||||
}
|
||||
|
||||
/// Déclenchement manuel (ex: bouton debug, test)
|
||||
Future<void> triggerConversation() => _handleConversation();
|
||||
|
||||
/// Dispatch direct d'une commande (utilisé par le lifecycle observer)
|
||||
Future<void> dispatchCommand(String command) => _dispatch(command);
|
||||
|
||||
/// Déclenchement scan QR depuis un chemin d'image existant.
|
||||
Future<void> triggerQrScan(String imagePath) async {
|
||||
final qr = await _tryDecodeQr(imagePath);
|
||||
if (qr != null) await explainSection(qr.sectionId, configurationId: qr.configId);
|
||||
}
|
||||
|
||||
// ── Wake word ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Déclenché quand le wake word est détecté sans commande inline.
|
||||
/// Lance un cycle STT séparé pour capturer la commande.
|
||||
void _onWakeWord() async {
|
||||
if (_inConversation) return;
|
||||
_inConversation = true;
|
||||
await wakeWordEngine.stop(); // envoie ACTION_PAUSE immédiatement — AudioRecord s'arrête pendant le son
|
||||
await _stopThinkingLoop();
|
||||
await _playWakeSound(); // ~300ms — largement suffisant pour que l'AudioRecord soit libéré
|
||||
try {
|
||||
await _handleConversation();
|
||||
} finally {
|
||||
_inConversation = false;
|
||||
if (_running) await wakeWordEngine.start(
|
||||
onDetected: _onWakeWord,
|
||||
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Déclenché quand le wake word ET la commande sont dans le même énoncé.
|
||||
/// "visite qu'est-ce que c'est" → commande = "qu'est-ce que c'est"
|
||||
/// Si commande vide → fallback sur cycle STT séparé.
|
||||
void _onWakeWordWithCommand(String inlineCommand) async {
|
||||
if (_inConversation) return;
|
||||
_inConversation = true;
|
||||
await wakeWordEngine.stop();
|
||||
await _stopThinkingLoop();
|
||||
await _playWakeSound();
|
||||
try {
|
||||
if (inlineCommand.isNotEmpty) {
|
||||
debugPrint('[GlassesOrchestrator] Inline command: "$inlineCommand"');
|
||||
await _dispatch(inlineCommand);
|
||||
} else {
|
||||
await _handleConversation();
|
||||
}
|
||||
} finally {
|
||||
_inConversation = false;
|
||||
if (_running) await wakeWordEngine.start(
|
||||
onDetected: _onWakeWord,
|
||||
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playSound(String asset) async {
|
||||
try {
|
||||
await _soundPlayer.setAsset(asset);
|
||||
await _soundPlayer.play();
|
||||
// play() se complète quand la lecture se termine
|
||||
} catch (_) {
|
||||
debugPrint('[GlassesOrchestrator] Sound not found: $asset');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playWakeSound() => _playSound(_wakeSound);
|
||||
Future<void> _playDoneSound() => _playSound(_doneSound);
|
||||
|
||||
Future<void> _startThinkingLoop() async {
|
||||
try {
|
||||
await _thinkingPlayer.setAsset(_thinkingSound);
|
||||
await _thinkingPlayer.setLoopMode(LoopMode.one);
|
||||
_thinkingPlayer.play(); // pas de await — tourne en arrière-plan
|
||||
} catch (_) {
|
||||
debugPrint('[GlassesOrchestrator] Thinking sound not found');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopThinkingLoop() async {
|
||||
await _thinkingPlayer.stop();
|
||||
}
|
||||
|
||||
// ── Conversation vocale ────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _handleConversation() async {
|
||||
final lang = visitAppContext.language ?? 'FR';
|
||||
final langCode = _toLangCode(lang);
|
||||
isListeningForCommand.value = true;
|
||||
final command = await sttEngine.transcribeOnce(languageCode: langCode);
|
||||
isListeningForCommand.value = false;
|
||||
debugPrint('[GlassesOrchestrator] Command: "$command"');
|
||||
if (command.isEmpty) return;
|
||||
lastTranscription.value = command;
|
||||
await _dispatch(command);
|
||||
}
|
||||
|
||||
Future<void> _dispatch(String command, {bool continueConversation = true}) async {
|
||||
final lang = visitAppContext.language ?? 'FR';
|
||||
final langCode = _toLangCode(lang);
|
||||
|
||||
if (_isStopCommand(command)) {
|
||||
debugPrint('[GlassesOrchestrator] Annulé par l\'utilisateur: "$command"');
|
||||
await _stopThinkingLoop();
|
||||
// Pas de son pour l'annulation — évite une bascule audio focus supplémentaire
|
||||
// qui déclenche le muting persistant MIUI
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isQrScanCommand(command)) {
|
||||
await _handleQrScan();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isPhotoCommand(command)) {
|
||||
await _handlePhotoCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isRepeatCommand(command)) {
|
||||
await ttsEngine.replay();
|
||||
} else {
|
||||
// Question libre → thinking loop → LLM → done → TTS
|
||||
try {
|
||||
await _startThinkingLoop();
|
||||
final reply = await llmClient.chat(
|
||||
command,
|
||||
configurationId: visitAppContext.configuration?.id,
|
||||
languageCode: lang,
|
||||
);
|
||||
await _stopThinkingLoop();
|
||||
if (reply.isNotEmpty) {
|
||||
lastTtsText.value = reply;
|
||||
await _playDoneSound();
|
||||
await ttsEngine.speak(reply, languageCode: langCode);
|
||||
}
|
||||
} catch (e) {
|
||||
await _stopThinkingLoop();
|
||||
debugPrint('[GlassesOrchestrator] LLM error: $e');
|
||||
return; // pas de follow-up si erreur
|
||||
}
|
||||
}
|
||||
|
||||
// Mode conversation : écoute directement la réponse sans redemander le wake word
|
||||
if (continueConversation) {
|
||||
await _listenForFollowUp();
|
||||
}
|
||||
}
|
||||
|
||||
/// Écoute une question de suivi après la réponse TTS.
|
||||
/// Timeout = 5s de silence → fin de conversation, retour au wake word.
|
||||
Future<void> _listenForFollowUp() async {
|
||||
final lang = visitAppContext.language ?? 'FR';
|
||||
final langCode = _toLangCode(lang);
|
||||
|
||||
isListeningForCommand.value = true;
|
||||
final followUp = await sttEngine.transcribeOnce(
|
||||
languageCode: langCode,
|
||||
timeout: const Duration(seconds: 5),
|
||||
);
|
||||
isListeningForCommand.value = false;
|
||||
|
||||
if (followUp.isNotEmpty) lastTranscription.value = followUp;
|
||||
|
||||
if (followUp.isEmpty || _isStopCommand(followUp)) {
|
||||
debugPrint('[GlassesOrchestrator] Conversation ended (silence or stop)');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('[GlassesOrchestrator] Follow-up: "$followUp"');
|
||||
await _dispatch(followUp, continueConversation: true);
|
||||
}
|
||||
|
||||
// ── QR scan depuis frames du stream ──────────────────────────────────────
|
||||
|
||||
/// Démarre le stream, prend jusqu'à 5 frames espacées de 400ms,
|
||||
/// tente de décoder un QR sur chacune. Stoppe le stream après.
|
||||
Future<void> _handleQrScan() async {
|
||||
final lang = _toLangCode(visitAppContext.language ?? 'FR');
|
||||
debugPrint('[QrScan] Starting — requesting photo capture...');
|
||||
final completer = Completer<String?>();
|
||||
|
||||
final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
|
||||
MetaGlassesService.instance.onPhotoCaptured = (path) {
|
||||
debugPrint('[QrScan] Photo callback received — path="${path.isEmpty ? "EMPTY/ERROR" : path}"');
|
||||
if (!completer.isCompleted) completer.complete(path.isEmpty ? null : path);
|
||||
prevCallback?.call(path);
|
||||
};
|
||||
Timer(const Duration(seconds: 10), () {
|
||||
if (!completer.isCompleted) {
|
||||
debugPrint('[QrScan] Timeout — no photo received after 10s');
|
||||
completer.complete(null);
|
||||
}
|
||||
});
|
||||
|
||||
await MetaGlassesService.instance.requestPhotoCapture();
|
||||
debugPrint('[QrScan] requestPhotoCapture() returned — waiting for callback...');
|
||||
final photoPath = await completer.future;
|
||||
MetaGlassesService.instance.onPhotoCaptured = prevCallback;
|
||||
|
||||
if (photoPath == null) {
|
||||
debugPrint('[QrScan] No photo — camera unavailable or error');
|
||||
lastTtsText.value = TranslationHelper.getFromLocale('voice.cameraUnavailable', visitAppContext);
|
||||
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('[QrScan] Photo received at $photoPath — decoding QR...');
|
||||
final qr = await _tryDecodeQr(photoPath);
|
||||
try { File(photoPath).deleteSync(); } catch (_) {}
|
||||
|
||||
if (qr != null) {
|
||||
debugPrint('[QrScan] QR found — sectionId=${qr.sectionId} configId=${qr.configId}');
|
||||
await explainSection(qr.sectionId, configurationId: qr.configId);
|
||||
} else {
|
||||
debugPrint('[QrScan] No QR code found in photo');
|
||||
lastTtsText.value = TranslationHelper.getFromLocale('voice.noQrFound', visitAppContext);
|
||||
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Photo capture : mémoire visite ────────────────────────────────────────
|
||||
|
||||
/// Capture une photo, essaie de décoder un QR code.
|
||||
/// Si QR valide → explique la section.
|
||||
/// Si pas de QR → sauvegarde la photo pour la visite (V2).
|
||||
Future<void> _handlePhotoCapture() async {
|
||||
final completer = Completer<String?>();
|
||||
|
||||
// Écoute la photo quand elle arrive
|
||||
final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
|
||||
MetaGlassesService.instance.onPhotoCaptured = (path) {
|
||||
if (!completer.isCompleted) completer.complete(path);
|
||||
prevCallback?.call(path);
|
||||
};
|
||||
|
||||
// Timeout si pas de photo en 10s
|
||||
Timer(const Duration(seconds: 10), () {
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
});
|
||||
|
||||
await MetaGlassesService.instance.requestPhotoCapture();
|
||||
final photoPath = await completer.future;
|
||||
|
||||
// Restaure le callback précédent
|
||||
MetaGlassesService.instance.onPhotoCaptured = prevCallback;
|
||||
|
||||
if (photoPath == null || photoPath.isEmpty) {
|
||||
debugPrint('[GlassesOrchestrator] Photo capture failed or timeout');
|
||||
final lang = _toLangCode(visitAppContext.language ?? 'FR');
|
||||
lastTtsText.value = TranslationHelper.getFromLocale('voice.photoFailed', visitAppContext);
|
||||
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||
return;
|
||||
}
|
||||
|
||||
// Essaie de décoder un QR code
|
||||
final qr = await _tryDecodeQr(photoPath);
|
||||
|
||||
if (qr != null) {
|
||||
await explainSection(qr.sectionId, configurationId: qr.configId);
|
||||
} else {
|
||||
// Pas de QR — sauvegarde pour la visite
|
||||
visitPhotos.add(photoPath);
|
||||
debugPrint('[GlassesOrchestrator] Photo saved for visit (no QR): $photoPath');
|
||||
// V2 : résumé en fin de visite, identification d'œuvre, etc.
|
||||
// Pour l'instant : feedback vocal simple
|
||||
final lang = _toLangCode(visitAppContext.language ?? 'FR');
|
||||
lastTtsText.value = TranslationHelper.getFromLocale('voice.photoCaptured', visitAppContext);
|
||||
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||
}
|
||||
}
|
||||
|
||||
/// Essaie de décoder un QR code depuis l'image.
|
||||
/// Retourne le sectionId si trouvé, null sinon.
|
||||
Future<({String sectionId, String? configId})?> _tryDecodeQr(String imagePath) async {
|
||||
debugPrint('[QrScan] analyzeImage: $imagePath');
|
||||
final controller = MobileScannerController();
|
||||
({String sectionId, String? configId})? result;
|
||||
try {
|
||||
final completer = Completer<({String sectionId, String? configId})?>();
|
||||
final sub = controller.barcodes.listen((capture) {
|
||||
debugPrint('[QrScan] barcodes detected: ${capture.barcodes.length}');
|
||||
for (final barcode in capture.barcodes) {
|
||||
final raw = barcode.rawValue;
|
||||
debugPrint('[QrScan] raw value: "$raw"');
|
||||
if (raw != null) {
|
||||
final ids = _extractQrIds(raw);
|
||||
debugPrint('[QrScan] extracted: sectionId=${ids?.sectionId} configId=${ids?.configId}');
|
||||
if (ids != null && !completer.isCompleted) completer.complete(ids);
|
||||
}
|
||||
}
|
||||
});
|
||||
await controller.analyzeImage(imagePath);
|
||||
Timer(const Duration(seconds: 2), () {
|
||||
if (!completer.isCompleted) {
|
||||
debugPrint('[QrScan] analyzeImage timeout — no barcode found');
|
||||
completer.complete(null);
|
||||
}
|
||||
});
|
||||
result = await completer.future;
|
||||
await sub.cancel();
|
||||
} catch (e) {
|
||||
debugPrint('[QrScan] decode error: $e');
|
||||
} finally {
|
||||
controller.dispose();
|
||||
}
|
||||
debugPrint('[QrScan] result: ${result != null ? "found sectionId=${result.sectionId}" : "null"}');
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Retourne (sectionId, configId) extraits du QR.
|
||||
/// configId peut être null si le QR est un ID brut sans URL.
|
||||
({String sectionId, String? configId})? _extractQrIds(String raw) {
|
||||
final m1 = _urlPattern1.firstMatch(raw);
|
||||
if (m1 != null) return (sectionId: m1.group(3)!, configId: m1.group(2));
|
||||
final m2 = _urlPattern2.firstMatch(raw);
|
||||
if (m2 != null) return (sectionId: m2.group(3)!, configId: m2.group(2));
|
||||
// ID brut — valider contre la config si disponible
|
||||
if (visitAppContext.sectionIds != null) {
|
||||
return visitAppContext.sectionIds!.contains(raw)
|
||||
? (sectionId: raw, configId: visitAppContext.configuration?.id)
|
||||
: null;
|
||||
}
|
||||
// Pas de config chargée — accepter l'ID brut sans configId
|
||||
return (sectionId: raw, configId: null);
|
||||
}
|
||||
|
||||
Future<void> explainSection(String sectionId, {String? configurationId}) async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if ((now - (_lastQrTime[sectionId] ?? 0)) < _qrCooldownMs) return;
|
||||
_lastQrTime[sectionId] = now;
|
||||
|
||||
// Priorité : configId passé explicitement (extrait du QR URL) > config active > null (mode instance)
|
||||
final cfgId = configurationId ?? visitAppContext.configuration?.id;
|
||||
final lang = visitAppContext.language ?? 'FR';
|
||||
try {
|
||||
final reply = await llmClient.chat(
|
||||
'Le visiteur vient de scanner le QR code de la section "$sectionId". '
|
||||
'Appelle GetSectionDetail avec cet ID, puis présente le contenu de façon engageante en 2-3 phrases. '
|
||||
'Utilise les informations réelles du champ Contenu — cite des détails concrets, pas de généralités.',
|
||||
configurationId: cfgId,
|
||||
languageCode: lang,
|
||||
);
|
||||
if (reply.isNotEmpty) {
|
||||
lastTtsText.value = reply;
|
||||
await ttsEngine.speak(reply, languageCode: _toLangCode(lang));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[GlassesOrchestrator] explainSection error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
bool _isStopCommand(String text) {
|
||||
final t = text.toLowerCase().trim();
|
||||
return t == 'non' ||
|
||||
t == 'rien' ||
|
||||
t == 'non rien' ||
|
||||
t == 'rien merci' ||
|
||||
t == 'non merci' ||
|
||||
t == 'laisse tomber' ||
|
||||
t == 'annule' ||
|
||||
t == 'annuler' ||
|
||||
t.contains('stop') ||
|
||||
t.contains('arrête') ||
|
||||
t.contains('au revoir') ||
|
||||
t.contains('c\'est bon') ||
|
||||
t.contains('ok merci') ||
|
||||
t.contains('laisse tomber') ||
|
||||
(t.length < 10 && (t.contains('non') || t.contains('rien')));
|
||||
}
|
||||
|
||||
bool _isQrScanCommand(String text) {
|
||||
final t = text.toLowerCase();
|
||||
return t.contains('scan') || t.contains('qr') || t.contains('code') ||
|
||||
t.contains('regarde');
|
||||
}
|
||||
|
||||
bool _isPhotoCommand(String text) {
|
||||
final t = text.toLowerCase();
|
||||
return t.contains('photo') || t.contains('prends') || t.contains('capture');
|
||||
}
|
||||
|
||||
bool _isRepeatCommand(String text) {
|
||||
final t = text.toLowerCase();
|
||||
return t.contains('répète') || t.contains('repete') || t.contains('encore');
|
||||
}
|
||||
|
||||
String _toLangCode(String lang) {
|
||||
switch (lang.toUpperCase()) {
|
||||
case 'FR': return 'fr-FR';
|
||||
case 'NL': return 'nl-NL';
|
||||
case 'EN': return 'en-US';
|
||||
case 'DE': return 'de-DE';
|
||||
default: return 'fr-FR';
|
||||
}
|
||||
}
|
||||
}
|
||||
// Alias de compatibilité
|
||||
typedef GlassesOrchestrator = VoiceOrchestrator;
|
||||
|
||||
408
lib/Services/Glasses/voice_orchestrator.dart
Normal file
408
lib/Services/Glasses/voice_orchestrator.dart
Normal file
@ -0,0 +1,408 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/llm_client.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/stt_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/tts_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||
|
||||
/// Instance active de l'orchestrateur, accessible globalement.
|
||||
/// Gérée par VoiceController — ne pas modifier directement.
|
||||
VoiceOrchestrator? activeVoiceOrchestrator;
|
||||
|
||||
/// Orchestre le pipeline complet mains-libres :
|
||||
/// WakeWord → STT → dispatch → LLM ou QR scan → TTS
|
||||
///
|
||||
/// Toutes les dépendances sont injectées via les interfaces abstraites
|
||||
/// pour pouvoir swapper chaque maillon indépendamment.
|
||||
class VoiceOrchestrator {
|
||||
final VisitAppContext visitAppContext;
|
||||
final WakeWordEngine wakeWordEngine;
|
||||
final SttEngine sttEngine;
|
||||
final TtsEngine ttsEngine;
|
||||
final LlmClient llmClient;
|
||||
|
||||
bool _running = false;
|
||||
bool _inConversation = false;
|
||||
|
||||
final ValueNotifier<String> lastTranscription = ValueNotifier('');
|
||||
final ValueNotifier<bool> isListeningForCommand = ValueNotifier(false);
|
||||
final ValueNotifier<String> lastTtsText = ValueNotifier('');
|
||||
final ValueNotifier<List<String>> visitPhotosNotifier = ValueNotifier([]);
|
||||
final ValueNotifier<String?> lastQrScanPhoto = ValueNotifier(null);
|
||||
|
||||
final List<String> visitPhotos = [];
|
||||
|
||||
static const String _wakeSound = 'assets/sounds/wake_detected.mp3';
|
||||
static const String _thinkingSound = 'assets/sounds/thinking.mp3';
|
||||
static const String _doneSound = 'assets/sounds/done.mp3';
|
||||
final AudioPlayer _soundPlayer = AudioPlayer();
|
||||
final AudioPlayer _thinkingPlayer = AudioPlayer();
|
||||
|
||||
final Map<String, int> _lastQrTime = {};
|
||||
static const int _qrCooldownMs = 10000;
|
||||
|
||||
static final RegExp _urlPattern1 =
|
||||
RegExp(r'https://web\.mymuseum\.be/([^/]+)/([^/]+)/([^/\s]+)');
|
||||
static final RegExp _urlPattern2 =
|
||||
RegExp(r'https://web\.myinfomate\.be/([^/]+)/([^/]+)/([^/\s]+)');
|
||||
|
||||
VoiceOrchestrator({
|
||||
required this.visitAppContext,
|
||||
required this.wakeWordEngine,
|
||||
required this.sttEngine,
|
||||
required this.ttsEngine,
|
||||
required this.llmClient,
|
||||
});
|
||||
|
||||
Future<void> start() async {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
await wakeWordEngine.start(
|
||||
onDetected: _onWakeWord,
|
||||
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||
);
|
||||
debugPrint('[VoiceOrchestrator] Started');
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await wakeWordEngine.stop();
|
||||
await sttEngine.cancel();
|
||||
await ttsEngine.stop();
|
||||
_running = false;
|
||||
debugPrint('[VoiceOrchestrator] Stopped');
|
||||
}
|
||||
|
||||
bool get isRunning => _running;
|
||||
bool get isInConversation => _inConversation;
|
||||
bool get isListening => _running && !_inConversation;
|
||||
|
||||
Future<void> restartWakeWord() async {
|
||||
if (!_running || _inConversation) return;
|
||||
await wakeWordEngine.start(
|
||||
onDetected: _onWakeWord,
|
||||
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> triggerConversation() => _handleConversation();
|
||||
Future<void> dispatchCommand(String command) => _dispatch(command);
|
||||
|
||||
Future<void> triggerQrScan(String imagePath) async {
|
||||
final qr = await _tryDecodeQr(imagePath);
|
||||
if (qr != null) await explainSection(qr.sectionId, configurationId: qr.configId);
|
||||
}
|
||||
|
||||
// ── Wake word ──────────────────────────────────────────────────────────────
|
||||
|
||||
void _onWakeWord() async {
|
||||
if (_inConversation) return;
|
||||
_inConversation = true;
|
||||
await wakeWordEngine.stop();
|
||||
await _stopThinkingLoop();
|
||||
_playWakeSound();
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
try {
|
||||
await _handleConversation();
|
||||
} finally {
|
||||
_inConversation = false;
|
||||
if (_running) await wakeWordEngine.start(
|
||||
onDetected: _onWakeWord,
|
||||
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onWakeWordWithCommand(String inlineCommand) async {
|
||||
if (_inConversation) return;
|
||||
_inConversation = true;
|
||||
await wakeWordEngine.stop();
|
||||
await _stopThinkingLoop();
|
||||
_playWakeSound();
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
try {
|
||||
if (inlineCommand.isNotEmpty) {
|
||||
debugPrint('[VoiceOrchestrator] Inline command: "$inlineCommand"');
|
||||
await _dispatch(inlineCommand);
|
||||
} else {
|
||||
await _handleConversation();
|
||||
}
|
||||
} finally {
|
||||
_inConversation = false;
|
||||
if (_running) await wakeWordEngine.start(
|
||||
onDetected: _onWakeWord,
|
||||
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playSound(String asset) async {
|
||||
try {
|
||||
await _soundPlayer.setAsset(asset);
|
||||
await _soundPlayer.play();
|
||||
} catch (_) {
|
||||
debugPrint('[VoiceOrchestrator] Sound not found: $asset');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playWakeSound() => _playSound(_wakeSound);
|
||||
Future<void> _playDoneSound() => _playSound(_doneSound);
|
||||
|
||||
Future<void> _startThinkingLoop() async {
|
||||
try {
|
||||
await _thinkingPlayer.setAsset(_thinkingSound);
|
||||
await _thinkingPlayer.setLoopMode(LoopMode.one);
|
||||
_thinkingPlayer.play();
|
||||
} catch (_) {
|
||||
debugPrint('[VoiceOrchestrator] Thinking sound not found');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopThinkingLoop() async {
|
||||
await _thinkingPlayer.stop();
|
||||
}
|
||||
|
||||
// ── Conversation vocale ────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _handleConversation() async {
|
||||
final lang = visitAppContext.language ?? 'FR';
|
||||
final langCode = _toLangCode(lang);
|
||||
isListeningForCommand.value = true;
|
||||
final command = await sttEngine.transcribeOnce(languageCode: langCode);
|
||||
isListeningForCommand.value = false;
|
||||
debugPrint('[VoiceOrchestrator] Command: "$command"');
|
||||
if (command.isEmpty) return;
|
||||
lastTranscription.value = command;
|
||||
await _dispatch(command);
|
||||
}
|
||||
|
||||
Future<void> _dispatch(String command, {bool continueConversation = true}) async {
|
||||
final lang = visitAppContext.language ?? 'FR';
|
||||
final langCode = _toLangCode(lang);
|
||||
|
||||
if (_isStopCommand(command)) {
|
||||
debugPrint('[VoiceOrchestrator] Annulé: "$command"');
|
||||
await _stopThinkingLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isQrScanCommand(command)) { await _handleQrScan(); return; }
|
||||
if (_isPhotoCommand(command)) { await _handlePhotoCapture(); return; }
|
||||
|
||||
if (_isRepeatCommand(command)) {
|
||||
await ttsEngine.replay();
|
||||
} else {
|
||||
bool expectsReply = true;
|
||||
try {
|
||||
await _startThinkingLoop();
|
||||
final result = await llmClient.chat(
|
||||
command,
|
||||
configurationId: visitAppContext.configuration?.id,
|
||||
languageCode: lang,
|
||||
);
|
||||
expectsReply = result.expectsReply;
|
||||
await _stopThinkingLoop();
|
||||
if (result.reply.isNotEmpty) {
|
||||
lastTtsText.value = result.reply;
|
||||
await _playDoneSound();
|
||||
await ttsEngine.speak(result.reply, languageCode: langCode);
|
||||
}
|
||||
} catch (e) {
|
||||
await _stopThinkingLoop();
|
||||
debugPrint('[VoiceOrchestrator] LLM error: $e');
|
||||
return;
|
||||
}
|
||||
if (!expectsReply) return;
|
||||
}
|
||||
|
||||
if (continueConversation) await _listenForFollowUp();
|
||||
}
|
||||
|
||||
Future<void> _listenForFollowUp() async {
|
||||
final lang = visitAppContext.language ?? 'FR';
|
||||
final langCode = _toLangCode(lang);
|
||||
isListeningForCommand.value = true;
|
||||
final followUp = await sttEngine.transcribeOnce(
|
||||
languageCode: langCode,
|
||||
timeout: const Duration(seconds: 5),
|
||||
);
|
||||
isListeningForCommand.value = false;
|
||||
if (followUp.isNotEmpty) lastTranscription.value = followUp;
|
||||
if (followUp.isEmpty || _isStopCommand(followUp)) return;
|
||||
await _dispatch(followUp, continueConversation: true);
|
||||
}
|
||||
|
||||
// ── QR scan ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _handleQrScan() async {
|
||||
final lang = _toLangCode(visitAppContext.language ?? 'FR');
|
||||
final completer = Completer<String?>();
|
||||
final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
|
||||
MetaGlassesService.instance.onPhotoCaptured = (path) {
|
||||
if (!completer.isCompleted) completer.complete(path.isEmpty ? null : path);
|
||||
prevCallback?.call(path);
|
||||
};
|
||||
Timer(const Duration(seconds: 25), () {
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
});
|
||||
unawaited(MetaGlassesService.instance.requestPhotoCapture());
|
||||
final photoPath = await completer.future;
|
||||
MetaGlassesService.instance.onPhotoCaptured = prevCallback;
|
||||
|
||||
if (photoPath == null) {
|
||||
lastTtsText.value = TranslationHelper.getFromLocale('voice.cameraUnavailable', visitAppContext);
|
||||
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastQrScanPhoto.value != null && lastQrScanPhoto.value != photoPath) {
|
||||
try { File(lastQrScanPhoto.value!).deleteSync(); } catch (_) {}
|
||||
}
|
||||
lastQrScanPhoto.value = photoPath;
|
||||
|
||||
final qr = await _tryDecodeQr(photoPath);
|
||||
if (qr != null) {
|
||||
await explainSection(qr.sectionId, configurationId: qr.configId);
|
||||
} else {
|
||||
lastTtsText.value = TranslationHelper.getFromLocale('voice.noQrFound', visitAppContext);
|
||||
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePhotoCapture() async {
|
||||
final completer = Completer<String?>();
|
||||
final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
|
||||
MetaGlassesService.instance.onPhotoCaptured = (path) {
|
||||
if (!completer.isCompleted) completer.complete(path);
|
||||
prevCallback?.call(path);
|
||||
};
|
||||
Timer(const Duration(seconds: 25), () {
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
});
|
||||
unawaited(MetaGlassesService.instance.requestPhotoCapture());
|
||||
final photoPath = await completer.future;
|
||||
MetaGlassesService.instance.onPhotoCaptured = prevCallback;
|
||||
|
||||
if (photoPath == null || photoPath.isEmpty) {
|
||||
final lang = _toLangCode(visitAppContext.language ?? 'FR');
|
||||
lastTtsText.value = TranslationHelper.getFromLocale('voice.photoFailed', visitAppContext);
|
||||
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||
return;
|
||||
}
|
||||
|
||||
final qr = await _tryDecodeQr(photoPath);
|
||||
if (qr != null) {
|
||||
await explainSection(qr.sectionId, configurationId: qr.configId);
|
||||
} else {
|
||||
visitPhotos.add(photoPath);
|
||||
visitPhotosNotifier.value = List.unmodifiable(visitPhotos);
|
||||
final lang = _toLangCode(visitAppContext.language ?? 'FR');
|
||||
lastTtsText.value = TranslationHelper.getFromLocale('voice.photoCaptured', visitAppContext);
|
||||
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||
}
|
||||
}
|
||||
|
||||
Future<({String sectionId, String? configId})?> _tryDecodeQr(String imagePath) async {
|
||||
final controller = MobileScannerController();
|
||||
({String sectionId, String? configId})? result;
|
||||
try {
|
||||
final completer = Completer<({String sectionId, String? configId})?>();
|
||||
final sub = controller.barcodes.listen((capture) {
|
||||
for (final barcode in capture.barcodes) {
|
||||
final raw = barcode.rawValue;
|
||||
if (raw != null) {
|
||||
final ids = _extractQrIds(raw);
|
||||
if (ids != null && !completer.isCompleted) completer.complete(ids);
|
||||
}
|
||||
}
|
||||
});
|
||||
await controller.analyzeImage(imagePath);
|
||||
Timer(const Duration(seconds: 2), () {
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
});
|
||||
result = await completer.future;
|
||||
await sub.cancel();
|
||||
} catch (e) {
|
||||
debugPrint('[VoiceOrchestrator] QR decode error: $e');
|
||||
} finally {
|
||||
controller.dispose();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
({String sectionId, String? configId})? _extractQrIds(String raw) {
|
||||
final m1 = _urlPattern1.firstMatch(raw);
|
||||
if (m1 != null) return (sectionId: m1.group(3)!, configId: m1.group(2));
|
||||
final m2 = _urlPattern2.firstMatch(raw);
|
||||
if (m2 != null) return (sectionId: m2.group(3)!, configId: m2.group(2));
|
||||
if (visitAppContext.sectionIds != null) {
|
||||
return visitAppContext.sectionIds!.contains(raw)
|
||||
? (sectionId: raw, configId: visitAppContext.configuration?.id)
|
||||
: null;
|
||||
}
|
||||
return (sectionId: raw, configId: null);
|
||||
}
|
||||
|
||||
Future<void> explainSection(String sectionId, {String? configurationId}) async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if ((now - (_lastQrTime[sectionId] ?? 0)) < _qrCooldownMs) return;
|
||||
_lastQrTime[sectionId] = now;
|
||||
final cfgId = configurationId ?? visitAppContext.configuration?.id;
|
||||
final lang = visitAppContext.language ?? 'FR';
|
||||
try {
|
||||
final result = await llmClient.chat(
|
||||
'Le visiteur vient de scanner le QR code de la section "$sectionId". '
|
||||
'Appelle GetSectionDetail avec cet ID, puis présente le contenu de façon engageante en 2-3 phrases.',
|
||||
configurationId: cfgId,
|
||||
languageCode: lang,
|
||||
);
|
||||
if (result.reply.isNotEmpty) {
|
||||
lastTtsText.value = result.reply;
|
||||
await ttsEngine.speak(result.reply, languageCode: _toLangCode(lang));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[VoiceOrchestrator] explainSection error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
bool _isStopCommand(String text) {
|
||||
final t = text.toLowerCase().trim();
|
||||
return t == 'non' || t == 'rien' || t == 'non rien' || t == 'rien merci' ||
|
||||
t == 'non merci' || t == 'laisse tomber' || t == 'annule' || t == 'annuler' ||
|
||||
t.contains('stop') || t.contains('arrête') || t.contains('au revoir') ||
|
||||
t.contains('c\'est bon') || t.contains('ok merci') || t.contains('laisse tomber') ||
|
||||
(t.length < 10 && (t.contains('non') || t.contains('rien')));
|
||||
}
|
||||
|
||||
bool _isQrScanCommand(String text) {
|
||||
final t = text.toLowerCase();
|
||||
return t.contains('scan') || t.contains('qr') || t.contains('code') || t.contains('regarde');
|
||||
}
|
||||
|
||||
bool _isPhotoCommand(String text) {
|
||||
final t = text.toLowerCase();
|
||||
return t.contains('photo') || t.contains('prends') || t.contains('capture');
|
||||
}
|
||||
|
||||
bool _isRepeatCommand(String text) {
|
||||
final t = text.toLowerCase();
|
||||
return t.contains('répète') || t.contains('repete') || t.contains('encore');
|
||||
}
|
||||
|
||||
String _toLangCode(String lang) {
|
||||
switch (lang.toUpperCase()) {
|
||||
case 'FR': return 'fr-FR';
|
||||
case 'NL': return 'nl-NL';
|
||||
case 'EN': return 'en-US';
|
||||
case 'DE': return 'de-DE';
|
||||
default: return 'fr-FR';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,33 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:manager_api_new/api.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Models/AssistantResponse.dart';
|
||||
|
||||
class AssistantService {
|
||||
final VisitAppContext visitAppContext;
|
||||
final List<AiChatMessage> history = [];
|
||||
|
||||
AssistantService({required this.visitAppContext});
|
||||
/// Nombre maximum de messages conservés dans l'historique envoyé au backend.
|
||||
final int maxHistory;
|
||||
|
||||
/// Durée d'inactivité après laquelle l'historique est automatiquement vidé.
|
||||
/// null = pas de vidage automatique.
|
||||
final Duration? inactivityTimeout;
|
||||
|
||||
final List<AiChatMessage> _history = [];
|
||||
Timer? _inactivityTimer;
|
||||
|
||||
AssistantService({
|
||||
required this.visitAppContext,
|
||||
this.maxHistory = 10,
|
||||
this.inactivityTimeout = const Duration(minutes: 5),
|
||||
});
|
||||
|
||||
Future<AssistantResponse> chat({
|
||||
required String message,
|
||||
String? configurationId,
|
||||
}) => chatWithAppType(message: message, configurationId: configurationId);
|
||||
bool isVoice = false,
|
||||
}) => chatWithAppType(message: message, configurationId: configurationId, isVoice: isVoice);
|
||||
|
||||
Future<AssistantResponse> chatWithAppType({
|
||||
required String message,
|
||||
@ -19,13 +35,15 @@ class AssistantService {
|
||||
AppType appType = AppType.Mobile,
|
||||
bool isVoice = false,
|
||||
}) async {
|
||||
_resetInactivityTimer();
|
||||
|
||||
final request = AiChatRequest(
|
||||
message: message,
|
||||
instanceId: visitAppContext.instanceId,
|
||||
appType: appType,
|
||||
configurationId: configurationId,
|
||||
language: visitAppContext.language?.toUpperCase() ?? 'FR',
|
||||
history: List.from(history),
|
||||
history: List.from(_history),
|
||||
isVoice: isVoice,
|
||||
);
|
||||
|
||||
@ -35,7 +53,7 @@ class AssistantService {
|
||||
throw Exception('Empty response from assistant');
|
||||
}
|
||||
|
||||
print("AI raw response: reply='${response.reply}' navigation.sectionId='${response.navigation?.sectionId}' navigation.sectionTitle='${response.navigation?.sectionTitle}' navigation.sectionType='${response.navigation?.sectionType}' cards=${response.cards?.length}");
|
||||
debugPrint("AI raw response: reply='${response.reply}' navigation.sectionId='${response.navigation?.sectionId}' navigation.sectionTitle='${response.navigation?.sectionTitle}' navigation.sectionType='${response.navigation?.sectionType}' cards=${response.cards?.length}");
|
||||
|
||||
final result = AssistantResponse(
|
||||
reply: response.reply ?? '',
|
||||
@ -54,12 +72,35 @@ class AssistantService {
|
||||
imageUrl: response.navigation!.imageUrl,
|
||||
)
|
||||
: null,
|
||||
expectsReply: response.expectsReply ?? true,
|
||||
);
|
||||
|
||||
history.add(AiChatMessage(role: 'user', content: message));
|
||||
history.add(AiChatMessage(role: 'assistant', content: result.reply));
|
||||
_history.add(AiChatMessage(role: 'user', content: message));
|
||||
_history.add(AiChatMessage(role: 'assistant', content: result.reply));
|
||||
|
||||
// Cap local — inutile de garder plus que maxHistory côté client
|
||||
if (_history.length > maxHistory) {
|
||||
_history.removeRange(0, _history.length - maxHistory);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void clearHistory() => history.clear();
|
||||
}
|
||||
void _resetInactivityTimer() {
|
||||
if (inactivityTimeout == null) return;
|
||||
_inactivityTimer?.cancel();
|
||||
_inactivityTimer = Timer(inactivityTimeout!, () {
|
||||
debugPrint('[AssistantService] Inactivity timeout — clearing history');
|
||||
clearHistory();
|
||||
});
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
_history.clear();
|
||||
_inactivityTimer?.cancel();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_inactivityTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@ -203,11 +203,12 @@ class GeoBeaconTriggerService {
|
||||
final response = await _assistantService!.chat(
|
||||
message: prompt,
|
||||
configurationId: _visitAppContext?.configuration?.id,
|
||||
isVoice: true,
|
||||
);
|
||||
|
||||
if (response.reply.isNotEmpty) {
|
||||
final lang = _visitAppContext?.language ?? 'FR';
|
||||
await activeOrchestrator?.ttsEngine.speak(
|
||||
await activeVoiceOrchestrator?.ttsEngine.speak(
|
||||
response.reply,
|
||||
languageCode: _toLangCode(lang),
|
||||
);
|
||||
|
||||
119
lib/Services/voice_controller.dart
Normal file
119
lib/Services/voice_controller.dart
Normal file
@ -0,0 +1,119 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/flutter_tts_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/gemini_tts_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/myinfomate_llm_client.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/native_wake_word_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_stt.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_wake_word.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/whisper_stt_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/glasses_background_service.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/voice_orchestrator.dart';
|
||||
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||
import 'package:mymuseum_visitapp/constants.dart';
|
||||
|
||||
enum VoiceMode { none, voiceOnly, glasses }
|
||||
|
||||
class VoiceController extends ChangeNotifier {
|
||||
VoiceMode _mode = VoiceMode.none;
|
||||
VoiceMode get mode => _mode;
|
||||
|
||||
bool _isActivating = false;
|
||||
bool get isActivating => _isActivating;
|
||||
|
||||
VoiceController() {
|
||||
MetaGlassesService.instance.state.addListener(_onGlassesStateChanged);
|
||||
}
|
||||
|
||||
bool isFeatureAvailable(VisitAppContext ctx) =>
|
||||
ctx.applicationInstanceDTO?.isAssistant == true;
|
||||
|
||||
bool get isGlassesConnected =>
|
||||
MetaGlassesService.instance.state.value == GlassesState.connected ||
|
||||
MetaGlassesService.instance.state.value == GlassesState.streaming;
|
||||
|
||||
/// Active le mode demandé. Si un mode est déjà actif, le stoppe d'abord.
|
||||
Future<void> activate(VoiceMode targetMode, VisitAppContext ctx) async {
|
||||
if (_isActivating) return;
|
||||
_isActivating = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _stopOrchestrator();
|
||||
|
||||
if (targetMode == VoiceMode.glasses) {
|
||||
await GlassesBackgroundService.initialize();
|
||||
await GlassesBackgroundService.start();
|
||||
await MetaGlassesService.instance.connect();
|
||||
}
|
||||
|
||||
_buildOrchestrator(ctx);
|
||||
await activeVoiceOrchestrator!.start();
|
||||
_mode = targetMode;
|
||||
} catch (e) {
|
||||
debugPrint('[VoiceController] activate error: $e');
|
||||
await _stopOrchestrator();
|
||||
_mode = VoiceMode.none;
|
||||
} finally {
|
||||
_isActivating = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deactivate() async {
|
||||
await _stopOrchestrator();
|
||||
_mode = VoiceMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Relance le wake word après un retour en foreground.
|
||||
void restartWakeWordIfNeeded() {
|
||||
final o = activeVoiceOrchestrator;
|
||||
if (o != null && o.isRunning && !o.isInConversation) {
|
||||
o.restartWakeWord();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopOrchestrator() async {
|
||||
if (activeVoiceOrchestrator != null) {
|
||||
await activeVoiceOrchestrator!.stop();
|
||||
activeVoiceOrchestrator = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _buildOrchestrator(VisitAppContext ctx) {
|
||||
activeVoiceOrchestrator = VoiceOrchestrator(
|
||||
visitAppContext: ctx,
|
||||
wakeWordEngine: Platform.isAndroid
|
||||
? NativeWakeWordEngine(modelNames: ['hey_marco', 'hey_alba', 'hey_vasco'])
|
||||
: SpeechToTextWakeWordEngine(keyword: 'visite'),
|
||||
sttEngine: kWhisperApiKey.isNotEmpty
|
||||
? WhisperSttEngine(apiKey: kWhisperApiKey, endpoint: kWhisperEndpoint)
|
||||
: SpeechToTextSttEngine(),
|
||||
ttsEngine: kGeminiApiKey.isNotEmpty
|
||||
? GeminiTtsEngine(
|
||||
apiKey: kGeminiApiKey,
|
||||
voiceName: kGeminiTtsVoice,
|
||||
voicePrompt: kGeminiTtsPrompt,
|
||||
)
|
||||
: FlutterTtsEngine(),
|
||||
llmClient: MyInfoMateLlmClient(visitAppContext: ctx),
|
||||
);
|
||||
}
|
||||
|
||||
void _onGlassesStateChanged() {
|
||||
if (_mode == VoiceMode.glasses &&
|
||||
MetaGlassesService.instance.state.value == GlassesState.disconnected) {
|
||||
debugPrint('[VoiceController] Glasses disconnected — deactivating');
|
||||
deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
MetaGlassesService.instance.state.removeListener(_onGlassesStateChanged);
|
||||
_stopOrchestrator();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@ -125,6 +125,7 @@ class WakeWordService {
|
||||
final response = await _assistantService!.chat(
|
||||
message: command,
|
||||
configurationId: _assistantService!.visitAppContext.configuration?.id,
|
||||
isVoice: true,
|
||||
);
|
||||
if (response.reply.isNotEmpty) {
|
||||
await GlassesTtsService.instance.speak(
|
||||
@ -133,6 +134,9 @@ class WakeWordService {
|
||||
voiceId: kElevenLabsVoiceId,
|
||||
);
|
||||
}
|
||||
if (!response.expectsReply) {
|
||||
debugPrint('[WakeWordService] No follow-up expected, conversation ends here.');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[WakeWordService] dispatch error: $e');
|
||||
}
|
||||
|
||||
@ -46,6 +46,9 @@ class Client {
|
||||
SectionEventApi? _sectionEventApi;
|
||||
SectionEventApi? get sectionEventApi => _sectionEventApi;
|
||||
|
||||
SectionParcoursApi? _sectionParcoursApi;
|
||||
SectionParcoursApi? get sectionParcoursApi => _sectionParcoursApi;
|
||||
|
||||
Client(String path, {String? apiKey}) {
|
||||
_apiClient = ApiClient(basePath: path);
|
||||
if (apiKey != null) _apiClient!.addDefaultHeader('X-Api-Key', apiKey);
|
||||
@ -62,5 +65,6 @@ class Client {
|
||||
_sectionAgendaApi = SectionAgendaApi(_apiClient);
|
||||
_sectionMapApi = SectionMapApi(_apiClient);
|
||||
_sectionEventApi = SectionEventApi(_apiClient);
|
||||
_sectionParcoursApi = SectionParcoursApi(_apiClient);
|
||||
}
|
||||
}
|
||||
@ -11,17 +11,9 @@ import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart';
|
||||
import 'package:mymuseum_visitapp/Models/articleRead.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/splash_screen.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/flutter_tts_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/gemini_tts_engine.dart';
|
||||
// ElevenLabsTtsEngine disponible dans impl/ mais retiré du pipeline — trop cher
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/glasses_background_service.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/myinfomate_llm_client.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_stt.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/whisper_stt_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/native_wake_word_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_wake_word.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/voice_orchestrator.dart';
|
||||
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||
import 'package:mymuseum_visitapp/Services/voice_controller.dart';
|
||||
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
|
||||
import 'package:mymuseum_visitapp/l10n/app_localizations.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@ -114,9 +106,8 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
||||
localContext.localPath = localPath;
|
||||
print("Local path $localPath");
|
||||
|
||||
// Glasses init — initialize only here, startSession() est déclenché dans _MyAppState
|
||||
// TODO: remplacer glassesEnabled par un vrai toggle UI
|
||||
localContext.glassesEnabled = true;
|
||||
// Glasses SDK init — toujours initialisé pour détecter la connexion BT.
|
||||
// L'orchestrateur vocal ne démarre PAS automatiquement — c'est VoiceController qui gère ça.
|
||||
if (!Platform.isWindows && (Platform.isAndroid || Platform.isIOS)) {
|
||||
await MetaGlassesService.instance.initialize();
|
||||
}
|
||||
@ -156,49 +147,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
if (widget.visitAppContext.glassesEnabled &&
|
||||
!Platform.isWindows &&
|
||||
(Platform.isAndroid || Platform.isIOS)) {
|
||||
// Activity complètement attachée — démarrer le pipeline lunettes
|
||||
final ctx = widget.visitAppContext;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
// Foreground service Android — garde le main isolate vivant en background
|
||||
await GlassesBackgroundService.initialize();
|
||||
await GlassesBackgroundService.start();
|
||||
|
||||
// connect() = enregistrement DAT + HFP audio routing (micro lunettes actif)
|
||||
await MetaGlassesService.instance.connect();
|
||||
|
||||
// Orchestrateur avec implémentations swappables
|
||||
// Pour passer à ElevenLabs TTS : remplacer FlutterTtsEngine par ElevenLabsTtsEngine
|
||||
// Pour passer à Porcupine wake word : remplacer SpeechToTextWakeWordEngine par PorcupineWakeWordEngine
|
||||
final orchestrator = GlassesOrchestrator(
|
||||
visitAppContext: ctx,
|
||||
// OpenWakeWord natif Android (TFLite, background, écran éteint)
|
||||
// Changer modelName: 'hey_viva' pour l'autre wakeword disponible
|
||||
wakeWordEngine: Platform.isAndroid
|
||||
? NativeWakeWordEngine(modelName: 'hey_viva')
|
||||
: SpeechToTextWakeWordEngine(keyword: 'visite'), // fallback iOS
|
||||
// STT : WhisperSttEngine si clé configurée, sinon SpeechToTextSttEngine
|
||||
// OpenAI : --dart-define=WHISPER_API_KEY=sk-xxx
|
||||
// Auto-hébergé : --dart-define=WHISPER_API_KEY=xxx --dart-define=WHISPER_ENDPOINT=http://ton-serveur:8000/v1/audio/transcriptions
|
||||
sttEngine: kWhisperApiKey.isNotEmpty
|
||||
? WhisperSttEngine(apiKey: kWhisperApiKey, endpoint: kWhisperEndpoint)
|
||||
: SpeechToTextSttEngine(),
|
||||
// TTS : Gemini 2.5 Flash si clé configurée, sinon flutter_tts on-device (tests)
|
||||
ttsEngine: kGeminiApiKey.isNotEmpty
|
||||
? GeminiTtsEngine(
|
||||
apiKey: kGeminiApiKey,
|
||||
voiceName: kGeminiTtsVoice,
|
||||
voicePrompt: kGeminiTtsPrompt,
|
||||
)
|
||||
: FlutterTtsEngine(),
|
||||
llmClient: MyInfoMateLlmClient(visitAppContext: ctx),
|
||||
);
|
||||
activeOrchestrator = orchestrator;
|
||||
await orchestrator.start();
|
||||
});
|
||||
}
|
||||
// Le mode vocal ne démarre plus automatiquement — l'utilisateur choisit via VoiceModeSheet.
|
||||
}
|
||||
|
||||
@override
|
||||
@ -207,14 +156,10 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Relance l'écoute wake word quand l'app repasse en foreground (déverrouillage).
|
||||
/// SpeechRecognizer se suspend quand l'écran est verrouillé — on le redémarre.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
final o = activeOrchestrator;
|
||||
// Relance uniquement si l'orchestrateur tourne ET qu'aucune conversation
|
||||
// n'est en cours (sinon on interromprait une question/réponse active)
|
||||
final o = activeVoiceOrchestrator;
|
||||
if (o != null && o.isRunning && !o.isInConversation) {
|
||||
o.restartWakeWord();
|
||||
}
|
||||
@ -225,8 +170,15 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
Widget build(BuildContext context) {
|
||||
Get.put(RequirementStateController());
|
||||
|
||||
return ChangeNotifierProvider<AppContext>(
|
||||
create: (_) => AppContext(widget.visitAppContext),
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AppContext>(
|
||||
create: (_) => AppContext(widget.visitAppContext),
|
||||
),
|
||||
ChangeNotifierProvider<VoiceController>(
|
||||
create: (_) => VoiceController(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Carnaval de Marche', //'Musée de la fraise' // Autres // 'Fort Saint Héribert'
|
||||
|
||||
15
pubspec.lock
15
pubspec.lock
@ -579,14 +579,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.30.0"
|
||||
flutter_staggered_grid_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_staggered_grid_view
|
||||
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
flutter_svg:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1060,6 +1052,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
myinfomate_layout:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../manager-app/myinfomate_layout"
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.0.0"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -62,7 +62,6 @@ dependencies:
|
||||
sqflite: #not in web
|
||||
just_audio_cache: ^0.1.2 #not in web
|
||||
beacon_scanner: ^0.0.4 #not in web
|
||||
flutter_staggered_grid_view: ^0.7.0
|
||||
|
||||
smooth_page_indicator: ^1.2.1
|
||||
|
||||
@ -91,6 +90,8 @@ dependencies:
|
||||
|
||||
manager_api_new:
|
||||
path: ../manager-app/manager_api_new
|
||||
myinfomate_layout:
|
||||
path: ../manager-app/myinfomate_layout
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.2
|
||||
|
||||
1287
wakeword/Hey_marco_training_working.ipynb
Normal file
1287
wakeword/Hey_marco_training_working.ipynb
Normal file
File diff suppressed because one or more lines are too long
BIN
wakeword/hey_marco_v2.onnx
Normal file
BIN
wakeword/hey_marco_v2.onnx
Normal file
Binary file not shown.
BIN
wakeword/hey_marco_v2.tflite
Normal file
BIN
wakeword/hey_marco_v2.tflite
Normal file
Binary file not shown.
BIN
wakeword/hey_vasco.onnx
Normal file
BIN
wakeword/hey_vasco.onnx
Normal file
Binary file not shown.
BIN
wakeword/hey_vasco.tflite
Normal file
BIN
wakeword/hey_vasco.tflite
Normal file
Binary file not shown.
BIN
wakeword/hey_visit.onnx
Normal file
BIN
wakeword/hey_visit.onnx
Normal file
Binary file not shown.
BIN
wakeword/hey_visit.tflite
Normal file
BIN
wakeword/hey_visit.tflite
Normal file
Binary file not shown.
BIN
wakeword/hey_viva.onnx
Normal file
BIN
wakeword/hey_viva.onnx
Normal file
Binary file not shown.
BIN
wakeword/hey_viva.tflite
Normal file
BIN
wakeword/hey_viva.tflite
Normal file
Binary file not shown.
269
wakeword/wakeword_training_template.ipynb
Normal file
269
wakeword/wakeword_training_template.ipynb
Normal file
@ -0,0 +1,269 @@
|
||||
{
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0,
|
||||
"metadata": {
|
||||
"colab": {
|
||||
"provenance": [],
|
||||
"name": "wakeword_training_template.ipynb",
|
||||
"gpuType": "T4"
|
||||
},
|
||||
"accelerator": "GPU",
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Wake Word Training\n",
|
||||
"\n",
|
||||
"Basé sur `Automatic_model_training_simple_TFR`. \n",
|
||||
"Génération multi-langue : EN/EN-GB/DE/NL/IT/ES + FR (phonétique dédiée).\n",
|
||||
"\n",
|
||||
"**Runtime recommandé : GPU (T4)** — Durée estimée : ~45-90 min"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"from google.colab import drive\n",
|
||||
"drive.mount('/content/drive')\n",
|
||||
"\n",
|
||||
"import os\n",
|
||||
"drive_output_dir = '/content/drive/My Drive/Colab Notebooks/Wakeword/openwakeword'\n",
|
||||
"os.makedirs(drive_output_dir, exist_ok=True)\n",
|
||||
"print(f'Models will be saved in: {drive_output_dir}')"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": "# @title Configuration globale { display-mode: \"form\" }\n# @markdown ### Wake word\n# @markdown `PHRASE_DEFAULT` : prononciation EN (utilise _ entre les mots)\n# @markdown `PHRASE_FR` : prononciation FR (avec accents si besoin)\n\nPHRASE_DEFAULT = 'hey_marco' # @param {type:\"string\"}\nPHRASE_FR = 'hé marco' # @param {type:\"string\"}\n\n# @markdown ---\n# @markdown ### Paramètres d'entraînement\n\nnumber_of_examples = 10000 # @param {type:\"slider\", min:1000, max:50000, step:500}\nnumber_of_training_steps = 25000 # @param {type:\"slider\", min:1000, max:50000, step:500}\nfalse_activation_penalty = 1500 # @param {type:\"slider\", min:100, max:3000, step:100}\n\n# @markdown ---\n# @markdown ### Répartition FR / EN (%)\n# @markdown Le reste (100% - FR%) va en EN. Mettre FR=0 pour full EN.\n\nfr_pct = 35 # @param {type:\"slider\", min:0, max:100, step:5}\n\n# @markdown ---\n# @markdown ### Objectifs qualité (early stopping)\n\ntarget_accuracy = 0.7 # @param {type:\"slider\", min:0.1, max:1.0, step:0.05}\ntarget_recall = 0.5 # @param {type:\"slider\", min:0.1, max:1.0, step:0.05}\n\n# ── Calcul automatique ────────────────────────────────────────────────────────\nimport math\n\nen_pct = 100 - fr_pct\nlang_samples = {\n 'fr': math.floor(number_of_examples * fr_pct / 100),\n 'en': number_of_examples - math.floor(number_of_examples * fr_pct / 100),\n}\n\nMODEL_NAME = PHRASE_DEFAULT.replace(' ', '_')\nCLIPS_DIR = f'./{MODEL_NAME}_clips'\n\nprint(f'Wake word : \"{PHRASE_DEFAULT}\" (EN) | \"{PHRASE_FR}\" (FR)')\nprint(f'Model name : {MODEL_NAME}')\nprint(f'Total samples : {sum(lang_samples.values())}')\nprint(f' fr : {lang_samples[\"fr\"]} ({fr_pct}%)')\nprint(f' en : {lang_samples[\"en\"]} ({en_pct}%)')",
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Étape 1 — Piper sample generator + modèles TTS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import os, sys\n",
|
||||
"from IPython.display import Audio, display\n",
|
||||
"\n",
|
||||
"if not os.path.exists('./piper-sample-generator'):\n",
|
||||
" !git clone https://github.com/rhasspy/piper-sample-generator\n",
|
||||
" !cd piper-sample-generator && git checkout 213d4d5\n",
|
||||
" !pip install piper-tts piper-phonemize-cross\n",
|
||||
" !pip install webrtcvad\n",
|
||||
" !pip install torch==2.5.0 torchvision==0.20.0 torchaudio==2.5.0 --index-url https://download.pytorch.org/whl/cu121\n",
|
||||
"\n",
|
||||
"# Modèle EN\n",
|
||||
"os.makedirs('piper-sample-generator/models', exist_ok=True)\n",
|
||||
"if not os.path.exists('piper-sample-generator/models/en_US-libritts_r-medium.pt'):\n",
|
||||
" !wget -q -O piper-sample-generator/models/en_US-libritts_r-medium.pt \\\n",
|
||||
" 'https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/en_US-libritts_r-medium.pt'\n",
|
||||
"\n",
|
||||
"# Modèle FR via piper-tts .onnx\n",
|
||||
"if not os.path.exists('fr_FR-siwis-medium.onnx'):\n",
|
||||
" !wget -q -O fr_FR-siwis-medium.onnx \\\n",
|
||||
" 'https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx'\n",
|
||||
" !wget -q -O fr_FR-siwis-medium.onnx.json \\\n",
|
||||
" 'https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json'\n",
|
||||
"\n",
|
||||
"if 'piper-sample-generator/' not in sys.path:\n",
|
||||
" sys.path.append('piper-sample-generator/')\n",
|
||||
"from generate_samples import generate_samples\n",
|
||||
"\n",
|
||||
"print('Piper prêt.')"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Test audio — vérifie les prononciations avant de lancer le training\n",
|
||||
"generate_samples(text=PHRASE_DEFAULT, max_samples=1, output_dir='./', batch_size=1,\n",
|
||||
" auto_reduce_batch_size=True, file_names=['test_en.wav'])\n",
|
||||
"print(f'EN : \"{PHRASE_DEFAULT}\"')\n",
|
||||
"display(Audio('test_en.wav', autoplay=False))\n",
|
||||
"\n",
|
||||
"!echo \"{PHRASE_FR}\" | piper --model fr_FR-siwis-medium.onnx --output_file test_fr.wav\n",
|
||||
"print(f'FR : \"{PHRASE_FR}\"')\n",
|
||||
"display(Audio('test_fr.wav', autoplay=False))"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Étape 2 — Téléchargement des données"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import locale\n",
|
||||
"def getpreferredencoding(do_setlocale=True):\n",
|
||||
" return 'UTF-8'\n",
|
||||
"locale.getpreferredencoding = getpreferredencoding\n",
|
||||
"\n",
|
||||
"import os, shutil\n",
|
||||
"\n",
|
||||
"# Clone openWakeWord\n",
|
||||
"if os.path.exists('./openwakeword'):\n",
|
||||
" shutil.rmtree('./openwakeword')\n",
|
||||
"!rm -rf ./openwakeword\n",
|
||||
"!git clone https://github.com/dscripka/openwakeword\n",
|
||||
"!pip install -e ./openwakeword --no-deps\n",
|
||||
"\n",
|
||||
"!pip install mutagen==1.47.0\n",
|
||||
"!pip install torchinfo==1.8.0\n",
|
||||
"!pip install torchmetrics==1.2.0\n",
|
||||
"!pip install speechbrain==0.5.14\n",
|
||||
"!pip install audiomentations==0.33.0\n",
|
||||
"!pip install torch-audiomentations==0.11.0\n",
|
||||
"!pip install acoustics==0.2.6\n",
|
||||
"!pip install onnxruntime==1.22.1 ai_edge_litert==1.4.0 onnxsim\n",
|
||||
"!pip install onnx2tf\n",
|
||||
"!pip install onnx==1.19.1\n",
|
||||
"!pip install onnx_graphsurgeon sng4onnx\n",
|
||||
"!pip install pronouncing==0.2.0\n",
|
||||
"!pip install datasets==2.14.6\n",
|
||||
"!pip install deep-phonemizer==0.0.19\n",
|
||||
"\n",
|
||||
"os.makedirs('./openwakeword/openwakeword/resources/models', exist_ok=True)\n",
|
||||
"base = 'https://github.com/dscripka/openWakeWord/releases/download/v0.5.1'\n",
|
||||
"for f in ['embedding_model.onnx', 'embedding_model.tflite', 'melspectrogram.onnx', 'melspectrogram.tflite']:\n",
|
||||
" !wget -q {base}/{f} -O ./openwakeword/openwakeword/resources/models/{f}\n",
|
||||
"\n",
|
||||
"print('Installation terminée.')"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import numpy as np, sys, yaml, datasets, scipy\n",
|
||||
"from pathlib import Path\n",
|
||||
"from tqdm import tqdm\n",
|
||||
"\n",
|
||||
"# MIT Room Impulse Responses\n",
|
||||
"output_dir = './mit_rirs'\n",
|
||||
"if not os.path.exists(output_dir):\n",
|
||||
" os.mkdir(output_dir)\n",
|
||||
" !git lfs install\n",
|
||||
" !git clone https://huggingface.co/datasets/davidscripka/MIT_environmental_impulse_responses\n",
|
||||
" rir_dataset = datasets.Dataset.from_dict({\n",
|
||||
" 'audio': [str(i) for i in Path('./MIT_environmental_impulse_responses/16khz').glob('*.wav')]\n",
|
||||
" }).cast_column('audio', datasets.Audio())\n",
|
||||
" for row in tqdm(rir_dataset):\n",
|
||||
" name = row['audio']['path'].split('/')[-1]\n",
|
||||
" scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n",
|
||||
"\n",
|
||||
"# FMA music\n",
|
||||
"output_dir = './fma'\n",
|
||||
"if not os.path.exists(output_dir):\n",
|
||||
" os.mkdir(output_dir)\n",
|
||||
" fma_dataset = datasets.load_dataset('rudraml/fma', name='small', split='train', streaming=True)\n",
|
||||
" fma_dataset = iter(fma_dataset.cast_column('audio', datasets.Audio(sampling_rate=16000)))\n",
|
||||
" n_hours = 1\n",
|
||||
" for i in tqdm(range(n_hours*3600//30)):\n",
|
||||
" row = next(fma_dataset)\n",
|
||||
" name = row['audio']['path'].split('/')[-1].replace('.mp3', '.wav')\n",
|
||||
" scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n",
|
||||
"\n",
|
||||
"# Features pré-calculées openWakeWord\n",
|
||||
"if not os.path.exists('./openwakeword_features_ACAV100M_2000_hrs_16bit.npy'):\n",
|
||||
" !wget -q https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n",
|
||||
"if not os.path.exists('validation_set_features.npy'):\n",
|
||||
" !wget -q https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/validation_set_features.npy\n",
|
||||
"\n",
|
||||
"print('Données prêtes.')"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Étape 3 — Génération des samples multi-langue"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": "import sys, os, glob\nimport scipy.io.wavfile as wav\nimport scipy.signal as signal\nimport numpy as np\nfrom tqdm import tqdm\n\nif 'piper-sample-generator/' not in sys.path:\n sys.path.append('piper-sample-generator/')\nfrom generate_samples import generate_samples\n\nos.makedirs(CLIPS_DIR, exist_ok=True)\n\n# ── EN via piper-sample-generator ────────────────────────────────────────────\nen_dir = f'{CLIPS_DIR}/en'\nos.makedirs(en_dir, exist_ok=True)\nexisting_en = len([f for f in os.listdir(en_dir) if f.endswith('.wav')])\nremaining_en = lang_samples['en'] - existing_en\n\nif remaining_en > 0:\n print(f' [en] génération {remaining_en} samples : \"{PHRASE_DEFAULT}\"')\n generate_samples(\n text=PHRASE_DEFAULT, max_samples=remaining_en,\n length_scales=[0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15],\n noise_scales=[0.4, 0.667, 1.0], noise_scale_ws=[0.8],\n output_dir=en_dir, batch_size=50, auto_reduce_batch_size=True,\n )\nelse:\n print(f' [en] déjà complet ({existing_en}/{lang_samples[\"en\"]})')\n\n# ── FR via piper CLI + rééchantillonnage 16kHz ───────────────────────────────\nfr_dir = f'{CLIPS_DIR}/fr'\nos.makedirs(fr_dir, exist_ok=True)\nexisting_fr = len([f for f in os.listdir(fr_dir) if f.endswith('.wav')])\nremaining_fr = lang_samples['fr'] - existing_fr\n\nif remaining_fr > 0:\n print(f' [fr] génération {remaining_fr} samples : \"{PHRASE_FR}\"')\n speeds = [0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15]\n n_per_speed = remaining_fr // len(speeds)\n remainder = remaining_fr % len(speeds)\n for i, speed in enumerate(tqdm(speeds)):\n n = n_per_speed + (1 if i < remainder else 0)\n with open('/tmp/fr_input.txt', 'w', encoding='utf-8') as f:\n f.write('\\n'.join([PHRASE_FR] * n))\n !piper --model fr_FR-siwis-medium.onnx --length_scale {speed} --output_dir {fr_dir} < /tmp/fr_input.txt\n\n resampled = 0\n for path in glob.glob(f'{fr_dir}/*.wav'):\n if os.path.getsize(path) == 0:\n os.remove(path)\n continue\n try:\n sr, data = wav.read(path)\n if sr != 16000:\n n_s = round(len(data) * 16000 / sr)\n wav.write(path, 16000, signal.resample(data, n_s).astype(np.int16))\n resampled += 1\n except Exception:\n os.remove(path)\n print(f' [fr] {resampled} clips rééchantillonnés à 16kHz')\nelif lang_samples['fr'] > 0:\n print(f' [fr] déjà complet ({existing_fr}/{lang_samples[\"fr\"]})')\nelse:\n print(' [fr] désactivé (fr_pct = 0)')\n\ntotal = sum(\n len([f for f in os.listdir(f'{CLIPS_DIR}/{lang}') if f.endswith('.wav')])\n for lang in ['en', 'fr'] if os.path.exists(f'{CLIPS_DIR}/{lang}')\n)\nprint(f'\\nTotal clips générés : {total}/{number_of_examples}')",
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Étape 4 — Training"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": "import yaml, os, sys, shutil, glob, random\nimport scipy.io.wavfile as wav\nimport scipy.signal as signal\nimport numpy as np\n\nconfig = yaml.load(open('openwakeword/examples/custom_model.yml', 'r').read(), yaml.Loader)\n\nPHRASE_DEFAULT_SPACE = PHRASE_DEFAULT.replace('_', ' ')\n\nconfig['target_phrase'] = [PHRASE_DEFAULT_SPACE, PHRASE_FR]\nconfig['model_name'] = MODEL_NAME\nconfig['n_samples'] = number_of_examples\nconfig['n_samples_val'] = max(500, number_of_examples // 10)\nconfig['steps'] = number_of_training_steps\nconfig['target_accuracy'] = target_accuracy\nconfig['target_recall'] = target_recall\nconfig['max_negative_weight'] = false_activation_penalty\n\nfinal_drive_output_dir = os.path.join(drive_output_dir, MODEL_NAME)\nos.makedirs(final_drive_output_dir, exist_ok=True)\n\nlocal_temp_dir = './openwakeword_training_temp'\nconfig['output_dir'] = local_temp_dir\nconfig['background_paths'] = ['./fma']\nconfig['false_positive_validation_data_path'] = 'validation_set_features.npy'\nconfig['feature_data_files'] = {'ACAV100M_sample': 'openwakeword_features_ACAV100M_2000_hrs_16bit.npy'}\n\nwith open('my_model.yaml', 'w') as f:\n yaml.dump(config, f)\n\n# Rééchantillonner les clips FR à 16kHz\nfor path in glob.glob(f'{CLIPS_DIR}/fr/*.wav'):\n if os.path.getsize(path) == 0:\n os.remove(path)\n continue\n try:\n sr, data = wav.read(path)\n if sr != 16000:\n n_s = round(len(data) * 16000 / sr)\n wav.write(path, 16000, signal.resample(data, n_s).astype(np.int16))\n except Exception:\n os.remove(path)\n\n# Setup des dossiers via --generate_clips\nprint('Setup des dossiers via --generate_clips...')\n!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --generate_clips\n\n# S'assurer que les dossiers existent\npositive_clips_dir = os.path.join(local_temp_dir, MODEL_NAME, 'positive_clips')\npositive_test_dir = os.path.join(local_temp_dir, MODEL_NAME, 'positive_test')\nos.makedirs(positive_clips_dir, exist_ok=True)\nos.makedirs(positive_test_dir, exist_ok=True)\n\n# Remplacer les clips EN par nos clips multi-langue\nfor f in glob.glob(os.path.join(positive_clips_dir, '*.wav')): os.remove(f)\nfor f in glob.glob(os.path.join(positive_test_dir, '*.wav')): os.remove(f)\n\nall_clips = glob.glob(f'{CLIPS_DIR}/**/*.wav', recursive=True)\nrandom.shuffle(all_clips)\nn_val = config['n_samples_val']\nfor wav_path in all_clips[:n_val]:\n lang = os.path.basename(os.path.dirname(wav_path))\n shutil.copy(wav_path, os.path.join(positive_test_dir, f\"{lang}_{os.path.basename(wav_path)}\"))\nfor wav_path in all_clips[n_val:]:\n lang = os.path.basename(os.path.dirname(wav_path))\n shutil.copy(wav_path, os.path.join(positive_clips_dir, f\"{lang}_{os.path.basename(wav_path)}\"))\n\nprint(f'Training : {len(all_clips) - n_val} clips | Validation : {n_val} clips')\n\n# Supprimer le cache .npy\nfor f in glob.glob(os.path.join(local_temp_dir, MODEL_NAME, '*.npy')):\n os.remove(f)\n print(f'Cache supprimé : {os.path.basename(f)}')\n\n# Augmentation + training\n!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --augment_clips\n!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --train_model",
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Étape 5 — Export ONNX → TFLite + sauvegarde Drive"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import shutil, os\n",
|
||||
"from google.colab import files\n",
|
||||
"\n",
|
||||
"local_onnx = f'{local_temp_dir}/{MODEL_NAME}.onnx'\n",
|
||||
"drive_onnx = f'{final_drive_output_dir}/{MODEL_NAME}.onnx'\n",
|
||||
"drive_tflite1 = f'{final_drive_output_dir}/{MODEL_NAME}_float32.tflite'\n",
|
||||
"drive_tflite = f'{final_drive_output_dir}/{MODEL_NAME}.tflite'\n",
|
||||
"\n",
|
||||
"shutil.copy(local_onnx, drive_onnx)\n",
|
||||
"print(f'ONNX copié : {drive_onnx}')\n",
|
||||
"\n",
|
||||
"!onnx2tf -i \"{drive_onnx}\" -o \"{final_drive_output_dir}\" -kat onnx____Flatten_0\n",
|
||||
"!mv \"{drive_tflite1}\" \"{drive_tflite}\"\n",
|
||||
"print(f'TFLite : {drive_tflite}')\n",
|
||||
"\n",
|
||||
"files.download(drive_onnx)\n",
|
||||
"files.download(drive_tflite)"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user