multi wakeword + misc

This commit is contained in:
Thomas Fransolet 2026-07-17 15:23:12 +02:00
parent c6526046c8
commit 0f2b4ed6bd
55 changed files with 6934 additions and 1916 deletions

View File

@ -61,13 +61,12 @@ class MainActivity : FlutterFragmentActivity() {
when (call.method) { when (call.method) {
"start" -> { "start" -> {
requestBatteryOptimizationExemption() requestBatteryOptimizationExemption()
val modelName = call.argument<String>("modelName") val modelNames = call.argument<String>("modelNames")
?: WakeWordService.DEFAULT_MODEL ?: WakeWordService.DEFAULT_MODEL
// activeInstance dans WakeWordService gère le cleanup de l'instance précédente
startService( startService(
Intent(this, WakeWordService::class.java) Intent(this, WakeWordService::class.java)
.setAction(WakeWordService.ACTION_START) .setAction(WakeWordService.ACTION_START)
.putExtra(WakeWordService.EXTRA_MODEL_NAME, modelName) .putExtra(WakeWordService.EXTRA_MODEL_NAMES, modelNames)
.putExtra(WakeWordService.EXTRA_CACHE_DIR, wakeWordCacheDir) .putExtra(WakeWordService.EXTRA_CACHE_DIR, wakeWordCacheDir)
) )
result.success(null) result.success(null)

View File

@ -50,9 +50,9 @@ class WakeWordService : Service() {
private var activeInstance: WakeWordService? = null private var activeInstance: WakeWordService? = null
const val EVENT_ACTION = "be.unov.mymuseum.WAKE_WORD_EVENT" const val EVENT_ACTION = "be.unov.mymuseum.WAKE_WORD_EVENT"
const val EXTRA_EVENT = "event" const val EXTRA_EVENT = "event"
const val EXTRA_MODEL_NAME = "modelName" const val EXTRA_MODEL_NAMES = "modelNames" // virgule-séparé : "hey_alba,hey_marco,hey_vasco"
const val DEFAULT_MODEL = "hey_visit" const val DEFAULT_MODEL = "hey_visit"
const val EXTRA_CACHE_DIR = "cacheDir" const val EXTRA_CACHE_DIR = "cacheDir"
private const val CHANNEL_ID = "wake_word_channel_v2" // v2 force recréation du canal private const val CHANNEL_ID = "wake_word_channel_v2" // v2 force recréation du canal
private const val NOTIFICATION_ID = 1338 private const val NOTIFICATION_ID = 1338
@ -65,7 +65,7 @@ class WakeWordService : Service() {
private const val MEL_STRIDE = 8 private const val MEL_STRIDE = 8
private const val EMBEDDING_DIM = 96 private const val EMBEDDING_DIM = 96
private const val N_EMBEDDING_FRAMES = 16 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 private const val COOLDOWN_MS = 2000L
} }
@ -75,12 +75,12 @@ class WakeWordService : Service() {
private var ortEnv: OrtEnvironment? = null private var ortEnv: OrtEnvironment? = null
private var melSession: OrtSession? = null private var melSession: OrtSession? = null
private var embeddingInterpreter: Interpreter? = null private var embeddingInterpreter: Interpreter? = null
private var classifierInterpreter: Interpreter? = null private val classifiers = mutableMapOf<String, Interpreter>() // modelName → interpréteur
private val lastDetectionTimes = mutableMapOf<String, Long>() // cooldown par modèle
private var isRunning = false private var isRunning = false
@Volatile private var requestedPause = false // demande depuis main thread @Volatile private var requestedPause = false
private var isPaused = false // état réel — géré uniquement par le thread capture private var isPaused = false
private var lastDetectionTime = 0L private var modelNames: List<String> = listOf(DEFAULT_MODEL)
private var modelName = DEFAULT_MODEL
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val melBuffer = ArrayDeque<FloatArray>() private val melBuffer = ArrayDeque<FloatArray>()
@ -104,7 +104,8 @@ class WakeWordService : Service() {
} }
} }
activeInstance = this 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) ?: "" modelCacheDir = intent.getStringExtra(EXTRA_CACHE_DIR) ?: ""
startDetection() startDetection()
} }
@ -175,7 +176,9 @@ class WakeWordService : Service() {
melSession?.close(); melSession = null melSession?.close(); melSession = null
ortEnv?.close(); ortEnv = null ortEnv?.close(); ortEnv = null
embeddingInterpreter?.close(); embeddingInterpreter = null embeddingInterpreter?.close(); embeddingInterpreter = null
classifierInterpreter?.close(); classifierInterpreter = null classifiers.values.forEach { it.close() }
classifiers.clear()
lastDetectionTimes.clear()
melBuffer.clear(); embeddingBuffer.clear() melBuffer.clear(); embeddingBuffer.clear()
audioManager = null audioManager = null
if (wakeLock?.isHeld == true) wakeLock?.release() if (wakeLock?.isHeld == true) wakeLock?.release()
@ -192,9 +195,11 @@ class WakeWordService : Service() {
// embedding + classifieur : TFLite (formes statiques, pas de problème) // embedding + classifieur : TFLite (formes statiques, pas de problème)
embeddingInterpreter = loadTflite("embedding_model.tflite") embeddingInterpreter = loadTflite("embedding_model.tflite")
classifierInterpreter = loadTflite("$modelName.tflite") classifiers.clear()
for (name in modelNames) {
Log.d(TAG, "Models loaded — mel=ONNX, embedding+classifier=TFLite, model=$modelName") classifiers[name] = loadTflite("$name.tflite")
}
Log.d(TAG, "Models loaded — mel=ONNX, embedding=TFLite, classifiers=${modelNames}")
} }
private fun loadTflite(assetName: String): Interpreter { private fun loadTflite(assetName: String): Interpreter {
@ -326,6 +331,8 @@ class WakeWordService : Service() {
// ── Main loop ────────────────────────────────────────────────────────────── // ── Main loop ──────────────────────────────────────────────────────────────
private var debugFrameCount = 0 private var debugFrameCount = 0
private val scoreHistory = mutableMapOf<String, FloatArray>() // 50 derniers scores par modèle
private val scoreHistoryPos = mutableMapOf<String, Int>()
private fun runLoop() { private fun runLoop() {
val pcm = ShortArray(CHUNK_SAMPLES) val pcm = ShortArray(CHUNK_SAMPLES)
@ -367,15 +374,34 @@ class WakeWordService : Service() {
} }
if (embeddingBuffer.size == N_EMBEDDING_FRAMES) { 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() val now = SystemClock.elapsedRealtime()
if (score >= DETECTION_THRESHOLD && (now - lastDetectionTime) > COOLDOWN_MS) { for ((name, interpreter) in classifiers) {
lastDetectionTime = now val score = runClassifier(interpreter)
Log.d(TAG, "Wake word detected! score=$score model=$modelName")
broadcast("detected") // 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 ──────────────────────────────────────────── // ── 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()) val inputBuf = ByteBuffer.allocateDirect(4 * N_EMBEDDING_FRAMES * EMBEDDING_DIM).order(ByteOrder.nativeOrder())
for (embedding in embeddingBuffer) for (v in embedding) inputBuf.putFloat(v) for (embedding in embeddingBuffer) for (v in embedding) inputBuf.putFloat(v)
inputBuf.rewind() inputBuf.rewind()
val output = Array(1) { FloatArray(1) } val output = Array(1) { FloatArray(1) }
classifierInterpreter!!.run(inputBuf, output) interpreter.run(inputBuf, output)
return output[0][0] return output[0][0]
} }

BIN
assets/files/hey_alba.onnx Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/files/hey_marco.onnx Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/files/hey_vasco.onnx Normal file

Binary file not shown.

Binary file not shown.

View File

@ -128,7 +128,7 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
MetaGlassesService.instance.isConnected && MetaGlassesService.instance.isConnected &&
response.reply.isNotEmpty) { response.reply.isNotEmpty) {
final lang = widget.visitAppContext.language ?? 'FR'; final lang = widget.visitAppContext.language ?? 'FR';
activeOrchestrator?.ttsEngine.speak( activeVoiceOrchestrator?.ttsEngine.speak(
response.reply, response.reply,
languageCode: _toLangCode(lang), languageCode: _toLangCode(lang),
); );

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meta_wearables_dat/meta_wearables_dat.dart'; import 'package:meta_wearables_dat/meta_wearables_dat.dart';
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart'; import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
@ -179,7 +180,7 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
label: 'Test TTS', label: 'Test TTS',
icon: Icons.volume_up, icon: Icons.volume_up,
onTap: () => _run('TTS test', () async { onTap: () => _run('TTS test', () async {
final o = activeOrchestrator; final o = activeVoiceOrchestrator;
if (o == null) { if (o == null) {
_addLog('⚠ Orchestrateur non initialisé'); _addLog('⚠ Orchestrateur non initialisé');
return; return;
@ -227,13 +228,13 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
), ),
const Divider(color: Colors.grey), const Divider(color: Colors.grey),
// Transcription en direct // Transcription en direct
if (activeOrchestrator != null) if (activeVoiceOrchestrator != null)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ValueListenableBuilder<bool>( child: ValueListenableBuilder<bool>(
valueListenable: activeOrchestrator!.isListeningForCommand, valueListenable: activeVoiceOrchestrator!.isListeningForCommand,
builder: (_, listening, __) => ValueListenableBuilder<String>( builder: (_, listening, __) => ValueListenableBuilder<String>(
valueListenable: activeOrchestrator!.lastTranscription, valueListenable: activeVoiceOrchestrator!.lastTranscription,
builder: (_, text, __) => Container( builder: (_, text, __) => Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
@ -282,11 +283,11 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
), ),
), ),
// Dernier texte TTS // Dernier texte TTS
if (activeOrchestrator != null) if (activeVoiceOrchestrator != null)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ValueListenableBuilder<String>( child: ValueListenableBuilder<String>(
valueListenable: activeOrchestrator!.lastTtsText, valueListenable: activeVoiceOrchestrator!.lastTtsText,
builder: (_, text, __) => text.isEmpty builder: (_, text, __) => text.isEmpty
? const SizedBox.shrink() ? const SizedBox.shrink()
: Container( : Container(
@ -316,6 +317,84 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
), ),
), ),
), ),
// Photos QR scan
if (activeVoiceOrchestrator != null)
ValueListenableBuilder<String?>(
valueListenable: activeVoiceOrchestrator!.lastQrScanPhoto,
builder: (_, path, __) {
final exists = path != null && File(path).existsSync();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.qr_code_scanner, color: Colors.orange, size: 14),
const SizedBox(width: 6),
const Text('Dernier scan QR',
style: TextStyle(color: Colors.orange, fontSize: 11)),
]),
const SizedBox(height: 4),
exists
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(File(path!), height: 120, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Text('Image non disponible',
style: TextStyle(color: Colors.grey, fontSize: 11))),
)
: const Text('Aucun scan effectué',
style: TextStyle(color: Colors.grey, fontSize: 11)),
],
),
);
},
),
// Photos visite
if (activeVoiceOrchestrator != null)
ValueListenableBuilder<List<String>>(
valueListenable: activeVoiceOrchestrator!.visitPhotosNotifier,
builder: (_, photos, __) {
if (photos.isEmpty) return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(children: [
const Icon(Icons.photo_library, color: Colors.green, size: 14),
const SizedBox(width: 6),
const Text('Photos visite — aucune photo',
style: TextStyle(color: Colors.grey, fontSize: 11)),
]),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.photo_library, color: Colors.green, size: 14),
const SizedBox(width: 6),
Text('Photos visite (${photos.length})',
style: const TextStyle(color: Colors.green, fontSize: 11)),
]),
const SizedBox(height: 4),
SizedBox(
height: 80,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: photos.length,
separatorBuilder: (_, __) => const SizedBox(width: 4),
itemBuilder: (_, i) => ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(File(photos[i]), height: 80, width: 80,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 80, height: 80, color: Colors.grey[800])),
),
),
),
],
),
);
},
),
const Divider(color: Colors.grey), const Divider(color: Colors.grey),
// Log // Log
Expanded( Expanded(

View File

@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/Services/voice_controller.dart';
import 'package:provider/provider.dart';
/// Icône circulaire positionnée en haut à droite de l'écran d'accueil.
///
/// Visible si :
/// - Les lunettes sont connectées (quel que soit le mode)
/// - OU un mode vocal est actif (voiceOnly ou glasses)
///
/// Tap ouvre VoiceModeSheet.
class GlassesStatusWidget extends StatelessWidget {
final VisitAppContext visitAppContext;
const GlassesStatusWidget({super.key, required this.visitAppContext});
@override
Widget build(BuildContext context) {
return Consumer<VoiceController>(
builder: (context, controller, _) {
return ValueListenableBuilder<GlassesState>(
valueListenable: MetaGlassesService.instance.state,
builder: (context, glassesState, _) {
final isConnected = glassesState == GlassesState.connected ||
glassesState == GlassesState.streaming;
final modeActive = controller.mode != VoiceMode.none;
final featureAvailable = controller.isFeatureAvailable(visitAppContext);
if (!featureAvailable && !isConnected && !modeActive) return const SizedBox.shrink();
final Color iconColor = switch ((isConnected, controller.mode)) {
(true, VoiceMode.glasses) => Colors.greenAccent,
(_, VoiceMode.voiceOnly) => Colors.lightBlueAccent,
(true, _) => Colors.white70,
_ => Colors.white38,
};
return GestureDetector(
onTap: () => VoiceModeSheet.show(context, visitAppContext: visitAppContext),
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: Stack(
alignment: Alignment.center,
children: [
Icon(
controller.mode == VoiceMode.voiceOnly
? Icons.mic
: Icons.remove_red_eye_outlined,
color: iconColor,
size: 22,
),
// Point vert en bas à droite si mode actif
if (modeActive)
Positioned(
bottom: 8,
right: 8,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: controller.mode == VoiceMode.glasses
? Colors.greenAccent
: Colors.lightBlueAccent,
shape: BoxShape.circle,
border: Border.all(color: Colors.black54, width: 1),
),
),
),
],
),
),
);
},
);
},
);
}
}

View File

@ -67,11 +67,19 @@ class _SliderImagesWidget extends State<SliderImagesWidget> {
enlargeCenterPage: true, enlargeCenterPage: true,
reverse: false, reverse: false,
), ),
items: resourcesInWidget.map<Widget>((i) { items: resourcesInWidget.asMap().entries.map<Widget>((entry) {
int idx = entry.key;
ResourceModel? i = entry.value;
return Builder( return Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
AppContext appContext = Provider.of<AppContext>(context); AppContext appContext = Provider.of<AppContext>(context);
ContentDTO contentDTO = ContentDTO(resourceId: i!.id, resource: ResourceDTO(id: i.id, type: i.type, label: i.label, url: i.source)); ContentDTO? originalContentDTO = idx < widget.contentsDTO.length ? widget.contentsDTO[idx] : null;
ContentDTO contentDTO = ContentDTO(
resourceId: i!.id,
title: originalContentDTO?.title,
description: originalContentDTO?.description,
resource: ResourceDTO(id: i.id, type: i.type, label: i.label, url: i.source),
);
var resourcetoShow = getElementForResource(context, appContext, contentDTO, true); var resourcetoShow = getElementForResource(context, appContext, contentDTO, true);
return resourcetoShow; return resourcetoShow;
//print(widget.imagesDTO[currentIndex-1]); //print(widget.imagesDTO[currentIndex-1]);

View File

@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/Services/voice_controller.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart';
/// Bottom sheet de sélection et gestion du mode vocal.
///
/// Accessible depuis GlassesStatusWidget (icône top-right) ou depuis
/// les settings. Affiche :
/// - État de connexion des lunettes (avec bouton connect/disconnect)
/// - Tuiles de sélection de mode (vocal seul / lunettes)
/// - Bouton de désactivation si un mode est actif
class VoiceModeSheet extends StatelessWidget {
final VisitAppContext visitAppContext;
const VoiceModeSheet({super.key, required this.visitAppContext});
static Future<void> show(BuildContext context, {required VisitAppContext visitAppContext}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => VoiceModeSheet(visitAppContext: visitAppContext),
);
}
@override
Widget build(BuildContext context) {
return Consumer<VoiceController>(
builder: (context, controller, _) {
return Container(
decoration: const BoxDecoration(
color: Color(0xFF1E1E2E),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Handle
Center(
child: Container(
width: 40, height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 20),
// Titre
const Text(
'Assistant vocal',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Section lunettes toujours visible
_GlassesConnectionTile(),
const SizedBox(height: 20),
// Modes uniquement si isAssistant
if (controller.isFeatureAvailable(visitAppContext)) ...[
const Text(
'Mode',
style: TextStyle(color: Colors.white54, fontSize: 12, letterSpacing: 1),
),
const SizedBox(height: 8),
_ModeTile(
icon: Icons.mic,
label: 'Mode vocal',
subtitle: 'Micro du téléphone',
targetMode: VoiceMode.voiceOnly,
controller: controller,
visitAppContext: visitAppContext,
enabled: true,
),
const SizedBox(height: 8),
ValueListenableBuilder<GlassesState>(
valueListenable: MetaGlassesService.instance.state,
builder: (context, glassesState, _) {
final connected = glassesState == GlassesState.connected ||
glassesState == GlassesState.streaming;
return _ModeTile(
icon: Icons.remove_red_eye_outlined,
label: 'Mode lunettes',
subtitle: connected ? 'Ray-Ban Meta connectées' : 'Lunettes non connectées',
targetMode: VoiceMode.glasses,
controller: controller,
visitAppContext: visitAppContext,
enabled: connected,
);
},
),
const SizedBox(height: 16),
// Bouton désactiver
if (controller.mode != VoiceMode.none)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: controller.isActivating ? null : () => controller.deactivate(),
icon: const Icon(Icons.stop_circle_outlined, color: Colors.redAccent),
label: const Text('Désactiver', style: TextStyle(color: Colors.redAccent)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.redAccent),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
] else
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(
"L'assistant vocal n'est pas activé pour cette instance.",
style: TextStyle(color: Colors.white54, fontSize: 13),
),
),
],
),
);
},
);
}
}
class _GlassesConnectionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<GlassesState>(
valueListenable: MetaGlassesService.instance.state,
builder: (context, state, _) {
final label = switch (state) {
GlassesState.connected => 'Connectées',
GlassesState.streaming => 'Connectées (streaming)',
GlassesState.connecting => 'Connexion...',
GlassesState.disconnected => 'Non connectées',
};
final color = (state == GlassesState.connected || state == GlassesState.streaming)
? Colors.greenAccent
: state == GlassesState.connecting
? Colors.orangeAccent
: Colors.white38;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.remove_red_eye_outlined, color: color, size: 22),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Ray-Ban Meta', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
Text(label, style: TextStyle(color: color, fontSize: 12)),
],
),
),
if (state == GlassesState.disconnected)
TextButton(
onPressed: () => MetaGlassesService.instance.connect(),
child: const Text('Connecter', style: TextStyle(color: kMainColor1)),
)
else if (state == GlassesState.connected || state == GlassesState.streaming)
TextButton(
onPressed: () => MetaGlassesService.instance.disconnect(),
child: const Text('Déconnecter', style: TextStyle(color: Colors.white54)),
)
else
const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
],
),
);
},
);
}
}
class _ModeTile extends StatelessWidget {
final IconData icon;
final String label;
final String subtitle;
final VoiceMode targetMode;
final VoiceController controller;
final VisitAppContext visitAppContext;
final bool enabled;
const _ModeTile({
required this.icon,
required this.label,
required this.subtitle,
required this.targetMode,
required this.controller,
required this.visitAppContext,
required this.enabled,
});
@override
Widget build(BuildContext context) {
final isActive = controller.mode == targetMode;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: isActive ? kMainColor1.withValues(alpha: 0.2) : Colors.white10,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isActive ? kMainColor1 : Colors.transparent,
width: 1.5,
),
),
child: ListTile(
leading: Icon(icon, color: isActive ? kMainColor1 : enabled ? Colors.white70 : Colors.white30),
title: Text(
label,
style: TextStyle(
color: enabled ? Colors.white : Colors.white38,
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Text(subtitle, style: TextStyle(color: enabled ? Colors.white54 : Colors.white24, fontSize: 12)),
trailing: isActive
? const Icon(Icons.check_circle, color: kMainColor1)
: enabled
? const Icon(Icons.chevron_right, color: Colors.white38)
: const Icon(Icons.lock_outline, color: Colors.white24, size: 18),
onTap: (enabled && !controller.isActivating && !isActive)
? () => controller.activate(targetMode, visitAppContext)
: null,
),
);
}
}

