Working flow also in background ! With custom wakeword working (hey visit and hey viva). flow llm working + take photo (not stored for now) + scan qr code working (need to be tested but the issue was llm config in backend)

This commit is contained in:
Thomas Fransolet 2026-06-04 17:00:43 +02:00
parent 2701f4c963
commit c6526046c8
73 changed files with 5319 additions and 958 deletions

View File

@ -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'
}

View File

@ -8,6 +8,12 @@
<!--<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />-->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Foreground Service — garde le wake word actif téléphone en poche -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Request legacy Bluetooth permissions on older devices. -->
<!--<uses-permission android:name="android.permission.BLUETOOTH"
@ -23,6 +29,27 @@
android:name="${applicationName}"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher">
<!-- Meta Wearables DAT — "0" = Developer Mode (String via resource), remplacer en production -->
<meta-data
android:name="com.meta.wearable.mwdat.APPLICATION_ID"
android:value="@string/mwdat_app_id" />
<!-- FileProvider requis par meta_wearables_dat pour sauvegarder les photos capturées -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".WakeWordService"
android:foregroundServiceType="dataSync|microphone"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true"

View File

@ -1,6 +1,167 @@
package be.unov.mymuseum.fortsaintheribert
import io.flutter.embedding.android.FlutterActivity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() {
class MainActivity : FlutterFragmentActivity() {
private val audioRoutingChannel = "be.unov.mymuseum/audio_routing"
private val wakeWordChannel = "be.unov.mymuseum/wake_word"
private val wakeWordEventsChannel = "be.unov.mymuseum/wake_word_events"
private var wakeWordCacheDir: String = ""
companion object {
// Accès direct depuis WakeWordService (même processus)
@JvmStatic var eventSink: EventChannel.EventSink? = null
}
private val wakeWordReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val event = intent.getStringExtra(WakeWordService.EXTRA_EVENT) ?: return
android.util.Log.d("MainActivity", "WakeWord event received: $event — eventSink=${eventSink != null}")
runOnUiThread {
if (eventSink != null) {
eventSink?.success(event)
} else {
android.util.Log.w("MainActivity", "eventSink is null — Flutter stream not active!")
}
}
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Audio routing (BT SCO)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, audioRoutingChannel)
.setMethodCallHandler { call, result ->
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<String>("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<String>("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
}
}
}

View File

@ -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<FloatArray>()
private val embeddingBuffer = ArrayDeque<FloatArray>()
// ── 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<FloatArray> {
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<FloatArray> {
// 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>): 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)
}
}
}

View File

@ -12,4 +12,6 @@
<!--<string name="fb_login_protocol_scheme">fb742185533300745</string>-->
<string name="default_notification_channel_id">main</string>
<!-- Meta Wearables DAT — "0" = Developer Mode (string, pas integer) -->
<string name="mwdat_app_id">0</string>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cached_images" path="." />
<files-path name="app_files" path="." />
<external-path name="external_files" path="." />
</paths>

View File

@ -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
}

View File

@ -2,4 +2,8 @@ org.gradle.jvmargs=-Xmx4096M
android.useAndroidX=true
android.enableJetifier=false
android.experimental.enable16kApk=true
android.useNewNativePlugin=true
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

Binary file not shown.

BIN
assets/files/hey_visit.onnx Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/files/hey_viva.onnx Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/sounds/done.mp3 Normal file

Binary file not shown.

BIN
assets/sounds/thinking.mp3 Normal file

Binary file not shown.

Binary file not shown.

View File

@ -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)
}
}

View File

@ -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)")
}
}
}

View File

@ -4,6 +4,11 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<!-- Background audio — garde l'app active (HFP + wake word) quand téléphone en poche -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>

View File

@ -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<AdminPopup> {
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<AdminPopup> {
vertical: 5
),
),
),
SizedBox(
height: size.height*0.06,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ValueListenableBuilder<GlassesState>(
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,
),
),
),
)
],
) :

View File

@ -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<AssistantChatSheet> {
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<AssistantChatSheet> {
}
}
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<AssistantChatSheet> {
fontSize: 18,
fontWeight: FontWeight.w600,
color: kSecondGrey)),
const SizedBox(width: 8),
if (widget.visitAppContext.glassesEnabled)
ValueListenableBuilder<GlassesState>(
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),

View File

@ -72,14 +72,12 @@ class _CustomAppBarState extends State<CustomAppBar> {
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,

View File

@ -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<GlassesDebugPanel> createState() => _GlassesDebugPanelState();
}
class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
String _log = '';
bool _busy = false;
bool _monitoring = false;
final List<StreamSubscription> _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<void> _run(String label, Future<void> 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<GlassesState>(
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<bool>(
valueListenable: activeOrchestrator!.isListeningForCommand,
builder: (_, listening, __) => ValueListenableBuilder<String>(
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<String>(
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,
);
}
}

View File

@ -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});

View File

@ -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<void> 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<void> restoreDefaultOutput() async {
if (!Platform.isAndroid && !Platform.isIOS) return;
try {
await _channel.invokeMethod('restoreDefaultOutput');
} on PlatformException catch (e) {
print('[AudioRoutingChannel] restoreDefaultOutput failed: $e');
}
}
}

View File

@ -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<ConfigurationPage> with WidgetsBindi
// Beacon specific
final controller = Get.find<RequirementStateController>();
/*StreamSubscription<BluetoothState>? _streamBluetooth;
StreamSubscription<RangingResult>? _streamRanging;*/
StreamSubscription<ScanResult>? _streamRanging;
/*final _regionBeacons = <Region, List<Beacon>>{};
final _beacons = <Beacon>[];*/
bool _isDialogShowing = false;
@ -110,18 +109,19 @@ class _ConfigurationPageState extends State<ConfigurationPage> 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<AppContext>(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<ConfigurationPage> 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 = <Region>[
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<ConfigurationPage> 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<ConfigurationPage> 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<ConfigurationPage> with WidgetsBindi
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(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<ConfigurationPage> 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: <Widget>[
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<Permission, PermissionStatus> statuses0 = await [
Permission.bluetooth,
].request();
Map<Permission, PermissionStatus> statuses1 = await [
Permission.bluetoothScan,
].request();
Map<Permission, PermissionStatus> statuses2 = await [
Permission.bluetoothConnect,
].request();
Map<Permission, PermissionStatus> statuses3 = await [
Permission.locationWhenInUse,
].request();
Map<Permission, PermissionStatus> statuses4 = await [
Permission.location,
].request();
/*Map<Permission, PermissionStatus> 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<Permission, PermissionStatus> 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(

View File

@ -624,7 +624,7 @@ class _HomePage3State extends State<HomePage3> 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,

View File

@ -52,7 +52,7 @@ class _AgendaPage extends State<AgendaPage> {
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;

View File

@ -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<MapAnnotation>? 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<Polyline> _buildPolylines(List<MapAnnotationDTO> annotations) {
List<Polyline> _buildPolylines(List<MapAnnotationDTO> annotations, {Color? overrideColor}) {
final lines = <Polyline>[];
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<Marker> _buildBlockMarkers(List<MapAnnotation> annotations) {
final markers = <Marker>[];
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<Polyline> _buildBlockPolylines(List<MapAnnotation> annotations) {
final lines = <Polyline>[];
for (final ann in annotations) {
if (ann.geometryType?.value != 1) continue;
final coords = ann.geometry?.coordinates;
if (coords is! List) continue;
final points = <LatLng>[];
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;

View File

@ -436,6 +436,7 @@ class _EventPageState extends State<EventPage> {
builder: (_) => EventMapFullPage(
section: widget.section,
visitAppContextIn: widget.visitAppContextIn,
blockAnnotations: _activeBlock?.mapAnnotations,
),
)),
child: Container(
@ -462,6 +463,10 @@ class _EventPageState extends State<EventPage> {
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<EventPage> {
return lines;
}
List<Marker> _buildBlockMarkers(List<MapAnnotation> annotations) {
final markers = <Marker>[];
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<Polyline> _buildBlockPolylines(List<MapAnnotation> annotations) {
final lines = <Polyline>[];
for (final ann in annotations) {
if (ann.geometryType?.value != 1) continue;
final coords = ann.geometry?.coordinates;
if (coords is! List) continue;
final points = <LatLng>[];
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;

View File

@ -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<GamePage> {
List<Widget> pieces = [];
bool isSplittingImage = true;
bool showHint = false;
List<int> 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<GamePage> {
print("Taille réelle du widget : $size");
}
// we need to find out the image size, to be used in the PuzzlePiece widget
/*Future<Size> getImageSize(CachedNetworkImage image) async {
Completer<Size> completer = Completer<Size>();
/*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<Size> completer = Completer<Size>();
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<Size> completer = Completer<Size>();
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<AppContext>(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<int> 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<GamePage> {
});
}
List<int> _getAdjacentIndices(int index, int rows, int cols) {
List<int> 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<int> 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<GamePage> {
});
}
// 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<GamePage> {
pieces.remove(widget);
pieces.insert(0, widget);
if(isFinished) {
Size size = MediaQuery.of(context).size;
final appContext = Provider.of<AppContext>(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<AppContext>(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<AppContext>(context);
@ -335,21 +529,7 @@ class _GamePage extends State<GamePage> {
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<GamePage> {
),
],
),
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),
),
),
],
);
}

View File

@ -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<String, dynamic> 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<PuzzlePiece> {
@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<AppContext>(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<PuzzlePiece> {
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);
}

View File

@ -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),
),
],
),
),
),
),
],
),
),
),
);
}
}

View File

@ -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);
}
}

View File

@ -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<GuidedPathMapProgressionP
_positionSub = Geolocator.getPositionStream(
locationSettings: const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5),
).listen((pos) {
if (!mounted) return;
final newPos = LatLng(pos.latitude, pos.longitude);
setState(() => _userPosition = newPos);
_checkGeoZone(newPos);
@ -200,6 +202,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
}
void _checkGeoZone(LatLng position) {
if (!mounted) return;
final step = _currentStep;
if (step == null || !_hasGeoTrigger(step)) return;
@ -218,6 +221,11 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
final distMeters = const Distance().distance(position, triggerCenter);
final inZone = distMeters <= radius;
if (inZone && !_inGeoZone) {
PushNotificationService.showGeoZoneNotification(
stepTitle: _translate(_currentStep!.title),
);
}
if (inZone != _inGeoZone) {
setState(() => _inGeoZone = inZone);
}

View File

@ -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<MapPage> {
MapDTO? mapDTO;
//Completer<GoogleMapController> _controller = Completer();
//Uint8List? selectedMarkerIcon;
late ValueNotifier<List<GeoPointDTO>> _geoPoints = ValueNotifier<List<GeoPointDTO>>([]);
bool _showListView = false;
/*Future<Uint8List> getBytesFromAsset(ByteData data, int width) async {
//ByteData data = await rootBundle.load(path);
@ -72,6 +72,39 @@ class _MapPage extends State<MapPage> {
tilt: 59.440717697143555,
zoom: 59.151926040649414);*/
Widget _buildListView(List<GeoPointDTO> 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<MapContext>(context);
@ -104,15 +137,15 @@ class _MapPage extends State<MapPage> {
ValueListenableBuilder<List<GeoPointDTO>>(
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<MapPage> {
filteredPoints: (value) {
_geoPoints.value = value!;
}),
MarkerViewWidget(),
if (!_showListView) MarkerViewWidget(),
Positioned(
top: 35,
left: 10,
@ -181,6 +214,33 @@ class _MapPage extends State<MapPage> {
),
),
),
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,

View File

@ -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<VideoPage> {
//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<VideoPage> {
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<VideoPage> {
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<VideoPage> {
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(),
],
);
}
}
}

View File

@ -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<WeatherForecast> 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<WeatherPage> createState() => _WeatherPageState();
@ -24,359 +36,354 @@ class WeatherPage extends StatefulWidget {
class _WeatherPageState extends State<WeatherPage> {
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<String, dynamic> 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<WeatherForecast> allForecasts) {
final Map<int, List<WeatherForecast>> 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<WeatherForecast> getNextFiveDaysForecast(List<WeatherForecast> allForecasts) {
List<WeatherForecast> nextFiveDaysForecast = [];
DateTime today = DateTime.now();
List<WeatherForecast> nextDay1All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 1))).day).toList();
List<WeatherForecast> nextDay2All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 2))).day).toList();
List<WeatherForecast> nextDay3All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 3))).day).toList();
List<WeatherForecast> nextDay4All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 4))).day).toList();
List<WeatherForecast> 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<AppContext>(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
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),
],
),
),
);
}
}

View File

@ -348,7 +348,9 @@ Future<List<Map<String, dynamic>>> 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);

View File

@ -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<void> 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<void> stop() async {
await _player.stop();
_speaking = false;
}
@override
Future<void> replay() async {
if (_lastTempPath == null) return;
await _player.seek(Duration.zero);
await _player.play();
}
Future<void> _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();
}

View File

@ -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<void>? _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<void> 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<void>();
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<void> stop() async {
await _tts.stop();
_speaking = false;
_completer?.complete();
_completer = null;
}
@override
Future<void> replay() async {
if (_lastText != null) {
await speak(_lastText!, languageCode: _lastLang ?? 'fr-FR');
}
}
}

View File

@ -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<void> 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<void> stop() async {
await _player.stop();
_speaking = false;
}
@override
Future<void> replay() async {
if (_lastTempPath == null) return;
await _player.seek(Duration.zero);
await _player.play();
}
Future<String?> _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<String, dynamic>;
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<String, dynamic>?;
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<String> _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();
}

View File

@ -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<void> _ensureInitialized() async {
if (_initialized) return;
await Permission.microphone.request();
await _recorder.openRecorder();
_initialized = true;
}
@override
Future<String> 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<void> cancel() async {
try { await _recorder.stopRecorder(); } catch (_) {}
}
Future<void> _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<String> _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<String, dynamic>;
final text = (json['text'] as String? ?? '').trim();
debugPrint('[HomeAssistantSttEngine] "$text"');
return text;
}
}

