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 disconnect() async { + await Wearables.instance.stopStream(); + await AudioRoutingChannel.restoreDefaultOutput(); + state.value = GlassesState.disconnected; + } + + // ── Capture photo ───────────────────────────────────────────────────────── + + Future requestPhotoCapture() async { + if (!isConnected) { + debugPrint('[MetaGlassesService] Not connected — capture ignored'); + onPhotoCaptured?.call(''); + return; + } + bool streamStartedByUs = false; + if (state.value != GlassesState.streaming) { + await startStream(); + streamStartedByUs = true; + // Attend que le stream soit réellement actif (max 4s) + final sw = Stopwatch()..start(); + while (state.value != GlassesState.streaming && sw.elapsed.inSeconds < 8) { + await Future.delayed(const Duration(milliseconds: 200)); + } + if (state.value != GlassesState.streaming) { + debugPrint('[MetaGlassesService] Stream not ready after 8s — capture aborted'); + onPhotoCaptured?.call(''); + return; + } + // Délai de stabilisation si le stream vient juste de démarrer + await Future.delayed(const Duration(milliseconds: 1500)); + } + try { + final photo = await Wearables.instance.capturePhoto(); + debugPrint('[MetaGlassesService] Photo captured: ${photo.path}'); + onPhotoCaptured?.call(photo.path); + } catch (e) { + debugPrint('[MetaGlassesService] capturePhoto error: $e'); + onPhotoCaptured?.call(''); + } finally { + if (streamStartedByUs) { + await stopStream(); + debugPrint('[MetaGlassesService] Stream stopped after photo'); + } + } + } + + /// Démarre le stream et attend qu'il soit actif (max 4s). + /// Retourne true si le stream est prêt. + Future ensureStreaming() async { + if (!isConnected) return false; + if (state.value == GlassesState.streaming) return true; + final alreadyStarted = state.value == GlassesState.streaming; + await startStream(); + final sw = Stopwatch()..start(); + while (state.value != GlassesState.streaming && sw.elapsed.inSeconds < 8) { + await Future.delayed(const Duration(milliseconds: 200)); + } + if (state.value != GlassesState.streaming) return false; + // Si on vient de passer à streaming via 'started' (pas 'streaming'), attendre un peu + // que le SDK soit vraiment prêt pour capturePhoto() + if (!alreadyStarted) await Future.delayed(const Duration(milliseconds: 1500)); + return true; + } + + /// Capture un seul frame depuis le stream (doit être déjà actif). + /// Retourne le chemin du fichier temporaire, ou null si pas de frame en 2s. + Future grabFrame() async { + if (state.value != GlassesState.streaming) return null; + final completer = Completer(); + StreamSubscription? sub; + sub = Wearables.instance.videoFramesStream.listen((frame) { + if (!completer.isCompleted) completer.complete(frame); + sub?.cancel(); + }, onError: (_) { + if (!completer.isCompleted) completer.complete(null); + }); + Future.delayed(const Duration(seconds: 2), () { + if (!completer.isCompleted) completer.complete(null); + sub?.cancel(); + }); + final bytes = await completer.future; + if (bytes == null) return null; + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/qr_frame_${DateTime.now().millisecondsSinceEpoch}.jpg'); + await file.writeAsBytes(bytes); + return file.path; + } + + // ── Audio HFP (micro lunettes) ──────────────────────────────────────────── + + /// Appelé quand les lunettes sont détectées. + /// On ne force PAS MODE_IN_COMMUNICATION ici — ça dégraderait le TTS (HFP 8kHz). + /// Android route automatiquement le micro HFP vers SpeechRecognizer quand connecté. + /// A2DP reste actif pour la sortie TTS haute qualité. + void _onDeviceConnected() { + debugPrint('[MetaGlassesService] Glasses connected — A2DP + HFP active (no forced routing)'); + } + + // ── Callbacks SDK ───────────────────────────────────────────────────────── + + Future _ensureCameraPermission() async { + try { + final status = await Wearables.instance.checkCameraPermission(); + debugPrint('[MetaGlassesService] Camera permission: $status'); + if (status.toLowerCase() == 'granted') return; + await Wearables.instance.requestCameraPermission(); + } catch (e) { + debugPrint('[MetaGlassesService] Camera permission error: $e'); + } + } + + void _onRegistrationState(RegistrationState s) { + debugPrint('[MetaGlassesService] Registration: ${s.state} error=${s.error}'); + switch (s.state.toLowerCase()) { + case 'registered': + case 'available': + if (state.value == GlassesState.connecting) { + state.value = GlassesState.connected; + } + case 'unregistered': + case 'unavailable': + state.value = GlassesState.disconnected; + } + } + + void _onStreamState(String s) { + debugPrint('[MetaGlassesService] Stream state: $s'); + final lower = s.toLowerCase(); + // 'streaming' = flux vidéo actif, capturePhoto() fonctionne immédiatement + // 'started' = stream initialisé — capturePhoto() peut fonctionner après un court délai + if (lower.contains('streaming') || lower == 'started') { + state.value = GlassesState.streaming; + } else if (lower == 'stopped' || lower == 'closed') { + if (state.value == GlassesState.streaming) { + state.value = GlassesState.connected; + } + } + } + + void dispose() => state.dispose(); +} diff --git a/lib/Services/pushNotificationService.dart b/lib/Services/pushNotificationService.dart index 8f62a30..8387d8f 100644 --- a/lib/Services/pushNotificationService.dart +++ b/lib/Services/pushNotificationService.dart @@ -70,6 +70,41 @@ class PushNotificationService { .unsubscribeFromTopic('instance_$instanceId'); } + static Future showBeaconDiscoveryNotification({ + required String title, + required String body, + }) async { + const androidDetails = AndroidNotificationDetails( + 'beacon_notifications', + 'Beacon Notifications', + channelDescription: 'Notifications triggered by nearby beacons', + importance: Importance.high, + priority: Priority.high, + ); + await _localNotifications.show( + title.hashCode ^ body.hashCode, + title, + body, + const NotificationDetails(android: androidDetails, iOS: DarwinNotificationDetails()), + ); + } + + static Future showGeoZoneNotification({required String stepTitle}) async { + const androidDetails = AndroidNotificationDetails( + 'geo_notifications', + 'Geo Zone Notifications', + channelDescription: 'Notifications triggered when entering a guided path zone', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + ); + await _localNotifications.show( + stepTitle.hashCode, + stepTitle, + null, + const NotificationDetails(android: androidDetails, iOS: DarwinNotificationDetails()), + ); + } + static Future _showLocalNotification(RemoteMessage message) async { final notification = message.notification; if (notification == null) return; diff --git a/lib/Services/wake_word_service.dart b/lib/Services/wake_word_service.dart new file mode 100644 index 0000000..4fd2ca3 --- /dev/null +++ b/lib/Services/wake_word_service.dart @@ -0,0 +1,159 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +// import 'package:porcupine_flutter/porcupine_flutter.dart'; // V2 — lib manquante dans cache pub +import 'package:speech_to_text/speech_to_text.dart'; +import 'package:mymuseum_visitapp/Models/visitContext.dart'; +import 'package:mymuseum_visitapp/Services/assistantService.dart'; +import 'package:mymuseum_visitapp/Services/glasses_qr_scanner_service.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'; + +/// Écoute en permanence le wake word "Hey MyVisit" via Porcupine (on-device). +/// +/// Sur détection : +/// 1. Démarre speech_to_text pour transcrire la commande +/// 2. Dispatch : +/// - "scanne ce qr" / "scan" → MetaGlassesService.requestPhotoCapture() +/// - "répète" → GlassesTtsService.replay() +/// - autre → AssistantService.chat() → GlassesTtsService +/// 3. Relance Porcupine +/// +/// Prérequis : générer les fichiers .ppn sur https://console.picovoice.ai/ +/// et les placer dans assets/wake_words/ (déclarés dans pubspec.yaml). +class WakeWordService { + WakeWordService._(); + static final WakeWordService instance = WakeWordService._(); + + // Chemins des keyword files — générés pour "Hey MyVisit" sur Picovoice Console + static const String _keywordAndroid = 'assets/wake_words/hey_myvisit_android.ppn'; + static const String _keywordIOS = 'assets/wake_words/hey_myvisit_ios.ppn'; + + // PorcupineManager? _porcupineManager; // V2 + final SpeechToText _speech = SpeechToText(); + AssistantService? _assistantService; + + bool _running = false; + bool _inConversation = false; + + final ValueNotifier isListening = ValueNotifier(false); + + Future start({required VisitAppContext visitAppContext}) async { + if (_running) return; + if (!Platform.isAndroid && !Platform.isIOS) return; + + _assistantService = AssistantService(visitAppContext: visitAppContext); + await _speech.initialize(); + + // Wake word (Porcupine) désactivé en V1 — lib manquante dans pub cache. + // En attendant, la conversation se déclenche uniquement via le bouton hardware + // des lunettes (MetaGlassesService.requestPhotoCapture) ou depuis l'UI. + _running = true; + debugPrint('[WakeWordService] Started (stub — wake word disabled, button-only mode)'); + } + + Future stop() async { + await _speech.cancel(); + _running = false; + isListening.value = false; + } + + // TODO V2 : _startPorcupine() quand porcupine_flutter sera fonctionnel + + // TODO V2 : _onWakeWord(int keywordIndex) — brancher Porcupine ici + + /// Déclenche une conversation vocale manuellement (ex: depuis un bouton UI ou le bouton hardware lunettes). + Future triggerConversation() async { + if (_inConversation || !_running) return; + _inConversation = true; + isListening.value = true; + + final command = await _transcribeCommand(); + debugPrint('[WakeWordService] Command: "$command"'); + await _dispatch(command); + + _inConversation = false; + isListening.value = false; + } + + /// Transcrit la commande vocale via speech_to_text. + /// Retourne la transcription ou une chaîne vide en cas d'échec/timeout. + Future _transcribeCommand() async { + final ctx = _assistantService?.visitAppContext; + final locale = ctx?.language?.toLowerCase() ?? 'fr'; + final completer = Completer(); + String lastResult = ''; + + // Timeout de 6s pour capturer la commande + Timer? timeout = Timer(const Duration(seconds: 6), () { + if (!completer.isCompleted) completer.complete(lastResult); + }); + + await _speech.listen( + localeId: locale, + onResult: (result) { + lastResult = result.recognizedWords; + if (result.finalResult) { + timeout?.cancel(); + if (!completer.isCompleted) completer.complete(lastResult); + } + }, + listenOptions: SpeechListenOptions(partialResults: true), + ); + + return completer.future; + } + + Future _dispatch(String command) async { + final normalized = command.toLowerCase().trim(); + + if (_isQrScanCommand(normalized)) { + await MetaGlassesService.instance.requestPhotoCapture(); + return; + } + + if (_isRepeatCommand(normalized)) { + await GlassesTtsService.instance.replay(); + return; + } + + if (normalized.isEmpty) return; + + // Question libre → AssistantService → TTS + try { + final response = await _assistantService!.chat( + message: command, + configurationId: _assistantService!.visitAppContext.configuration?.id, + ); + if (response.reply.isNotEmpty) { + await GlassesTtsService.instance.speak( + response.reply, + apiKey: kElevenLabsApiKey, + voiceId: kElevenLabsVoiceId, + ); + } + } catch (e) { + debugPrint('[WakeWordService] dispatch error: $e'); + } + } + + bool _isQrScanCommand(String text) { + return text.contains('scann') || + text.contains('scan') || + text.contains('qr') || + text.contains('code'); + } + + bool _isRepeatCommand(String text) { + return text.contains('répète') || + text.contains('repete') || + text.contains('repeat') || + text.contains('encore'); + } + + void dispose() { + stop(); + isListening.dispose(); + } +} diff --git a/lib/constants.dart b/lib/constants.dart index 5ae10b4..ff1422f 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,5 +1,22 @@ import 'package:flutter/material.dart'; +// Third-party integrations — injectées via --dart-define ou à définir ici pour le dev +// Ex: flutter run --dart-define=ELEVENLABS_API_KEY=xxxx --dart-define=PICOVOICE_ACCESS_KEY=yyyy +// Whisper STT (OpenAI ou serveur auto-hébergé compatible) +const kWhisperApiKey = String.fromEnvironment('WHISPER_API_KEY', defaultValue: ''); +const kWhisperEndpoint = String.fromEnvironment('WHISPER_ENDPOINT', + defaultValue: 'https://api.openai.com/v1/audio/transcriptions'); + +// Gemini TTS (2.5 Flash) +const kGeminiApiKey = String.fromEnvironment('GEMINI_API_KEY', defaultValue: ''); +const kGeminiTtsVoice = String.fromEnvironment('GEMINI_TTS_VOICE', defaultValue: 'Algieba'); +const kGeminiTtsPrompt = String.fromEnvironment('GEMINI_TTS_PROMPT', + defaultValue: 'Voix de guide de musée, ton chaleureux et bienveillant, rythme posé et clair.'); + +// ElevenLabs retiré du pipeline (trop cher). Fichier impl/elevenlabs_tts_engine.dart +// conservé comme référence mais non utilisé. +const kPicovoiceAccessKey = String.fromEnvironment('PICOVOICE_ACCESS_KEY', defaultValue: ''); + // API configuration — injectées au build via --dart-define // Ex: flutter build appbundle --flavor mdlf --dart-define=INSTANCE_ID=65ccc67265373befd15be511 --dart-define=API_BASE_URL=https://api.mymuseum.be --dart-define=API_KEY=xxxx const kApiBaseUrl = String.fromEnvironment('API_BASE_URL', diff --git a/lib/main.dart b/lib/main.dart index a931e87..729f555 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,17 @@ import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart'; import 'package:mymuseum_visitapp/Models/articleRead.dart'; import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart'; import 'package:mymuseum_visitapp/Screens/splash_screen.dart'; +import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/flutter_tts_engine.dart'; +import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/gemini_tts_engine.dart'; +// ElevenLabsTtsEngine disponible dans impl/ mais retiré du pipeline — trop cher +import 'package:mymuseum_visitapp/Services/Glasses/glasses_background_service.dart'; +import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/myinfomate_llm_client.dart'; +import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_stt.dart'; +import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/whisper_stt_engine.dart'; +import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/native_wake_word_engine.dart'; +import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_wake_word.dart'; +import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart'; +import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart'; import 'package:mymuseum_visitapp/Services/pushNotificationService.dart'; import 'package:mymuseum_visitapp/l10n/app_localizations.dart'; import 'package:path_provider/path_provider.dart'; @@ -75,6 +86,22 @@ class _AppBootstrapState extends State { localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey ?? (kApiKey.isNotEmpty ? kApiKey : null)); + // Récupère publicApiKey depuis le backend si pas encore en mémoire + if (localContext.apiKey == null && localContext.instanceId != null) { + try { + final instanceDto = await localContext.clientAPI.instanceApi! + .instanceGetDetail(localContext.instanceId!); + if (instanceDto?.publicApiKey != null) { + localContext.apiKey = instanceDto!.publicApiKey; + localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey); + DatabaseHelper.instance.updateTableMain(DatabaseTableType.main, localContext); + print('publicApiKey loaded and saved'); + } + } catch (e) { + print('Could not load publicApiKey: $e'); + } + } + // Push notifications — subscribe to instance topic if enabled if (!Platform.isWindows && localContext.instanceId != null) { try { @@ -87,6 +114,13 @@ class _AppBootstrapState extends State { localContext.localPath = localPath; print("Local path $localPath"); + // Glasses init — initialize only here, startSession() est déclenché dans _MyAppState + // TODO: remplacer glassesEnabled par un vrai toggle UI + localContext.glassesEnabled = true; + if (!Platform.isWindows && (Platform.isAndroid || Platform.isIOS)) { + await MetaGlassesService.instance.initialize(); + } + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( systemNavigationBarColor: Colors.transparent, statusBarColor: Colors.transparent, @@ -116,7 +150,76 @@ class MyApp extends StatefulWidget { _MyAppState createState() => _MyAppState(); } -class _MyAppState extends State { +class _MyAppState extends State with WidgetsBindingObserver { + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + if (widget.visitAppContext.glassesEnabled && + !Platform.isWindows && + (Platform.isAndroid || Platform.isIOS)) { + // Activity complètement attachée — démarrer le pipeline lunettes + final ctx = widget.visitAppContext; + WidgetsBinding.instance.addPostFrameCallback((_) async { + // Foreground service Android — garde le main isolate vivant en background + await GlassesBackgroundService.initialize(); + await GlassesBackgroundService.start(); + + // connect() = enregistrement DAT + HFP audio routing (micro lunettes actif) + await MetaGlassesService.instance.connect(); + + // Orchestrateur avec implémentations swappables + // Pour passer à ElevenLabs TTS : remplacer FlutterTtsEngine par ElevenLabsTtsEngine + // Pour passer à Porcupine wake word : remplacer SpeechToTextWakeWordEngine par PorcupineWakeWordEngine + final orchestrator = GlassesOrchestrator( + visitAppContext: ctx, + // OpenWakeWord natif Android (TFLite, background, écran éteint) + // Changer modelName: 'hey_viva' pour l'autre wakeword disponible + wakeWordEngine: Platform.isAndroid + ? NativeWakeWordEngine(modelName: 'hey_viva') + : SpeechToTextWakeWordEngine(keyword: 'visite'), // fallback iOS + // STT : WhisperSttEngine si clé configurée, sinon SpeechToTextSttEngine + // OpenAI : --dart-define=WHISPER_API_KEY=sk-xxx + // Auto-hébergé : --dart-define=WHISPER_API_KEY=xxx --dart-define=WHISPER_ENDPOINT=http://ton-serveur:8000/v1/audio/transcriptions + sttEngine: kWhisperApiKey.isNotEmpty + ? WhisperSttEngine(apiKey: kWhisperApiKey, endpoint: kWhisperEndpoint) + : SpeechToTextSttEngine(), + // TTS : Gemini 2.5 Flash si clé configurée, sinon flutter_tts on-device (tests) + ttsEngine: kGeminiApiKey.isNotEmpty + ? GeminiTtsEngine( + apiKey: kGeminiApiKey, + voiceName: kGeminiTtsVoice, + voicePrompt: kGeminiTtsPrompt, + ) + : FlutterTtsEngine(), + llmClient: MyInfoMateLlmClient(visitAppContext: ctx), + ); + activeOrchestrator = orchestrator; + await orchestrator.start(); + }); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// Relance l'écoute wake word quand l'app repasse en foreground (déverrouillage). + /// SpeechRecognizer se suspend quand l'écran est verrouillé — on le redémarre. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + final o = activeOrchestrator; + // Relance uniquement si l'orchestrateur tourne ET qu'aucune conversation + // n'est en cours (sinon on interromprait une question/réponse active) + if (o != null && o.isRunning && !o.isInConversation) { + o.restartWakeWord(); + } + } + } @override Widget build(BuildContext context) { diff --git a/lib/translations.dart b/lib/translations.dart index 6407246..cf91a4c 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -35,7 +35,14 @@ List translations = [ "thursday": "Jeudi", "friday": "Vendredi", "saturday": "Samedi", - "sunday": "Dimanche" + "sunday": "Dimanche", + "beaconFound": "Contenu à proximité", + "beaconFoundBody": "Un contenu a été découvert près de vous.", + "voice.noConfig": "Sélectionne d'abord une visite dans l'application.", + "voice.noQrFound": "Je n'ai pas trouvé de QR code.", + "voice.photoCaptured": "Photo prise.", + "voice.photoFailed": "Je n'ai pas pu prendre la photo.", + "voice.cameraUnavailable": "Je ne peux pas accéder à la caméra." }), Translation(language: "EN", data: { "visitTitle": "List of tours", @@ -71,7 +78,14 @@ List translations = [ "thursday": "Thursday", "friday": "Friday", "saturday": "Saturday", - "sunday": "Sunday" + "sunday": "Sunday", + "beaconFound": "Nearby content", + "beaconFoundBody": "Content has been discovered near you.", + "voice.noConfig": "Please select a tour in the app first.", + "voice.noQrFound": "I didn't find any QR code.", + "voice.photoCaptured": "Photo taken.", + "voice.photoFailed": "I couldn't take the photo.", + "voice.cameraUnavailable": "I can't access the camera." }), Translation(language: "DE", data: { "visitTitle": "Liste der Touren", @@ -107,7 +121,14 @@ List translations = [ "thursday": "Donnerstag", "friday": "Freitag", "saturday": "Samstag", - "sunday": "Sonntag" + "sunday": "Sonntag", + "beaconFound": "Inhalt in der Nähe", + "beaconFoundBody": "In Ihrer Nähe wurde ein Inhalt entdeckt.", + "voice.noConfig": "Bitte wähle zuerst eine Tour in der App aus.", + "voice.noQrFound": "Ich habe keinen QR-Code gefunden.", + "voice.photoCaptured": "Foto aufgenommen.", + "voice.photoFailed": "Ich konnte das Foto nicht aufnehmen.", + "voice.cameraUnavailable": "Ich kann nicht auf die Kamera zugreifen." }), Translation(language: "NL", data: { "visitTitle": "Lijst met rondleidingen", @@ -143,7 +164,14 @@ List translations = [ "thursday": "Donderdag", "friday": "Vrijdag", "saturday": "Zaterdag", - "sunday": "Zondag" + "sunday": "Zondag", + "beaconFound": "Inhoud in de buurt", + "beaconFoundBody": "Er is inhoud ontdekt in uw buurt.", + "voice.noConfig": "Selecteer eerst een bezoek in de app.", + "voice.noQrFound": "Ik heb geen QR-code gevonden.", + "voice.photoCaptured": "Foto genomen.", + "voice.photoFailed": "Ik kon de foto niet nemen.", + "voice.cameraUnavailable": "Ik heb geen toegang tot de camera." }), Translation(language: "IT", data: { "visitTitle": "Elenco dei tour", @@ -179,7 +207,14 @@ List translations = [ "thursday": "Giovedì", "friday": "Venerdì", "saturday": "Sabato", - "sunday": "Domenica" + "sunday": "Domenica", + "beaconFound": "Contenuto nelle vicinanze", + "beaconFoundBody": "È stato scoperto un contenuto vicino a te.", + "voice.noConfig": "Seleziona prima una visita nell'app.", + "voice.noQrFound": "Non ho trovato nessun codice QR.", + "voice.photoCaptured": "Foto scattata.", + "voice.photoFailed": "Non sono riuscito a scattare la foto.", + "voice.cameraUnavailable": "Non riesco ad accedere alla fotocamera." }), Translation(language: "ES", data: { "visitTitle": "Lista de recorridos", @@ -215,7 +250,14 @@ List translations = [ "thursday": "Jueves", "friday": "Viernes", "saturday": "Sábado", - "sunday": "Domingo" + "sunday": "Domingo", + "beaconFound": "Contenido cercano", + "beaconFoundBody": "Se ha descubierto contenido cerca de ti.", + "voice.noConfig": "Primero selecciona una visita en la aplicación.", + "voice.noQrFound": "No he encontrado ningún código QR.", + "voice.photoCaptured": "Foto tomada.", + "voice.photoFailed": "No he podido tomar la foto.", + "voice.cameraUnavailable": "No puedo acceder a la cámara." }), Translation(language: "PL", data: { "visitTitle": "Lista wycieczek", @@ -251,7 +293,14 @@ List translations = [ "thursday": "Czwartek", "friday": "Piątek", "saturday": "Sobota", - "sunday": "Niedziela" + "sunday": "Niedziela", + "beaconFound": "Pobliskie treści", + "beaconFoundBody": "Odkryto treść w pobliżu Ciebie.", + "voice.noConfig": "Najpierw wybierz wizytę w aplikacji.", + "voice.noQrFound": "Nie znalazłem kodu QR.", + "voice.photoCaptured": "Zdjęcie zrobione.", + "voice.photoFailed": "Nie udało mi się zrobić zdjęcia.", + "voice.cameraUnavailable": "Nie mam dostępu do aparatu." }), Translation(language: "CN", data: { "visitTitle": "旅游清单", @@ -287,7 +336,14 @@ List translations = [ "thursday": "星期四", "friday": "星期五", "saturday": "星期六", - "sunday": "星期日" + "sunday": "星期日", + "beaconFound": "附近内容", + "beaconFoundBody": "在您附近发现了内容。", + "voice.noConfig": "请先在应用中选择一个参观。", + "voice.noQrFound": "我没有找到任何二维码。", + "voice.photoCaptured": "照片已拍摄。", + "voice.photoFailed": "我无法拍摄照片。", + "voice.cameraUnavailable": "我无法访问摄像头。" }), Translation(language: "UK", data: { "visitTitle": "Список турів", @@ -323,7 +379,14 @@ List translations = [ "thursday": "Четвер", "friday": "П'ятниця", "saturday": "Субота", - "sunday": "Неділя" + "sunday": "Неділя", + "beaconFound": "Контент поруч", + "beaconFoundBody": "Поруч з вами виявлено контент.", + "voice.noConfig": "Спочатку виберіть відвідування в застосунку.", + "voice.noQrFound": "Я не знайшов жодного QR-коду.", + "voice.photoCaptured": "Фото зроблено.", + "voice.photoFailed": "Мені не вдалося зробити фото.", + "voice.cameraUnavailable": "Я не можу отримати доступ до камери." }), Translation(language: "AR", data: { "visitTitle": "قائمة الجولات", @@ -359,6 +422,13 @@ List translations = [ "thursday": "الخميس", "friday": "الجمعة", "saturday": "السبت", - "sunday": "الأحد" + "sunday": "الأحد", + "beaconFound": "محتوى قريب", + "beaconFoundBody": "تم اكتشاف محتوى بالقرب منك.", + "voice.noConfig": "يرجى تحديد زيارة في التطبيق أولاً.", + "voice.noQrFound": "لم أجد أي رمز QR.", + "voice.photoCaptured": "تم التقاط الصورة.", + "voice.photoFailed": "لم أتمكن من التقاط الصورة.", + "voice.cameraUnavailable": "لا يمكنني الوصول إلى الكاميرا." }), ]; \ No newline at end of file diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f6f23bf..9c92be3 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_sound_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSoundPlugin"); + flutter_sound_plugin_register_with_registrar(flutter_sound_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f16b4c3..8f31061 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_sound url_launcher_linux ) diff --git a/pubspec.lock b/pubspec.lock index 62cef3c..e33565c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + beacon_scanner: + dependency: "direct main" + description: + name: beacon_scanner + sha256: "8fe668343e11011a0298af6b2600219aee706c60b71a9b739e648db9860f6a6b" + url: "https://pub.dev" + source: hosted + version: "0.0.4" + beacon_scanner_android: + dependency: transitive + description: + name: beacon_scanner_android + sha256: edc55b8fef0e22ac4a3a5883cf8479eccd5da122b87ae645fe2fb731f0bce240 + url: "https://pub.dev" + source: hosted + version: "0.0.4" + beacon_scanner_ios: + dependency: transitive + description: + name: beacon_scanner_ios + sha256: "518d04c9bc8f04a07c84a7a2506d3d24eaf30edb1109e234c9ad8f37ab550348" + url: "https://pub.dev" + source: hosted + version: "0.0.3+1" + beacon_scanner_platform_interface: + dependency: transitive + description: + name: beacon_scanner_platform_interface + sha256: bf90a70f73ad58f44567ee58d84afdf0e733e1743519469efc13786b08007c70 + url: "https://pub.dev" + source: hosted + version: "0.0.3" benchmark: dependency: transitive description: @@ -181,10 +213,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -390,6 +422,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.2" + flutter_foreground_task: + dependency: "direct main" + description: + name: flutter_foreground_task + sha256: "206017ee1bf864f34b8d7bce664a172717caa21af8da23f55866470dfe316644" + url: "https://pub.dev" + source: hosted + version: "8.17.0" flutter_inappwebview: dependency: transitive description: @@ -515,6 +555,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.26" + flutter_sound: + dependency: "direct main" + description: + name: flutter_sound + sha256: "77f0252a2f08449d621f68b8fd617c3b391e7f862eaa94bb32f53cc2dc3bbe85" + url: "https://pub.dev" + source: hosted + version: "9.30.0" + flutter_sound_platform_interface: + dependency: transitive + description: + name: flutter_sound_platform_interface + sha256: "5ffc858fd96c6fa277e3bb25eecc100849d75a8792b1f9674d1ba817aea9f861" + url: "https://pub.dev" + source: hosted + version: "9.30.0" + flutter_sound_web: + dependency: transitive + description: + name: flutter_sound_web + sha256: "3af46f45f44768c01c83ba260855c956d0963673664947926d942aa6fbf6f6fb" + url: "https://pub.dev" + source: hosted + version: "9.30.0" flutter_staggered_grid_view: dependency: "direct main" description: @@ -536,6 +600,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: ce5eb209b40e95f2f4a1397116c87ab2fcdff32257d04ed7a764e75894c03775 + url: "https://pub.dev" + source: hosted + version: "4.2.5" flutter_web_plugins: dependency: transitive description: flutter @@ -936,26 +1008,34 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" + meta_wearables_dat: + dependency: "direct main" + description: + name: meta_wearables_dat + sha256: e9fbd76a9306b3267b4af8eab58f244901029e98a303cb531704fec8f04657fe + url: "https://pub.dev" + source: hosted + version: "0.1.3" mgrs_dart: dependency: transitive description: @@ -1501,10 +1581,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.11" timezone: dependency: transitive description: @@ -1858,5 +1938,5 @@ packages: source: hosted version: "3.1.1" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 4ba571b..fe1bd6e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,7 +61,7 @@ dependencies: http: ^1.2.0 sqflite: #not in web just_audio_cache: ^0.1.2 #not in web - #flutter_beacon: ^0.5.1 #not in web + beacon_scanner: ^0.0.4 #not in web flutter_staggered_grid_view: ^0.7.0 smooth_page_indicator: ^1.2.1 @@ -81,6 +81,14 @@ dependencies: firebase_messaging: ^15.1.3 flutter_local_notifications: ^17.2.2 + # Ray-Ban Meta glasses integration + meta_wearables_dat: ^0.1.3 # Android — SDK DAT v0.3.0 + # meta_wearables: ^0.0.1 — package vide, skip + # porcupine_flutter: ^3.0.3 — wake word prod, à activer (payant) + flutter_tts: ^4.2.0 # TTS on-device gratuit (Google/Apple) — remplace ElevenLabs + flutter_foreground_task: ^8.11.0 # Garde le main isolate en vie (Android foreground service + iOS) + flutter_sound: ^9.2.13 # Enregistrement audio pour HomeAssistantSttEngine + manager_api_new: path: ../manager-app/manager_api_new # The following adds the Cupertino Icons font to your application. @@ -100,6 +108,7 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^1.0.0 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -119,6 +128,8 @@ flutter: - assets/images/ - assets/images/old/ - assets/files/ + - assets/wake_words/ + - assets/sounds/ #- assets/animations/ #- assets/files/ # To add assets to your application, add an assets section, like this: