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:
parent
2701f4c963
commit
c6526046c8
@ -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'
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
6
android/app/src/main/res/xml/file_paths.xml
Normal file
6
android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
assets/files/embedding_model.tflite
Normal file
BIN
assets/files/embedding_model.tflite
Normal file
Binary file not shown.
BIN
assets/files/hey_visit.onnx
Normal file
BIN
assets/files/hey_visit.onnx
Normal file
Binary file not shown.
BIN
assets/files/hey_visit.tflite
Normal file
BIN
assets/files/hey_visit.tflite
Normal file
Binary file not shown.
BIN
assets/files/hey_viva.onnx
Normal file
BIN
assets/files/hey_viva.onnx
Normal file
Binary file not shown.
BIN
assets/files/hey_viva.tflite
Normal file
BIN
assets/files/hey_viva.tflite
Normal file
Binary file not shown.
BIN
assets/files/melspectrogram.onnx
Normal file
BIN
assets/files/melspectrogram.onnx
Normal file
Binary file not shown.
BIN
assets/files/melspectrogram.tflite
Normal file
BIN
assets/files/melspectrogram.tflite
Normal file
Binary file not shown.
BIN
assets/sounds/done.mp3
Normal file
BIN
assets/sounds/done.mp3
Normal file
Binary file not shown.
BIN
assets/sounds/thinking.mp3
Normal file
BIN
assets/sounds/thinking.mp3
Normal file
Binary file not shown.
BIN
assets/sounds/wake_detected.mp3
Normal file
BIN
assets/sounds/wake_detected.mp3
Normal file
Binary file not shown.
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
57
ios/Runner/AudioRoutingPlugin.swift
Normal file
57
ios/Runner/AudioRoutingPlugin.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
) :
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
371
lib/Components/GlassesDebugPanel.dart
Normal file
371
lib/Components/GlassesDebugPanel.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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});
|
||||
|
||||
33
lib/PlatformChannels/audio_routing_channel.dart
Normal file
33
lib/PlatformChannels/audio_routing_channel.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
88
lib/Screens/Sections/Game/sliding_puzzle_piece.dart
Normal file
88
lib/Screens/Sections/Game/sliding_puzzle_piece.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
81
lib/Services/Glasses/engines/impl/elevenlabs_tts_engine.dart
Normal file
81
lib/Services/Glasses/engines/impl/elevenlabs_tts_engine.dart
Normal 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();
|
||||
}
|
||||
81
lib/Services/Glasses/engines/impl/flutter_tts_engine.dart
Normal file
81
lib/Services/Glasses/engines/impl/flutter_tts_engine.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
164
lib/Services/Glasses/engines/impl/gemini_tts_engine.dart
Normal file
164
lib/Services/Glasses/engines/impl/gemini_tts_engine.dart
Normal 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();
|
||||
}
|
||||
103
lib/Services/Glasses/engines/impl/home_assistant_stt_engine.dart
Normal file
103
lib/Services/Glasses/engines/impl/home_assistant_stt_engine.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
50
lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart
Normal file
50
lib/Services/Glasses/engines/impl/myinfomate_llm_client.dart
Normal 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();
|
||||
}
|
||||
135
lib/Services/Glasses/engines/impl/native_wake_word_engine.dart
Normal file
135
lib/Services/Glasses/engines/impl/native_wake_word_engine.dart
Normal 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});
|
||||
}
|
||||
}
|
||||
68
lib/Services/Glasses/engines/impl/openwakeword_engine.dart
Normal file
68
lib/Services/Glasses/engines/impl/openwakeword_engine.dart
Normal 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;
|
||||
}
|
||||
49
lib/Services/Glasses/engines/impl/porcupine_wake_word.dart
Normal file
49
lib/Services/Glasses/engines/impl/porcupine_wake_word.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
55
lib/Services/Glasses/engines/impl/speech_to_text_stt.dart
Normal file
55
lib/Services/Glasses/engines/impl/speech_to_text_stt.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
107
lib/Services/Glasses/engines/impl/whisper_stt_engine.dart
Normal file
107
lib/Services/Glasses/engines/impl/whisper_stt_engine.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
15
lib/Services/Glasses/engines/llm_client.dart
Normal file
15
lib/Services/Glasses/engines/llm_client.dart
Normal 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();
|
||||
}
|
||||
15
lib/Services/Glasses/engines/stt_engine.dart
Normal file
15
lib/Services/Glasses/engines/stt_engine.dart
Normal 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();
|
||||
}
|
||||
12
lib/Services/Glasses/engines/tts_engine.dart
Normal file
12
lib/Services/Glasses/engines/tts_engine.dart
Normal 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;
|
||||
}
|
||||
17
lib/Services/Glasses/engines/wake_word_engine.dart
Normal file
17
lib/Services/Glasses/engines/wake_word_engine.dart
Normal 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();
|
||||
}
|
||||
70
lib/Services/Glasses/glasses_background_service.dart
Normal file
70
lib/Services/Glasses/glasses_background_service.dart
Normal 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 {}
|
||||
}
|
||||
502
lib/Services/Glasses/glasses_orchestrator.dart
Normal file
502
lib/Services/Glasses/glasses_orchestrator.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
233
lib/Services/geo_beacon_trigger_service.dart
Normal file
233
lib/Services/geo_beacon_trigger_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
118
lib/Services/glasses_qr_scanner_service.dart
Normal file
118
lib/Services/glasses_qr_scanner_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
117
lib/Services/glasses_tts_service.dart
Normal file
117
lib/Services/glasses_tts_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
241
lib/Services/meta_glasses_service.dart
Normal file
241
lib/Services/meta_glasses_service.dart
Normal 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();
|
||||
}
|
||||
@ -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;
|
||||
|
||||
159
lib/Services/wake_word_service.dart
Normal file
159
lib/Services/wake_word_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
105
lib/main.dart
105
lib/main.dart
@ -11,6 +11,17 @@ import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart';
|
||||
import 'package:mymuseum_visitapp/Models/articleRead.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/splash_screen.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/flutter_tts_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/gemini_tts_engine.dart';
|
||||
// ElevenLabsTtsEngine disponible dans impl/ mais retiré du pipeline — trop cher
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/glasses_background_service.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/myinfomate_llm_client.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_stt.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/whisper_stt_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/native_wake_word_engine.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_wake_word.dart';
|
||||
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
|
||||
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
|
||||
import 'package:mymuseum_visitapp/l10n/app_localizations.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@ -75,6 +86,22 @@ class _AppBootstrapState extends State<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) {
|
||||
|
||||
@ -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": "لا يمكنني الوصول إلى الكاميرا."
|
||||
}),
|
||||
];
|
||||
@ -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);
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_sound
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
102
pubspec.lock
102
pubspec.lock
@ -65,6 +65,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
beacon_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: beacon_scanner
|
||||
sha256: "8fe668343e11011a0298af6b2600219aee706c60b71a9b739e648db9860f6a6b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.4"
|
||||
beacon_scanner_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: beacon_scanner_android
|
||||
sha256: edc55b8fef0e22ac4a3a5883cf8479eccd5da122b87ae645fe2fb731f0bce240
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.4"
|
||||
beacon_scanner_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: beacon_scanner_ios
|
||||
sha256: "518d04c9bc8f04a07c84a7a2506d3d24eaf30edb1109e234c9ad8f37ab550348"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.3+1"
|
||||
beacon_scanner_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: beacon_scanner_platform_interface
|
||||
sha256: bf90a70f73ad58f44567ee58d84afdf0e733e1743519469efc13786b08007c70
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.3"
|
||||
benchmark:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -181,10 +213,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -390,6 +422,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.2"
|
||||
flutter_foreground_task:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_foreground_task
|
||||
sha256: "206017ee1bf864f34b8d7bce664a172717caa21af8da23f55866470dfe316644"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.17.0"
|
||||
flutter_inappwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -515,6 +555,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.26"
|
||||
flutter_sound:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_sound
|
||||
sha256: "77f0252a2f08449d621f68b8fd617c3b391e7f862eaa94bb32f53cc2dc3bbe85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.30.0"
|
||||
flutter_sound_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_sound_platform_interface
|
||||
sha256: "5ffc858fd96c6fa277e3bb25eecc100849d75a8792b1f9674d1ba817aea9f861"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.30.0"
|
||||
flutter_sound_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_sound_web
|
||||
sha256: "3af46f45f44768c01c83ba260855c956d0963673664947926d942aa6fbf6f6fb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.30.0"
|
||||
flutter_staggered_grid_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -536,6 +600,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_tts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_tts
|
||||
sha256: ce5eb209b40e95f2f4a1397116c87ab2fcdff32257d04ed7a764e75894c03775
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.5"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@ -936,26 +1008,34 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.18.0"
|
||||
meta_wearables_dat:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: meta_wearables_dat
|
||||
sha256: e9fbd76a9306b3267b4af8eab58f244901029e98a303cb531704fec8f04657fe
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
mgrs_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1501,10 +1581,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1858,5 +1938,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=3.8.0-0 <4.0.0"
|
||||
dart: ">=3.10.0-0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
|
||||
13
pubspec.yaml
13
pubspec.yaml
@ -61,7 +61,7 @@ dependencies:
|
||||
http: ^1.2.0
|
||||
sqflite: #not in web
|
||||
just_audio_cache: ^0.1.2 #not in web
|
||||
#flutter_beacon: ^0.5.1 #not in web
|
||||
beacon_scanner: ^0.0.4 #not in web
|
||||
flutter_staggered_grid_view: ^0.7.0
|
||||
|
||||
smooth_page_indicator: ^1.2.1
|
||||
@ -81,6 +81,14 @@ dependencies:
|
||||
firebase_messaging: ^15.1.3
|
||||
flutter_local_notifications: ^17.2.2
|
||||
|
||||
# Ray-Ban Meta glasses integration
|
||||
meta_wearables_dat: ^0.1.3 # Android — SDK DAT v0.3.0
|
||||
# meta_wearables: ^0.0.1 — package vide, skip
|
||||
# porcupine_flutter: ^3.0.3 — wake word prod, à activer (payant)
|
||||
flutter_tts: ^4.2.0 # TTS on-device gratuit (Google/Apple) — remplace ElevenLabs
|
||||
flutter_foreground_task: ^8.11.0 # Garde le main isolate en vie (Android foreground service + iOS)
|
||||
flutter_sound: ^9.2.13 # Enregistrement audio pour HomeAssistantSttEngine
|
||||
|
||||
manager_api_new:
|
||||
path: ../manager-app/manager_api_new
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
@ -100,6 +108,7 @@ dev_dependencies:
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^1.0.0
|
||||
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
@ -119,6 +128,8 @@ flutter:
|
||||
- assets/images/
|
||||
- assets/images/old/
|
||||
- assets/files/
|
||||
- assets/wake_words/
|
||||
- assets/sounds/
|
||||
#- assets/animations/
|
||||
#- assets/files/
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user