View File

@ -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<String> 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();
}

View File

@ -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<void> 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<void> stop() async {
await _subscription?.cancel();
_subscription = null;
_running = false;
// Libère l'AudioRecord pour le STT — service et silent track restent vivants
try { await _channel.invokeMethod('pause'); } catch (_) {}
debugPrint('[NativeWakeWordEngine] Paused — AudioRecord released for STT');
}
/// Arrêt complet du service natif (appeler uniquement à la fermeture de l'app).
Future<void> stopNativeService() async {
await _subscription?.cancel();
_subscription = null;
_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<void> _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});
}
}

View File

@ -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<dynamic>? _subscription;
void Function()? _onDetected;
bool _running = false;
OpenWakeWordEngine({this.modelName = 'hey_visit'});
@override
Future<void> 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<void> 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;
}

View File

@ -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<void> 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<void> stop() async {
// await _manager?.stop();
// await _manager?.delete();
}
}

View File

@ -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<void> _ensureInitialized() async {
if (_initialized) return;
_initialized = await _speech.initialize();
debugPrint('[SpeechToTextSttEngine] initialized: $_initialized');
}
@override
Future<String> transcribeOnce({
required String languageCode,
Duration timeout = const Duration(seconds: 6),
}) async {
await _ensureInitialized();
if (!_initialized) return '';
final completer = Completer<String>();
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<void> cancel() async {
await _speech.cancel();
}
}

View File

@ -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<void> 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<void> stop() async {
_running = false;
await _speech.cancel();
debugPrint('[SpeechToTextWakeWordEngine] Stopped');
}
}

View File

@ -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<void> _ensureInitialized() async {
if (_initialized) return;
await Permission.microphone.request();
await _recorder.openRecorder();
_initialized = true;
}
@override
Future<String> 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<void> cancel() async {
try { await _recorder.stopRecorder(); } catch (_) {}
}
Future<String> _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<String, dynamic>;
final text = (json['text'] as String? ?? '').trim();
debugPrint('[WhisperSttEngine] "$text"');
return text;
}
}

View File

@ -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<String> chat(
String message, {
String? configurationId,
String languageCode = 'FR',
});
void clearHistory();
}

View File

@ -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<String> transcribeOnce({
required String languageCode,
Duration timeout = const Duration(seconds: 6),
});
Future<void> cancel();
}

View File

@ -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<void> speak(String text, {String languageCode = 'fr-FR'});
Future<void> stop();
Future<void> replay();
bool get isSpeaking;
}

View File

@ -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<void> start({
required void Function() onDetected,
void Function(String command)? onDetectedWithCommand,
});
Future<void> stop();
}

View File

@ -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<void> 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<void> 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<void> stop() async {
if (!Platform.isAndroid) return;
await FlutterForegroundTask.stopService();
}
static Future<void> 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<void> onStart(DateTime timestamp, TaskStarter starter) async {}
@override
void onRepeatEvent(DateTime timestamp) {}
@override
Future<void> onDestroy(DateTime timestamp) async {}
}

View File

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

View File

@ -11,14 +11,22 @@ class AssistantService {
Future<AssistantResponse> chat({
required String message,
String? configurationId,
}) => chatWithAppType(message: message, configurationId: configurationId);
Future<AssistantResponse> 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);

View File

