diff --git a/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/MainActivity.kt b/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/MainActivity.kt index dafb793..9c0b630 100644 --- a/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/MainActivity.kt +++ b/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/MainActivity.kt @@ -61,13 +61,12 @@ class MainActivity : FlutterFragmentActivity() { when (call.method) { "start" -> { requestBatteryOptimizationExemption() - val modelName = call.argument("modelName") + val modelNames = call.argument("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) diff --git a/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/WakeWordService.kt b/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/WakeWordService.kt index 751c3ad..f5dcd81 100644 --- a/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/WakeWordService.kt +++ b/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/WakeWordService.kt @@ -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() // modelName → interpréteur + private val lastDetectionTimes = mutableMapOf() // 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 = listOf(DEFAULT_MODEL) private var wakeLock: PowerManager.WakeLock? = null private val melBuffer = ArrayDeque() @@ -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() // 50 derniers scores par modèle + private val scoreHistoryPos = mutableMapOf() 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] } diff --git a/assets/files/hey_alba.onnx b/assets/files/hey_alba.onnx new file mode 100644 index 0000000..093af0a Binary files /dev/null and b/assets/files/hey_alba.onnx differ diff --git a/assets/files/hey_alba.tflite b/assets/files/hey_alba.tflite new file mode 100644 index 0000000..6150dfc Binary files /dev/null and b/assets/files/hey_alba.tflite differ diff --git a/assets/files/hey_marco.onnx b/assets/files/hey_marco.onnx new file mode 100644 index 0000000..aaac37e Binary files /dev/null and b/assets/files/hey_marco.onnx differ diff --git a/assets/files/hey_marco.tflite b/assets/files/hey_marco.tflite new file mode 100644 index 0000000..1a80cc8 Binary files /dev/null and b/assets/files/hey_marco.tflite differ diff --git a/assets/files/hey_vasco.onnx b/assets/files/hey_vasco.onnx new file mode 100644 index 0000000..ff8ca19 Binary files /dev/null and b/assets/files/hey_vasco.onnx differ diff --git a/assets/files/hey_vasco.tflite b/assets/files/hey_vasco.tflite new file mode 100644 index 0000000..9703c8c Binary files /dev/null and b/assets/files/hey_vasco.tflite differ diff --git a/lib/Components/AssistantChatSheet.dart b/lib/Components/AssistantChatSheet.dart index b63a561..0157537 100644 --- a/lib/Components/AssistantChatSheet.dart +++ b/lib/Components/AssistantChatSheet.dart @@ -128,7 +128,7 @@ class _AssistantChatSheetState extends State { MetaGlassesService.instance.isConnected && response.reply.isNotEmpty) { final lang = widget.visitAppContext.language ?? 'FR'; - activeOrchestrator?.ttsEngine.speak( + activeVoiceOrchestrator?.ttsEngine.speak( response.reply, languageCode: _toLangCode(lang), ); diff --git a/lib/Components/GlassesDebugPanel.dart b/lib/Components/GlassesDebugPanel.dart index 04debbe..bb216d5 100644 --- a/lib/Components/GlassesDebugPanel.dart +++ b/lib/Components/GlassesDebugPanel.dart @@ -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 { 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 { ), 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( - valueListenable: activeOrchestrator!.isListeningForCommand, + valueListenable: activeVoiceOrchestrator!.isListeningForCommand, builder: (_, listening, __) => ValueListenableBuilder( - 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 { ), ), // Dernier texte TTS - if (activeOrchestrator != null) + if (activeVoiceOrchestrator != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: ValueListenableBuilder( - valueListenable: activeOrchestrator!.lastTtsText, + valueListenable: activeVoiceOrchestrator!.lastTtsText, builder: (_, text, __) => text.isEmpty ? const SizedBox.shrink() : Container( @@ -316,6 +317,84 @@ class _GlassesDebugPanelState extends State { ), ), ), + // Photos QR scan + if (activeVoiceOrchestrator != null) + ValueListenableBuilder( + 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>( + 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( diff --git a/lib/Components/GlassesStatusWidget.dart b/lib/Components/GlassesStatusWidget.dart new file mode 100644 index 0000000..9d7835e --- /dev/null +++ b/lib/Components/GlassesStatusWidget.dart @@ -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( + builder: (context, controller, _) { + return ValueListenableBuilder( + 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), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/Components/SliderImages.dart b/lib/Components/SliderImages.dart index 708ce4e..544aefe 100644 --- a/lib/Components/SliderImages.dart +++ b/lib/Components/SliderImages.dart @@ -67,11 +67,19 @@ class _SliderImagesWidget extends State { enlargeCenterPage: true, reverse: false, ), - items: resourcesInWidget.map((i) { + items: resourcesInWidget.asMap().entries.map((entry) { + int idx = entry.key; + ResourceModel? i = entry.value; return Builder( builder: (BuildContext context) { AppContext appContext = Provider.of(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]); diff --git a/lib/Components/VoiceModeSheet.dart b/lib/Components/VoiceModeSheet.dart new file mode 100644 index 0000000..615960c --- /dev/null +++ b/lib/Components/VoiceModeSheet.dart @@ -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 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( + 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( + 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( + 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, + ), + ); + } +} diff --git a/lib/Helpers/DatabaseHelper.dart b/lib/Helpers/DatabaseHelper.dart index 4dfa074..c5775b7 100644 --- a/lib/Helpers/DatabaseHelper.dart +++ b/lib/Helpers/DatabaseHelper.dart @@ -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 _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)) ) '''); diff --git a/lib/Models/AssistantResponse.dart b/lib/Models/AssistantResponse.dart index 76cc6c5..c00bde6 100644 --- a/lib/Models/AssistantResponse.dart +++ b/lib/Models/AssistantResponse.dart @@ -38,11 +38,13 @@ class AssistantResponse { final String reply; final List? cards; final AssistantNavigationAction? navigation; + final bool expectsReply; const AssistantResponse({ required this.reply, this.cards, this.navigation, + this.expectsReply = true, }); factory AssistantResponse.fromJson(Map json) => @@ -55,5 +57,6 @@ class AssistantResponse { ? AssistantNavigationAction.fromJson( json['navigation'] as Map) : null, + expectsReply: json['expectsReply'] as bool? ?? true, ); } diff --git a/lib/Screens/Home/home_3.0.dart b/lib/Screens/Home/home_3.0.dart index 3f116a1..f5fedc9 100644 --- a/lib/Screens/Home/home_3.0.dart +++ b/lib/Screens/Home/home_3.0.dart @@ -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 with WidgetsBindingObserver { int currentIndex = 0; late List configurations = []; + List<_WeightedConfig> weightedConfigs = []; List alreadyDownloaded = []; late VisitAppContext visitAppContext; - late Future?> _futureConfigurations; + late Future?> _futureConfigurations; @override void initState() { @@ -53,15 +74,19 @@ class _HomePage3State extends State 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('
', ' '); + 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 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 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 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 with WidgetsBindingObserver { if (snapshot.connectionState == ConnectionState.done) { final mobileConfigIds = visitAppContext.applicationInstanceDTO ?.configurations?.map((c) => c.configurationId).toSet() ?? {}; - configurations = List.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 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 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 with WidgetsBindingObserver { ); } - Future?> getConfigurationsCall(AppContext appContext) async { + Future?> getConfigurationsCall(AppContext appContext) async { bool isOnline = await hasNetwork(); VisitAppContext visitAppContext = appContext.getContext(); @@ -588,7 +657,21 @@ class _HomePage3State extends State 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 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"); } diff --git a/lib/Screens/Sections/Event/event_page.dart b/lib/Screens/Sections/Event/event_page.dart index de153a7..db115a2 100644 --- a/lib/Screens/Sections/Event/event_page.dart +++ b/lib/Screens/Sections/Event/event_page.dart @@ -83,7 +83,6 @@ class _EventPageState extends State { 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 { children: [ if (hasProgramme) _buildProgrammeSection(), if (hasMapSection) _buildMapParcoursSection(context, annotations), - if (hasDescription) _buildDescriptionSection(), const SizedBox(height: 48), ], ), @@ -744,34 +742,6 @@ class _EventPageState extends State { ); } - // ─── 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( diff --git a/lib/Screens/Sections/Game/game_page.dart b/lib/Screens/Sections/Game/game_page.dart index 49ca77d..ffc6b60 100644 --- a/lib/Screens/Sections/Game/game_page.dart +++ b/lib/Screens/Sections/Game/game_page.dart @@ -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 { } 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()); diff --git a/lib/Screens/Sections/GuidedPath/guided_path_audio_player.dart b/lib/Screens/Sections/GuidedPath/guided_path_audio_player.dart new file mode 100644 index 0000000..8205de5 --- /dev/null +++ b/lib/Screens/Sections/GuidedPath/guided_path_audio_player.dart @@ -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 createState() => _GuidedPathAudioPlayerState(); +} + +class _GuidedPathAudioPlayerState extends State { + 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 _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(widget.accentColor), + ), + ), + ], + ), + ); + } +} diff --git a/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart b/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart index 12ae63f..fae7fab 100644 --- a/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart +++ b/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart @@ -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 { late List _steps; - int _currentStepIndex = 0; + int _index = 0; final Set _completedStepIds = {}; - List _stepQuestions = []; - bool _quizCompleted = false; - bool _quizPassed = false; + GuidedStepChallengeController? _challenge; + bool _expiredShown = false; - bool _inGeoZone = false; - StreamSubscription? _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? 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 _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() + : []; - // ── 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)), + ), + ), + ], ), ); } diff --git a/lib/Screens/Sections/GuidedPath/guided_path_end_view.dart b/lib/Screens/Sections/GuidedPath/guided_path_end_view.dart new file mode 100644 index 0000000..dccec36 --- /dev/null +++ b/lib/Screens/Sections/GuidedPath/guided_path_end_view.dart @@ -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 _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 _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)), + ), + ); +} diff --git a/lib/Screens/Sections/GuidedPath/guided_path_geo.dart b/lib/Screens/Sections/GuidedPath/guided_path_geo.dart new file mode 100644 index 0000000..e16df50 --- /dev/null +++ b/lib/Screens/Sections/GuidedPath/guided_path_geo.dart @@ -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 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 _pairs(dynamic points) { + if (points is! List) return const []; + final result = []; + 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 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; +} diff --git a/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart b/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart index 169d0a6..99f40e2 100644 --- a/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart +++ b/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart @@ -1,15 +1,17 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; -import 'package:mymuseum_visitapp/Models/ResponseSubDTO.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; -import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.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_path_geo.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_challenge.dart'; import 'package:mymuseum_visitapp/Services/pushNotificationService.dart'; -import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart'; import 'package:mymuseum_visitapp/constants.dart'; class GuidedPathMapProgressionPage extends StatefulWidget { @@ -25,46 +27,50 @@ class GuidedPathMapProgressionPage extends StatefulWidget { }) : super(key: key); @override - State createState() => _GuidedPathMapProgressionPageState(); + State createState() => + _GuidedPathMapProgressionPageState(); } class _GuidedPathMapProgressionPageState extends State with SingleTickerProviderStateMixin { late List _steps; - int _currentStepIndex = 0; + int _index = 0; final Set _completedStepIds = {}; - // Quiz state for current step - List _stepQuestions = []; - bool _quizCompleted = false; - bool _quizPassed = false; + GuidedStepChallengeController? _challenge; + bool _expiredShown = false; + bool _showDetail = false; - // Geo trigger state LatLng? _userPosition; bool _inGeoZone = false; + double? _distanceMeters; + bool _geoUnavailable = false; StreamSubscription? _positionSub; - // Map final MapController _mapController = MapController(); + late AnimationController _pulse; + late Animation _pulseAnim; - // Pulsing animation for current step marker - late AnimationController _pulseController; - late Animation _pulseAnimation; + static const _gold = Color(0xFFD4AF37); + static const _green = Color(0xFF2E9E6B); + + bool get _isGame => widget.path.isGameMode == true; + Color get _accent => _isGame ? _gold : kMainColor; + Color get _ink => _isGame ? const Color(0xFFF4ECD8) : const Color(0xFF1E2A33); + Color get _muted => _isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86); + Color get _detailBg => _isGame ? const Color(0xFF0B1018) : const Color(0xFFF6F3EE); + + GuidedStepDTO? get _currentStep => _steps.isEmpty ? null : _steps[_index]; @override void initState() { super.initState(); _steps = [...(widget.path.steps ?? [])] ..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); - - _pulseController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - )..repeat(reverse: true); - _pulseAnimation = Tween(begin: 1.0, end: 1.4).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - + _pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 900)) + ..repeat(reverse: true); + _pulseAnim = Tween(begin: 1.0, end: 1.4) + .animate(CurvedAnimation(parent: _pulse, curve: Curves.easeInOut)); _initStepState(); _startLocationTracking(); } @@ -72,697 +78,905 @@ class _GuidedPathMapProgressionPageState extends State 0; + _challenge = GuidedStepChallengeController( + questions: questions, + isGame: _isGame, + requireSuccess: widget.path.requireSuccessToAdvance ?? false, + hasTimer: hasTimer, + timerSeconds: step.timerSeconds ?? 0, + visitAppContext: widget.visitAppContext, + )..addListener(_onChallengeChanged); - 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 = []; - } - - // Re-check geo zone with current position if (_userPosition != null) _checkGeoZone(_userPosition!); - - // Center map on step if it has geometry - _centerMapOnCurrentStep(); + _centerOnCurrent(); } - void _centerMapOnCurrentStep() { - final coords = _currentStep?.geometry?.type == 'Point' - ? _currentStep!.geometry!.coordinates as List? - : null; - if (coords != null && coords.length >= 2) { - final lat = (coords[1] as num).toDouble(); - final lon = (coords[0] as num).toDouble(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _mapController.move(LatLng(lat, lon), _mapController.camera.zoom); - }); + 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? list) => + TranslationHelper.get(list, widget.visitAppContext); + + bool _hasGeoTrigger(GuidedStepDTO step) => + step.isGeoTriggered == true && + step.geometry != null && + (step.zoneRadiusMeters ?? 0) > 0; + bool get _canAdvance { final step = _currentStep; - if (step == null) return false; - if (step.isStepLocked == true) return false; - - // Geo trigger required - if (_hasGeoTrigger(step) && !_inGeoZone) return false; - - // Quiz required to pass - if ((widget.path.requireSuccessToAdvance ?? false) && - step.quizQuestions?.isNotEmpty == true && - !_quizPassed) return false; - - return true; + if (step == null || step.isStepLocked == true) return false; + final requireSuccess = widget.path.requireSuccessToAdvance ?? false; + final zoneOk = !_hasGeoTrigger(step) || _inGeoZone || !requireSuccess || _geoUnavailable; + return (_challenge?.canAdvance ?? true) && zoneOk; } + // ─── Navigation ─────────────────────────────────────────────────────────── + 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() { - 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'), - ), - ], - ), - ); + void _openChallenge() { + setState(() => _showDetail = true); + 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), + )); + } } - // ─── Geo tracking ──────────────────────────────────────────────────────── + 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(); + Navigator.of(context).pop(); + }, + ), + )); + } + + // ─── Géoloc ─────────────────────────────────────────────────────────────── Future _startLocationTracking() async { - bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (!serviceEnabled) return; - - LocationPermission permission = await Geolocator.checkPermission(); + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + if (mounted) setState(() => _geoUnavailable = true); + return; + } + var permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied) return; } - if (permission == LocationPermission.deniedForever) return; - + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + if (mounted) setState(() => _geoUnavailable = true); + return; + } + if (mounted) setState(() => _geoUnavailable = false); _positionSub = Geolocator.getPositionStream( - locationSettings: const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5), + locationSettings: + const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5), ).listen((pos) { if (!mounted) return; - final newPos = LatLng(pos.latitude, pos.longitude); - setState(() => _userPosition = newPos); - _checkGeoZone(newPos); + final p = LatLng(pos.latitude, pos.longitude); + setState(() => _userPosition = p); + _checkGeoZone(p); }); } + void _retryGeo() { + _positionSub?.cancel(); + _startLocationTracking(); + } + void _checkGeoZone(LatLng position) { if (!mounted) return; final step = _currentStep; if (step == null || !_hasGeoTrigger(step)) return; + final center = GuidedPathGeo.center(step.geometry); + if (center == null) return; - LatLng? triggerCenter; - double radius = step.zoneRadiusMeters ?? 30; - - if (step.geometry?.type == 'Point') { - final coords = step.geometry!.coordinates as List; - triggerCenter = LatLng((coords[1] as num).toDouble(), (coords[0] as num).toDouble()); - } else if (step.triggerGeoPoint?.geometry?.type == 'Point') { - final coords = step.triggerGeoPoint!.geometry!.coordinates as List; - triggerCenter = LatLng((coords[1] as num).toDouble(), (coords[0] as num).toDouble()); - } - - if (triggerCenter == null) return; - - final distMeters = const Distance().distance(position, triggerCenter); - final inZone = distMeters <= radius; + final radius = step.zoneRadiusMeters ?? 0; + final dist = const Distance().distance(position, center); + final inZone = dist <= radius; if (inZone && !_inGeoZone) { - PushNotificationService.showGeoZoneNotification( - stepTitle: _translate(_currentStep!.title), + PushNotificationService.showGeoZoneNotification(stepTitle: _translate(step.title)); + } + setState(() { + _distanceMeters = dist; + _inGeoZone = inZone; + }); + } + + // ─── Carte ────────────────────────────────────────────────────────────────── + + List get _visibleSteps => (widget.path.hideNextStepsUntilComplete ?? false) + ? _steps.sublist(0, _index + 1) + : _steps; + + List get _polylinePositions => _visibleSteps + .map((s) => GuidedPathGeo.center(s.geometry)) + .whereType() + .toList(); + + void _centerOnCurrent() { + final center = GuidedPathGeo.center(_currentStep?.geometry); + if (center == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + _mapController.move(center, _mapController.camera.zoom); + } catch (_) {} + }); + } + + void _fitToSteps() { + final pts = _polylinePositions; + if (pts.length < 2) return; + try { + _mapController.fitCamera( + CameraFit.bounds( + bounds: LatLngBounds.fromPoints(pts), + padding: const EdgeInsets.all(70), + maxZoom: 17, + ), ); - } - if (inZone != _inGeoZone) { - setState(() => _inGeoZone = inZone); - } + } catch (_) {} } - bool _hasGeoTrigger(GuidedStepDTO step) => - (step.geometry?.type == 'Point') || - (step.triggerGeoPointId != null && step.triggerGeoPoint != null); - - // ─── Helpers ───────────────────────────────────────────────────────────── - - GuidedStepDTO? get _currentStep => - _steps.isEmpty ? null : _steps[_currentStepIndex]; - - String _translate(List? list) => - TranslationHelper.get(list, widget.visitAppContext); - - // ─── Map ───────────────────────────────────────────────────────────────── - - List _buildStepMarkers() { - final markers = []; - for (int i = 0; i < _steps.length; i++) { - final step = _steps[i]; - final isCompleted = _completedStepIds.contains(step.id); - final isCurrent = i == _currentStepIndex; - final hideNext = widget.path.hideNextStepsUntilComplete ?? false; - final isVisible = !hideNext || isCompleted || isCurrent; - if (!isVisible) continue; - - final coords = step.geometry?.type == 'Point' && step.geometry?.coordinates is List - ? step.geometry!.coordinates as List - : null; - if (coords == null || coords.length < 2) continue; - - final lat = (coords[1] as num).toDouble(); - final lon = (coords[0] as num).toDouble(); - - markers.add(Marker( - point: LatLng(lat, lon), - width: 44, - height: 44, - child: GestureDetector( - onTap: isCurrent ? null : null, // tapping non-current steps could scroll sheet - child: isCurrent - ? AnimatedBuilder( - animation: _pulseAnimation, - builder: (_, __) => Transform.scale( - scale: _pulseAnimation.value, - child: const _StepPin(state: _StepPinState.current), - ), - ) - : _StepPin( - state: isCompleted - ? _StepPinState.completed - : step.isStepLocked == true - ? _StepPinState.locked - : _StepPinState.upcoming, - ), - ), - )); - } - return markers; + String _formatDistance(double m) { + if (m < 1000) return '${m.round()} m'; + return '${(m / 1000).toStringAsFixed(m < 10000 ? 1 : 0)} km'; } - Marker? _buildUserMarker() { - if (_userPosition == null) return null; - return Marker( - point: _userPosition!, - width: 36, - height: 36, - child: Container( - decoration: BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [BoxShadow(color: Colors.blue.withOpacity(0.4), blurRadius: 8, spreadRadius: 2)], - ), - child: const Icon(Icons.person, color: Colors.white, size: 18), - ), - ); - } + String _stripHtml(String html) => html + .replaceAll(RegExp(r'<[^>]*>'), ' ') + .replaceAll(' ', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); - // ─── Build ─────────────────────────────────────────────────────────────── + // ─── Build ──────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { - final center = widget.mapDTO.latitude != null && widget.mapDTO.longitude != null - ? LatLng(double.tryParse(widget.mapDTO.latitude!)!, double.tryParse(widget.mapDTO.longitude!)!) - : const LatLng(50.465503, 4.865105); - final zoom = widget.mapDTO.zoom?.toDouble() ?? 15.0; + final step = _currentStep; + if (step == null) { + return const Scaffold(body: Center(child: Text('Aucune étape'))); + } + final challenge = _challenge!; - final userMarker = _buildUserMarker(); - final stepMarkers = _buildStepMarkers(); + final fallback = widget.mapDTO.latitude != null && widget.mapDTO.longitude != null + ? LatLng(double.tryParse(widget.mapDTO.latitude!) ?? 50.4655, + double.tryParse(widget.mapDTO.longitude!) ?? 4.8651) + : const LatLng(50.465503, 4.865105); + final initialCenter = _polylinePositions.isNotEmpty ? _polylinePositions.first : fallback; + final zoom = widget.mapDTO.zoom?.toDouble() ?? 15.0; return Scaffold( body: Stack( children: [ - // ── Carte plein écran ────────────────────────────────────────── FlutterMap( mapController: _mapController, options: MapOptions( - initialCenter: center, + initialCenter: initialCenter, initialZoom: zoom, + onMapReady: _fitToSteps, ), children: [ TileLayer( urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', userAgentPackageName: 'be.unov.myinfomate.visitapp', ), - MarkerLayer( - markers: [ - ...stepMarkers, - if (userMarker != null) userMarker, - ], - ), + ..._buildShapeLayers(), + if (_polylinePositions.length > 1) + PolylineLayer( + polylines: [ + Polyline( + points: _polylinePositions, + color: _accent.withOpacity(0.85), + strokeWidth: 4, + pattern: StrokePattern.dashed(segments: const [8.0, 6.0]), + ), + ], + ), + MarkerLayer(markers: _buildMarkers()), ], ), - // ── Bouton retour ────────────────────────────────────────────── - Positioned( - top: MediaQuery.of(context).padding.top + 10, - left: 10, - child: _CircleButton( - icon: Icons.arrow_back, - onTap: () => Navigator.of(context).pop(), - ), - ), + _buildTopHeader(), + if (!_showDetail) _buildBottomSheet(step), + if (_showDetail) _buildDetailOverlay(step), - // ── Badge progression ────────────────────────────────────────── - Positioned( - top: MediaQuery.of(context).padding.top + 10, - right: 10, - child: _ProgressBadge( - current: _currentStepIndex + 1, - total: _steps.length, - completedIds: _completedStepIds, - steps: _steps, - ), - ), - - // ── Bottom sheet drag ────────────────────────────────────────── - DraggableScrollableSheet( - initialChildSize: 0.22, - minChildSize: 0.12, - maxChildSize: 0.75, - snap: true, - snapSizes: const [0.22, 0.75], - builder: (_, scrollController) => _buildSheet(scrollController), + GuidedStepChallengeOverlay( + controller: challenge, + stepIndex: _index, + accentColor: _accent, + isGame: _isGame, + visitAppContext: widget.visitAppContext, ), ], ), ); } - Widget _buildSheet(ScrollController scrollController) { - final step = _currentStep; - if (step == null) return const SizedBox(); - - final title = _translate(step.title); - final desc = _translate(step.description); - final hasGeo = _hasGeoTrigger(step); - final hasQuiz = step.quizQuestions?.isNotEmpty == true; - final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0; - - return Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10)], - ), - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Handle - Center( - child: Padding( - padding: const EdgeInsets.only(top: 10, bottom: 6), - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - ), - ), - - // ── Peek row ──────────────────────────────────────────── - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - if (hasGeo && !_inGeoZone) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.location_searching, size: 14, color: Colors.orange[700]), - const SizedBox(width: 4), - Text( - 'Zone requise', - style: TextStyle(fontSize: 12, color: Colors.orange[700]), - ), - ], - ), - if (hasTimer) - Padding( - padding: const EdgeInsets.only(left: 8), - child: GuidedStepTimer( - key: ValueKey(step.id), - seconds: step.timerSeconds!, - expiredMessage: _translate(step.timerExpiredMessage), - showAsBar: false, - ), - ), - ], - ), - ), - - const Divider(height: 20), - - // ── Détail étendu ─────────────────────────────────────── - - // Image - if (step.imageUrl != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.network( - step.imageUrl!, - height: 160, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - ), - - // Locked - if (step.isStepLocked == true) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - 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) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Text(desc, style: const TextStyle(fontSize: 14, height: 1.5)), - ), - - // Timer (mode barre, dans la version étendue) - if (hasTimer) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: GuidedStepTimer( - key: ValueKey('bar_${step.id}'), - seconds: step.timerSeconds!, - expiredMessage: _translate(step.timerExpiredMessage), - showAsBar: true, - ), - ), - - // Géo trigger - if (hasGeo) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: _GeoZoneIndicator(inZone: _inGeoZone), - ), - - // Quiz - if (hasQuiz && !_quizCompleted) - SizedBox( - height: 340, - child: QuestionsListWidget( - questionsSubDTO: _stepQuestions, - isShowResponse: false, - onShowResponse: () { - final goodCount = _stepQuestions.where((q) => - q.chosen != null && - q.chosen == q.responsesSubDTO!.indexWhere((r) => r.isGood == true), - ).length; - setState(() { - _quizCompleted = true; - _quizPassed = goodCount == _stepQuestions.length; - }); - }, - orientation: MediaQuery.of(context).orientation, - ), - ), - - if (hasQuiz && _quizCompleted) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: 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'), - ), - ], - ], - ), - ), - - // ── Boutons navigation ─────────────────────────────────── - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), - 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', - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -// ─── Widgets auxiliaires ─────────────────────────────────────────────────── - -enum _StepPinState { current, completed, upcoming, locked } - -class _StepPin extends StatelessWidget { - final _StepPinState state; - const _StepPin({required this.state}); - - @override - Widget build(BuildContext context) { - switch (state) { - case _StepPinState.completed: - return const CircleAvatar( - backgroundColor: Colors.green, - radius: 18, - child: Icon(Icons.check, color: Colors.white, size: 18), - ); - case _StepPinState.current: - return const CircleAvatar( - backgroundColor: Colors.blue, - radius: 18, - child: Icon(Icons.location_on, color: Colors.white, size: 18), - ); - case _StepPinState.locked: - return CircleAvatar( - backgroundColor: Colors.grey[400], - radius: 18, - child: const Icon(Icons.lock, color: Colors.white, size: 16), - ); - case _StepPinState.upcoming: - return CircleAvatar( - backgroundColor: Colors.grey[300], - radius: 18, - child: Icon(Icons.location_on, color: Colors.grey[600], size: 18), - ); + List _buildShapeLayers() { + final polylines = []; + final polygons = []; + for (final s in _visibleSteps) { + final shape = GuidedPathGeo.shape(s.geometry); + if (shape == null) continue; + final completed = s.id != null && _completedStepIds.contains(s.id); + final current = s.id == _currentStep?.id; + final color = completed ? _green : (current ? _accent : Colors.grey); + final opacity = (!completed && !current) ? 0.5 : 0.9; + if (shape.type == 'LineString') { + polylines.add(Polyline( + points: shape.positions, + color: color.withOpacity(opacity), + strokeWidth: 5, + )); + } else { + polygons.add(Polygon( + points: shape.positions, + color: color.withOpacity(0.15), + borderColor: color.withOpacity(opacity), + borderStrokeWidth: 3, + )); + } } + return [ + if (polygons.isNotEmpty) PolygonLayer(polygons: polygons), + if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), + ]; } -} -class _CircleButton extends StatelessWidget { - final IconData icon; - final VoidCallback onTap; - const _CircleButton({required this.icon, required this.onTap}); + List _buildMarkers() { + final markers = []; + var visibleOrder = 0; + for (int i = 0; i < _steps.length; i++) { + final step = _steps[i]; + final visible = _visibleSteps.contains(step); + if (!visible) continue; + visibleOrder++; + final center = GuidedPathGeo.center(step.geometry); + if (center == null) continue; - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( + final completed = step.id != null && _completedStepIds.contains(step.id); + final current = i == _index; + final locked = step.isStepLocked == true; + + Widget pin; + if (current) { + pin = AnimatedBuilder( + animation: _pulseAnim, + builder: (_, __) => Transform.scale( + scale: _pulseAnim.value, + child: _numberedPin('$visibleOrder', _accent, _isGame), + ), + ); + } else if (completed) { + pin = _iconPin(Icons.check, _green); + } else if (locked) { + pin = _iconPin(Icons.lock, Colors.grey.shade500); + } else { + pin = _numberedPin('$visibleOrder', Colors.grey.shade500, false); + } + + markers.add(Marker( + point: center, width: 44, height: 44, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6)], + child: pin, + )); + } + + if (_userPosition != null) { + markers.add(Marker( + point: _userPosition!, + width: 26, + height: 26, + child: Container( + decoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow(color: Colors.blue.withOpacity(0.4), blurRadius: 8, spreadRadius: 2) + ], + ), ), - child: Icon(icon, size: 22), + )); + } + return markers; + } + + Widget _numberedPin(String label, Color color, bool goldText) { + return Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 5)], ), + child: Text(label, + style: TextStyle( + color: goldText ? const Color(0xFF1a1305) : Colors.white, + fontWeight: FontWeight.w800, + fontSize: 14)), ); } -} -class _ProgressBadge extends StatelessWidget { - final int current; - final int total; - final Set completedIds; - final List steps; - - const _ProgressBadge({ - required this.current, - required this.total, - required this.completedIds, - required this.steps, - }); - - @override - Widget build(BuildContext context) { + Widget _iconPin(IconData icon, Color color) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + width: 34, + height: 34, decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6)], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '$current / $total', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(total, (i) { - final isCompleted = steps[i].id != null && completedIds.contains(steps[i].id); - final isCurrent = i == current - 1; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isCompleted - ? Colors.green - : isCurrent - ? Colors.blue - : Colors.grey[300], - ), - ), - ); - }), - ), - ], + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 5)], ), + child: Icon(icon, color: Colors.white, size: 18), ); } -} -class _GeoZoneIndicator extends StatelessWidget { - final bool inZone; - const _GeoZoneIndicator({required this.inZone}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: inZone ? Colors.green.withOpacity(0.1) : Colors.orange.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: inZone ? Colors.green : Colors.orange), - ), + Widget _buildTopHeader() { + return Positioned( + top: MediaQuery.of(context).padding.top + 12, + left: 14, + right: 14, child: Row( - mainAxisSize: MainAxisSize.min, children: [ - Icon( - inZone ? Icons.location_on : Icons.location_searching, - color: inZone ? Colors.green : Colors.orange, - size: 18, + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.9), + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.12), blurRadius: 8)], + ), + child: const Icon(Icons.arrow_back_ios_new, size: 16, color: Color(0xFF1E2A33)), + ), ), - const SizedBox(width: 8), - Text( - inZone ? 'Vous êtes dans la zone ✓' : 'Approchez-vous du point indiqué', - style: TextStyle( - color: inZone ? Colors.green[700] : Colors.orange[700], - fontSize: 13, + const SizedBox(width: 10), + Expanded( + child: Container( + height: 40, + padding: const EdgeInsets.only(left: 14, right: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.9), + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.12), blurRadius: 8)], + ), + child: Row( + children: [ + Expanded( + child: Text(_translate(widget.path.title), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF1E2A33))), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _accent.withOpacity(0.12), + borderRadius: BorderRadius.circular(9), + ), + child: Text('${_index + 1} / ${_steps.length}', + style: TextStyle( + color: _accent, fontSize: 12, fontWeight: FontWeight.w700)), + ), + ], + ), ), ), ], ), ); } + + Widget _buildBottomSheet(GuidedStepDTO step) { + final title = _translate(step.title); + final hasQuiz = _challenge?.hasQuiz ?? false; + final progress = _steps.length > 1 ? _index / (_steps.length - 1) : 0.0; + + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(22)), + boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 24, offset: Offset(0, -8))], + ), + padding: EdgeInsets.only(bottom: 18 + MediaQuery.of(context).padding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: const Color(0xFFE2DCD2), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + GestureDetector( + onTap: () => setState(() => _showDetail = true), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 4, 18, 0), + child: Row( + children: [ + _stepThumb(step), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('ÉTAPE EN COURS', + style: TextStyle( + color: _accent, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.5)), + const SizedBox(height: 3), + Text(title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFF1E2A33), + fontSize: 18, + fontWeight: FontWeight.w600, + height: 1.15)), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF6B7B86)), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 12, 18, 0), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress.clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: const Color(0xFFEEE9E0), + valueColor: AlwaysStoppedAnimation(_accent), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 12, 18, 0), + child: Row( + children: [ + if (_index > 0 && !(widget.path.isLinear ?? false)) ...[ + Expanded( + child: OutlinedButton( + onPressed: _goBack, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF46555F), + side: const BorderSide(color: Color(0xFFE2DCD2), width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + child: const Text('Précédente', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700)), + ), + ), + const SizedBox(width: 11), + ], + if (hasQuiz) ...[ + Expanded( + child: OutlinedButton.icon( + onPressed: () => setState(() => _showDetail = true), + style: OutlinedButton.styleFrom( + foregroundColor: _accent, + side: BorderSide(color: _accent.withOpacity(0.5), width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + icon: Icon(Icons.star, size: 13, color: _accent), + label: const Text('Défi', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700)), + ), + ), + const SizedBox(width: 11), + ], + Expanded( + flex: 2, + child: ElevatedButton.icon( + onPressed: _canAdvance ? _advance : () => setState(() => _showDetail = true), + style: ElevatedButton.styleFrom( + backgroundColor: + _canAdvance ? _accent : _accent.withOpacity(0.35), + foregroundColor: _isGame ? const Color(0xFF1a1305) : Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + icon: const Icon(Icons.flag, size: 14), + label: Text(_index < _steps.length - 1 ? 'Étape suivante' : 'Terminer', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700)), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _stepThumb(GuidedStepDTO step) { + return Container( + width: 72, + height: 72, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_accent.withOpacity(0.6), _accent], + ), + ), + clipBehavior: Clip.antiAlias, + child: step.imageUrl != null + ? Image.network(step.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _thumbBadge()) + : _thumbBadge(), + ); + } + + Widget _thumbBadge() => Center( + child: Text('${_index + 1}', + style: TextStyle( + color: _isGame ? const Color(0xFF1a1305) : Colors.white, + fontSize: 22, + fontWeight: FontWeight.w800)), + ); + + Widget _buildDetailOverlay(GuidedStepDTO step) { + final title = _translate(step.title); + final desc = _translate(step.description); + final hasQuiz = _challenge?.hasQuiz ?? false; + final hasGeo = _hasGeoTrigger(step); + final radius = (step.zoneRadiusMeters ?? 0).round(); + + 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; + }(); + + return Positioned.fill( + child: Container( + color: _detailBg, + child: SafeArea( + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 14, 8), + child: Row( + children: [ + GestureDetector( + onTap: () => setState(() => _showDetail = false), + 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 : _green) + : i == _index + ? _accent + : (_isGame + ? Colors.white.withOpacity(0.1) + : const Color(0xFFE2DCD2)), + ), + ), + ); + }), + ), + ), + const SizedBox(width: 10), + Text('${_index + 1} / ${_steps.length}', + style: TextStyle( + color: _isGame ? _gold : const Color(0xFF46555F), + fontSize: 13, + fontWeight: FontWeight.w700)), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (step.imageUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.network(step.imageUrl!, + height: 200, width: double.infinity, fit: BoxFit.cover), + ), + if (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), + 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)), + if (hasGeo) ...[ + const SizedBox(height: 16), + _buildGeoIndicator(radius), + ], + ], + ), + ), + ), + _buildDetailBottomBar(hasQuiz), + ], + ), + ), + ), + ); + } + + Widget _buildGeoIndicator(int radius) { + final String label; + if (_inGeoZone) { + label = 'Vous êtes dans la zone'; + } else if (_distanceMeters != null) { + label = 'À ${_formatDistance(_distanceMeters!)} de la zone ($radius m)'; + } else { + label = 'Zone à atteindre · $radius m'; + } + final color = _inGeoZone ? _green : _accent; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: color.withOpacity(_inGeoZone ? 0.14 : 0.12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.location_on, size: 14, color: color), + const SizedBox(width: 6), + Text(label, style: TextStyle(color: color, fontSize: 12.5)), + ], + ), + ), + if (_geoUnavailable) ...[ + const SizedBox(height: 8), + GestureDetector( + onTap: _retryGeo, + child: const Text('Localisation indisponible — réessayer', + style: TextStyle( + color: Color(0xFFD14343), + fontSize: 12, + decoration: TextDecoration.underline)), + ), + ], + ], + ); + } + + Widget _buildDetailBottomBar(bool hasQuiz) { + return Container( + padding: EdgeInsets.fromLTRB(18, 12, 18, 18 + MediaQuery.of(context).padding.bottom), + child: Row( + children: [ + 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: 14, 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 + ? () { + setState(() => _showDetail = false); + _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)), + ), + ), + ], + ), + ); + } + + void _showExpiredModal(String html) { + showDialog( + context: context, + 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)), + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/Screens/Sections/GuidedPath/guided_path_puzzle.dart b/lib/Screens/Sections/GuidedPath/guided_path_puzzle.dart new file mode 100644 index 0000000..c4c9209 --- /dev/null +++ b/lib/Screens/Sections/GuidedPath/guided_path_puzzle.dart @@ -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 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 { + 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)), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/Screens/Sections/GuidedPath/guided_path_sliding_puzzle.dart b/lib/Screens/Sections/GuidedPath/guided_path_sliding_puzzle.dart new file mode 100644 index 0000000..7366650 --- /dev/null +++ b/lib/Screens/Sections/GuidedPath/guided_path_sliding_puzzle.dart @@ -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 createState() => _GuidedPathSlidingPuzzleState(); +} + +class _GuidedPathSlidingPuzzleState extends State { + // tiles[slot] = pièce d'origine occupant ce slot ; -1 = case vide. + late List _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 _neighboursOf(int idx) { + final row = idx ~/ widget.cols; + final col = idx % widget.cols; + final list = []; + 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), + ), + ), + ), + ), + ); + } +} diff --git a/lib/Screens/Sections/GuidedPath/guided_step_challenge.dart b/lib/Screens/Sections/GuidedPath/guided_step_challenge.dart new file mode 100644 index 0000000..fe7c2d4 --- /dev/null +++ b/lib/Screens/Sections/GuidedPath/guided_step_challenge.dart @@ -0,0 +1,1016 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_widget_from_html/flutter_widget_from_html.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_puzzle.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_sliding_puzzle.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart'; + +enum _Kind { mcq, puzzle, simple } + +_Kind _kindOf(QuizQuestion q) { + switch (q.validationQuestionType?.value) { + case 2: + return _Kind.puzzle; + case 0: + return _Kind.simple; + default: + return _Kind.mcq; + } +} + +String _stripHtml(String html) => html + .replaceAll(RegExp(r'<[^>]*>'), ' ') + .replaceAll(' ', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + +String _normalize(String text) { + const accents = 'àâäáãåçèéêëìîïíòôöóõùûüúÿñ'; + const plain = 'aaaaaaceeeeiiiiooooouuuuyn'; + var s = _stripHtml(text).toLowerCase(); + final buffer = StringBuffer(); + for (final ch in s.split('')) { + final idx = accents.indexOf(ch); + buffer.write(idx >= 0 ? plain[idx] : ch); + } + return buffer.toString().trim(); +} + +/// Une réponse "Simple" purement numérique s'affiche comme un digicode +/// (pavé de boutons) plutôt qu'un champ de texte libre. +bool _isDigicode(String expected) => + expected.isNotEmpty && RegExp(r'^[0-9]+$').hasMatch(expected); + +/// État + logique d'un défi d'étape (QCM / puzzle / réponse ouverte), en miroir +/// de `ProgressView`/`ChallengeModal`/`StepQuiz` de visitapp-web. Le décompte du +/// timer vit ici (il démarre à l'ouverture du défi et continue même overlay fermé). +class GuidedStepChallengeController extends ChangeNotifier { + final List questions; + final bool isGame; + final bool requireSuccess; + final bool hasTimer; + final int timerSeconds; + final VisitAppContext visitAppContext; + + GuidedStepChallengeController({ + required this.questions, + required this.isGame, + required this.requireSuccess, + required this.hasTimer, + required this.timerSeconds, + required this.visitAppContext, + }) { + _remaining = timerSeconds; + } + + /// Construit la liste combinée et triée des questions valides d'une étape. + static List buildQuestions(GuidedStepDTO step) { + final all = [...(step.quizQuestions ?? [])]; + final valid = all.where((q) { + switch (_kindOf(q)) { + case _Kind.puzzle: + return q.puzzleImage?.url != null; + case _Kind.simple: + return q.responses.isNotEmpty; + case _Kind.mcq: + return q.responses.isNotEmpty; + } + }).toList() + ..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); + return valid; + } + + final Map _mcq = {}; + final Set _puzzle = {}; + final Map _simple = {}; + final Set _simpleChecked = {}; + + int challengeIndex = 0; + bool showResult = false; + bool quizPassed = false; + bool open = false; + + bool timerStarted = false; + bool expired = false; + int _remaining = 0; + Timer? _timer; + + int get remaining => _remaining; + bool get hasQuiz => questions.isNotEmpty; + + bool _correct(QuizQuestion q) { + switch (_kindOf(q)) { + case _Kind.puzzle: + return _puzzle.contains(q.id); + case _Kind.simple: + return _simpleCorrect(q, _simple[q.id] ?? ''); + case _Kind.mcq: + return _mcqCorrect(q, _mcq[q.id]); + } + } + + bool _mcqCorrect(QuizQuestion q, int? idx) { + if (idx == null) return false; + final sorted = [...q.responses] + ..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); + return idx >= 0 && idx < sorted.length && sorted[idx].isGood == true; + } + + String _expectedSimple(QuizQuestion q) => + TranslationHelper.getWithResource(q.responses.first.label, visitAppContext); + + bool _simpleCorrect(QuizQuestion q, String given) { + final expected = _normalize(_expectedSimple(q)); + return expected.isNotEmpty && _normalize(given) == expected; + } + + int get wrongCount => questions.where((q) => !_correct(q)).length; + + bool get allAnsweredCorrectly => + hasQuiz && questions.isNotEmpty && wrongCount == 0; + + bool get canAdvance => !hasQuiz || !requireSuccess || quizPassed; + + bool get _timerActive => + timerStarted && !expired && !(hasQuiz && (quizPassed || allAnsweredCorrectly)); + + /// Ouvre l'overlay. Retourne true si le chrono vient d'être démarré. + bool openChallenge() { + open = true; + var started = false; + if (hasTimer && !timerStarted) { + timerStarted = true; + started = true; + _timer = Timer.periodic(const Duration(seconds: 1), _tick); + } + notifyListeners(); + return started; + } + + void close() { + open = false; + notifyListeners(); + } + + void _tick(Timer t) { + if (!_timerActive) { + t.cancel(); + return; + } + if (_remaining <= 1) { + _remaining = 0; + expired = true; + t.cancel(); + notifyListeners(); + return; + } + _remaining -= 1; + notifyListeners(); + } + + void answerMcq(int qId, int sortedIndex) { + _mcq[qId] = sortedIndex; + notifyListeners(); + } + + void solvePuzzle(int qId) { + _puzzle.add(qId); + notifyListeners(); + } + + // Le texte est transitoire jusqu'à la validation : pas de notifyListeners ici + // (le champ de saisie gère son propre état pour éviter les rebuilds). + void setSimple(int qId, String text) { + _simple[qId] = text; + } + + void checkSimple(int qId) { + _simpleChecked.add(qId); + notifyListeners(); + } + + void nextItem() { + if (challengeIndex < questions.length - 1) challengeIndex++; + notifyListeners(); + } + + void submit() { + quizPassed = wrongCount == 0; + showResult = true; + notifyListeners(); + } + + void retryWrong() { + final wrongIds = questions.where((q) => !_correct(q)).map((q) => q.id).toSet(); + _mcq.removeWhere((id, _) => wrongIds.contains(id)); + _simple.removeWhere((id, _) => wrongIds.contains(id)); + _simpleChecked.removeWhere((id) => wrongIds.contains(id)); + final firstWrong = questions.indexWhere((q) => wrongIds.contains(q.id)); + challengeIndex = firstWrong >= 0 ? firstWrong : 0; + showResult = false; + quizPassed = false; + notifyListeners(); + } + + // Lecture d'état par cellule + int? mcqAnswer(int qId) => _mcq[qId]; + bool puzzleSolved(int qId) => _puzzle.contains(qId); + String simpleText(int qId) => _simple[qId] ?? ''; + bool simpleChecked(int qId) => _simpleChecked.contains(qId); + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} + +// ─── Overlay ────────────────────────────────────────────────────────────────── + +class GuidedStepChallengeOverlay extends StatelessWidget { + final GuidedStepChallengeController controller; + final int stepIndex; + final Color accentColor; + final bool isGame; + final VisitAppContext visitAppContext; + + const GuidedStepChallengeOverlay({ + Key? key, + required this.controller, + required this.stepIndex, + required this.accentColor, + required this.isGame, + required this.visitAppContext, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + if (!controller.open) return const SizedBox.shrink(); + final sheetBg = isGame ? const Color(0xFF0B1018) : Colors.white; + + return Positioned.fill( + child: Stack( + children: [ + GestureDetector( + onTap: controller.close, + child: Container(color: Colors.black.withOpacity(0.45)), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + decoration: BoxDecoration( + color: sheetBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: isGame + ? Colors.white.withOpacity(0.15) + : const Color(0xFFD4C9B8), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isGame + ? const Color(0xFFD4AF37).withOpacity(0.12) + : accentColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'DÉFI · ÉTAPE ${stepIndex + 1}', + style: TextStyle( + color: accentColor, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ), + GestureDetector( + onTap: controller.close, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isGame + ? Colors.white.withOpacity(0.08) + : const Color(0xFFF0EBE3), + ), + child: Icon(Icons.close, + size: 16, + color: isGame + ? const Color(0xFF8a8068) + : const Color(0xFF6B7B86)), + ), + ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: Column( + children: [ + if (controller.hasTimer) ...[ + GuidedStepTimer( + totalSeconds: controller.timerSeconds, + remainingSeconds: controller.remaining, + isGame: isGame, + ), + const SizedBox(height: 14), + ], + _StepQuiz( + controller: controller, + accentColor: accentColor, + isGame: isGame, + visitAppContext: visitAppContext, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _StepQuiz extends StatelessWidget { + final GuidedStepChallengeController controller; + final Color accentColor; + final bool isGame; + final VisitAppContext visitAppContext; + + const _StepQuiz({ + required this.controller, + required this.accentColor, + required this.isGame, + required this.visitAppContext, + }); + + Color get _ink => isGame ? const Color(0xFFF4ECD8) : const Color(0xFF1E2A33); + Color get _muted => isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86); + + String _label(List? l) => + TranslationHelper.getWithResource(l, visitAppContext); + + @override + Widget build(BuildContext context) { + final c = controller; + final questions = c.questions; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isGame ? Colors.white.withOpacity(0.04) : const Color(0xFFF9F6F1), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isGame + ? const Color(0xFFD4AF37).withOpacity(0.2) + : const Color(0xFFECE6DC)), + ), + child: c.showResult + ? _buildResult(context) + : questions.isEmpty + ? const SizedBox.shrink() + : _buildQuestion(context), + ); + } + + Widget _buildResult(BuildContext context) { + final c = controller; + if (c.quizPassed) { + return Column( + children: [ + _resultBadge(const Color(0xFF2E9E6B), Icons.check), + const SizedBox(height: 12), + Text('Bien joué !', + style: TextStyle(color: _ink, fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + Text('Continuez votre parcours.', + style: TextStyle(color: _muted, fontSize: 14)), + ], + ); + } + final total = c.questions.length; + final good = total - c.wrongCount; + return Column( + children: [ + _resultBadge(const Color(0xFFD14343), Icons.close), + const SizedBox(height: 12), + Text('Pas tout à fait…', + style: TextStyle(color: _ink, fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + Text('$good / $total bonnes réponses.', + style: TextStyle(color: _muted, fontSize: 14)), + const SizedBox(height: 4), + Text('Observez bien les détails autour de vous.', + textAlign: TextAlign.center, style: TextStyle(color: _muted, fontSize: 14)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: c.retryWrong, + style: ElevatedButton.styleFrom( + backgroundColor: accentColor, + foregroundColor: isGame ? const Color(0xFF1a1305) : Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 13), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + child: Text( + c.wrongCount == 1 ? 'Corriger cette réponse' : 'Corriger ces réponses', + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700), + ), + ), + ], + ); + } + + Widget _resultBadge(Color color, IconData icon) => Container( + width: 64, + height: 64, + decoration: BoxDecoration(shape: BoxShape.circle, color: color.withOpacity(0.14)), + child: Center( + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + child: Icon(icon, color: Colors.white, size: 26), + ), + ), + ); + + Widget _buildQuestion(BuildContext context) { + final c = controller; + final questions = c.questions; + final current = questions[c.challengeIndex]; + final isLast = c.challengeIndex >= questions.length - 1; + final kind = _kindOf(current); + + final hasAnswer = kind == _Kind.mcq + ? c.mcqAnswer(current.id) != null + : kind == _Kind.puzzle + ? c.puzzleSolved(current.id) + : c.simpleChecked(current.id); + final isCorrectPick = kind == _Kind.mcq + ? c._mcqCorrect(current, c.mcqAnswer(current.id)) + : kind == _Kind.puzzle + ? true + : c._simpleCorrect(current, c.simpleText(current.id)); + final revealImmediately = !isGame; + final checked = isGame ? (hasAnswer && isCorrectPick) : hasAnswer; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: List.generate(questions.length, (i) { + return Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 2), + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: i <= c.challengeIndex + ? accentColor + : (isGame ? Colors.white.withOpacity(0.12) : const Color(0xFFE2DCD2)), + ), + ), + ); + }), + ), + const SizedBox(height: 14), + Text('QUESTION ${c.challengeIndex + 1} / ${questions.length}', + style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.3, color: _muted)), + const SizedBox(height: 10), + HtmlWidget(_label(current.label), + textStyle: TextStyle(color: _ink, fontSize: 15, fontWeight: FontWeight.w600, height: 1.4)), + const SizedBox(height: 12), + if (kind == _Kind.mcq) + _buildMcq(current, hasAnswer, checked, revealImmediately) + else if (kind == _Kind.puzzle) + _buildPuzzle(current) + else + _buildSimple(current, hasAnswer, checked, revealImmediately), + if (checked) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: isLast ? c.submit : c.nextItem, + style: ElevatedButton.styleFrom( + backgroundColor: accentColor, + foregroundColor: isGame ? const Color(0xFF1a1305) : Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + child: Text(isLast ? 'Terminer le défi' : 'Question suivante', + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + ), + ), + ], + ], + ); + } + + Widget _buildMcq( + QuizQuestion current, bool hasAnswer, bool checked, bool revealImmediately) { + final sorted = [...current.responses] + ..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); + final chosen = controller.mcqAnswer(current.id); + + return Column( + children: [ + for (int ri = 0; ri < sorted.length; ri++) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _mcqOption(current, sorted[ri], ri, chosen, hasAnswer, checked, revealImmediately), + ), + ], + ); + } + + Widget _mcqOption(QuizQuestion current, ResponseDTO r, int ri, int? chosen, + bool hasAnswer, bool checked, bool revealImmediately) { + final isSelected = chosen == ri; + var bg = isGame ? Colors.white.withOpacity(0.04) : const Color(0xFFF9F6F1); + var border = isGame ? Colors.white.withOpacity(0.1) : const Color(0xFFE2DCD2); + if (hasAnswer) { + if (isSelected) { + if (r.isGood == true) { + bg = const Color(0xFF2E9E6B).withOpacity(0.14); + border = const Color(0xFF2E9E6B); + } else { + bg = const Color(0xFFD14343).withOpacity(0.12); + border = const Color(0xFFD14343); + } + } else if (revealImmediately && r.isGood == true) { + bg = const Color(0xFF2E9E6B).withOpacity(0.14); + border = const Color(0xFF2E9E6B); + } + } + + return GestureDetector( + onTap: checked ? null : () => controller.answerMcq(current.id, ri), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: border, width: 1.5), + ), + child: Row( + children: [ + Container( + width: 30, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: isSelected + ? accentColor + : (isGame ? Colors.white.withOpacity(0.08) : const Color(0xFFE8E3DA)), + ), + child: Text( + String.fromCharCode(65 + ri), + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 13, + color: isSelected + ? (isGame ? const Color(0xFF1a1305) : Colors.white) + : (isGame ? const Color(0xFFD4AF37) : const Color(0xFF54636E)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: HtmlWidget(_label(r.label), + textStyle: TextStyle(color: _ink, fontSize: 15, fontWeight: FontWeight.w500)), + ), + ], + ), + ), + ); + } + + Widget _buildPuzzle(QuizQuestion current) { + final rows = (current.puzzleRows ?? 3).clamp(2, 6).toInt(); + final cols = (current.puzzleCols ?? 3).clamp(2, 6).toInt(); + void onSolved() => controller.solvePuzzle(current.id); + + if (current.isSlidingPuzzle == true) { + return GuidedPathSlidingPuzzle( + key: ValueKey('puzzle-${current.id}'), + imageUrl: current.puzzleImage!.url!, + rows: rows, + cols: cols, + accentColor: accentColor, + isGame: isGame, + onSolved: onSolved, + ); + } + return GuidedPathPuzzle( + key: ValueKey('puzzle-${current.id}'), + imageUrl: current.puzzleImage!.url!, + rows: rows, + cols: cols, + accentColor: accentColor, + isGame: isGame, + onSolved: onSolved, + ); + } + + Widget _buildSimple( + QuizQuestion current, bool hasAnswer, bool checked, bool revealImmediately) { + final expectedRaw = _stripHtml(_label(current.responses.first.label)); + if (_isDigicode(expectedRaw)) { + return _DigicodeField( + key: ValueKey('digicode-${current.id}'), + controller: controller, + question: current, + accentColor: accentColor, + isGame: isGame, + ink: _ink, + muted: _muted, + expectedLength: expectedRaw.length, + expectedLabel: expectedRaw, + hasAnswer: hasAnswer, + checked: checked, + revealImmediately: revealImmediately, + ); + } + return _SimpleAnswerField( + key: ValueKey('simple-${current.id}'), + controller: controller, + question: current, + accentColor: accentColor, + isGame: isGame, + ink: _ink, + muted: _muted, + expectedLabel: expectedRaw, + hasAnswer: hasAnswer, + checked: checked, + revealImmediately: revealImmediately, + ); + } +} + +/// Champ de réponse ouverte, autonome pour préserver le curseur pendant la +/// saisie (le contrôleur de défi ne rebuild pas à chaque frappe). +class _SimpleAnswerField extends StatefulWidget { + final GuidedStepChallengeController controller; + final QuizQuestion question; + final Color accentColor; + final bool isGame; + final Color ink; + final Color muted; + final String expectedLabel; + final bool hasAnswer; + final bool checked; + final bool revealImmediately; + + const _SimpleAnswerField({ + Key? key, + required this.controller, + required this.question, + required this.accentColor, + required this.isGame, + required this.ink, + required this.muted, + required this.expectedLabel, + required this.hasAnswer, + required this.checked, + required this.revealImmediately, + }) : super(key: key); + + @override + State<_SimpleAnswerField> createState() => _SimpleAnswerFieldState(); +} + +class _SimpleAnswerFieldState extends State<_SimpleAnswerField> { + late final TextEditingController _tec; + + @override + void initState() { + super.initState(); + _tec = TextEditingController(text: widget.controller.simpleText(widget.question.id)); + } + + @override + void didUpdateWidget(_SimpleAnswerField old) { + super.didUpdateWidget(old); + // Reset après un "Corriger ces réponses" (checked repasse à false et le + // texte stocké est vidé). + if (old.checked && !widget.checked) { + _tec.clear(); + } + } + + @override + void dispose() { + _tec.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final c = widget.controller; + final correct = c._simpleCorrect(widget.question, _tec.text); + final empty = _tec.text.trim().isEmpty; + + Color borderColor; + Color fillColor; + if (widget.hasAnswer) { + borderColor = correct ? const Color(0xFF2E9E6B) : const Color(0xFFD14343); + fillColor = + (correct ? const Color(0xFF2E9E6B) : const Color(0xFFD14343)).withOpacity(0.12); + } else { + borderColor = widget.isGame ? Colors.white.withOpacity(0.1) : const Color(0xFFE2DCD2); + fillColor = widget.isGame ? Colors.white.withOpacity(0.04) : const Color(0xFFF9F6F1); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + enabled: !widget.checked, + controller: _tec, + onChanged: (v) { + c.setSimple(widget.question.id, v); + setState(() {}); + }, + style: TextStyle(color: widget.ink, fontSize: 15), + decoration: InputDecoration( + hintText: 'Votre réponse…', + filled: true, + fillColor: fillColor, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: borderColor, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: borderColor, width: 1.5), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: borderColor, width: 1.5), + ), + ), + ), + if (!widget.checked) ...[ + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: empty ? null : () => c.checkSimple(widget.question.id), + style: ElevatedButton.styleFrom( + backgroundColor: widget.accentColor, + foregroundColor: widget.isGame ? const Color(0xFF1a1305) : Colors.white, + disabledBackgroundColor: widget.accentColor.withOpacity(0.5), + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + child: const Text('Valider cette réponse', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + ), + ), + ], + if (widget.hasAnswer && !correct) ...[ + const SizedBox(height: 8), + widget.revealImmediately + ? Text.rich( + TextSpan(children: [ + TextSpan( + text: 'La bonne réponse était : ', + style: TextStyle(fontSize: 13, color: widget.muted)), + TextSpan( + text: widget.expectedLabel, + style: TextStyle( + fontSize: 13, color: widget.muted, fontWeight: FontWeight.w700)), + ]), + ) + : const Text('Ce n\'est pas la bonne réponse, réessayez.', + style: TextStyle( + fontSize: 13, color: Color(0xFFD14343), fontWeight: FontWeight.w600)), + ], + ], + ); + } +} + +/// Pavé digicode (boutons 0-9) pour une réponse "Simple" purement numérique — +/// même contrat que `_SimpleAnswerField` (stocke via `controller.setSimple`), +/// juste un rendu façon cadenas physique plutôt qu'un clavier texte. +class _DigicodeField extends StatefulWidget { + final GuidedStepChallengeController controller; + final QuizQuestion question; + final Color accentColor; + final bool isGame; + final Color ink; + final Color muted; + final int expectedLength; + final String expectedLabel; + final bool hasAnswer; + final bool checked; + final bool revealImmediately; + + const _DigicodeField({ + Key? key, + required this.controller, + required this.question, + required this.accentColor, + required this.isGame, + required this.ink, + required this.muted, + required this.expectedLength, + required this.expectedLabel, + required this.hasAnswer, + required this.checked, + required this.revealImmediately, + }) : super(key: key); + + @override + State<_DigicodeField> createState() => _DigicodeFieldState(); +} + +class _DigicodeFieldState extends State<_DigicodeField> { + late String _entered; + + @override + void initState() { + super.initState(); + _entered = widget.controller.simpleText(widget.question.id); + } + + @override + void didUpdateWidget(_DigicodeField old) { + super.didUpdateWidget(old); + if (old.checked && !widget.checked) { + _entered = ''; + } + } + + void _press(String key) { + if (widget.checked) return; + setState(() { + if (key == 'back') { + if (_entered.isNotEmpty) { + _entered = _entered.substring(0, _entered.length - 1); + } + } else if (_entered.length < widget.expectedLength) { + _entered += key; + } + widget.controller.setSimple(widget.question.id, _entered); + }); + } + + @override + Widget build(BuildContext context) { + final c = widget.controller; + final correct = c._simpleCorrect(widget.question, _entered); + final filled = _entered.length == widget.expectedLength; + + Color boxBorder; + Color boxBg; + if (widget.hasAnswer) { + boxBorder = correct ? const Color(0xFF2E9E6B) : const Color(0xFFD14343); + boxBg = (correct ? const Color(0xFF2E9E6B) : const Color(0xFFD14343)) + .withOpacity(0.12); + } else { + boxBorder = widget.isGame ? Colors.white.withOpacity(0.15) : const Color(0xFFE2DCD2); + boxBg = widget.isGame ? Colors.white.withOpacity(0.04) : const Color(0xFFF9F6F1); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.expectedLength, (i) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 40, + height: 48, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: boxBorder, width: 1.5), + color: boxBg, + ), + child: Text( + i < _entered.length ? _entered[i] : '', + style: TextStyle( + color: widget.ink, fontSize: 22, fontWeight: FontWeight.w800), + ), + ); + }), + ), + const SizedBox(height: 16), + _numpad(), + if (!widget.checked) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: filled ? () => c.checkSimple(widget.question.id) : null, + style: ElevatedButton.styleFrom( + backgroundColor: widget.accentColor, + foregroundColor: widget.isGame ? const Color(0xFF1a1305) : Colors.white, + disabledBackgroundColor: widget.accentColor.withOpacity(0.5), + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + child: const Text('Valider ce code', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + ), + ), + ], + if (widget.hasAnswer && !correct) ...[ + const SizedBox(height: 8), + widget.revealImmediately + ? Text.rich( + TextSpan(children: [ + TextSpan( + text: 'Le bon code était : ', + style: TextStyle(fontSize: 13, color: widget.muted)), + TextSpan( + text: widget.expectedLabel, + style: TextStyle( + fontSize: 13, color: widget.muted, fontWeight: FontWeight.w700)), + ]), + ) + : const Text('Ce n\'est pas le bon code, réessayez.', + style: TextStyle( + fontSize: 13, color: Color(0xFFD14343), fontWeight: FontWeight.w600)), + ], + ], + ); + } + + Widget _numpad() { + const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'back', '0', '']; + return Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: keys.map((key) { + if (key.isEmpty) return const SizedBox(width: 56, height: 48); + final isBack = key == 'back'; + return SizedBox( + width: 56, + height: 48, + child: ElevatedButton( + onPressed: widget.checked ? null : () => _press(key), + style: ElevatedButton.styleFrom( + backgroundColor: + widget.isGame ? Colors.white.withOpacity(0.06) : const Color(0xFFF0EBE3), + foregroundColor: widget.ink, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + child: isBack + ? const Icon(Icons.backspace_outlined, size: 18) + : Text(key, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + ), + ); + }).toList(), + ); + } +} diff --git a/lib/Screens/Sections/GuidedPath/guided_step_timer.dart b/lib/Screens/Sections/GuidedPath/guided_step_timer.dart index 433c1f7..824a5b6 100644 --- a/lib/Screens/Sections/GuidedPath/guided_step_timer.dart +++ b/lib/Screens/Sections/GuidedPath/guided_step_timer.dart @@ -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 createState() => _GuidedStepTimerState(); -} - -class _GuidedStepTimerState extends State { - 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), - 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), + ), + ), ], ); } - - String _formatTime(int s) { - final m = s ~/ 60; - final sec = s % 60; - return '${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}'; - } } diff --git a/lib/Screens/Sections/Map/map_page.dart b/lib/Screens/Sections/Map/map_page.dart index c0c0b4f..2d1e4aa 100644 --- a/lib/Screens/Sections/Map/map_page.dart +++ b/lib/Screens/Sections/Map/map_page.dart @@ -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 { ) ), ), - 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, diff --git a/lib/Screens/Sections/Map/marker_view.dart b/lib/Screens/Sections/Map/marker_view.dart index a7f2d07..82e5ecf 100644 --- a/lib/Screens/Sections/Map/marker_view.dart +++ b/lib/Screens/Sections/Map/marker_view.dart @@ -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), + ), + ), + ], ), ), ), diff --git a/lib/Screens/Sections/Parcours/parcours_page.dart b/lib/Screens/Sections/Parcours/parcours_page.dart new file mode 100644 index 0000000..0756e33 --- /dev/null +++ b/lib/Screens/Sections/Parcours/parcours_page.dart @@ -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 createState() => _ParcoursPageState(); +} + +class _ParcoursPageState extends State { + List _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 _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 _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; +} diff --git a/lib/Screens/section_page.dart b/lib/Screens/section_page.dart index 48ddf27..dde9719 100644 --- a/lib/Screens/section_page.dart +++ b/lib/Screens/section_page.dart @@ -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 { 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")); } diff --git a/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart b/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart index 6fb59a5..a55bf39 100644 --- a/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart +++ b/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart @@ -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 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; diff --git a/lib/Services/Glasses/engines/impl/native_wake_word_engine.dart b/lib/Services/Glasses/engines/impl/native_wake_word_engine.dart index 5a0fe4b..5fff991 100644 --- a/lib/Services/Glasses/engines/impl/native_wake_word_engine.dart +++ b/lib/Services/Glasses/engines/impl/native_wake_word_engine.dart @@ -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 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? modelNames, + String? modelName, + }) : modelNames = modelNames ?? (modelName != null ? [modelName] : ['hey_visit']); @override Future 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 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 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 _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}); } } diff --git a/lib/Services/Glasses/engines/impl/openwakeword_engine.dart b/lib/Services/Glasses/engines/impl/openwakeword_engine.dart index 15a566c..da67ed5 100644 --- a/lib/Services/Glasses/engines/impl/openwakeword_engine.dart +++ b/lib/Services/Glasses/engines/impl/openwakeword_engine.dart @@ -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 modelNames; StreamSubscription? _subscription; void Function()? _onDetected; + void Function(String modelName)? _onModelDetected; bool _running = false; - OpenWakeWordEngine({this.modelName = 'hey_visit'}); + OpenWakeWordEngine({ + List? modelNames, + String? modelName, + }) : modelNames = modelNames ?? (modelName != null ? [modelName] : ['hey_visit']); @override Future 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'); } diff --git a/lib/Services/Glasses/engines/llm_client.dart b/lib/Services/Glasses/engines/llm_client.dart index 566b058..e579664 100644 --- a/lib/Services/Glasses/engines/llm_client.dart +++ b/lib/Services/Glasses/engines/llm_client.dart @@ -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 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', diff --git a/lib/Services/Glasses/glasses_orchestrator.dart b/lib/Services/Glasses/glasses_orchestrator.dart index 261cae0..f412fe8 100644 --- a/lib/Services/Glasses/glasses_orchestrator.dart +++ b/lib/Services/Glasses/glasses_orchestrator.dart @@ -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 lastTranscription = ValueNotifier(''); - final ValueNotifier isListeningForCommand = ValueNotifier(false); - /// Dernier texte envoyé au TTS — pour détecter les astérisques et autres artefacts. - final ValueNotifier lastTtsText = ValueNotifier(''); - - // Photos de visite (V2 — stockées pour un résumé en fin de visite) - final List 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 _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 start() async { - if (_running) return; - _running = true; - - await wakeWordEngine.start( - onDetected: _onWakeWord, - onDetectedWithCommand: _onWakeWordWithCommand, - ); - debugPrint('[GlassesOrchestrator] Started'); - } - - Future 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 restartWakeWord() async { - if (!_running || _inConversation) return; - await wakeWordEngine.start( - onDetected: _onWakeWord, - onDetectedWithCommand: _onWakeWordWithCommand, - ); - } - - /// Déclenchement manuel (ex: bouton debug, test) - Future triggerConversation() => _handleConversation(); - - /// Dispatch direct d'une commande (utilisé par le lifecycle observer) - Future dispatchCommand(String command) => _dispatch(command); - - /// Déclenchement scan QR depuis un chemin d'image existant. - Future 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 _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 _playWakeSound() => _playSound(_wakeSound); - Future _playDoneSound() => _playSound(_doneSound); - - Future _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 _stopThinkingLoop() async { - await _thinkingPlayer.stop(); - } - - // ── Conversation vocale ──────────────────────────────────────────────────── - - Future _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 _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 _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 _handleQrScan() async { - final lang = _toLangCode(visitAppContext.language ?? 'FR'); - debugPrint('[QrScan] Starting — requesting photo capture...'); - final completer = Completer(); - - 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 _handlePhotoCapture() async { - final completer = Completer(); - - // É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 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; diff --git a/lib/Services/Glasses/voice_orchestrator.dart b/lib/Services/Glasses/voice_orchestrator.dart new file mode 100644 index 0000000..cd9a6c9 --- /dev/null +++ b/lib/Services/Glasses/voice_orchestrator.dart @@ -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 lastTranscription = ValueNotifier(''); + final ValueNotifier isListeningForCommand = ValueNotifier(false); + final ValueNotifier lastTtsText = ValueNotifier(''); + final ValueNotifier> visitPhotosNotifier = ValueNotifier([]); + final ValueNotifier lastQrScanPhoto = ValueNotifier(null); + + final List 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 _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 start() async { + if (_running) return; + _running = true; + await wakeWordEngine.start( + onDetected: _onWakeWord, + onDetectedWithCommand: _onWakeWordWithCommand, + ); + debugPrint('[VoiceOrchestrator] Started'); + } + + Future 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 restartWakeWord() async { + if (!_running || _inConversation) return; + await wakeWordEngine.start( + onDetected: _onWakeWord, + onDetectedWithCommand: _onWakeWordWithCommand, + ); + } + + Future triggerConversation() => _handleConversation(); + Future dispatchCommand(String command) => _dispatch(command); + + Future 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 _playSound(String asset) async { + try { + await _soundPlayer.setAsset(asset); + await _soundPlayer.play(); + } catch (_) { + debugPrint('[VoiceOrchestrator] Sound not found: $asset'); + } + } + + Future _playWakeSound() => _playSound(_wakeSound); + Future _playDoneSound() => _playSound(_doneSound); + + Future _startThinkingLoop() async { + try { + await _thinkingPlayer.setAsset(_thinkingSound); + await _thinkingPlayer.setLoopMode(LoopMode.one); + _thinkingPlayer.play(); + } catch (_) { + debugPrint('[VoiceOrchestrator] Thinking sound not found'); + } + } + + Future _stopThinkingLoop() async { + await _thinkingPlayer.stop(); + } + + // ── Conversation vocale ──────────────────────────────────────────────────── + + Future _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 _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 _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 _handleQrScan() async { + final lang = _toLangCode(visitAppContext.language ?? 'FR'); + final completer = Completer(); + 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 _handlePhotoCapture() async { + final completer = Completer(); + 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 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'; + } + } +} diff --git a/lib/Services/assistantService.dart b/lib/Services/assistantService.dart index 5b17f3d..8b20efa 100644 --- a/lib/Services/assistantService.dart +++ b/lib/Services/assistantService.dart @@ -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 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 _history = []; + Timer? _inactivityTimer; + + AssistantService({ + required this.visitAppContext, + this.maxHistory = 10, + this.inactivityTimeout = const Duration(minutes: 5), + }); Future chat({ required String message, String? configurationId, - }) => chatWithAppType(message: message, configurationId: configurationId); + bool isVoice = false, + }) => chatWithAppType(message: message, configurationId: configurationId, isVoice: isVoice); Future 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(); -} \ No newline at end of file + 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(); + } +} diff --git a/lib/Services/geo_beacon_trigger_service.dart b/lib/Services/geo_beacon_trigger_service.dart index cc6ea6e..1cc9dea 100644 --- a/lib/Services/geo_beacon_trigger_service.dart +++ b/lib/Services/geo_beacon_trigger_service.dart @@ -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), ); diff --git a/lib/Services/voice_controller.dart b/lib/Services/voice_controller.dart new file mode 100644 index 0000000..d341f75 --- /dev/null +++ b/lib/Services/voice_controller.dart @@ -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 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 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 _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(); + } +} diff --git a/lib/Services/wake_word_service.dart b/lib/Services/wake_word_service.dart index 4fd2ca3..89030b8 100644 --- a/lib/Services/wake_word_service.dart +++ b/lib/Services/wake_word_service.dart @@ -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'); } diff --git a/lib/client.dart b/lib/client.dart index bc344c5..35fa14b 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -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); } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 729f555..fb68342 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { 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 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 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 with WidgetsBindingObserver { Widget build(BuildContext context) { Get.put(RequirementStateController()); - return ChangeNotifierProvider( - create: (_) => AppContext(widget.visitAppContext), + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => AppContext(widget.visitAppContext), + ), + ChangeNotifierProvider( + create: (_) => VoiceController(), + ), + ], child: MaterialApp( debugShowCheckedModeBanner: false, title: 'Carnaval de Marche', //'Musée de la fraise' // Autres // 'Fort Saint Héribert' diff --git a/pubspec.lock b/pubspec.lock index e33565c..1281b8b 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index fe1bd6e..c591a23 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/wakeword/Hey_marco_training_working.ipynb b/wakeword/Hey_marco_training_working.ipynb new file mode 100644 index 0000000..2d55f1a --- /dev/null +++ b/wakeword/Hey_marco_training_working.ipynb @@ -0,0 +1,1287 @@ +{ + "nbformat": 4, + "nbformat_minor": 0, + "metadata": { + "colab": { + "provenance": [], + "gpuType": "T4" + }, + "accelerator": "GPU", + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "kQ_GGoU_5ArA" + }, + "source": [ + "# Hey Marco — Wake Word Training\n", + "\n", + "Basé sur `Automatic_model_training_simple_TFR`. \n", + "Cible : **\"hey marco\"** (EN/DE/NL/IT/ES) + **\"hé marco\"** (FR).\n", + "\n", + "**Runtime recommandé : GPU (T4)** — Durée estimée : ~45-90 min" + ] + }, + { + "cell_type": "code", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "RoPWDlcB5ArC", + "outputId": "60c0195c-e746-4648-8368-1d467bfb1493" + }, + "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": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Drive already mounted at /content/drive; to attempt to forcibly remount, call drive.mount(\"/content/drive\", force_remount=True).\n", + "Models will be saved in: /content/drive/My Drive/Colab Notebooks/Wakeword/openwakeword\n" + ] + } + ], + "execution_count": 1 + }, + { + "cell_type": "code", + "source": [ + "# @title Configuration globale { display-mode: \"form\" }\n", + "# @markdown Tous les paramètres du training. Les proportions par langue sont calculées automatiquement.\n", + "\n", + "number_of_examples = 10000 # @param {type:\"slider\", min:1000, max:50000, step:500}\n", + "number_of_training_steps = 25000 # @param {type:\"slider\", min:1000, max:50000, step:500}\n", + "false_activation_penalty = 1500 # @param {type:\"slider\", min:100, max:3000, step:100}\n", + "\n", + "# Répartition par langue (% fixes, total = number_of_examples)\n", + "LANG_RATIOS = {\n", + " 'fr': 0.35, # hé marco — phonétique différente, priorité FR\n", + " 'en': 0.30,\n", + " 'en-gb': 0.10,\n", + " 'de': 0.10,\n", + " 'nl': 0.05,\n", + " 'it': 0.05,\n", + " 'es': 0.05,\n", + "}\n", + "\n", + "import math\n", + "lang_samples = {}\n", + "total_assigned = 0\n", + "langs = list(LANG_RATIOS.keys())\n", + "for lang in langs[:-1]:\n", + " n = math.floor(number_of_examples * LANG_RATIOS[lang])\n", + " lang_samples[lang] = n\n", + " total_assigned += n\n", + "lang_samples[langs[-1]] = number_of_examples - total_assigned\n", + "\n", + "print(f'Total samples : {sum(lang_samples.values())}')\n", + "for lang, n in lang_samples.items():\n", + " print(f' {lang:6s} : {n}')" + ], + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "-2axZd7PnRvh", + "outputId": "d6ee46f6-03b3-432b-f45d-5b625fd7e600" + }, + "execution_count": 11, + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Total samples : 10000\n", + " fr : 3500\n", + " en : 3000\n", + " en-gb : 1000\n", + " de : 1000\n", + " nl : 500\n", + " it : 500\n", + " es : 500\n" + ] + } + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "owgMy2-z5ArC" + }, + "source": [ + "## Étape 1 — Piper sample generator + modèles TTS" + ] + }, + { + "cell_type": "code", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "6P1c2LlY5ArD", + "outputId": "5ab575c9-f9aa-4258-e4a6-95df5a9bf441" + }, + "source": [ + "import os, sys\n", + "from IPython.display import Audio\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 (le .pt n'est pas disponible dans le LFS du repo)\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": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Piper prêt.\n" + ] + } + ], + "execution_count": 12 + }, + { + "cell_type": "code", + "metadata": { + "id": "3YZc7M5J5ArD", + "colab": { + "base_uri": "https://localhost:8080/", + "height": 168 + }, + "outputId": "9d244b69-aece-494d-8488-f9cd36a17adf" + }, + "source": [ + "# Test audio EN\n", + "generate_samples(text='hey_marco', max_samples=1, output_dir='./', batch_size=1,\n", + " auto_reduce_batch_size=True, file_names=['test_en.wav'])\n", + "print('EN:')\n", + "display(Audio('test_en.wav', autoplay=False))\n", + "\n", + "# Test audio FR via piper CLI\n", + "!echo \"hé marco\" | piper --model fr_FR-siwis-medium.onnx --output_file test_fr.wav\n", + "print('FR:')\n", + "display(Audio('test_fr.wav', autoplay=False))" + ], + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "EN:\n" + ] + }, + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + }, + { + "output_type": "stream", + "name": "stdout", + "text": [ + "FR:\n" + ] + }, + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + " \n", + " " + ] + }, + "metadata": {} + } + ], + "execution_count": 13 + }, + { + "cell_type": "markdown", + "metadata": { + "id": "fgAcnSFS5ArD" + }, + "source": [ + "## Étape 2 — Téléchargement des données" + ] + }, + { + "cell_type": "code", + "metadata": { + "id": "ikCXMW_i5ArE", + "colab": { + "base_uri": "https://localhost:8080/" + }, + "outputId": "b9648065-04c1-4f82-c1a1-23c6fa552c7c" + }, + "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", + "# Autres dépendances\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", + "# Modèles embedding openWakeWord\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": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Cloning into 'openwakeword'...\n", + "remote: Enumerating objects: 1248, done.\u001b[K\n", + "remote: Counting objects: 100% (554/554), done.\u001b[K\n", + "remote: Compressing objects: 100% (146/146), done.\u001b[K\n", + "remote: Total 1248 (delta 446), reused 408 (delta 408), pack-reused 694 (from 2)\u001b[K\n", + "Receiving objects: 100% (1248/1248), 3.24 MiB | 15.41 MiB/s, done.\n", + "Resolving deltas: 100% (771/771), done.\n", + "Obtaining file:///content/openwakeword\n", + " Installing build dependencies ... \u001b[?25l\u001b[?25hdone\n", + " Checking if build backend supports build_editable ... \u001b[?25l\u001b[?25hdone\n", + " Getting requirements to build editable ... \u001b[?25l\u001b[?25hdone\n", + " Preparing editable metadata (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n", + "Building wheels for collected packages: openwakeword\n", + " Building editable for openwakeword (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n", + " Created wheel for openwakeword: filename=openwakeword-0.6.0-0.editable-py3-none-any.whl size=17481 sha256=a3fe81720c4a733c8a4ca9a95233759e0aa9c8ec77b24a33bdd5ef5e3f34993d\n", + " Stored in directory: /tmp/pip-ephem-wheel-cache-qxd0c5ly/wheels/95/b4/6f/f887431fea7b3379ef42ac3594a92164114b83a95abb8b7969\n", + "Successfully built openwakeword\n", + "Installing collected packages: openwakeword\n", + " Attempting uninstall: openwakeword\n", + " Found existing installation: openwakeword 0.6.0\n", + " Uninstalling openwakeword-0.6.0:\n", + " Successfully uninstalled openwakeword-0.6.0\n", + "Successfully installed openwakeword-0.6.0\n", + "Requirement already satisfied: mutagen==1.47.0 in /usr/local/lib/python3.12/dist-packages (1.47.0)\n", + "Requirement already satisfied: torchinfo==1.8.0 in /usr/local/lib/python3.12/dist-packages (1.8.0)\n", + "Requirement already satisfied: torchmetrics==1.2.0 in /usr/local/lib/python3.12/dist-packages (1.2.0)\n", + "Requirement already satisfied: numpy>1.20.0 in /usr/local/lib/python3.12/dist-packages (from torchmetrics==1.2.0) (1.26.4)\n", + "Requirement already satisfied: torch>=1.8.1 in /usr/local/lib/python3.12/dist-packages (from torchmetrics==1.2.0) (2.5.0+cu121)\n", + "Requirement already satisfied: lightning-utilities>=0.8.0 in /usr/local/lib/python3.12/dist-packages (from torchmetrics==1.2.0) (0.15.3)\n", + "Requirement already satisfied: packaging>=22 in /usr/local/lib/python3.12/dist-packages (from lightning-utilities>=0.8.0->torchmetrics==1.2.0) (26.2)\n", + "Requirement already satisfied: typing_extensions in /usr/local/lib/python3.12/dist-packages (from lightning-utilities>=0.8.0->torchmetrics==1.2.0) (4.15.0)\n", + "Requirement already satisfied: filelock in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (3.29.0)\n", + "Requirement already satisfied: networkx in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (3.6.1)\n", + "Requirement already satisfied: jinja2 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (3.1.6)\n", + "Requirement already satisfied: fsspec in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (2023.10.0)\n", + "Requirement already satisfied: nvidia-cuda-nvrtc-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (12.1.105)\n", + "Requirement already satisfied: nvidia-cuda-runtime-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (12.1.105)\n", + "Requirement already satisfied: nvidia-cuda-cupti-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (12.1.105)\n", + "Requirement already satisfied: nvidia-cudnn-cu12==9.1.0.70 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (9.1.0.70)\n", + "Requirement already satisfied: nvidia-cublas-cu12==12.1.3.1 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (12.1.3.1)\n", + "Requirement already satisfied: nvidia-cufft-cu12==11.0.2.54 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (11.0.2.54)\n", + "Requirement already satisfied: nvidia-curand-cu12==10.3.2.106 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (10.3.2.106)\n", + "Requirement already satisfied: nvidia-cusolver-cu12==11.4.5.107 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (11.4.5.107)\n", + "Requirement already satisfied: nvidia-cusparse-cu12==12.1.0.106 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (12.1.0.106)\n", + "Requirement already satisfied: nvidia-nccl-cu12==2.21.5 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (2.21.5)\n", + "Requirement already satisfied: nvidia-nvtx-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (12.1.105)\n", + "Requirement already satisfied: triton==3.1.0 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (3.1.0)\n", + "Requirement already satisfied: setuptools in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (75.2.0)\n", + "Requirement already satisfied: sympy==1.13.1 in /usr/local/lib/python3.12/dist-packages (from torch>=1.8.1->torchmetrics==1.2.0) (1.13.1)\n", + "Requirement already satisfied: nvidia-nvjitlink-cu12 in /usr/local/lib/python3.12/dist-packages (from nvidia-cusolver-cu12==11.4.5.107->torch>=1.8.1->torchmetrics==1.2.0) (12.8.93)\n", + "Requirement already satisfied: mpmath<1.4,>=1.1.0 in /usr/local/lib/python3.12/dist-packages (from sympy==1.13.1->torch>=1.8.1->torchmetrics==1.2.0) (1.3.0)\n", + "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.12/dist-packages (from jinja2->torch>=1.8.1->torchmetrics==1.2.0) (3.0.3)\n", + "Requirement already satisfied: speechbrain==0.5.14 in /usr/local/lib/python3.12/dist-packages (0.5.14)\n", + "Requirement already satisfied: hyperpyyaml in /usr/local/lib/python3.12/dist-packages (from speechbrain==0.5.14) (1.2.3)\n", + "Requirement already satisfied: joblib in /usr/local/lib/python3.12/dist-packages (from speechbrain==0.5.14) (1.5.3)\n", + "Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (from speechbrain==0.5.14) (1.26.4)\n", + "Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from speechbrain==0.5.14) (26.2)\n", + "Requirement already satisfied: scipy in /usr/local/lib/python3.12/dist-packages (from speechbrain==0.5.14) (1.16.3)\n", + "Requirement already satisfied: sentencepiece in /usr/local/lib/python3.12/dist-packages (from speechbrain==0.5.14) (0.2.1)\n", + "Requirement already satisfied: torch>=1.9 in /usr/local/lib/python3.12/dist-packages (from speechbrain==0.5.14) (2.5.0+cu121)\n", + "Requirement already satisfied: torchaudio in /usr/local/lib/python3.12/dist-packages (from speechbrain==0.5.14) (2.5.0+cu121)\n", + "Requirement already satisfied: tqdm in /usr/local/lib/python3.12/dist-packages (from speechbrain==0.5.14) (4.67.1)\n", + "Requirement already satisfied: huggingface-hub in /usr/local/lib/python3.12/dist-packages (from speechbrain==0.5.14) (0.36.2)\n", + "Requirement already satisfied: filelock in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (3.29.0)\n", + "Requirement already satisfied: typing-extensions>=4.8.0 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (4.15.0)\n", + "Requirement already satisfied: networkx in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (3.6.1)\n", + "Requirement already satisfied: jinja2 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (3.1.6)\n", + "Requirement already satisfied: fsspec in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (2023.10.0)\n", + "Requirement already satisfied: nvidia-cuda-nvrtc-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (12.1.105)\n", + "Requirement already satisfied: nvidia-cuda-runtime-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (12.1.105)\n", + "Requirement already satisfied: nvidia-cuda-cupti-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (12.1.105)\n", + "Requirement already satisfied: nvidia-cudnn-cu12==9.1.0.70 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (9.1.0.70)\n", + "Requirement already satisfied: nvidia-cublas-cu12==12.1.3.1 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (12.1.3.1)\n", + "Requirement already satisfied: nvidia-cufft-cu12==11.0.2.54 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (11.0.2.54)\n", + "Requirement already satisfied: nvidia-curand-cu12==10.3.2.106 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (10.3.2.106)\n", + "Requirement already satisfied: nvidia-cusolver-cu12==11.4.5.107 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (11.4.5.107)\n", + "Requirement already satisfied: nvidia-cusparse-cu12==12.1.0.106 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (12.1.0.106)\n", + "Requirement already satisfied: nvidia-nccl-cu12==2.21.5 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (2.21.5)\n", + "Requirement already satisfied: nvidia-nvtx-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (12.1.105)\n", + "Requirement already satisfied: triton==3.1.0 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (3.1.0)\n", + "Requirement already satisfied: setuptools in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (75.2.0)\n", + "Requirement already satisfied: sympy==1.13.1 in /usr/local/lib/python3.12/dist-packages (from torch>=1.9->speechbrain==0.5.14) (1.13.1)\n", + "Requirement already satisfied: nvidia-nvjitlink-cu12 in /usr/local/lib/python3.12/dist-packages (from nvidia-cusolver-cu12==11.4.5.107->torch>=1.9->speechbrain==0.5.14) (12.8.93)\n", + "Requirement already satisfied: mpmath<1.4,>=1.1.0 in /usr/local/lib/python3.12/dist-packages (from sympy==1.13.1->torch>=1.9->speechbrain==0.5.14) (1.3.0)\n", + "Requirement already satisfied: hf-xet<2.0.0,>=1.1.3 in /usr/local/lib/python3.12/dist-packages (from huggingface-hub->speechbrain==0.5.14) (1.5.0)\n", + "Requirement already satisfied: pyyaml>=5.1 in /usr/local/lib/python3.12/dist-packages (from huggingface-hub->speechbrain==0.5.14) (6.0.3)\n", + "Requirement already satisfied: requests in /usr/local/lib/python3.12/dist-packages (from huggingface-hub->speechbrain==0.5.14) (2.32.4)\n", + "Requirement already satisfied: ruamel.yaml<0.19.0,>=0.17.28 in /usr/local/lib/python3.12/dist-packages (from hyperpyyaml->speechbrain==0.5.14) (0.18.17)\n", + "Requirement already satisfied: ruamel.yaml.clib>=0.2.15 in /usr/local/lib/python3.12/dist-packages (from ruamel.yaml<0.19.0,>=0.17.28->hyperpyyaml->speechbrain==0.5.14) (0.2.15)\n", + "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.12/dist-packages (from jinja2->torch>=1.9->speechbrain==0.5.14) (3.0.3)\n", + "Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.12/dist-packages (from requests->huggingface-hub->speechbrain==0.5.14) (3.4.7)\n", + "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.12/dist-packages (from requests->huggingface-hub->speechbrain==0.5.14) (3.15)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.12/dist-packages (from requests->huggingface-hub->speechbrain==0.5.14) (2.5.0)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.12/dist-packages (from requests->huggingface-hub->speechbrain==0.5.14) (2026.5.20)\n", + "Requirement already satisfied: audiomentations==0.33.0 in /usr/local/lib/python3.12/dist-packages (0.33.0)\n", + "Requirement already satisfied: numpy>=1.18.0 in /usr/local/lib/python3.12/dist-packages (from audiomentations==0.33.0) (1.26.4)\n", + "Requirement already satisfied: librosa!=0.10.0,<0.11.0,>=0.8.0 in /usr/local/lib/python3.12/dist-packages (from audiomentations==0.33.0) (0.10.2.post1)\n", + "Requirement already satisfied: scipy<2,>=1.4.0 in /usr/local/lib/python3.12/dist-packages (from audiomentations==0.33.0) (1.16.3)\n", + "Requirement already satisfied: soxr<1.0.0,>=0.3.2 in /usr/local/lib/python3.12/dist-packages (from audiomentations==0.33.0) (0.5.0.post1)\n", + "Requirement already satisfied: audioread>=2.1.9 in /usr/local/lib/python3.12/dist-packages (from librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (3.1.0)\n", + "Requirement already satisfied: scikit-learn>=0.20.0 in /usr/local/lib/python3.12/dist-packages (from librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (1.6.1)\n", + "Requirement already satisfied: joblib>=0.14 in /usr/local/lib/python3.12/dist-packages (from librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (1.5.3)\n", + "Requirement already satisfied: decorator>=4.3.0 in /usr/local/lib/python3.12/dist-packages (from librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (4.4.2)\n", + "Requirement already satisfied: numba>=0.51.0 in /usr/local/lib/python3.12/dist-packages (from librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (0.60.0)\n", + "Requirement already satisfied: soundfile>=0.12.1 in /usr/local/lib/python3.12/dist-packages (from librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (0.13.1)\n", + "Requirement already satisfied: pooch>=1.1 in /usr/local/lib/python3.12/dist-packages (from librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (1.9.0)\n", + "Requirement already satisfied: typing-extensions>=4.1.1 in /usr/local/lib/python3.12/dist-packages (from librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (4.15.0)\n", + "Requirement already satisfied: lazy-loader>=0.1 in /usr/local/lib/python3.12/dist-packages (from librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (0.5)\n", + "Requirement already satisfied: msgpack>=1.0 in /usr/local/lib/python3.12/dist-packages (from librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (1.1.2)\n", + "Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from lazy-loader>=0.1->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (26.2)\n", + "Requirement already satisfied: llvmlite<0.44,>=0.43.0dev0 in /usr/local/lib/python3.12/dist-packages (from numba>=0.51.0->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (0.43.0)\n", + "Requirement already satisfied: platformdirs>=2.5.0 in /usr/local/lib/python3.12/dist-packages (from pooch>=1.1->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (4.9.6)\n", + "Requirement already satisfied: requests>=2.19.0 in /usr/local/lib/python3.12/dist-packages (from pooch>=1.1->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (2.32.4)\n", + "Requirement already satisfied: threadpoolctl>=3.1.0 in /usr/local/lib/python3.12/dist-packages (from scikit-learn>=0.20.0->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (3.6.0)\n", + "Requirement already satisfied: cffi>=1.0 in /usr/local/lib/python3.12/dist-packages (from soundfile>=0.12.1->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (2.0.0)\n", + "Requirement already satisfied: pycparser in /usr/local/lib/python3.12/dist-packages (from cffi>=1.0->soundfile>=0.12.1->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (3.0)\n", + "Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->pooch>=1.1->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (3.4.7)\n", + "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->pooch>=1.1->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (3.15)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->pooch>=1.1->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (2.5.0)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->pooch>=1.1->librosa!=0.10.0,<0.11.0,>=0.8.0->audiomentations==0.33.0) (2026.5.20)\n", + "Requirement already satisfied: torch-audiomentations==0.11.0 in /usr/local/lib/python3.12/dist-packages (0.11.0)\n", + "Requirement already satisfied: julius<0.3,>=0.2.3 in /usr/local/lib/python3.12/dist-packages (from torch-audiomentations==0.11.0) (0.2.8)\n", + "Requirement already satisfied: librosa>=0.6.0 in /usr/local/lib/python3.12/dist-packages (from torch-audiomentations==0.11.0) (0.10.2.post1)\n", + "Requirement already satisfied: torch>=1.7.0 in /usr/local/lib/python3.12/dist-packages (from torch-audiomentations==0.11.0) (2.5.0+cu121)\n", + "Requirement already satisfied: torchaudio>=0.7.0 in /usr/local/lib/python3.12/dist-packages (from torch-audiomentations==0.11.0) (2.5.0+cu121)\n", + "Requirement already satisfied: torch-pitch-shift>=1.2.2 in /usr/local/lib/python3.12/dist-packages (from torch-audiomentations==0.11.0) (1.2.5)\n", + "Requirement already satisfied: audioread>=2.1.9 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (3.1.0)\n", + "Requirement already satisfied: numpy!=1.22.0,!=1.22.1,!=1.22.2,>=1.20.3 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (1.26.4)\n", + "Requirement already satisfied: scipy>=1.2.0 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (1.16.3)\n", + "Requirement already satisfied: scikit-learn>=0.20.0 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (1.6.1)\n", + "Requirement already satisfied: joblib>=0.14 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (1.5.3)\n", + "Requirement already satisfied: decorator>=4.3.0 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (4.4.2)\n", + "Requirement already satisfied: numba>=0.51.0 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (0.60.0)\n", + "Requirement already satisfied: soundfile>=0.12.1 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (0.13.1)\n", + "Requirement already satisfied: pooch>=1.1 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (1.9.0)\n", + "Requirement already satisfied: soxr>=0.3.2 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (0.5.0.post1)\n", + "Requirement already satisfied: typing-extensions>=4.1.1 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (4.15.0)\n", + "Requirement already satisfied: lazy-loader>=0.1 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (0.5)\n", + "Requirement already satisfied: msgpack>=1.0 in /usr/local/lib/python3.12/dist-packages (from librosa>=0.6.0->torch-audiomentations==0.11.0) (1.1.2)\n", + "Requirement already satisfied: filelock in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (3.29.0)\n", + "Requirement already satisfied: networkx in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (3.6.1)\n", + "Requirement already satisfied: jinja2 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (3.1.6)\n", + "Requirement already satisfied: fsspec in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (2023.10.0)\n", + "Requirement already satisfied: nvidia-cuda-nvrtc-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (12.1.105)\n", + "Requirement already satisfied: nvidia-cuda-runtime-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (12.1.105)\n", + "Requirement already satisfied: nvidia-cuda-cupti-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (12.1.105)\n", + "Requirement already satisfied: nvidia-cudnn-cu12==9.1.0.70 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (9.1.0.70)\n", + "Requirement already satisfied: nvidia-cublas-cu12==12.1.3.1 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (12.1.3.1)\n", + "Requirement already satisfied: nvidia-cufft-cu12==11.0.2.54 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (11.0.2.54)\n", + "Requirement already satisfied: nvidia-curand-cu12==10.3.2.106 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (10.3.2.106)\n", + "Requirement already satisfied: nvidia-cusolver-cu12==11.4.5.107 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (11.4.5.107)\n", + "Requirement already satisfied: nvidia-cusparse-cu12==12.1.0.106 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (12.1.0.106)\n", + "Requirement already satisfied: nvidia-nccl-cu12==2.21.5 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (2.21.5)\n", + "Requirement already satisfied: nvidia-nvtx-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (12.1.105)\n", + "Requirement already satisfied: triton==3.1.0 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (3.1.0)\n", + "Requirement already satisfied: setuptools in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (75.2.0)\n", + "Requirement already satisfied: sympy==1.13.1 in /usr/local/lib/python3.12/dist-packages (from torch>=1.7.0->torch-audiomentations==0.11.0) (1.13.1)\n", + "Requirement already satisfied: nvidia-nvjitlink-cu12 in /usr/local/lib/python3.12/dist-packages (from nvidia-cusolver-cu12==11.4.5.107->torch>=1.7.0->torch-audiomentations==0.11.0) (12.8.93)\n", + "Requirement already satisfied: mpmath<1.4,>=1.1.0 in /usr/local/lib/python3.12/dist-packages (from sympy==1.13.1->torch>=1.7.0->torch-audiomentations==0.11.0) (1.3.0)\n", + "Requirement already satisfied: primePy>=1.3 in /usr/local/lib/python3.12/dist-packages (from torch-pitch-shift>=1.2.2->torch-audiomentations==0.11.0) (1.3)\n", + "Requirement already satisfied: packaging>=21.3 in /usr/local/lib/python3.12/dist-packages (from torch-pitch-shift>=1.2.2->torch-audiomentations==0.11.0) (26.2)\n", + "Requirement already satisfied: llvmlite<0.44,>=0.43.0dev0 in /usr/local/lib/python3.12/dist-packages (from numba>=0.51.0->librosa>=0.6.0->torch-audiomentations==0.11.0) (0.43.0)\n", + "Requirement already satisfied: platformdirs>=2.5.0 in /usr/local/lib/python3.12/dist-packages (from pooch>=1.1->librosa>=0.6.0->torch-audiomentations==0.11.0) (4.9.6)\n", + "Requirement already satisfied: requests>=2.19.0 in /usr/local/lib/python3.12/dist-packages (from pooch>=1.1->librosa>=0.6.0->torch-audiomentations==0.11.0) (2.32.4)\n", + "Requirement already satisfied: threadpoolctl>=3.1.0 in /usr/local/lib/python3.12/dist-packages (from scikit-learn>=0.20.0->librosa>=0.6.0->torch-audiomentations==0.11.0) (3.6.0)\n", + "Requirement already satisfied: cffi>=1.0 in /usr/local/lib/python3.12/dist-packages (from soundfile>=0.12.1->librosa>=0.6.0->torch-audiomentations==0.11.0) (2.0.0)\n", + "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.12/dist-packages (from jinja2->torch>=1.7.0->torch-audiomentations==0.11.0) (3.0.3)\n", + "Requirement already satisfied: pycparser in /usr/local/lib/python3.12/dist-packages (from cffi>=1.0->soundfile>=0.12.1->librosa>=0.6.0->torch-audiomentations==0.11.0) (3.0)\n", + "Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->pooch>=1.1->librosa>=0.6.0->torch-audiomentations==0.11.0) (3.4.7)\n", + "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->pooch>=1.1->librosa>=0.6.0->torch-audiomentations==0.11.0) (3.15)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->pooch>=1.1->librosa>=0.6.0->torch-audiomentations==0.11.0) (2.5.0)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->pooch>=1.1->librosa>=0.6.0->torch-audiomentations==0.11.0) (2026.5.20)\n", + "Requirement already satisfied: acoustics==0.2.6 in /usr/local/lib/python3.12/dist-packages (0.2.6)\n", + "Requirement already satisfied: numpy>=1.8 in /usr/local/lib/python3.12/dist-packages (from acoustics==0.2.6) (1.26.4)\n", + "Requirement already satisfied: scipy>=0.16 in /usr/local/lib/python3.12/dist-packages (from acoustics==0.2.6) (1.16.3)\n", + "Requirement already satisfied: matplotlib in /usr/local/lib/python3.12/dist-packages (from acoustics==0.2.6) (3.10.0)\n", + "Requirement already satisfied: six>=1.4.1 in /usr/local/lib/python3.12/dist-packages (from acoustics==0.2.6) (1.17.0)\n", + "Requirement already satisfied: pandas>=0.15 in /usr/local/lib/python3.12/dist-packages (from acoustics==0.2.6) (2.2.2)\n", + "Requirement already satisfied: tabulate in /usr/local/lib/python3.12/dist-packages (from acoustics==0.2.6) (0.9.0)\n", + "Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas>=0.15->acoustics==0.2.6) (2.9.0.post0)\n", + "Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas>=0.15->acoustics==0.2.6) (2025.2)\n", + "Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas>=0.15->acoustics==0.2.6) (2026.2)\n", + "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib->acoustics==0.2.6) (1.3.3)\n", + "Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.12/dist-packages (from matplotlib->acoustics==0.2.6) (0.12.1)\n", + "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.12/dist-packages (from matplotlib->acoustics==0.2.6) (4.63.0)\n", + "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib->acoustics==0.2.6) (1.5.0)\n", + "Requirement already satisfied: packaging>=20.0 in /usr/local/lib/python3.12/dist-packages (from matplotlib->acoustics==0.2.6) (26.2)\n", + "Requirement already satisfied: pillow>=8 in /usr/local/lib/python3.12/dist-packages (from matplotlib->acoustics==0.2.6) (11.3.0)\n", + "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib->acoustics==0.2.6) (3.3.2)\n", + "Collecting onnxruntime==1.22.1\n", + " Using cached onnxruntime-1.22.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (4.9 kB)\n", + "Collecting ai_edge_litert==1.4.0\n", + " Using cached ai_edge_litert-1.4.0-cp312-cp312-manylinux_2_17_x86_64.whl.metadata (1.9 kB)\n", + "Requirement already satisfied: onnxsim in /usr/local/lib/python3.12/dist-packages (0.6.4)\n", + "Requirement already satisfied: coloredlogs in /usr/local/lib/python3.12/dist-packages (from onnxruntime==1.22.1) (15.0.1)\n", + "Requirement already satisfied: flatbuffers in /usr/local/lib/python3.12/dist-packages (from onnxruntime==1.22.1) (25.12.19)\n", + "Requirement already satisfied: numpy>=1.21.6 in /usr/local/lib/python3.12/dist-packages (from onnxruntime==1.22.1) (1.26.4)\n", + "Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from onnxruntime==1.22.1) (26.2)\n", + "Requirement already satisfied: protobuf in /usr/local/lib/python3.12/dist-packages (from onnxruntime==1.22.1) (4.25.5)\n", + "Requirement already satisfied: sympy in /usr/local/lib/python3.12/dist-packages (from onnxruntime==1.22.1) (1.13.1)\n", + "Requirement already satisfied: backports.strenum in /usr/local/lib/python3.12/dist-packages (from ai_edge_litert==1.4.0) (1.2.8)\n", + "Requirement already satisfied: tqdm in /usr/local/lib/python3.12/dist-packages (from ai_edge_litert==1.4.0) (4.67.1)\n", + "Requirement already satisfied: typing-extensions in /usr/local/lib/python3.12/dist-packages (from ai_edge_litert==1.4.0) (4.15.0)\n", + "Requirement already satisfied: onnx in /usr/local/lib/python3.12/dist-packages (from onnxsim) (1.19.1)\n", + "Requirement already satisfied: rich in /usr/local/lib/python3.12/dist-packages (from onnxsim) (13.9.4)\n", + "Requirement already satisfied: humanfriendly>=9.1 in /usr/local/lib/python3.12/dist-packages (from coloredlogs->onnxruntime==1.22.1) (10.0)\n", + "Requirement already satisfied: ml_dtypes>=0.5.0 in /usr/local/lib/python3.12/dist-packages (from onnx->onnxsim) (0.5.1)\n", + "Requirement already satisfied: markdown-it-py>=2.2.0 in /usr/local/lib/python3.12/dist-packages (from rich->onnxsim) (4.2.0)\n", + "Requirement already satisfied: pygments<3.0.0,>=2.13.0 in /usr/local/lib/python3.12/dist-packages (from rich->onnxsim) (2.20.0)\n", + "Requirement already satisfied: mpmath<1.4,>=1.1.0 in /usr/local/lib/python3.12/dist-packages (from sympy->onnxruntime==1.22.1) (1.3.0)\n", + "Requirement already satisfied: mdurl~=0.1 in /usr/local/lib/python3.12/dist-packages (from markdown-it-py>=2.2.0->rich->onnxsim) (0.1.2)\n", + "Using cached onnxruntime-1.22.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (16.5 MB)\n", + "Using cached ai_edge_litert-1.4.0-cp312-cp312-manylinux_2_17_x86_64.whl (11.2 MB)\n", + "Installing collected packages: ai_edge_litert, onnxruntime\n", + " Attempting uninstall: ai_edge_litert\n", + " Found existing installation: ai-edge-litert 2.1.2\n", + " Uninstalling ai-edge-litert-2.1.2:\n", + " Successfully uninstalled ai-edge-litert-2.1.2\n", + " Attempting uninstall: onnxruntime\n", + " Found existing installation: onnxruntime 1.24.3\n", + " Uninstalling onnxruntime-1.24.3:\n", + " Successfully uninstalled onnxruntime-1.24.3\n", + "\u001b[31mERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.\n", + "openwakeword 0.6.0 requires speexdsp-ns<1,>=0.1.2; platform_system == \"Linux\", which is not installed.\n", + "onnx2tf 2.4.1 requires ai-edge-litert==2.1.2, but you have ai-edge-litert 1.4.0 which is incompatible.\n", + "onnx2tf 2.4.1 requires onnx==1.20.1, but you have onnx 1.19.1 which is incompatible.\n", + "onnx2tf 2.4.1 requires onnxruntime==1.24.3, but you have onnxruntime 1.22.1 which is incompatible.\n", + "openwakeword 0.6.0 requires ai-edge-litert<3,>=2.0.2; platform_system == \"Linux\" or platform_system == \"Darwin\", but you have ai-edge-litert 1.4.0 which is incompatible.\u001b[0m\u001b[31m\n", + "\u001b[0mSuccessfully installed ai_edge_litert-1.4.0 onnxruntime-1.22.1\n", + "Requirement already satisfied: onnx2tf in /usr/local/lib/python3.12/dist-packages (2.4.1)\n", + "Requirement already satisfied: numpy==1.26.4 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (1.26.4)\n", + "Collecting onnx==1.20.1 (from onnx2tf)\n", + " Using cached onnx-1.20.1-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (8.4 kB)\n", + "Collecting onnxruntime==1.24.3 (from onnx2tf)\n", + " Using cached onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.metadata (5.1 kB)\n", + "Requirement already satisfied: opencv-python==4.11.0.86 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (4.11.0.86)\n", + "Requirement already satisfied: onnxsim-prebuilt==0.4.39.post2 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (0.4.39.post2)\n", + "Requirement already satisfied: onnxoptimizer==0.4.2 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (0.4.2)\n", + "Requirement already satisfied: onnxscript==0.6.2 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (0.6.2)\n", + "Collecting ai-edge-litert==2.1.2 (from onnx2tf)\n", + " Using cached ai_edge_litert-2.1.2-cp312-cp312-manylinux_2_27_x86_64.whl.metadata (2.1 kB)\n", + "Requirement already satisfied: sne4onnx==2.0.1 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (2.0.1)\n", + "Requirement already satisfied: sng4onnx==2.0.1 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (2.0.1)\n", + "Requirement already satisfied: psutil==5.9.5 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (5.9.5)\n", + "Requirement already satisfied: protobuf==4.25.5 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (4.25.5)\n", + "Requirement already satisfied: h5py==3.12.1 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (3.12.1)\n", + "Requirement already satisfied: ml-dtypes==0.5.1 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (0.5.1)\n", + "Requirement already satisfied: flatbuffers==25.12.19 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (25.12.19)\n", + "Requirement already satisfied: tqdm==4.67.1 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (4.67.1)\n", + "Requirement already satisfied: pytest==9.0.2 in /usr/local/lib/python3.12/dist-packages (from onnx2tf) (9.0.2)\n", + "Requirement already satisfied: backports.strenum in /usr/local/lib/python3.12/dist-packages (from ai-edge-litert==2.1.2->onnx2tf) (1.2.8)\n", + "Requirement already satisfied: typing-extensions in /usr/local/lib/python3.12/dist-packages (from ai-edge-litert==2.1.2->onnx2tf) (4.15.0)\n", + "Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from onnxruntime==1.24.3->onnx2tf) (26.2)\n", + "Requirement already satisfied: sympy in /usr/local/lib/python3.12/dist-packages (from onnxruntime==1.24.3->onnx2tf) (1.13.1)\n", + "Requirement already satisfied: onnx_ir<2,>=0.1.15 in /usr/local/lib/python3.12/dist-packages (from onnxscript==0.6.2->onnx2tf) (0.2.1)\n", + "Requirement already satisfied: rich in /usr/local/lib/python3.12/dist-packages (from onnxsim-prebuilt==0.4.39.post2->onnx2tf) (13.9.4)\n", + "Requirement already satisfied: iniconfig>=1.0.1 in /usr/local/lib/python3.12/dist-packages (from pytest==9.0.2->onnx2tf) (2.3.0)\n", + "Requirement already satisfied: pluggy<2,>=1.5 in /usr/local/lib/python3.12/dist-packages (from pytest==9.0.2->onnx2tf) (1.6.0)\n", + "Requirement already satisfied: pygments>=2.7.2 in /usr/local/lib/python3.12/dist-packages (from pytest==9.0.2->onnx2tf) (2.20.0)\n", + "Requirement already satisfied: mpmath<1.4,>=1.1.0 in /usr/local/lib/python3.12/dist-packages (from sympy->onnxruntime==1.24.3->onnx2tf) (1.3.0)\n", + "Requirement already satisfied: markdown-it-py>=2.2.0 in /usr/local/lib/python3.12/dist-packages (from rich->onnxsim-prebuilt==0.4.39.post2->onnx2tf) (4.2.0)\n", + "Requirement already satisfied: mdurl~=0.1 in /usr/local/lib/python3.12/dist-packages (from markdown-it-py>=2.2.0->rich->onnxsim-prebuilt==0.4.39.post2->onnx2tf) (0.1.2)\n", + "Using cached ai_edge_litert-2.1.2-cp312-cp312-manylinux_2_27_x86_64.whl (16.3 MB)\n", + "Using cached onnx-1.20.1-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (17.5 MB)\n", + "Using cached onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (17.2 MB)\n", + "Installing collected packages: ai-edge-litert, onnxruntime, onnx\n", + " Attempting uninstall: ai-edge-litert\n", + " Found existing installation: ai-edge-litert 1.4.0\n", + " Uninstalling ai-edge-litert-1.4.0:\n", + " Successfully uninstalled ai-edge-litert-1.4.0\n", + " Attempting uninstall: onnxruntime\n", + " Found existing installation: onnxruntime 1.22.1\n", + " Uninstalling onnxruntime-1.22.1:\n", + " Successfully uninstalled onnxruntime-1.22.1\n", + " Attempting uninstall: onnx\n", + " Found existing installation: onnx 1.19.1\n", + " Uninstalling onnx-1.19.1:\n", + " Successfully uninstalled onnx-1.19.1\n", + "\u001b[31mERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.\n", + "openwakeword 0.6.0 requires speexdsp-ns<1,>=0.1.2; platform_system == \"Linux\", which is not installed.\u001b[0m\u001b[31m\n", + "\u001b[0mSuccessfully installed ai-edge-litert-2.1.2 onnx-1.20.1 onnxruntime-1.24.3\n", + "Collecting onnx==1.19.1\n", + " Using cached onnx-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (7.0 kB)\n", + "Requirement already satisfied: numpy>=1.22 in /usr/local/lib/python3.12/dist-packages (from onnx==1.19.1) (1.26.4)\n", + "Requirement already satisfied: protobuf>=4.25.1 in /usr/local/lib/python3.12/dist-packages (from onnx==1.19.1) (4.25.5)\n", + "Requirement already satisfied: typing_extensions>=4.7.1 in /usr/local/lib/python3.12/dist-packages (from onnx==1.19.1) (4.15.0)\n", + "Requirement already satisfied: ml_dtypes>=0.5.0 in /usr/local/lib/python3.12/dist-packages (from onnx==1.19.1) (0.5.1)\n", + "Using cached onnx-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (18.2 MB)\n", + "Installing collected packages: onnx\n", + " Attempting uninstall: onnx\n", + " Found existing installation: onnx 1.20.1\n", + " Uninstalling onnx-1.20.1:\n", + " Successfully uninstalled onnx-1.20.1\n", + "\u001b[31mERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.\n", + "onnx2tf 2.4.1 requires onnx==1.20.1, but you have onnx 1.19.1 which is incompatible.\u001b[0m\u001b[31m\n", + "\u001b[0mSuccessfully installed onnx-1.19.1\n", + "Requirement already satisfied: onnx_graphsurgeon in /usr/local/lib/python3.12/dist-packages (0.6.1)\n", + "Requirement already satisfied: sng4onnx in /usr/local/lib/python3.12/dist-packages (2.0.1)\n", + "Requirement already satisfied: ml-dtypes in /usr/local/lib/python3.12/dist-packages (from onnx_graphsurgeon) (0.5.1)\n", + "Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (from onnx_graphsurgeon) (1.26.4)\n", + "Requirement already satisfied: onnx>=1.14.0 in /usr/local/lib/python3.12/dist-packages (from onnx_graphsurgeon) (1.19.1)\n", + "Requirement already satisfied: protobuf>=4.25.1 in /usr/local/lib/python3.12/dist-packages (from onnx>=1.14.0->onnx_graphsurgeon) (4.25.5)\n", + "Requirement already satisfied: typing_extensions>=4.7.1 in /usr/local/lib/python3.12/dist-packages (from onnx>=1.14.0->onnx_graphsurgeon) (4.15.0)\n", + "Requirement already satisfied: pronouncing==0.2.0 in /usr/local/lib/python3.12/dist-packages (0.2.0)\n", + "Requirement already satisfied: cmudict>=0.4.0 in /usr/local/lib/python3.12/dist-packages (from pronouncing==0.2.0) (1.1.3)\n", + "Requirement already satisfied: importlib-metadata>=5 in /usr/local/lib/python3.12/dist-packages (from cmudict>=0.4.0->pronouncing==0.2.0) (8.7.1)\n", + "Requirement already satisfied: importlib-resources>=5 in /usr/local/lib/python3.12/dist-packages (from cmudict>=0.4.0->pronouncing==0.2.0) (7.1.0)\n", + "Requirement already satisfied: zipp>=3.20 in /usr/local/lib/python3.12/dist-packages (from importlib-metadata>=5->cmudict>=0.4.0->pronouncing==0.2.0) (4.1.0)\n", + "Requirement already satisfied: datasets==2.14.6 in /usr/local/lib/python3.12/dist-packages (2.14.6)\n", + "Requirement already satisfied: numpy>=1.17 in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (1.26.4)\n", + "Requirement already satisfied: pyarrow>=8.0.0 in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (18.1.0)\n", + "Requirement already satisfied: dill<0.3.8,>=0.3.0 in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (0.3.7)\n", + "Requirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (2.2.2)\n", + "Requirement already satisfied: requests>=2.19.0 in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (2.32.4)\n", + "Requirement already satisfied: tqdm>=4.62.1 in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (4.67.1)\n", + "Requirement already satisfied: xxhash in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (3.7.0)\n", + "Requirement already satisfied: multiprocess in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (0.70.15)\n", + "Requirement already satisfied: fsspec<=2023.10.0,>=2023.1.0 in /usr/local/lib/python3.12/dist-packages (from fsspec[http]<=2023.10.0,>=2023.1.0->datasets==2.14.6) (2023.10.0)\n", + "Requirement already satisfied: aiohttp in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (3.13.5)\n", + "Requirement already satisfied: huggingface-hub<1.0.0,>=0.14.0 in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (0.36.2)\n", + "Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (26.2)\n", + "Requirement already satisfied: pyyaml>=5.1 in /usr/local/lib/python3.12/dist-packages (from datasets==2.14.6) (6.0.3)\n", + "Requirement already satisfied: aiohappyeyeballs>=2.5.0 in /usr/local/lib/python3.12/dist-packages (from aiohttp->datasets==2.14.6) (2.6.2)\n", + "Requirement already satisfied: aiosignal>=1.4.0 in /usr/local/lib/python3.12/dist-packages (from aiohttp->datasets==2.14.6) (1.4.0)\n", + "Requirement already satisfied: attrs>=17.3.0 in /usr/local/lib/python3.12/dist-packages (from aiohttp->datasets==2.14.6) (26.1.0)\n", + "Requirement already satisfied: frozenlist>=1.1.1 in /usr/local/lib/python3.12/dist-packages (from aiohttp->datasets==2.14.6) (1.8.0)\n", + "Requirement already satisfied: multidict<7.0,>=4.5 in /usr/local/lib/python3.12/dist-packages (from aiohttp->datasets==2.14.6) (6.7.1)\n", + "Requirement already satisfied: propcache>=0.2.0 in /usr/local/lib/python3.12/dist-packages (from aiohttp->datasets==2.14.6) (0.5.2)\n", + "Requirement already satisfied: yarl<2.0,>=1.17.0 in /usr/local/lib/python3.12/dist-packages (from aiohttp->datasets==2.14.6) (1.24.2)\n", + "Requirement already satisfied: filelock in /usr/local/lib/python3.12/dist-packages (from huggingface-hub<1.0.0,>=0.14.0->datasets==2.14.6) (3.29.0)\n", + "Requirement already satisfied: hf-xet<2.0.0,>=1.1.3 in /usr/local/lib/python3.12/dist-packages (from huggingface-hub<1.0.0,>=0.14.0->datasets==2.14.6) (1.5.0)\n", + "Requirement already satisfied: typing-extensions>=3.7.4.3 in /usr/local/lib/python3.12/dist-packages (from huggingface-hub<1.0.0,>=0.14.0->datasets==2.14.6) (4.15.0)\n", + "Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->datasets==2.14.6) (3.4.7)\n", + "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->datasets==2.14.6) (3.15)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->datasets==2.14.6) (2.5.0)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.12/dist-packages (from requests>=2.19.0->datasets==2.14.6) (2026.5.20)\n", + "Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas->datasets==2.14.6) (2.9.0.post0)\n", + "Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas->datasets==2.14.6) (2025.2)\n", + "Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas->datasets==2.14.6) (2026.2)\n", + "Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas->datasets==2.14.6) (1.17.0)\n", + "Requirement already satisfied: deep-phonemizer==0.0.19 in /usr/local/lib/python3.12/dist-packages (0.0.19)\n", + "Requirement already satisfied: torch>=1.2.0 in /usr/local/lib/python3.12/dist-packages (from deep-phonemizer==0.0.19) (2.5.0+cu121)\n", + "Requirement already satisfied: tqdm>=4.38.0 in /usr/local/lib/python3.12/dist-packages (from deep-phonemizer==0.0.19) (4.67.1)\n", + "Requirement already satisfied: PyYAML>=5.1 in /usr/local/lib/python3.12/dist-packages (from deep-phonemizer==0.0.19) (6.0.3)\n", + "Requirement already satisfied: tensorboard in /usr/local/lib/python3.12/dist-packages (from deep-phonemizer==0.0.19) (2.20.0)\n", + "Requirement already satisfied: certifi>=2022.12.7 in /usr/local/lib/python3.12/dist-packages (from deep-phonemizer==0.0.19) (2026.5.20)\n", + "Requirement already satisfied: wheel>=0.38.0 in /usr/local/lib/python3.12/dist-packages (from deep-phonemizer==0.0.19) (0.47.0)\n", + "Requirement already satisfied: setuptools>=65.5.1 in /usr/local/lib/python3.12/dist-packages (from deep-phonemizer==0.0.19) (75.2.0)\n", + "Requirement already satisfied: filelock in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (3.29.0)\n", + "Requirement already satisfied: typing-extensions>=4.8.0 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (4.15.0)\n", + "Requirement already satisfied: networkx in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (3.6.1)\n", + "Requirement already satisfied: jinja2 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (3.1.6)\n", + "Requirement already satisfied: fsspec in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (2023.10.0)\n", + "Requirement already satisfied: nvidia-cuda-nvrtc-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (12.1.105)\n", + "Requirement already satisfied: nvidia-cuda-runtime-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (12.1.105)\n", + "Requirement already satisfied: nvidia-cuda-cupti-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (12.1.105)\n", + "Requirement already satisfied: nvidia-cudnn-cu12==9.1.0.70 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (9.1.0.70)\n", + "Requirement already satisfied: nvidia-cublas-cu12==12.1.3.1 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (12.1.3.1)\n", + "Requirement already satisfied: nvidia-cufft-cu12==11.0.2.54 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (11.0.2.54)\n", + "Requirement already satisfied: nvidia-curand-cu12==10.3.2.106 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (10.3.2.106)\n", + "Requirement already satisfied: nvidia-cusolver-cu12==11.4.5.107 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (11.4.5.107)\n", + "Requirement already satisfied: nvidia-cusparse-cu12==12.1.0.106 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (12.1.0.106)\n", + "Requirement already satisfied: nvidia-nccl-cu12==2.21.5 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (2.21.5)\n", + "Requirement already satisfied: nvidia-nvtx-cu12==12.1.105 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (12.1.105)\n", + "Requirement already satisfied: triton==3.1.0 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (3.1.0)\n", + "Requirement already satisfied: sympy==1.13.1 in /usr/local/lib/python3.12/dist-packages (from torch>=1.2.0->deep-phonemizer==0.0.19) (1.13.1)\n", + "Requirement already satisfied: nvidia-nvjitlink-cu12 in /usr/local/lib/python3.12/dist-packages (from nvidia-cusolver-cu12==11.4.5.107->torch>=1.2.0->deep-phonemizer==0.0.19) (12.8.93)\n", + "Requirement already satisfied: mpmath<1.4,>=1.1.0 in /usr/local/lib/python3.12/dist-packages (from sympy==1.13.1->torch>=1.2.0->deep-phonemizer==0.0.19) (1.3.0)\n", + "Requirement already satisfied: packaging>=24.0 in /usr/local/lib/python3.12/dist-packages (from wheel>=0.38.0->deep-phonemizer==0.0.19) (26.2)\n", + "Requirement already satisfied: absl-py>=0.4 in /usr/local/lib/python3.12/dist-packages (from tensorboard->deep-phonemizer==0.0.19) (1.4.0)\n", + "Requirement already satisfied: grpcio>=1.48.2 in /usr/local/lib/python3.12/dist-packages (from tensorboard->deep-phonemizer==0.0.19) (1.80.0)\n", + "Requirement already satisfied: markdown>=2.6.8 in /usr/local/lib/python3.12/dist-packages (from tensorboard->deep-phonemizer==0.0.19) (3.10.2)\n", + "Requirement already satisfied: numpy>=1.12.0 in /usr/local/lib/python3.12/dist-packages (from tensorboard->deep-phonemizer==0.0.19) (1.26.4)\n", + "Requirement already satisfied: pillow in /usr/local/lib/python3.12/dist-packages (from tensorboard->deep-phonemizer==0.0.19) (11.3.0)\n", + "Requirement already satisfied: protobuf!=4.24.0,>=3.19.6 in /usr/local/lib/python3.12/dist-packages (from tensorboard->deep-phonemizer==0.0.19) (4.25.5)\n", + "Requirement already satisfied: tensorboard-data-server<0.8.0,>=0.7.0 in /usr/local/lib/python3.12/dist-packages (from tensorboard->deep-phonemizer==0.0.19) (0.7.2)\n", + "Requirement already satisfied: werkzeug>=1.0.1 in /usr/local/lib/python3.12/dist-packages (from tensorboard->deep-phonemizer==0.0.19) (3.1.8)\n", + "Requirement already satisfied: markupsafe>=2.1.1 in /usr/local/lib/python3.12/dist-packages (from werkzeug>=1.0.1->tensorboard->deep-phonemizer==0.0.19) (3.0.3)\n", + "Installation terminée.\n" + ] + } + ], + "execution_count": 14 + }, + { + "cell_type": "code", + "metadata": { + "id": "hTJFtu2T5ArE", + "colab": { + "base_uri": "https://localhost:8080/" + }, + "outputId": "cfd86de4-0ccd-484c-b7dc-dfd8c90a6f79" + }, + "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 (1h de clips pour bruit de fond)\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": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Données prêtes.\n" + ] + } + ], + "execution_count": 15 + }, + { + "cell_type": "markdown", + "metadata": { + "id": "lR8ZNUgE5ArE" + }, + "source": [ + "## Étape 3 — Génération des samples hey_marco multi-langue" + ] + }, + { + "cell_type": "code", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "yVTgaVB_5ArE", + "outputId": "5fe63df8-579a-4a23-c10b-4436f22158bc" + }, + "source": [ + "import sys, os, glob\n", + "import scipy.io.wavfile as wav\n", + "import scipy.signal as signal\n", + "import numpy as np\n", + "from tqdm import tqdm\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", + "os.makedirs('./hey_marco_clips', exist_ok=True)\n", + "\n", + "# ── EN/DE/NL/IT/ES via piper-sample-generator ───────────────────────────────\n", + "for lang in ['en', 'en-gb', 'de', 'nl', 'it', 'es']:\n", + " n = lang_samples[lang]\n", + " out_dir = f'./hey_marco_clips/{lang}'\n", + " os.makedirs(out_dir, exist_ok=True)\n", + " existing = len([f for f in os.listdir(out_dir) if f.endswith('.wav')])\n", + " remaining = n - existing\n", + " if remaining <= 0:\n", + " print(f' [{lang}] déjà complet ({existing}/{n})')\n", + " continue\n", + " print(f' [{lang}] génération {remaining} samples...')\n", + " generate_samples(\n", + " text='hey_marco', max_samples=remaining,\n", + " length_scales=[0.9, 1.0, 1.1], noise_scales=[0.667], noise_scale_ws=[0.8],\n", + " output_dir=out_dir, batch_size=50, auto_reduce_batch_size=True,\n", + " )\n", + "\n", + "# ── FR via piper CLI (redirection fichier) + rééchantillonnage 16kHz ─────────\n", + "fr_out_dir = './hey_marco_clips/fr'\n", + "os.makedirs(fr_out_dir, exist_ok=True)\n", + "existing_fr = len([f for f in os.listdir(fr_out_dir) if f.endswith('.wav')])\n", + "remaining_fr = lang_samples['fr'] - existing_fr\n", + "\n", + "if remaining_fr > 0:\n", + " print(f' [fr] génération {remaining_fr} samples : \"hé marco\"')\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(['hé marco'] * n))\n", + " !piper --model fr_FR-siwis-medium.onnx --length_scale {speed} --output_dir {fr_out_dir} < /tmp/fr_input.txt\n", + "\n", + " resampled = 0\n", + " for path in glob.glob(f'{fr_out_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')\n", + "else:\n", + " print(f' [fr] déjà complet ({existing_fr}/{lang_samples[\"fr\"]})')\n", + "\n", + "total = sum(\n", + " len([f for f in os.listdir(f'./hey_marco_clips/{lang}') if f.endswith('.wav')])\n", + " for lang in lang_samples\n", + ")\n", + "print(f'\\nTotal clips générés : {total}/{number_of_examples}')" + ], + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + " [en] déjà complet (3000/3000)\n", + " [en-gb] déjà complet (1000/1000)\n", + " [de] déjà complet (1000/1000)\n", + " [nl] déjà complet (500/500)\n", + " [it] déjà complet (500/500)\n", + " [es] déjà complet (500/500)\n", + " [fr] déjà complet (3500/3500)\n", + "\n", + "Total clips générés : 10000/10000\n" + ] + } + ], + "execution_count": 16 + }, + { + "cell_type": "markdown", + "metadata": { + "id": "tS_Ml81B5ArF" + }, + "source": [ + "## Étape 4 — Training" + ] + }, + { + "cell_type": "code", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "-GHdlmWe5ArF", + "outputId": "6e89d7cb-defb-4490-8034-330f88e81106" + }, + "source": [ + "import yaml, os, sys, shutil, glob, random\n", + "import scipy.io.wavfile as wav\n", + "import scipy.signal as signal\n", + "import numpy as np\n", + "\n", + "config = yaml.load(open('openwakeword/examples/custom_model.yml', 'r').read(), yaml.Loader)\n", + "\n", + "# Paramètres depuis la cellule de configuration globale\n", + "config['target_phrase'] = ['hey marco', 'hé marco']\n", + "config['model_name'] = 'hey_marco'\n", + "config['n_samples'] = number_of_examples\n", + "config['n_samples_val'] = max(500, number_of_examples // 10)\n", + "config['steps'] = number_of_training_steps\n", + "config['target_accuracy'] = 0.5\n", + "config['target_recall'] = 0.25\n", + "config['max_negative_weight'] = false_activation_penalty\n", + "\n", + "final_drive_output_dir = os.path.join(drive_output_dir, 'hey_marco')\n", + "os.makedirs(final_drive_output_dir, exist_ok=True)\n", + "\n", + "local_temp_dir = './openwakeword_training_temp'\n", + "config['output_dir'] = local_temp_dir\n", + "config['background_paths'] = ['./fma']\n", + "config['false_positive_validation_data_path'] = 'validation_set_features.npy'\n", + "config['feature_data_files'] = {'ACAV100M_sample': 'openwakeword_features_ACAV100M_2000_hrs_16bit.npy'}\n", + "\n", + "with open('my_model.yaml', 'w') as f:\n", + " yaml.dump(config, f)\n", + "\n", + "# Rééchantillonner les clips FR à 16kHz\n", + "for path in glob.glob('./hey_marco_clips/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\n", + "print('Setup des dossiers via --generate_clips...')\n", + "!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --generate_clips\n", + "\n", + "# Remplacer les clips EN par nos clips multi-langue\n", + "positive_clips_dir = os.path.join(local_temp_dir, 'hey_marco', 'positive_clips')\n", + "positive_test_dir = os.path.join(local_temp_dir, 'hey_marco', 'positive_test')\n", + "os.makedirs(positive_clips_dir, exist_ok=True)\n", + "os.makedirs(positive_test_dir, exist_ok=True)\n", + "\n", + "for f in glob.glob(os.path.join(positive_clips_dir, '*.wav')): os.remove(f)\n", + "for f in glob.glob(os.path.join(positive_test_dir, '*.wav')): os.remove(f)\n", + "\n", + "all_clips = glob.glob('./hey_marco_clips/**/*.wav', recursive=True)\n", + "random.shuffle(all_clips)\n", + "n_val = config['n_samples_val']\n", + "for 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)}\"))\n", + "for 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", + "\n", + "print(f'Training : {len(all_clips) - n_val} clips | Validation : {n_val} clips')\n", + "\n", + "# Supprimer le cache .npy\n", + "for f in glob.glob(os.path.join(local_temp_dir, 'hey_marco', '*.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": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "Setup des dossiers via --generate_clips...\n", + "/usr/local/lib/python3.12/dist-packages/torch_audiomentations/utils/io.py:27: UserWarning: torchaudio._backend.set_audio_backend has been deprecated. With dispatcher enabled, this function is no-op. You can remove the function call.\n", + " torchaudio.set_audio_backend(\"soundfile\")\n", + "INFO:root:##################################################\n", + "Generating positive clips for training\n", + "##################################################\n", + "WARNING:root:Skipping generation of positive clips for training, as ~10000 already exist\n", + "INFO:root:##################################################\n", + "Generating positive clips for testing\n", + "##################################################\n", + "WARNING:root:Skipping generation of positive clips testing, as ~1000 already exist\n", + "INFO:root:##################################################\n", + "Generating negative clips for training\n", + "##################################################\n", + "WARNING:root:Skipping generation of negative clips for training, as ~10000 already exist\n", + "INFO:root:##################################################\n", + "Generating negative clips for testing\n", + "##################################################\n", + "WARNING:root:Skipping generation of negative clips for testing, as ~1000 already exist\n", + "Training : 9000 clips | Validation : 1000 clips\n", + "Cache supprimé : positive_features_train.npy\n", + "Cache supprimé : negative_features_test.npy\n", + "Cache supprimé : negative_features_train.npy\n", + "Cache supprimé : positive_features_test.npy\n", + "/usr/local/lib/python3.12/dist-packages/torch_audiomentations/utils/io.py:27: UserWarning: torchaudio._backend.set_audio_backend has been deprecated. With dispatcher enabled, this function is no-op. You can remove the function call.\n", + " torchaudio.set_audio_backend(\"soundfile\")\n", + "INFO:root:##################################################\n", + "Computing openwakeword features for generated samples\n", + "##################################################\n", + "/usr/local/lib/python3.12/dist-packages/onnxruntime/capi/onnxruntime_inference_collection.py:123: UserWarning: Specified provider 'CUDAExecutionProvider' is not in available provider names.Available providers: 'AzureExecutionProvider, CPUExecutionProvider'\n", + " warnings.warn(\n", + "/usr/local/lib/python3.12/dist-packages/torch_audiomentations/core/transforms_interface.py:77: FutureWarning: Transforms now expect an `output_type` argument that currently defaults to 'tensor', will default to 'dict' in v0.12, and will be removed in v0.13. Make sure to update your code to something like:\n", + " >>> augment = PitchShift(..., output_type='dict')\n", + " >>> augmented_samples = augment(samples).samples\n", + " warnings.warn(\n", + "/usr/local/lib/python3.12/dist-packages/torch_audiomentations/core/transforms_interface.py:77: FutureWarning: Transforms now expect an `output_type` argument that currently defaults to 'tensor', will default to 'dict' in v0.12, and will be removed in v0.13. Make sure to update your code to something like:\n", + " >>> augment = BandStopFilter(..., output_type='dict')\n", + " >>> augmented_samples = augment(samples).samples\n", + " warnings.warn(\n", + "/usr/local/lib/python3.12/dist-packages/torch_audiomentations/core/transforms_interface.py:77: FutureWarning: Transforms now expect an `output_type` argument that currently defaults to 'tensor', will default to 'dict' in v0.12, and will be removed in v0.13. Make sure to update your code to something like:\n", + " >>> augment = AddColoredNoise(..., output_type='dict')\n", + " >>> augmented_samples = augment(samples).samples\n", + " warnings.warn(\n", + "/usr/local/lib/python3.12/dist-packages/torch_audiomentations/core/transforms_interface.py:77: FutureWarning: Transforms now expect an `output_type` argument that currently defaults to 'tensor', will default to 'dict' in v0.12, and will be removed in v0.13. Make sure to update your code to something like:\n", + " >>> augment = AddBackgroundNoise(..., output_type='dict')\n", + " >>> augmented_samples = augment(samples).samples\n", + " warnings.warn(\n", + "/usr/local/lib/python3.12/dist-packages/torch_audiomentations/core/transforms_interface.py:77: FutureWarning: Transforms now expect an `output_type` argument that currently defaults to 'tensor', will default to 'dict' in v0.12, and will be removed in v0.13. Make sure to update your code to something like:\n", + " >>> augment = Gain(..., output_type='dict')\n", + " >>> augmented_samples = augment(samples).samples\n", + " warnings.warn(\n", + "/usr/local/lib/python3.12/dist-packages/torch_audiomentations/core/composition.py:42: FutureWarning: Transforms now expect an `output_type` argument that currently defaults to 'tensor', will default to 'dict' in v0.12, and will be removed in v0.13. Make sure to update your code to something like:\n", + " >>> augment = Compose(..., output_type='dict')\n", + " >>> augmented_samples = augment(samples).samples\n", + " warnings.warn(\n", + "/usr/local/lib/python3.12/dist-packages/audiomentations/core/transforms_interface.py:61: UserWarning: Warning: input samples dtype is np.float64. Converting to np.float32\n", + " warnings.warn(\n", + "Computing features: 14% 89/625 [01:05<07:51, 1.14it/s]" + ] + } + ], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": { + "id": "7PzwdVPt5ArF" + }, + "source": [ + "## Étape 5 — Export ONNX → TFLite + sauvegarde Drive" + ] + }, + { + "cell_type": "code", + "metadata": { + "id": "-Y8mDdku5ArF", + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000 + }, + "outputId": "1e1cc21f-d261-49c8-c5ec-7f2c86beea8d" + }, + "source": [ + "import shutil, os\n", + "from google.colab import files\n", + "\n", + "local_onnx = f'{local_temp_dir}/hey_marco.onnx'\n", + "drive_onnx = f'{final_drive_output_dir}/hey_marco.onnx'\n", + "drive_tflite1 = f'{final_drive_output_dir}/hey_marco_float32.tflite'\n", + "drive_tflite = f'{final_drive_output_dir}/hey_marco.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": [ + { + "output_type": "stream", + "name": "stdout", + "text": [ + "ONNX copié : /content/drive/My Drive/Colab Notebooks/Wakeword/openwakeword/hey_marco/hey_marco.onnx\n", + "\n", + "\u001b[07mModel optimizing started\u001b[0m ============================================================\n", + "Simplifying...\n", + "Finish! Here is the difference:\n", + "┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓\n", + "┃ ┃ Original Model ┃ Simplified Model ┃\n", + "┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩\n", + "│ Add │ 4 │ 4 │\n", + "│ Constant │ 14 │ 12 │\n", + "│ Div │ 2 │ 2 │\n", + "│ Flatten │ 1 │ 1 │\n", + "│ Gemm │ 3 │ 3 │\n", + "│ Mul │ 2 │ 2 │\n", + "│ Pow │ 2 │ 2 │\n", + "│ ReduceMean │ 4 │ 4 │\n", + "│ Relu │ 2 │ 2 │\n", + "│ Sigmoid │ 1 │ 1 │\n", + "│ Sqrt │ 2 │ 2 │\n", + "│ Sub │ 2 │ 2 │\n", + "│ Model Size │ 200.6KiB │ 201.4KiB │\n", + "└────────────┴────────────────┴──────────────────┘\n", + "\n", + "Simplifying...\n", + "Finish! Here is the difference:\n", + "┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓\n", + "┃ ┃ Original Model ┃ Simplified Model ┃\n", + "┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩\n", + "│ Add │ 4 │ 4 │\n", + "│ Constant │ 12 │ 12 │\n", + "│ Div │ 2 │ 2 │\n", + "│ Flatten │ 1 │ 1 │\n", + "│ Gemm │ 3 │ 3 │\n", + "│ Mul │ 2 │ 2 │\n", + "│ Pow │ 2 │ 2 │\n", + "│ ReduceMean │ 4 │ 4 │\n", + "│ Relu │ 2 │ 2 │\n", + "│ Sigmoid │ 1 │ 1 │\n", + "│ Sqrt │ 2 │ 2 │\n", + "│ Sub │ 2 │ 2 │\n", + "│ Model Size │ 201.4KiB │ 201.4KiB │\n", + "└────────────┴────────────────┴──────────────────┘\n", + "\n", + "Simplifying...\n", + "Finish! Here is the difference:\n", + "┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓\n", + "┃ ┃ Original Model ┃ Simplified Model ┃\n", + "┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩\n", + "│ Add │ 4 │ 4 │\n", + "│ Constant │ 12 │ 12 │\n", + "│ Div │ 2 │ 2 │\n", + "│ Flatten │ 1 │ 1 │\n", + "│ Gemm │ 3 │ 3 │\n", + "│ Mul │ 2 │ 2 │\n", + "│ Pow │ 2 │ 2 │\n", + "│ ReduceMean │ 4 │ 4 │\n", + "│ Relu │ 2 │ 2 │\n", + "│ Sigmoid │ 1 │ 1 │\n", + "│ Sqrt │ 2 │ 2 │\n", + "│ Sub │ 2 │ 2 │\n", + "│ Model Size │ 201.4KiB │ 201.4KiB │\n", + "└────────────┴────────────────┴──────────────────┘\n", + "\n", + "\u001b[32mModel optimizing complete!\u001b[0m\n", + "\n", + "\u001b[07mAutomatic generation of each OP name started\u001b[0m ========================================\n", + "\u001b[32mAutomatic generation of each OP name complete!\u001b[0m\n", + "\n", + "\u001b[07mModel loaded\u001b[0m ========================================================================\n", + "\n", + "\u001b[07mflatbuffer_direct fast path started\u001b[0m ===============================================\n", + "flatbuffer_direct lowering: 100% 25/25 [00:00<00:00, 7270.17it/s]\n", + "flatbuffer_direct post-lowering [6/6] topological sort: 100% 6/6 [00:00<00:00, 126.88it/s]\n", + "flatbuffer_direct write timing: stage=float32 mode=builder_direct total=0.018s serialize=0.006s (sanitize=0.000s build=0.001s pack=0.006s output=0.000s) write=0.009s size=0.20MB\n", + "flatbuffer_direct write timing: stage=float16 mode=builder_direct total=0.020s serialize=0.005s (sanitize=0.000s build=0.000s pack=0.005s output=0.000s) write=0.014s size=0.10MB\n", + "flatbuffer_direct export [4/3] write float16 tflite: 4it [00:00, 67.57it/s]\n", + "\u001b[32mFloat32 tflite output complete! (/content/drive/My Drive/Colab Notebooks/Wakeword/openwakeword/hey_marco/hey_marco_float32.tflite)\u001b[0m\n", + "\u001b[32mFloat16 tflite output complete! (/content/drive/My Drive/Colab Notebooks/Wakeword/openwakeword/hey_marco/hey_marco_float16.tflite)\u001b[0m\n", + "\u001b[32mTensor correspondence report output complete! (/content/drive/My Drive/Colab Notebooks/Wakeword/openwakeword/hey_marco/hey_marco_tensor_correspondence_report.json)\u001b[0m\n", + "TFLite : /content/drive/My Drive/Colab Notebooks/Wakeword/openwakeword/hey_marco/hey_marco.tflite\n" + ] + }, + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "application/javascript": [ + "\n", + " async function download(id, filename, size) {\n", + " if (!google.colab.kernel.accessAllowed) {\n", + " return;\n", + " }\n", + " const div = document.createElement('div');\n", + " const label = document.createElement('label');\n", + " label.textContent = `Downloading \"${filename}\": `;\n", + " div.appendChild(label);\n", + " const progress = document.createElement('progress');\n", + " progress.max = size;\n", + " div.appendChild(progress);\n", + " document.body.appendChild(div);\n", + "\n", + " const buffers = [];\n", + " let downloaded = 0;\n", + "\n", + " const channel = await google.colab.kernel.comms.open(id);\n", + " // Send a message to notify the kernel that we're ready.\n", + " channel.send({})\n", + "\n", + " for await (const message of channel.messages) {\n", + " // Send a message to notify the kernel that we're ready.\n", + " channel.send({})\n", + " if (message.buffers) {\n", + " for (const buffer of message.buffers) {\n", + " buffers.push(buffer);\n", + " downloaded += buffer.byteLength;\n", + " progress.value = downloaded;\n", + " }\n", + " }\n", + " }\n", + " const blob = new Blob(buffers, {type: 'application/binary'});\n", + " const a = document.createElement('a');\n", + " a.href = window.URL.createObjectURL(blob);\n", + " a.download = filename;\n", + " div.appendChild(a);\n", + " a.click();\n", + " div.remove();\n", + " }\n", + " " + ] + }, + "metadata": {} + }, + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "application/javascript": [ + "download(\"download_3db68d78-6807-4b91-a375-6120a8095472\", \"hey_marco.onnx\", 206276)" + ] + }, + "metadata": {} + }, + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "application/javascript": [ + "\n", + " async function download(id, filename, size) {\n", + " if (!google.colab.kernel.accessAllowed) {\n", + " return;\n", + " }\n", + " const div = document.createElement('div');\n", + " const label = document.createElement('label');\n", + " label.textContent = `Downloading \"${filename}\": `;\n", + " div.appendChild(label);\n", + " const progress = document.createElement('progress');\n", + " progress.max = size;\n", + " div.appendChild(progress);\n", + " document.body.appendChild(div);\n", + "\n", + " const buffers = [];\n", + " let downloaded = 0;\n", + "\n", + " const channel = await google.colab.kernel.comms.open(id);\n", + " // Send a message to notify the kernel that we're ready.\n", + " channel.send({})\n", + "\n", + " for await (const message of channel.messages) {\n", + " // Send a message to notify the kernel that we're ready.\n", + " channel.send({})\n", + " if (message.buffers) {\n", + " for (const buffer of message.buffers) {\n", + " buffers.push(buffer);\n", + " downloaded += buffer.byteLength;\n", + " progress.value = downloaded;\n", + " }\n", + " }\n", + " }\n", + " const blob = new Blob(buffers, {type: 'application/binary'});\n", + " const a = document.createElement('a');\n", + " a.href = window.URL.createObjectURL(blob);\n", + " a.download = filename;\n", + " div.appendChild(a);\n", + " a.click();\n", + " div.remove();\n", + " }\n", + " " + ] + }, + "metadata": {} + }, + { + "output_type": "display_data", + "data": { + "text/plain": [ + "" + ], + "application/javascript": [ + "download(\"download_bc5d9c4f-dc14-43d1-aacf-8c09ee4bde00\", \"hey_marco.tflite\", 206992)" + ] + }, + "metadata": {} + } + ], + "execution_count": 10 + } + ] +} \ No newline at end of file diff --git a/wakeword/hey_marco_v2.onnx b/wakeword/hey_marco_v2.onnx new file mode 100644 index 0000000..20528e4 Binary files /dev/null and b/wakeword/hey_marco_v2.onnx differ diff --git a/wakeword/hey_marco_v2.tflite b/wakeword/hey_marco_v2.tflite new file mode 100644 index 0000000..0502714 Binary files /dev/null and b/wakeword/hey_marco_v2.tflite differ diff --git a/wakeword/hey_vasco.onnx b/wakeword/hey_vasco.onnx new file mode 100644 index 0000000..65ea808 Binary files /dev/null and b/wakeword/hey_vasco.onnx differ diff --git a/wakeword/hey_vasco.tflite b/wakeword/hey_vasco.tflite new file mode 100644 index 0000000..8173f80 Binary files /dev/null and b/wakeword/hey_vasco.tflite differ diff --git a/wakeword/hey_visit.onnx b/wakeword/hey_visit.onnx new file mode 100644 index 0000000..d254732 Binary files /dev/null and b/wakeword/hey_visit.onnx differ diff --git a/wakeword/hey_visit.tflite b/wakeword/hey_visit.tflite new file mode 100644 index 0000000..547b0e6 Binary files /dev/null and b/wakeword/hey_visit.tflite differ diff --git a/wakeword/hey_viva.onnx b/wakeword/hey_viva.onnx new file mode 100644 index 0000000..08fd30d Binary files /dev/null and b/wakeword/hey_viva.onnx differ diff --git a/wakeword/hey_viva.tflite b/wakeword/hey_viva.tflite new file mode 100644 index 0000000..d36f49c Binary files /dev/null and b/wakeword/hey_viva.tflite differ diff --git a/wakeword/wakeword_training_template.ipynb b/wakeword/wakeword_training_template.ipynb new file mode 100644 index 0000000..e5caf45 --- /dev/null +++ b/wakeword/wakeword_training_template.ipynb @@ -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 + } + ] +} \ No newline at end of file