View File

@ -19,7 +19,7 @@ enum DatabaseTableType {
class DatabaseHelper { class DatabaseHelper {
static const _databaseName = "visit_database.db"; static const _databaseName = "visit_database.db";
static const _databaseVersion = 1; static const _databaseVersion = 3;
static const mainTable = 'visitAppContext'; static const mainTable = 'visitAppContext';
static const columnLanguage = 'language'; static const columnLanguage = 'language';
@ -38,6 +38,9 @@ class DatabaseHelper {
static const columnLanguages = 'languages'; static const columnLanguages = 'languages';
static const columnPrimaryColor = 'primaryColor'; static const columnPrimaryColor = 'primaryColor';
static const columnSecondaryColor = 'secondaryColor'; static const columnSecondaryColor = 'secondaryColor';
static const columnConfigOrder = 'configOrder';
static const columnGridColSpan = 'gridColSpan';
static const columnGridRowSpan = 'gridRowSpan';
static const sectionsTable = 'sections'; static const sectionsTable = 'sections';
static const columnTitle = 'title'; static const columnTitle = 'title';
@ -78,7 +81,21 @@ class DatabaseHelper {
Future<Database> _initDatabase() async { Future<Database> _initDatabase() async {
String path = join(await getDatabasesPath(), _databaseName); String path = join(await getDatabasesPath(), _databaseName);
return await openDatabase(path, 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 // SQL code to create the database table
@ -178,6 +195,9 @@ class DatabaseHelper {
$columnDateCreation TEXT NOT NULL, $columnDateCreation TEXT NOT NULL,
$columnPrimaryColor TEXT, $columnPrimaryColor TEXT,
$columnSecondaryColor TEXT, $columnSecondaryColor TEXT,
$columnConfigOrder INT,
$columnGridColSpan INT,
$columnGridRowSpan INT,
$columnIsOffline BOOLEAN NOT NULL CHECK ($columnIsOffline IN (0,1)) $columnIsOffline BOOLEAN NOT NULL CHECK ($columnIsOffline IN (0,1))
) )
'''); ''');

View File

@ -38,11 +38,13 @@ class AssistantResponse {
final String reply; final String reply;
final List<AiCard>? cards; final List<AiCard>? cards;
final AssistantNavigationAction? navigation; final AssistantNavigationAction? navigation;
final bool expectsReply;
const AssistantResponse({ const AssistantResponse({
required this.reply, required this.reply,
this.cards, this.cards,
this.navigation, this.navigation,
this.expectsReply = true,
}); });
factory AssistantResponse.fromJson(Map<String, dynamic> json) => factory AssistantResponse.fromJson(Map<String, dynamic> json) =>
@ -55,5 +57,6 @@ class AssistantResponse {
? AssistantNavigationAction.fromJson( ? AssistantNavigationAction.fromJson(
json['navigation'] as Map<String, dynamic>) json['navigation'] as Map<String, dynamic>)
: null, : null,
expectsReply: json['expectsReply'] as bool? ?? true,
); );
} }

View File

@ -3,11 +3,13 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart'; import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/services.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:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:manager_api_new/api.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/AssistantChatSheet.dart';
import 'package:mymuseum_visitapp/Components/CustomAppBar.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/AdminPopup.dart';
import 'package:mymuseum_visitapp/Components/ScannerBouton.dart'; import 'package:mymuseum_visitapp/Components/ScannerBouton.dart';
import 'package:mymuseum_visitapp/Services/pushNotificationService.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:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.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 { class HomePage3 extends StatefulWidget {
const HomePage3({Key? key}) : super(key: key); const HomePage3({Key? key}) : super(key: key);
@ -40,10 +60,11 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
int currentIndex = 0; int currentIndex = 0;
late List<ConfigurationDTO> configurations = []; late List<ConfigurationDTO> configurations = [];
List<_WeightedConfig> weightedConfigs = [];
List<String?> alreadyDownloaded = []; List<String?> alreadyDownloaded = [];
late VisitAppContext visitAppContext; late VisitAppContext visitAppContext;
late Future<List<ConfigurationDTO>?> _futureConfigurations; late Future<List<_WeightedConfig>?> _futureConfigurations;
@override @override
void initState() { void initState() {
@ -53,15 +74,19 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
_futureConfigurations = getConfigurationsCall(appContext); _futureConfigurations = getConfigurationsCall(appContext);
} }
Widget _buildCard(BuildContext context, int index) { Widget _buildCard(BuildContext context, ConfigurationDTO config) {
final lang = visitAppContext.language ?? "FR"; final lang = visitAppContext.language ?? "FR";
final config = configurations[index];
final titleEntry = config.title?.firstWhere( final titleEntry = config.title?.firstWhere(
(t) => t.language == lang, (t) => t.language == lang,
orElse: () => config.title!.first, orElse: () => config.title!.first,
); );
final cleanedTitle = (titleEntry?.value ?? '').replaceAll('\n', ' ').replaceAll('<br>', ' '); final cleanedTitle = (titleEntry?.value ?? '').replaceAll('\n', ' ').replaceAll('<br>', ' ');
final fallbackBaseColor = _parseStoredColor(config.primaryColor) ??
_parseStoredColor(visitAppContext.applicationInstanceDTO?.primaryColor) ??
kMainColor1;
final fallbackColor = Color.lerp(fallbackBaseColor, Colors.white, 0.35)!;
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
onTap: () { onTap: () {
@ -80,7 +105,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: kSecondGrey, color: fallbackColor,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
@ -115,23 +140,29 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
), ),
), ),
), ),
// Title at bottom-left // Title at bottom-left hauteur bornée + clip : empêche le débordement
// même si le moteur HTML natif ignore -webkit-line-clamp (contenu Quill).
Positioned( Positioned(
bottom: 10, bottom: 10,
left: 10, left: 10,
right: 28, right: 28,
child: HtmlWidget( child: ClipRect(
cleanedTitle, child: SizedBox(
textStyle: const TextStyle( height: 40,
color: Colors.white, child: HtmlWidget(
fontFamily: 'Roboto', cleanedTitle,
fontSize: 14, textStyle: const TextStyle(
fontWeight: FontWeight.w600, 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 // Chevron
@ -178,6 +209,21 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
), ),
), ),
const SizedBox(height: 20), 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 Text('Langue', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( Wrap(
@ -256,13 +302,10 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
if (snapshot.connectionState == ConnectionState.done) { if (snapshot.connectionState == ConnectionState.done) {
final mobileConfigIds = visitAppContext.applicationInstanceDTO final mobileConfigIds = visitAppContext.applicationInstanceDTO
?.configurations?.map((c) => c.configurationId).toSet() ?? {}; ?.configurations?.map((c) => c.configurationId).toSet() ?? {};
configurations = List<ConfigurationDTO>.from(snapshot.data) weightedConfigs = List<_WeightedConfig>.from(snapshot.data)
.where((c) => mobileConfigIds.isEmpty || mobileConfigIds.contains(c.id)) .where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id))
.toList(); .toList();
configurations = weightedConfigs.map((w) => w.configuration).toList();
final layoutType = visitAppContext.applicationInstanceDTO?.layoutMainPage;
final isMasonry = layoutType == null ||
layoutType.value == LayoutMainPageType.MasonryGrid.value;
final lang = visitAppContext.language ?? "FR"; final lang = visitAppContext.language ?? "FR";
final headerTitleEntry = configurations.isNotEmpty final headerTitleEntry = configurations.isNotEmpty
@ -415,6 +458,12 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
); );
}), }),
), ),
// Icône lunettes / mode vocal à gauche du bouton settings
Positioned(
top: 35,
right: 68,
child: GlassesStatusWidget(visitAppContext: visitAppContext),
),
Positioned( Positioned(
top: 35, top: 35,
right: 10, right: 10,
@ -477,30 +526,50 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
SliverPadding( SliverPadding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 8.0, right: 8.0, top: 8.0, bottom: 20.0), left: 8.0, right: 8.0, top: 8.0, bottom: 20.0),
sliver: isMasonry sliver: SliverToBoxAdapter(
? SliverMasonryGrid.count( child: Builder(builder: (context) {
crossAxisCount: 2, const columns = 2; // grille mobile
mainAxisSpacing: 12, const gap = 12.0;
crossAxisSpacing: 12, final gridWidth = size.width - 16.0;
childCount: configurations.length, final cellWidth = (gridWidth - (columns - 1) * gap) / columns;
itemBuilder: (context, index) => SizedBox( final cellHeight = cellWidth * 0.9;
height: 160 + (index % 3) * 50,
child: _buildCard(context, index), final result = weightedConfigs.isNotEmpty
), ? bentoLayout(
) weightedConfigs
: SliverGrid( .map((w) => BentoItem(
gridDelegate: id: w.configuration.id!,
const SliverGridDelegateWithFixedCrossAxisCount( colSpan: w.colSpan.clamp(1, columns),
crossAxisCount: 2, rowSpan: w.rowSpan.clamp(1, 2),
crossAxisSpacing: 12, ))
mainAxisSpacing: 12, .toList(),
childAspectRatio: 0.82, columns,
), )
delegate: SliverChildBuilderDelegate( : const BentoResult(placements: [], rowCount: 0);
_buildCard, final totalHeight = result.rowCount <= 0
childCount: configurations.length, ? 0.0
), : result.rowCount * cellHeight + (result.rowCount - 1) * gap;
final configById = {
for (final w in weightedConfigs) w.configuration.id!: w.configuration,
};
return SizedBox(
height: totalHeight,
child: Stack(
children: result.placements.map((p) {
final config = configById[p.id]!;
return Positioned(
left: p.col * (cellWidth + gap),
top: p.row * (cellHeight + gap),
width: p.colSpan * cellWidth + (p.colSpan - 1) * gap,
height: p.rowSpan * cellHeight + (p.rowSpan - 1) * gap,
child: _buildCard(context, config),
);
}).toList(),
), ),
);
}),
),
), ),
], ],
), ),
@ -555,7 +624,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
); );
} }
Future<List<ConfigurationDTO>?> getConfigurationsCall(AppContext appContext) async { Future<List<_WeightedConfig>?> getConfigurationsCall(AppContext appContext) async {
bool isOnline = await hasNetwork(); bool isOnline = await hasNetwork();
VisitAppContext visitAppContext = appContext.getContext(); VisitAppContext visitAppContext = appContext.getContext();
@ -588,7 +657,21 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
visitAppContext.beaconSections = beaconSections; visitAppContext.beaconSections = beaconSections;
//appContext.setContext(visitAppContext); //appContext.setContext(visitAppContext);
return configurations; // Récupère order/spans en brut (pas exposés sur ConfigurationDTO, qui
// vivent sur le lien sectioninstance) 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) { if(visitAppContext.beaconSections == null) {
@ -646,12 +729,34 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
final links = await visitAppContext.clientAPI.applicationInstanceApi! final links = await visitAppContext.clientAPI.applicationInstanceApi!
.applicationInstanceGetAllApplicationLinkFromApplicationInstance( .applicationInstanceGetAllApplicationLinkFromApplicationInstance(
visitAppContext.applicationInstanceDTO!.id!); visitAppContext.applicationInstanceDTO!.id!);
final configs = links final weightedConfigs = links
?.where((l) => l.configuration != null) ?.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() ?? .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) { } catch (e) {
print("Could not load configurations from app instance links: $e"); print("Could not load configurations from app instance links: $e");
} }

View File

@ -83,7 +83,6 @@ class _EventPageState extends State<EventPage> {
widget.section.baseSectionMapId != null || widget.section.baseSectionMapId != null ||
widget.section.latitude != null || widget.section.latitude != null ||
_paths.isNotEmpty; _paths.isNotEmpty;
final hasDescription = widget.section.description?.isNotEmpty ?? false;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF111111), backgroundColor: const Color(0xFF111111),
@ -96,7 +95,6 @@ class _EventPageState extends State<EventPage> {
children: [ children: [
if (hasProgramme) _buildProgrammeSection(), if (hasProgramme) _buildProgrammeSection(),
if (hasMapSection) _buildMapParcoursSection(context, annotations), if (hasMapSection) _buildMapParcoursSection(context, annotations),
if (hasDescription) _buildDescriptionSection(),
const SizedBox(height: 48), const SizedBox(height: 48),
], ],
), ),
@ -744,34 +742,6 @@ class _EventPageState extends State<EventPage> {
); );
} }
// Description
Widget _buildDescriptionSection() {
final desc = _safeTranslate(widget.section.description);
if (desc.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 28, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.info_outline, color: kMainColor, size: 20),
const SizedBox(width: 8),
const Text(
'À propos',
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 12),
Text(desc, style: TextStyle(color: Colors.grey[300], fontSize: 14, height: 1.6)),
],
),
);
}
// Helpers // Helpers
Widget _sectionHeader(IconData icon, String label) => Padding( Widget _sectionHeader(IconData icon, String label) => Padding(

View File

@ -9,7 +9,6 @@ import 'package:mymuseum_visitapp/Components/loading_common.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Game/message_dialog.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/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart'; import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -277,55 +276,6 @@ class _GamePage extends State<GamePage> {
} }
Widget _buildContent(VisitAppContext visitAppContext) { 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 (gameDTO.gameType == GameTypes.SlidingPuzzle) {
if (slidingTileIndices.isEmpty) return Center(child: LoadingCommon()); if (slidingTileIndices.isEmpty) return Center(child: LoadingCommon());

View File

@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
/// Lecteur audio inline (play/pause, waveform cliquable, durée), équivalent du
/// lecteur custom de visitapp-web (`ParcoursSection` / `ProgressView`).
class GuidedPathAudioPlayer extends StatefulWidget {
final String url;
final Color accentColor;
final bool isGame;
const GuidedPathAudioPlayer({
Key? key,
required this.url,
required this.accentColor,
required this.isGame,
}) : super(key: key);
@override
State<GuidedPathAudioPlayer> createState() => _GuidedPathAudioPlayerState();
}
class _GuidedPathAudioPlayerState extends State<GuidedPathAudioPlayer> {
final AudioPlayer _player = AudioPlayer();
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
bool _playing = false;
static const _bars = [4, 8, 13, 10, 16, 11, 7, 14, 9, 5, 12, 8, 10, 15, 7, 11, 13, 6, 14, 9, 5, 12, 10, 7, 13];
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
await _player.setUrl(widget.url);
} catch (_) {}
_player.durationStream.listen((d) {
if (d != null && mounted) setState(() => _duration = d);
});
_player.positionStream.listen((p) {
if (mounted) setState(() => _position = p);
});
_player.playerStateStream.listen((s) {
if (!mounted) return;
if (s.processingState == ProcessingState.completed) {
_player.pause();
_player.seek(Duration.zero);
setState(() => _playing = false);
} else {
setState(() => _playing = s.playing);
}
});
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
void _toggle() {
if (_playing) {
_player.pause();
} else {
_player.play();
}
}
void _seekFraction(double f) {
if (_duration.inMilliseconds > 0) {
_player.seek(Duration(milliseconds: (f * _duration.inMilliseconds).round()));
}
}
String _fmt(Duration d) {
final m = d.inMinutes;
final s = d.inSeconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final isGame = widget.isGame;
final progress = _duration.inMilliseconds > 0
? _position.inMilliseconds / _duration.inMilliseconds
: 0.0;
final muted = isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86);
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: isGame ? Colors.white.withOpacity(0.06) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isGame
? const Color(0xFFD4AF37).withOpacity(0.2)
: const Color(0xFFE8E3DA)),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
GestureDetector(
onTap: _toggle,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isGame ? const Color(0xFFD4AF37) : const Color(0xFF1E2A33),
),
child: Icon(
_playing ? Icons.pause : Icons.play_arrow,
color: isGame ? const Color(0xFF1a1305) : Colors.white,
size: 22,
),
),
),
const SizedBox(width: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onTapDown: (d) =>
_seekFraction((d.localPosition.dx / constraints.maxWidth).clamp(0.0, 1.0)),
child: SizedBox(
height: 20,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (int i = 0; i < _bars.length; i++)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Container(
height: _bars[i].toDouble(),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(1.5),
color: (i / _bars.length) <= progress
? widget.accentColor
: const Color(0x33646E78),
),
),
),
),
],
),
),
);
},
),
),
const SizedBox(width: 12),
Text(
_duration.inMilliseconds > 0 ? _fmt(_duration) : '--:--',
style: TextStyle(color: muted, fontSize: 12.5, fontWeight: FontWeight.w600),
),
],
),
),
if (_duration.inMilliseconds > 0)
ClipRRect(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
child: LinearProgressIndicator(
value: progress.clamp(0.0, 1.0),
minHeight: 3,
backgroundColor:
isGame ? Colors.white.withOpacity(0.06) : const Color(0xFFF0EBE3),
valueColor: AlwaysStoppedAnimation<Color>(widget.accentColor),
),
),
],
),
);
}
}

View File

@ -1,17 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:latlong2/latlong.dart';
import 'package:manager_api_new/api.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/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/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/Services/pushNotificationService.dart'; import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_end_view.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.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). /// Vue de progression centrée contenu (escape game, ou parcours sans carte).
/// Miroir de `ProgressView` (non-carte) de visitapp-web.
class GuidedPathContentProgressionPage extends StatefulWidget { class GuidedPathContentProgressionPage extends StatefulWidget {
final GuidedPathDTO path; final GuidedPathDTO path;
final VisitAppContext visitAppContext; final VisitAppContext visitAppContext;
@ -30,15 +31,20 @@ class GuidedPathContentProgressionPage extends StatefulWidget {
class _GuidedPathContentProgressionPageState class _GuidedPathContentProgressionPageState
extends State<GuidedPathContentProgressionPage> { extends State<GuidedPathContentProgressionPage> {
late List<GuidedStepDTO> _steps; late List<GuidedStepDTO> _steps;
int _currentStepIndex = 0; int _index = 0;
final Set<String> _completedStepIds = {}; final Set<String> _completedStepIds = {};
List<QuestionSubDTO> _stepQuestions = []; GuidedStepChallengeController? _challenge;
bool _quizCompleted = false; bool _expiredShown = false;
bool _quizPassed = false;
bool _inGeoZone = false; static const _gold = Color(0xFFD4AF37);
StreamSubscription<Position>? _positionSub; 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 @override
void initState() { void initState() {
@ -46,406 +52,402 @@ class _GuidedPathContentProgressionPageState
_steps = [...(widget.path.steps ?? [])] _steps = [...(widget.path.steps ?? [])]
..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); ..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
_initStepState(); _initStepState();
_startLocationTracking();
} }
@override @override
void dispose() { void dispose() {
_positionSub?.cancel(); _challenge?.removeListener(_onChallengeChanged);
_challenge?.dispose();
super.dispose(); super.dispose();
} }
// State
void _initStepState() { void _initStepState() {
_challenge?.removeListener(_onChallengeChanged);
_challenge?.dispose();
_expiredShown = false;
final step = _currentStep; final step = _currentStep;
if (step == null) return; if (step == null) return;
_quizCompleted = false;
_quizPassed = false;
_inGeoZone = false;
if (step.quizQuestions?.isNotEmpty == true) { final questions = GuidedStepChallengeController.buildQuestions(step);
_stepQuestions = step.quizQuestions! final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0;
.map((q) => QuestionSubDTO(
chosen: null, _challenge = GuidedStepChallengeController(
label: q.label, questions: questions,
responsesSubDTO: ResponseSubDTO().fromJSON(q.responses), isGame: _isGame,
resourceId: q.resourceId, requireSuccess: widget.path.requireSuccessToAdvance ?? false,
resourceUrl: q.resource?.url, hasTimer: hasTimer,
order: q.order, timerSeconds: step.timerSeconds ?? 0,
)) visitAppContext: widget.visitAppContext,
.toList(); )..addListener(_onChallengeChanged);
} else {
_stepQuestions = [];
}
} }
GuidedStepDTO? get _currentStep => void _onChallengeChanged() {
_steps.isEmpty ? null : _steps[_currentStepIndex]; if (!mounted) return;
setState(() {});
final c = _challenge;
final step = _currentStep;
if (c != null && c.expired && !_expiredShown) {
final msg = TranslationHelper.get(step?.timerExpiredMessage, widget.visitAppContext);
if (msg.isNotEmpty) {
_expiredShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _showExpiredModal(msg));
}
}
}
String _translate(List<TranslationDTO>? list) => String _translate(List<TranslationDTO>? list) =>
TranslationHelper.get(list, widget.visitAppContext); TranslationHelper.get(list, widget.visitAppContext);
bool _hasGeoTrigger(GuidedStepDTO step) => bool get _canAdvance => _challenge?.canAdvance ?? true;
(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;
}
void _advance() { void _advance() {
final step = _currentStep; final step = _currentStep;
if (step == null || !_canAdvance) return; if (step == null || !_canAdvance) return;
if (step.id != null) _completedStepIds.add(step.id!); if (step.id != null) _completedStepIds.add(step.id!);
if (_currentStepIndex < _steps.length - 1) { if (_index < _steps.length - 1) {
setState(() { setState(() {
_currentStepIndex++; _index++;
_initStepState(); _initStepState();
}); });
} else { } else {
_showCompletionDialog(); _showEnd();
} }
} }
void _goBack() { void _goBack() {
if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false)) { if (_index > 0 && !(widget.path.isLinear ?? false)) {
setState(() { setState(() {
_currentStepIndex--; _index--;
_initStepState(); _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( showDialog(
context: context, context: context,
builder: (_) => AlertDialog( builder: (_) => Dialog(
title: const Text('Parcours terminé !'), backgroundColor: _isGame ? const Color(0xFF0B1018) : Colors.white,
content: const Text('Vous avez complété toutes les étapes.'), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
actions: [ child: Padding(
TextButton( padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
onPressed: () { child: Column(
Navigator.of(context).pop(); mainAxisSize: MainAxisSize.min,
Navigator.of(context).pop(); children: [
}, Container(
child: const Text('Fermer'), 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('&nbsp;', ' ')
.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 Widget _buildHeader(GuidedStepDTO step) {
return Container(
Future<void> _startLocationTracking() async { padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); color: _isGame ? const Color(0xFF0B1018) : const Color(0xFFF6F3EE),
if (!serviceEnabled) return; child: Row(
LocationPermission permission = await Geolocator.checkPermission(); children: [
if (permission == LocationPermission.denied) { GestureDetector(
permission = await Geolocator.requestPermission(); onTap: () => Navigator.of(context).pop(),
if (permission == LocationPermission.denied) return; child: Container(
} width: 40,
if (permission == LocationPermission.deniedForever) return; height: 40,
decoration: BoxDecoration(
_positionSub = Geolocator.getPositionStream( borderRadius: BorderRadius.circular(12),
locationSettings: color: _isGame ? Colors.white.withOpacity(0.08) : Colors.white,
const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5), ),
).listen((pos) => _checkGeoZone(LatLng(pos.latitude, pos.longitude))); 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) { Widget _buildContent(GuidedStepDTO step) {
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')));
final title = _translate(step.title); final title = _translate(step.title);
final desc = _translate(step.description); final desc = _translate(step.description);
final hasQuiz = step.quizQuestions?.isNotEmpty == true; final hasContents = step.contents?.isNotEmpty == true;
final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0;
final hasGeo = _hasGeoTrigger(step);
final isLocked = step.isStepLocked == true;
return Scaffold( final audioUrl = () {
body: SafeArea( final list = step.audioIds ?? [];
child: Column( if (list.isEmpty) return null;
children: [ final match = list.firstWhere(
// Header (a) => a.language == widget.visitAppContext.language,
Padding( orElse: () => list.first,
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), );
child: Row( final v = match.value;
children: [ return (v != null && v.startsWith('http')) ? v : null;
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),
),
],
),
),
// Dot progress final contentResources = hasContents
Padding( ? step.contents!
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), .map((c) => ResourceModel(
child: Row( id: c.resourceId, source: c.resource?.url, type: ResourceType.Image))
mainAxisAlignment: MainAxisAlignment.center, .toList()
children: List.generate(_steps.length, (i) { : <ResourceModel>[];
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],
),
),
);
}),
),
),
// Contenu scrollable return SingleChildScrollView(
Expanded( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: SingleChildScrollView( child: Column(
padding: const EdgeInsets.all(16), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, if (hasContents)
children: [ ClipRRect(
// Image borderRadius: BorderRadius.circular(16),
if (step.imageUrl != null) child: SizedBox(
ClipRRect( height: 200,
borderRadius: BorderRadius.circular(12), child: SliderImagesWidget(
child: Image.network( resources: contentResources,
step.imageUrl!, height: 200,
height: 200, contentsDTO: step.contents!,
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'),
),
],
],
),
],
), ),
), ),
)
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 Widget _buildBottomBar(bool hasQuiz) {
Padding( return Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: EdgeInsets.fromLTRB(18, 12, 18, 18 + MediaQuery.of(context).padding.bottom),
child: Row( color: _bg,
children: [ child: Row(
if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false)) children: [
OutlinedButton.icon( if (_index > 0 && !(widget.path.isLinear ?? false)) ...[
onPressed: _goBack, GestureDetector(
icon: const Icon(Icons.arrow_back, size: 16), onTap: _goBack,
label: const Text('Précédent'), child: Container(
), width: 50,
const Spacer(), height: 50,
FilledButton.icon( decoration: BoxDecoration(
onPressed: _canAdvance ? _advance : null, borderRadius: BorderRadius.circular(14),
icon: Icon( color: _isGame ? Colors.white.withOpacity(0.06) : Colors.white,
_currentStepIndex < _steps.length - 1 border: Border.all(
? Icons.arrow_forward color: _isGame ? _gold.withOpacity(0.3) : const Color(0xFFE2DCD2),
: Icons.flag, width: 1.5),
size: 16, ),
), child: Icon(Icons.chevron_left,
label: Text( color: _isGame ? _gold : const Color(0xFF54636E)),
_currentStepIndex < _steps.length - 1 ? 'Suivant' : 'Terminer',
),
),
],
), ),
), ),
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)),
),
),
],
), ),
); );
} }

View File

@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
/// Écran de fin de parcours (équivalent `EndView` de visitapp-web) : version
/// escape game (badge + message de fin) et version découverte (stats).
class GuidedPathEndView extends StatelessWidget {
final bool isGame;
final Color primaryColor;
final String pathTitle;
final int stepsCount;
final int? estimatedDurationMinutes;
final String gameOutro;
final VoidCallback onBack;
const GuidedPathEndView({
Key? key,
required this.isGame,
required this.primaryColor,
required this.pathTitle,
required this.stepsCount,
required this.estimatedDurationMinutes,
required this.gameOutro,
required this.onBack,
}) : super(key: key);
static const _gold = Color(0xFFD4AF37);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: isGame
? const RadialGradient(
center: Alignment(0, -0.6),
radius: 1.1,
colors: [Color(0xFF1a2436), Color(0xFF0B1018), Color(0xFF05070b)],
stops: [0.0, 0.6, 1.0],
)
: const RadialGradient(
center: Alignment(0, -1),
radius: 1.1,
colors: [Colors.white, Color(0xFFF6F3EE)],
stops: [0.0, 0.6],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 34, vertical: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: isGame ? _game(context) : _normal(context),
),
),
),
),
),
);
}
List<Widget> _game(BuildContext context) => [
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: _gold.withOpacity(0.5), width: 1.5),
boxShadow: [BoxShadow(color: _gold.withOpacity(0.35), blurRadius: 60, spreadRadius: 4)],
),
child: const Icon(Icons.star, size: 50, color: Color(0xFFE7C765)),
),
const SizedBox(height: 24),
const Text('ÉNIGME RÉSOLUE',
style: TextStyle(
color: _gold, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 4)),
const SizedBox(height: 14),
const Text('Le secret est à vous',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFFF4ECD8),
fontSize: 30,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
height: 1.1)),
if (gameOutro.isNotEmpty) ...[
const SizedBox(height: 14),
Text('« $gameOutro »',
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFb9ac8c),
fontSize: 15,
fontStyle: FontStyle.italic,
height: 1.6)),
],
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
border: Border.all(color: _gold.withOpacity(0.4)),
color: _gold.withOpacity(0.08),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.emoji_events, size: 16, color: Color(0xFFE7C765)),
SizedBox(width: 8),
Text('Badge obtenu',
style: TextStyle(
color: Color(0xFFE7C765), fontSize: 13.5, fontWeight: FontWeight.w700)),
],
),
),
const SizedBox(height: 28),
_button('Terminer l\'aventure'),
];
List<Widget> _normal(BuildContext context) => [
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF2E9E6B),
boxShadow: [
BoxShadow(
color: const Color(0xFF2E9E6B).withOpacity(0.4),
blurRadius: 32,
offset: const Offset(0, 16))
],
),
child: const Icon(Icons.check, size: 44, color: Colors.white),
),
const SizedBox(height: 24),
const Text('Parcours terminé',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFF1E2A33),
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.4)),
const SizedBox(height: 10),
Text(
'Vous avez exploré $stepsCount étape${stepsCount > 1 ? 's' : ''} de « $pathTitle ».',
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF6B7B86), fontSize: 15, height: 1.55),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_statCard('$stepsCount', 'étapes'),
if (estimatedDurationMinutes != null) ...[
const SizedBox(width: 12),
_statCard('$estimatedDurationMinutes min', 'estimé'),
],
],
),
const SizedBox(height: 32),
_button('Retour à la section'),
];
Widget _statCard(String value, String label) => Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFEEE9E0)),
),
child: Column(
children: [
Text(value,
style: TextStyle(
fontSize: 22, fontWeight: FontWeight.w600, color: primaryColor)),
const SizedBox(height: 2),
Text(label,
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF8A969F))),
],
),
);
Widget _button(String label) => SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onBack,
style: ElevatedButton.styleFrom(
backgroundColor: isGame ? _gold : primaryColor,
foregroundColor: isGame ? const Color(0xFF1a1305) : Colors.white,
padding: const EdgeInsets.symmetric(vertical: 17),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: Text(label,
style: TextStyle(fontSize: 16, fontWeight: isGame ? FontWeight.w800 : FontWeight.w700)),
),
);
}

View File

@ -0,0 +1,61 @@
import 'package:latlong2/latlong.dart';
import 'package:manager_api_new/api.dart';
class StepShape {
final String type; // 'LineString' | 'Polygon'
final List<LatLng> positions;
const StepShape(this.type, this.positions);
}
/// Géométrie des GuidedStep : les coordonnées sont stockées en `[lat, lng]`
/// (et NON en GeoJSON `[lng, lat]`), en miroir du `map_geometry_picker` de
/// manager-app et de visitapp-web (voir `visitapp-web/src/lib/geo.ts`).
class GuidedPathGeo {
static List<LatLng> _pairs(dynamic points) {
if (points is! List) return const [];
final result = <LatLng>[];
for (final p in points) {
if (p is List && p.length >= 2 && p[0] is num && p[1] is num) {
result.add(LatLng((p[0] as num).toDouble(), (p[1] as num).toDouble()));
}
}
return result;
}
static LatLng? _average(List<LatLng> pts) {
if (pts.isEmpty) return null;
final lat = pts.map((p) => p.latitude).reduce((a, b) => a + b) / pts.length;
final lng = pts.map((p) => p.longitude).reduce((a, b) => a + b) / pts.length;
return LatLng(lat, lng);
}
/// Point représentatif : Point lui-même ; LineString/Polygon centroïde.
static LatLng? center(GeometryDTO? geometry) {
final coords = geometry?.coordinates;
if (coords is! List || coords.isEmpty) return null;
if (geometry?.type == 'LineString') return _average(_pairs(coords));
if (geometry?.type == 'Polygon') return _average(_pairs(coords[0]));
if (coords[0] is num && coords[1] is num) {
return LatLng((coords[0] as num).toDouble(), (coords[1] as num).toDouble());
}
return null;
}
/// Tracé réel d'un LineString/Polygon (pour dessiner sur la carte) ;
/// null pour un Point.
static StepShape? shape(GeometryDTO? geometry) {
final coords = geometry?.coordinates;
if (coords is! List) return null;
if (geometry?.type == 'LineString') {
final pos = _pairs(coords);
return pos.length > 1 ? StepShape('LineString', pos) : null;
}
if (geometry?.type == 'Polygon') {
final pos = _pairs(coords.isNotEmpty ? coords[0] : null);
return pos.length > 2 ? StepShape('Polygon', pos) : null;
}
return null;
}
static bool hasPoint(GeometryDTO? geometry) => center(geometry) != null;
}

View File

@ -0,0 +1,246 @@
import 'dart:math';
import 'package:flutter/material.dart';
/// Puzzle en pièces éparpillées, déplacées par glisser-déposer avec
/// accrochage ("snap") à la bonne position. Équivalent de `PuzzleGame.tsx`
/// (web) la variante non-sliding (`isSlidingPuzzle == false`).
class GuidedPathPuzzle extends StatefulWidget {
final String imageUrl;
final int rows;
final int cols;
final Color accentColor;
final bool isGame;
final VoidCallback onSolved;
const GuidedPathPuzzle({
Key? key,
required this.imageUrl,
required this.rows,
required this.cols,
required this.accentColor,
required this.isGame,
required this.onSolved,
}) : super(key: key);
@override
State<GuidedPathPuzzle> createState() => _GuidedPathPuzzleState();
}
class _Piece {
final int row;
final int col;
Offset pos; // position courante (px depuis le coin haut-gauche du board)
bool placed = false;
double z;
_Piece({required this.row, required this.col, required this.pos, this.z = 0});
int get id => row * 1000 + col;
}
class _GuidedPathPuzzleState extends State<GuidedPathPuzzle> {
List<_Piece> _pieces = [];
bool _showHint = false;
bool _solved = false;
double _topZ = 1;
int? _draggingId;
@override
void initState() {
super.initState();
}
void _initPieces(double board) {
if (_pieces.isNotEmpty) return;
final pw = board / widget.cols;
final ph = board / widget.rows;
final rand = Random();
final list = <_Piece>[];
var z = 1.0;
for (int r = 0; r < widget.rows; r++) {
for (int c = 0; c < widget.cols; c++) {
list.add(_Piece(
row: r,
col: c,
pos: Offset(rand.nextDouble() * (board - pw), rand.nextDouble() * (board - ph)),
z: z++,
));
}
}
_pieces = list;
_topZ = z;
}
void _onPanStart(_Piece piece) {
if (piece.placed || _solved) return;
setState(() {
_draggingId = piece.id;
piece.z = _topZ++;
});
}
void _onPanUpdate(_Piece piece, DragUpdateDetails details, double board) {
if (piece.placed || _solved) return;
setState(() {
final pw = board / widget.cols;
final ph = board / widget.rows;
piece.pos = Offset(
(piece.pos.dx + details.delta.dx).clamp(0.0, board - pw),
(piece.pos.dy + details.delta.dy).clamp(0.0, board - ph),
);
});
}
void _onPanEnd(_Piece piece, double board) {
if (piece.placed || _solved) return;
final pw = board / widget.cols;
final ph = board / widget.rows;
final targetX = piece.col * pw;
final targetY = piece.row * ph;
final dx = piece.pos.dx - targetX;
final dy = piece.pos.dy - targetY;
final threshold = min(pw, ph) * 0.35;
setState(() {
_draggingId = null;
if (dx.abs() < threshold && dy.abs() < threshold) {
piece.pos = Offset(targetX, targetY);
piece.placed = true;
piece.z = 0;
}
if (_pieces.every((p) => p.placed)) {
_solved = true;
Future.delayed(const Duration(milliseconds: 400), () {
if (mounted) widget.onSolved();
});
}
});
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final board = constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.of(context).size.width - 64;
_initPieces(board);
final pw = board / widget.cols;
final ph = board / widget.rows;
final sorted = [..._pieces]..sort((a, b) => a.z.compareTo(b.z));
return SizedBox(
width: board,
height: board,
child: Stack(
clipBehavior: Clip.none,
children: [
// Cadre du plateau (toujours visible)
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.black.withOpacity(0.18), width: 1.5),
color: Colors.white.withOpacity(0.4),
),
),
if (_showHint && !_solved)
Positioned.fill(
child: Opacity(
opacity: 0.55,
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
),
),
for (final piece in sorted)
Positioned(
left: piece.pos.dx,
top: piece.pos.dy,
width: pw,
height: ph,
child: GestureDetector(
onPanStart: (_) => _onPanStart(piece),
onPanUpdate: (d) => _onPanUpdate(piece, d, board),
onPanEnd: (_) => _onPanEnd(piece, board),
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.black.withOpacity(0.15)),
boxShadow: piece.placed
? []
: [
BoxShadow(
color: Colors.black.withOpacity(
_draggingId == piece.id ? 0.35 : 0.2),
blurRadius: _draggingId == piece.id ? 12 : 6,
),
],
),
transform: _draggingId == piece.id
? (Matrix4.identity()..scaleByDouble(1.06, 1.06, 1.06, 1.0))
: Matrix4.identity(),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: OverflowBox(
maxWidth: board,
maxHeight: board,
alignment: Alignment(
widget.cols == 1 ? 0.0 : -1.0 + (piece.col * 2 / (widget.cols - 1)),
widget.rows == 1 ? 0.0 : -1.0 + (piece.row * 2 / (widget.rows - 1)),
),
child: SizedBox(
width: board,
height: board,
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
),
),
),
),
),
),
if (_solved)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.35),
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: const Text(
'Puzzle résolu !',
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
),
),
),
if (!_solved)
Positioned(
right: 8,
bottom: 8,
child: GestureDetector(
onTap: () => setState(() => _showHint = !_showHint),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _showHint
? widget.accentColor
: (widget.isGame ? Colors.white.withOpacity(0.1) : Colors.white),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 8)],
),
child: Icon(
Icons.remove_red_eye,
size: 18,
color: _showHint
? (widget.isGame ? const Color(0xFF1a1305) : Colors.white)
: (widget.isGame ? const Color(0xFFD4AF37) : const Color(0xFF54636E)),
),
),
),
),
],
),
);
},
);
}
}

View File

@ -0,0 +1,232 @@
import 'dart:math';
import 'package:flutter/material.dart';
/// Taquin classique (15-puzzle) : une case vide, seule une tuile adjacente à
/// la case vide peut glisser dedans. Équivalent de `SlidingPuzzle.tsx` (web).
class GuidedPathSlidingPuzzle extends StatefulWidget {
final String imageUrl;
final int rows;
final int cols;
final Color accentColor;
final bool isGame;
final VoidCallback onSolved;
const GuidedPathSlidingPuzzle({
Key? key,
required this.imageUrl,
required this.rows,
required this.cols,
required this.accentColor,
required this.isGame,
required this.onSolved,
}) : super(key: key);
@override
State<GuidedPathSlidingPuzzle> createState() => _GuidedPathSlidingPuzzleState();
}
class _GuidedPathSlidingPuzzleState extends State<GuidedPathSlidingPuzzle> {
// tiles[slot] = pièce d'origine occupant ce slot ; -1 = case vide.
late List<int> _tiles;
bool _won = false;
bool _showHint = false;
int get _total => widget.rows * widget.cols;
@override
void initState() {
super.initState();
_shuffle();
}
@override
void didUpdateWidget(GuidedPathSlidingPuzzle old) {
super.didUpdateWidget(old);
if (old.rows != widget.rows || old.cols != widget.cols) _shuffle();
}
List<int> _neighboursOf(int idx) {
final row = idx ~/ widget.cols;
final col = idx % widget.cols;
final list = <int>[];
if (row > 0) list.add(idx - widget.cols);
if (row < widget.rows - 1) list.add(idx + widget.cols);
if (col > 0) list.add(idx - 1);
if (col < widget.cols - 1) list.add(idx + 1);
return list;
}
void _shuffle() {
final arr = List.generate(_total - 1, (i) => i)..add(-1);
var emptyIdx = _total - 1;
final rand = Random();
for (int n = 0; n < 200; n++) {
final neighbours = _neighboursOf(emptyIdx);
final pick = neighbours[rand.nextInt(neighbours.length)];
final tmp = arr[emptyIdx];
arr[emptyIdx] = arr[pick];
arr[pick] = tmp;
emptyIdx = pick;
}
_tiles = arr;
_won = false;
}
void _onTapSlot(int slotIdx) {
if (_won) return;
final emptyIdx = _tiles.indexOf(-1);
if (!_neighboursOf(emptyIdx).contains(slotIdx)) return;
setState(() {
final tmp = _tiles[emptyIdx];
_tiles[emptyIdx] = _tiles[slotIdx];
_tiles[slotIdx] = tmp;
final win = _tiles.asMap().entries.every(
(e) => e.key == _tiles.length - 1 ? e.value == -1 : e.value == e.key);
if (win) {
_won = true;
Future.delayed(const Duration(milliseconds: 400), () {
if (mounted) widget.onSolved();
});
}
});
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final board = constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.of(context).size.width - 64;
final tileW = board / widget.cols;
final tileH = board / widget.rows;
return SizedBox(
width: board,
height: board,
child: Stack(
children: [
if (_showHint && !_won)
Positioned.fill(
child: Opacity(
opacity: 0.25,
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
),
),
for (int slot = 0; slot < _total; slot++)
if (_tiles[slot] != -1)
AnimatedPositioned(
key: ValueKey('slide-${_tiles[slot]}'),
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
left: (slot % widget.cols) * tileW,
top: (slot ~/ widget.cols) * tileH,
width: tileW,
height: tileH,
child: _SlidingTile(
piece: _tiles[slot],
rows: widget.rows,
cols: widget.cols,
boardSize: board,
imageUrl: widget.imageUrl,
onTap: () => _onTapSlot(slot),
),
),
if (_won)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.35),
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: const Text(
'Puzzle résolu !',
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
),
),
),
if (!_won)
Positioned(
right: 8,
bottom: 8,
child: GestureDetector(
onTap: () => setState(() => _showHint = !_showHint),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _showHint
? widget.accentColor
: (widget.isGame ? Colors.white.withOpacity(0.1) : Colors.white),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 8)],
),
child: Icon(
Icons.remove_red_eye,
size: 18,
color: _showHint
? (widget.isGame ? const Color(0xFF1a1305) : Colors.white)
: (widget.isGame ? const Color(0xFFD4AF37) : const Color(0xFF54636E)),
),
),
),
),
],
),
);
},
);
}
}
class _SlidingTile extends StatelessWidget {
final int piece;
final int rows;
final int cols;
final double boardSize;
final String imageUrl;
final VoidCallback onTap;
const _SlidingTile({
required this.piece,
required this.rows,
required this.cols,
required this.boardSize,
required this.imageUrl,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final row = piece ~/ cols;
final col = piece % cols;
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.all(1.5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 6)],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: OverflowBox(
maxWidth: boardSize,
maxHeight: boardSize,
alignment: Alignment(
cols == 1 ? 0.0 : -1.0 + (col * 2 / (cols - 1)),
rows == 1 ? 0.0 : -1.0 + (row * 2 / (rows - 1)),
),
child: SizedBox(
width: boardSize,
height: boardSize,
child: Image.network(imageUrl, fit: BoxFit.cover),
),
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,108 +1,70 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class GuidedStepTimer extends StatefulWidget { /// Barre de temps présentational le décompte est piloté par le parent
final int seconds; /// (GuidedStepChallengeController), en miroir de `StepTimer` de visitapp-web.
final String expiredMessage; class GuidedStepTimer extends StatelessWidget {
final VoidCallback? onExpired; final int totalSeconds;
/// true = barre de progression + texte MM:SS (mode escape) final int remainingSeconds;
/// false = texte MM:SS seul (mode peek carte) final bool isGame;
final bool showAsBar;
const GuidedStepTimer({ const GuidedStepTimer({
Key? key, Key? key,
required this.seconds, required this.totalSeconds,
required this.expiredMessage, required this.remainingSeconds,
this.onExpired, this.isGame = false,
this.showAsBar = false,
}) : super(key: key); }) : super(key: key);
@override static String _format(int s) {
State<GuidedStepTimer> createState() => _GuidedStepTimerState(); final m = s ~/ 60;
} final r = s % 60;
return '$m:${r.toString().padLeft(2, '0')}';
class _GuidedStepTimerState extends State<GuidedStepTimer> {
late int _remaining;
Timer? _timer;
bool _expired = false;
@override
void initState() {
super.initState();
_remaining = widget.seconds;
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
}
void _tick(Timer t) {
if (!mounted) return;
if (_remaining <= 0) {
_timer?.cancel();
setState(() => _expired = true);
widget.onExpired?.call();
return;
}
setState(() => _remaining--);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_expired) { final pct = totalSeconds > 0 ? remainingSeconds / totalSeconds : 0.0;
return Text( final color = pct > 0.5
widget.expiredMessage, ? const Color(0xFF16A34A)
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold), : pct > 0.25
); ? const Color(0xFFF59E0B)
} : const Color(0xFFDC2626);
final timeText = _formatTime(_remaining); return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
if (widget.showAsBar) {
final progress = widget.seconds > 0 ? _remaining / widget.seconds : 0.0;
final color = progress > 0.5
? Colors.green
: progress > 0.25
? Colors.orange
: Colors.red;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.timer, size: 18),
const SizedBox(width: 6),
Text(timeText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
],
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.timer, size: 14), Row(
const SizedBox(width: 4), mainAxisAlignment: MainAxisAlignment.spaceBetween,
Text(timeText, style: const TextStyle(fontSize: 13)), children: [
Text(
'Temps restant',
style: TextStyle(
fontSize: 12,
color: isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86),
),
),
Text(
_format(remainingSeconds),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
fontFeatures: const [FontFeature.tabularFigures()],
color: color,
),
),
],
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct.clamp(0.0, 1.0),
minHeight: 8,
backgroundColor:
isGame ? Colors.white.withOpacity(0.1) : const Color(0xFFE2DCD2),
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
], ],
); );
} }
String _formatTime(int s) {
final m = s ~/ 60;
final sec = s % 60;
return '${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}';
}
} }

View File

@ -9,7 +9,6 @@ import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.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/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/geo_point_filter.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Map/map_box_view.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Map/map_box_view.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Map/marker_view.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Map/marker_view.dart';
@ -179,41 +178,6 @@ class _MapPage extends State<MapPage> {
) )
), ),
), ),
if (mapDTO?.guidedPaths?.isNotEmpty == true)
Positioned(
top: 35,
right: 10,
child: InkWell(
onTap: () {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => GuidedPathListSheet(
paths: mapDTO!.guidedPaths!,
mapDTO: mapDTO!,
visitAppContext: visitAppContext,
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: primaryColor,
borderRadius: BorderRadius.circular(25),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.route, size: 18, color: Colors.white),
SizedBox(width: 6),
Text('Parcours', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500)),
],
),
),
),
),
if (mapDTO?.isListViewEnabled == true) if (mapDTO?.isListViewEnabled == true)
Positioned( Positioned(
bottom: 24, bottom: 24,

View File

@ -9,6 +9,7 @@ import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Components/loading_common.dart'; import 'package:mymuseum_visitapp/Components/loading_common.dart';
import 'package:mymuseum_visitapp/Components/show_element_for_resource.dart'; import 'package:mymuseum_visitapp/Components/show_element_for_resource.dart';
import 'package:mymuseum_visitapp/Helpers/ImageCustomProvider.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/Models/visitContext.dart';
import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/app_context.dart';
import 'package:photo_view/photo_view.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)), borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0)),
color: kBackgroundColor, color: kBackgroundColor,
), ),
constraints: const BoxConstraints(minWidth: 250, minHeight: 250, maxHeight: 350), constraints: const BoxConstraints(minWidth: 250, minHeight: 250, maxHeight: 550),
height: size.height * 0.6, height: size.height * 0.75,
width: size.width * 0.85, width: size.width * 0.85,
child: Container( child: SingleChildScrollView(
decoration: BoxDecoration( child: Column(
//color: Colors.yellow, mainAxisSize: MainAxisSize.min,
borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 15.0)), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
child: PhotoView( if (TranslationHelper.get(i.title, appContext.getContext()).isNotEmpty)
imageProvider: ImageCustomProvider.getImageProvider(appContext, i.resourceId!, i.resource!.url!), Padding(
minScale: PhotoViewComputedScale.contained * 0.8, padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
maxScale: PhotoViewComputedScale.contained * 3.0, child: HtmlWidget(
backgroundDecoration: BoxDecoration( TranslationHelper.get(i.title, appContext.getContext()),
color: kBackgroundGrey, textStyle: const TextStyle(fontSize: kArticleContentSize, fontWeight: FontWeight.w600),
shape: BoxShape.rectangle, ),
borderRadius: BorderRadius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 15.0), ),
), 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),
),
),
],
), ),
), ),
), ),

View File

@ -0,0 +1,816 @@
import 'package:flutter/material.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart';
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart';
import 'package:mymuseum_visitapp/constants.dart';
class ParcoursPage extends StatefulWidget {
final ParcoursDTO section;
final VisitAppContext visitAppContextIn;
const ParcoursPage({
Key? key,
required this.section,
required this.visitAppContextIn,
}) : super(key: key);
@override
State<ParcoursPage> createState() => _ParcoursPageState();
}
class _ParcoursPageState extends State<ParcoursPage> {
List<GuidedPathDTO> _paths = [];
bool _loading = true;
MapDTO? _baseMap;
static const _bg = Color(0xFFF6F3EE);
static const _inkDark = Color(0xFF1E2A33);
static const _inkMid = Color(0xFF46555F);
static const _inkMuted = Color(0xFF6B7B86);
static const _borderLight = Color(0xFFEFEAE2);
static const _gold = Color(0xFFD4AF37);
Color get _primary => kMainColor;
@override
void initState() {
super.initState();
_loadPaths();
}
Future<void> _loadPaths() async {
if (widget.section.id == null) {
setState(() => _loading = false);
return;
}
try {
final api = widget.visitAppContextIn.clientAPI.sectionParcoursApi!;
final paths = await api.sectionParcoursGetAllGuidedPathFromSection(widget.section.id!);
if (!mounted) return;
setState(() {
_paths = (paths ?? [])..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
_loading = false;
});
if (widget.section.showMap == true && widget.section.baseSectionMapId != null) {
_loadBaseMap();
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadBaseMap() async {
try {
final rawMap = await widget.visitAppContextIn.clientAPI.sectionApi!
.sectionGetDetail(widget.section.baseSectionMapId!);
if (rawMap != null && mounted) {
setState(() => _baseMap = MapDTO.fromJson(rawMap));
}
} catch (_) {}
}
String get _sectionTitle =>
TranslationHelper.get(widget.section.title, widget.visitAppContextIn);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _bg,
body: SafeArea(
child: Column(
children: [
_buildHero(context),
Expanded(child: _buildPathList()),
],
),
),
);
}
Widget _buildHero(BuildContext context) {
return Stack(
children: [
Container(
height: 220,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_primary.withOpacity(0.85),
_primary,
],
),
),
child: widget.section.imageSource != null
? Image.network(
widget.section.imageSource!,
width: double.infinity,
height: 220,
fit: BoxFit.cover,
color: _primary.withOpacity(0.5),
colorBlendMode: BlendMode.darken,
errorBuilder: (_, __, ___) => const SizedBox(),
)
: null,
),
Container(
height: 220,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withOpacity(0.45)],
),
),
),
Positioned(
top: 12,
left: 16,
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18),
),
),
),
Positioned(
left: 20,
bottom: 20,
right: 20,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_paths.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.92),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${_paths.length} parcours',
style: TextStyle(
color: _primary,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
),
),
const SizedBox(height: 8),
Text(
_sectionTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
height: 1.1,
),
),
],
),
),
],
);
}
Widget _buildPathList() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_paths.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.route_outlined, size: 48, color: _inkMuted),
const SizedBox(height: 12),
Text(
'Aucun parcours disponible',
style: TextStyle(color: _inkMuted, fontSize: 15),
),
],
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Text(
'Choisir un parcours',
style: TextStyle(
color: _inkDark,
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: _paths.length,
itemBuilder: (_, i) => _buildPathCard(_paths[i]),
),
),
],
);
}
Widget _buildPathCard(GuidedPathDTO path) {
final title = TranslationHelper.get(path.title, widget.visitAppContextIn);
final desc = TranslationHelper.get(path.description, widget.visitAppContextIn);
final stepCount = path.steps?.length ?? 0;
final isGame = path.isGameMode == true;
final durationMin = path.estimatedDurationMinutes;
return GestureDetector(
onTap: () => _onPathTap(path),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _borderLight),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Row(
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(19),
bottomLeft: Radius.circular(19),
),
gradient: isGame
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF1a2436), Color(0xFF0B1018)],
)
: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_primary.withOpacity(0.8),
_primary,
],
),
),
child: Icon(
isGame ? Icons.lock_outlined : Icons.route_outlined,
color: isGame ? _gold : Colors.white.withOpacity(0.9),
size: 32,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(13, 12, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isGame
? _gold.withOpacity(0.15)
: _primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(7),
),
child: Text(
isGame ? 'ESCAPE GAME' : 'DÉCOUVERTE',
style: TextStyle(
color: isGame ? _gold : _primary,
fontSize: 10.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
),
),
const SizedBox(height: 6),
Text(
title,
style: const TextStyle(
color: _inkDark,
fontSize: 17,
fontWeight: FontWeight.w600,
height: 1.15,
),
),
if (desc.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
desc,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: _inkMuted,
fontSize: 12.5,
height: 1.35,
),
),
],
const SizedBox(height: 6),
Row(
children: [
Icon(Icons.flag_outlined, size: 12, color: _inkMuted),
const SizedBox(width: 4),
Text(
'$stepCount étape${stepCount > 1 ? 's' : ''}',
style: const TextStyle(
color: _inkMuted,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
if (durationMin != null) ...[
const SizedBox(width: 10),
Icon(Icons.access_time_outlined, size: 12, color: _inkMuted),
const SizedBox(width: 4),
Text(
'~$durationMin min',
style: const TextStyle(
color: _inkMuted,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
],
),
],
),
),
),
Padding(
padding: const EdgeInsets.only(right: 14),
child: Icon(Icons.arrow_forward_ios,
size: 14, color: _inkMuted),
),
],
),
),
);
}
void _onPathTap(GuidedPathDTO path) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _ParcoursPathStartSheet(
path: path,
section: widget.section,
baseMap: _baseMap,
visitAppContext: widget.visitAppContextIn,
primaryColor: _primary,
),
);
}
}
class _ParcoursPathStartSheet extends StatelessWidget {
final GuidedPathDTO path;
final ParcoursDTO section;
final MapDTO? baseMap;
final VisitAppContext visitAppContext;
final Color primaryColor;
static const _bg = Color(0xFFF6F3EE);
static const _inkDark = Color(0xFF1E2A33);
static const _inkMid = Color(0xFF46555F);
static const _inkMuted = Color(0xFF6B7B86);
static const _gold = Color(0xFFD4AF37);
const _ParcoursPathStartSheet({
Key? key,
required this.path,
required this.section,
required this.baseMap,
required this.visitAppContext,
required this.primaryColor,
}) : super(key: key);
bool get _isGame => path.isGameMode == true;
bool get _showMap => section.showMap == true && baseMap != null;
String get _title => TranslationHelper.get(path.title, visitAppContext);
String get _desc => TranslationHelper.get(path.description, visitAppContext);
String get _gameIntro {
final msg = path.gameMessageDebut;
if (msg == null || msg.isEmpty) return '';
final found = msg.firstWhere(
(m) => m.language == visitAppContext.language,
orElse: () => msg.first,
);
return found.value ?? '';
}
@override
Widget build(BuildContext context) {
return _isGame ? _buildGameStart(context) : _buildNormalStart(context);
}
Widget _buildNormalStart(BuildContext context) {
final stepCount = path.steps?.length ?? 0;
final durationMin = path.estimatedDurationMinutes;
return Container(
decoration: const BoxDecoration(
color: _bg,
borderRadius: BorderRadius.vertical(top: Radius.circular(26)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 5,
margin: const EdgeInsets.only(top: 11, bottom: 4),
decoration: BoxDecoration(
color: const Color(0xFFD8D2C8),
borderRadius: BorderRadius.circular(3),
),
),
),
Container(
height: 180,
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [primaryColor.withOpacity(0.7), primaryColor],
),
),
child: Stack(
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CustomPaint(
painter: _MapSketchPainter(primaryColor),
),
),
),
Positioned(
left: 14,
top: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.92),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'DÉCOUVERTE LIBRE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
color: Color(0xFF264863),
),
),
),
),
Positioned(
left: 16,
right: 16,
bottom: 14,
child: Text(
_title,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
height: 1.1,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Row(
children: [
if (durationMin != null) ...[
Icon(Icons.access_time, size: 15, color: primaryColor),
const SizedBox(width: 5),
Text('~$durationMin min',
style: TextStyle(color: const Color(0xFF54636E), fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(width: 18),
],
Icon(Icons.map_outlined, size: 15, color: primaryColor),
const SizedBox(width: 5),
Text('$stepCount étape${stepCount > 1 ? 's' : ''}',
style: const TextStyle(color: Color(0xFF54636E), fontSize: 13, fontWeight: FontWeight.w600)),
],
),
),
if (_desc.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: Text(
_desc,
style: const TextStyle(
color: _inkMid,
fontSize: 15,
height: 1.55,
),
),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_startPath(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 17),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
elevation: 0,
),
child: const Text(
'Commencer le parcours',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
),
),
),
),
],
),
);
}
Widget _buildGameStart(BuildContext context) {
final stepCount = path.steps?.length ?? 0;
final durationMin = path.estimatedDurationMinutes;
final intro = _gameIntro;
return Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: const Alignment(0, -0.4),
radius: 1.2,
colors: [const Color(0xFF16202e), const Color(0xFF0B1018)],
),
borderRadius: const BorderRadius.vertical(top: Radius.circular(26)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 34,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40,
height: 5,
margin: const EdgeInsets.only(top: 11, bottom: 20),
decoration: BoxDecoration(
color: _gold.withOpacity(0.4),
borderRadius: BorderRadius.circular(3),
),
),
),
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: _gold.withOpacity(0.5), width: 1.5),
boxShadow: [
BoxShadow(
color: _gold.withOpacity(0.2),
blurRadius: 30,
spreadRadius: 4,
),
],
),
child: const Icon(Icons.lock_open_outlined, color: _gold, size: 40),
),
const SizedBox(height: 18),
const Text(
'ESCAPE GAME',
style: TextStyle(
color: _gold,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 4,
),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Text(
_title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFF4ECD8),
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
height: 1.1,
),
),
),
if (intro.isNotEmpty) ...[
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Text(
'« $intro »',
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFFb9ac8c),
fontSize: 15,
fontStyle: FontStyle.italic,
height: 1.6,
),
),
),
],
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('$stepCount énigme${stepCount > 1 ? 's' : ''}',
style: const TextStyle(color: Color(0xFF8a8068), fontSize: 13, fontWeight: FontWeight.w600)),
if (durationMin != null) ...[
const SizedBox(width: 8),
const Text('·', style: TextStyle(color: Color(0xFF8a8068))),
const SizedBox(width: 8),
Text('~$durationMin min',
style: const TextStyle(color: Color(0xFF8a8068), fontSize: 13, fontWeight: FontWeight.w600)),
],
],
),
const SizedBox(height: 26),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_startPath(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: _gold,
foregroundColor: const Color(0xFF1a1305),
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text(
"Démarrer l'aventure",
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800, letterSpacing: 0.3),
),
),
),
),
const SizedBox(height: 8),
if (_isGame)
const Text(
'Activez le son pour une immersion totale',
style: TextStyle(color: Color(0xFF6f6655), fontSize: 12),
),
],
),
);
}
void _startPath(BuildContext context) {
if (_showMap) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => GuidedPathMapProgressionPage(
path: path,
mapDTO: baseMap!,
visitAppContext: visitAppContext,
),
));
} else {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => GuidedPathContentProgressionPage(
path: path,
visitAppContext: visitAppContext,
),
));
}
}
}
class _MapSketchPainter extends CustomPainter {
final Color primaryColor;
_MapSketchPainter(this.primaryColor);
@override
void paint(Canvas canvas, Size size) {
final bgPaint = Paint()
..color = Colors.white.withOpacity(0.06)
..style = PaintingStyle.fill;
final roadPaint = Paint()
..color = Colors.white.withOpacity(0.12)
..strokeWidth = 8
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final routePaint = Paint()
..color = Colors.white.withOpacity(0.7)
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final road1 = Path()
..moveTo(-10, size.height * 0.45)
..lineTo(size.width * 0.35, size.height * 0.45)
..lineTo(size.width * 0.55, size.height * 0.2)
..lineTo(size.width + 10, size.height * 0.2);
final road2 = Path()
..moveTo(size.width * 0.2, -10)
..lineTo(size.width * 0.2, size.height * 0.55)
..lineTo(size.width * 0.4, size.height + 10);
canvas.drawPath(road1, roadPaint);
canvas.drawPath(road2, roadPaint);
final route = Path()
..moveTo(size.width * 0.15, size.height * 0.65)
..lineTo(size.width * 0.35, size.height * 0.35)
..lineTo(size.width * 0.65, size.height * 0.55)
..lineTo(size.width * 0.82, size.height * 0.35);
final dashPaint = Paint()
..color = Colors.white.withOpacity(0.8)
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
_drawDashedPath(canvas, route, dashPaint, dashLength: 4, gapLength: 8);
final dotPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(size.width * 0.15, size.height * 0.65), 5, dotPaint);
canvas.drawCircle(Offset(size.width * 0.82, size.height * 0.35), 5,
dotPaint..color = const Color(0xFF2E9E6B));
}
void _drawDashedPath(Canvas canvas, Path path, Paint paint,
{required double dashLength, required double gapLength}) {
final metrics = path.computeMetrics();
for (final metric in metrics) {
double distance = 0;
while (distance < metric.length) {
final start = distance;
final end = (start + dashLength).clamp(0.0, metric.length);
canvas.drawPath(metric.extractPath(start, end), paint);
distance += dashLength + gapLength;
}
}
}
@override
bool shouldRepaint(_MapSketchPainter old) => old.primaryColor != primaryColor;
}