@ -148,7 +148,7 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
if(sections!.isNotEmpty) {
List<SectionDTO> sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, widget.configuration.id!);
List<SectionDTO> 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<SectionDTO> 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<DownloadConfigurationWidge
if(sections!.isNotEmpty) {
List<SectionDTO> sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, configuration.id!);
List<SectionDTO> 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<SectionDTO> 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;

View File

@ -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<Position>? _geoSub;
StreamSubscription<ScanResult>? _beaconSub;
final List<GeoTriggerPoint> _geoPoints = [];
final Set<String> _triggeredIds = {};
final Map<String, int> _lastTriggerTime = {};
bool _running = false;
/// Lance le service.
/// [geoPoints] : points de déclenchement GPS (à peupler depuis la configuration).
Future<void> start({
required VisitAppContext visitAppContext,
List<GeoTriggerPoint> 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<void> 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<GeoTriggerPoint> points) {
_geoPoints
..clear()
..addAll(points);
_triggeredIds.clear();
}
// GPS
Future<void> _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<void> _startBeaconTracking() async {
final ctx = _visitAppContext;
if (ctx?.beaconSections == null || ctx!.beaconSections!.isEmpty) return;
final regions = <Region>[
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<void> _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();
}
}

View File

@ -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<String, int> _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<void> _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<void> _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');
}
}
}

View File

@ -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<void> 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<void> replay() async {
if (_lastTempPath == null) return;
try {
await _player.seek(Duration.zero);
await _player.play();
} catch (e) {
debugPrint('[GlassesTtsService] replay error: $e');
}
}
Future<void> stop() async {
await _player.stop();
_isSpeaking = false;
}
Future<void> _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<File> _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();
}
}

View File

@ -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<GlassesState> 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<void> 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<void> 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<void> startStream() async {
if (!isConnected) return;
await Wearables.instance.startStream(videoQuality: 'MEDIUM', frameRate: 24);
debugPrint('[MetaGlassesService] Stream started');
}
Future<void> stopStream() async {
await Wearables.instance.stopStream();
if (state.value == GlassesState.streaming) {
state.value = GlassesState.connected;
}
debugPrint('[MetaGlassesService] Stream stopped');
}
// 4. Déconnexion
Future<void> disconnect() async {
await Wearables.instance.stopStream();
await AudioRoutingChannel.restoreDefaultOutput();
state.value = GlassesState.disconnected;
}
// Capture photo
Future<void> 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<bool> 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<String?> grabFrame() async {
if (state.value != GlassesState.streaming) return null;
final completer = Completer<Uint8List?>();
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<void> _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();
}

View File

@ -70,6 +70,41 @@ class PushNotificationService {
.unsubscribeFromTopic('instance_$instanceId');
}
static Future<void> 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<void> 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<void> _showLocalNotification(RemoteMessage message) async {
final notification = message.notification;
if (notification == null) return;

View File

@ -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<bool> isListening = ValueNotifier(false);
Future<void> 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<void> 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<void> 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<String> _transcribeCommand() async {
final ctx = _assistantService?.visitAppContext;
final locale = ctx?.language?.toLowerCase() ?? 'fr';
final completer = Completer<String>();
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<void> _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();
}
}

View File

@ -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',

View File

@ -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<AppBootstrap> {
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<AppBootstrap> {
localContext.localPath = localPath;
print("Local path $localPath");
// Glasses init initialize only here, startSession() est déclenché dans _MyAppState
// TODO: remplacer glassesEnabled par un vrai toggle UI
localContext.glassesEnabled = true;
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<MyApp> {
class _MyAppState extends State<MyApp> 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) {

View File

@ -35,7 +35,14 @@ List<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> 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<Translation> translations = [
"thursday": "الخميس",
"friday": "الجمعة",
"saturday": "السبت",
"sunday": "الأحد"
"sunday": "الأحد",
"beaconFound": "محتوى قريب",
"beaconFoundBody": "تم اكتشاف محتوى بالقرب منك.",
"voice.noConfig": "يرجى تحديد زيارة في التطبيق أولاً.",
"voice.noQrFound": "لم أجد أي رمز QR.",
"voice.photoCaptured": "تم التقاط الصورة.",
"voice.photoFailed": "لم أتمكن من التقاط الصورة.",
"voice.cameraUnavailable": "لا يمكنني الوصول إلى الكاميرا."
}),
];

View File

@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
#include <flutter_sound/flutter_sound_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
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);

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_sound
url_launcher_linux
)

View File

@ -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"

View File

@ -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: