diff --git a/android/app/build.gradle b/android/app/build.gradle
index 7a1f8a1..273c8f2 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -66,11 +66,15 @@ android {
}
defaultConfig {
- minSdkVersion 24
+ minSdkVersion 29 // meta_wearables SDK requires API 29+
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
+ manifestPlaceholders = [
+ mwdatCallbackScheme: "mwdat-myinfomate",
+ applicationName: "io.flutter.app.FlutterApplication"
+ ]
}
flavorDimensions "client"
@@ -80,16 +84,28 @@ android {
dimension "client"
applicationId "be.unov.myinfomate.test"
resValue "string", "app_name", "MyMuseum Dev"
+ manifestPlaceholders = [
+ mwdatCallbackScheme: "mwdat-myinfomate-dev",
+ applicationName: "io.flutter.app.FlutterApplication"
+ ]
}
mdlf {
dimension "client"
applicationId "be.unov.mymuseum.mdlf"
resValue "string", "app_name", "MDLF"
+ manifestPlaceholders = [
+ mwdatCallbackScheme: "mwdat-mymuseum-mdlf",
+ applicationName: "io.flutter.app.FlutterApplication"
+ ]
}
fortsaintheribert {
dimension "client"
applicationId "be.unov.mymuseum.fortsaintheribert"
resValue "string", "app_name", "Fort Saint-Héribert"
+ manifestPlaceholders = [
+ mwdatCallbackScheme: "mwdat-mymuseum-fortsaintheribert",
+ applicationName: "io.flutter.app.FlutterApplication"
+ ]
}
}
@@ -119,4 +135,6 @@ flutter {
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
+ implementation 'org.tensorflow:tensorflow-lite:2.12.0'
+ implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.16.0'
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d80e6d6..270ea73 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ when (call.method) {
+ "enableBluetoothOutput" -> { enableBluetoothOutput(); result.success(null) }
+ "restoreDefaultOutput" -> { restoreDefaultOutput(); result.success(null) }
+ else -> result.notImplemented()
+ }
+ }
+
+ // Start / stop du service wake word
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, wakeWordChannel)
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "start" -> {
+ requestBatteryOptimizationExemption()
+ val modelName = call.argument("modelName")
+ ?: WakeWordService.DEFAULT_MODEL
+ // activeInstance dans WakeWordService gère le cleanup de l'instance précédente
+ startService(
+ Intent(this, WakeWordService::class.java)
+ .setAction(WakeWordService.ACTION_START)
+ .putExtra(WakeWordService.EXTRA_MODEL_NAME, modelName)
+ .putExtra(WakeWordService.EXTRA_CACHE_DIR, wakeWordCacheDir)
+ )
+ result.success(null)
+ }
+ "setCacheDir" -> {
+ // Flutter nous passe le chemin où les modèles ont été extraits
+ val path = call.argument("path") ?: ""
+ wakeWordCacheDir = path
+ result.success(null)
+ }
+ "pause" -> {
+ startService(
+ Intent(this, WakeWordService::class.java)
+ .setAction(WakeWordService.ACTION_PAUSE)
+ )
+ result.success(null)
+ }
+ "resume" -> {
+ startService(
+ Intent(this, WakeWordService::class.java)
+ .setAction(WakeWordService.ACTION_RESUME)
+ )
+ result.success(null)
+ }
+ "stop" -> {
+ startService(
+ Intent(this, WakeWordService::class.java)
+ .setAction(WakeWordService.ACTION_STOP)
+ )
+ result.success(null)
+ }
+ else -> result.notImplemented()
+ }
+ }
+
+ // Stream des événements wake word vers Flutter
+ EventChannel(flutterEngine.dartExecutor.binaryMessenger, wakeWordEventsChannel)
+ .setStreamHandler(object : EventChannel.StreamHandler {
+ override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
+ eventSink = sink
+ val filter = IntentFilter(WakeWordService.EVENT_ACTION)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(wakeWordReceiver, filter, RECEIVER_NOT_EXPORTED)
+ } else {
+ registerReceiver(wakeWordReceiver, filter)
+ }
+ }
+
+ override fun onCancel(arguments: Any?) {
+ eventSink = null
+ try { unregisterReceiver(wakeWordReceiver) } catch (_: Exception) {}
+ }
+ })
+ }
+
+ private fun requestBatteryOptimizationExemption() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val pm = getSystemService(POWER_SERVICE) as PowerManager
+ if (!pm.isIgnoringBatteryOptimizations(packageName)) {
+ startActivity(
+ Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
+ .setData(Uri.parse("package:$packageName"))
+ )
+ }
+ }
+ }
+
+ private fun enableBluetoothOutput() {
+ val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
+ audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val btDevice = audioManager.availableCommunicationDevices
+ .firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP }
+ ?: audioManager.availableCommunicationDevices
+ .firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
+ if (btDevice != null) audioManager.setCommunicationDevice(btDevice)
+ } else {
+ @Suppress("DEPRECATION")
+ audioManager.startBluetoothSco()
+ @Suppress("DEPRECATION")
+ audioManager.isBluetoothScoOn = true
+ }
+ }
+
+ private fun restoreDefaultOutput() {
+ val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
+ audioManager.mode = AudioManager.MODE_NORMAL
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ audioManager.clearCommunicationDevice()
+ } else {
+ @Suppress("DEPRECATION")
+ audioManager.stopBluetoothSco()
+ @Suppress("DEPRECATION")
+ audioManager.isBluetoothScoOn = false
+ }
+ }
}
diff --git a/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/WakeWordService.kt b/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/WakeWordService.kt
new file mode 100644
index 0000000..751c3ad
--- /dev/null
+++ b/android/app/src/main/kotlin/be/unov/myvisit/mymuseum_visitapp/WakeWordService.kt
@@ -0,0 +1,518 @@
+package be.unov.mymuseum.fortsaintheribert
+
+import ai.onnxruntime.OnnxTensor
+import ai.onnxruntime.OrtEnvironment
+import ai.onnxruntime.OrtSession
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Intent
+import android.media.AudioAttributes
+import android.media.AudioFocusRequest
+import android.media.AudioFormat
+import android.media.AudioManager
+import android.media.AudioRecord
+import android.media.MediaRecorder
+import android.os.Build
+import android.content.pm.ServiceInfo
+import android.os.IBinder
+import android.os.PowerManager
+import android.os.SystemClock
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import org.tensorflow.lite.Interpreter
+import java.io.File
+import java.io.FileOutputStream
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+/**
+ * Pipeline OpenWakeWord :
+ * AudioRecord (16kHz PCM16)
+ * ↓ chunks de 1280 samples (80ms)
+ * melspectrogram.onnx → [n_frames, 32] via ONNX Runtime (supporte formes dynamiques)
+ * ↓ fenêtres de 76 frames, stride 8
+ * embedding_model.tflite → [1, 96] par fenêtre
+ * ↓ ring buffer des 16 derniers embeddings
+ * {model}.tflite → score [0..1]
+ * ↓ score ≥ 0.5 → broadcast "detected"
+ */
+class WakeWordService : Service() {
+
+ companion object {
+ const val ACTION_START = "be.unov.mymuseum.WAKE_WORD_START"
+ const val ACTION_STOP = "be.unov.mymuseum.WAKE_WORD_STOP"
+ const val ACTION_PAUSE = "be.unov.mymuseum.WAKE_WORD_PAUSE" // pause AudioRecord (pendant STT)
+ const val ACTION_RESUME = "be.unov.mymuseum.WAKE_WORD_RESUME" // reprend après STT
+
+ // Instance active — garantit qu'une seule instance tourne à la fois
+ private var activeInstance: WakeWordService? = null
+ const val EVENT_ACTION = "be.unov.mymuseum.WAKE_WORD_EVENT"
+ const val EXTRA_EVENT = "event"
+ const val EXTRA_MODEL_NAME = "modelName"
+ const val DEFAULT_MODEL = "hey_visit"
+ const val EXTRA_CACHE_DIR = "cacheDir"
+
+ private const val CHANNEL_ID = "wake_word_channel_v2" // v2 force recréation du canal
+ private const val NOTIFICATION_ID = 1338
+ private const val TAG = "WakeWordService"
+
+ private const val SAMPLE_RATE = 16000
+ private const val CHUNK_SAMPLES = 1280 // 80ms @ 16kHz
+ private const val MEL_BINS = 32
+ private const val MEL_WINDOW = 76 // frames → embedding model
+ private const val MEL_STRIDE = 8
+ private const val EMBEDDING_DIM = 96
+ private const val N_EMBEDDING_FRAMES = 16
+ private const val DETECTION_THRESHOLD = 0.1f // Calibrer après re-entraînement avec 50k exemples
+ private const val COOLDOWN_MS = 2000L
+ }
+
+ private var audioRecord: AudioRecord? = null
+ private var silenceTrack: android.media.AudioTrack? = null // maintient session audio active (pattern OpenGlasses)
+ private var audioFocusRequest: AudioFocusRequest? = null
+ private var ortEnv: OrtEnvironment? = null
+ private var melSession: OrtSession? = null
+ private var embeddingInterpreter: Interpreter? = null
+ private var classifierInterpreter: Interpreter? = null
+ private var isRunning = false
+ @Volatile private var requestedPause = false // demande depuis main thread
+ private var isPaused = false // état réel — géré uniquement par le thread capture
+ private var lastDetectionTime = 0L
+ private var modelName = DEFAULT_MODEL
+ private var wakeLock: PowerManager.WakeLock? = null
+
+ private val melBuffer = ArrayDeque()
+ private val embeddingBuffer = ArrayDeque()
+
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START -> {
+ // Stoppe l'instance précédente si elle tourne encore
+ activeInstance?.let {
+ if (it !== this) {
+ Log.d(TAG, "Stopping previous instance before starting new one")
+ it.stopDetection()
+ }
+ }
+ activeInstance = this
+ modelName = intent.getStringExtra(EXTRA_MODEL_NAME) ?: DEFAULT_MODEL
+ modelCacheDir = intent.getStringExtra(EXTRA_CACHE_DIR) ?: ""
+ startDetection()
+ }
+ ACTION_PAUSE -> {
+ requestedPause = true
+ Log.d(TAG, "Pause requested — AudioRecord will stop on next loop iteration")
+ }
+ ACTION_RESUME -> {
+ requestedPause = false
+ Log.d(TAG, "Resume requested — AudioRecord will restart on next loop iteration")
+ }
+ ACTION_STOP -> {
+ activeInstance = null
+ stopDetection()
+ stopSelf()
+ }
+ }
+ return START_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() { stopDetection(); super.onDestroy() }
+
+ // ── Start / Stop ──────────────────────────────────────────────────────────
+
+ private fun startDetection() {
+ if (isRunning) return
+ isRunning = true
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(
+ NOTIFICATION_ID, buildNotification(),
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
+ )
+ } else {
+ startForeground(NOTIFICATION_ID, buildNotification())
+ }
+ val pm = getSystemService(POWER_SERVICE) as PowerManager
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MyVisit:WakeWordLock").apply { acquire() }
+ Thread {
+ try {
+ loadModels()
+ initAudioRecord()
+ runLoop()
+ } catch (e: Exception) {
+ Log.e(TAG, "Fatal error", e)
+ broadcast("error")
+ }
+ }.start()
+ }
+
+ private fun stopDetection() {
+ isRunning = false
+ requestedPause = false
+ isPaused = false
+ reacquireFocusHandler?.removeCallbacksAndMessages(null)
+ reacquireFocusHandler = null
+ audioRecord?.stop(); audioRecord?.release(); audioRecord = null
+ silenceTrack?.stop(); silenceTrack?.release(); silenceTrack = null
+ val am = getSystemService(AUDIO_SERVICE) as? AudioManager
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ audioFocusRequest?.let { am?.abandonAudioFocusRequest(it) }
+ } else {
+ @Suppress("DEPRECATION")
+ am?.abandonAudioFocus(audioFocusListener)
+ }
+ audioFocusRequest = null
+ melSession?.close(); melSession = null
+ ortEnv?.close(); ortEnv = null
+ embeddingInterpreter?.close(); embeddingInterpreter = null
+ classifierInterpreter?.close(); classifierInterpreter = null
+ melBuffer.clear(); embeddingBuffer.clear()
+ audioManager = null
+ if (wakeLock?.isHeld == true) wakeLock?.release()
+ wakeLock = null
+ }
+
+ // ── Model loading ─────────────────────────────────────────────────────────
+
+ private fun loadModels() {
+ // mel : ONNX Runtime → supporte [1, ?] dynamique sans overflow
+ ortEnv = OrtEnvironment.getEnvironment()
+ val melFile = extractAsset("melspectrogram.onnx")
+ melSession = ortEnv!!.createSession(melFile.absolutePath, OrtSession.SessionOptions())
+
+ // embedding + classifieur : TFLite (formes statiques, pas de problème)
+ embeddingInterpreter = loadTflite("embedding_model.tflite")
+ classifierInterpreter = loadTflite("$modelName.tflite")
+
+ Log.d(TAG, "Models loaded — mel=ONNX, embedding+classifier=TFLite, model=$modelName")
+ }
+
+ private fun loadTflite(assetName: String): Interpreter {
+ val file = extractAsset(assetName)
+ val options = Interpreter.Options().apply {
+ setUseXNNPACK(false)
+ setNumThreads(2)
+ }
+ return Interpreter(file, options)
+ }
+
+ // Répertoire où Flutter a extrait les modèles (transmis via setCacheDir)
+ var modelCacheDir: String = ""
+
+ private fun extractAsset(assetName: String): File {
+ // Priorité : répertoire transmis par Flutter (toujours accessible)
+ if (modelCacheDir.isNotEmpty()) {
+ val file = File(modelCacheDir, assetName)
+ if (file.exists()) return file
+ }
+ // Fallback : AssetManager (fonctionne en release)
+ val file = File(cacheDir, assetName)
+ if (!file.exists()) {
+ assets.open("flutter_assets/assets/files/$assetName").use { input ->
+ FileOutputStream(file).use { output -> input.copyTo(output) }
+ }
+ }
+ return file
+ }
+
+ // ── Audio ──────────────────────────────────────────────────────────────────
+
+ private var audioManager: android.media.AudioManager? = null
+
+ private var reacquireFocusHandler: android.os.Handler? = null
+
+ // Listener et requête créés UNE SEULE FOIS — réutiliser le même objet évite
+ // qu'une nouvelle requête envoie AUDIOFOCUS_LOSS à l'ancienne, ce qui créait une boucle infinie.
+ private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
+ when (focusChange) {
+ AudioManager.AUDIOFOCUS_LOSS,
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
+ Log.d(TAG, "AudioFocus lost ($focusChange) — will re-acquire in 1.5s")
+ reacquireFocusHandler?.removeCallbacksAndMessages(null)
+ reacquireFocusHandler = android.os.Handler(android.os.Looper.getMainLooper()).also { h ->
+ h.postDelayed({
+ if (isRunning) {
+ val am = getSystemService(AUDIO_SERVICE) as? AudioManager
+ // Même requête, même listener — pas de boucle
+ audioFocusRequest?.let { am?.requestAudioFocus(it) }
+ Log.d(TAG, "AudioFocus re-acquired")
+ }
+ }, 1500)
+ }
+ }
+ AudioManager.AUDIOFOCUS_GAIN -> Log.d(TAG, "AudioFocus gained")
+ }
+ }
+
+ private fun acquireAudioFocus() {
+ val am = getSystemService(AUDIO_SERVICE) as AudioManager
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (audioFocusRequest == null) {
+ audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .build()
+ )
+ .setWillPauseWhenDucked(false)
+ .setOnAudioFocusChangeListener(audioFocusListener)
+ .build()
+ }
+ am.requestAudioFocus(audioFocusRequest!!)
+ } else {
+ @Suppress("DEPRECATION")
+ am.requestAudioFocus(audioFocusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
+ }
+ Log.d(TAG, "AudioFocus acquired")
+ }
+
+ private fun initAudioRecord() {
+ acquireAudioFocus()
+
+ // Silent AudioTrack — maintient la session audio active même en background (pattern OpenGlasses iOS)
+ // iOS : AVAudioEngine en .playAndRecord permanent | Android : AudioTrack silence en loop
+ val silenceBufSize = android.media.AudioTrack.getMinBufferSize(
+ 16000, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT
+ )
+ silenceTrack = android.media.AudioTrack(
+ android.media.AudioManager.STREAM_MUSIC,
+ 16000,
+ AudioFormat.CHANNEL_OUT_MONO,
+ AudioFormat.ENCODING_PCM_16BIT,
+ silenceBufSize * 2,
+ android.media.AudioTrack.MODE_STREAM
+ )
+ // Bruit blanc Gaussien sigma=10 (~0.03% du max) — complètement inaudible, au-dessus du seuil "zero".
+ // PAS de setVolume(0f) — le [mute] résultant déclenche MIUI encore plus vite que [zero].
+ val rng = java.util.Random(42L)
+ val noiseBuffer = ShortArray(silenceBufSize) { (rng.nextGaussian() * 10.0).toInt().toShort() }
+ silenceTrack?.play()
+ Thread {
+ while (isRunning) {
+ val written = silenceTrack?.write(noiseBuffer, 0, noiseBuffer.size) ?: 0
+ if (written <= 0) {
+ // Buffer plein ou track suspendu — on attend avant de réessayer
+ Thread.sleep(100)
+ } else {
+ Thread.sleep(50)
+ }
+ }
+ }.also { it.isDaemon = true; it.start() }
+ Log.d(TAG, "Silent AudioTrack started (keeps audio session alive)")
+
+ val minBuf = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
+ audioRecord = AudioRecord(
+ MediaRecorder.AudioSource.MIC,
+ SAMPLE_RATE,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT,
+ maxOf(minBuf, CHUNK_SAMPLES * 4)
+ )
+ audioRecord!!.startRecording()
+ Log.d(TAG, "AudioRecord started at ${SAMPLE_RATE}Hz")
+ }
+
+ // ── Main loop ──────────────────────────────────────────────────────────────
+
+ private var debugFrameCount = 0
+
+ private fun runLoop() {
+ val pcm = ShortArray(CHUNK_SAMPLES)
+ while (isRunning) {
+ // Transition pause — géré uniquement ici, le seul thread qui touche AudioRecord
+ if (requestedPause && !isPaused) {
+ audioRecord?.stop()
+ isPaused = true
+ melBuffer.clear()
+ embeddingBuffer.clear()
+ Log.d(TAG, "AudioRecord stopped — STT can now use the mic")
+ }
+ if (!requestedPause && isPaused) {
+ audioRecord?.startRecording()
+ isPaused = false
+ Log.d(TAG, "AudioRecord restarted — wake word listening resumed")
+ }
+
+ if (isPaused) { Thread.sleep(20); continue }
+
+ val read = audioRecord?.read(pcm, 0, CHUNK_SAMPLES) ?: break
+ if (read <= 0) continue
+
+ val newFrames = computeMelOnnx(pcm, read)
+ melBuffer.addAll(newFrames)
+
+ // Debug toutes les 50 itérations (~4s)
+ debugFrameCount++
+ if (debugFrameCount % 50 == 0) {
+ Log.d(TAG, "DEBUG — melBuffer=${melBuffer.size} embBuf=${embeddingBuffer.size} newMelFrames=${newFrames.size}")
+ }
+
+ while (melBuffer.size >= MEL_WINDOW) {
+ val window = melBuffer.take(MEL_WINDOW)
+ repeat(MEL_STRIDE) { if (melBuffer.isNotEmpty()) melBuffer.removeFirst() }
+ val embedding = computeEmbedding(window)
+ embeddingBuffer.addLast(embedding)
+ if (embeddingBuffer.size > N_EMBEDDING_FRAMES) embeddingBuffer.removeFirst()
+ }
+
+ if (embeddingBuffer.size == N_EMBEDDING_FRAMES) {
+ val score = runClassifier()
+ if (debugFrameCount % 50 == 0) {
+ Log.d(TAG, "DEBUG — classifier score=$score (threshold=$DETECTION_THRESHOLD)")
+ }
+ val now = SystemClock.elapsedRealtime()
+ if (score >= DETECTION_THRESHOLD && (now - lastDetectionTime) > COOLDOWN_MS) {
+ lastDetectionTime = now
+ Log.d(TAG, "Wake word detected! score=$score model=$modelName")
+ broadcast("detected")
+ }
+ }
+ }
+ }
+
+ // ── Step 1 : mel via ONNX Runtime ─────────────────────────────────────────
+
+ private fun computeMelOnnx(pcm: ShortArray, length: Int): List {
+ val env = ortEnv ?: return emptyList()
+ val session = melSession ?: return emptyList()
+
+ // Log les noms d'entrée/sortie au premier appel
+ if (debugFrameCount == 0) {
+ Log.d(TAG, "ONNX input names: ${session.inputNames}")
+ Log.d(TAG, "ONNX output names: ${session.outputNames}")
+ }
+
+ return try {
+ _computeMelOnnxInternal(env, session, pcm, length)
+ } catch (e: Exception) {
+ Log.e(TAG, "computeMelOnnx error (input length=$length)", e)
+ emptyList()
+ }
+ }
+
+ private fun _computeMelOnnxInternal(env: OrtEnvironment, session: OrtSession, pcm: ShortArray, length: Int): List {
+ // Input : float32 normalisé [1, length]
+ val inputArray = Array(1) { FloatArray(length) { i -> pcm[i] / 32768f } }
+ val inputTensor = OnnxTensor.createTensor(env, inputArray)
+
+ val output = session.run(mapOf(session.inputNames.first() to inputTensor))
+ val rawValue = output[0].value
+ inputTensor.close(); output.close()
+
+ // Log le type de sortie au premier appel pour debug
+ if (debugFrameCount == 0) {
+ Log.d(TAG, "ONNX output type: ${rawValue?.javaClass?.name} — ${rawValue?.javaClass?.componentType?.name}")
+ if (rawValue is Array<*> && rawValue.isNotEmpty()) {
+ Log.d(TAG, "ONNX output[0] type: ${rawValue[0]?.javaClass?.name}")
+ if (rawValue[0] is Array<*>) {
+ val inner = rawValue[0] as Array<*>
+ Log.d(TAG, "ONNX output[0][0] type: ${inner[0]?.javaClass?.name}, size=${inner.size}")
+ }
+ }
+ }
+
+ // Shape réelle : [1, 1, n_frames, 32] = float[][][][]
+ // rawValue[0] → [1, n_frames, 32] (batch removed)
+ // rawValue[0][0] → [n_frames, 32] (extra dim removed)
+ // rawValue[0][0][frame] → float[32] (les mel bins)
+ val batchOutput = (rawValue as? Array<*>)?.get(0) as? Array<*>
+ ?: run { Log.w(TAG, "Unexpected ONNX output: ${rawValue?.javaClass?.name}"); return emptyList() }
+
+ val timeSlice = batchOutput.getOrNull(0) as? Array<*>
+ ?: run { Log.w(TAG, "No time slice in ONNX output"); return emptyList() }
+
+ val frames = timeSlice.mapNotNull { it as? FloatArray }
+
+ if (debugFrameCount == 0) Log.d(TAG, "Parsed ${frames.size} mel frames from [1,1,${frames.size},32]")
+
+ // Normalisation OWW : spec/10 + 2
+ return frames.map { f -> FloatArray(MEL_BINS) { i -> f[i] / 10f + 2f } }
+ }
+
+ // ── Step 2 : embedding TFLite ─────────────────────────────────────────────
+
+ private fun computeEmbedding(melWindow: List): FloatArray {
+ val inputBuf = ByteBuffer.allocateDirect(4 * MEL_WINDOW * MEL_BINS).order(ByteOrder.nativeOrder())
+ for (frame in melWindow) for (bin in frame) inputBuf.putFloat(bin)
+ inputBuf.rewind()
+ // Shape réelle [1, 1, 1, 96] — output 4D
+ val output = Array(1) { Array(1) { Array(1) { FloatArray(EMBEDDING_DIM) } } }
+ embeddingInterpreter!!.run(inputBuf, output)
+ return output[0][0][0]
+ }
+
+ // ── Step 3 : classifier TFLite ────────────────────────────────────────────
+
+ private fun runClassifier(): Float {
+ val inputBuf = ByteBuffer.allocateDirect(4 * N_EMBEDDING_FRAMES * EMBEDDING_DIM).order(ByteOrder.nativeOrder())
+ for (embedding in embeddingBuffer) for (v in embedding) inputBuf.putFloat(v)
+ inputBuf.rewind()
+ val output = Array(1) { FloatArray(1) }
+ classifierInterpreter!!.run(inputBuf, output)
+ return output[0][0]
+ }
+
+ // ── Utilities ──────────────────────────────────────────────────────────────
+
+ private fun broadcast(event: String) {
+ val sink = MainActivity.eventSink
+ if (sink != null) {
+ // Appel direct au sink Flutter (même processus, plus fiable que sendBroadcast)
+ android.os.Handler(android.os.Looper.getMainLooper()).post {
+ try { sink.success(event) } catch (e: Exception) {
+ Log.w(TAG, "Direct sink failed, fallback to broadcast: $e")
+ sendBroadcast(Intent(EVENT_ACTION).putExtra(EXTRA_EVENT, event))
+ }
+ }
+ } else {
+ // Fallback si MainActivity n'est pas encore initialisée
+ Log.w(TAG, "eventSink null — sending broadcast instead")
+ sendBroadcast(Intent(EVENT_ACTION).putExtra(EXTRA_EVENT, event))
+ }
+ }
+
+ private fun buildNotification(): Notification {
+ // Intent pour ouvrir l'app en tapant sur la notification
+ val openIntent = packageManager.getLaunchIntentForPackage(packageName)
+ val pendingIntent = android.app.PendingIntent.getActivity(
+ this, 0, openIntent,
+ android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("Guide de visite actif 🎧")
+ .setContentText("Dites « hey visit » pour parler à votre guide")
+ .setSmallIcon(android.R.drawable.ic_btn_speak_now)
+ // PRIORITY_DEFAULT (pas LOW) — OEM restrictifs comme MIUI respectent mieux les services visibles
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setOngoing(true) // non dismissible par l'utilisateur
+ .setContentIntent(pendingIntent)
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ // IMPORTANCE_DEFAULT avec son désactivé — visible mais pas intrusif
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Guide vocal MyVisit",
+ NotificationManager.IMPORTANCE_DEFAULT
+ ).apply {
+ setSound(null, null)
+ enableVibration(false)
+ description = "Écoute active du wake word"
+ }
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+ }
+}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 44a2fa5..b5031c8 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -12,4 +12,6 @@
main
+
+ 0
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..30b2596
--- /dev/null
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/build.gradle b/android/build.gradle
index c8ad5a4..20d4950 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -11,10 +11,25 @@
}
}*/
+def rootLocalProps = new Properties()
+def rootLocalPropsFile = rootProject.file('local.properties')
+if (rootLocalPropsFile.exists()) {
+ rootLocalPropsFile.withReader('UTF-8') { reader -> rootLocalProps.load(reader) }
+}
+
allprojects {
repositories {
google()
mavenCentral()
+ maven {
+ url = uri("https://maven.pkg.github.com/facebook/meta-wearables-dat-android")
+ credentials {
+ username = "x-access-token"
+ password = System.getenv("GITHUB_TOKEN")
+ ?: rootLocalProps.getProperty("github_token")
+ ?: ""
+ }
+ }
}
}
@@ -26,6 +41,22 @@ subprojects {
project.evaluationDependsOn(':app')
}
+// Fix pour les packages Flutter qui n'ont pas de namespace (beacon_scanner v0.0.4, etc.)
+subprojects {
+ plugins.withId("com.android.library") {
+ if (!android.namespace) {
+ def manifestFile = android.sourceSets.main.manifest.srcFile
+ if (manifestFile?.exists()) {
+ def packageName = new groovy.xml.XmlSlurper()
+ .parse(manifestFile)['@package'].text()
+ if (packageName) {
+ android.namespace = packageName
+ }
+ }
+ }
+ }
+}
+
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
diff --git a/android/gradle.properties b/android/gradle.properties
index 5156fa7..4b7787f 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -2,4 +2,8 @@ org.gradle.jvmargs=-Xmx4096M
android.useAndroidX=true
android.enableJetifier=false
android.experimental.enable16kApk=true
-android.useNewNativePlugin=true
\ No newline at end of file
+android.useNewNativePlugin=true
+# This builtInKotlin flag was added automatically by Flutter migrator
+android.builtInKotlin=false
+# This newDsl flag was added automatically by Flutter migrator
+android.newDsl=false
diff --git a/assets/files/embedding_model.tflite b/assets/files/embedding_model.tflite
new file mode 100644
index 0000000..d3c3bed
Binary files /dev/null and b/assets/files/embedding_model.tflite differ
diff --git a/assets/files/hey_visit.onnx b/assets/files/hey_visit.onnx
new file mode 100644
index 0000000..d254732
Binary files /dev/null and b/assets/files/hey_visit.onnx differ
diff --git a/assets/files/hey_visit.tflite b/assets/files/hey_visit.tflite
new file mode 100644
index 0000000..547b0e6
Binary files /dev/null and b/assets/files/hey_visit.tflite differ
diff --git a/assets/files/hey_viva.onnx b/assets/files/hey_viva.onnx
new file mode 100644
index 0000000..08fd30d
Binary files /dev/null and b/assets/files/hey_viva.onnx differ
diff --git a/assets/files/hey_viva.tflite b/assets/files/hey_viva.tflite
new file mode 100644
index 0000000..d36f49c
Binary files /dev/null and b/assets/files/hey_viva.tflite differ
diff --git a/assets/files/melspectrogram.onnx b/assets/files/melspectrogram.onnx
new file mode 100644
index 0000000..a3a6035
Binary files /dev/null and b/assets/files/melspectrogram.onnx differ
diff --git a/assets/files/melspectrogram.tflite b/assets/files/melspectrogram.tflite
new file mode 100644
index 0000000..e290399
Binary files /dev/null and b/assets/files/melspectrogram.tflite differ
diff --git a/assets/sounds/done.mp3 b/assets/sounds/done.mp3
new file mode 100644
index 0000000..71d80c7
Binary files /dev/null and b/assets/sounds/done.mp3 differ
diff --git a/assets/sounds/thinking.mp3 b/assets/sounds/thinking.mp3
new file mode 100644
index 0000000..08e96cd
Binary files /dev/null and b/assets/sounds/thinking.mp3 differ
diff --git a/assets/sounds/wake_detected.mp3 b/assets/sounds/wake_detected.mp3
new file mode 100644
index 0000000..05248b8
Binary files /dev/null and b/assets/sounds/wake_detected.mp3 differ
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 70693e4..793748d 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -8,6 +8,9 @@ import Flutter
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
+ if let registrar = self.registrar(forPlugin: "AudioRoutingPlugin") {
+ AudioRoutingPlugin.register(with: registrar)
+ }
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
diff --git a/ios/Runner/AudioRoutingPlugin.swift b/ios/Runner/AudioRoutingPlugin.swift
new file mode 100644
index 0000000..013d851
--- /dev/null
+++ b/ios/Runner/AudioRoutingPlugin.swift
@@ -0,0 +1,57 @@
+// AudioRoutingPlugin.swift
+// Force la sortie audio sur les lunettes Ray-Ban Meta via AVAudioSession.
+// Pattern extrait de OpenGlasses (straff2002) WakeWordService.swift + GeminiLiveAudioManager.swift.
+
+import Flutter
+import AVFoundation
+
+@objc class AudioRoutingPlugin: NSObject, FlutterPlugin {
+
+ static func register(with registrar: FlutterPluginRegistrar) {
+ let channel = FlutterMethodChannel(
+ name: "be.unov.mymuseum/audio_routing",
+ binaryMessenger: registrar.messenger()
+ )
+ let instance = AudioRoutingPlugin()
+ registrar.addMethodCallDelegate(instance, channel: channel)
+ }
+
+ func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "enableBluetoothOutput":
+ enableBluetoothOutput()
+ result(nil)
+ case "restoreDefaultOutput":
+ restoreDefaultOutput()
+ result(nil)
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ private func enableBluetoothOutput() {
+ do {
+ let session = AVAudioSession.sharedInstance()
+ // Mode voiceChat + A2DP + HFP : pattern validé dans OpenGlasses
+ // pour jouer audio sur lunettes tout en gardant le micro disponible
+ try session.setCategory(
+ .playAndRecord,
+ mode: .voiceChat,
+ options: [.allowBluetoothHFP, .allowBluetoothA2DP, .defaultToSpeaker]
+ )
+ try session.setActive(true, options: .notifyOthersOnDeactivation)
+ } catch {
+ print("[AudioRoutingPlugin] enableBluetoothOutput error: \(error)")
+ }
+ }
+
+ private func restoreDefaultOutput() {
+ do {
+ let session = AVAudioSession.sharedInstance()
+ try session.setCategory(.playback, options: [])
+ try session.setActive(true)
+ } catch {
+ print("[AudioRoutingPlugin] restoreDefaultOutput error: \(error)")
+ }
+ }
+}
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 146c2a1..020b0da 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -4,6 +4,11 @@
CADisableMinimumFrameDurationOnPhone
+
+ UIBackgroundModes
+
+ audio
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
diff --git a/lib/Components/AdminPopup.dart b/lib/Components/AdminPopup.dart
index 08bc865..505b94b 100644
--- a/lib/Components/AdminPopup.dart
+++ b/lib/Components/AdminPopup.dart
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
+import 'package:mymuseum_visitapp/Components/GlassesDebugPanel.dart';
import 'package:mymuseum_visitapp/Components/check_input_container.dart';
import 'package:mymuseum_visitapp/Components/rounded_button.dart';
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
+import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:provider/provider.dart';
import '../constants.dart';
@@ -34,7 +36,7 @@ class _AdminPopupState extends State {
return Container(
width: size.width*0.7,
- height: isPasswordOk ? size.height*0.5 : size.height*0.15,
+ height: isPasswordOk ? size.height*0.6 : size.height*0.15,
margin: const EdgeInsets.all(kDefaultPadding),
child: isPasswordOk ? Column(
children: [
@@ -110,6 +112,30 @@ class _AdminPopupState extends State {
vertical: 5
),
),
+ ),
+ SizedBox(
+ height: size.height*0.06,
+ child: Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: ValueListenableBuilder(
+ valueListenable: MetaGlassesService.instance.state,
+ builder: (_, state, __) => RoundedButton(
+ text: "Lunettes • ${state.name}",
+ color: state == GlassesState.streaming || state == GlassesState.connected
+ ? Colors.green[700]!
+ : Colors.orange[700]!,
+ textColor: Colors.white,
+ icon: Icons.smart_toy_outlined,
+ press: () {
+ Navigator.of(context).pop();
+ GlassesDebugPanel.show(context);
+ },
+ fontSize: 16,
+ horizontal: 20,
+ vertical: 5,
+ ),
+ ),
+ ),
)
],
) :
diff --git a/lib/Components/AssistantChatSheet.dart b/lib/Components/AssistantChatSheet.dart
index 4d9e847..b63a561 100644
--- a/lib/Components/AssistantChatSheet.dart
+++ b/lib/Components/AssistantChatSheet.dart
@@ -3,6 +3,8 @@ import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:mymuseum_visitapp/Models/AssistantResponse.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Services/assistantService.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
+import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:speech_to_text/speech_to_text.dart';
@@ -120,8 +122,19 @@ class _AssistantChatSheetState extends State {
onNavigate: widget.onNavigateToSection,
));
});
+
+ // Pipe TTS vers les lunettes si connectées
+ if (widget.visitAppContext.glassesEnabled &&
+ MetaGlassesService.instance.isConnected &&
+ response.reply.isNotEmpty) {
+ final lang = widget.visitAppContext.language ?? 'FR';
+ activeOrchestrator?.ttsEngine.speak(
+ response.reply,
+ languageCode: _toLangCode(lang),
+ );
+ }
} catch (e) {
- print("AssistantChatSheet error: $e");
+ debugPrint('AssistantChatSheet error: $e');
setState(() {
_bubbles.add(_ChatBubble(text: "Une erreur est survenue, réessayez.", isUser: false));
});
@@ -131,6 +144,16 @@ class _AssistantChatSheetState extends State {
}
}
+ 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';
+ }
+ }
+
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
@@ -167,6 +190,33 @@ class _AssistantChatSheetState extends State {
fontSize: 18,
fontWeight: FontWeight.w600,
color: kSecondGrey)),
+ const SizedBox(width: 8),
+ if (widget.visitAppContext.glassesEnabled)
+ ValueListenableBuilder(
+ valueListenable: MetaGlassesService.instance.state,
+ builder: (_, glassesState, __) {
+ final connected = glassesState == GlassesState.connected ||
+ glassesState == GlassesState.streaming;
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.smart_toy_outlined,
+ size: 14,
+ color: connected ? Colors.green : Colors.grey[400],
+ ),
+ const SizedBox(width: 3),
+ Text(
+ connected ? 'Lunettes' : 'Déconnecté',
+ style: TextStyle(
+ fontSize: 11,
+ color: connected ? Colors.green : Colors.grey[400],
+ ),
+ ),
+ ],
+ );
+ },
+ ),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
diff --git a/lib/Components/CustomAppBar.dart b/lib/Components/CustomAppBar.dart
index 4604ad1..3442fa0 100644
--- a/lib/Components/CustomAppBar.dart
+++ b/lib/Components/CustomAppBar.dart
@@ -72,14 +72,12 @@ class _CustomAppBarState extends State {
leading: widget.isHomeButton ? IconButton(
icon: const Icon(Icons.home, color: Colors.white),
onPressed: () {
- // Set new State
setState(() {
visitAppContext.configuration = null;
visitAppContext.isScanningBeacons = false;
- //Navigator.of(context).pop();
Navigator.of(context).pushAndRemoveUntil(MaterialPageRoute(
builder: (context) => const HomePage3(),
- ),(route) => false);
+ ), (route) => false);
});
}
) : null,
diff --git a/lib/Components/GlassesDebugPanel.dart b/lib/Components/GlassesDebugPanel.dart
new file mode 100644
index 0000000..04debbe
--- /dev/null
+++ b/lib/Components/GlassesDebugPanel.dart
@@ -0,0 +1,371 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:meta_wearables_dat/meta_wearables_dat.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
+import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
+import 'package:mymuseum_visitapp/constants.dart';
+
+/// Panneau de debug pour l'intégration Ray-Ban Meta.
+/// À ouvrir via un bouton discret dans l'app (ex: appui long sur le logo).
+/// Ne pas inclure en production.
+class GlassesDebugPanel extends StatefulWidget {
+ const GlassesDebugPanel({super.key});
+
+ static void show(BuildContext context) {
+ showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: Colors.grey[900],
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
+ ),
+ builder: (_) => const GlassesDebugPanel(),
+ );
+ }
+
+ @override
+ State createState() => _GlassesDebugPanelState();
+}
+
+class _GlassesDebugPanelState extends State {
+ String _log = '';
+ bool _busy = false;
+ bool _monitoring = false;
+ final List _subs = [];
+
+ @override
+ void dispose() {
+ for (final s in _subs) { s.cancel(); }
+ super.dispose();
+ }
+
+ void _addLog(String msg) {
+ if (!mounted) return;
+ setState(() => _log = '${DateTime.now().toIso8601String().substring(11, 19)} $msg\n$_log');
+ }
+
+ void _toggleMonitor() {
+ if (_monitoring) {
+ for (final s in _subs) { s.cancel(); }
+ _subs.clear();
+ setState(() => _monitoring = false);
+ _addLog('⏹ Monitor arrêté');
+ return;
+ }
+ _subs.add(Wearables.instance.registrationStateStream.listen(
+ (s) => _addLog('📋 registration: ${s.state} err=${s.error}'),
+ onError: (e) => _addLog('📋 registration error: $e'),
+ ));
+ _subs.add(Wearables.instance.devicesStream.listen(
+ (d) => _addLog('📱 devices: $d'),
+ onError: (e) => _addLog('📱 devices error: $e'),
+ ));
+ _subs.add(Wearables.instance.streamStateStream.listen(
+ (s) => _addLog('🎥 streamState: $s'),
+ onError: (e) => _addLog('🎥 streamState error: $e'),
+ ));
+ _subs.add(Wearables.instance.videoFramesStream.listen(
+ (f) => _addLog('🖼 videoFrame: ${f.length} bytes'),
+ onError: (e) => _addLog('🖼 videoFrame error: $e'),
+ ));
+ setState(() => _monitoring = true);
+ _addLog('▶ Monitor démarré — interagis avec les lunettes');
+ }
+
+ Future _run(String label, Future Function() fn) async {
+ if (_busy) return;
+ setState(() => _busy = true);
+ _addLog('▶ $label');
+ try {
+ await fn();
+ _addLog('✓ $label');
+ } catch (e) {
+ _addLog('✗ $label: $e');
+ } finally {
+ setState(() => _busy = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return DraggableScrollableSheet(
+ expand: false,
+ initialChildSize: 0.75,
+ maxChildSize: 0.95,
+ builder: (_, scroll) => Column(
+ children: [
+ // Handle
+ Container(
+ margin: const EdgeInsets.symmetric(vertical: 8),
+ width: 40, height: 4,
+ decoration: BoxDecoration(
+ color: Colors.grey[600],
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Row(
+ children: [
+ const Icon(Icons.bug_report, color: Colors.amber, size: 18),
+ const SizedBox(width: 8),
+ const Text('Glasses Debug',
+ style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
+ const Spacer(),
+ // État en temps réel
+ ValueListenableBuilder(
+ valueListenable: MetaGlassesService.instance.state,
+ builder: (_, state, __) {
+ final color = state == GlassesState.streaming
+ ? Colors.green
+ : state == GlassesState.connected
+ ? Colors.lightGreen
+ : state == GlassesState.connecting
+ ? Colors.orange
+ : Colors.red;
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.2),
+ border: Border.all(color: color),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ state.name.toUpperCase(),
+ style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold),
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ const Divider(color: Colors.grey),
+ // Boutons actions
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12),
+ child: Wrap(
+ spacing: 8, runSpacing: 8,
+ children: [
+ _ActionButton(
+ label: 'Activer caméra',
+ icon: Icons.camera_alt,
+ onTap: () => _run('requestCameraPermission + startStream', () async {
+ await Wearables.instance.requestCameraPermission();
+ await Wearables.instance.startStream(
+ videoQuality: 'MEDIUM',
+ frameRate: 24,
+ );
+ }),
+ ),
+ _ActionButton(
+ label: 'Start stream',
+ icon: Icons.videocam,
+ onTap: () => _run('startStream (direct)', () async {
+ await Wearables.instance.startStream(
+ videoQuality: 'MEDIUM',
+ frameRate: 24,
+ );
+ }),
+ ),
+ _ActionButton(
+ label: 'Capture photo',
+ icon: Icons.photo_camera,
+ onTap: () => _run('capturePhoto', () async {
+ await MetaGlassesService.instance.requestPhotoCapture();
+ }),
+ ),
+ _ActionButton(
+ label: 'Test TTS',
+ icon: Icons.volume_up,
+ onTap: () => _run('TTS test', () async {
+ final o = activeOrchestrator;
+ if (o == null) {
+ _addLog('⚠ Orchestrateur non initialisé');
+ return;
+ }
+ // Stoppe l'écoute wake word pendant le TTS pour éviter le conflit audio focus
+ await o.wakeWordEngine.stop();
+ try {
+ await o.ttsEngine.speak(
+ 'Bonjour. Je suis votre assistant de visite. Bienvenue au musée.',
+ languageCode: 'fr-FR',
+ );
+ } finally {
+ await o.wakeWordEngine.start(
+ onDetected: () => o.triggerConversation(),
+ onDetectedWithCommand: (cmd) => o.triggerConversation(),
+ );
+ }
+ }),
+ ),
+ _ActionButton(
+ label: _monitoring ? 'Stop monitor' : 'Event monitor',
+ icon: _monitoring ? Icons.sensors_off : Icons.sensors,
+ color: _monitoring ? Colors.orange : Colors.purple,
+ onTap: _toggleMonitor,
+ ),
+ _ActionButton(
+ label: 'Stop stream',
+ icon: Icons.stop,
+ color: Colors.red,
+ onTap: () => _run('stopStream', () async {
+ await Wearables.instance.stopStream();
+ }),
+ ),
+ _ActionButton(
+ label: 'Reconnect',
+ icon: Icons.refresh,
+ onTap: () => _run('disconnect + connect', () async {
+ await MetaGlassesService.instance.disconnect();
+ await Future.delayed(const Duration(milliseconds: 500));
+ await MetaGlassesService.instance.connect();
+ }),
+ ),
+ ],
+ ),
+ ),
+ const Divider(color: Colors.grey),
+ // Transcription en direct
+ if (activeOrchestrator != null)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
+ child: ValueListenableBuilder(
+ valueListenable: activeOrchestrator!.isListeningForCommand,
+ builder: (_, listening, __) => ValueListenableBuilder(
+ valueListenable: activeOrchestrator!.lastTranscription,
+ builder: (_, text, __) => Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: listening
+ ? Colors.green.withValues(alpha: 0.15)
+ : Colors.grey[850],
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: listening ? Colors.green : Colors.grey[700]!,
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(children: [
+ Icon(
+ listening ? Icons.mic : Icons.mic_none,
+ color: listening ? Colors.green : Colors.grey,
+ size: 14,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ listening ? 'Écoute en cours...' : 'Dernière transcription',
+ style: TextStyle(
+ color: listening ? Colors.green : Colors.grey,
+ fontSize: 11,
+ ),
+ ),
+ ]),
+ if (text.isNotEmpty) ...[
+ const SizedBox(height: 4),
+ Text(
+ '"$text"',
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 13,
+ fontStyle: FontStyle.italic,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ // Dernier texte TTS
+ if (activeOrchestrator != null)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
+ child: ValueListenableBuilder(
+ valueListenable: activeOrchestrator!.lastTtsText,
+ builder: (_, text, __) => text.isEmpty
+ ? const SizedBox.shrink()
+ : Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: Colors.blue.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.blue.withValues(alpha: 0.4)),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(children: [
+ const Icon(Icons.volume_up, color: Colors.blue, size: 14),
+ const SizedBox(width: 6),
+ const Text('Dernier TTS',
+ style: TextStyle(color: Colors.blue, fontSize: 11)),
+ ]),
+ const SizedBox(height: 4),
+ Text(
+ text,
+ style: const TextStyle(color: Colors.white70, fontSize: 12),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ const Divider(color: Colors.grey),
+ // Log
+ Expanded(
+ child: SingleChildScrollView(
+ controller: scroll,
+ padding: const EdgeInsets.all(12),
+ child: _log.isEmpty
+ ? const Text('Logs apparaîtront ici...',
+ style: TextStyle(color: Colors.grey, fontSize: 12))
+ : Text(
+ _log,
+ style: const TextStyle(
+ color: Colors.greenAccent,
+ fontSize: 11,
+ fontFamily: 'monospace',
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ActionButton extends StatelessWidget {
+ final String label;
+ final IconData icon;
+ final VoidCallback onTap;
+ final Color color;
+
+ const _ActionButton({
+ required this.label,
+ required this.icon,
+ required this.onTap,
+ this.color = Colors.blue,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return ElevatedButton.icon(
+ style: ElevatedButton.styleFrom(
+ backgroundColor: color.withValues(alpha: 0.15),
+ foregroundColor: color,
+ side: BorderSide(color: color.withValues(alpha: 0.4)),
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ ),
+ icon: Icon(icon, size: 16),
+ label: Text(label, style: const TextStyle(fontSize: 12)),
+ onPressed: onTap,
+ );
+ }
+}
diff --git a/lib/Models/visitContext.dart b/lib/Models/visitContext.dart
index 1733554..17029b6 100644
--- a/lib/Models/visitContext.dart
+++ b/lib/Models/visitContext.dart
@@ -46,6 +46,13 @@ class VisitAppContext with ChangeNotifier {
bool? isAllLanguages = false;
bool notificationsEnabled = true;
+ /// Active le mode lunettes Ray-Ban Meta (SDK DAT + TTS Bluetooth).
+ bool glassesEnabled = false;
+
+ /// Mode proactif lunettes : l'assistant parle automatiquement à l'approche
+ /// d'un beacon ou d'une zone géographique, sans que le visiteur pose de question.
+ bool proactiveModeEnabled = false;
+
String? localPath;
VisitAppContext({this.language, this.id, this.configuration, this.isAdmin, this.isAllLanguages, this.instanceId, this.apiKey, this.notificationsEnabled = true});
diff --git a/lib/PlatformChannels/audio_routing_channel.dart b/lib/PlatformChannels/audio_routing_channel.dart
new file mode 100644
index 0000000..b28a4c7
--- /dev/null
+++ b/lib/PlatformChannels/audio_routing_channel.dart
@@ -0,0 +1,33 @@
+import 'dart:io';
+import 'package:flutter/services.dart';
+
+/// Channel Dart vers le plugin natif AudioRoutingPlugin.
+/// Permet de forcer la sortie audio sur les lunettes (A2DP Bluetooth)
+/// au lieu du haut-parleur téléphone.
+class AudioRoutingChannel {
+ static const MethodChannel _channel =
+ MethodChannel('be.unov.mymuseum/audio_routing');
+
+ /// Force la sortie audio vers le device Bluetooth connecté (lunettes).
+ /// iOS : AVAudioSession .playAndRecord + .allowBluetoothA2DP + .allowBluetoothHFP
+ /// Android : AudioManager setCommunicationDevice(A2DP)
+ static Future enableBluetoothOutput() async {
+ if (!Platform.isAndroid && !Platform.isIOS) return;
+ try {
+ await _channel.invokeMethod('enableBluetoothOutput');
+ } on PlatformException catch (e) {
+ // Dégradation gracieuse — le son jouera sur le haut-parleur par défaut
+ print('[AudioRoutingChannel] enableBluetoothOutput failed: $e');
+ }
+ }
+
+ /// Restaure la sortie audio par défaut (haut-parleur téléphone).
+ static Future restoreDefaultOutput() async {
+ if (!Platform.isAndroid && !Platform.isIOS) return;
+ try {
+ await _channel.invokeMethod('restoreDefaultOutput');
+ } on PlatformException catch (e) {
+ print('[AudioRoutingChannel] restoreDefaultOutput failed: $e');
+ }
+ }
+}
diff --git a/lib/Screens/ConfigurationPage/configuration_page.dart b/lib/Screens/ConfigurationPage/configuration_page.dart
index d3a4c06..e49ec63 100644
--- a/lib/Screens/ConfigurationPage/configuration_page.dart
+++ b/lib/Screens/ConfigurationPage/configuration_page.dart
@@ -3,7 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-// TODO //import 'package:flutter_beacon/flutter_beacon.dart';
+import 'package:beacon_scanner/beacon_scanner.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:manager_api_new/api.dart';
@@ -45,8 +45,7 @@ class _ConfigurationPageState extends State with WidgetsBindi
// Beacon specific
final controller = Get.find();
- /*StreamSubscription? _streamBluetooth;
- StreamSubscription? _streamRanging;*/
+ StreamSubscription? _streamRanging;
/*final _regionBeacons = >{};
final _beacons = [];*/
bool _isDialogShowing = false;
@@ -110,18 +109,19 @@ class _ConfigurationPageState extends State with WidgetsBindi
visitAppContext.configuration = widget.configuration;
visitAppContext.sectionIds = widget.configuration.sectionIds;
appContext.setContext(visitAppContext);
+
+ listener = controller.startStream.listen((flag) async {
+ if (flag == true && mounted) {
+ final ctx = Provider.of(context, listen: false).getContext();
+ await initScanBeacon(ctx);
+ }
+ });
});
//listeningState();
}
listeningState() async {
- print('Listening to bluetooth state');
- /*_streamBluetooth = flutterBeacon
- .bluetoothStateChanged()
- .listen((BluetoothState state) async {
- controller.updateBluetoothState(state);
- await checkAllRequirements();
- });*/
+ await BeaconScanner.instance.initialize(true);
}
checkAllRequirements() async {
@@ -177,89 +177,55 @@ class _ConfigurationPageState extends State with WidgetsBindi
}
initScanBeacon(VisitAppContext visitAppContext) async {
+ if (_streamRanging != null && !_streamRanging!.isPaused) return;
- //await flutterBeacon.initializeScanning;
- /*if (!controller.authorizationStatusOk ||
- !controller.locationServiceEnabled ||
- !controller.bluetoothEnabled) {
- print(
- 'RETURNED, authorizationStatusOk=${controller.authorizationStatusOk}, '
- 'locationServiceEnabled=${controller.locationServiceEnabled}, '
- 'bluetoothEnabled=${controller.bluetoothEnabled}');
+ if (_streamRanging != null && _streamRanging!.isPaused) {
+ _streamRanging!.resume();
return;
- }*/
+ }
- /*if (_streamRanging != null) {
- if (_streamRanging!.isPaused) {
- _streamRanging?.resume();
- return;
+ final regions = [
+ if (Platform.isIOS)
+ Region(
+ identifier: 'MyMuseumB',
+ beaconId: IBeaconId(proximityUUID: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825'),
+ )
+ else
+ Region(identifier: 'MyMuseumB'),
+ ];
+
+ _streamRanging = BeaconScanner.instance.ranging(regions).listen((ScanResult result) {
+ if (!mounted || result.beacons.isEmpty) return;
+
+ final beaconSections = visitAppContext.beaconSections;
+ if (beaconSections == null) return;
+
+ var matches = beaconSections.where((bs) =>
+ result.beacons.any((b) =>
+ b.id.minorId == bs!.minorBeaconId && b.accuracy < meterToBeacon
+ )
+ );
+
+ if (matches.isNotEmpty) {
+ matches = matches.where((bs) => bs!.configurationId == visitAppContext.configuration!.id!);
}
- }*/
- /*_streamRanging =
- flutterBeacon.ranging(regions).listen((RangingResult result) {
- //print(result);
- if (mounted) {
+ if (matches.isNotEmpty && !modeDebugBeacon) {
+ matches = matches.where((bs) =>
+ !visitAppContext.readSections.any((ra) => ra.id == bs!.sectionId)
+ );
+ }
- //print("visitAppContext");
- //print(visitAppContext);
- //print(visitAppContext!.beaconSections);
+ if (matches.isEmpty) return;
- if(result.beacons.isNotEmpty) {
- print(result);
- print(result.beacons.map((b) => b.macAddress));
+ final milliLastTime = lastTimePopUpWasClosed?.millisecondsSinceEpoch ?? 0;
+ final cooldownOk = (DateTime.now().millisecondsSinceEpoch - milliLastTime) > timeBetweenBeaconPopUp;
- if(visitAppContext.beaconSections != null)
- {
- print(visitAppContext.beaconSections!.map((bb) => bb!.minorBeaconId));
- var beaconList = visitAppContext.beaconSections!.where((bs) => result.beacons.any((element) => element.minor == bs!.minorBeaconId && element.accuracy < meterToBeacon));
-
- if(beaconList.isNotEmpty) {
- // FILTER CONFIG
- beaconList = beaconList.where((beacon) => beacon!.configurationId == visitAppContext.configuration!.id!);
- }
-
- if(beaconList.isNotEmpty && !modeDebugBeacon) {
- // FILTER ALREADY READ
- beaconList = beaconList.where((b) => !visitAppContext.readSections.any((ra) => ra.id == b!.sectionId));
- }
-
- if(beaconList.isNotEmpty)
- {
- var milliLastTime = lastTimePopUpWasClosed == null ? 0 : lastTimePopUpWasClosed!.millisecondsSinceEpoch;
- var checkIfMoreThanSec = (DateTime.now().millisecondsSinceEpoch - milliLastTime) > timeBetweenBeaconPopUp;
-
- if(!_isDialogShowing && !visitAppContext.isContentCurrentlyShown && checkIfMoreThanSec && visitAppContext.isScanningBeacons) {
- print("Before sorting");
- print(beaconList);
- beaconList.toList().sort((a, b) => a!.orderInConfig!.compareTo(b!.orderInConfig!));
- print("after storting");
- print(beaconList);
- _onBeaconFound(visitAppContext, beaconList.first);
- } else {
- print("Non pas possible d'afficher pour le moment");
- }
-
- /*ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('BEACON - ${result.beacons.first.macAddress} - ${result.beacons.first.accuracy} - ${result.beacons.first.proximity.name}'), backgroundColor: kBlue2),
- );*/
- }
- } else {
- print("beaconSections is null !");
- }
- }
-
- //setState(() {
- /*_regionBeacons[result.region] = result.beacons;
- _beacons.clear();
- _regionBeacons.values.forEach((list) {
- _beacons.addAll(list);
- });
- _beacons.sort(_compareParameters);*/
- //});
- }
- });
-*/
+ if (!_isDialogShowing && !visitAppContext.isContentCurrentlyShown && cooldownOk && visitAppContext.isScanningBeacons) {
+ final sorted = matches.toList()..sort((a, b) => a!.orderInConfig!.compareTo(b!.orderInConfig!));
+ _onBeaconFound(visitAppContext, sorted.first);
+ }
+ });
}
/*pauseScanBeacon() async {
@@ -302,6 +268,10 @@ class _ConfigurationPageState extends State with WidgetsBindi
void _onBeaconFound(VisitAppContext visitAppContext, BeaconSection? beaconSection) {
_isDialogShowing = true;
+ PushNotificationService.showBeaconDiscoveryNotification(
+ title: TranslationHelper.getFromLocale('beaconFound', visitAppContext),
+ body: TranslationHelper.getFromLocale('beaconFoundBody', visitAppContext),
+ );
showDialog(
barrierDismissible: false,
builder: (BuildContext context) => AlertDialog(
@@ -353,6 +323,8 @@ class _ConfigurationPageState extends State with WidgetsBindi
@override
void dispose() {
PushNotificationService.tappedMessage.removeListener(_notificationListener);
+ listener?.cancel();
+ _streamRanging?.cancel();
controller.pauseScanning();
super.dispose();
}
@@ -361,16 +333,6 @@ class _ConfigurationPageState extends State with WidgetsBindi
Widget build(BuildContext context) {
final appContext = Provider.of(context);
VisitAppContext visitAppContext = appContext.getContext();
- //configuration = visitAppContext.configuration;
-
- listener = controller.startStream.listen((flag) async {
- print(flag);
- if (flag == true) {
- print("FIIIIIIREEEE ---------------");
- await initScanBeacon(visitAppContext);
- controller.startScanning();
- }
- });
return PopScope(
canPop: true,
@@ -418,135 +380,22 @@ class _ConfigurationPageState extends State with WidgetsBindi
padding: const EdgeInsets.only(right: 90, bottom: 1),
child: InkWell(
onTap: () async {
- bool isCancel = false;
-
- /*if(!controller.authorizationStatusOk) {
- //await handleOpenLocationSettings();
-
- await showDialog(
- context: context,
- barrierDismissible: false,
- builder: (_) {
- return AlertDialog(
- backgroundColor: Colors.white,
- content: Padding(
- padding: const EdgeInsets.symmetric(vertical: 20),
- child: SizedBox(
- height: 215,
- child: Column(
- mainAxisAlignment: MainAxisAlignment.spaceAround,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- const Icon(Icons.my_location, color: kSecondGrey),
- Padding(
- padding: const EdgeInsets.all(10.0),
- child: Text(TranslationHelper.getFromLocale("locationWarning", visitAppContext), style: const TextStyle(color: kSecondGrey), textAlign: TextAlign.center),
- ),
- ],
- ),
- ),
- ),
- actions: [
- TextButton(
- child: Text(TranslationHelper.getFromLocale("close", visitAppContext), style: TextStyle(color: kSecondGrey)),
- onPressed: () {
- isCancel = true;
- Navigator.of(context).pop();
- },
- ),
- TextButton(
- child: Text(TranslationHelper.getFromLocale("ok", visitAppContext), style: TextStyle(color: kSecondGrey)),
- onPressed: () async {
- Navigator.of(context).pop();
- },
- )
- ],
- actionsAlignment: MainAxisAlignment.spaceAround,
- contentPadding: EdgeInsets.zero,
- );
- });
-
-
- if(!isCancel) {
- if(Platform.isIOS) {
- Map statuses0 = await [
- Permission.bluetooth,
- ].request();
-
- Map statuses1 = await [
- Permission.bluetoothScan,
- ].request();
-
- Map statuses2 = await [
- Permission.bluetoothConnect,
- ].request();
-
- Map statuses3 = await [
- Permission.locationWhenInUse,
- ].request();
-
- Map statuses4 = await [
- Permission.location,
- ].request();
-
- /*Map statuses5 = await [
- Permission.locationAlways,
- ].request();*/
-
- print(statuses0[Permission.bluetooth]);
- print(statuses1[Permission.bluetoothScan]);
- print(statuses2[Permission.bluetoothConnect]);
- print(statuses3[Permission.locationWhenInUse]);
- print(statuses4[Permission.location]);
- //print(statuses5[Permission.locationAlways]);
-
- } else {
- Map statuses = await [
- Permission.bluetoothScan,
- Permission.bluetoothConnect,
- Permission.location,
- ].request();
-
- print(statuses[Permission.bluetoothScan]);
- print(statuses[Permission.bluetoothConnect]);
- print(statuses[Permission.location]);
- print(statuses[Permission.locationWhenInUse]);
-
- var status = await Permission.bluetoothScan.status;
- print(status);
- }
- await listeningState();
- }
+ if (!visitAppContext.isScanningBeacons) {
+ await [
+ Permission.bluetoothScan,
+ Permission.bluetoothConnect,
+ Permission.location,
+ ].request();
+ controller.startScanning();
+ visitAppContext.isScanningBeacons = true;
+ visitAppContext.isScanBeaconAlreadyAllowed = true;
+ appContext.setContext(visitAppContext);
+ } else {
+ _streamRanging?.pause();
+ controller.pauseScanning();
+ visitAppContext.isScanningBeacons = false;
+ appContext.setContext(visitAppContext);
}
-
- if(!isCancel) {
- if(!controller.bluetoothEnabled) {
- await handleOpenBluetooth();
- }
-
- if(!controller.locationServiceEnabled) {
- await handleOpenLocationSettings();
- }
- if(!visitAppContext.isScanningBeacons) {
- print("Start Scan");
- /*print(_streamRanging);
- if (_streamRanging != null) {
- _streamRanging?.resume();
- } else {
- await initScanBeacon(visitAppContext);
- }*/
-
- controller.startScanning();
- visitAppContext.isScanningBeacons = true;
- visitAppContext.isScanBeaconAlreadyAllowed = true;
- appContext.setContext(visitAppContext);
- } else {
- print("Pause Scan");
- controller.pauseScanning(); // PAUSE OR DISPOSE ?
- visitAppContext.isScanningBeacons = false;
- appContext.setContext(visitAppContext);
- }
- }*/
},
child: Container(
decoration: BoxDecoration(
diff --git a/lib/Screens/Home/home_3.0.dart b/lib/Screens/Home/home_3.0.dart
index d1e2d2e..3f116a1 100644
--- a/lib/Screens/Home/home_3.0.dart
+++ b/lib/Screens/Home/home_3.0.dart
@@ -624,7 +624,7 @@ class _HomePage3State extends State with WidgetsBindingObserver {
final mobileInstance = instances?.where((e) => e.appType == AppType.Mobile).firstOrNull;
if (mobileInstance != null) {
visitAppContext.applicationInstanceDTO = mobileInstance;
- if (mobileInstance.isStatistic ?? false) {
+ if (mobileInstance.hasStats == true) {
visitAppContext.statisticsService = StatisticsService(
clientAPI: visitAppContext.clientAPI,
instanceId: visitAppContext.instanceId,
diff --git a/lib/Screens/Sections/Agenda/agenda_page.dart b/lib/Screens/Sections/Agenda/agenda_page.dart
index d6b3826..303e9e6 100644
--- a/lib/Screens/Sections/Agenda/agenda_page.dart
+++ b/lib/Screens/Sections/Agenda/agenda_page.dart
@@ -52,7 +52,7 @@ class _AgendaPage extends State {
if (dtos == null) return null;
final events = dtos
- .map((dto) => EventAgenda.fromDto(dto, visitAppContext.language))
+ .map((dto) => EventAgenda.fromDto(dto, visitAppContext.language ?? 'FR'))
.toList();
events.sort((a, b) {
if (a.dateFrom == null) return 1;
diff --git a/lib/Screens/Sections/Event/event_map_full_page.dart b/lib/Screens/Sections/Event/event_map_full_page.dart
index 37cde9b..536594e 100644
--- a/lib/Screens/Sections/Event/event_map_full_page.dart
+++ b/lib/Screens/Sections/Event/event_map_full_page.dart
@@ -10,10 +10,12 @@ class EventMapFullPage extends StatelessWidget {
Key? key,
required this.section,
required this.visitAppContextIn,
+ this.blockAnnotations,
}) : super(key: key);
final SectionEventDTO section;
final VisitAppContext visitAppContextIn;
+ final List? blockAnnotations;
@override
Widget build(BuildContext context) {
@@ -39,6 +41,10 @@ class EventMapFullPage extends StatelessWidget {
MarkerLayer(markers: _buildMarkers(annotations)),
PolylineLayer(polylines: _buildPolylines(annotations)),
],
+ if (blockAnnotations?.isNotEmpty ?? false) ...[
+ MarkerLayer(markers: _buildBlockMarkers(blockAnnotations!)),
+ PolylineLayer(polylines: _buildBlockPolylines(blockAnnotations!)),
+ ],
],
),
Positioned(
@@ -77,7 +83,7 @@ class EventMapFullPage extends StatelessWidget {
return markers;
}
- List _buildPolylines(List annotations) {
+ List _buildPolylines(List annotations, {Color? overrideColor}) {
final lines = [];
for (final ann in annotations) {
if (ann.geometryType?.value != 1) continue;
@@ -90,12 +96,48 @@ class EventMapFullPage extends StatelessWidget {
}
}
if (points.length < 2) continue;
- final color = ann.polyColor != null ? _hexColor(ann.polyColor!) : kMainColor;
+ final color = overrideColor ?? (ann.polyColor != null ? _hexColor(ann.polyColor!) : kMainColor);
lines.add(Polyline(points: points, color: color, strokeWidth: 4));
}
return lines;
}
+ List _buildBlockMarkers(List annotations) {
+ final markers = [];
+ for (final ann in annotations) {
+ if (ann.geometryType?.value != 0) continue;
+ final coords = ann.geometry?.coordinates;
+ if (coords is! List || coords.length < 2) continue;
+ final lat = (coords[1] as num).toDouble();
+ final lng = (coords[0] as num).toDouble();
+ markers.add(Marker(
+ point: LatLng(lat, lng),
+ width: 32,
+ height: 32,
+ child: const Icon(Icons.place, color: Colors.orange, size: 32),
+ ));
+ }
+ return markers;
+ }
+
+ List _buildBlockPolylines(List annotations) {
+ final lines = [];
+ for (final ann in annotations) {
+ if (ann.geometryType?.value != 1) continue;
+ final coords = ann.geometry?.coordinates;
+ if (coords is! List) continue;
+ final points = [];
+ for (final c in coords) {
+ if (c is List && c.length >= 2) {
+ points.add(LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble()));
+ }
+ }
+ if (points.length < 2) continue;
+ lines.add(Polyline(points: points, color: Colors.orange, strokeWidth: 4));
+ }
+ return lines;
+ }
+
Color _hexColor(String hex) {
final v = int.tryParse('FF${hex.replaceAll('#', '')}', radix: 16);
return v != null ? Color(v) : kMainColor;
diff --git a/lib/Screens/Sections/Event/event_page.dart b/lib/Screens/Sections/Event/event_page.dart
index be7be04..de153a7 100644
--- a/lib/Screens/Sections/Event/event_page.dart
+++ b/lib/Screens/Sections/Event/event_page.dart
@@ -436,6 +436,7 @@ class _EventPageState extends State {
builder: (_) => EventMapFullPage(
section: widget.section,
visitAppContextIn: widget.visitAppContextIn,
+ blockAnnotations: _activeBlock?.mapAnnotations,
),
)),
child: Container(
@@ -462,6 +463,10 @@ class _EventPageState extends State {
MarkerLayer(markers: _buildMarkers(annotations)),
PolylineLayer(polylines: _buildPolylines(annotations)),
],
+ if (_activeBlock?.mapAnnotations?.isNotEmpty ?? false) ...[
+ MarkerLayer(markers: _buildBlockMarkers(_activeBlock!.mapAnnotations!)),
+ PolylineLayer(polylines: _buildBlockPolylines(_activeBlock!.mapAnnotations!)),
+ ],
],
),
Positioned(
@@ -569,6 +574,42 @@ class _EventPageState extends State {
return lines;
}
+ List _buildBlockMarkers(List annotations) {
+ final markers = [];
+ for (final ann in annotations) {
+ if (ann.geometryType?.value != 0) continue;
+ final coords = ann.geometry?.coordinates;
+ if (coords is! List || coords.length < 2) continue;
+ final lat = (coords[1] as num).toDouble();
+ final lng = (coords[0] as num).toDouble();
+ markers.add(Marker(
+ point: LatLng(lat, lng),
+ width: 30,
+ height: 30,
+ child: const Icon(Icons.place, color: Colors.orange, size: 30),
+ ));
+ }
+ return markers;
+ }
+
+ List _buildBlockPolylines(List annotations) {
+ final lines = [];
+ for (final ann in annotations) {
+ if (ann.geometryType?.value != 1) continue;
+ final coords = ann.geometry?.coordinates;
+ if (coords is! List) continue;
+ final points = [];
+ for (final c in coords) {
+ if (c is List && c.length >= 2) {
+ points.add(LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble()));
+ }
+ }
+ if (points.length < 2) continue;
+ lines.add(Polyline(points: points, color: Colors.orange, strokeWidth: 3.5));
+ }
+ return lines;
+ }
+
Color _hexColor(String hex) {
final v = int.tryParse('FF${hex.replaceAll('#', '')}', radix: 16);
return v != null ? Color(v) : kMainColor;
diff --git a/lib/Screens/Sections/Game/game_page.dart b/lib/Screens/Sections/Game/game_page.dart
index 21efc05..49ca77d 100644
--- a/lib/Screens/Sections/Game/game_page.dart
+++ b/lib/Screens/Sections/Game/game_page.dart
@@ -1,6 +1,5 @@
-import 'dart:convert';
+import 'dart:math';
import 'dart:async';
-import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
@@ -10,9 +9,11 @@ import 'package:mymuseum_visitapp/Components/loading_common.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Game/message_dialog.dart';
+import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart';
+import 'package:mymuseum_visitapp/Screens/Sections/Game/sliding_puzzle_piece.dart';
import 'puzzle_piece.dart';
const IMAGE_PATH = 'image_path';
@@ -36,6 +37,9 @@ class _GamePage extends State {
List pieces = [];
bool isSplittingImage = true;
+ bool showHint = false;
+ List slidingTileIndices = []; // Maps current slot index to original tile index. -1 for empty.
+ int emptySlotIndex = -1;
@override
void initState() {
@@ -89,86 +93,96 @@ class _GamePage extends State {
print("Taille réelle du widget : $size");
}
- // we need to find out the image size, to be used in the PuzzlePiece widget
- /*Future getImageSize(CachedNetworkImage image) async {
- Completer completer = Completer();
-
- /*image.image
- .resolve(const ImageConfiguration())
- .addListener(ImageStreamListener((ImageInfo info, bool _) {
- completer.complete(
- Size(info.image.width.toDouble(), info.image.height.toDouble()));
- }));*/
-
- CachedNetworkImage(
- imageUrl: 'https://example.com/image.jpg',
- placeholder: (context, url) => CircularProgressIndicator(),
- errorWidget: (context, url, error) => Icon(Icons.error),
- imageBuilder: (BuildContext context, ImageProvider imageProvider) {
- Completer completer = Completer();
-
- imageProvider
- .resolve(const ImageConfiguration())
- .addListener(ImageStreamListener((ImageInfo info, bool _) {
- completer.complete(
- Size(info.image.width.toDouble(), info.image.height.toDouble()));
- }));
-
- return CachedNetworkImage(
- imageUrl: 'https://example.com/image.jpg',
- placeholder: (context, url) => CircularProgressIndicator(),
- errorWidget: (context, url, error) => Icon(Icons.error),
- imageBuilder: (context, imageProvider) {
- return Image(
- image: imageProvider,
- loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
- if (loadingProgress == null) {
- return child;
- } else {
- return Center(
- child: CircularProgressIndicator(
- value: loadingProgress.expectedTotalBytes != null
- ? loadingProgress.cumulativeBytesLoaded / (loadingProgress.expectedTotalBytes ?? 1)
- : null,
- ),
- );
- }
- },
- );
- },
- );
- },
- );
-
- Size imageSize = await completer.future;
-
- return imageSize;
- }*/
-
// here we will split the image into small pieces
// using the rows and columns defined above; each piece will be added to a stack
void splitImage(CachedNetworkImage image) async {
- //Size imageSize = await getImageSize(image);
- //imageSize = realWidgetSize!;
- Size imageSize = Size(realWidgetSize!.width * 1.25, realWidgetSize!.height * 1.25);
+ final Completer completer = Completer();
+ final ImageProvider provider = CachedNetworkImageProvider(gameDTO.puzzleImage!.url!);
+
+ provider.resolve(const ImageConfiguration()).addListener(
+ ImageStreamListener((ImageInfo info, bool _) {
+ if (!completer.isCompleted) {
+ completer.complete(Size(info.image.width.toDouble(), info.image.height.toDouble()));
+ }
+ }),
+ );
- for (int x = 0; x < gameDTO.rows!; x++) {
- for (int y = 0; y < gameDTO.cols!; y++) {
- setState(() {
- pieces.add(
- PuzzlePiece(
- key: GlobalKey(),
- image: image,
- imageSize: imageSize,
- row: x,
- col: y,
- maxRow: gameDTO.rows!,
- maxCol: gameDTO.cols!,
- bringToTop: bringToTop,
- sendToBack: sendToBack,
- ),
- );
- });
+ Size imageOriginalSize = await completer.future;
+ double imageAspectRatio = imageOriginalSize.width / imageOriginalSize.height;
+
+ // Calculate best fit for the puzzle inside the available area
+ double containerWidth = realWidgetSize!.width * 0.9; // 90% of available width
+ double containerHeight = realWidgetSize!.height * 0.8; // 80% of available height
+ double containerAspectRatio = containerWidth / containerHeight;
+
+ double puzzleWidth, puzzleHeight;
+ if (imageAspectRatio > containerAspectRatio) {
+ puzzleWidth = containerWidth;
+ puzzleHeight = containerWidth / imageAspectRatio;
+ } else {
+ puzzleHeight = containerHeight;
+ puzzleWidth = containerHeight * imageAspectRatio;
+ }
+
+ final appContext = Provider.of(context, listen: false);
+ VisitAppContext visitAppContext = appContext.getContext();
+ setState(() {
+ visitAppContext.puzzleSize = Size(puzzleWidth, puzzleHeight);
+ appContext.setContext(visitAppContext);
+ });
+
+ final pieceWidth = puzzleWidth / gameDTO.cols!;
+ final pieceHeight = puzzleHeight / gameDTO.rows!;
+
+ if (gameDTO.gameType == GameTypes.SlidingPuzzle) {
+ // Initialize sliding puzzle slots: 0 to N-2 are tiles, last one is empty (-1)
+ int totalTiles = gameDTO.rows! * gameDTO.cols!;
+ slidingTileIndices = List.generate(totalTiles, (i) => i);
+
+ // Shuffle by making random valid moves to ensure solvability
+ emptySlotIndex = totalTiles - 1;
+ slidingTileIndices[emptySlotIndex] = -1; // -1 represents the empty slot
+
+ // Perform enough random moves to shuffle decently
+ int shuffleMoves = 100;
+ Random random = Random();
+ for (int i = 0; i < shuffleMoves; i++) {
+ List adjacent = _getAdjacentIndices(emptySlotIndex, gameDTO.rows!, gameDTO.cols!);
+ int moveIndex = adjacent[random.nextInt(adjacent.length)];
+ // Swap empty slot with the adjacent tile
+ slidingTileIndices[emptySlotIndex] = slidingTileIndices[moveIndex];
+ slidingTileIndices[moveIndex] = -1;
+ emptySlotIndex = moveIndex;
+ }
+ } else {
+ for (int x = 0; x < gameDTO.rows!; x++) {
+ for (int y = 0; y < gameDTO.cols!; y++) {
+ // Target position in the puzzle grid
+ double targetLeft = y * pieceWidth;
+ double targetTop = x * pieceHeight;
+
+ // Scatter logic: Randomly place within the container.
+ double initialLeft = Random().nextDouble() * (containerWidth - pieceWidth);
+ double initialTop = Random().nextDouble() * (containerHeight - pieceHeight);
+
+ setState(() {
+ pieces.add(
+ PuzzlePiece(
+ key: GlobalKey(),
+ image: image,
+ imageSize: Size(puzzleWidth, puzzleHeight),
+ row: x,
+ col: y,
+ maxRow: gameDTO.rows!,
+ maxCol: gameDTO.cols!,
+ bringToTop: bringToTop,
+ sendToBack: sendToBack,
+ initialLeft: initialLeft - targetLeft,
+ initialTop: initialTop - targetTop,
+ ),
+ );
+ });
+ }
}
}
@@ -177,6 +191,50 @@ class _GamePage extends State {
});
}
+ List _getAdjacentIndices(int index, int rows, int cols) {
+ List adjacent = [];
+ int r = index ~/ cols;
+ int c = index % cols;
+
+ if (r > 0) adjacent.add(index - cols); // Top
+ if (r < rows - 1) adjacent.add(index + cols); // Bottom
+ if (c > 0) adjacent.add(index - 1); // Left
+ if (c < cols - 1) adjacent.add(index + 1); // Right
+
+ return adjacent;
+ }
+
+ void _onSlidingTileTapped(int currentSlot) {
+ if (isFinished) return;
+
+ // Check if empty slot is adjacent
+ List adjacent = _getAdjacentIndices(currentSlot, gameDTO.rows!, gameDTO.cols!);
+ if (adjacent.contains(emptySlotIndex)) {
+ setState(() {
+ // Swap
+ slidingTileIndices[emptySlotIndex] = slidingTileIndices[currentSlot];
+ slidingTileIndices[currentSlot] = -1;
+ emptySlotIndex = currentSlot;
+
+ // Check win condition
+ bool won = true;
+ for (int i = 0; i < slidingTileIndices.length; i++) {
+ // In a solved puzzle, index i should contain tile i (or -1 at the very last slot)
+ if (i == slidingTileIndices.length - 1) {
+ if (slidingTileIndices[i] != -1) won = false;
+ } else {
+ if (slidingTileIndices[i] != i) won = false;
+ }
+ }
+
+ if (won) {
+ isFinished = true;
+ _onGameFinished('SlidingPuzzle');
+ }
+ });
+ }
+ }
+
// when the pan of a piece starts, we need to bring it to the front of the stack
void bringToTop(Widget widget) {
setState(() {
@@ -185,8 +243,8 @@ class _GamePage extends State {
});
}
-// when a piece reaches its final position,
-// it will be sent to the back of the stack to not get in the way of other, still movable, pieces
+ // when a piece reaches its final position,
+ // it will be sent to the back of the stack to not get in the way of other, still movable, pieces
void sendToBack(Widget widget) {
setState(() {
allInPlaceCount++;
@@ -194,24 +252,160 @@ class _GamePage extends State {
pieces.remove(widget);
pieces.insert(0, widget);
- if(isFinished) {
- Size size = MediaQuery.of(context).size;
- final appContext = Provider.of(context, listen: false);
- VisitAppContext visitAppContext = appContext.getContext();
- final duration = _gameStartTime != null ? DateTime.now().difference(_gameStartTime!).inSeconds : 0;
- visitAppContext.statisticsService?.track(
- VisitEventType.gameComplete,
- metadata: {'gameType': 'Puzzle', 'durationSeconds': duration},
- );
- TranslationAndResourceDTO? messageFin = gameDTO.messageFin != null && gameDTO.messageFin!.isNotEmpty ? gameDTO.messageFin!.where((message) => message.language!.toUpperCase() == visitAppContext.language!.toUpperCase()).firstOrNull : null;
-
- if(messageFin != null) {
- showMessage(messageFin, appContext, context, size);
- }
+ if (isFinished) {
+ _onGameFinished('Puzzle');
}
});
}
+ void _onGameFinished(String gameType) {
+ Size size = MediaQuery.of(context).size;
+ final appContext = Provider.of(context, listen: false);
+ VisitAppContext visitAppContext = appContext.getContext();
+ final duration = _gameStartTime != null ? DateTime.now().difference(_gameStartTime!).inSeconds : 0;
+
+ visitAppContext.statisticsService?.track(
+ VisitEventType.gameComplete,
+ metadata: {'gameType': gameType, 'durationSeconds': duration},
+ );
+
+ TranslationAndResourceDTO? messageFin = gameDTO.messageFin != null && gameDTO.messageFin!.isNotEmpty ? gameDTO.messageFin!.where((message) => message.language!.toUpperCase() == visitAppContext.language!.toUpperCase()).firstOrNull : null;
+
+ if(messageFin != null) {
+ showMessage(messageFin, appContext, context, size);
+ }
+ }
+
+ Widget _buildContent(VisitAppContext visitAppContext) {
+ if (gameDTO.gameType == GameTypes.Escape) {
+ final paths = gameDTO.guidedPaths ?? [];
+ if (paths.isEmpty) {
+ return Center(
+ child: Text('Aucun parcours disponible', style: TextStyle(color: Colors.grey[500], fontSize: 15)),
+ );
+ }
+ return ListView.builder(
+ padding: const EdgeInsets.all(16),
+ itemCount: paths.length,
+ itemBuilder: (_, i) {
+ final path = paths[i];
+ final title = TranslationHelper.get(path.title, visitAppContext);
+ final desc = TranslationHelper.get(path.description, visitAppContext);
+ final stepCount = path.steps?.length ?? 0;
+ return Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: ListTile(
+ contentPadding: const EdgeInsets.all(12),
+ leading: Container(
+ width: 42,
+ height: 42,
+ decoration: BoxDecoration(
+ color: kMainColor.withOpacity(0.12),
+ shape: BoxShape.circle,
+ ),
+ child: const Icon(Icons.explore, color: kMainColor, size: 22),
+ ),
+ title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
+ subtitle: Text(
+ desc.isNotEmpty ? desc : '$stepCount étape${stepCount > 1 ? 's' : ''}',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(color: Colors.grey[600], fontSize: 12),
+ ),
+ trailing: const Icon(Icons.arrow_forward_ios, size: 14),
+ onTap: () => Navigator.of(context).push(MaterialPageRoute(
+ builder: (_) => GuidedPathContentProgressionPage(
+ path: path,
+ visitAppContext: visitAppContext,
+ ),
+ )),
+ ),
+ );
+ },
+ );
+ }
+
+ if (gameDTO.gameType == GameTypes.SlidingPuzzle) {
+ if (slidingTileIndices.isEmpty) return Center(child: LoadingCommon());
+
+ final puzzleSize = visitAppContext.puzzleSize ?? Size(realWidgetSize!.width * 0.8, realWidgetSize!.height * 0.6);
+ final tileWidth = puzzleSize.width / gameDTO.cols!;
+ final tileHeight = puzzleSize.height / gameDTO.rows!;
+
+ return Center(
+ child: Container(
+ width: puzzleSize.width,
+ height: puzzleSize.height,
+ child: Stack(
+ children: [
+ // Hint Background
+ if (showHint)
+ Opacity(
+ opacity: 0.25,
+ child: CachedNetworkImage(
+ imageUrl: gameDTO.puzzleImage!.url!,
+ fit: BoxFit.fill,
+ ),
+ ),
+ // Tiles
+ for (int i = 0; i < slidingTileIndices.length; i++)
+ if (slidingTileIndices[i] != -1) // Don't draw the empty slot
+ AnimatedPositioned(
+ key: ValueKey('tile_${slidingTileIndices[i]}'),
+ duration: const Duration(milliseconds: 250),
+ curve: Curves.easeInOut,
+ left: (i % gameDTO.cols!) * tileWidth,
+ top: (i ~/ gameDTO.cols!) * tileHeight,
+ child: SlidingPuzzlePiece(
+ image: CachedNetworkImage(imageUrl: gameDTO.puzzleImage!.url!, fit: BoxFit.fill),
+ imageSize: puzzleSize,
+ originalRow: slidingTileIndices[i] ~/ gameDTO.cols!,
+ originalCol: slidingTileIndices[i] % gameDTO.cols!,
+ maxRows: gameDTO.rows!,
+ maxCols: gameDTO.cols!,
+ width: tileWidth,
+ height: tileHeight,
+ showNumberHint: showHint,
+ onTap: () => _onSlidingTileTapped(i),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ // Default: Puzzle
+ if (gameDTO.puzzleImage == null || gameDTO.puzzleImage!.url == null || realWidgetSize == null) {
+ return Center(child: Text("Aucune image à afficher", style: TextStyle(fontSize: kNoneInfoOrIncorrect)));
+ }
+
+ final puzzleSize = visitAppContext.puzzleSize ?? Size(realWidgetSize!.width * 0.8, realWidgetSize!.height * 0.6);
+
+ return Center(
+ child: Container(
+ width: puzzleSize.width,
+ height: puzzleSize.height,
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ // Hint Background
+ if (showHint)
+ Opacity(
+ opacity: 0.2, // Unified opacity for both
+ child: CachedNetworkImage(
+ imageUrl: gameDTO.puzzleImage!.url!,
+ fit: BoxFit.fill,
+ ),
+ ),
+ ...pieces,
+ ],
+ ),
+ ),
+ );
+ }
+
@override
Widget build(BuildContext context) {
final appContext = Provider.of(context);
@@ -335,21 +529,7 @@ class _GamePage extends State {
child: Padding(
key: _widgetKey,
padding: const EdgeInsets.all(0.0),
- child: isSplittingImage ? Center(child: LoadingCommon()) :
- gameDTO.puzzleImage == null || gameDTO.puzzleImage!.url == null || realWidgetSize == null
- ? Center(child: Text("Aucune image à afficher", style: TextStyle(fontSize: kNoneInfoOrIncorrect)))
- : Center(
- child: Padding(
- padding: const EdgeInsets.all(0.0),
- child: Container(
- width: visitAppContext.puzzleSize != null && visitAppContext.puzzleSize!.width > 0 ? visitAppContext.puzzleSize!.width : realWidgetSize!.width * 0.8,
- height: visitAppContext.puzzleSize != null && visitAppContext.puzzleSize!.height > 0 ? visitAppContext.puzzleSize!.height +1.5 : realWidgetSize!.height * 0.85,
- child: Stack(
- children: pieces,
- ),
- ),
- ),
- ),
+ child: isSplittingImage ? Center(child: LoadingCommon()) : _buildContent(visitAppContext),
),
),
)
@@ -358,6 +538,21 @@ class _GamePage extends State {
),
],
),
+ if (gameDTO.gameType == null || gameDTO.gameType == GameTypes.Puzzle || gameDTO.gameType == GameTypes.SlidingPuzzle)
+ Positioned(
+ bottom: 25,
+ right: 20,
+ child: FloatingActionButton(
+ heroTag: 'hint_button',
+ onPressed: () {
+ setState(() {
+ showHint = !showHint;
+ });
+ },
+ backgroundColor: showHint ? kMainColor : Colors.grey[400],
+ child: const Icon(Icons.help_outline, color: Colors.white, size: 28),
+ ),
+ ),
],
);
}
diff --git a/lib/Screens/Sections/Game/puzzle_piece.dart b/lib/Screens/Sections/Game/puzzle_piece.dart
index e03f5f3..1f1eb78 100644
--- a/lib/Screens/Sections/Game/puzzle_piece.dart
+++ b/lib/Screens/Sections/Game/puzzle_piece.dart
@@ -1,10 +1,5 @@
-import 'dart:math';
-
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
-import 'package:mymuseum_visitapp/Models/visitContext.dart';
-import 'package:mymuseum_visitapp/app_context.dart';
-import 'package:provider/provider.dart';
class PuzzlePiece extends StatefulWidget {
final CachedNetworkImage image;
@@ -15,31 +10,22 @@ class PuzzlePiece extends StatefulWidget {
final int maxCol;
final Function bringToTop;
final Function sendToBack;
+ final double initialTop;
+ final double initialLeft;
- static PuzzlePiece fromMap(Map map) {
- return PuzzlePiece(
- image: map['image'],
- imageSize: map['imageSize'],
- row: map['row'],
- col: map['col'],
- maxRow: map['maxRow'],
- maxCol: map['maxCol'],
- bringToTop: map['bringToTop'],
- sendToBack: map['SendToBack'],
- );
- }
-
- PuzzlePiece(
- {Key? key,
- required this.image,
- required this.imageSize,
- required this.row,
- required this.col,
- required this.maxRow,
- required this.maxCol,
- required this.bringToTop,
- required this.sendToBack})
- : super(key: key);
+ PuzzlePiece({
+ Key? key,
+ required this.image,
+ required this.imageSize,
+ required this.row,
+ required this.col,
+ required this.maxRow,
+ required this.maxCol,
+ required this.bringToTop,
+ required this.sendToBack,
+ required this.initialTop,
+ required this.initialLeft,
+ }) : super(key: key);
@override
_PuzzlePieceState createState() => _PuzzlePieceState();
@@ -58,65 +44,32 @@ class _PuzzlePieceState extends State {
@override
void initState() {
super.initState();
+ top = widget.initialTop;
+ left = widget.initialLeft;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- setState(() {
-
- RenderBox renderBox = _widgetPieceKey.currentContext?.findRenderObject() as RenderBox;
- Size size = renderBox.size;
-
- final appContext = Provider.of(context, listen: false);
- VisitAppContext visitAppContext = appContext.getContext();
- visitAppContext.puzzleSize = size; // do it another way
- appContext.setContext(visitAppContext);
-
- if(widget.row == 0 && widget.col == 0) {
- widget.sendToBack(widget);
- }
- });
- });
+ if (widget.row == 0 && widget.col == 0) {
+ isMovable = false;
+ top = 0;
+ left = 0;
+ }
}
@override
Widget build(BuildContext context) {
-
- var isPortrait = MediaQuery.of(context).orientation == Orientation.portrait;
-
- var imageHeight = isPortrait ? widget.imageSize.width/1.55 : widget.imageSize.width;
- var imageWidth = isPortrait ? widget.imageSize.width/1.55 : widget.imageSize.height;
-
- final pieceWidth = imageWidth / widget.maxCol;
- final pieceHeight = imageHeight / widget.maxRow;
-
- if (top == null) {
- top = Random().nextInt((imageHeight - pieceHeight).ceil()).toDouble();
- var test = top!;
- test -= widget.row * pieceHeight;
- top = test /7; // TODO change ?
- }
-
- if (left == null) {
- left = Random().nextInt((imageWidth - pieceWidth).ceil()).toDouble();
- var test = left!;
- test -= widget.col * pieceWidth;
- left = test /7; // TODO change ?
- }
-
- if(widget.row == 0 && widget.col == 0) {
- top = 0;
- left = 0;
- isMovable = false;
- }
-
- return Positioned(
- top: top,
- left: left,
- width: imageWidth,
+ return AnimatedPositioned(
+ duration: isMovable ? Duration.zero : const Duration(milliseconds: 300),
+ curve: Curves.easeOutBack,
+ top: top,
+ left: left,
+ width: widget.imageSize.width,
+ child: AnimatedScale(
+ duration: const Duration(milliseconds: 300),
+ scale: isMovable ? 1.05 : 1.0,
child: Container(
key: _widgetPieceKey,
decoration: widget.col == 0 && widget.row == 0 ? BoxDecoration(
border: Border.all(
- color: Colors.black,
+ color: Colors.black.withOpacity(0.3),
width: 0.5,
),
) : null,
@@ -134,34 +87,40 @@ class _PuzzlePieceState extends State {
onPanUpdate: (dragUpdateDetails) {
if (isMovable) {
setState(() {
- var testTop = top!;
- var testLeft = left!;
- testTop = top!;
- testLeft = left!;
- testTop += dragUpdateDetails.delta.dy;
- testLeft += dragUpdateDetails.delta.dx;
- top = testTop;
- left = testLeft;
+ top = top! + dragUpdateDetails.delta.dy;
+ left = left! + dragUpdateDetails.delta.dx;
- if (-10 < top! && top! < 10 && -10 < left! && left! < 10) {
- top = 0;
- left = 0;
- isMovable = false;
+ // Target position is (0,0) relative to its original offset in the image
+ if (top!.abs() < 15 && left!.abs() < 15) {
+ setState(() {
+ top = 0;
+ left = 0;
+ isMovable = false;
+ });
widget.sendToBack(widget);
}
});
}
},
- child: ClipPath(
- child: CustomPaint(
- foregroundPainter: PuzzlePiecePainter(
- widget.row, widget.col, widget.maxRow, widget.maxCol),
- child: widget.image),
+ child: PhysicalShape(
clipper: PuzzlePieceClipper(
widget.row, widget.col, widget.maxRow, widget.maxCol),
+ elevation: isMovable ? 4.0 : 0.0,
+ color: Colors.transparent,
+ shadowColor: Colors.black.withOpacity(0.5),
+ child: ClipPath(
+ child: CustomPaint(
+ foregroundPainter: PuzzlePiecePainter(
+ widget.row, widget.col, widget.maxRow, widget.maxCol),
+ child: widget.image),
+ clipper: PuzzlePieceClipper(
+ widget.row, widget.col, widget.maxRow, widget.maxCol),
+ ),
),
),
- ));
+ ),
+ ),
+ );
}
}
@@ -195,9 +154,9 @@ class PuzzlePiecePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
- ..color = Colors.black//Color(0x80FFFFFF)
+ ..color = Colors.black.withOpacity(0.2)
..style = PaintingStyle.stroke
- ..strokeWidth = 2.5;
+ ..strokeWidth = 1.0;
canvas.drawPath(getPiecePath(size, row, col, maxRow, maxCol), paint);
}
diff --git a/lib/Screens/Sections/Game/sliding_puzzle_piece.dart b/lib/Screens/Sections/Game/sliding_puzzle_piece.dart
new file mode 100644
index 0000000..460540f
--- /dev/null
+++ b/lib/Screens/Sections/Game/sliding_puzzle_piece.dart
@@ -0,0 +1,88 @@
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+
+class SlidingPuzzlePiece extends StatelessWidget {
+ final CachedNetworkImage image;
+ final Size imageSize;
+ final int originalRow;
+ final int originalCol;
+ final int maxRows;
+ final int maxCols;
+ final VoidCallback onTap;
+ final double width;
+ final double height;
+ final bool showNumberHint;
+
+ const SlidingPuzzlePiece({
+ Key? key,
+ required this.image,
+ required this.imageSize,
+ required this.originalRow,
+ required this.originalCol,
+ required this.maxRows,
+ required this.maxCols,
+ required this.onTap,
+ required this.width,
+ required this.height,
+ this.showNumberHint = false,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ width: width,
+ height: height,
+ margin: const EdgeInsets.all(2), // Subtle gap
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.2),
+ blurRadius: 4,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: Stack(
+ children: [
+ OverflowBox(
+ maxWidth: imageSize.width,
+ maxHeight: imageSize.height,
+ alignment: Alignment(
+ maxCols == 1 ? 0.0 : -1.0 + (originalCol * 2 / (maxCols - 1)),
+ maxRows == 1 ? 0.0 : -1.0 + (originalRow * 2 / (maxRows - 1)),
+ ),
+ child: image,
+ ),
+ if (showNumberHint)
+ Container(
+ color: Colors.black.withOpacity(0.3),
+ child: Center(
+ child: Text(
+ '${originalRow * maxCols + originalCol + 1}',
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ shadows: [
+ Shadow(
+ blurRadius: 4.0,
+ color: Colors.black,
+ offset: Offset(0, 0),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart b/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart
index 63b77fd..12ae63f 100644
--- a/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart
+++ b/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart
@@ -8,6 +8,7 @@ import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/ResponseSubDTO.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart';
+import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart';
/// Vue de progression centrée contenu (escape game, ou parcours sans carte).
@@ -160,6 +161,7 @@ class _GuidedPathContentProgressionPageState
}
void _checkGeoZone(LatLng position) {
+ if (!mounted) return;
final step = _currentStep;
if (step == null || !_hasGeoTrigger(step)) return;
@@ -176,8 +178,14 @@ class _GuidedPathContentProgressionPageState
if (center == null) return;
final dist = const Distance().distance(position, center);
- if ((dist <= radius) != _inGeoZone) {
- setState(() => _inGeoZone = dist <= radius);
+ final inZone = dist <= radius;
+ if (inZone && !_inGeoZone) {
+ PushNotificationService.showGeoZoneNotification(
+ stepTitle: _translate(_currentStep!.title),
+ );
+ }
+ if (inZone != _inGeoZone) {
+ setState(() => _inGeoZone = inZone);
}
}
diff --git a/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart b/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart
index 22703aa..169d0a6 100644
--- a/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart
+++ b/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart
@@ -8,6 +8,7 @@ import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/ResponseSubDTO.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart';
+import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart';
import 'package:mymuseum_visitapp/constants.dart';
@@ -193,6 +194,7 @@ class _GuidedPathMapProgressionPageState extends State _userPosition = newPos);
_checkGeoZone(newPos);
@@ -200,6 +202,7 @@ class _GuidedPathMapProgressionPageState extends State _inGeoZone = inZone);
}
diff --git a/lib/Screens/Sections/Map/map_page.dart b/lib/Screens/Sections/Map/map_page.dart
index 1e92a86..c0c0b4f 100644
--- a/lib/Screens/Sections/Map/map_page.dart
+++ b/lib/Screens/Sections/Map/map_page.dart
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
//import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
+import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Map/flutter_map_view.dart';
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_list_sheet.dart';
import 'package:mymuseum_visitapp/Screens/Sections/Map/geo_point_filter.dart';
@@ -38,9 +39,8 @@ class MapPage extends StatefulWidget {
class _MapPage extends State {
MapDTO? mapDTO;
- //Completer _controller = Completer();
- //Uint8List? selectedMarkerIcon;
late ValueNotifier> _geoPoints = ValueNotifier>([]);
+ bool _showListView = false;
/*Future getBytesFromAsset(ByteData data, int width) async {
//ByteData data = await rootBundle.load(path);
@@ -72,6 +72,39 @@ class _MapPage extends State {
tilt: 59.440717697143555,
zoom: 59.151926040649414);*/
+ Widget _buildListView(List points, VisitAppContext visitAppContext) {
+ if (points.isEmpty) {
+ return const Center(child: Text('Aucun point à afficher'));
+ }
+ return ListView.builder(
+ padding: const EdgeInsets.only(top: 80, bottom: 24),
+ itemCount: points.length,
+ itemBuilder: (_, i) {
+ final point = points[i];
+ final title = TranslationHelper.get(point.title, visitAppContext);
+ final desc = TranslationHelper.get(point.description, visitAppContext);
+ return ListTile(
+ leading: point.imageUrl != null
+ ? ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: Image.network(
+ point.imageUrl!,
+ width: 48,
+ height: 48,
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => const Icon(Icons.place, color: kMainColor, size: 32),
+ ),
+ )
+ : const Icon(Icons.place, color: kMainColor, size: 32),
+ title: Text(title.isNotEmpty ? title : 'Point d\'intérêt'),
+ subtitle: desc.isNotEmpty
+ ? Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis)
+ : null,
+ );
+ },
+ );
+ }
+
@override
Widget build(BuildContext context) {
//final mapContext = Provider.of(context);
@@ -104,15 +137,15 @@ class _MapPage extends State {
ValueListenableBuilder>(
valueListenable: _geoPoints,
builder: (context, value, _) {
+ if (_showListView) {
+ return _buildListView(value, visitAppContext);
+ }
switch(mapDTO!.mapProvider) {
case MapProvider.Google:
return GoogleMapView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO!, icons: widget.icons);
case MapProvider.MapBox:
return MapBoxView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO, icons: widget.icons);
- // If mapbox bug as 3.24 flutter, we can test via this new widget
- return FlutterMapView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO, icons: widget.icons);
default:
- // By default google
return GoogleMapView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO!, icons: widget.icons);
}
}
@@ -125,7 +158,7 @@ class _MapPage extends State {
filteredPoints: (value) {
_geoPoints.value = value!;
}),
- MarkerViewWidget(),
+ if (!_showListView) MarkerViewWidget(),
Positioned(
top: 35,
left: 10,
@@ -181,6 +214,33 @@ class _MapPage extends State {
),
),
),
+ if (mapDTO?.isListViewEnabled == true)
+ Positioned(
+ bottom: 24,
+ right: 16,
+ child: GestureDetector(
+ onTap: () => setState(() => _showListView = !_showListView),
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ decoration: BoxDecoration(
+ color: primaryColor,
+ borderRadius: BorderRadius.circular(25),
+ boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 2))],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(_showListView ? Icons.map_outlined : Icons.list, size: 18, color: Colors.white),
+ const SizedBox(width: 6),
+ Text(
+ _showListView ? 'Carte' : 'Liste',
+ style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
]
/*floatingActionButton: FloatingActionButton.extended(
onPressed: _goToTheLake,
diff --git a/lib/Screens/Sections/Video/video_page.dart b/lib/Screens/Sections/Video/video_page.dart
index 34f3176..0e20742 100644
--- a/lib/Screens/Sections/Video/video_page.dart
+++ b/lib/Screens/Sections/Video/video_page.dart
@@ -1,15 +1,26 @@
-import 'dart:convert';
-
-import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:manager_api_new/api.dart';
+import 'package:mymuseum_visitapp/Components/video_viewer.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart';
-//import 'package:youtube_player_iframe/youtube_player_iframe.dart' as iframe;
-import 'package:youtube_player_flutter/youtube_player_flutter.dart';
+import 'package:webview_flutter/webview_flutter.dart';
+import 'package:youtube_player_iframe/youtube_player_iframe.dart' as iframe;
+
+enum _VideoSourceType { youtube, vimeo, direct }
+
+_VideoSourceType _detectSourceType(String url) {
+ if (url.contains('youtube.com') || url.contains('youtu.be')) return _VideoSourceType.youtube;
+ if (url.contains('vimeo.com')) return _VideoSourceType.vimeo;
+ return _VideoSourceType.direct;
+}
+
+String? _extractVimeoId(String url) {
+ final match = RegExp(r'vimeo\.com/(?:.*?/)?(\d+)').firstMatch(url);
+ return match?.group(1);
+}
class VideoPage extends StatefulWidget {
final VideoDTO section;
@@ -20,95 +31,92 @@ class VideoPage extends StatefulWidget {
}
class _VideoPage extends State {
- //iframe.YoutubePlayer? _videoViewWeb;
- YoutubePlayer? _videoView;
- VideoDTO? videoDTO;
- late YoutubePlayerController _controller;
+ iframe.YoutubePlayerController? _youtubeController;
+ WebViewController? _vimeoController;
+ _VideoSourceType? _sourceType;
bool isFullScreen = false;
+ static const _browserUserAgent =
+ 'Mozilla/5.0 (Linux; Android 10; Tablet) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
+
@override
void initState() {
- //print(widget.section!.data);
- //videoDTO = VideoDTO.fromJson(jsonDecode(widget.section!.data!));
- //print(videoDTO);
- videoDTO= widget.section;
+ super.initState();
+ final source = widget.section.source_;
+ if (source == null || source.isEmpty) return;
- String? videoId;
- if (videoDTO!.source_ != null && videoDTO!.source_!.isNotEmpty) {
- videoId = YoutubePlayer.convertUrlToId(videoDTO!.source_!);
+ _sourceType = _detectSourceType(source);
- /*if (false) {
- final _controllerWeb = iframe.YoutubePlayerController(
- params: iframe.YoutubePlayerParams(
- mute: false,
- showControls: true,
- showFullscreenButton: false,
- loop: true,
- showVideoAnnotations: false,
- strictRelatedVideos: false,
- enableKeyboard: false,
- enableCaption: false,
- pointerEvents: iframe.PointerEvents.auto
- ),
- );
-
- _controllerWeb.loadVideo(videoDTO!.source_!);
-
- _videoViewWeb = iframe.YoutubePlayer(
- controller: _controllerWeb,
- //showVideoProgressIndicator: false,
- /*progressIndicatorColor: Colors.amber,
- progressColors: ProgressBarColors(
- playedColor: Colors.amber,
- handleColor: Colors.amberAccent,
- ),*/
- );
- } else {*/
- videoId = YoutubePlayer.convertUrlToId(videoDTO!.source_!);
- _controller = YoutubePlayerController(
- initialVideoId: videoId!,
- flags: const YoutubePlayerFlags(
- autoPlay: true,
- controlsVisibleAtStart: false,
- loop: true,
- hideControls: false,
- hideThumbnail: true,
- ),
- )..addListener(_onYoutubePlayerChanged);
-
- _videoView = YoutubePlayer(
- controller: _controller,
- //showVideoProgressIndicator: false,
- progressIndicatorColor: kMainColor,
- progressColors: const ProgressBarColors(
- playedColor: kMainColor,
- handleColor: kSecondColor,
- ),
- );
- //}
- _controller.toggleFullScreenMode();
- super.initState();
- }
- }
-
- void _onYoutubePlayerChanged() {
- if (_controller.value.isFullScreen) {
- setState(() {
- isFullScreen = true;
- });
- } else {
- setState(() {
- isFullScreen = false;
+ if (_sourceType == _VideoSourceType.youtube) {
+ _youtubeController = iframe.YoutubePlayerController(
+ params: const iframe.YoutubePlayerParams(
+ mute: false,
+ showControls: true,
+ showFullscreenButton: true,
+ loop: true,
+ showVideoAnnotations: false,
+ strictRelatedVideos: false,
+ enableKeyboard: false,
+ enableCaption: false,
+ pointerEvents: iframe.PointerEvents.auto,
+ userAgent: _browserUserAgent,
+ ),
+ );
+ _youtubeController!.loadVideo(source);
+ _youtubeController!.listen((value) {
+ if (mounted) {
+ setState(() {
+ isFullScreen = value.fullScreenOption.enabled;
+ });
+ }
});
+ } else if (_sourceType == _VideoSourceType.vimeo) {
+ final vimeoId = _extractVimeoId(source);
+ if (vimeoId != null) {
+ _vimeoController = WebViewController()
+ ..setJavaScriptMode(JavaScriptMode.unrestricted)
+ ..setUserAgent(_browserUserAgent)
+ ..loadRequest(Uri.parse('https://player.vimeo.com/video/$vimeoId'));
+ }
}
}
@override
void dispose() {
- _videoView = null;
+ _youtubeController?.close();
super.dispose();
}
+ Widget _buildPlayer() {
+ final source = widget.section.source_;
+ if (source == null || source.isEmpty) {
+ return const Center(
+ child: Text(
+ "La vidéo ne peut pas être affichée, l'url est incorrecte",
+ style: TextStyle(fontSize: kNoneInfoOrIncorrect),
+ ),
+ );
+ }
+ switch (_sourceType) {
+ case _VideoSourceType.youtube:
+ return iframe.YoutubePlayer(controller: _youtubeController!);
+ case _VideoSourceType.vimeo:
+ if (_vimeoController == null) {
+ return const Center(
+ child: Text(
+ "Impossible d'extraire l'identifiant Vimeo depuis l'URL",
+ style: TextStyle(fontSize: kNoneInfoOrIncorrect),
+ ),
+ );
+ }
+ return WebViewWidget(controller: _vimeoController!);
+ case _VideoSourceType.direct:
+ return VideoViewer(videoUrl: source, file: null);
+ default:
+ return const SizedBox();
+ }
+ }
+
@override
Widget build(BuildContext context) {
Size size = MediaQuery.of(context).size;
@@ -127,32 +135,19 @@ class _VideoPage extends State {
color: kMainGrey,
spreadRadius: 0.5,
blurRadius: 5,
- offset: Offset(0, 1), // changes position of shadow
+ offset: Offset(0, 1),
),
],
gradient: const LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
- colors: [
- /*Color(0xFFDD79C2),
- Color(0xFFB65FBE),
- Color(0xFF9146BA),
- Color(0xFF7633B8),
- Color(0xFF6528B6),
- Color(0xFF6025B6)*/
- kMainColor0, //Color(0xFFf6b3c4)
- kMainColor1,
- kMainColor2,
-
- ],
+ colors: [kMainColor0, kMainColor1, kMainColor2],
),
image: widget.section.imageSource != null ? DecorationImage(
fit: BoxFit.cover,
opacity: 0.65,
- image: NetworkImage(
- widget.section.imageSource!,
- ),
- ): null,
+ image: NetworkImage(widget.section.imageSource!),
+ ) : null,
),
) : const SizedBox(),
Column(
@@ -167,12 +162,11 @@ class _VideoPage extends State {
child: Padding(
padding: const EdgeInsets.only(top: 22.0),
child: SizedBox(
- width: size.width *0.7,
+ width: size.width * 0.7,
child: HtmlWidget(
cleanedTitle,
textStyle: const TextStyle(color: Colors.white, fontFamily: 'Roboto', fontSize: 20),
- customStylesBuilder: (element)
- {
+ customStylesBuilder: (element) {
return {'text-align': 'center', 'font-family': "Roboto", '-webkit-line-clamp': "2"};
},
),
@@ -183,81 +177,75 @@ class _VideoPage extends State {
top: 35,
left: 10,
child: SizedBox(
- width: 50,
- height: 50,
- child: InkWell(
- onTap: () {
- _controller.dispose();
- _videoView = null;
- Navigator.of(context).pop();
- },
- child: Container(
- decoration: const BoxDecoration(
- color: kMainColor,
- shape: BoxShape.circle,
- ),
- child: const Icon(Icons.arrow_back, size: 23, color: Colors.white)
+ width: 50,
+ height: 50,
+ child: InkWell(
+ onTap: () => Navigator.of(context).pop(),
+ child: Container(
+ decoration: const BoxDecoration(
+ color: kMainColor,
+ shape: BoxShape.circle,
),
- )
+ child: const Icon(Icons.arrow_back, size: 23, color: Colors.white),
+ ),
+ ),
),
),
],
),
- ): const SizedBox(),
+ ) : const SizedBox(),
Expanded(
child: Container(
- margin: const EdgeInsets.only(top: 0),
- decoration: const BoxDecoration(
- boxShadow: [
- BoxShadow(
- color: kMainGrey,
- spreadRadius: 0.5,
- blurRadius: 2,
- offset: Offset(0, 1), // changes position of shadow
- ),
- ],
- color: kBackgroundColor,
- borderRadius: BorderRadius.only(
- topLeft: Radius.circular(30),
- topRight: Radius.circular(30),
+ margin: const EdgeInsets.only(top: 0),
+ decoration: const BoxDecoration(
+ boxShadow: [
+ BoxShadow(
+ color: kMainGrey,
+ spreadRadius: 0.5,
+ blurRadius: 2,
+ offset: Offset(0, 1),
),
+ ],
+ color: kBackgroundColor,
+ borderRadius: BorderRadius.only(
+ topLeft: Radius.circular(30),
+ topRight: Radius.circular(30),
),
- child: ClipRRect(
- borderRadius: !isFullScreen ? const BorderRadius.only(
- topLeft: Radius.circular(30),
- topRight: Radius.circular(30),
- ): BorderRadius.zero,
- child: videoDTO!.source_ != null && videoDTO!.source_!.isNotEmpty ?
- _videoView :
- const Center(child: Text("La vidéo ne peut pas être affichée, l'url est incorrecte", style: TextStyle(fontSize: kNoneInfoOrIncorrect))))
+ ),
+ child: ClipRRect(
+ borderRadius: !isFullScreen ? const BorderRadius.only(
+ topLeft: Radius.circular(30),
+ topRight: Radius.circular(30),
+ ) : BorderRadius.zero,
+ child: _buildPlayer(),
+ ),
),
),
],
),
- isFullScreen ? Positioned(
- top: 35,
- left: 10,
- child: SizedBox(
+ if (isFullScreen)
+ Positioned(
+ top: 35,
+ left: 10,
+ child: SizedBox(
width: 50,
height: 50,
child: InkWell(
onTap: () {
- _controller.toggleFullScreenMode();
- _controller.dispose();
- _videoView = null;
+ _youtubeController?.exitFullScreen();
Navigator.of(context).pop();
},
child: Container(
- decoration: const BoxDecoration(
- color: kMainColor,
- shape: BoxShape.circle,
- ),
- child: const Icon(Icons.arrow_back, size: 23, color: Colors.white)
+ decoration: const BoxDecoration(
+ color: kMainColor,
+ shape: BoxShape.circle,
+ ),
+ child: const Icon(Icons.arrow_back, size: 23, color: Colors.white),
),
- )
+ ),
+ ),
),
- ): const SizedBox(),
],
);
}
-}
\ No newline at end of file
+}
diff --git a/lib/Screens/Sections/Weather/weather_page.dart b/lib/Screens/Sections/Weather/weather_page.dart
index 49175a6..c146712 100644
--- a/lib/Screens/Sections/Weather/weather_page.dart
+++ b/lib/Screens/Sections/Weather/weather_page.dart
@@ -1,22 +1,34 @@
import 'dart:convert';
-import 'package:auto_size_text/auto_size_text.dart';
import 'package:cached_network_image/cached_network_image.dart';
-import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
-import 'package:flutter/widgets.dart';
+import 'package:intl/intl.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/Models/weatherData.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:provider/provider.dart';
-import 'package:intl/intl.dart';
+
+class _DaySummary {
+ final int dt;
+ final List forecasts;
+ final WeatherForecast representative;
+ final double minTemp;
+ final double maxTemp;
+
+ _DaySummary({
+ required this.dt,
+ required this.forecasts,
+ required this.representative,
+ required this.minTemp,
+ required this.maxTemp,
+ });
+}
class WeatherPage extends StatefulWidget {
final WeatherDTO section;
- WeatherPage({required this.section});
+ const WeatherPage({super.key, required this.section});
@override
State createState() => _WeatherPageState();
@@ -24,359 +36,354 @@ class WeatherPage extends StatefulWidget {
class _WeatherPageState extends State {
WeatherDTO weatherDTO = WeatherDTO();
- WeatherData? weatherData = null;
- int nbrNextHours = 5;
+ WeatherData? weatherData;
+ List<_DaySummary> _days = [];
+ int _selectedDayIndex = 0;
@override
void initState() {
- /*print(widget.section!.data);
- weatherDTO = WeatherDTO.fromJson(jsonDecode(widget.section!.data!))!;
- print(weatherDTO);*/
- weatherDTO = widget.section;
- if(weatherDTO.result != null) {
- Map weatherResultInJson = jsonDecode(weatherDTO.result!);
- weatherData = WeatherData.fromJson(weatherResultInJson);
- }
-
super.initState();
+ weatherDTO = widget.section;
+ if (weatherDTO.result != null) {
+ weatherData = WeatherData.fromJson(jsonDecode(weatherDTO.result!));
+ _days = _buildDays(weatherData!.list!);
+ }
}
- String formatTimestamp(int timestamp, AppContext appContext, bool isHourOnly, bool isDateOnly) {
+ List<_DaySummary> _buildDays(List allForecasts) {
+ final Map> byDay = {};
- DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
-
- // Determine the date format based on the application language
- String dateFormat = appContext.getContext().language.toString().toUpperCase() == "EN" ?
- 'MM/dd/yyyy HH:mm'
- : 'dd/MM/yyyy HH:mm';
-
- if(isHourOnly) {
- dateFormat = 'HH:mm';
+ for (final f in allForecasts) {
+ final date = DateTime.fromMillisecondsSinceEpoch(f.dt! * 1000);
+ final key = DateTime(date.year, date.month, date.day).millisecondsSinceEpoch;
+ byDay.putIfAbsent(key, () => []).add(f);
}
- if(isDateOnly) {
- dateFormat = dateFormat.replaceAll('/yyyy HH:mm', '');
- }
-
- String formattedDate = DateFormat(dateFormat).format(dateTime);
-
- return formattedDate;
+ return byDay.entries.take(6).map((entry) {
+ final forecasts = entry.value;
+ final representative = forecasts.firstWhere(
+ (f) => DateTime.fromMillisecondsSinceEpoch(f.dt! * 1000).hour == 12,
+ orElse: () => forecasts.last,
+ );
+ final minTemp = forecasts.map((f) => f.main!.tempMin!).reduce((a, b) => a < b ? a : b);
+ final maxTemp = forecasts.map((f) => f.main!.tempMax!).reduce((a, b) => a > b ? a : b);
+ return _DaySummary(
+ dt: entry.key ~/ 1000,
+ forecasts: forecasts,
+ representative: representative,
+ minTemp: minTemp,
+ maxTemp: maxTemp,
+ );
+ }).toList();
}
- String getTranslatedDayOfWeek(int timestamp, AppContext appContext, bool isDate) {
- DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
-
- String dayToPrint = "";
-
- print("dateTime.weekday");
- print(dateTime.weekday);
-
- switch(dateTime.weekday) {
- case 1:
- dayToPrint = TranslationHelper.getFromLocale("monday", appContext.getContext());
- break;
- case 2:
- dayToPrint = TranslationHelper.getFromLocale("tuesday", appContext.getContext());
- break;
- case 3:
- dayToPrint = TranslationHelper.getFromLocale("wednesday", appContext.getContext());
- break;
- case 4:
- dayToPrint = TranslationHelper.getFromLocale("thursday", appContext.getContext());
- break;
- case 5:
- dayToPrint = TranslationHelper.getFromLocale("friday", appContext.getContext());
- break;
- case 6:
- dayToPrint = TranslationHelper.getFromLocale("saturday", appContext.getContext());
- break;
- case 7:
- dayToPrint = TranslationHelper.getFromLocale("sunday", appContext.getContext());
- break;
+ String _shortDayLabel(int dt, AppContext appContext) {
+ final now = DateTime.now();
+ final date = DateTime.fromMillisecondsSinceEpoch(dt * 1000);
+ if (date.day == now.day && date.month == now.month) {
+ final lang = appContext.getContext().language?.toString().toUpperCase() ?? 'FR';
+ return lang == 'EN' ? 'Today' : lang == 'NL' ? 'Vandaag' : lang == 'DE' ? 'Heute' : 'Auj.';
}
-
- return isDate ? "${dayToPrint} ${formatTimestamp(timestamp, appContext, false, true)}" : dayToPrint;
+ return _weekdayName(date.weekday, appContext, short: true);
}
- List getNextFiveDaysForecast(List allForecasts) {
- List nextFiveDaysForecast = [];
- DateTime today = DateTime.now();
-
- List nextDay1All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 1))).day).toList();
- List nextDay2All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 2))).day).toList();
- List nextDay3All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 3))).day).toList();
- List nextDay4All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 4))).day).toList();
- List nextDay5All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 5))).day).toList();
-
- var nextDay1MiddayTest = nextDay1All.where((nd) => (DateTime.fromMillisecondsSinceEpoch(nd.dt! * 1000)).hour == 12).firstOrNull;
- if(nextDay1All.isNotEmpty) {
- WeatherForecast nextDay1AllSummary = nextDay1MiddayTest ?? nextDay1All.last;
- nextFiveDaysForecast.add(nextDay1AllSummary);
+ String _fullDayLabel(int dt, AppContext appContext) {
+ final now = DateTime.now();
+ final date = DateTime.fromMillisecondsSinceEpoch(dt * 1000);
+ final lang = appContext.getContext().language?.toString().toUpperCase() ?? 'FR';
+ if (date.day == now.day && date.month == now.month) {
+ return lang == 'EN' ? 'Today' : lang == 'NL' ? 'Vandaag' : lang == 'DE' ? 'Heute' : "Aujourd'hui";
}
-
- var nextDay2MiddayTest = nextDay2All.where((nd) => (DateTime.fromMillisecondsSinceEpoch(nd.dt! * 1000)).hour == 12).firstOrNull;
- if(nextDay2All.isNotEmpty) {
- WeatherForecast nextDay2Midday = nextDay2MiddayTest ?? nextDay2All.last;
- nextFiveDaysForecast.add(nextDay2Midday);
+ final dayName = _weekdayName(date.weekday, appContext, short: false);
+ final locale = lang == 'EN' ? 'en_US' : lang == 'NL' ? 'nl_NL' : lang == 'DE' ? 'de_DE' : 'fr_FR';
+ try {
+ final monthDay = lang == 'EN'
+ ? DateFormat('MMMM d', locale).format(date)
+ : DateFormat('d MMMM', locale).format(date);
+ return '$dayName $monthDay';
+ } catch (_) {
+ return '$dayName ${date.day}/${date.month}';
}
+ }
- var nextDay3MiddayTest = nextDay3All.where((nd) => (DateTime.fromMillisecondsSinceEpoch(nd.dt! * 1000)).hour == 12).firstOrNull;
- if(nextDay3All.isNotEmpty) {
- WeatherForecast nextDay3Midday = nextDay3MiddayTest ?? nextDay3All.last;
- nextFiveDaysForecast.add(nextDay3Midday);
- }
-
- var nextDay4MiddayTest = nextDay4All.where((nd) => (DateTime.fromMillisecondsSinceEpoch(nd.dt! * 1000)).hour == 12).firstOrNull;
- if(nextDay4All.isNotEmpty) {
- WeatherForecast nextDay4Midday = nextDay4MiddayTest ?? nextDay4All.last;
- nextFiveDaysForecast.add(nextDay4Midday);
- }
-
- var nextDay5MiddayTest = nextDay5All.where((nd) => (DateTime.fromMillisecondsSinceEpoch(nd.dt! * 1000)).hour == 12).firstOrNull;
- if(nextDay5All.isNotEmpty) {
- WeatherForecast nextDay5Midday = nextDay5MiddayTest ?? nextDay5All.last;
- nextFiveDaysForecast.add(nextDay5Midday);
- }
-
- return nextFiveDaysForecast;
+ String _weekdayName(int weekday, AppContext appContext, {required bool short}) {
+ final ctx = appContext.getContext();
+ final keys = {
+ 1: 'monday',
+ 2: 'tuesday',
+ 3: 'wednesday',
+ 4: 'thursday',
+ 5: 'friday',
+ 6: 'saturday',
+ 7: 'sunday',
+ };
+ final full = TranslationHelper.getFromLocale(keys[weekday]!, ctx);
+ return short && full.length > 3 ? '${full.substring(0, 3)}.' : full;
}
@override
Widget build(BuildContext context) {
- Size size = MediaQuery.of(context).size;
final appContext = Provider.of(context);
- VisitAppContext visitAppContext = appContext.getContext();
-
- var primaryColor = visitAppContext.configuration != null ? visitAppContext.configuration!.primaryColor != null ? Color(int.parse(visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16)) : kSecondColor : kSecondColor;
+ final visitAppContext = appContext.getContext();
+ final primaryColor = visitAppContext.configuration?.primaryColor != null
+ ? Color(int.parse(
+ visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0],
+ radix: 16))
+ : kSecondColor;
+ final roundedValue =
+ visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0;
return Stack(
children: [
- weatherData == null ? const Center(child: Text("Aucune donnée à afficher")) : Container( // TODO translate ?
- decoration: BoxDecoration(
- borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0)),
- gradient: LinearGradient(
- begin: Alignment.topRight,
- end: Alignment.bottomLeft,
- stops: const [
- 0.2,
- 0.5,
- 0.9,
- 0.95
- ],
- colors: [
- Colors.blue[50]!,
- Colors.blue[100]!,
- Colors.blue[200]!,
- Colors.blue[300]!
- ]
- )
- ),
- //color: Colors.yellow,
- //height: 300,
- //width: 300,
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Padding(
- padding: const EdgeInsets.all(10.0),
- child: Center(child: Text(weatherDTO.city!, style: const TextStyle(fontSize: kSectionTitleDetailSize, fontWeight: FontWeight.w500, color: Colors.black54, fontFamily: "Roboto"))),
- ),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Padding(
- padding: const EdgeInsets.all(8.0),
- child: Text("${weatherData!.list!.first.main!.temp!.round().toString()}°", style: const TextStyle(fontSize: 55.0, fontWeight: FontWeight.w400, color: Colors.black54, fontFamily: "Roboto")),
- ),
- Container(
- //color: Colors.green,
- height: size.height * 0.2,
- width: size.width * 0.45,
- constraints: BoxConstraints(minWidth: 80),
- child: Center(
- child: CachedNetworkImage(imageUrl: "https://openweathermap.org/img/wn/${weatherData!.list!.first.weather!.first.icon!}@4x.png")
- )
- ),
- Container(
- // color: Colors.green,
- width: size.width * 0.2,
- //color: Colors.red,
- constraints: BoxConstraints(minWidth: 100),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.spaceAround,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Padding(
- padding: const EdgeInsets.all(8.0),
- child: Column(
- children: [
- const Icon(Icons.water_drop_outlined, color: kSecondColor),
- Text("${weatherData!.list!.first.pop!.round().toString()}%", style: const TextStyle(fontSize: 15.0, fontWeight: FontWeight.w400, color: Colors.black54, fontFamily: "Roboto")),
- ],
- ),
- ),
- Padding(
- padding: const EdgeInsets.all(8.0),
- child: Column(
- children: [
- const Icon(Icons.air, color: kSecondColor),
- Text("${(weatherData!.list!.first.wind!.speed! * 3.6).toStringAsFixed(1)}km/h", style: const TextStyle(fontSize: 15.0, fontWeight: FontWeight.w400, color: Colors.black54, fontFamily: "Roboto")),
- ],
- ),
- ),
- ],
- ),
- ),
- ]),
- Container(
- height: size.height * 0.25,
- width: size.width,
- /*decoration: BoxDecoration(
- borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0)),
- //color: Colors.grey,
- ),*/
- child: Column(
- children: [
- Padding(
- padding: const EdgeInsets.only(left: 15, bottom: 10),
- child: Align(alignment: Alignment.centerLeft, child: Text(TranslationHelper.getFromLocale("weather.hourly", appContext.getContext()), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w400, color: Colors.black54, fontFamily: "Roboto"))),
- ),
- Container(
- height: size.height * 0.18,
- width: size.width,
- //color: Colors.lightGreen,
- child: ListView(
- scrollDirection: Axis.horizontal,
- children: List.generate(
- nbrNextHours,
- (index) {
- final weatherForecast = weatherData!.list!.sublist(1)[index];
- return Padding(
- padding: const EdgeInsets.all(8.0),
- child: Container(
- height: size.height * 0.15,
- width: size.width * 0.25,
- constraints: const BoxConstraints(minWidth: 125, maxWidth: 250),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0)),
- color: Colors.lightBlueAccent,
- boxShadow: [
- BoxShadow(
- color: kBackgroundGrey.withValues(alpha: 0.6),
- spreadRadius: 0.75,
- blurRadius: 3.1,
- offset: Offset(0, 2.5), // changes position of shadow
- ),
- ],
- ),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.spaceAround,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Text(formatTimestamp(weatherForecast.dt!, appContext, true, false), style: const TextStyle(fontSize: 12.0, fontWeight: FontWeight.w400, color: Colors.white, fontFamily: "Roboto")),
- Center(child: CachedNetworkImage(imageUrl: "https://openweathermap.org/img/wn/${weatherForecast.weather!.first.icon!}.png")),
- Text('${weatherForecast.main!.temp!.round().toString()}°', style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.w600, color: Colors.white, fontFamily: "Roboto")),
- ],
- ),
- ),
- );
- },
- ),
- ),
- ),
- ],
- ),
- ),
- Padding(
- padding: const EdgeInsets.all(0.0),
- child: Container(
- height: size.height * 0.3,
- width: size.width,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0)),
- //color: Colors.amber,
- ),
- child: Column(
- children: [
- Padding(
- padding: const EdgeInsets.all(8.0),
- child: Align(alignment: Alignment.centerLeft, child: Text(TranslationHelper.getFromLocale("weather.nextdays", appContext.getContext()), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w400, color: Colors.black54, fontFamily: "Roboto"))),
- ),
- Container(
- height: size.height * 0.23,
- width: size.width,
- //color: Colors.lightGreen,
- child: ListView(
- scrollDirection: Axis.horizontal,
- children:List.generate(
- getNextFiveDaysForecast(weatherData!.list!).length, // nbrNextHours
- (index) {
- final weatherForecastNextDay = getNextFiveDaysForecast(weatherData!.list!)[index];
- return Padding(
- padding: const EdgeInsets.all(8.0),
- child: Container(
- height: size.height * 0.22,
- width: size.width * 0.125,
- constraints: const BoxConstraints(minWidth: 150, maxWidth: 250),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0)),
- color: Colors.lightBlue,
- boxShadow: [
- BoxShadow(
- color: kBackgroundGrey.withValues(alpha: 0.5),
- spreadRadius: 0.75,
- blurRadius: 3.1,
- offset: const Offset(0, 2.5), // changes position of shadow
- ),
- ],
- ),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.spaceAround,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Center(child: CachedNetworkImage(imageUrl: "https://openweathermap.org/img/wn/${weatherForecastNextDay.weather!.first.icon!}@2x.png")),
- Text('${weatherForecastNextDay.main!.temp!.round().toString()}°', style: const TextStyle(fontSize: 25.0, fontWeight: FontWeight.w600, color: Colors.white, fontFamily: "Roboto")),
- Text(getTranslatedDayOfWeek(weatherForecastNextDay.dt!, appContext, true), style: const TextStyle(fontSize: 16.0, fontWeight: FontWeight.w400, color: Colors.white, fontFamily: "Roboto")),
- ],
- ),
- ),
- );
- },
- ),
- ),
- ),
- ],
- ),
- ),
- )
- ],
- ),
- ),
+ weatherData == null || _days.isEmpty
+ ? const Center(child: Text("Aucune donnée météo"))
+ : _buildContent(appContext, primaryColor, roundedValue),
Positioned(
top: 35,
left: 10,
child: SizedBox(
- width: 50,
- height: 50,
- child: InkWell(
- onTap: () {
- Navigator.of(context).pop();
- },
- child: Container(
- decoration: BoxDecoration(
- color: primaryColor,
- shape: BoxShape.circle,
- ),
- child: const Icon(Icons.arrow_back, size: 23, color: Colors.white)
- ),
- )
+ width: 50,
+ height: 50,
+ child: InkWell(
+ onTap: () => Navigator.of(context).pop(),
+ child: Container(
+ decoration: BoxDecoration(color: primaryColor, shape: BoxShape.circle),
+ child: const Icon(Icons.arrow_back, size: 23, color: Colors.white),
+ ),
+ ),
),
),
],
);
}
-}
-//_webView
\ No newline at end of file
+ Widget _buildContent(AppContext appContext, Color primaryColor, double roundedValue) {
+ final selected = _days[_selectedDayIndex];
+ final rep = selected.representative;
+ final description = rep.weather?.first.description;
+
+ return Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.all(Radius.circular(roundedValue)),
+ color: const Color(0xFFE8F4FD),
+ ),
+ child: SingleChildScrollView(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SizedBox(height: 90),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20),
+ child: Text(
+ weatherDTO.city ?? '',
+ style: const TextStyle(
+ fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF6B7280)),
+ ),
+ ),
+ const SizedBox(height: 10),
+
+ // Day tabs
+ SizedBox(
+ height: 95,
+ child: ListView.builder(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 12),
+ itemCount: _days.length,
+ itemBuilder: (context, index) {
+ final day = _days[index];
+ final isSelected = index == _selectedDayIndex;
+ return GestureDetector(
+ onTap: () => setState(() => _selectedDayIndex = index),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 180),
+ margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
+ decoration: BoxDecoration(
+ color: isSelected ? Colors.white : Colors.transparent,
+ borderRadius: BorderRadius.circular(18),
+ boxShadow: isSelected
+ ? [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.08),
+ blurRadius: 8,
+ offset: const Offset(0, 2))
+ ]
+ : [],
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ _shortDayLabel(day.dt, appContext),
+ style: TextStyle(
+ fontSize: 13,
+ fontWeight:
+ isSelected ? FontWeight.w600 : FontWeight.w400,
+ color: isSelected
+ ? const Color(0xFF1F2937)
+ : const Color(0xFF6B7280),
+ ),
+ ),
+ const SizedBox(height: 4),
+ CachedNetworkImage(
+ imageUrl:
+ "https://openweathermap.org/img/wn/${day.representative.weather!.first.icon!}.png",
+ width: 32,
+ height: 32,
+ ),
+ Text(
+ '${day.maxTemp.round()}°/${day.minTemp.round()}°',
+ style: TextStyle(
+ fontSize: 11,
+ color: isSelected
+ ? const Color(0xFF1F2937)
+ : const Color(0xFF9CA3AF),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ // Selected day main info
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ _fullDayLabel(selected.dt, appContext),
+ style: const TextStyle(fontSize: 14, color: Color(0xFF6B7280)),
+ ),
+ const SizedBox(height: 4),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Text(
+ '${selected.maxTemp.round()}°',
+ style: const TextStyle(
+ fontSize: 56,
+ fontWeight: FontWeight.w300,
+ color: Color(0xFF1F2937)),
+ ),
+ Text(
+ '/${selected.minTemp.round()}°',
+ style: const TextStyle(
+ fontSize: 32,
+ fontWeight: FontWeight.w300,
+ color: Color(0xFF9CA3AF)),
+ ),
+ const SizedBox(width: 4),
+ CachedNetworkImage(
+ imageUrl:
+ "https://openweathermap.org/img/wn/${rep.weather!.first.icon!}@2x.png",
+ width: 64,
+ height: 64,
+ ),
+ ],
+ ),
+ if (description != null && description.isNotEmpty)
+ Text(
+ description[0].toUpperCase() + description.substring(1),
+ style: TextStyle(
+ fontSize: 15,
+ color: primaryColor,
+ fontWeight: FontWeight.w500),
+ ),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 20),
+
+ // Hourly section
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20),
+ child: Text(
+ TranslationHelper.getFromLocale(
+ "weather.hourly", appContext.getContext()),
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: Color(0xFF1F2937)),
+ ),
+ ),
+ const SizedBox(height: 8),
+ Container(
+ margin: const EdgeInsets.symmetric(horizontal: 12),
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(18),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.05),
+ blurRadius: 8,
+ offset: const Offset(0, 2))
+ ],
+ ),
+ child: SizedBox(
+ height: 100,
+ child: ListView.builder(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ itemCount: selected.forecasts.length > 8 ? 8 : selected.forecasts.length,
+ itemBuilder: (context, index) {
+ final f = selected.forecasts[index];
+ final hour = DateFormat('HH:mm').format(
+ DateTime.fromMillisecondsSinceEpoch(f.dt! * 1000));
+ final pop = f.pop is num
+ ? ((f.pop as num).toDouble() * 100).round()
+ : 0;
+ return SizedBox(
+ width: 72,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ Text(
+ '${f.main!.temp!.round()}°',
+ style: const TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ color: Color(0xFF1F2937)),
+ ),
+ pop > 0
+ ? Text(
+ '$pop %',
+ style: const TextStyle(
+ fontSize: 11,
+ color: Color(0xFF3B82F6),
+ fontWeight: FontWeight.w500),
+ )
+ : const SizedBox(height: 14),
+ CachedNetworkImage(
+ imageUrl:
+ "https://openweathermap.org/img/wn/${f.weather!.first.icon!}.png",
+ width: 34,
+ height: 34,
+ ),
+ Text(
+ hour,
+ style: const TextStyle(
+ fontSize: 12, color: Color(0xFF6B7280)),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 24),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/Screens/section_page.dart b/lib/Screens/section_page.dart
index 04ae67b..48ddf27 100644
--- a/lib/Screens/section_page.dart
+++ b/lib/Screens/section_page.dart
@@ -348,7 +348,9 @@ Future>> getByteIcons(VisitAppContext visitAppContext,
Uint8List fileData = await http.readBytes(Uri.parse(mapDTO.iconSource!));
selectedMarkerIcon = resizeImage(fileData, 40);
} else {
- File? localIcon = await _checkIfLocalResourceExists(visitAppContext, mapDTO.iconResourceId!);
+ File? localIcon = mapDTO.iconResourceId != null
+ ? await _checkIfLocalResourceExists(visitAppContext, mapDTO.iconResourceId!)
+ : null;
if(localIcon == null) {
final ByteData imageData = await NetworkAssetBundle(Uri.parse(mapDTO.iconSource!)).load("");
selectedMarkerIcon = await getBytesFromAsset(imageData, 50);
diff --git a/lib/Services/Glasses/engines/impl/elevenlabs_tts_engine.dart b/lib/Services/Glasses/engines/impl/elevenlabs_tts_engine.dart
new file mode 100644
index 0000000..b8dcf9c
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/elevenlabs_tts_engine.dart
@@ -0,0 +1,81 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+import 'package:just_audio/just_audio.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:mymuseum_visitapp/PlatformChannels/audio_routing_channel.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/tts_engine.dart';
+
+/// TTS via ElevenLabs API — voix naturelle, payant.
+/// Upgrade par rapport à FlutterTtsEngine quand la qualité vocale prime.
+class ElevenLabsTtsEngine implements TtsEngine {
+ static const String _baseUrl = 'https://api.elevenlabs.io/v1';
+
+ final String apiKey;
+ final String voiceId;
+ final AudioPlayer _player = AudioPlayer();
+
+ bool _speaking = false;
+ String? _lastTempPath;
+
+ ElevenLabsTtsEngine({required this.apiKey, required this.voiceId});
+
+ @override
+ bool get isSpeaking => _speaking;
+
+ @override
+ Future speak(String text, {String languageCode = 'fr-FR'}) async {
+ if (text.isEmpty) return;
+ try {
+ _speaking = true;
+ if (!Platform.isWindows) await AudioRoutingChannel.enableBluetoothOutput();
+ await _speakElevenLabs(text);
+ } catch (e) {
+ debugPrint('[ElevenLabsTtsEngine] speak error: $e');
+ } finally {
+ _speaking = false;
+ }
+ }
+
+ @override
+ Future stop() async {
+ await _player.stop();
+ _speaking = false;
+ }
+
+ @override
+ Future replay() async {
+ if (_lastTempPath == null) return;
+ await _player.seek(Duration.zero);
+ await _player.play();
+ }
+
+ Future _speakElevenLabs(String text) async {
+ final response = await http.post(
+ Uri.parse('$_baseUrl/text-to-speech/$voiceId'),
+ headers: {
+ 'xi-api-key': apiKey,
+ 'Content-Type': 'application/json',
+ 'Accept': 'audio/mpeg',
+ },
+ body: jsonEncode({
+ 'text': text,
+ 'model_id': 'eleven_multilingual_v2',
+ 'voice_settings': {'stability': 0.5, 'similarity_boost': 0.75},
+ }),
+ );
+ if (response.statusCode != 200) {
+ throw Exception('ElevenLabs ${response.statusCode}');
+ }
+ final dir = await getTemporaryDirectory();
+ final file = File('${dir.path}/glasses_tts.mp3');
+ await file.writeAsBytes(response.bodyBytes, flush: true);
+ _lastTempPath = file.path;
+ await _player.setFilePath(file.path);
+ await _player.play();
+ }
+
+ void dispose() => _player.dispose();
+}
diff --git a/lib/Services/Glasses/engines/impl/flutter_tts_engine.dart b/lib/Services/Glasses/engines/impl/flutter_tts_engine.dart
new file mode 100644
index 0000000..5800d9c
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/flutter_tts_engine.dart
@@ -0,0 +1,81 @@
+import 'dart:async';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_tts/flutter_tts.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/tts_engine.dart';
+
+/// TTS on-device via Google (Android) / AVSpeechSynthesizer (iOS).
+/// Gratuit, pas de latence réseau, supporte FR/NL/EN/DE.
+class FlutterTtsEngine implements TtsEngine {
+ final FlutterTts _tts = FlutterTts();
+ bool _speaking = false;
+ String? _lastText;
+ String? _lastLang;
+ Completer? _completer;
+
+ FlutterTtsEngine() {
+ _tts.setStartHandler(() {
+ _speaking = true;
+ });
+ _tts.setCompletionHandler(() {
+ _speaking = false;
+ _completer?.complete();
+ _completer = null;
+ });
+ _tts.setCancelHandler(() {
+ _speaking = false;
+ _completer?.complete();
+ _completer = null;
+ });
+ _tts.setErrorHandler((msg) {
+ _speaking = false;
+ _completer?.completeError(msg);
+ _completer = null;
+ });
+ }
+
+ @override
+ bool get isSpeaking => _speaking;
+
+ @override
+ Future speak(String text, {String languageCode = 'fr-FR'}) async {
+ if (text.isEmpty) return;
+ _lastText = text;
+ _lastLang = languageCode;
+
+ try {
+ await _tts.stop();
+ await _tts.setLanguage(languageCode);
+ await _tts.setSpeechRate(0.45);
+ await _tts.setVolume(1.0);
+ await _tts.setPitch(1.0);
+
+ _completer = Completer();
+ await _tts.speak(text);
+
+ // Attend la vraie fin de la synthèse
+ await _completer!.future.timeout(
+ const Duration(seconds: 30),
+ onTimeout: () {},
+ );
+ } catch (e) {
+ debugPrint('[FlutterTtsEngine] speak error: $e');
+ } finally {
+ _speaking = false;
+ }
+ }
+
+ @override
+ Future stop() async {
+ await _tts.stop();
+ _speaking = false;
+ _completer?.complete();
+ _completer = null;
+ }
+
+ @override
+ Future replay() async {
+ if (_lastText != null) {
+ await speak(_lastText!, languageCode: _lastLang ?? 'fr-FR');
+ }
+ }
+}
diff --git a/lib/Services/Glasses/engines/impl/gemini_tts_engine.dart b/lib/Services/Glasses/engines/impl/gemini_tts_engine.dart
new file mode 100644
index 0000000..a88780a
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/gemini_tts_engine.dart
@@ -0,0 +1,164 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+import 'package:just_audio/just_audio.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/tts_engine.dart';
+
+/// TTS via Gemini 2.5 Flash — voix naturelle, style configurable via prompt.
+///
+/// Voix disponibles : Algieba, Iapetus, Aoede, Charon, Fenrir, Kore,
+/// Leda, Orus, Puck, Schedar, Sulafat, Umbriel...
+///
+/// Exemple :
+/// GeminiTtsEngine(
+/// apiKey: kGeminiApiKey,
+/// voiceName: 'Algieba',
+/// voicePrompt: 'Voix de guide de musée, ton chaleureux, rythme posé, '
+/// 'comme un narrateur de documentaire culturel.',
+/// )
+class GeminiTtsEngine implements TtsEngine {
+ final String apiKey;
+ final String voiceName;
+ final String voicePrompt;
+
+ static const String _model = 'gemini-2.5-flash-preview-tts';
+ static const String _baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
+
+ final AudioPlayer _player = AudioPlayer();
+ bool _speaking = false;
+ String? _lastTempPath;
+
+ GeminiTtsEngine({
+ required this.apiKey,
+ this.voiceName = 'Algieba',
+ this.voicePrompt = 'Voix de guide de musée, ton chaleureux et bienveillant, '
+ 'rythme posé et clair, comme un narrateur de documentaire culturel.',
+ });
+
+ @override
+ bool get isSpeaking => _speaking;
+
+ @override
+ Future speak(String text, {String languageCode = 'fr-FR'}) async {
+ if (text.isEmpty) return;
+ try {
+ _speaking = true;
+ final wavPath = await _synthesize(text, languageCode);
+ if (wavPath == null) return;
+ _lastTempPath = wavPath;
+ await _player.setFilePath(wavPath);
+ await _player.play();
+ } catch (e) {
+ debugPrint('[GeminiTtsEngine] speak error: $e');
+ } finally {
+ _speaking = false;
+ }
+ }
+
+ @override
+ Future stop() async {
+ await _player.stop();
+ _speaking = false;
+ }
+
+ @override
+ Future replay() async {
+ if (_lastTempPath == null) return;
+ await _player.seek(Duration.zero);
+ await _player.play();
+ }
+
+ Future _synthesize(String text, String languageCode) async {
+ final body = jsonEncode({
+ 'contents': [
+ {
+ 'parts': [{'text': text}]
+ }
+ ],
+ 'systemInstruction': {
+ 'parts': [{'text': '$voicePrompt\nLangue : $languageCode.'}]
+ },
+ 'generationConfig': {
+ 'responseModalities': ['AUDIO'],
+ 'speechConfig': {
+ 'voiceConfig': {
+ 'prebuiltVoiceConfig': {'voiceName': voiceName}
+ }
+ }
+ }
+ });
+
+ final response = await http.post(
+ Uri.parse('$_baseUrl/models/$_model:generateContent?key=$apiKey'),
+ headers: {'Content-Type': 'application/json'},
+ body: body,
+ ).timeout(const Duration(seconds: 15));
+
+ if (response.statusCode != 200) {
+ debugPrint('[GeminiTtsEngine] HTTP ${response.statusCode}: ${response.body}');
+ return null;
+ }
+
+ final json = jsonDecode(response.body) as Map;
+ final candidates = json['candidates'] as List?;
+ if (candidates == null || candidates.isEmpty) return null;
+
+ final parts = candidates[0]['content']['parts'] as List?;
+ if (parts == null || parts.isEmpty) return null;
+
+ final inlineData = parts[0]['inlineData'] as Map?;
+ if (inlineData == null) return null;
+
+ final pcmBase64 = inlineData['data'] as String?;
+ if (pcmBase64 == null) return null;
+
+ final pcmBytes = base64Decode(pcmBase64);
+ return _pcmToWav(pcmBytes, sampleRate: 24000);
+ }
+
+ /// Convertit du PCM16 brut en fichier WAV lisible par just_audio.
+ Future _pcmToWav(Uint8List pcm, {int sampleRate = 24000}) async {
+ const channels = 1;
+ const bitsPerSample = 16;
+ final byteRate = sampleRate * channels * bitsPerSample ~/ 8;
+ final blockAlign = channels * bitsPerSample ~/ 8;
+ final dataSize = pcm.length;
+ final chunkSize = 36 + dataSize;
+
+ final header = ByteData(44);
+ // RIFF chunk
+ header.setUint8(0, 0x52); header.setUint8(1, 0x49);
+ header.setUint8(2, 0x46); header.setUint8(3, 0x46); // "RIFF"
+ header.setUint32(4, chunkSize, Endian.little);
+ header.setUint8(8, 0x57); header.setUint8(9, 0x41);
+ header.setUint8(10, 0x56); header.setUint8(11, 0x45); // "WAVE"
+ // fmt chunk
+ header.setUint8(12, 0x66); header.setUint8(13, 0x6D);
+ header.setUint8(14, 0x74); header.setUint8(15, 0x20); // "fmt "
+ header.setUint32(16, 16, Endian.little); // subchunk size
+ header.setUint16(20, 1, Endian.little); // PCM format
+ header.setUint16(22, channels, Endian.little);
+ header.setUint32(24, sampleRate, Endian.little);
+ header.setUint32(28, byteRate, Endian.little);
+ header.setUint16(32, blockAlign, Endian.little);
+ header.setUint16(34, bitsPerSample, Endian.little);
+ // data chunk
+ header.setUint8(36, 0x64); header.setUint8(37, 0x61);
+ header.setUint8(38, 0x74); header.setUint8(39, 0x61); // "data"
+ header.setUint32(40, dataSize, Endian.little);
+
+ final wav = Uint8List(44 + dataSize)
+ ..setAll(0, header.buffer.asUint8List())
+ ..setAll(44, pcm);
+
+ final dir = await getTemporaryDirectory();
+ final path = '${dir.path}/gemini_tts_${DateTime.now().millisecondsSinceEpoch}.wav';
+ await File(path).writeAsBytes(wav, flush: true);
+ return path;
+ }
+
+ void dispose() => _player.dispose();
+}
diff --git a/lib/Services/Glasses/engines/impl/home_assistant_stt_engine.dart b/lib/Services/Glasses/engines/impl/home_assistant_stt_engine.dart
new file mode 100644
index 0000000..dcabbdc
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/home_assistant_stt_engine.dart
@@ -0,0 +1,103 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_sound/flutter_sound.dart';
+import 'package:http/http.dart' as http;
+import 'package:path_provider/path_provider.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/stt_engine.dart';
+
+/// STT via faster-whisper hébergé sur Home Assistant.
+///
+/// POST http://{haUrl}/api/stt/{providerId}
+/// Header : Authorization: Bearer {token}
+/// Body : bytes audio WAV 16kHz mono
+class HomeAssistantSttEngine implements SttEngine {
+ final String haUrl;
+ final String haToken;
+ final String providerId;
+
+ final FlutterSoundRecorder _recorder = FlutterSoundRecorder();
+ bool _initialized = false;
+
+ HomeAssistantSttEngine({
+ required this.haUrl,
+ required this.haToken,
+ this.providerId = 'faster_whisper',
+ });
+
+ Future _ensureInitialized() async {
+ if (_initialized) return;
+ await Permission.microphone.request();
+ await _recorder.openRecorder();
+ _initialized = true;
+ }
+
+ @override
+ Future transcribeOnce({
+ required String languageCode,
+ Duration timeout = const Duration(seconds: 8),
+ }) async {
+ await _ensureInitialized();
+
+ final dir = await getTemporaryDirectory();
+ final path = '${dir.path}/ha_stt_${DateTime.now().millisecondsSinceEpoch}.wav';
+
+ try {
+ await _recorder.startRecorder(
+ toFile: path,
+ codec: Codec.pcm16WAV,
+ sampleRate: 16000,
+ numChannels: 1,
+ );
+
+ await _waitForSilenceOrTimeout(timeout);
+ await _recorder.stopRecorder();
+
+ return await _transcribe(path, languageCode);
+ } catch (e) {
+ debugPrint('[HomeAssistantSttEngine] error: $e');
+ try { await _recorder.stopRecorder(); } catch (_) {}
+ return '';
+ } finally {
+ try { File(path).deleteSync(); } catch (_) {}
+ }
+ }
+
+ @override
+ Future cancel() async {
+ try { await _recorder.stopRecorder(); } catch (_) {}
+ }
+
+ Future _waitForSilenceOrTimeout(Duration timeout) async {
+ // Attend la durée complète — l'utilisateur a le temps de parler
+ // flutter_sound ne fournit pas d'accès simple au niveau audio en temps réel
+ await Future.delayed(timeout);
+ }
+
+ Future _transcribe(String wavPath, String languageCode) async {
+ final bytes = await File(wavPath).readAsBytes();
+ final lang = languageCode.split('-').first;
+
+ final response = await http.post(
+ Uri.parse('$haUrl/api/stt/$providerId'),
+ headers: {
+ 'Authorization': 'Bearer $haToken',
+ 'Content-Type': 'audio/wav',
+ 'X-Speech-Content': 'language=$lang',
+ },
+ body: bytes,
+ ).timeout(const Duration(seconds: 10));
+
+ if (response.statusCode != 200) {
+ debugPrint('[HomeAssistantSttEngine] HTTP ${response.statusCode}: ${response.body}');
+ return '';
+ }
+
+ final json = jsonDecode(response.body) as Map;
+ final text = (json['text'] as String? ?? '').trim();
+ debugPrint('[HomeAssistantSttEngine] "$text"');
+ return text;
+ }
+}
diff --git a/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart b/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart
new file mode 100644
index 0000000..6fb59a5
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart
@@ -0,0 +1,50 @@
+import 'package:flutter/foundation.dart';
+import 'package:manager_api_new/api.dart';
+import 'package:mymuseum_visitapp/Models/visitContext.dart';
+import 'package:mymuseum_visitapp/Services/assistantService.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/llm_client.dart';
+
+/// Client LLM via backend MyInfoMate (.NET + Gemini Flash).
+///
+/// Utilise AppType.Voice pour signaler au backend :
+/// - Pas de navigation UI (pas de navigate_to_section / navigate_to_configuration)
+/// - Pas de show_cards
+/// - Prompt audio-friendly : réponses courtes, sans markdown, max 30s à l'oral
+class MyInfoMateLlmClient implements LlmClient {
+ final VisitAppContext visitAppContext;
+ late final AssistantService _service;
+
+ MyInfoMateLlmClient({required this.visitAppContext}) {
+ _service = AssistantService(visitAppContext: visitAppContext);
+ }
+
+ @override
+ Future chat(
+ String message, {
+ String? configurationId,
+ String languageCode = 'FR',
+ }) async {
+ final cfgId = configurationId ?? visitAppContext.configuration?.id;
+
+ debugPrint('[MyInfoMateLlmClient] chat Voice — instanceId=${visitAppContext.instanceId} '
+ 'configId=$cfgId lang=$languageCode apiKey=${visitAppContext.apiKey != null ? "set" : "null"}');
+
+ try {
+ // AppType.Mobile + isVoice:true → backend adapte le prompt (audio-friendly)
+ // sans navigation, sans markdown, dates en toutes lettres
+ final response = await _service.chatWithAppType(
+ message: message,
+ configurationId: cfgId,
+ appType: AppType.Mobile,
+ isVoice: true,
+ );
+ return response.reply;
+ } catch (e) {
+ debugPrint('[MyInfoMateLlmClient] error: $e');
+ rethrow;
+ }
+ }
+
+ @override
+ void clearHistory() => _service.clearHistory();
+}
diff --git a/lib/Services/Glasses/engines/impl/native_wake_word_engine.dart b/lib/Services/Glasses/engines/impl/native_wake_word_engine.dart
new file mode 100644
index 0000000..5a0fe4b
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/native_wake_word_engine.dart
@@ -0,0 +1,135 @@
+import 'dart:async';
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
+
+/// Wake word via OpenWakeWord — pipeline TFLite+ONNX natif Android.
+///
+/// Les modèles sont extraits des assets Flutter vers le cache natif
+/// avant de démarrer le foreground service (les assets Flutter ne sont
+/// pas accessibles directement depuis un Service Android en debug).
+class NativeWakeWordEngine implements WakeWordEngine {
+ final String modelName;
+
+ static const MethodChannel _channel =
+ MethodChannel('be.unov.mymuseum/wake_word');
+ static const EventChannel _events =
+ EventChannel('be.unov.mymuseum/wake_word_events');
+
+ StreamSubscription? _subscription;
+ bool _running = false;
+ bool _nativeServiceAlive = false; // vrai si le foreground service tourne (même en pause)
+
+ NativeWakeWordEngine({this.modelName = 'hey_visit'});
+
+ @override
+ Future start({
+ required void Function() onDetected,
+ void Function(String command)? onDetectedWithCommand,
+ }) async {
+ if (_subscription != null) {
+ // Déjà abonné — rien à faire
+ return;
+ }
+
+ if (_nativeServiceAlive) {
+ // Service natif en pause — reprendre l'AudioRecord et se re-souscrire
+ debugPrint('[NativeWakeWordEngine] Resuming native AudioRecord');
+ try { await _channel.invokeMethod('resume'); } catch (_) {}
+ _running = true;
+ _subscription = _events.receiveBroadcastStream().listen((event) {
+ if (event == 'detected') {
+ debugPrint('[NativeWakeWordEngine] "$modelName" detected!');
+ onDetected();
+ }
+ });
+ return;
+ }
+
+ // Permission micro requise avant de démarrer le foreground service (Android 14+)
+ final micStatus = await Permission.microphone.request();
+ if (!micStatus.isGranted) {
+ debugPrint('[NativeWakeWordEngine] Microphone permission denied');
+ return;
+ }
+
+ // Extraire les modèles vers le cache natif (accessible par le Service)
+ await _extractModels();
+
+ try {
+ await _channel.invokeMethod('start', {'modelName': modelName});
+ _running = true;
+ _nativeServiceAlive = true;
+
+ _subscription = _events.receiveBroadcastStream().listen((event) {
+ if (event == 'detected') {
+ debugPrint('[NativeWakeWordEngine] "$modelName" detected!');
+ onDetected();
+ } else if (event == 'error') {
+ debugPrint('[NativeWakeWordEngine] Service error — restarting listener');
+ // Tente de relancer après une erreur
+ Future.delayed(const Duration(seconds: 2), () {
+ if (_running) start(onDetected: onDetected, onDetectedWithCommand: onDetectedWithCommand);
+ });
+ }
+ });
+
+ debugPrint('[NativeWakeWordEngine] Started — model: $modelName');
+ } catch (e) {
+ debugPrint('[NativeWakeWordEngine] start error: $e');
+ _running = false;
+ }
+ }
+
+ /// Pause l'écoute côté Flutter ET libère l'AudioRecord natif pour que le STT puisse accéder au micro.
+ /// Le foreground service reste vivant → le silent AudioTrack continue, MIUI ne mute pas.
+ @override
+ Future stop() async {
+ await _subscription?.cancel();
+ _subscription = null;
+ _running = false;
+ // Libère l'AudioRecord pour le STT — service et silent track restent vivants
+ try { await _channel.invokeMethod('pause'); } catch (_) {}
+ debugPrint('[NativeWakeWordEngine] Paused — AudioRecord released for STT');
+ }
+
+ /// Arrêt complet du service natif (appeler uniquement à la fermeture de l'app).
+ Future stopNativeService() async {
+ await _subscription?.cancel();
+ _subscription = null;
+ _running = false;
+ _nativeServiceAlive = false;
+ try { await _channel.invokeMethod('stop'); } catch (_) {}
+ debugPrint('[NativeWakeWordEngine] Native service stopped');
+ }
+
+ /// Copie les modèles depuis les assets Flutter vers le cache de l'app.
+ /// Le Service Android peut y accéder via getCacheDir().
+ Future _extractModels() async {
+ final dir = await getTemporaryDirectory();
+ final models = [
+ 'assets/files/melspectrogram.onnx',
+ 'assets/files/embedding_model.tflite',
+ 'assets/files/$modelName.tflite',
+ ];
+
+ for (final assetPath in models) {
+ final fileName = assetPath.split('/').last;
+ final dest = File('${dir.path}/$fileName');
+ if (dest.existsSync()) continue; // déjà extrait
+ try {
+ final data = await rootBundle.load(assetPath);
+ await dest.writeAsBytes(data.buffer.asUint8List(), flush: true);
+ debugPrint('[NativeWakeWordEngine] Extracted: $fileName → ${dir.path}');
+ } catch (e) {
+ debugPrint('[NativeWakeWordEngine] Failed to extract $fileName: $e');
+ }
+ }
+
+ // Passe le chemin du cache au service natif
+ await _channel.invokeMethod('setCacheDir', {'path': dir.path});
+ }
+}
diff --git a/lib/Services/Glasses/engines/impl/openwakeword_engine.dart b/lib/Services/Glasses/engines/impl/openwakeword_engine.dart
new file mode 100644
index 0000000..15a566c
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/openwakeword_engine.dart
@@ -0,0 +1,68 @@
+import 'dart:async';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
+
+/// Wake word via OpenWakeWord — service Android natif (foreground service).
+/// Utilise ONNX Runtime pour l'inférence on-device du modèle hey_visit.onnx.
+/// Survit au background Android contrairement au STT continu.
+///
+/// iOS non supporté — fallback sur SpeechToTextWakeWordEngine côté iOS.
+class OpenWakeWordEngine implements WakeWordEngine {
+ static const _methodChannel = MethodChannel('be.unov.mymuseum/wake_word');
+ static const _eventChannel = EventChannel('be.unov.mymuseum/wake_word_events');
+
+ /// Nom du fichier .tflite dans assets/files (sans extension).
+ /// Ex: "hey_visit", "hey_museum", "hey_guide"
+ final String modelName;
+
+ StreamSubscription? _subscription;
+ void Function()? _onDetected;
+ bool _running = false;
+
+ OpenWakeWordEngine({this.modelName = 'hey_visit'});
+
+ @override
+ Future start({
+ required void Function() onDetected,
+ void Function(String command)? onDetectedWithCommand,
+ }) async {
+ if (_running) return;
+ if (!_isAndroid) {
+ debugPrint('[OpenWakeWordEngine] Android only — no-op on this platform');
+ return;
+ }
+
+ _onDetected = onDetected;
+ _running = true;
+
+ _subscription = _eventChannel.receiveBroadcastStream().listen(
+ (event) {
+ if (event == 'detected' && _running) {
+ debugPrint('[OpenWakeWordEngine] Wake word detected');
+ // OpenWakeWord ne capture pas la commande inline — l'orchestrateur
+ // lance un cycle STT séparé via onDetected()
+ _onDetected?.call();
+ }
+ },
+ onError: (e) => debugPrint('[OpenWakeWordEngine] Event error: $e'),
+ );
+
+ await _methodChannel.invokeMethod('start', {'modelName': modelName});
+ debugPrint('[OpenWakeWordEngine] Service started (model: $modelName)');
+ }
+
+ @override
+ Future stop() async {
+ if (!_running) return;
+ _running = false;
+ await _subscription?.cancel();
+ _subscription = null;
+ if (_isAndroid) {
+ await _methodChannel.invokeMethod('stop');
+ }
+ debugPrint('[OpenWakeWordEngine] Service stopped');
+ }
+
+ bool get _isAndroid => defaultTargetPlatform == TargetPlatform.android;
+}
diff --git a/lib/Services/Glasses/engines/impl/porcupine_wake_word.dart b/lib/Services/Glasses/engines/impl/porcupine_wake_word.dart
new file mode 100644
index 0000000..1b873f8
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/porcupine_wake_word.dart
@@ -0,0 +1,49 @@
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
+
+// import 'package:porcupine_flutter/porcupine_flutter.dart';
+// Décommenter quand porcupine_flutter sera disponible (payant Picovoice).
+// Fichiers .ppn à générer sur https://console.picovoice.ai/
+
+/// Wake word on-device via Porcupine (Picovoice).
+/// Précis, léger (~30KB model), <1% faux positifs.
+/// Nécessite un abonnement Picovoice pour la production.
+class PorcupineWakeWordEngine implements WakeWordEngine {
+ final String accessKey;
+ final String keywordAssetPath;
+
+ PorcupineWakeWordEngine({
+ required this.accessKey,
+ required this.keywordAssetPath,
+ });
+
+ @override
+ Future start({
+ required void Function() onDetected,
+ void Function(String command)? onDetectedWithCommand,
+ }) async {
+ if (!Platform.isAndroid && !Platform.isIOS) return;
+ if (accessKey.isEmpty) {
+ debugPrint('[PorcupineWakeWordEngine] No access key — disabled');
+ return;
+ }
+
+ // TODO: décommenter quand porcupine_flutter est disponible
+ // final path = Platform.isIOS
+ // ? 'assets/wake_words/hey_myvisit_ios.ppn'
+ // : 'assets/wake_words/hey_myvisit_android.ppn';
+ // _manager = await PorcupineManager.fromKeywordPaths(
+ // accessKey, [path], (_) => onDetected(),
+ // );
+ // await _manager!.start();
+
+ debugPrint('[PorcupineWakeWordEngine] Stub — wire Porcupine when available');
+ }
+
+ @override
+ Future stop() async {
+ // await _manager?.stop();
+ // await _manager?.delete();
+ }
+}
diff --git a/lib/Services/Glasses/engines/impl/speech_to_text_stt.dart b/lib/Services/Glasses/engines/impl/speech_to_text_stt.dart
new file mode 100644
index 0000000..7467bf4
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/speech_to_text_stt.dart
@@ -0,0 +1,55 @@
+import 'dart:async';
+import 'package:flutter/foundation.dart';
+import 'package:speech_to_text/speech_to_text.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/stt_engine.dart';
+
+/// STT via speech_to_text — Google on-device (Android) / Apple (iOS).
+/// Gratuit, supporte FR/NL/EN/DE.
+class SpeechToTextSttEngine implements SttEngine {
+ final SpeechToText _speech = SpeechToText();
+ bool _initialized = false;
+
+ Future _ensureInitialized() async {
+ if (_initialized) return;
+ _initialized = await _speech.initialize();
+ debugPrint('[SpeechToTextSttEngine] initialized: $_initialized');
+ }
+
+ @override
+ Future transcribeOnce({
+ required String languageCode,
+ Duration timeout = const Duration(seconds: 6),
+ }) async {
+ await _ensureInitialized();
+ if (!_initialized) return '';
+
+ final completer = Completer();
+ String lastResult = '';
+
+ // Petit délai pour laisser le micro se réinitialiser après le wake word
+ await Future.delayed(const Duration(milliseconds: 200));
+
+ final timer = Timer(timeout, () {
+ if (!completer.isCompleted) completer.complete(lastResult);
+ });
+
+ await _speech.listen(
+ localeId: languageCode,
+ onResult: (result) {
+ lastResult = result.recognizedWords;
+ if (result.finalResult) {
+ timer.cancel();
+ if (!completer.isCompleted) completer.complete(lastResult);
+ }
+ },
+ listenOptions: SpeechListenOptions(partialResults: true),
+ );
+
+ return completer.future;
+ }
+
+ @override
+ Future cancel() async {
+ await _speech.cancel();
+ }
+}
diff --git a/lib/Services/Glasses/engines/impl/speech_to_text_wake_word.dart b/lib/Services/Glasses/engines/impl/speech_to_text_wake_word.dart
new file mode 100644
index 0000000..c8d69e7
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/speech_to_text_wake_word.dart
@@ -0,0 +1,76 @@
+import 'dart:async';
+import 'package:flutter/foundation.dart';
+import 'package:speech_to_text/speech_to_text.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
+
+/// Wake word via speech_to_text en écoute continue.
+///
+/// Quand "visite" est détecté dans un énoncé, le texte qui SUIT "visite"
+/// dans le même énoncé est passé comme commande — pas besoin d'un second
+/// cycle d'écoute STT séparé.
+///
+/// Exemple : "visite qu'est-ce que c'est ?" → commande = "qu'est-ce que c'est"
+class SpeechToTextWakeWordEngine implements WakeWordEngine {
+ final String keyword;
+ final SpeechToText _speech = SpeechToText();
+ bool _running = false;
+ void Function(String command)? _onDetected;
+
+ SpeechToTextWakeWordEngine({this.keyword = 'visite'});
+
+ @override
+ Future start({
+ required void Function() onDetected,
+ void Function(String command)? onDetectedWithCommand,
+ }) async {
+ if (_running) return;
+ // Supporte les deux signatures : avec ou sans commande inline
+ _onDetected = onDetectedWithCommand ?? (_) => onDetected();
+
+ final ok = await _speech.initialize();
+ if (!ok) {
+ debugPrint('[SpeechToTextWakeWordEngine] STT not available');
+ return;
+ }
+
+ _running = true;
+ debugPrint('[SpeechToTextWakeWordEngine] Listening for "$keyword"');
+ _listen();
+ }
+
+ void _listen() {
+ if (!_running) return;
+
+ _speech.listen(
+ localeId: 'fr-FR',
+ listenFor: const Duration(seconds: 8),
+ pauseFor: const Duration(seconds: 3),
+ onResult: (result) {
+ final text = result.recognizedWords.toLowerCase().trim();
+ debugPrint('[SpeechToTextWakeWordEngine] heard: "$text"');
+
+ if (text.contains(keyword)) {
+ // Extrait la commande qui suit le keyword dans le même énoncé
+ final idx = text.indexOf(keyword);
+ final afterKeyword = text.substring(idx + keyword.length).trim();
+ debugPrint('[SpeechToTextWakeWordEngine] Wake word! inline command: "$afterKeyword"');
+ _onDetected?.call(afterKeyword);
+ }
+ },
+ listenOptions: SpeechListenOptions(partialResults: false),
+ );
+
+ _speech.statusListener = (status) {
+ if ((status == 'done' || status == 'notListening') && _running) {
+ Future.delayed(const Duration(milliseconds: 300), _listen);
+ }
+ };
+ }
+
+ @override
+ Future stop() async {
+ _running = false;
+ await _speech.cancel();
+ debugPrint('[SpeechToTextWakeWordEngine] Stopped');
+ }
+}
diff --git a/lib/Services/Glasses/engines/impl/whisper_stt_engine.dart b/lib/Services/Glasses/engines/impl/whisper_stt_engine.dart
new file mode 100644
index 0000000..ece3846
--- /dev/null
+++ b/lib/Services/Glasses/engines/impl/whisper_stt_engine.dart
@@ -0,0 +1,107 @@
+import 'dart:convert';
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_sound/flutter_sound.dart';
+import 'package:http/http.dart' as http;
+import 'package:path_provider/path_provider.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/engines/stt_engine.dart';
+
+/// STT via API Whisper — format OpenAI multipart/form-data.
+///
+/// Compatible avec :
+/// - OpenAI Whisper API : endpoint = 'https://api.openai.com/v1/audio/transcriptions'
+/// - faster-whisper-server : endpoint = 'http://ton-serveur:8000/v1/audio/transcriptions'
+/// - openedai-speech : même format
+///
+/// Coût : $0.006/min (OpenAI) ou gratuit si auto-hébergé.
+class WhisperSttEngine implements SttEngine {
+ final String endpoint;
+ final String apiKey;
+ final String model;
+
+ final FlutterSoundRecorder _recorder = FlutterSoundRecorder();
+ bool _initialized = false;
+ String? _currentPath;
+
+ WhisperSttEngine({
+ required this.apiKey,
+ this.endpoint = 'https://api.openai.com/v1/audio/transcriptions',
+ this.model = 'whisper-1',
+ });
+
+ Future _ensureInitialized() async {
+ if (_initialized) return;
+ await Permission.microphone.request();
+ await _recorder.openRecorder();
+ _initialized = true;
+ }
+
+ @override
+ Future transcribeOnce({
+ required String languageCode,
+ Duration timeout = const Duration(seconds: 8),
+ }) async {
+ await _ensureInitialized();
+
+ final dir = await getTemporaryDirectory();
+ _currentPath = '${dir.path}/whisper_stt_${DateTime.now().millisecondsSinceEpoch}.wav';
+
+ try {
+ await _recorder.startRecorder(
+ toFile: _currentPath,
+ codec: Codec.pcm16WAV,
+ sampleRate: 16000,
+ numChannels: 1,
+ );
+
+ await Future.delayed(timeout);
+ await _recorder.stopRecorder();
+
+ return await _transcribe(_currentPath!, languageCode);
+ } catch (e) {
+ debugPrint('[WhisperSttEngine] error: $e');
+ try { await _recorder.stopRecorder(); } catch (_) {}
+ return '';
+ } finally {
+ try {
+ if (_currentPath != null) File(_currentPath!).deleteSync();
+ } catch (_) {}
+ _currentPath = null;
+ }
+ }
+
+ @override
+ Future cancel() async {
+ try { await _recorder.stopRecorder(); } catch (_) {}
+ }
+
+ Future _transcribe(String wavPath, String languageCode) async {
+ final lang = languageCode.split('-').first; // "fr-FR" → "fr"
+ final bytes = await File(wavPath).readAsBytes();
+
+ final request = http.MultipartRequest('POST', Uri.parse(endpoint))
+ ..headers['Authorization'] = 'Bearer $apiKey'
+ ..fields['model'] = model
+ ..fields['language'] = lang
+ ..fields['response_format'] = 'json'
+ ..files.add(http.MultipartFile.fromBytes(
+ 'file',
+ bytes,
+ filename: 'audio.wav',
+ ));
+
+ final response = await request.send().timeout(const Duration(seconds: 15));
+ final body = await response.stream.bytesToString();
+
+ if (response.statusCode != 200) {
+ debugPrint('[WhisperSttEngine] HTTP ${response.statusCode}: $body');
+ return '';
+ }
+
+ final json = jsonDecode(body) as Map;
+ final text = (json['text'] as String? ?? '').trim();
+ debugPrint('[WhisperSttEngine] "$text"');
+ return text;
+ }
+}
diff --git a/lib/Services/Glasses/engines/llm_client.dart b/lib/Services/Glasses/engines/llm_client.dart
new file mode 100644
index 0000000..566b058
--- /dev/null
+++ b/lib/Services/Glasses/engines/llm_client.dart
@@ -0,0 +1,15 @@
+/// Interface abstraite pour le client LLM.
+/// Implémentations disponibles :
+/// - MyInfoMateLlmClient (backend .NET MyInfoMate — Gemini Flash)
+/// - ClaudeClient (Anthropic API — upgrade futur)
+/// - OpenAiClient (upgrade futur)
+abstract class LlmClient {
+ /// Envoie un message et retourne la réponse complète.
+ Future chat(
+ String message, {
+ String? configurationId,
+ String languageCode = 'FR',
+ });
+
+ void clearHistory();
+}
diff --git a/lib/Services/Glasses/engines/stt_engine.dart b/lib/Services/Glasses/engines/stt_engine.dart
new file mode 100644
index 0000000..5fd7c31
--- /dev/null
+++ b/lib/Services/Glasses/engines/stt_engine.dart
@@ -0,0 +1,15 @@
+/// Interface abstraite pour la reconnaissance vocale (Speech-to-Text).
+/// Implémentations disponibles :
+/// - SpeechToTextSttEngine (speech_to_text — Google on-device, gratuit)
+/// - DeepgramSttEngine (Deepgram API — upgrade futur)
+/// - WhisperSttEngine (whisper.cpp ou API OpenAI — upgrade futur)
+abstract class SttEngine {
+ /// Transcrit une commande vocale unique après le wake word.
+ /// Retourne la transcription ou chaîne vide si timeout.
+ Future transcribeOnce({
+ required String languageCode,
+ Duration timeout = const Duration(seconds: 6),
+ });
+
+ Future cancel();
+}
diff --git a/lib/Services/Glasses/engines/tts_engine.dart b/lib/Services/Glasses/engines/tts_engine.dart
new file mode 100644
index 0000000..9947f8b
--- /dev/null
+++ b/lib/Services/Glasses/engines/tts_engine.dart
@@ -0,0 +1,12 @@
+/// Interface abstraite pour la synthèse vocale (Text-to-Speech).
+/// Implémentations disponibles :
+/// - FlutterTtsEngine (Google on-device — gratuit, FR/NL/EN/DE)
+/// - ElevenLabsTtsEngine (ElevenLabs API — voix naturelle, payant)
+/// - AzureTtsEngine (upgrade futur)
+abstract class TtsEngine {
+ Future speak(String text, {String languageCode = 'fr-FR'});
+ Future stop();
+ Future replay();
+
+ bool get isSpeaking;
+}
diff --git a/lib/Services/Glasses/engines/wake_word_engine.dart b/lib/Services/Glasses/engines/wake_word_engine.dart
new file mode 100644
index 0000000..e117a89
--- /dev/null
+++ b/lib/Services/Glasses/engines/wake_word_engine.dart
@@ -0,0 +1,17 @@
+/// Interface abstraite pour la détection de wake word.
+/// Implémentations disponibles :
+/// - PorcupineWakeWordEngine (on-device, Picovoice — payant prod)
+/// - SpeechToTextWakeWordEngine (speech_to_text — gratuit, moins précis)
+abstract class WakeWordEngine {
+ /// Démarre l'écoute continue.
+ /// [onDetected] — callback minimal, appelé sans commande.
+ /// [onDetectedWithCommand] — callback enrichi : texte après le keyword
+ /// dans le même énoncé ("visite qu'est-ce que c'est" → "qu'est-ce que c'est").
+ /// Si vide, l'orchestrateur lance un cycle STT séparé.
+ Future start({
+ required void Function() onDetected,
+ void Function(String command)? onDetectedWithCommand,
+ });
+
+ Future stop();
+}
diff --git a/lib/Services/Glasses/glasses_background_service.dart b/lib/Services/Glasses/glasses_background_service.dart
new file mode 100644
index 0000000..e35cb63
--- /dev/null
+++ b/lib/Services/Glasses/glasses_background_service.dart
@@ -0,0 +1,70 @@
+import 'dart:io';
+import 'package:flutter_foreground_task/flutter_foreground_task.dart';
+
+/// Foreground Service Android — garde le processus vivant quand écran verrouillé.
+/// iOS : UIBackgroundModes audio dans Info.plist suffit.
+class GlassesBackgroundService {
+ static Future initialize() async {
+ if (!Platform.isAndroid) return;
+
+ FlutterForegroundTask.init(
+ androidNotificationOptions: AndroidNotificationOptions(
+ channelId: 'myinfomate_glasses',
+ channelName: 'MyInfoMate Guide',
+ channelDescription: 'Votre guide de visite est actif',
+ channelImportance: NotificationChannelImportance.LOW,
+ priority: NotificationPriority.LOW,
+ ),
+ iosNotificationOptions: const IOSNotificationOptions(
+ showNotification: false,
+ ),
+ foregroundTaskOptions: ForegroundTaskOptions(
+ eventAction: ForegroundTaskEventAction.nothing(),
+ autoRunOnBoot: false,
+ allowWakeLock: true,
+ ),
+ );
+ }
+
+ static Future start() async {
+ if (!Platform.isAndroid) return;
+ if (await FlutterForegroundTask.isRunningService) return;
+
+ await FlutterForegroundTask.startService(
+ serviceId: 1001,
+ notificationTitle: 'Guide de visite actif',
+ notificationText: 'Dites "visite" pour parler à votre guide',
+ callback: _taskCallback,
+ );
+ }
+
+ static Future stop() async {
+ if (!Platform.isAndroid) return;
+ await FlutterForegroundTask.stopService();
+ }
+
+ static Future updateNotification(String text) async {
+ if (!Platform.isAndroid) return;
+ await FlutterForegroundTask.updateService(
+ notificationTitle: 'Guide de visite actif',
+ notificationText: text,
+ );
+ }
+}
+
+// Doit être top-level pour flutter_foreground_task
+@pragma('vm:entry-point')
+void _taskCallback() {
+ FlutterForegroundTask.setTaskHandler(_GlassesTaskHandler());
+}
+
+class _GlassesTaskHandler extends TaskHandler {
+ @override
+ Future onStart(DateTime timestamp, TaskStarter starter) async {}
+
+ @override
+ void onRepeatEvent(DateTime timestamp) {}
+
+ @override
+ Future onDestroy(DateTime timestamp) async {}
+}
diff --git a/lib/Services/Glasses/glasses_orchestrator.dart b/lib/Services/Glasses/glasses_orchestrator.dart
new file mode 100644
index 0000000..261cae0
--- /dev/null
+++ b/lib/Services/Glasses/glasses_orchestrator.dart
@@ -0,0 +1,502 @@
+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.
+/// Initialisée dans main.dart après la connexion lunettes.
+GlassesOrchestrator? activeOrchestrator;
+
+/// Orchestre le pipeline complet mains-libres :
+/// WakeWord → STT → dispatch → LLM ou QR scan → TTS
+///
+/// Toutes les dépendances sont injectées via les interfaces abstraites
+/// pour pouvoir swapper chaque maillon indépendamment.
+class GlassesOrchestrator {
+ final VisitAppContext visitAppContext;
+ final WakeWordEngine wakeWordEngine;
+ final SttEngine sttEngine;
+ final TtsEngine ttsEngine;
+ final LlmClient llmClient;
+
+ bool _running = false;
+ bool _inConversation = false;
+
+ /// Dernière transcription capturée — observable depuis l'UI.
+ final ValueNotifier lastTranscription = ValueNotifier('');
+ final ValueNotifier isListeningForCommand = ValueNotifier(false);
+ /// Dernier texte envoyé au TTS — pour détecter les astérisques et autres artefacts.
+ final ValueNotifier lastTtsText = ValueNotifier('');
+
+ // Photos de visite (V2 — stockées pour un résumé en fin de visite)
+ final List visitPhotos = [];
+
+ // Sons de feedback — fichiers courts dans assets/sounds/
+ // wake_detected.mp3 : ~0.3s — "je t'écoute"
+ // thinking.mp3 : ~0.5s — "je réfléchis"
+ // done.mp3 : ~0.3s — "réponse prête" (optionnel)
+ static const String _wakeSound = 'assets/sounds/wake_detected.mp3';
+ static const String _thinkingSound = 'assets/sounds/thinking.mp3';
+ static const String _doneSound = 'assets/sounds/done.mp3';
+ final AudioPlayer _soundPlayer = AudioPlayer(); // sons one-shot
+ final AudioPlayer _thinkingPlayer = AudioPlayer(); // thinking loop
+
+ // Anti-spam QR
+ final Map _lastQrTime = {};
+ static const int _qrCooldownMs = 10000;
+
+ static final RegExp _urlPattern1 =
+ RegExp(r'https://web\.mymuseum\.be/([^/]+)/([^/]+)/([^/\s]+)');
+ static final RegExp _urlPattern2 =
+ RegExp(r'https://web\.myinfomate\.be/([^/]+)/([^/]+)/([^/\s]+)');
+
+ GlassesOrchestrator({
+ required this.visitAppContext,
+ required this.wakeWordEngine,
+ required this.sttEngine,
+ required this.ttsEngine,
+ required this.llmClient,
+ });
+
+ Future start() async {
+ if (_running) return;
+ _running = true;
+
+ await wakeWordEngine.start(
+ onDetected: _onWakeWord,
+ onDetectedWithCommand: _onWakeWordWithCommand,
+ );
+ debugPrint('[GlassesOrchestrator] Started');
+ }
+
+ Future stop() async {
+ await wakeWordEngine.stop();
+ await sttEngine.cancel();
+ await ttsEngine.stop();
+ _running = false;
+ debugPrint('[GlassesOrchestrator] Stopped');
+ }
+
+ bool get isRunning => _running;
+ bool get isInConversation => _inConversation;
+ bool get isListening => _running && !_inConversation;
+
+ /// Relance l'écoute wake word en utilisant les callbacks internes
+ /// (avec son de détection). À appeler depuis le lifecycle observer.
+ Future restartWakeWord() async {
+ if (!_running || _inConversation) return;
+ await wakeWordEngine.start(
+ onDetected: _onWakeWord,
+ onDetectedWithCommand: _onWakeWordWithCommand,
+ );
+ }
+
+ /// Déclenchement manuel (ex: bouton debug, test)
+ Future triggerConversation() => _handleConversation();
+
+ /// Dispatch direct d'une commande (utilisé par le lifecycle observer)
+ Future dispatchCommand(String command) => _dispatch(command);
+
+ /// Déclenchement scan QR depuis un chemin d'image existant.
+ Future triggerQrScan(String imagePath) async {
+ final qr = await _tryDecodeQr(imagePath);
+ if (qr != null) await explainSection(qr.sectionId, configurationId: qr.configId);
+ }
+
+ // ── Wake word ──────────────────────────────────────────────────────────────
+
+ /// Déclenché quand le wake word est détecté sans commande inline.
+ /// Lance un cycle STT séparé pour capturer la commande.
+ void _onWakeWord() async {
+ if (_inConversation) return;
+ _inConversation = true;
+ await wakeWordEngine.stop(); // envoie ACTION_PAUSE immédiatement — AudioRecord s'arrête pendant le son
+ await _stopThinkingLoop();
+ await _playWakeSound(); // ~300ms — largement suffisant pour que l'AudioRecord soit libéré
+ try {
+ await _handleConversation();
+ } finally {
+ _inConversation = false;
+ if (_running) await wakeWordEngine.start(
+ onDetected: _onWakeWord,
+ onDetectedWithCommand: _onWakeWordWithCommand,
+ );
+ }
+ }
+
+ /// Déclenché quand le wake word ET la commande sont dans le même énoncé.
+ /// "visite qu'est-ce que c'est" → commande = "qu'est-ce que c'est"
+ /// Si commande vide → fallback sur cycle STT séparé.
+ void _onWakeWordWithCommand(String inlineCommand) async {
+ if (_inConversation) return;
+ _inConversation = true;
+ await wakeWordEngine.stop();
+ await _stopThinkingLoop();
+ await _playWakeSound();
+ try {
+ if (inlineCommand.isNotEmpty) {
+ debugPrint('[GlassesOrchestrator] Inline command: "$inlineCommand"');
+ await _dispatch(inlineCommand);
+ } else {
+ await _handleConversation();
+ }
+ } finally {
+ _inConversation = false;
+ if (_running) await wakeWordEngine.start(
+ onDetected: _onWakeWord,
+ onDetectedWithCommand: _onWakeWordWithCommand,
+ );
+ }
+ }
+
+ Future _playSound(String asset) async {
+ try {
+ await _soundPlayer.setAsset(asset);
+ await _soundPlayer.play();
+ // play() se complète quand la lecture se termine
+ } catch (_) {
+ debugPrint('[GlassesOrchestrator] Sound not found: $asset');
+ }
+ }
+
+ Future _playWakeSound() => _playSound(_wakeSound);
+ Future _playDoneSound() => _playSound(_doneSound);
+
+ Future _startThinkingLoop() async {
+ try {
+ await _thinkingPlayer.setAsset(_thinkingSound);
+ await _thinkingPlayer.setLoopMode(LoopMode.one);
+ _thinkingPlayer.play(); // pas de await — tourne en arrière-plan
+ } catch (_) {
+ debugPrint('[GlassesOrchestrator] Thinking sound not found');
+ }
+ }
+
+ Future _stopThinkingLoop() async {
+ await _thinkingPlayer.stop();
+ }
+
+ // ── Conversation vocale ────────────────────────────────────────────────────
+
+ Future _handleConversation() async {
+ final lang = visitAppContext.language ?? 'FR';
+ final langCode = _toLangCode(lang);
+ isListeningForCommand.value = true;
+ final command = await sttEngine.transcribeOnce(languageCode: langCode);
+ isListeningForCommand.value = false;
+ debugPrint('[GlassesOrchestrator] Command: "$command"');
+ if (command.isEmpty) return;
+ lastTranscription.value = command;
+ await _dispatch(command);
+ }
+
+ Future _dispatch(String command, {bool continueConversation = true}) async {
+ final lang = visitAppContext.language ?? 'FR';
+ final langCode = _toLangCode(lang);
+
+ if (_isStopCommand(command)) {
+ debugPrint('[GlassesOrchestrator] Annulé par l\'utilisateur: "$command"');
+ await _stopThinkingLoop();
+ // Pas de son pour l'annulation — évite une bascule audio focus supplémentaire
+ // qui déclenche le muting persistant MIUI
+ return;
+ }
+
+ if (_isQrScanCommand(command)) {
+ await _handleQrScan();
+ return;
+ }
+
+ if (_isPhotoCommand(command)) {
+ await _handlePhotoCapture();
+ return;
+ }
+
+ if (_isRepeatCommand(command)) {
+ await ttsEngine.replay();
+ } else {
+ // Question libre → thinking loop → LLM → done → TTS
+ try {
+ await _startThinkingLoop();
+ final reply = await llmClient.chat(
+ command,
+ configurationId: visitAppContext.configuration?.id,
+ languageCode: lang,
+ );
+ await _stopThinkingLoop();
+ if (reply.isNotEmpty) {
+ lastTtsText.value = reply;
+ await _playDoneSound();
+ await ttsEngine.speak(reply, languageCode: langCode);
+ }
+ } catch (e) {
+ await _stopThinkingLoop();
+ debugPrint('[GlassesOrchestrator] LLM error: $e');
+ return; // pas de follow-up si erreur
+ }
+ }
+
+ // Mode conversation : écoute directement la réponse sans redemander le wake word
+ if (continueConversation) {
+ await _listenForFollowUp();
+ }
+ }
+
+ /// Écoute une question de suivi après la réponse TTS.
+ /// Timeout = 5s de silence → fin de conversation, retour au wake word.
+ Future _listenForFollowUp() async {
+ final lang = visitAppContext.language ?? 'FR';
+ final langCode = _toLangCode(lang);
+
+ isListeningForCommand.value = true;
+ final followUp = await sttEngine.transcribeOnce(
+ languageCode: langCode,
+ timeout: const Duration(seconds: 5),
+ );
+ isListeningForCommand.value = false;
+
+ if (followUp.isNotEmpty) lastTranscription.value = followUp;
+
+ if (followUp.isEmpty || _isStopCommand(followUp)) {
+ debugPrint('[GlassesOrchestrator] Conversation ended (silence or stop)');
+ return;
+ }
+
+ debugPrint('[GlassesOrchestrator] Follow-up: "$followUp"');
+ await _dispatch(followUp, continueConversation: true);
+ }
+
+ // ── QR scan depuis frames du stream ──────────────────────────────────────
+
+ /// Démarre le stream, prend jusqu'à 5 frames espacées de 400ms,
+ /// tente de décoder un QR sur chacune. Stoppe le stream après.
+ Future _handleQrScan() async {
+ final lang = _toLangCode(visitAppContext.language ?? 'FR');
+ debugPrint('[QrScan] Starting — requesting photo capture...');
+ final completer = Completer();
+
+ final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
+ MetaGlassesService.instance.onPhotoCaptured = (path) {
+ debugPrint('[QrScan] Photo callback received — path="${path.isEmpty ? "EMPTY/ERROR" : path}"');
+ if (!completer.isCompleted) completer.complete(path.isEmpty ? null : path);
+ prevCallback?.call(path);
+ };
+ Timer(const Duration(seconds: 10), () {
+ if (!completer.isCompleted) {
+ debugPrint('[QrScan] Timeout — no photo received after 10s');
+ completer.complete(null);
+ }
+ });
+
+ await MetaGlassesService.instance.requestPhotoCapture();
+ debugPrint('[QrScan] requestPhotoCapture() returned — waiting for callback...');
+ final photoPath = await completer.future;
+ MetaGlassesService.instance.onPhotoCaptured = prevCallback;
+
+ if (photoPath == null) {
+ debugPrint('[QrScan] No photo — camera unavailable or error');
+ lastTtsText.value = TranslationHelper.getFromLocale('voice.cameraUnavailable', visitAppContext);
+ await ttsEngine.speak(lastTtsText.value, languageCode: lang);
+ return;
+ }
+
+ debugPrint('[QrScan] Photo received at $photoPath — decoding QR...');
+ final qr = await _tryDecodeQr(photoPath);
+ try { File(photoPath).deleteSync(); } catch (_) {}
+
+ if (qr != null) {
+ debugPrint('[QrScan] QR found — sectionId=${qr.sectionId} configId=${qr.configId}');
+ await explainSection(qr.sectionId, configurationId: qr.configId);
+ } else {
+ debugPrint('[QrScan] No QR code found in photo');
+ lastTtsText.value = TranslationHelper.getFromLocale('voice.noQrFound', visitAppContext);
+ await ttsEngine.speak(lastTtsText.value, languageCode: lang);
+ }
+ }
+
+ // ── Photo capture : mémoire visite ────────────────────────────────────────
+
+ /// Capture une photo, essaie de décoder un QR code.
+ /// Si QR valide → explique la section.
+ /// Si pas de QR → sauvegarde la photo pour la visite (V2).
+ Future _handlePhotoCapture() async {
+ final completer = Completer();
+
+ // Écoute la photo quand elle arrive
+ final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
+ MetaGlassesService.instance.onPhotoCaptured = (path) {
+ if (!completer.isCompleted) completer.complete(path);
+ prevCallback?.call(path);
+ };
+
+ // Timeout si pas de photo en 10s
+ Timer(const Duration(seconds: 10), () {
+ if (!completer.isCompleted) completer.complete(null);
+ });
+
+ await MetaGlassesService.instance.requestPhotoCapture();
+ final photoPath = await completer.future;
+
+ // Restaure le callback précédent
+ MetaGlassesService.instance.onPhotoCaptured = prevCallback;
+
+ if (photoPath == null || photoPath.isEmpty) {
+ debugPrint('[GlassesOrchestrator] Photo capture failed or timeout');
+ final lang = _toLangCode(visitAppContext.language ?? 'FR');
+ lastTtsText.value = TranslationHelper.getFromLocale('voice.photoFailed', visitAppContext);
+ await ttsEngine.speak(lastTtsText.value, languageCode: lang);
+ return;
+ }
+
+ // Essaie de décoder un QR code
+ final qr = await _tryDecodeQr(photoPath);
+
+ if (qr != null) {
+ await explainSection(qr.sectionId, configurationId: qr.configId);
+ } else {
+ // Pas de QR — sauvegarde pour la visite
+ visitPhotos.add(photoPath);
+ debugPrint('[GlassesOrchestrator] Photo saved for visit (no QR): $photoPath');
+ // V2 : résumé en fin de visite, identification d'œuvre, etc.
+ // Pour l'instant : feedback vocal simple
+ final lang = _toLangCode(visitAppContext.language ?? 'FR');
+ lastTtsText.value = TranslationHelper.getFromLocale('voice.photoCaptured', visitAppContext);
+ await ttsEngine.speak(lastTtsText.value, languageCode: lang);
+ }
+ }
+
+ /// Essaie de décoder un QR code depuis l'image.
+ /// Retourne le sectionId si trouvé, null sinon.
+ Future<({String sectionId, String? configId})?> _tryDecodeQr(String imagePath) async {
+ debugPrint('[QrScan] analyzeImage: $imagePath');
+ final controller = MobileScannerController();
+ ({String sectionId, String? configId})? result;
+ try {
+ final completer = Completer<({String sectionId, String? configId})?>();
+ final sub = controller.barcodes.listen((capture) {
+ debugPrint('[QrScan] barcodes detected: ${capture.barcodes.length}');
+ for (final barcode in capture.barcodes) {
+ final raw = barcode.rawValue;
+ debugPrint('[QrScan] raw value: "$raw"');
+ if (raw != null) {
+ final ids = _extractQrIds(raw);
+ debugPrint('[QrScan] extracted: sectionId=${ids?.sectionId} configId=${ids?.configId}');
+ if (ids != null && !completer.isCompleted) completer.complete(ids);
+ }
+ }
+ });
+ await controller.analyzeImage(imagePath);
+ Timer(const Duration(seconds: 2), () {
+ if (!completer.isCompleted) {
+ debugPrint('[QrScan] analyzeImage timeout — no barcode found');
+ completer.complete(null);
+ }
+ });
+ result = await completer.future;
+ await sub.cancel();
+ } catch (e) {
+ debugPrint('[QrScan] decode error: $e');
+ } finally {
+ controller.dispose();
+ }
+ debugPrint('[QrScan] result: ${result != null ? "found sectionId=${result.sectionId}" : "null"}');
+ return result;
+ }
+
+ /// Retourne (sectionId, configId) extraits du QR.
+ /// configId peut être null si le QR est un ID brut sans URL.
+ ({String sectionId, String? configId})? _extractQrIds(String raw) {
+ final m1 = _urlPattern1.firstMatch(raw);
+ if (m1 != null) return (sectionId: m1.group(3)!, configId: m1.group(2));
+ final m2 = _urlPattern2.firstMatch(raw);
+ if (m2 != null) return (sectionId: m2.group(3)!, configId: m2.group(2));
+ // ID brut — valider contre la config si disponible
+ if (visitAppContext.sectionIds != null) {
+ return visitAppContext.sectionIds!.contains(raw)
+ ? (sectionId: raw, configId: visitAppContext.configuration?.id)
+ : null;
+ }
+ // Pas de config chargée — accepter l'ID brut sans configId
+ return (sectionId: raw, configId: null);
+ }
+
+ Future explainSection(String sectionId, {String? configurationId}) async {
+ final now = DateTime.now().millisecondsSinceEpoch;
+ if ((now - (_lastQrTime[sectionId] ?? 0)) < _qrCooldownMs) return;
+ _lastQrTime[sectionId] = now;
+
+ // Priorité : configId passé explicitement (extrait du QR URL) > config active > null (mode instance)
+ final cfgId = configurationId ?? visitAppContext.configuration?.id;
+ final lang = visitAppContext.language ?? 'FR';
+ try {
+ final reply = await llmClient.chat(
+ 'Le visiteur vient de scanner le QR code de la section "$sectionId". '
+ 'Appelle GetSectionDetail avec cet ID, puis présente le contenu de façon engageante en 2-3 phrases. '
+ 'Utilise les informations réelles du champ Contenu — cite des détails concrets, pas de généralités.',
+ configurationId: cfgId,
+ languageCode: lang,
+ );
+ if (reply.isNotEmpty) {
+ lastTtsText.value = reply;
+ await ttsEngine.speak(reply, languageCode: _toLangCode(lang));
+ }
+ } catch (e) {
+ debugPrint('[GlassesOrchestrator] explainSection error: $e');
+ }
+ }
+
+ // ── Helpers ────────────────────────────────────────────────────────────────
+
+ bool _isStopCommand(String text) {
+ final t = text.toLowerCase().trim();
+ return t == 'non' ||
+ t == 'rien' ||
+ t == 'non rien' ||
+ t == 'rien merci' ||
+ t == 'non merci' ||
+ t == 'laisse tomber' ||
+ t == 'annule' ||
+ t == 'annuler' ||
+ t.contains('stop') ||
+ t.contains('arrête') ||
+ t.contains('au revoir') ||
+ t.contains('c\'est bon') ||
+ t.contains('ok merci') ||
+ t.contains('laisse tomber') ||
+ (t.length < 10 && (t.contains('non') || t.contains('rien')));
+ }
+
+ bool _isQrScanCommand(String text) {
+ final t = text.toLowerCase();
+ return t.contains('scan') || t.contains('qr') || t.contains('code') ||
+ t.contains('regarde');
+ }
+
+ bool _isPhotoCommand(String text) {
+ final t = text.toLowerCase();
+ return t.contains('photo') || t.contains('prends') || t.contains('capture');
+ }
+
+ bool _isRepeatCommand(String text) {
+ final t = text.toLowerCase();
+ return t.contains('répète') || t.contains('repete') || t.contains('encore');
+ }
+
+ String _toLangCode(String lang) {
+ switch (lang.toUpperCase()) {
+ case 'FR': return 'fr-FR';
+ case 'NL': return 'nl-NL';
+ case 'EN': return 'en-US';
+ case 'DE': return 'de-DE';
+ default: return 'fr-FR';
+ }
+ }
+}
diff --git a/lib/Services/assistantService.dart b/lib/Services/assistantService.dart
index 7f698b8..5b17f3d 100644
--- a/lib/Services/assistantService.dart
+++ b/lib/Services/assistantService.dart
@@ -11,14 +11,22 @@ class AssistantService {
Future chat({
required String message,
String? configurationId,
+ }) => chatWithAppType(message: message, configurationId: configurationId);
+
+ Future chatWithAppType({
+ required String message,
+ String? configurationId,
+ AppType appType = AppType.Mobile,
+ bool isVoice = false,
}) async {
final request = AiChatRequest(
message: message,
instanceId: visitAppContext.instanceId,
- appType: AppType.Mobile,
+ appType: appType,
configurationId: configurationId,
language: visitAppContext.language?.toUpperCase() ?? 'FR',
history: List.from(history),
+ isVoice: isVoice,
);
final response = await visitAppContext.clientAPI.aiApi!.aiChat(request);
diff --git a/lib/Services/downloadConfiguration.dart b/lib/Services/downloadConfiguration.dart
index fbcd093..1d85396 100644
--- a/lib/Services/downloadConfiguration.dart
+++ b/lib/Services/downloadConfiguration.dart
@@ -148,7 +148,7 @@ class _DownloadConfigurationWidgetState extends State sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, widget.configuration.id!);
- List sectionsToKeep = sections.where((s) => s.type == SectionType.Article || s.type == SectionType.Quiz).toList(); // TODO handle other type of section (for now, Article and Quizz)
+ List sectionsToKeep = sections.where((s) => s.type == SectionType.Article || s.type == SectionType.Quiz).toList(); // TODO: supporter tous les types de sections (Game, Menu, Map, PDF, Video, Slider, Web, Weather, Agenda) — actuellement limité à Article et Quiz
sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!));
int newOrder = 0;
@@ -390,7 +390,7 @@ class _DownloadConfigurationWidgetState extends State sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, configuration.id!);
- List sectionsToKeep = sections.where((s) => s.type == SectionType.Article || s.type == SectionType.Quizz).toList(); // TODO handle other type of section (for now, Article and Quizz)
+ List sectionsToKeep = sections.where((s) => s.type == SectionType.Article || s.type == SectionType.Quizz).toList(); // TODO: supporter tous les types de sections (Game, Menu, Map, PDF, Video, Slider, Web, Weather, Agenda) — actuellement limité à Article et Quiz
sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!));
int newOrder = 0;
diff --git a/lib/Services/geo_beacon_trigger_service.dart b/lib/Services/geo_beacon_trigger_service.dart
new file mode 100644
index 0000000..cc6ea6e
--- /dev/null
+++ b/lib/Services/geo_beacon_trigger_service.dart
@@ -0,0 +1,233 @@
+import 'dart:async';
+import 'dart:io';
+import 'package:beacon_scanner/beacon_scanner.dart';
+import 'package:flutter/foundation.dart';
+import 'package:geolocator/geolocator.dart';
+import 'package:mymuseum_visitapp/Models/beaconSection.dart';
+import 'package:mymuseum_visitapp/Models/visitContext.dart';
+import 'package:mymuseum_visitapp/Services/assistantService.dart';
+import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
+import 'package:mymuseum_visitapp/constants.dart';
+
+/// Point de déclenchement géographique pour le TTS automatique.
+class GeoTriggerPoint {
+ final String id;
+ final double latitude;
+ final double longitude;
+ final double radiusMeters;
+
+ const GeoTriggerPoint({
+ required this.id,
+ required this.latitude,
+ required this.longitude,
+ this.radiusMeters = 20.0,
+ });
+}
+
+/// Déclenche automatiquement une réponse TTS de l'assistant quand :
+/// - Le visiteur entre dans le rayon d'un GeoTriggerPoint (GPS)
+/// - Un beacon BLE est détecté à proximité (< [beaconProximityMeters])
+///
+/// Le mode proactif doit être activé dans [VisitAppContext.proactiveModeEnabled]
+/// pour que les déclenchements soient actifs.
+class GeoBeaconTriggerService {
+ GeoBeaconTriggerService._();
+ static final GeoBeaconTriggerService instance = GeoBeaconTriggerService._();
+
+ static const double beaconProximityMeters = 3.0;
+ static const int cooldownMillis = 30000; // 30s entre deux triggers du même point
+
+ VisitAppContext? _visitAppContext;
+ AssistantService? _assistantService;
+
+ StreamSubscription? _geoSub;
+ StreamSubscription? _beaconSub;
+
+ final List _geoPoints = [];
+ final Set _triggeredIds = {};
+ final Map _lastTriggerTime = {};
+
+ bool _running = false;
+
+ /// Lance le service.
+ /// [geoPoints] : points de déclenchement GPS (à peupler depuis la configuration).
+ Future start({
+ required VisitAppContext visitAppContext,
+ List geoPoints = const [],
+ }) async {
+ if (_running) return;
+ if (!Platform.isAndroid && !Platform.isIOS) return;
+
+ _visitAppContext = visitAppContext;
+ _assistantService = AssistantService(visitAppContext: visitAppContext);
+ _geoPoints
+ ..clear()
+ ..addAll(geoPoints);
+ _running = true;
+
+ await _startGeoTracking();
+ await _startBeaconTracking();
+
+ debugPrint('[GeoBeaconTriggerService] Started — ${_geoPoints.length} geo points, beacons active');
+ }
+
+ Future stop() async {
+ await _geoSub?.cancel();
+ await _beaconSub?.cancel();
+ _geoSub = null;
+ _beaconSub = null;
+ _running = false;
+ debugPrint('[GeoBeaconTriggerService] Stopped');
+ }
+
+ /// Recharge les GeoTriggerPoints (appeler après un changement de configuration).
+ void updateGeoPoints(List points) {
+ _geoPoints
+ ..clear()
+ ..addAll(points);
+ _triggeredIds.clear();
+ }
+
+ // ── GPS ─────────────────────────────────────────────────────────────────────
+
+ Future _startGeoTracking() async {
+ final permission = await Geolocator.checkPermission();
+ if (permission == LocationPermission.denied ||
+ permission == LocationPermission.deniedForever) {
+ debugPrint('[GeoBeaconTriggerService] Location permission not granted');
+ return;
+ }
+
+ _geoSub = Geolocator.getPositionStream(
+ locationSettings: const LocationSettings(
+ accuracy: LocationAccuracy.high,
+ distanceFilter: 5,
+ ),
+ ).listen(_onPosition);
+ }
+
+ void _onPosition(Position position) {
+ if (!_isProactiveModeActive()) return;
+
+ for (final point in _geoPoints) {
+ final dist = Geolocator.distanceBetween(
+ position.latitude,
+ position.longitude,
+ point.latitude,
+ point.longitude,
+ );
+ if (dist <= point.radiusMeters && _canTrigger(point.id)) {
+ _trigger(
+ triggerId: point.id,
+ prompt: 'Tu es un guide audio de musée. '
+ 'Le visiteur vient d\'entrer dans la zone "${point.id}". '
+ 'Accueille-le et donne-lui une brève information de bienvenue en 2 phrases.',
+ );
+ }
+ }
+ }
+
+ // ── Beacons ──────────────────────────────────────────────────────────────────
+
+ Future _startBeaconTracking() async {
+ final ctx = _visitAppContext;
+ if (ctx?.beaconSections == null || ctx!.beaconSections!.isEmpty) return;
+
+ final regions = [
+ if (Platform.isIOS)
+ Region(
+ identifier: 'GlassesBeacon',
+ beaconId: IBeaconId(proximityUUID: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825'),
+ )
+ else
+ Region(identifier: 'GlassesBeacon'),
+ ];
+
+ try {
+ _beaconSub = BeaconScanner.instance
+ .ranging(regions)
+ .listen((ScanResult result) => _onBeaconResult(result));
+ } catch (e) {
+ debugPrint('[GeoBeaconTriggerService] Beacon scan error: $e');
+ }
+ }
+
+ void _onBeaconResult(ScanResult result) {
+ if (!_isProactiveModeActive()) return;
+ final ctx = _visitAppContext;
+ if (ctx?.beaconSections == null) return;
+
+ for (final beacon in result.beacons) {
+ if (beacon.accuracy > beaconProximityMeters) continue;
+
+ final match = ctx!.beaconSections!.firstWhere(
+ (bs) =>
+ bs != null &&
+ bs.minorBeaconId == beacon.id.minorId &&
+ bs.configurationId == ctx.configuration?.id,
+ orElse: () => null,
+ );
+
+ if (match?.sectionId != null && _canTrigger(match!.sectionId!)) {
+ _trigger(
+ triggerId: match.sectionId!,
+ prompt: 'Tu es un guide audio de musée. '
+ 'Le visiteur est juste devant l\'œuvre ou l\'espace lié au point d\'intérêt "${match.sectionId}". '
+ 'Donne une présentation courte et engageante en 2-3 phrases.',
+ );
+ }
+ }
+ }
+
+ // ── Déclenchement commun ─────────────────────────────────────────────────────
+
+ bool _canTrigger(String id) {
+ final now = DateTime.now().millisecondsSinceEpoch;
+ final last = _lastTriggerTime[id] ?? 0;
+ return (now - last) >= cooldownMillis;
+ }
+
+ bool _isProactiveModeActive() {
+ return _visitAppContext?.proactiveModeEnabled == true &&
+ _visitAppContext?.glassesEnabled == true;
+ }
+
+ Future _trigger({
+ required String triggerId,
+ required String prompt,
+ }) async {
+ _lastTriggerTime[triggerId] = DateTime.now().millisecondsSinceEpoch;
+ debugPrint('[GeoBeaconTriggerService] Trigger: $triggerId');
+
+ try {
+ final response = await _assistantService!.chat(
+ message: prompt,
+ configurationId: _visitAppContext?.configuration?.id,
+ );
+
+ if (response.reply.isNotEmpty) {
+ final lang = _visitAppContext?.language ?? 'FR';
+ await activeOrchestrator?.ttsEngine.speak(
+ response.reply,
+ languageCode: _toLangCode(lang),
+ );
+ }
+ } catch (e) {
+ debugPrint('[GeoBeaconTriggerService] Trigger error for $triggerId: $e');
+ }
+ }
+
+ 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';
+ }
+ }
+
+ void dispose() {
+ stop();
+ }
+}
diff --git a/lib/Services/glasses_qr_scanner_service.dart b/lib/Services/glasses_qr_scanner_service.dart
new file mode 100644
index 0000000..5a45501
--- /dev/null
+++ b/lib/Services/glasses_qr_scanner_service.dart
@@ -0,0 +1,118 @@
+import 'package:flutter/foundation.dart';
+import 'package:mobile_scanner/mobile_scanner.dart';
+import 'package:mymuseum_visitapp/Models/visitContext.dart';
+import 'package:mymuseum_visitapp/Services/assistantService.dart';
+import 'package:mymuseum_visitapp/Services/glasses_tts_service.dart';
+import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
+import 'package:mymuseum_visitapp/constants.dart';
+
+/// Décode les QR codes depuis les photos capturées par les lunettes.
+///
+/// Déclencheur : bouton hardware des lunettes → MetaGlassesService.requestPhotoCapture()
+/// → capturePhoto() retourne un chemin fichier → analyzeImage() → stream barcodes
+///
+/// Sur QR valide (format web.mymuseum.be ou web.myinfomate.be) :
+/// - Appelle AssistantService avec un prompt de résumé de la section
+/// - Joue la réponse via GlassesTtsService
+///
+/// Anti-spam : 10s de cooldown entre deux traitements du même QR.
+class GlassesQrScannerService {
+ GlassesQrScannerService._();
+ static final GlassesQrScannerService instance = GlassesQrScannerService._();
+
+ static const int _cooldownMs = 10000;
+
+ static final RegExp _urlPattern1 =
+ RegExp(r'https://web\.mymuseum\.be/([^/]+)/([^/]+)/([^/\s]+)');
+ static final RegExp _urlPattern2 =
+ RegExp(r'https://web\.myinfomate\.be/([^/]+)/([^/]+)/([^/\s]+)');
+
+ VisitAppContext? _visitAppContext;
+ AssistantService? _assistantService;
+ MobileScannerController? _scannerController;
+
+ final Map _lastScanTime = {};
+
+ void start({required VisitAppContext visitAppContext}) {
+ _visitAppContext = visitAppContext;
+ _assistantService = AssistantService(visitAppContext: visitAppContext);
+
+ _scannerController = MobileScannerController();
+ _scannerController!.barcodes.listen((capture) {
+ for (final barcode in capture.barcodes) {
+ final raw = barcode.rawValue;
+ if (raw != null) _processRawValue(raw);
+ }
+ });
+
+ // meta_wearables_dat : capturePhoto() retourne un CapturedPhoto avec .path
+ MetaGlassesService.instance.onPhotoCaptured = _onPhotoCaptured;
+ debugPrint('[GlassesQrScannerService] Started');
+ }
+
+ void stop() {
+ MetaGlassesService.instance.onPhotoCaptured = null;
+ _scannerController?.dispose();
+ _scannerController = null;
+ debugPrint('[GlassesQrScannerService] Stopped');
+ }
+
+ Future _onPhotoCaptured(String photoPath) async {
+ if (_visitAppContext == null || _scannerController == null) return;
+ try {
+ // analyzeImage déclenche le stream .barcodes si un QR est trouvé
+ await _scannerController!.analyzeImage(photoPath);
+ } catch (e) {
+ debugPrint('[GlassesQrScannerService] analyzeImage error: $e');
+ }
+ }
+
+ void _processRawValue(String raw) {
+ final sectionId = _extractSectionId(raw);
+ if (sectionId == null) {
+ debugPrint('[GlassesQrScannerService] QR not recognized: $raw');
+ return;
+ }
+
+ final ctx = _visitAppContext!;
+ if (ctx.sectionIds != null && !ctx.sectionIds!.contains(sectionId)) {
+ debugPrint('[GlassesQrScannerService] Section $sectionId not in current config');
+ return;
+ }
+
+ final now = DateTime.now().millisecondsSinceEpoch;
+ if ((now - (_lastScanTime[sectionId] ?? 0)) < _cooldownMs) return;
+ _lastScanTime[sectionId] = now;
+
+ _explainSection(sectionId);
+ }
+
+ String? _extractSectionId(String raw) {
+ final m1 = _urlPattern1.firstMatch(raw);
+ if (m1 != null) return m1.group(3);
+ final m2 = _urlPattern2.firstMatch(raw);
+ if (m2 != null) return m2.group(3);
+ if (_visitAppContext?.sectionIds?.contains(raw) == true) return raw;
+ return null;
+ }
+
+ Future _explainSection(String sectionId) async {
+ debugPrint('[GlassesQrScannerService] Explaining section: $sectionId');
+ try {
+ final response = await _assistantService!.chat(
+ message: 'Le visiteur vient de scanner le QR code de la section "$sectionId". '
+ 'Présente-lui ce contenu de façon engageante en 3 phrases maximum.',
+ configurationId: _visitAppContext?.configuration?.id,
+ );
+ if (response.reply.isNotEmpty) {
+ await GlassesTtsService.instance.speak(
+ response.reply,
+ apiKey: kElevenLabsApiKey,
+ voiceId: kElevenLabsVoiceId,
+ );
+ }
+ } catch (e) {
+ debugPrint('[GlassesQrScannerService] explainSection error: $e');
+ }
+ }
+}
diff --git a/lib/Services/glasses_tts_service.dart b/lib/Services/glasses_tts_service.dart
new file mode 100644
index 0000000..298b529
--- /dev/null
+++ b/lib/Services/glasses_tts_service.dart
@@ -0,0 +1,117 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+import 'package:just_audio/just_audio.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:mymuseum_visitapp/PlatformChannels/audio_routing_channel.dart';
+import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
+
+/// Service TTS qui synthétise du texte via ElevenLabs
+/// et joue l'audio directement dans les lunettes via Bluetooth A2DP.
+///
+/// Si les lunettes ne sont pas connectées, le son joue sur le haut-parleur.
+/// Si la clé ElevenLabs n'est pas configurée, le TTS est silencieux.
+class GlassesTtsService {
+ GlassesTtsService._();
+ static final GlassesTtsService instance = GlassesTtsService._();
+
+ static const String _baseUrl = 'https://api.elevenlabs.io/v1';
+
+ final AudioPlayer _player = AudioPlayer();
+ String? _lastSpokenText;
+ String? _lastTempPath;
+ bool _isSpeaking = false;
+
+ bool get isSpeaking => _isSpeaking;
+
+ /// Synthétise [text] et joue l'audio sur les lunettes (ou haut-parleur en fallback).
+ ///
+ /// [apiKey] : clé ElevenLabs (typiquement kElevenLabsApiKey de constants.dart)
+ /// [voiceId] : ID de voix ElevenLabs (kElevenLabsVoiceId)
+ Future speak(
+ String text, {
+ required String apiKey,
+ required String voiceId,
+ }) async {
+ if (text.isEmpty) return;
+ _lastSpokenText = text;
+
+ try {
+ _isSpeaking = true;
+
+ if (MetaGlassesService.instance.isConnected && !Platform.isWindows) {
+ await AudioRoutingChannel.enableBluetoothOutput();
+ }
+
+ if (apiKey.isNotEmpty) {
+ await _speakElevenLabs(text, apiKey, voiceId);
+ } else {
+ debugPrint('[GlassesTtsService] No ElevenLabs API key — TTS skipped');
+ }
+ } catch (e) {
+ debugPrint('[GlassesTtsService] speak error: $e');
+ } finally {
+ _isSpeaking = false;
+ }
+ }
+
+ /// Rejoue la dernière synthèse (commande vocale "répète").
+ Future replay() async {
+ if (_lastTempPath == null) return;
+ try {
+ await _player.seek(Duration.zero);
+ await _player.play();
+ } catch (e) {
+ debugPrint('[GlassesTtsService] replay error: $e');
+ }
+ }
+
+ Future stop() async {
+ await _player.stop();
+ _isSpeaking = false;
+ }
+
+ Future _speakElevenLabs(
+ String text, String apiKey, String voiceId) async {
+ final body = jsonEncode({
+ 'text': text,
+ 'model_id': 'eleven_multilingual_v2',
+ 'voice_settings': {'stability': 0.5, 'similarity_boost': 0.75},
+ });
+
+ final response = await http.post(
+ Uri.parse('$_baseUrl/text-to-speech/$voiceId'),
+ headers: {
+ 'xi-api-key': apiKey,
+ 'Content-Type': 'application/json',
+ 'Accept': 'audio/mpeg',
+ },
+ body: body,
+ );
+
+ if (response.statusCode != 200) {
+ throw Exception(
+ 'ElevenLabs error ${response.statusCode}: ${response.body}');
+ }
+
+ final file = await _writeTempMp3(response.bodyBytes);
+ _lastTempPath = file.path;
+
+ await _player.setFilePath(file.path);
+ await _player.play();
+ }
+
+ Future _writeTempMp3(Uint8List bytes) async {
+ final dir = await getTemporaryDirectory();
+ final file = File('${dir.path}/glasses_tts.mp3');
+ await file.writeAsBytes(bytes, flush: true);
+ return file;
+ }
+
+ void dispose() {
+ _player.dispose();
+ AudioRoutingChannel.restoreDefaultOutput();
+ }
+}
diff --git a/lib/Services/meta_glasses_service.dart b/lib/Services/meta_glasses_service.dart
new file mode 100644
index 0000000..408c911
--- /dev/null
+++ b/lib/Services/meta_glasses_service.dart
@@ -0,0 +1,241 @@
+import 'dart:async';
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:flutter/foundation.dart';
+import 'package:meta_wearables_dat/meta_wearables_dat.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:mymuseum_visitapp/PlatformChannels/audio_routing_channel.dart';
+
+enum GlassesState { disconnected, connecting, connected, streaming }
+
+/// Gère la connexion aux lunettes Ray-Ban Meta via le SDK DAT.
+///
+/// Cycle de vie intentionnel :
+/// 1. initialize() — init SDK, écoute les streams d'état (au démarrage app)
+/// 2. connect() — enregistrement DAT + permission caméra + HFP audio routing
+/// Le téléphone est connecté aux lunettes, micro HFP actif.
+/// PAS de stream vidéo encore.
+/// 3. startStream() — démarre le stream vidéo (uniquement quand caméra nécessaire)
+/// 4. stopStream() — arrête le stream, reste connecté
+/// 5. disconnect() — déconnexion complète
+class MetaGlassesService {
+ MetaGlassesService._();
+ static final MetaGlassesService instance = MetaGlassesService._();
+
+ final ValueNotifier state = ValueNotifier(GlassesState.disconnected);
+
+ /// Appelé avec le chemin de la photo après capturePhoto().
+ void Function(String photoPath)? onPhotoCaptured;
+
+ bool get isConnected =>
+ state.value == GlassesState.connected || state.value == GlassesState.streaming;
+
+ // ── 1. Initialisation SDK ─────────────────────────────────────────────────
+
+ Future initialize() async {
+ if (!Platform.isAndroid) return;
+
+ try {
+ final ok = await Wearables.instance.initialize();
+ if (!ok) {
+ debugPrint('[MetaGlassesService] SDK init failed');
+ return;
+ }
+ } catch (e) {
+ // ALREADY_INITIALIZED = hot restart, le SDK natif garde son état → OK
+ if (e.toString().contains('ALREADY_INITIALIZED')) {
+ debugPrint('[MetaGlassesService] Already initialized (hot restart) — continuing');
+ } else {
+ debugPrint('[MetaGlassesService] Init error: $e');
+ return;
+ }
+ }
+
+ Wearables.instance.registrationStateStream.listen(_onRegistrationState);
+ Wearables.instance.streamStateStream.listen(_onStreamState);
+ Wearables.instance.devicesStream.listen((devices) {
+ debugPrint('[MetaGlassesService] Devices: $devices');
+ if (devices.isNotEmpty) _onDeviceConnected();
+ });
+
+ debugPrint('[MetaGlassesService] Initialized');
+ }
+
+ // ── 2. Connexion (sans stream vidéo) ──────────────────────────────────────
+
+ /// Connecte aux lunettes et active le routing audio HFP.
+ /// Le micro des lunettes devient actif pour le wake word.
+ /// PAS de stream vidéo — utiliser [startStream] séparément.
+ Future connect() async {
+ if (!Platform.isAndroid) return;
+ state.value = GlassesState.connecting;
+
+ await Permission.camera.request();
+ await Wearables.instance.startRegistration();
+ await _ensureCameraPermission();
+
+ debugPrint('[MetaGlassesService] Connected (no stream yet)');
+ }
+
+ // ── 3. Stream vidéo (sur demande) ─────────────────────────────────────────
+
+ /// Démarre le stream vidéo continu (nécessaire pour capturePhoto).
+ /// Appeler uniquement quand la caméra est requise.
+ Future startStream() async {
+ if (!isConnected) return;
+ await Wearables.instance.startStream(videoQuality: 'MEDIUM', frameRate: 24);
+ debugPrint('[MetaGlassesService] Stream started');
+ }
+
+ Future stopStream() async {
+ await Wearables.instance.stopStream();
+ if (state.value == GlassesState.streaming) {
+ state.value = GlassesState.connected;
+ }
+ debugPrint('[MetaGlassesService] Stream stopped');
+ }
+
+ // ── 4. Déconnexion ────────────────────────────────────────────────────────
+
+ Future