View File

@ -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/Weather/weather_page.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Event/event_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/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/app_context.dart';
import 'package:mymuseum_visitapp/client.dart'; import 'package:mymuseum_visitapp/client.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -159,6 +160,9 @@ class _SectionPageState extends State<SectionPage> {
case SectionType.Event: case SectionType.Event:
SectionEventDTO eventDTO = SectionEventDTO.fromJson(sectionResult)!; SectionEventDTO eventDTO = SectionEventDTO.fromJson(sectionResult)!;
return EventPage(section: eventDTO, visitAppContextIn: visitAppContext); return EventPage(section: eventDTO, visitAppContextIn: visitAppContext);
case SectionType.Parcours:
ParcoursDTO parcoursDTO = ParcoursDTO.fromJson(sectionResult)!;
return ParcoursPage(section: parcoursDTO, visitAppContextIn: visitAppContext);
default: default:
return const Center(child: Text("Unsupported type")); return const Center(child: Text("Unsupported type"));
} }

View File

@ -15,11 +15,15 @@ class MyInfoMateLlmClient implements LlmClient {
late final AssistantService _service; late final AssistantService _service;
MyInfoMateLlmClient({required this.visitAppContext}) { MyInfoMateLlmClient({required this.visitAppContext}) {
_service = AssistantService(visitAppContext: visitAppContext); _service = AssistantService(
visitAppContext: visitAppContext,
maxHistory: 6,
inactivityTimeout: const Duration(minutes: 5),
);
} }
@override @override
Future<String> chat( Future<({String reply, bool expectsReply})> chat(
String message, { String message, {
String? configurationId, String? configurationId,
String languageCode = 'FR', String languageCode = 'FR',
@ -30,15 +34,13 @@ class MyInfoMateLlmClient implements LlmClient {
'configId=$cfgId lang=$languageCode apiKey=${visitAppContext.apiKey != null ? "set" : "null"}'); 'configId=$cfgId lang=$languageCode apiKey=${visitAppContext.apiKey != null ? "set" : "null"}');
try { try {
// AppType.Mobile + isVoice:true backend adapte le prompt (audio-friendly)
// sans navigation, sans markdown, dates en toutes lettres
final response = await _service.chatWithAppType( final response = await _service.chatWithAppType(
message: message, message: message,
configurationId: cfgId, configurationId: cfgId,
appType: AppType.Mobile, appType: AppType.Mobile,
isVoice: true, isVoice: true,
); );
return response.reply; return (reply: response.reply, expectsReply: response.expectsReply);
} catch (e) { } catch (e) {
debugPrint('[MyInfoMateLlmClient] error: $e'); debugPrint('[MyInfoMateLlmClient] error: $e');
rethrow; rethrow;

View File

@ -7,12 +7,9 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart'; import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
/// Wake word via OpenWakeWord pipeline TFLite+ONNX natif Android. /// Wake word via OpenWakeWord pipeline TFLite+ONNX natif Android.
/// /// Supporte plusieurs modèles en parallèle (même pipeline melembedding partagé).
/// 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).
class NativeWakeWordEngine implements WakeWordEngine { class NativeWakeWordEngine implements WakeWordEngine {
final String modelName; final List<String> modelNames;
static const MethodChannel _channel = static const MethodChannel _channel =
MethodChannel('be.unov.mymuseum/wake_word'); MethodChannel('be.unov.mymuseum/wake_word');
@ -21,82 +18,86 @@ class NativeWakeWordEngine implements WakeWordEngine {
StreamSubscription? _subscription; StreamSubscription? _subscription;
bool _running = false; bool _running = false;
bool _nativeServiceAlive = false; // vrai si le foreground service tourne (même en pause) bool _nativeServiceAlive = false;
NativeWakeWordEngine({this.modelName = 'hey_visit'}); NativeWakeWordEngine({
List<String>? modelNames,
String? modelName,
}) : modelNames = modelNames ?? (modelName != null ? [modelName] : ['hey_visit']);
@override @override
Future<void> start({ Future<void> start({
required void Function() onDetected, required void Function() onDetected,
void Function(String command)? onDetectedWithCommand, void Function(String command)? onDetectedWithCommand,
}) async { }) async {
if (_subscription != null) { if (_subscription != null) return;
// Déjà abonné rien à faire
return;
}
if (_nativeServiceAlive) { if (_nativeServiceAlive) {
// Service natif en pause reprendre l'AudioRecord et se re-souscrire
debugPrint('[NativeWakeWordEngine] Resuming native AudioRecord'); debugPrint('[NativeWakeWordEngine] Resuming native AudioRecord');
try { await _channel.invokeMethod('resume'); } catch (_) {} try { await _channel.invokeMethod('resume'); } catch (_) {}
_running = true; _running = true;
_subscription = _events.receiveBroadcastStream().listen((event) { _subscription = _events.receiveBroadcastStream().listen((event) {
if (event == 'detected') { _handleEvent(event as String, onDetected, onDetectedWithCommand);
debugPrint('[NativeWakeWordEngine] "$modelName" detected!');
onDetected();
}
}); });
return; return;
} }
// Permission micro requise avant de démarrer le foreground service (Android 14+)
final micStatus = await Permission.microphone.request(); final micStatus = await Permission.microphone.request();
if (!micStatus.isGranted) { if (!micStatus.isGranted) {
debugPrint('[NativeWakeWordEngine] Microphone permission denied'); debugPrint('[NativeWakeWordEngine] Microphone permission denied');
return; return;
} }
// Extraire les modèles vers le cache natif (accessible par le Service)
await _extractModels(); await _extractModels();
try { try {
await _channel.invokeMethod('start', {'modelName': modelName}); await _channel.invokeMethod('start', {'modelNames': modelNames.join(',')});
_running = true; _running = true;
_nativeServiceAlive = true; _nativeServiceAlive = true;
_subscription = _events.receiveBroadcastStream().listen((event) { _subscription = _events.receiveBroadcastStream().listen((event) {
if (event == 'detected') { _handleEvent(event as String, onDetected, onDetectedWithCommand);
debugPrint('[NativeWakeWordEngine] "$modelName" detected!'); }, onError: (e) {
onDetected(); debugPrint('[NativeWakeWordEngine] Event error: $e');
} else if (event == 'error') { Future.delayed(const Duration(seconds: 2), () {
debugPrint('[NativeWakeWordEngine] Service error — restarting listener'); if (_running) start(onDetected: onDetected, onDetectedWithCommand: onDetectedWithCommand);
// Tente de relancer après une erreur });
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) { } catch (e) {
debugPrint('[NativeWakeWordEngine] start error: $e'); debugPrint('[NativeWakeWordEngine] start error: $e');
_running = false; _running = false;
} }
} }
/// Pause l'écoute côté Flutter ET libère l'AudioRecord natif pour que le STT puisse accéder au micro. void _handleEvent(
/// Le foreground service reste vivant le silent AudioTrack continue, MIUI ne mute pas. 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 @override
Future<void> stop() async { Future<void> stop() async {
await _subscription?.cancel(); await _subscription?.cancel();
_subscription = null; _subscription = null;
_running = false; _running = false;
// Libère l'AudioRecord pour le STT — service et silent track restent vivants
try { await _channel.invokeMethod('pause'); } catch (_) {} try { await _channel.invokeMethod('pause'); } catch (_) {}
debugPrint('[NativeWakeWordEngine] Paused — AudioRecord released for STT'); debugPrint('[NativeWakeWordEngine] Paused — AudioRecord released for STT');
} }
/// Arrêt complet du service natif (appeler uniquement à la fermeture de l'app).
Future<void> stopNativeService() async { Future<void> stopNativeService() async {
await _subscription?.cancel(); await _subscription?.cancel();
_subscription = null; _subscription = null;
@ -106,30 +107,27 @@ class NativeWakeWordEngine implements WakeWordEngine {
debugPrint('[NativeWakeWordEngine] Native service stopped'); debugPrint('[NativeWakeWordEngine] Native service stopped');
} }
/// Copie les modèles depuis les assets Flutter vers le cache de l'app.
/// Le Service Android peut y accéder via getCacheDir().
Future<void> _extractModels() async { Future<void> _extractModels() async {
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
final models = [ final assets = [
'assets/files/melspectrogram.onnx', 'assets/files/melspectrogram.onnx',
'assets/files/embedding_model.tflite', '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 fileName = assetPath.split('/').last;
final dest = File('${dir.path}/$fileName'); final dest = File('${dir.path}/$fileName');
if (dest.existsSync()) continue; // déjà extrait if (dest.existsSync()) continue;
try { try {
final data = await rootBundle.load(assetPath); final data = await rootBundle.load(assetPath);
await dest.writeAsBytes(data.buffer.asUint8List(), flush: true); await dest.writeAsBytes(data.buffer.asUint8List(), flush: true);
debugPrint('[NativeWakeWordEngine] Extracted: $fileName${dir.path}'); debugPrint('[NativeWakeWordEngine] Extracted: $fileName');
} catch (e) { } catch (e) {
debugPrint('[NativeWakeWordEngine] Failed to extract $fileName: $e'); debugPrint('[NativeWakeWordEngine] Failed to extract $fileName: $e');
} }
} }
// Passe le chemin du cache au service natif
await _channel.invokeMethod('setCacheDir', {'path': dir.path}); await _channel.invokeMethod('setCacheDir', {'path': dir.path});
} }
} }

View File

@ -4,23 +4,30 @@ import 'package:flutter/services.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart'; import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
/// Wake word via OpenWakeWord service Android natif (foreground service). /// Wake word via OpenWakeWord service Android natif (foreground service).
/// Utilise ONNX Runtime pour l'inférence on-device du modèle hey_visit.onnx. /// Supporte plusieurs modèles en parallèle : un seul AudioRecord + pipeline
/// Survit au background Android contrairement au STT continu. /// melembedding 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 { class OpenWakeWordEngine implements WakeWordEngine {
static const _methodChannel = MethodChannel('be.unov.mymuseum/wake_word'); static const _methodChannel = MethodChannel('be.unov.mymuseum/wake_word');
static const _eventChannel = EventChannel('be.unov.mymuseum/wake_word_events'); static const _eventChannel = EventChannel('be.unov.mymuseum/wake_word_events');
/// Nom du fichier .tflite dans assets/files (sans extension). /// Un ou plusieurs modèles .tflite dans assets/files (sans extension).
/// Ex: "hey_visit", "hey_museum", "hey_guide" /// Ex: ['hey_marco'] ou ['hey_alba', 'hey_marco', 'hey_vasco']
final String modelName; final List<String> modelNames;
StreamSubscription<dynamic>? _subscription; StreamSubscription<dynamic>? _subscription;
void Function()? _onDetected; void Function()? _onDetected;
void Function(String modelName)? _onModelDetected;
bool _running = false; bool _running = false;
OpenWakeWordEngine({this.modelName = 'hey_visit'}); OpenWakeWordEngine({
List<String>? modelNames,
String? modelName,
}) : modelNames = modelNames ?? (modelName != null ? [modelName] : ['hey_visit']);
@override @override
Future<void> start({ Future<void> start({
@ -34,22 +41,29 @@ class OpenWakeWordEngine implements WakeWordEngine {
} }
_onDetected = onDetected; _onDetected = onDetected;
_onModelDetected = onDetectedWithCommand;
_running = true; _running = true;
_subscription = _eventChannel.receiveBroadcastStream().listen( _subscription = _eventChannel.receiveBroadcastStream().listen(
(event) { (event) {
if (event == 'detected' && _running) { if (!_running || event is! String) return;
debugPrint('[OpenWakeWordEngine] Wake word detected'); if (event.startsWith('detected:')) {
// OpenWakeWord ne capture pas la commande inline l'orchestrateur final name = event.substring('detected:'.length);
// lance un cycle STT séparé via onDetected() debugPrint('[OpenWakeWordEngine] Wake word detected: $name');
_onDetected?.call(); if (_onModelDetected != null) {
_onModelDetected!(name);
} else {
_onDetected?.call();
}
} }
}, },
onError: (e) => debugPrint('[OpenWakeWordEngine] Event error: $e'), onError: (e) => debugPrint('[OpenWakeWordEngine] Event error: $e'),
); );
await _methodChannel.invokeMethod('start', {'modelName': modelName}); await _methodChannel.invokeMethod('start', {
debugPrint('[OpenWakeWordEngine] Service started (model: $modelName)'); 'modelNames': modelNames.join(','),
});
debugPrint('[OpenWakeWordEngine] Service started (models: $modelNames)');
} }
@override @override
@ -58,9 +72,7 @@ class OpenWakeWordEngine implements WakeWordEngine {
_running = false; _running = false;
await _subscription?.cancel(); await _subscription?.cancel();
_subscription = null; _subscription = null;
if (_isAndroid) { if (_isAndroid) await _methodChannel.invokeMethod('stop');
await _methodChannel.invokeMethod('stop');
}
debugPrint('[OpenWakeWordEngine] Service stopped'); debugPrint('[OpenWakeWordEngine] Service stopped');
} }

View File

@ -4,8 +4,9 @@
/// - ClaudeClient (Anthropic API upgrade futur) /// - ClaudeClient (Anthropic API upgrade futur)
/// - OpenAiClient (upgrade futur) /// - OpenAiClient (upgrade futur)
abstract class LlmClient { abstract class LlmClient {
/// Envoie un message et retourne la réponse complète. /// Envoie un message et retourne la réponse avec un flag indiquant si le
Future<String> chat( /// visiteur est attendu à répondre (false = info pure, fin de conversation).
Future<({String reply, bool expectsReply})> chat(
String message, { String message, {
String? configurationId, String? configurationId,
String languageCode = 'FR', String languageCode = 'FR',

View File

@ -1,502 +1,6 @@
import 'dart:async'; // Renommé en VoiceOrchestrator ce fichier reste pour la compatibilité des imports existants.
import 'dart:io'; import 'package:mymuseum_visitapp/Services/Glasses/voice_orchestrator.dart';
import 'package:flutter/foundation.dart'; export 'voice_orchestrator.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. // Alias de compatibilité
/// Initialisée dans main.dart après la connexion lunettes. typedef GlassesOrchestrator = VoiceOrchestrator;
GlassesOrchestrator? activeOrchestrator;
/// Orchestre le pipeline complet mains-libres :
/// WakeWord STT dispatch LLM ou QR scan TTS
///
/// Toutes les dépendances sont injectées via les interfaces abstraites
/// pour pouvoir swapper chaque maillon indépendamment.
class GlassesOrchestrator {
final VisitAppContext visitAppContext;
final WakeWordEngine wakeWordEngine;
final SttEngine sttEngine;
final TtsEngine ttsEngine;
final LlmClient llmClient;
bool _running = false;
bool _inConversation = false;
/// Dernière transcription capturée observable depuis l'UI.
final ValueNotifier<String> lastTranscription = ValueNotifier('');
final ValueNotifier<bool> isListeningForCommand = ValueNotifier(false);
/// Dernier texte envoyé au TTS pour détecter les astérisques et autres artefacts.
final ValueNotifier<String> lastTtsText = ValueNotifier('');
// Photos de visite (V2 stockées pour un résumé en fin de visite)
final List<String> visitPhotos = [];
// Sons de feedback fichiers courts dans assets/sounds/
// wake_detected.mp3 : ~0.3s "je t'écoute"
// thinking.mp3 : ~0.5s "je réfléchis"
// done.mp3 : ~0.3s "réponse prête" (optionnel)
static const String _wakeSound = 'assets/sounds/wake_detected.mp3';
static const String _thinkingSound = 'assets/sounds/thinking.mp3';
static const String _doneSound = 'assets/sounds/done.mp3';
final AudioPlayer _soundPlayer = AudioPlayer(); // sons one-shot
final AudioPlayer _thinkingPlayer = AudioPlayer(); // thinking loop
// Anti-spam QR
final Map<String, int> _lastQrTime = {};
static const int _qrCooldownMs = 10000;
static final RegExp _urlPattern1 =
RegExp(r'https://web\.mymuseum\.be/([^/]+)/([^/]+)/([^/\s]+)');
static final RegExp _urlPattern2 =
RegExp(r'https://web\.myinfomate\.be/([^/]+)/([^/]+)/([^/\s]+)');
GlassesOrchestrator({
required this.visitAppContext,
required this.wakeWordEngine,
required this.sttEngine,
required this.ttsEngine,
required this.llmClient,
});
Future<void> start() async {
if (_running) return;
_running = true;
await wakeWordEngine.start(
onDetected: _onWakeWord,
onDetectedWithCommand: _onWakeWordWithCommand,
);
debugPrint('[GlassesOrchestrator] Started');
}
Future<void> stop() async {
await wakeWordEngine.stop();
await sttEngine.cancel();
await ttsEngine.stop();
_running = false;
debugPrint('[GlassesOrchestrator] Stopped');
}
bool get isRunning => _running;
bool get isInConversation => _inConversation;
bool get isListening => _running && !_inConversation;
/// Relance l'écoute wake word en utilisant les callbacks internes
/// (avec son de détection). À appeler depuis le lifecycle observer.
Future<void> restartWakeWord() async {
if (!_running || _inConversation) return;
await wakeWordEngine.start(
onDetected: _onWakeWord,
onDetectedWithCommand: _onWakeWordWithCommand,
);
}
/// Déclenchement manuel (ex: bouton debug, test)
Future<void> triggerConversation() => _handleConversation();
/// Dispatch direct d'une commande (utilisé par le lifecycle observer)
Future<void> dispatchCommand(String command) => _dispatch(command);
/// Déclenchement scan QR depuis un chemin d'image existant.
Future<void> triggerQrScan(String imagePath) async {
final qr = await _tryDecodeQr(imagePath);
if (qr != null) await explainSection(qr.sectionId, configurationId: qr.configId);
}
// Wake word
/// Déclenché quand le wake word est détecté sans commande inline.
/// Lance un cycle STT séparé pour capturer la commande.
void _onWakeWord() async {
if (_inConversation) return;
_inConversation = true;
await wakeWordEngine.stop(); // envoie ACTION_PAUSE immédiatement AudioRecord s'arrête pendant le son
await _stopThinkingLoop();
await _playWakeSound(); // ~300ms largement suffisant pour que l'AudioRecord soit libéré
try {
await _handleConversation();
} finally {
_inConversation = false;
if (_running) await wakeWordEngine.start(
onDetected: _onWakeWord,
onDetectedWithCommand: _onWakeWordWithCommand,
);
}
}
/// Déclenché quand le wake word ET la commande sont dans le même énoncé.
/// "visite qu'est-ce que c'est" commande = "qu'est-ce que c'est"
/// Si commande vide fallback sur cycle STT séparé.
void _onWakeWordWithCommand(String inlineCommand) async {
if (_inConversation) return;
_inConversation = true;
await wakeWordEngine.stop();
await _stopThinkingLoop();
await _playWakeSound();
try {
if (inlineCommand.isNotEmpty) {
debugPrint('[GlassesOrchestrator] Inline command: "$inlineCommand"');
await _dispatch(inlineCommand);
} else {
await _handleConversation();
}
} finally {
_inConversation = false;
if (_running) await wakeWordEngine.start(
onDetected: _onWakeWord,
onDetectedWithCommand: _onWakeWordWithCommand,
);
}
}
Future<void> _playSound(String asset) async {
try {
await _soundPlayer.setAsset(asset);
await _soundPlayer.play();
// play() se complète quand la lecture se termine
} catch (_) {
debugPrint('[GlassesOrchestrator] Sound not found: $asset');
}
}
Future<void> _playWakeSound() => _playSound(_wakeSound);
Future<void> _playDoneSound() => _playSound(_doneSound);
Future<void> _startThinkingLoop() async {
try {
await _thinkingPlayer.setAsset(_thinkingSound);
await _thinkingPlayer.setLoopMode(LoopMode.one);
_thinkingPlayer.play(); // pas de await tourne en arrière-plan
} catch (_) {
debugPrint('[GlassesOrchestrator] Thinking sound not found');
}
}
Future<void> _stopThinkingLoop() async {
await _thinkingPlayer.stop();
}
// Conversation vocale
Future<void> _handleConversation() async {
final lang = visitAppContext.language ?? 'FR';
final langCode = _toLangCode(lang);
isListeningForCommand.value = true;
final command = await sttEngine.transcribeOnce(languageCode: langCode);
isListeningForCommand.value = false;
debugPrint('[GlassesOrchestrator] Command: "$command"');
if (command.isEmpty) return;
lastTranscription.value = command;
await _dispatch(command);
}
Future<void> _dispatch(String command, {bool continueConversation = true}) async {
final lang = visitAppContext.language ?? 'FR';
final langCode = _toLangCode(lang);
if (_isStopCommand(command)) {
debugPrint('[GlassesOrchestrator] Annulé par l\'utilisateur: "$command"');
await _stopThinkingLoop();
// Pas de son pour l'annulation — évite une bascule audio focus supplémentaire
// qui déclenche le muting persistant MIUI
return;
}
if (_isQrScanCommand(command)) {
await _handleQrScan();
return;
}
if (_isPhotoCommand(command)) {
await _handlePhotoCapture();
return;
}
if (_isRepeatCommand(command)) {
await ttsEngine.replay();
} else {
// Question libre thinking loop LLM done TTS
try {
await _startThinkingLoop();
final reply = await llmClient.chat(
command,
configurationId: visitAppContext.configuration?.id,
languageCode: lang,
);
await _stopThinkingLoop();
if (reply.isNotEmpty) {
lastTtsText.value = reply;
await _playDoneSound();
await ttsEngine.speak(reply, languageCode: langCode);
}
} catch (e) {
await _stopThinkingLoop();
debugPrint('[GlassesOrchestrator] LLM error: $e');
return; // pas de follow-up si erreur
}
}
// Mode conversation : écoute directement la réponse sans redemander le wake word
if (continueConversation) {
await _listenForFollowUp();
}
}
/// Écoute une question de suivi après la réponse TTS.
/// Timeout = 5s de silence fin de conversation, retour au wake word.
Future<void> _listenForFollowUp() async {
final lang = visitAppContext.language ?? 'FR';
final langCode = _toLangCode(lang);
isListeningForCommand.value = true;
final followUp = await sttEngine.transcribeOnce(
languageCode: langCode,
timeout: const Duration(seconds: 5),
);
isListeningForCommand.value = false;
if (followUp.isNotEmpty) lastTranscription.value = followUp;
if (followUp.isEmpty || _isStopCommand(followUp)) {
debugPrint('[GlassesOrchestrator] Conversation ended (silence or stop)');
return;
}
debugPrint('[GlassesOrchestrator] Follow-up: "$followUp"');
await _dispatch(followUp, continueConversation: true);
}
// QR scan depuis frames du stream
/// Démarre le stream, prend jusqu'à 5 frames espacées de 400ms,
/// tente de décoder un QR sur chacune. Stoppe le stream après.
Future<void> _handleQrScan() async {
final lang = _toLangCode(visitAppContext.language ?? 'FR');
debugPrint('[QrScan] Starting — requesting photo capture...');
final completer = Completer<String?>();
final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
MetaGlassesService.instance.onPhotoCaptured = (path) {
debugPrint('[QrScan] Photo callback received — path="${path.isEmpty ? "EMPTY/ERROR" : path}"');
if (!completer.isCompleted) completer.complete(path.isEmpty ? null : path);
prevCallback?.call(path);
};
Timer(const Duration(seconds: 10), () {
if (!completer.isCompleted) {
debugPrint('[QrScan] Timeout — no photo received after 10s');
completer.complete(null);
}
});
await MetaGlassesService.instance.requestPhotoCapture();
debugPrint('[QrScan] requestPhotoCapture() returned — waiting for callback...');
final photoPath = await completer.future;
MetaGlassesService.instance.onPhotoCaptured = prevCallback;
if (photoPath == null) {
debugPrint('[QrScan] No photo — camera unavailable or error');
lastTtsText.value = TranslationHelper.getFromLocale('voice.cameraUnavailable', visitAppContext);
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
return;
}
debugPrint('[QrScan] Photo received at $photoPath — decoding QR...');
final qr = await _tryDecodeQr(photoPath);
try { File(photoPath).deleteSync(); } catch (_) {}
if (qr != null) {
debugPrint('[QrScan] QR found — sectionId=${qr.sectionId} configId=${qr.configId}');
await explainSection(qr.sectionId, configurationId: qr.configId);
} else {
debugPrint('[QrScan] No QR code found in photo');
lastTtsText.value = TranslationHelper.getFromLocale('voice.noQrFound', visitAppContext);
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
}
}
// Photo capture : mémoire visite
/// Capture une photo, essaie de décoder un QR code.
/// Si QR valide explique la section.
/// Si pas de QR sauvegarde la photo pour la visite (V2).
Future<void> _handlePhotoCapture() async {
final completer = Completer<String?>();
// Écoute la photo quand elle arrive
final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
MetaGlassesService.instance.onPhotoCaptured = (path) {
if (!completer.isCompleted) completer.complete(path);
prevCallback?.call(path);
};
// Timeout si pas de photo en 10s
Timer(const Duration(seconds: 10), () {
if (!completer.isCompleted) completer.complete(null);
});
await MetaGlassesService.instance.requestPhotoCapture();
final photoPath = await completer.future;
// Restaure le callback précédent
MetaGlassesService.instance.onPhotoCaptured = prevCallback;
if (photoPath == null || photoPath.isEmpty) {
debugPrint('[GlassesOrchestrator] Photo capture failed or timeout');
final lang = _toLangCode(visitAppContext.language ?? 'FR');
lastTtsText.value = TranslationHelper.getFromLocale('voice.photoFailed', visitAppContext);
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
return;
}
// Essaie de décoder un QR code
final qr = await _tryDecodeQr(photoPath);
if (qr != null) {
await explainSection(qr.sectionId, configurationId: qr.configId);
} else {
// Pas de QR sauvegarde pour la visite
visitPhotos.add(photoPath);
debugPrint('[GlassesOrchestrator] Photo saved for visit (no QR): $photoPath');
// V2 : résumé en fin de visite, identification d'œuvre, etc.
// Pour l'instant : feedback vocal simple
final lang = _toLangCode(visitAppContext.language ?? 'FR');
lastTtsText.value = TranslationHelper.getFromLocale('voice.photoCaptured', visitAppContext);
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
}
}
/// Essaie de décoder un QR code depuis l'image.
/// Retourne le sectionId si trouvé, null sinon.
Future<({String sectionId, String? configId})?> _tryDecodeQr(String imagePath) async {
debugPrint('[QrScan] analyzeImage: $imagePath');
final controller = MobileScannerController();
({String sectionId, String? configId})? result;
try {
final completer = Completer<({String sectionId, String? configId})?>();
final sub = controller.barcodes.listen((capture) {
debugPrint('[QrScan] barcodes detected: ${capture.barcodes.length}');
for (final barcode in capture.barcodes) {
final raw = barcode.rawValue;
debugPrint('[QrScan] raw value: "$raw"');
if (raw != null) {
final ids = _extractQrIds(raw);
debugPrint('[QrScan] extracted: sectionId=${ids?.sectionId} configId=${ids?.configId}');
if (ids != null && !completer.isCompleted) completer.complete(ids);
}
}
});
await controller.analyzeImage(imagePath);
Timer(const Duration(seconds: 2), () {
if (!completer.isCompleted) {
debugPrint('[QrScan] analyzeImage timeout — no barcode found');
completer.complete(null);
}
});
result = await completer.future;
await sub.cancel();
} catch (e) {
debugPrint('[QrScan] decode error: $e');
} finally {
controller.dispose();
}
debugPrint('[QrScan] result: ${result != null ? "found sectionId=${result.sectionId}" : "null"}');
return result;
}
/// Retourne (sectionId, configId) extraits du QR.
/// configId peut être null si le QR est un ID brut sans URL.
({String sectionId, String? configId})? _extractQrIds(String raw) {
final m1 = _urlPattern1.firstMatch(raw);
if (m1 != null) return (sectionId: m1.group(3)!, configId: m1.group(2));
final m2 = _urlPattern2.firstMatch(raw);
if (m2 != null) return (sectionId: m2.group(3)!, configId: m2.group(2));
// ID brut valider contre la config si disponible
if (visitAppContext.sectionIds != null) {
return visitAppContext.sectionIds!.contains(raw)
? (sectionId: raw, configId: visitAppContext.configuration?.id)
: null;
}
// Pas de config chargée accepter l'ID brut sans configId
return (sectionId: raw, configId: null);
}
Future<void> explainSection(String sectionId, {String? configurationId}) async {
final now = DateTime.now().millisecondsSinceEpoch;
if ((now - (_lastQrTime[sectionId] ?? 0)) < _qrCooldownMs) return;
_lastQrTime[sectionId] = now;
// Priorité : configId passé explicitement (extrait du QR URL) > config active > null (mode instance)
final cfgId = configurationId ?? visitAppContext.configuration?.id;
final lang = visitAppContext.language ?? 'FR';
try {
final reply = await llmClient.chat(
'Le visiteur vient de scanner le QR code de la section "$sectionId". '
'Appelle GetSectionDetail avec cet ID, puis présente le contenu de façon engageante en 2-3 phrases. '
'Utilise les informations réelles du champ Contenu — cite des détails concrets, pas de généralités.',
configurationId: cfgId,
languageCode: lang,
);
if (reply.isNotEmpty) {
lastTtsText.value = reply;
await ttsEngine.speak(reply, languageCode: _toLangCode(lang));
}
} catch (e) {
debugPrint('[GlassesOrchestrator] explainSection error: $e');
}
}
// Helpers
bool _isStopCommand(String text) {
final t = text.toLowerCase().trim();
return t == 'non' ||
t == 'rien' ||
t == 'non rien' ||
t == 'rien merci' ||
t == 'non merci' ||
t == 'laisse tomber' ||
t == 'annule' ||
t == 'annuler' ||
t.contains('stop') ||
t.contains('arrête') ||
t.contains('au revoir') ||
t.contains('c\'est bon') ||
t.contains('ok merci') ||
t.contains('laisse tomber') ||
(t.length < 10 && (t.contains('non') || t.contains('rien')));
}
bool _isQrScanCommand(String text) {
final t = text.toLowerCase();
return t.contains('scan') || t.contains('qr') || t.contains('code') ||
t.contains('regarde');
}
bool _isPhotoCommand(String text) {
final t = text.toLowerCase();
return t.contains('photo') || t.contains('prends') || t.contains('capture');
}
bool _isRepeatCommand(String text) {
final t = text.toLowerCase();
return t.contains('répète') || t.contains('repete') || t.contains('encore');
}
String _toLangCode(String lang) {
switch (lang.toUpperCase()) {
case 'FR': return 'fr-FR';
case 'NL': return 'nl-NL';
case 'EN': return 'en-US';
case 'DE': return 'de-DE';
default: return 'fr-FR';
}
}
}

View File

@ -0,0 +1,408 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:just_audio/just_audio.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/llm_client.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/stt_engine.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/tts_engine.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
/// Instance active de l'orchestrateur, accessible globalement.
/// Gérée par VoiceController ne pas modifier directement.
VoiceOrchestrator? activeVoiceOrchestrator;
/// Orchestre le pipeline complet mains-libres :
/// WakeWord STT dispatch LLM ou QR scan TTS
///
/// Toutes les dépendances sont injectées via les interfaces abstraites
/// pour pouvoir swapper chaque maillon indépendamment.
class VoiceOrchestrator {
final VisitAppContext visitAppContext;
final WakeWordEngine wakeWordEngine;
final SttEngine sttEngine;
final TtsEngine ttsEngine;
final LlmClient llmClient;
bool _running = false;
bool _inConversation = false;
final ValueNotifier<String> lastTranscription = ValueNotifier('');
final ValueNotifier<bool> isListeningForCommand = ValueNotifier(false);
final ValueNotifier<String> lastTtsText = ValueNotifier('');
final ValueNotifier<List<String>> visitPhotosNotifier = ValueNotifier([]);
final ValueNotifier<String?> lastQrScanPhoto = ValueNotifier(null);
final List<String> visitPhotos = [];
static const String _wakeSound = 'assets/sounds/wake_detected.mp3';
static const String _thinkingSound = 'assets/sounds/thinking.mp3';
static const String _doneSound = 'assets/sounds/done.mp3';
final AudioPlayer _soundPlayer = AudioPlayer();
final AudioPlayer _thinkingPlayer = AudioPlayer();
final Map<String, int> _lastQrTime = {};
static const int _qrCooldownMs = 10000;
static final RegExp _urlPattern1 =
RegExp(r'https://web\.mymuseum\.be/([^/]+)/([^/]+)/([^/\s]+)');
static final RegExp _urlPattern2 =
RegExp(r'https://web\.myinfomate\.be/([^/]+)/([^/]+)/([^/\s]+)');
VoiceOrchestrator({
required this.visitAppContext,
required this.wakeWordEngine,
required this.sttEngine,
required this.ttsEngine,
required this.llmClient,
});
Future<void> start() async {
if (_running) return;
_running = true;
await wakeWordEngine.start(
onDetected: _onWakeWord,
onDetectedWithCommand: _onWakeWordWithCommand,
);
debugPrint('[VoiceOrchestrator] Started');
}
Future<void> stop() async {
await wakeWordEngine.stop();
await sttEngine.cancel();
await ttsEngine.stop();
_running = false;
debugPrint('[VoiceOrchestrator] Stopped');
}
bool get isRunning => _running;
bool get isInConversation => _inConversation;
bool get isListening => _running && !_inConversation;
Future<void> restartWakeWord() async {
if (!_running || _inConversation) return;
await wakeWordEngine.start(
onDetected: _onWakeWord,
onDetectedWithCommand: _onWakeWordWithCommand,
);
}
Future<void> triggerConversation() => _handleConversation();
Future<void> dispatchCommand(String command) => _dispatch(command);
Future<void> triggerQrScan(String imagePath) async {
final qr = await _tryDecodeQr(imagePath);
if (qr != null) await explainSection(qr.sectionId, configurationId: qr.configId);
}
// Wake word
void _onWakeWord() async {
if (_inConversation) return;
_inConversation = true;
await wakeWordEngine.stop();
await _stopThinkingLoop();
_playWakeSound();
await Future.delayed(const Duration(milliseconds: 200));
try {
await _handleConversation();
} finally {
_inConversation = false;
if (_running) await wakeWordEngine.start(
onDetected: _onWakeWord,
onDetectedWithCommand: _onWakeWordWithCommand,
);
}
}
void _onWakeWordWithCommand(String inlineCommand) async {
if (_inConversation) return;
_inConversation = true;
await wakeWordEngine.stop();
await _stopThinkingLoop();
_playWakeSound();
await Future.delayed(const Duration(milliseconds: 200));
try {
if (inlineCommand.isNotEmpty) {
debugPrint('[VoiceOrchestrator] Inline command: "$inlineCommand"');
await _dispatch(inlineCommand);
} else {
await _handleConversation();
}
} finally {
_inConversation = false;
if (_running) await wakeWordEngine.start(
onDetected: _onWakeWord,
onDetectedWithCommand: _onWakeWordWithCommand,
);
}
}
Future<void> _playSound(String asset) async {
try {
await _soundPlayer.setAsset(asset);
await _soundPlayer.play();
} catch (_) {
debugPrint('[VoiceOrchestrator] Sound not found: $asset');
}
}
Future<void> _playWakeSound() => _playSound(_wakeSound);
Future<void> _playDoneSound() => _playSound(_doneSound);
Future<void> _startThinkingLoop() async {
try {
await _thinkingPlayer.setAsset(_thinkingSound);
await _thinkingPlayer.setLoopMode(LoopMode.one);
_thinkingPlayer.play();
} catch (_) {
debugPrint('[VoiceOrchestrator] Thinking sound not found');
}
}
Future<void> _stopThinkingLoop() async {
await _thinkingPlayer.stop();
}
// Conversation vocale
Future<void> _handleConversation() async {
final lang = visitAppContext.language ?? 'FR';
final langCode = _toLangCode(lang);
isListeningForCommand.value = true;
final command = await sttEngine.transcribeOnce(languageCode: langCode);
isListeningForCommand.value = false;
debugPrint('[VoiceOrchestrator] Command: "$command"');
if (command.isEmpty) return;
lastTranscription.value = command;
await _dispatch(command);
}
Future<void> _dispatch(String command, {bool continueConversation = true}) async {
final lang = visitAppContext.language ?? 'FR';
final langCode = _toLangCode(lang);
if (_isStopCommand(command)) {
debugPrint('[VoiceOrchestrator] Annulé: "$command"');
await _stopThinkingLoop();
return;
}
if (_isQrScanCommand(command)) { await _handleQrScan(); return; }
if (_isPhotoCommand(command)) { await _handlePhotoCapture(); return; }
if (_isRepeatCommand(command)) {
await ttsEngine.replay();
} else {
bool expectsReply = true;
try {
await _startThinkingLoop();
final result = await llmClient.chat(
command,
configurationId: visitAppContext.configuration?.id,
languageCode: lang,
);
expectsReply = result.expectsReply;
await _stopThinkingLoop();
if (result.reply.isNotEmpty) {
lastTtsText.value = result.reply;
await _playDoneSound();
await ttsEngine.speak(result.reply, languageCode: langCode);
}
} catch (e) {
await _stopThinkingLoop();
debugPrint('[VoiceOrchestrator] LLM error: $e');
return;
}
if (!expectsReply) return;
}
if (continueConversation) await _listenForFollowUp();
}
Future<void> _listenForFollowUp() async {
final lang = visitAppContext.language ?? 'FR';
final langCode = _toLangCode(lang);
isListeningForCommand.value = true;
final followUp = await sttEngine.transcribeOnce(
languageCode: langCode,
timeout: const Duration(seconds: 5),
);
isListeningForCommand.value = false;
if (followUp.isNotEmpty) lastTranscription.value = followUp;
if (followUp.isEmpty || _isStopCommand(followUp)) return;
await _dispatch(followUp, continueConversation: true);
}
// QR scan
Future<void> _handleQrScan() async {
final lang = _toLangCode(visitAppContext.language ?? 'FR');
final completer = Completer<String?>();
final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
MetaGlassesService.instance.onPhotoCaptured = (path) {
if (!completer.isCompleted) completer.complete(path.isEmpty ? null : path);
prevCallback?.call(path);
};
Timer(const Duration(seconds: 25), () {
if (!completer.isCompleted) completer.complete(null);
});
unawaited(MetaGlassesService.instance.requestPhotoCapture());
final photoPath = await completer.future;
MetaGlassesService.instance.onPhotoCaptured = prevCallback;
if (photoPath == null) {
lastTtsText.value = TranslationHelper.getFromLocale('voice.cameraUnavailable', visitAppContext);
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
return;
}
if (lastQrScanPhoto.value != null && lastQrScanPhoto.value != photoPath) {
try { File(lastQrScanPhoto.value!).deleteSync(); } catch (_) {}
}
lastQrScanPhoto.value = photoPath;
final qr = await _tryDecodeQr(photoPath);
if (qr != null) {
await explainSection(qr.sectionId, configurationId: qr.configId);
} else {
lastTtsText.value = TranslationHelper.getFromLocale('voice.noQrFound', visitAppContext);
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
}
}
Future<void> _handlePhotoCapture() async {
final completer = Completer<String?>();
final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
MetaGlassesService.instance.onPhotoCaptured = (path) {
if (!completer.isCompleted) completer.complete(path);
prevCallback?.call(path);
};
Timer(const Duration(seconds: 25), () {
if (!completer.isCompleted) completer.complete(null);
});
unawaited(MetaGlassesService.instance.requestPhotoCapture());
final photoPath = await completer.future;
MetaGlassesService.instance.onPhotoCaptured = prevCallback;
if (photoPath == null || photoPath.isEmpty) {
final lang = _toLangCode(visitAppContext.language ?? 'FR');
lastTtsText.value = TranslationHelper.getFromLocale('voice.photoFailed', visitAppContext);
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
return;
}
final qr = await _tryDecodeQr(photoPath);
if (qr != null) {
await explainSection(qr.sectionId, configurationId: qr.configId);
} else {
visitPhotos.add(photoPath);
visitPhotosNotifier.value = List.unmodifiable(visitPhotos);
final lang = _toLangCode(visitAppContext.language ?? 'FR');
lastTtsText.value = TranslationHelper.getFromLocale('voice.photoCaptured', visitAppContext);
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
}
}
Future<({String sectionId, String? configId})?> _tryDecodeQr(String imagePath) async {
final controller = MobileScannerController();
({String sectionId, String? configId})? result;
try {
final completer = Completer<({String sectionId, String? configId})?>();
final sub = controller.barcodes.listen((capture) {
for (final barcode in capture.barcodes) {
final raw = barcode.rawValue;
if (raw != null) {
final ids = _extractQrIds(raw);
if (ids != null && !completer.isCompleted) completer.complete(ids);
}
}
});
await controller.analyzeImage(imagePath);
Timer(const Duration(seconds: 2), () {
if (!completer.isCompleted) completer.complete(null);
});
result = await completer.future;
await sub.cancel();
} catch (e) {
debugPrint('[VoiceOrchestrator] QR decode error: $e');
} finally {
controller.dispose();
}
return result;
}
({String sectionId, String? configId})? _extractQrIds(String raw) {
final m1 = _urlPattern1.firstMatch(raw);
if (m1 != null) return (sectionId: m1.group(3)!, configId: m1.group(2));
final m2 = _urlPattern2.firstMatch(raw);
if (m2 != null) return (sectionId: m2.group(3)!, configId: m2.group(2));
if (visitAppContext.sectionIds != null) {
return visitAppContext.sectionIds!.contains(raw)
? (sectionId: raw, configId: visitAppContext.configuration?.id)
: null;
}
return (sectionId: raw, configId: null);
}
Future<void> explainSection(String sectionId, {String? configurationId}) async {
final now = DateTime.now().millisecondsSinceEpoch;
if ((now - (_lastQrTime[sectionId] ?? 0)) < _qrCooldownMs) return;
_lastQrTime[sectionId] = now;
final cfgId = configurationId ?? visitAppContext.configuration?.id;
final lang = visitAppContext.language ?? 'FR';
try {
final result = await llmClient.chat(
'Le visiteur vient de scanner le QR code de la section "$sectionId". '
'Appelle GetSectionDetail avec cet ID, puis présente le contenu de façon engageante en 2-3 phrases.',
configurationId: cfgId,
languageCode: lang,
);
if (result.reply.isNotEmpty) {
lastTtsText.value = result.reply;
await ttsEngine.speak(result.reply, languageCode: _toLangCode(lang));
}
} catch (e) {
debugPrint('[VoiceOrchestrator] explainSection error: $e');
}
}
// Helpers
bool _isStopCommand(String text) {
final t = text.toLowerCase().trim();
return t == 'non' || t == 'rien' || t == 'non rien' || t == 'rien merci' ||
t == 'non merci' || t == 'laisse tomber' || t == 'annule' || t == 'annuler' ||
t.contains('stop') || t.contains('arrête') || t.contains('au revoir') ||
t.contains('c\'est bon') || t.contains('ok merci') || t.contains('laisse tomber') ||
(t.length < 10 && (t.contains('non') || t.contains('rien')));
}
bool _isQrScanCommand(String text) {
final t = text.toLowerCase();
return t.contains('scan') || t.contains('qr') || t.contains('code') || t.contains('regarde');
}
bool _isPhotoCommand(String text) {
final t = text.toLowerCase();
return t.contains('photo') || t.contains('prends') || t.contains('capture');
}
bool _isRepeatCommand(String text) {
final t = text.toLowerCase();
return t.contains('répète') || t.contains('repete') || t.contains('encore');
}
String _toLangCode(String lang) {
switch (lang.toUpperCase()) {
case 'FR': return 'fr-FR';
case 'NL': return 'nl-NL';
case 'EN': return 'en-US';
case 'DE': return 'de-DE';
default: return 'fr-FR';
}
}
}

View File

@ -1,17 +1,33 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:manager_api_new/api.dart'; import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Models/AssistantResponse.dart'; import 'package:mymuseum_visitapp/Models/AssistantResponse.dart';
class AssistantService { class AssistantService {
final VisitAppContext visitAppContext; final VisitAppContext visitAppContext;
final List<AiChatMessage> history = [];
AssistantService({required this.visitAppContext}); /// Nombre maximum de messages conservés dans l'historique envoyé au backend.
final int maxHistory;
/// Durée d'inactivité après laquelle l'historique est automatiquement vidé.
/// null = pas de vidage automatique.
final Duration? inactivityTimeout;
final List<AiChatMessage> _history = [];
Timer? _inactivityTimer;
AssistantService({
required this.visitAppContext,
this.maxHistory = 10,
this.inactivityTimeout = const Duration(minutes: 5),
});
Future<AssistantResponse> chat({ Future<AssistantResponse> chat({
required String message, required String message,
String? configurationId, String? configurationId,
}) => chatWithAppType(message: message, configurationId: configurationId); bool isVoice = false,
}) => chatWithAppType(message: message, configurationId: configurationId, isVoice: isVoice);
Future<AssistantResponse> chatWithAppType({ Future<AssistantResponse> chatWithAppType({
required String message, required String message,
@ -19,13 +35,15 @@ class AssistantService {
AppType appType = AppType.Mobile, AppType appType = AppType.Mobile,
bool isVoice = false, bool isVoice = false,
}) async { }) async {
_resetInactivityTimer();
final request = AiChatRequest( final request = AiChatRequest(
message: message, message: message,
instanceId: visitAppContext.instanceId, instanceId: visitAppContext.instanceId,
appType: appType, appType: appType,
configurationId: configurationId, configurationId: configurationId,
language: visitAppContext.language?.toUpperCase() ?? 'FR', language: visitAppContext.language?.toUpperCase() ?? 'FR',
history: List.from(history), history: List.from(_history),
isVoice: isVoice, isVoice: isVoice,
); );
@ -35,7 +53,7 @@ class AssistantService {
throw Exception('Empty response from assistant'); 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( final result = AssistantResponse(
reply: response.reply ?? '', reply: response.reply ?? '',
@ -54,12 +72,35 @@ class AssistantService {
imageUrl: response.navigation!.imageUrl, imageUrl: response.navigation!.imageUrl,
) )
: null, : null,
expectsReply: response.expectsReply ?? true,
); );
history.add(AiChatMessage(role: 'user', content: message)); _history.add(AiChatMessage(role: 'user', content: message));
history.add(AiChatMessage(role: 'assistant', content: result.reply)); _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; return result;
} }
void clearHistory() => history.clear(); void _resetInactivityTimer() {
if (inactivityTimeout == null) return;
_inactivityTimer?.cancel();
_inactivityTimer = Timer(inactivityTimeout!, () {
debugPrint('[AssistantService] Inactivity timeout — clearing history');
clearHistory();
});
}
void clearHistory() {
_history.clear();
_inactivityTimer?.cancel();
}
void dispose() {
_inactivityTimer?.cancel();
}
} }

View File

@ -203,11 +203,12 @@ class GeoBeaconTriggerService {
final response = await _assistantService!.chat( final response = await _assistantService!.chat(
message: prompt, message: prompt,
configurationId: _visitAppContext?.configuration?.id, configurationId: _visitAppContext?.configuration?.id,
isVoice: true,
); );
if (response.reply.isNotEmpty) { if (response.reply.isNotEmpty) {
final lang = _visitAppContext?.language ?? 'FR'; final lang = _visitAppContext?.language ?? 'FR';
await activeOrchestrator?.ttsEngine.speak( await activeVoiceOrchestrator?.ttsEngine.speak(
response.reply, response.reply,
languageCode: _toLangCode(lang), languageCode: _toLangCode(lang),
); );

View File

@ -0,0 +1,119 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/flutter_tts_engine.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/gemini_tts_engine.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/myinfomate_llm_client.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/native_wake_word_engine.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_stt.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_wake_word.dart';
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/whisper_stt_engine.dart';
import 'package:mymuseum_visitapp/Services/Glasses/glasses_background_service.dart';
import 'package:mymuseum_visitapp/Services/Glasses/voice_orchestrator.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/constants.dart';
enum VoiceMode { none, voiceOnly, glasses }
class VoiceController extends ChangeNotifier {
VoiceMode _mode = VoiceMode.none;
VoiceMode get mode => _mode;
bool _isActivating = false;
bool get isActivating => _isActivating;
VoiceController() {
MetaGlassesService.instance.state.addListener(_onGlassesStateChanged);
}
bool isFeatureAvailable(VisitAppContext ctx) =>
ctx.applicationInstanceDTO?.isAssistant == true;
bool get isGlassesConnected =>
MetaGlassesService.instance.state.value == GlassesState.connected ||
MetaGlassesService.instance.state.value == GlassesState.streaming;
/// Active le mode demandé. Si un mode est déjà actif, le stoppe d'abord.
Future<void> activate(VoiceMode targetMode, VisitAppContext ctx) async {
if (_isActivating) return;
_isActivating = true;
notifyListeners();
try {
await _stopOrchestrator();
if (targetMode == VoiceMode.glasses) {
await GlassesBackgroundService.initialize();
await GlassesBackgroundService.start();
await MetaGlassesService.instance.connect();
}
_buildOrchestrator(ctx);
await activeVoiceOrchestrator!.start();
_mode = targetMode;
} catch (e) {
debugPrint('[VoiceController] activate error: $e');
await _stopOrchestrator();
_mode = VoiceMode.none;
} finally {
_isActivating = false;
notifyListeners();
}
}
Future<void> deactivate() async {
await _stopOrchestrator();
_mode = VoiceMode.none;
notifyListeners();
}
/// Relance le wake word après un retour en foreground.
void restartWakeWordIfNeeded() {
final o = activeVoiceOrchestrator;
if (o != null && o.isRunning && !o.isInConversation) {
o.restartWakeWord();
}
}
Future<void> _stopOrchestrator() async {
if (activeVoiceOrchestrator != null) {
await activeVoiceOrchestrator!.stop();
activeVoiceOrchestrator = null;
}
}
void _buildOrchestrator(VisitAppContext ctx) {
activeVoiceOrchestrator = VoiceOrchestrator(
visitAppContext: ctx,
wakeWordEngine: Platform.isAndroid
? NativeWakeWordEngine(modelNames: ['hey_marco', 'hey_alba', 'hey_vasco'])
: SpeechToTextWakeWordEngine(keyword: 'visite'),
sttEngine: kWhisperApiKey.isNotEmpty
? WhisperSttEngine(apiKey: kWhisperApiKey, endpoint: kWhisperEndpoint)
: SpeechToTextSttEngine(),
ttsEngine: kGeminiApiKey.isNotEmpty
? GeminiTtsEngine(
apiKey: kGeminiApiKey,
voiceName: kGeminiTtsVoice,
voicePrompt: kGeminiTtsPrompt,
)
: FlutterTtsEngine(),
llmClient: MyInfoMateLlmClient(visitAppContext: ctx),
);
}
void _onGlassesStateChanged() {
if (_mode == VoiceMode.glasses &&
MetaGlassesService.instance.state.value == GlassesState.disconnected) {
debugPrint('[VoiceController] Glasses disconnected — deactivating');
deactivate();
}
}
@override
void dispose() {
MetaGlassesService.instance.state.removeListener(_onGlassesStateChanged);
_stopOrchestrator();
super.dispose();
}
}

View File

@ -125,6 +125,7 @@ class WakeWordService {
final response = await _assistantService!.chat( final response = await _assistantService!.chat(
message: command, message: command,
configurationId: _assistantService!.visitAppContext.configuration?.id, configurationId: _assistantService!.visitAppContext.configuration?.id,
isVoice: true,
); );
if (response.reply.isNotEmpty) { if (response.reply.isNotEmpty) {
await GlassesTtsService.instance.speak( await GlassesTtsService.instance.speak(
@ -133,6 +134,9 @@ class WakeWordService {
voiceId: kElevenLabsVoiceId, voiceId: kElevenLabsVoiceId,
); );
} }
if (!response.expectsReply) {
debugPrint('[WakeWordService] No follow-up expected, conversation ends here.');
}
} catch (e) { } catch (e) {
debugPrint('[WakeWordService] dispatch error: $e'); debugPrint('[WakeWordService] dispatch error: $e');
} }

View File

@ -46,6 +46,9 @@ class Client {
SectionEventApi? _sectionEventApi; SectionEventApi? _sectionEventApi;
SectionEventApi? get sectionEventApi => _sectionEventApi; SectionEventApi? get sectionEventApi => _sectionEventApi;
SectionParcoursApi? _sectionParcoursApi;
SectionParcoursApi? get sectionParcoursApi => _sectionParcoursApi;
Client(String path, {String? apiKey}) { Client(String path, {String? apiKey}) {
_apiClient = ApiClient(basePath: path); _apiClient = ApiClient(basePath: path);
if (apiKey != null) _apiClient!.addDefaultHeader('X-Api-Key', apiKey); if (apiKey != null) _apiClient!.addDefaultHeader('X-Api-Key', apiKey);
@ -62,5 +65,6 @@ class Client {
_sectionAgendaApi = SectionAgendaApi(_apiClient); _sectionAgendaApi = SectionAgendaApi(_apiClient);
_sectionMapApi = SectionMapApi(_apiClient); _sectionMapApi = SectionMapApi(_apiClient);
_sectionEventApi = SectionEventApi(_apiClient); _sectionEventApi = SectionEventApi(_apiClient);
_sectionParcoursApi = SectionParcoursApi(_apiClient);
} }
} }

View File

@ -11,17 +11,9 @@ import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart';
import 'package:mymuseum_visitapp/Models/articleRead.dart'; import 'package:mymuseum_visitapp/Models/articleRead.dart';
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart'; import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
import 'package:mymuseum_visitapp/Screens/splash_screen.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/voice_orchestrator.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/meta_glasses_service.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/Services/pushNotificationService.dart';
import 'package:mymuseum_visitapp/l10n/app_localizations.dart'; import 'package:mymuseum_visitapp/l10n/app_localizations.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -114,9 +106,8 @@ class _AppBootstrapState extends State<AppBootstrap> {
localContext.localPath = localPath; localContext.localPath = localPath;
print("Local path $localPath"); print("Local path $localPath");
// Glasses init initialize only here, startSession() est déclenché dans _MyAppState // Glasses SDK init toujours initialisé pour détecter la connexion BT.
// TODO: remplacer glassesEnabled par un vrai toggle UI // L'orchestrateur vocal ne démarre PAS automatiquement — c'est VoiceController qui gère ça.
localContext.glassesEnabled = true;
if (!Platform.isWindows && (Platform.isAndroid || Platform.isIOS)) { if (!Platform.isWindows && (Platform.isAndroid || Platform.isIOS)) {
await MetaGlassesService.instance.initialize(); await MetaGlassesService.instance.initialize();
} }
@ -156,49 +147,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
if (widget.visitAppContext.glassesEnabled && // Le mode vocal ne démarre plus automatiquement l'utilisateur choisit via VoiceModeSheet.
!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();
});
}
} }
@override @override
@ -207,14 +156,10 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
super.dispose(); 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 @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
final o = activeOrchestrator; final o = activeVoiceOrchestrator;
// Relance uniquement si l'orchestrateur tourne ET qu'aucune conversation
// n'est en cours (sinon on interromprait une question/réponse active)
if (o != null && o.isRunning && !o.isInConversation) { if (o != null && o.isRunning && !o.isInConversation) {
o.restartWakeWord(); o.restartWakeWord();
} }
@ -225,8 +170,15 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
Widget build(BuildContext context) { Widget build(BuildContext context) {
Get.put(RequirementStateController()); Get.put(RequirementStateController());
return ChangeNotifierProvider<AppContext>( return MultiProvider(
create: (_) => AppContext(widget.visitAppContext), providers: [
ChangeNotifierProvider<AppContext>(
create: (_) => AppContext(widget.visitAppContext),
),
ChangeNotifierProvider<VoiceController>(
create: (_) => VoiceController(),
),
],
child: MaterialApp( child: MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: 'Carnaval de Marche', //'Musée de la fraise' // Autres // 'Fort Saint Héribert' title: 'Carnaval de Marche', //'Musée de la fraise' // Autres // 'Fort Saint Héribert'

View File

@ -579,14 +579,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.30.0" 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: flutter_svg:
dependency: transitive dependency: transitive
description: description:
@ -1060,6 +1052,13 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.1" version: "4.0.1"
myinfomate_layout:
dependency: "direct main"
description:
path: "../manager-app/myinfomate_layout"
relative: true
source: path
version: "1.0.0"
nested: nested:
dependency: transitive dependency: transitive
description: description:

View File

@ -62,7 +62,6 @@ dependencies:
sqflite: #not in web sqflite: #not in web
just_audio_cache: ^0.1.2 #not in web just_audio_cache: ^0.1.2 #not in web
beacon_scanner: ^0.0.4 #not in web beacon_scanner: ^0.0.4 #not in web
flutter_staggered_grid_view: ^0.7.0
smooth_page_indicator: ^1.2.1 smooth_page_indicator: ^1.2.1
@ -91,6 +90,8 @@ dependencies:
manager_api_new: manager_api_new:
path: ../manager-app/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. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2

File diff suppressed because one or more lines are too long

BIN
wakeword/hey_marco_v2.onnx Normal file

Binary file not shown.

Binary file not shown.

BIN
wakeword/hey_vasco.onnx Normal file

Binary file not shown.

BIN
wakeword/hey_vasco.tflite Normal file

Binary file not shown.

BIN
wakeword/hey_visit.onnx Normal file

Binary file not shown.

BIN
wakeword/hey_visit.tflite Normal file

Binary file not shown.

BIN
wakeword/hey_viva.onnx Normal file

Binary file not shown.

BIN
wakeword/hey_viva.tflite Normal file

Binary file not shown.

View File

@ -0,0 +1,269 @@
{
"nbformat": 4,
"nbformat_minor": 0,
"metadata": {
"colab": {
"provenance": [],
"name": "wakeword_training_template.ipynb",
"gpuType": "T4"
},
"accelerator": "GPU",
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Wake Word Training\n",
"\n",
"Basé sur `Automatic_model_training_simple_TFR`. \n",
"Génération multi-langue : EN/EN-GB/DE/NL/IT/ES + FR (phonétique dédiée).\n",
"\n",
"**Runtime recommandé : GPU (T4)** — Durée estimée : ~45-90 min"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from google.colab import drive\n",
"drive.mount('/content/drive')\n",
"\n",
"import os\n",
"drive_output_dir = '/content/drive/My Drive/Colab Notebooks/Wakeword/openwakeword'\n",
"os.makedirs(drive_output_dir, exist_ok=True)\n",
"print(f'Models will be saved in: {drive_output_dir}')"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": "# @title Configuration globale { display-mode: \"form\" }\n# @markdown ### Wake word\n# @markdown `PHRASE_DEFAULT` : prononciation EN (utilise _ entre les mots)\n# @markdown `PHRASE_FR` : prononciation FR (avec accents si besoin)\n\nPHRASE_DEFAULT = 'hey_marco' # @param {type:\"string\"}\nPHRASE_FR = 'hé marco' # @param {type:\"string\"}\n\n# @markdown ---\n# @markdown ### Paramètres d'entraînement\n\nnumber_of_examples = 10000 # @param {type:\"slider\", min:1000, max:50000, step:500}\nnumber_of_training_steps = 25000 # @param {type:\"slider\", min:1000, max:50000, step:500}\nfalse_activation_penalty = 1500 # @param {type:\"slider\", min:100, max:3000, step:100}\n\n# @markdown ---\n# @markdown ### Répartition FR / EN (%)\n# @markdown Le reste (100% - FR%) va en EN. Mettre FR=0 pour full EN.\n\nfr_pct = 35 # @param {type:\"slider\", min:0, max:100, step:5}\n\n# @markdown ---\n# @markdown ### Objectifs qualité (early stopping)\n\ntarget_accuracy = 0.7 # @param {type:\"slider\", min:0.1, max:1.0, step:0.05}\ntarget_recall = 0.5 # @param {type:\"slider\", min:0.1, max:1.0, step:0.05}\n\n# ── Calcul automatique ────────────────────────────────────────────────────────\nimport math\n\nen_pct = 100 - fr_pct\nlang_samples = {\n 'fr': math.floor(number_of_examples * fr_pct / 100),\n 'en': number_of_examples - math.floor(number_of_examples * fr_pct / 100),\n}\n\nMODEL_NAME = PHRASE_DEFAULT.replace(' ', '_')\nCLIPS_DIR = f'./{MODEL_NAME}_clips'\n\nprint(f'Wake word : \"{PHRASE_DEFAULT}\" (EN) | \"{PHRASE_FR}\" (FR)')\nprint(f'Model name : {MODEL_NAME}')\nprint(f'Total samples : {sum(lang_samples.values())}')\nprint(f' fr : {lang_samples[\"fr\"]} ({fr_pct}%)')\nprint(f' en : {lang_samples[\"en\"]} ({en_pct}%)')",
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Étape 1 — Piper sample generator + modèles TTS"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"import os, sys\n",
"from IPython.display import Audio, display\n",
"\n",
"if not os.path.exists('./piper-sample-generator'):\n",
" !git clone https://github.com/rhasspy/piper-sample-generator\n",
" !cd piper-sample-generator && git checkout 213d4d5\n",
" !pip install piper-tts piper-phonemize-cross\n",
" !pip install webrtcvad\n",
" !pip install torch==2.5.0 torchvision==0.20.0 torchaudio==2.5.0 --index-url https://download.pytorch.org/whl/cu121\n",
"\n",
"# Modèle EN\n",
"os.makedirs('piper-sample-generator/models', exist_ok=True)\n",
"if not os.path.exists('piper-sample-generator/models/en_US-libritts_r-medium.pt'):\n",
" !wget -q -O piper-sample-generator/models/en_US-libritts_r-medium.pt \\\n",
" 'https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/en_US-libritts_r-medium.pt'\n",
"\n",
"# Modèle FR via piper-tts .onnx\n",
"if not os.path.exists('fr_FR-siwis-medium.onnx'):\n",
" !wget -q -O fr_FR-siwis-medium.onnx \\\n",
" 'https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx'\n",
" !wget -q -O fr_FR-siwis-medium.onnx.json \\\n",
" 'https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json'\n",
"\n",
"if 'piper-sample-generator/' not in sys.path:\n",
" sys.path.append('piper-sample-generator/')\n",
"from generate_samples import generate_samples\n",
"\n",
"print('Piper prêt.')"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Test audio — vérifie les prononciations avant de lancer le training\n",
"generate_samples(text=PHRASE_DEFAULT, max_samples=1, output_dir='./', batch_size=1,\n",
" auto_reduce_batch_size=True, file_names=['test_en.wav'])\n",
"print(f'EN : \"{PHRASE_DEFAULT}\"')\n",
"display(Audio('test_en.wav', autoplay=False))\n",
"\n",
"!echo \"{PHRASE_FR}\" | piper --model fr_FR-siwis-medium.onnx --output_file test_fr.wav\n",
"print(f'FR : \"{PHRASE_FR}\"')\n",
"display(Audio('test_fr.wav', autoplay=False))"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Étape 2 — Téléchargement des données"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"import locale\n",
"def getpreferredencoding(do_setlocale=True):\n",
" return 'UTF-8'\n",
"locale.getpreferredencoding = getpreferredencoding\n",
"\n",
"import os, shutil\n",
"\n",
"# Clone openWakeWord\n",
"if os.path.exists('./openwakeword'):\n",
" shutil.rmtree('./openwakeword')\n",
"!rm -rf ./openwakeword\n",
"!git clone https://github.com/dscripka/openwakeword\n",
"!pip install -e ./openwakeword --no-deps\n",
"\n",
"!pip install mutagen==1.47.0\n",
"!pip install torchinfo==1.8.0\n",
"!pip install torchmetrics==1.2.0\n",
"!pip install speechbrain==0.5.14\n",
"!pip install audiomentations==0.33.0\n",
"!pip install torch-audiomentations==0.11.0\n",
"!pip install acoustics==0.2.6\n",
"!pip install onnxruntime==1.22.1 ai_edge_litert==1.4.0 onnxsim\n",
"!pip install onnx2tf\n",
"!pip install onnx==1.19.1\n",
"!pip install onnx_graphsurgeon sng4onnx\n",
"!pip install pronouncing==0.2.0\n",
"!pip install datasets==2.14.6\n",
"!pip install deep-phonemizer==0.0.19\n",
"\n",
"os.makedirs('./openwakeword/openwakeword/resources/models', exist_ok=True)\n",
"base = 'https://github.com/dscripka/openWakeWord/releases/download/v0.5.1'\n",
"for f in ['embedding_model.onnx', 'embedding_model.tflite', 'melspectrogram.onnx', 'melspectrogram.tflite']:\n",
" !wget -q {base}/{f} -O ./openwakeword/openwakeword/resources/models/{f}\n",
"\n",
"print('Installation terminée.')"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"import numpy as np, sys, yaml, datasets, scipy\n",
"from pathlib import Path\n",
"from tqdm import tqdm\n",
"\n",
"# MIT Room Impulse Responses\n",
"output_dir = './mit_rirs'\n",
"if not os.path.exists(output_dir):\n",
" os.mkdir(output_dir)\n",
" !git lfs install\n",
" !git clone https://huggingface.co/datasets/davidscripka/MIT_environmental_impulse_responses\n",
" rir_dataset = datasets.Dataset.from_dict({\n",
" 'audio': [str(i) for i in Path('./MIT_environmental_impulse_responses/16khz').glob('*.wav')]\n",
" }).cast_column('audio', datasets.Audio())\n",
" for row in tqdm(rir_dataset):\n",
" name = row['audio']['path'].split('/')[-1]\n",
" scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n",
"\n",
"# FMA music\n",
"output_dir = './fma'\n",
"if not os.path.exists(output_dir):\n",
" os.mkdir(output_dir)\n",
" fma_dataset = datasets.load_dataset('rudraml/fma', name='small', split='train', streaming=True)\n",
" fma_dataset = iter(fma_dataset.cast_column('audio', datasets.Audio(sampling_rate=16000)))\n",
" n_hours = 1\n",
" for i in tqdm(range(n_hours*3600//30)):\n",
" row = next(fma_dataset)\n",
" name = row['audio']['path'].split('/')[-1].replace('.mp3', '.wav')\n",
" scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n",
"\n",
"# Features pré-calculées openWakeWord\n",
"if not os.path.exists('./openwakeword_features_ACAV100M_2000_hrs_16bit.npy'):\n",
" !wget -q https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n",
"if not os.path.exists('validation_set_features.npy'):\n",
" !wget -q https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/validation_set_features.npy\n",
"\n",
"print('Données prêtes.')"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Étape 3 — Génération des samples multi-langue"
]
},
{
"cell_type": "code",
"metadata": {},
"source": "import sys, os, glob\nimport scipy.io.wavfile as wav\nimport scipy.signal as signal\nimport numpy as np\nfrom tqdm import tqdm\n\nif 'piper-sample-generator/' not in sys.path:\n sys.path.append('piper-sample-generator/')\nfrom generate_samples import generate_samples\n\nos.makedirs(CLIPS_DIR, exist_ok=True)\n\n# ── EN via piper-sample-generator ────────────────────────────────────────────\nen_dir = f'{CLIPS_DIR}/en'\nos.makedirs(en_dir, exist_ok=True)\nexisting_en = len([f for f in os.listdir(en_dir) if f.endswith('.wav')])\nremaining_en = lang_samples['en'] - existing_en\n\nif remaining_en > 0:\n print(f' [en] génération {remaining_en} samples : \"{PHRASE_DEFAULT}\"')\n generate_samples(\n text=PHRASE_DEFAULT, max_samples=remaining_en,\n length_scales=[0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15],\n noise_scales=[0.4, 0.667, 1.0], noise_scale_ws=[0.8],\n output_dir=en_dir, batch_size=50, auto_reduce_batch_size=True,\n )\nelse:\n print(f' [en] déjà complet ({existing_en}/{lang_samples[\"en\"]})')\n\n# ── FR via piper CLI + rééchantillonnage 16kHz ───────────────────────────────\nfr_dir = f'{CLIPS_DIR}/fr'\nos.makedirs(fr_dir, exist_ok=True)\nexisting_fr = len([f for f in os.listdir(fr_dir) if f.endswith('.wav')])\nremaining_fr = lang_samples['fr'] - existing_fr\n\nif remaining_fr > 0:\n print(f' [fr] génération {remaining_fr} samples : \"{PHRASE_FR}\"')\n speeds = [0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15]\n n_per_speed = remaining_fr // len(speeds)\n remainder = remaining_fr % len(speeds)\n for i, speed in enumerate(tqdm(speeds)):\n n = n_per_speed + (1 if i < remainder else 0)\n with open('/tmp/fr_input.txt', 'w', encoding='utf-8') as f:\n f.write('\\n'.join([PHRASE_FR] * n))\n !piper --model fr_FR-siwis-medium.onnx --length_scale {speed} --output_dir {fr_dir} < /tmp/fr_input.txt\n\n resampled = 0\n for path in glob.glob(f'{fr_dir}/*.wav'):\n if os.path.getsize(path) == 0:\n os.remove(path)\n continue\n try:\n sr, data = wav.read(path)\n if sr != 16000:\n n_s = round(len(data) * 16000 / sr)\n wav.write(path, 16000, signal.resample(data, n_s).astype(np.int16))\n resampled += 1\n except Exception:\n os.remove(path)\n print(f' [fr] {resampled} clips rééchantillonnés à 16kHz')\nelif lang_samples['fr'] > 0:\n print(f' [fr] déjà complet ({existing_fr}/{lang_samples[\"fr\"]})')\nelse:\n print(' [fr] désactivé (fr_pct = 0)')\n\ntotal = sum(\n len([f for f in os.listdir(f'{CLIPS_DIR}/{lang}') if f.endswith('.wav')])\n for lang in ['en', 'fr'] if os.path.exists(f'{CLIPS_DIR}/{lang}')\n)\nprint(f'\\nTotal clips générés : {total}/{number_of_examples}')",
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Étape 4 — Training"
]
},
{
"cell_type": "code",
"metadata": {},
"source": "import yaml, os, sys, shutil, glob, random\nimport scipy.io.wavfile as wav\nimport scipy.signal as signal\nimport numpy as np\n\nconfig = yaml.load(open('openwakeword/examples/custom_model.yml', 'r').read(), yaml.Loader)\n\nPHRASE_DEFAULT_SPACE = PHRASE_DEFAULT.replace('_', ' ')\n\nconfig['target_phrase'] = [PHRASE_DEFAULT_SPACE, PHRASE_FR]\nconfig['model_name'] = MODEL_NAME\nconfig['n_samples'] = number_of_examples\nconfig['n_samples_val'] = max(500, number_of_examples // 10)\nconfig['steps'] = number_of_training_steps\nconfig['target_accuracy'] = target_accuracy\nconfig['target_recall'] = target_recall\nconfig['max_negative_weight'] = false_activation_penalty\n\nfinal_drive_output_dir = os.path.join(drive_output_dir, MODEL_NAME)\nos.makedirs(final_drive_output_dir, exist_ok=True)\n\nlocal_temp_dir = './openwakeword_training_temp'\nconfig['output_dir'] = local_temp_dir\nconfig['background_paths'] = ['./fma']\nconfig['false_positive_validation_data_path'] = 'validation_set_features.npy'\nconfig['feature_data_files'] = {'ACAV100M_sample': 'openwakeword_features_ACAV100M_2000_hrs_16bit.npy'}\n\nwith open('my_model.yaml', 'w') as f:\n yaml.dump(config, f)\n\n# Rééchantillonner les clips FR à 16kHz\nfor path in glob.glob(f'{CLIPS_DIR}/fr/*.wav'):\n if os.path.getsize(path) == 0:\n os.remove(path)\n continue\n try:\n sr, data = wav.read(path)\n if sr != 16000:\n n_s = round(len(data) * 16000 / sr)\n wav.write(path, 16000, signal.resample(data, n_s).astype(np.int16))\n except Exception:\n os.remove(path)\n\n# Setup des dossiers via --generate_clips\nprint('Setup des dossiers via --generate_clips...')\n!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --generate_clips\n\n# S'assurer que les dossiers existent\npositive_clips_dir = os.path.join(local_temp_dir, MODEL_NAME, 'positive_clips')\npositive_test_dir = os.path.join(local_temp_dir, MODEL_NAME, 'positive_test')\nos.makedirs(positive_clips_dir, exist_ok=True)\nos.makedirs(positive_test_dir, exist_ok=True)\n\n# Remplacer les clips EN par nos clips multi-langue\nfor f in glob.glob(os.path.join(positive_clips_dir, '*.wav')): os.remove(f)\nfor f in glob.glob(os.path.join(positive_test_dir, '*.wav')): os.remove(f)\n\nall_clips = glob.glob(f'{CLIPS_DIR}/**/*.wav', recursive=True)\nrandom.shuffle(all_clips)\nn_val = config['n_samples_val']\nfor wav_path in all_clips[:n_val]:\n lang = os.path.basename(os.path.dirname(wav_path))\n shutil.copy(wav_path, os.path.join(positive_test_dir, f\"{lang}_{os.path.basename(wav_path)}\"))\nfor wav_path in all_clips[n_val:]:\n lang = os.path.basename(os.path.dirname(wav_path))\n shutil.copy(wav_path, os.path.join(positive_clips_dir, f\"{lang}_{os.path.basename(wav_path)}\"))\n\nprint(f'Training : {len(all_clips) - n_val} clips | Validation : {n_val} clips')\n\n# Supprimer le cache .npy\nfor f in glob.glob(os.path.join(local_temp_dir, MODEL_NAME, '*.npy')):\n os.remove(f)\n print(f'Cache supprimé : {os.path.basename(f)}')\n\n# Augmentation + training\n!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --augment_clips\n!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --train_model",
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Étape 5 — Export ONNX → TFLite + sauvegarde Drive"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"import shutil, os\n",
"from google.colab import files\n",
"\n",
"local_onnx = f'{local_temp_dir}/{MODEL_NAME}.onnx'\n",
"drive_onnx = f'{final_drive_output_dir}/{MODEL_NAME}.onnx'\n",
"drive_tflite1 = f'{final_drive_output_dir}/{MODEL_NAME}_float32.tflite'\n",
"drive_tflite = f'{final_drive_output_dir}/{MODEL_NAME}.tflite'\n",
"\n",
"shutil.copy(local_onnx, drive_onnx)\n",
"print(f'ONNX copié : {drive_onnx}')\n",
"\n",
"!onnx2tf -i \"{drive_onnx}\" -o \"{final_drive_output_dir}\" -kat onnx____Flatten_0\n",
"!mv \"{drive_tflite1}\" \"{drive_tflite}\"\n",
"print(f'TFLite : {drive_tflite}')\n",
"\n",
"files.download(drive_onnx)\n",
"files.download(drive_tflite)"
],
"outputs": [],
"execution_count": null
}
]
}