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 {
|
defaultConfig {
|
||||||
minSdkVersion 24
|
minSdkVersion 29 // meta_wearables SDK requires API 29+
|
||||||
targetSdkVersion flutter.targetSdkVersion
|
targetSdkVersion flutter.targetSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
|
manifestPlaceholders = [
|
||||||
|
mwdatCallbackScheme: "mwdat-myinfomate",
|
||||||
|
applicationName: "io.flutter.app.FlutterApplication"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
flavorDimensions "client"
|
flavorDimensions "client"
|
||||||
@ -80,16 +84,28 @@ android {
|
|||||||
dimension "client"
|
dimension "client"
|
||||||
applicationId "be.unov.myinfomate.test"
|
applicationId "be.unov.myinfomate.test"
|
||||||
resValue "string", "app_name", "MyMuseum Dev"
|
resValue "string", "app_name", "MyMuseum Dev"
|
||||||
|
manifestPlaceholders = [
|
||||||
|
mwdatCallbackScheme: "mwdat-myinfomate-dev",
|
||||||
|
applicationName: "io.flutter.app.FlutterApplication"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
mdlf {
|
mdlf {
|
||||||
dimension "client"
|
dimension "client"
|
||||||
applicationId "be.unov.mymuseum.mdlf"
|
applicationId "be.unov.mymuseum.mdlf"
|
||||||
resValue "string", "app_name", "MDLF"
|
resValue "string", "app_name", "MDLF"
|
||||||
|
manifestPlaceholders = [
|
||||||
|
mwdatCallbackScheme: "mwdat-mymuseum-mdlf",
|
||||||
|
applicationName: "io.flutter.app.FlutterApplication"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
fortsaintheribert {
|
fortsaintheribert {
|
||||||
dimension "client"
|
dimension "client"
|
||||||
applicationId "be.unov.mymuseum.fortsaintheribert"
|
applicationId "be.unov.mymuseum.fortsaintheribert"
|
||||||
resValue "string", "app_name", "Fort Saint-Héribert"
|
resValue "string", "app_name", "Fort Saint-Héribert"
|
||||||
|
manifestPlaceholders = [
|
||||||
|
mwdatCallbackScheme: "mwdat-mymuseum-fortsaintheribert",
|
||||||
|
applicationName: "io.flutter.app.FlutterApplication"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,4 +135,6 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
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.ACCESS_BACKGROUND_LOCATION" />-->
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<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. -->
|
<!-- Request legacy Bluetooth permissions on older devices. -->
|
||||||
<!--<uses-permission android:name="android.permission.BLUETOOTH"
|
<!--<uses-permission android:name="android.permission.BLUETOOTH"
|
||||||
@ -23,6 +29,27 @@
|
|||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:icon="@mipmap/ic_launcher">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@ -1,6 +1,167 @@
|
|||||||
package be.unov.mymuseum.fortsaintheribert
|
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="fb_login_protocol_scheme">fb742185533300745</string>-->
|
||||||
|
|
||||||
<string name="default_notification_channel_id">main</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>
|
</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 {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
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')
|
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) {
|
tasks.register("clean", Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,3 +3,7 @@ android.useAndroidX=true
|
|||||||
android.enableJetifier=false
|
android.enableJetifier=false
|
||||||
android.experimental.enable16kApk=true
|
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]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
if let registrar = self.registrar(forPlugin: "AudioRoutingPlugin") {
|
||||||
|
AudioRoutingPlugin.register(with: registrar)
|
||||||
|
}
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
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>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<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>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/check_input_container.dart';
|
||||||
import 'package:mymuseum_visitapp/Components/rounded_button.dart';
|
import 'package:mymuseum_visitapp/Components/rounded_button.dart';
|
||||||
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
|
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
|
||||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||||
import 'package:mymuseum_visitapp/app_context.dart';
|
import 'package:mymuseum_visitapp/app_context.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../constants.dart';
|
import '../constants.dart';
|
||||||
@ -34,7 +36,7 @@ class _AdminPopupState extends State<AdminPopup> {
|
|||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: size.width*0.7,
|
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),
|
margin: const EdgeInsets.all(kDefaultPadding),
|
||||||
child: isPasswordOk ? Column(
|
child: isPasswordOk ? Column(
|
||||||
children: [
|
children: [
|
||||||
@ -110,6 +112,30 @@ class _AdminPopupState extends State<AdminPopup> {
|
|||||||
vertical: 5
|
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/AssistantResponse.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
import 'package:mymuseum_visitapp/Services/assistantService.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:mymuseum_visitapp/constants.dart';
|
||||||
import 'package:speech_to_text/speech_to_text.dart';
|
import 'package:speech_to_text/speech_to_text.dart';
|
||||||
|
|
||||||
@ -120,8 +122,19 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
|||||||
onNavigate: widget.onNavigateToSection,
|
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) {
|
} catch (e) {
|
||||||
print("AssistantChatSheet error: $e");
|
debugPrint('AssistantChatSheet error: $e');
|
||||||
setState(() {
|
setState(() {
|
||||||
_bubbles.add(_ChatBubble(text: "Une erreur est survenue, réessayez.", isUser: false));
|
_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() {
|
void _scrollToBottom() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_scrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
@ -167,6 +190,33 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
|||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: kSecondGrey)),
|
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(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
|
|||||||
@ -72,14 +72,12 @@ class _CustomAppBarState extends State<CustomAppBar> {
|
|||||||
leading: widget.isHomeButton ? IconButton(
|
leading: widget.isHomeButton ? IconButton(
|
||||||
icon: const Icon(Icons.home, color: Colors.white),
|
icon: const Icon(Icons.home, color: Colors.white),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// Set new State
|
|
||||||
setState(() {
|
setState(() {
|
||||||
visitAppContext.configuration = null;
|
visitAppContext.configuration = null;
|
||||||
visitAppContext.isScanningBeacons = false;
|
visitAppContext.isScanningBeacons = false;
|
||||||
//Navigator.of(context).pop();
|
|
||||||
Navigator.of(context).pushAndRemoveUntil(MaterialPageRoute(
|
Navigator.of(context).pushAndRemoveUntil(MaterialPageRoute(
|
||||||
builder: (context) => const HomePage3(),
|
builder: (context) => const HomePage3(),
|
||||||
),(route) => false);
|
), (route) => false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
) : null,
|
) : 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? isAllLanguages = false;
|
||||||
bool notificationsEnabled = true;
|
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;
|
String? localPath;
|
||||||
|
|
||||||
VisitAppContext({this.language, this.id, this.configuration, this.isAdmin, this.isAllLanguages, this.instanceId, this.apiKey, this.notificationsEnabled = true});
|
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/material.dart';
|
||||||
import 'package:flutter/services.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:get/get.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:manager_api_new/api.dart';
|
import 'package:manager_api_new/api.dart';
|
||||||
@ -45,8 +45,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
|
|||||||
|
|
||||||
// Beacon specific
|
// Beacon specific
|
||||||
final controller = Get.find<RequirementStateController>();
|
final controller = Get.find<RequirementStateController>();
|
||||||
/*StreamSubscription<BluetoothState>? _streamBluetooth;
|
StreamSubscription<ScanResult>? _streamRanging;
|
||||||
StreamSubscription<RangingResult>? _streamRanging;*/
|
|
||||||
/*final _regionBeacons = <Region, List<Beacon>>{};
|
/*final _regionBeacons = <Region, List<Beacon>>{};
|
||||||
final _beacons = <Beacon>[];*/
|
final _beacons = <Beacon>[];*/
|
||||||
bool _isDialogShowing = false;
|
bool _isDialogShowing = false;
|
||||||
@ -110,18 +109,19 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
|
|||||||
visitAppContext.configuration = widget.configuration;
|
visitAppContext.configuration = widget.configuration;
|
||||||
visitAppContext.sectionIds = widget.configuration.sectionIds;
|
visitAppContext.sectionIds = widget.configuration.sectionIds;
|
||||||
appContext.setContext(visitAppContext);
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
listeningState() async {
|
listeningState() async {
|
||||||
print('Listening to bluetooth state');
|
await BeaconScanner.instance.initialize(true);
|
||||||
/*_streamBluetooth = flutterBeacon
|
|
||||||
.bluetoothStateChanged()
|
|
||||||
.listen((BluetoothState state) async {
|
|
||||||
controller.updateBluetoothState(state);
|
|
||||||
await checkAllRequirements();
|
|
||||||
});*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
checkAllRequirements() async {
|
checkAllRequirements() async {
|
||||||
@ -177,89 +177,55 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
|
|||||||
}
|
}
|
||||||
|
|
||||||
initScanBeacon(VisitAppContext visitAppContext) async {
|
initScanBeacon(VisitAppContext visitAppContext) async {
|
||||||
|
if (_streamRanging != null && !_streamRanging!.isPaused) return;
|
||||||
|
|
||||||
//await flutterBeacon.initializeScanning;
|
if (_streamRanging != null && _streamRanging!.isPaused) {
|
||||||
/*if (!controller.authorizationStatusOk ||
|
_streamRanging!.resume();
|
||||||
!controller.locationServiceEnabled ||
|
|
||||||
!controller.bluetoothEnabled) {
|
|
||||||
print(
|
|
||||||
'RETURNED, authorizationStatusOk=${controller.authorizationStatusOk}, '
|
|
||||||
'locationServiceEnabled=${controller.locationServiceEnabled}, '
|
|
||||||
'bluetoothEnabled=${controller.bluetoothEnabled}');
|
|
||||||
return;
|
return;
|
||||||
}*/
|
}
|
||||||
|
|
||||||
/*if (_streamRanging != null) {
|
final regions = <Region>[
|
||||||
if (_streamRanging!.isPaused) {
|
if (Platform.isIOS)
|
||||||
_streamRanging?.resume();
|
Region(
|
||||||
return;
|
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 =
|
if (matches.isNotEmpty && !modeDebugBeacon) {
|
||||||
flutterBeacon.ranging(regions).listen((RangingResult result) {
|
matches = matches.where((bs) =>
|
||||||
//print(result);
|
!visitAppContext.readSections.any((ra) => ra.id == bs!.sectionId)
|
||||||
if (mounted) {
|
);
|
||||||
|
}
|
||||||
|
|
||||||
//print("visitAppContext");
|
if (matches.isEmpty) return;
|
||||||
//print(visitAppContext);
|
|
||||||
//print(visitAppContext!.beaconSections);
|
|
||||||
|
|
||||||
if(result.beacons.isNotEmpty) {
|
final milliLastTime = lastTimePopUpWasClosed?.millisecondsSinceEpoch ?? 0;
|
||||||
print(result);
|
final cooldownOk = (DateTime.now().millisecondsSinceEpoch - milliLastTime) > timeBetweenBeaconPopUp;
|
||||||
print(result.beacons.map((b) => b.macAddress));
|
|
||||||
|
|
||||||
if(visitAppContext.beaconSections != null)
|
if (!_isDialogShowing && !visitAppContext.isContentCurrentlyShown && cooldownOk && visitAppContext.isScanningBeacons) {
|
||||||
{
|
final sorted = matches.toList()..sort((a, b) => a!.orderInConfig!.compareTo(b!.orderInConfig!));
|
||||||
print(visitAppContext.beaconSections!.map((bb) => bb!.minorBeaconId));
|
_onBeaconFound(visitAppContext, sorted.first);
|
||||||
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);*/
|
|
||||||
//});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*pauseScanBeacon() async {
|
/*pauseScanBeacon() async {
|
||||||
@ -302,6 +268,10 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
|
|||||||
|
|
||||||
void _onBeaconFound(VisitAppContext visitAppContext, BeaconSection? beaconSection) {
|
void _onBeaconFound(VisitAppContext visitAppContext, BeaconSection? beaconSection) {
|
||||||
_isDialogShowing = true;
|
_isDialogShowing = true;
|
||||||
|
PushNotificationService.showBeaconDiscoveryNotification(
|
||||||
|
title: TranslationHelper.getFromLocale('beaconFound', visitAppContext),
|
||||||
|
body: TranslationHelper.getFromLocale('beaconFoundBody', visitAppContext),
|
||||||
|
);
|
||||||
showDialog(
|
showDialog(
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext context) => AlertDialog(
|
builder: (BuildContext context) => AlertDialog(
|
||||||
@ -353,6 +323,8 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
PushNotificationService.tappedMessage.removeListener(_notificationListener);
|
PushNotificationService.tappedMessage.removeListener(_notificationListener);
|
||||||
|
listener?.cancel();
|
||||||
|
_streamRanging?.cancel();
|
||||||
controller.pauseScanning();
|
controller.pauseScanning();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@ -361,16 +333,6 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appContext = Provider.of<AppContext>(context);
|
final appContext = Provider.of<AppContext>(context);
|
||||||
VisitAppContext visitAppContext = appContext.getContext();
|
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(
|
return PopScope(
|
||||||
canPop: true,
|
canPop: true,
|
||||||
@ -418,135 +380,22 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
|
|||||||
padding: const EdgeInsets.only(right: 90, bottom: 1),
|
padding: const EdgeInsets.only(right: 90, bottom: 1),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
bool isCancel = false;
|
if (!visitAppContext.isScanningBeacons) {
|
||||||
|
await [
|
||||||
/*if(!controller.authorizationStatusOk) {
|
Permission.bluetoothScan,
|
||||||
//await handleOpenLocationSettings();
|
Permission.bluetoothConnect,
|
||||||
|
Permission.location,
|
||||||
await showDialog(
|
].request();
|
||||||
context: context,
|
controller.startScanning();
|
||||||
barrierDismissible: false,
|
visitAppContext.isScanningBeacons = true;
|
||||||
builder: (_) {
|
visitAppContext.isScanBeaconAlreadyAllowed = true;
|
||||||
return AlertDialog(
|
appContext.setContext(visitAppContext);
|
||||||
backgroundColor: Colors.white,
|
} else {
|
||||||
content: Padding(
|
_streamRanging?.pause();
|
||||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
controller.pauseScanning();
|
||||||
child: SizedBox(
|
visitAppContext.isScanningBeacons = false;
|
||||||
height: 215,
|
appContext.setContext(visitAppContext);
|
||||||
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(!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(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@ -624,7 +624,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
final mobileInstance = instances?.where((e) => e.appType == AppType.Mobile).firstOrNull;
|
final mobileInstance = instances?.where((e) => e.appType == AppType.Mobile).firstOrNull;
|
||||||
if (mobileInstance != null) {
|
if (mobileInstance != null) {
|
||||||
visitAppContext.applicationInstanceDTO = mobileInstance;
|
visitAppContext.applicationInstanceDTO = mobileInstance;
|
||||||
if (mobileInstance.isStatistic ?? false) {
|
if (mobileInstance.hasStats == true) {
|
||||||
visitAppContext.statisticsService = StatisticsService(
|
visitAppContext.statisticsService = StatisticsService(
|
||||||
clientAPI: visitAppContext.clientAPI,
|
clientAPI: visitAppContext.clientAPI,
|
||||||
instanceId: visitAppContext.instanceId,
|
instanceId: visitAppContext.instanceId,
|
||||||
|
|||||||
@ -52,7 +52,7 @@ class _AgendaPage extends State<AgendaPage> {
|
|||||||
if (dtos == null) return null;
|
if (dtos == null) return null;
|
||||||
|
|
||||||
final events = dtos
|
final events = dtos
|
||||||
.map((dto) => EventAgenda.fromDto(dto, visitAppContext.language))
|
.map((dto) => EventAgenda.fromDto(dto, visitAppContext.language ?? 'FR'))
|
||||||
.toList();
|
.toList();
|
||||||
events.sort((a, b) {
|
events.sort((a, b) {
|
||||||
if (a.dateFrom == null) return 1;
|
if (a.dateFrom == null) return 1;
|
||||||
|
|||||||
@ -10,10 +10,12 @@ class EventMapFullPage extends StatelessWidget {
|
|||||||
Key? key,
|
Key? key,
|
||||||
required this.section,
|
required this.section,
|
||||||
required this.visitAppContextIn,
|
required this.visitAppContextIn,
|
||||||
|
this.blockAnnotations,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final SectionEventDTO section;
|
final SectionEventDTO section;
|
||||||
final VisitAppContext visitAppContextIn;
|
final VisitAppContext visitAppContextIn;
|
||||||
|
final List<MapAnnotation>? blockAnnotations;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -39,6 +41,10 @@ class EventMapFullPage extends StatelessWidget {
|
|||||||
MarkerLayer(markers: _buildMarkers(annotations)),
|
MarkerLayer(markers: _buildMarkers(annotations)),
|
||||||
PolylineLayer(polylines: _buildPolylines(annotations)),
|
PolylineLayer(polylines: _buildPolylines(annotations)),
|
||||||
],
|
],
|
||||||
|
if (blockAnnotations?.isNotEmpty ?? false) ...[
|
||||||
|
MarkerLayer(markers: _buildBlockMarkers(blockAnnotations!)),
|
||||||
|
PolylineLayer(polylines: _buildBlockPolylines(blockAnnotations!)),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@ -77,7 +83,7 @@ class EventMapFullPage extends StatelessWidget {
|
|||||||
return markers;
|
return markers;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Polyline> _buildPolylines(List<MapAnnotationDTO> annotations) {
|
List<Polyline> _buildPolylines(List<MapAnnotationDTO> annotations, {Color? overrideColor}) {
|
||||||
final lines = <Polyline>[];
|
final lines = <Polyline>[];
|
||||||
for (final ann in annotations) {
|
for (final ann in annotations) {
|
||||||
if (ann.geometryType?.value != 1) continue;
|
if (ann.geometryType?.value != 1) continue;
|
||||||
@ -90,12 +96,48 @@ class EventMapFullPage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (points.length < 2) continue;
|
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));
|
lines.add(Polyline(points: points, color: color, strokeWidth: 4));
|
||||||
}
|
}
|
||||||
return lines;
|
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) {
|
Color _hexColor(String hex) {
|
||||||
final v = int.tryParse('FF${hex.replaceAll('#', '')}', radix: 16);
|
final v = int.tryParse('FF${hex.replaceAll('#', '')}', radix: 16);
|
||||||
return v != null ? Color(v) : kMainColor;
|
return v != null ? Color(v) : kMainColor;
|
||||||
|
|||||||
@ -436,6 +436,7 @@ class _EventPageState extends State<EventPage> {
|
|||||||
builder: (_) => EventMapFullPage(
|
builder: (_) => EventMapFullPage(
|
||||||
section: widget.section,
|
section: widget.section,
|
||||||
visitAppContextIn: widget.visitAppContextIn,
|
visitAppContextIn: widget.visitAppContextIn,
|
||||||
|
blockAnnotations: _activeBlock?.mapAnnotations,
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
child: Container(
|
child: Container(
|
||||||
@ -462,6 +463,10 @@ class _EventPageState extends State<EventPage> {
|
|||||||
MarkerLayer(markers: _buildMarkers(annotations)),
|
MarkerLayer(markers: _buildMarkers(annotations)),
|
||||||
PolylineLayer(polylines: _buildPolylines(annotations)),
|
PolylineLayer(polylines: _buildPolylines(annotations)),
|
||||||
],
|
],
|
||||||
|
if (_activeBlock?.mapAnnotations?.isNotEmpty ?? false) ...[
|
||||||
|
MarkerLayer(markers: _buildBlockMarkers(_activeBlock!.mapAnnotations!)),
|
||||||
|
PolylineLayer(polylines: _buildBlockPolylines(_activeBlock!.mapAnnotations!)),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@ -569,6 +574,42 @@ class _EventPageState extends State<EventPage> {
|
|||||||
return lines;
|
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) {
|
Color _hexColor(String hex) {
|
||||||
final v = int.tryParse('FF${hex.replaceAll('#', '')}', radix: 16);
|
final v = int.tryParse('FF${hex.replaceAll('#', '')}', radix: 16);
|
||||||
return v != null ? Color(v) : kMainColor;
|
return v != null ? Color(v) : kMainColor;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:math';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.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/Helpers/translationHelper.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Game/message_dialog.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Game/message_dialog.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart';
|
||||||
import 'package:mymuseum_visitapp/app_context.dart';
|
import 'package:mymuseum_visitapp/app_context.dart';
|
||||||
import 'package:mymuseum_visitapp/constants.dart';
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Screens/Sections/Game/sliding_puzzle_piece.dart';
|
||||||
import 'puzzle_piece.dart';
|
import 'puzzle_piece.dart';
|
||||||
|
|
||||||
const IMAGE_PATH = 'image_path';
|
const IMAGE_PATH = 'image_path';
|
||||||
@ -36,6 +37,9 @@ class _GamePage extends State<GamePage> {
|
|||||||
List<Widget> pieces = [];
|
List<Widget> pieces = [];
|
||||||
|
|
||||||
bool isSplittingImage = true;
|
bool isSplittingImage = true;
|
||||||
|
bool showHint = false;
|
||||||
|
List<int> slidingTileIndices = []; // Maps current slot index to original tile index. -1 for empty.
|
||||||
|
int emptySlotIndex = -1;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -89,86 +93,96 @@ class _GamePage extends State<GamePage> {
|
|||||||
print("Taille réelle du widget : $size");
|
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
|
// here we will split the image into small pieces
|
||||||
// using the rows and columns defined above; each piece will be added to a stack
|
// using the rows and columns defined above; each piece will be added to a stack
|
||||||
void splitImage(CachedNetworkImage image) async {
|
void splitImage(CachedNetworkImage image) async {
|
||||||
//Size imageSize = await getImageSize(image);
|
final Completer<Size> completer = Completer<Size>();
|
||||||
//imageSize = realWidgetSize!;
|
final ImageProvider provider = CachedNetworkImageProvider(gameDTO.puzzleImage!.url!);
|
||||||
Size imageSize = Size(realWidgetSize!.width * 1.25, realWidgetSize!.height * 1.25);
|
|
||||||
|
|
||||||
for (int x = 0; x < gameDTO.rows!; x++) {
|
provider.resolve(const ImageConfiguration()).addListener(
|
||||||
for (int y = 0; y < gameDTO.cols!; y++) {
|
ImageStreamListener((ImageInfo info, bool _) {
|
||||||
setState(() {
|
if (!completer.isCompleted) {
|
||||||
pieces.add(
|
completer.complete(Size(info.image.width.toDouble(), info.image.height.toDouble()));
|
||||||
PuzzlePiece(
|
}
|
||||||
key: GlobalKey(),
|
}),
|
||||||
image: image,
|
);
|
||||||
imageSize: imageSize,
|
|
||||||
row: x,
|
Size imageOriginalSize = await completer.future;
|
||||||
col: y,
|
double imageAspectRatio = imageOriginalSize.width / imageOriginalSize.height;
|
||||||
maxRow: gameDTO.rows!,
|
|
||||||
maxCol: gameDTO.cols!,
|
// Calculate best fit for the puzzle inside the available area
|
||||||
bringToTop: bringToTop,
|
double containerWidth = realWidgetSize!.width * 0.9; // 90% of available width
|
||||||
sendToBack: sendToBack,
|
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
|
// when the pan of a piece starts, we need to bring it to the front of the stack
|
||||||
void bringToTop(Widget widget) {
|
void bringToTop(Widget widget) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@ -185,8 +243,8 @@ class _GamePage extends State<GamePage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// when a piece reaches its final position,
|
// 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
|
// 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) {
|
void sendToBack(Widget widget) {
|
||||||
setState(() {
|
setState(() {
|
||||||
allInPlaceCount++;
|
allInPlaceCount++;
|
||||||
@ -194,24 +252,160 @@ class _GamePage extends State<GamePage> {
|
|||||||
pieces.remove(widget);
|
pieces.remove(widget);
|
||||||
pieces.insert(0, widget);
|
pieces.insert(0, widget);
|
||||||
|
|
||||||
if(isFinished) {
|
if (isFinished) {
|
||||||
Size size = MediaQuery.of(context).size;
|
_onGameFinished('Puzzle');
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appContext = Provider.of<AppContext>(context);
|
final appContext = Provider.of<AppContext>(context);
|
||||||
@ -335,21 +529,7 @@ class _GamePage extends State<GamePage> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
key: _widgetKey,
|
key: _widgetKey,
|
||||||
padding: const EdgeInsets.all(0.0),
|
padding: const EdgeInsets.all(0.0),
|
||||||
child: isSplittingImage ? Center(child: LoadingCommon()) :
|
child: isSplittingImage ? Center(child: LoadingCommon()) : _buildContent(visitAppContext),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -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:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.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 {
|
class PuzzlePiece extends StatefulWidget {
|
||||||
final CachedNetworkImage image;
|
final CachedNetworkImage image;
|
||||||
@ -15,31 +10,22 @@ class PuzzlePiece extends StatefulWidget {
|
|||||||
final int maxCol;
|
final int maxCol;
|
||||||
final Function bringToTop;
|
final Function bringToTop;
|
||||||
final Function sendToBack;
|
final Function sendToBack;
|
||||||
|
final double initialTop;
|
||||||
|
final double initialLeft;
|
||||||
|
|
||||||
static PuzzlePiece fromMap(Map<String, dynamic> map) {
|
PuzzlePiece({
|
||||||
return PuzzlePiece(
|
Key? key,
|
||||||
image: map['image'],
|
required this.image,
|
||||||
imageSize: map['imageSize'],
|
required this.imageSize,
|
||||||
row: map['row'],
|
required this.row,
|
||||||
col: map['col'],
|
required this.col,
|
||||||
maxRow: map['maxRow'],
|
required this.maxRow,
|
||||||
maxCol: map['maxCol'],
|
required this.maxCol,
|
||||||
bringToTop: map['bringToTop'],
|
required this.bringToTop,
|
||||||
sendToBack: map['SendToBack'],
|
required this.sendToBack,
|
||||||
);
|
required this.initialTop,
|
||||||
}
|
required this.initialLeft,
|
||||||
|
}) : 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})
|
|
||||||
: super(key: key);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_PuzzlePieceState createState() => _PuzzlePieceState();
|
_PuzzlePieceState createState() => _PuzzlePieceState();
|
||||||
@ -58,65 +44,32 @@ class _PuzzlePieceState extends State<PuzzlePiece> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
top = widget.initialTop;
|
||||||
|
left = widget.initialLeft;
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
if (widget.row == 0 && widget.col == 0) {
|
||||||
setState(() {
|
isMovable = false;
|
||||||
|
top = 0;
|
||||||
RenderBox renderBox = _widgetPieceKey.currentContext?.findRenderObject() as RenderBox;
|
left = 0;
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedPositioned(
|
||||||
var isPortrait = MediaQuery.of(context).orientation == Orientation.portrait;
|
duration: isMovable ? Duration.zero : const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
var imageHeight = isPortrait ? widget.imageSize.width/1.55 : widget.imageSize.width;
|
top: top,
|
||||||
var imageWidth = isPortrait ? widget.imageSize.width/1.55 : widget.imageSize.height;
|
left: left,
|
||||||
|
width: widget.imageSize.width,
|
||||||
final pieceWidth = imageWidth / widget.maxCol;
|
child: AnimatedScale(
|
||||||
final pieceHeight = imageHeight / widget.maxRow;
|
duration: const Duration(milliseconds: 300),
|
||||||
|
scale: isMovable ? 1.05 : 1.0,
|
||||||
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,
|
|
||||||
child: Container(
|
child: Container(
|
||||||
key: _widgetPieceKey,
|
key: _widgetPieceKey,
|
||||||
decoration: widget.col == 0 && widget.row == 0 ? BoxDecoration(
|
decoration: widget.col == 0 && widget.row == 0 ? BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.black,
|
color: Colors.black.withOpacity(0.3),
|
||||||
width: 0.5,
|
width: 0.5,
|
||||||
),
|
),
|
||||||
) : null,
|
) : null,
|
||||||
@ -134,34 +87,40 @@ class _PuzzlePieceState extends State<PuzzlePiece> {
|
|||||||
onPanUpdate: (dragUpdateDetails) {
|
onPanUpdate: (dragUpdateDetails) {
|
||||||
if (isMovable) {
|
if (isMovable) {
|
||||||
setState(() {
|
setState(() {
|
||||||
var testTop = top!;
|
top = top! + dragUpdateDetails.delta.dy;
|
||||||
var testLeft = left!;
|
left = left! + dragUpdateDetails.delta.dx;
|
||||||
testTop = top!;
|
|
||||||
testLeft = left!;
|
|
||||||
testTop += dragUpdateDetails.delta.dy;
|
|
||||||
testLeft += dragUpdateDetails.delta.dx;
|
|
||||||
top = testTop;
|
|
||||||
left = testLeft;
|
|
||||||
|
|
||||||
if (-10 < top! && top! < 10 && -10 < left! && left! < 10) {
|
// Target position is (0,0) relative to its original offset in the image
|
||||||
top = 0;
|
if (top!.abs() < 15 && left!.abs() < 15) {
|
||||||
left = 0;
|
setState(() {
|
||||||
isMovable = false;
|
top = 0;
|
||||||
|
left = 0;
|
||||||
|
isMovable = false;
|
||||||
|
});
|
||||||
widget.sendToBack(widget);
|
widget.sendToBack(widget);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: ClipPath(
|
child: PhysicalShape(
|
||||||
child: CustomPaint(
|
|
||||||
foregroundPainter: PuzzlePiecePainter(
|
|
||||||
widget.row, widget.col, widget.maxRow, widget.maxCol),
|
|
||||||
child: widget.image),
|
|
||||||
clipper: PuzzlePieceClipper(
|
clipper: PuzzlePieceClipper(
|
||||||
widget.row, widget.col, widget.maxRow, widget.maxCol),
|
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
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final Paint paint = Paint()
|
final Paint paint = Paint()
|
||||||
..color = Colors.black//Color(0x80FFFFFF)
|
..color = Colors.black.withOpacity(0.2)
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 2.5;
|
..strokeWidth = 1.0;
|
||||||
|
|
||||||
canvas.drawPath(getPiecePath(size, row, col, maxRow, maxCol), paint);
|
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/ResponseSubDTO.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart';
|
||||||
|
|
||||||
/// Vue de progression centrée contenu (escape game, ou parcours sans carte).
|
/// Vue de progression centrée contenu (escape game, ou parcours sans carte).
|
||||||
@ -160,6 +161,7 @@ class _GuidedPathContentProgressionPageState
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _checkGeoZone(LatLng position) {
|
void _checkGeoZone(LatLng position) {
|
||||||
|
if (!mounted) return;
|
||||||
final step = _currentStep;
|
final step = _currentStep;
|
||||||
if (step == null || !_hasGeoTrigger(step)) return;
|
if (step == null || !_hasGeoTrigger(step)) return;
|
||||||
|
|
||||||
@ -176,8 +178,14 @@ class _GuidedPathContentProgressionPageState
|
|||||||
|
|
||||||
if (center == null) return;
|
if (center == null) return;
|
||||||
final dist = const Distance().distance(position, center);
|
final dist = const Distance().distance(position, center);
|
||||||
if ((dist <= radius) != _inGeoZone) {
|
final inZone = dist <= radius;
|
||||||
setState(() => _inGeoZone = 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/ResponseSubDTO.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart';
|
||||||
import 'package:mymuseum_visitapp/constants.dart';
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
|
|
||||||
@ -193,6 +194,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
|
|||||||
_positionSub = Geolocator.getPositionStream(
|
_positionSub = Geolocator.getPositionStream(
|
||||||
locationSettings: const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5),
|
locationSettings: const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5),
|
||||||
).listen((pos) {
|
).listen((pos) {
|
||||||
|
if (!mounted) return;
|
||||||
final newPos = LatLng(pos.latitude, pos.longitude);
|
final newPos = LatLng(pos.latitude, pos.longitude);
|
||||||
setState(() => _userPosition = newPos);
|
setState(() => _userPosition = newPos);
|
||||||
_checkGeoZone(newPos);
|
_checkGeoZone(newPos);
|
||||||
@ -200,6 +202,7 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _checkGeoZone(LatLng position) {
|
void _checkGeoZone(LatLng position) {
|
||||||
|
if (!mounted) return;
|
||||||
final step = _currentStep;
|
final step = _currentStep;
|
||||||
if (step == null || !_hasGeoTrigger(step)) return;
|
if (step == null || !_hasGeoTrigger(step)) return;
|
||||||
|
|
||||||
@ -218,6 +221,11 @@ class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionP
|
|||||||
|
|
||||||
final distMeters = const Distance().distance(position, triggerCenter);
|
final distMeters = const Distance().distance(position, triggerCenter);
|
||||||
final inZone = distMeters <= radius;
|
final inZone = distMeters <= radius;
|
||||||
|
if (inZone && !_inGeoZone) {
|
||||||
|
PushNotificationService.showGeoZoneNotification(
|
||||||
|
stepTitle: _translate(_currentStep!.title),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (inZone != _inGeoZone) {
|
if (inZone != _inGeoZone) {
|
||||||
setState(() => _inGeoZone = inZone);
|
setState(() => _inGeoZone = inZone);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
|||||||
//import 'package:google_maps_flutter/google_maps_flutter.dart';
|
//import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||||
import 'package:manager_api_new/api.dart';
|
import 'package:manager_api_new/api.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Map/flutter_map_view.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Map/flutter_map_view.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_list_sheet.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_list_sheet.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Map/geo_point_filter.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Map/geo_point_filter.dart';
|
||||||
@ -38,9 +39,8 @@ class MapPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _MapPage extends State<MapPage> {
|
class _MapPage extends State<MapPage> {
|
||||||
MapDTO? mapDTO;
|
MapDTO? mapDTO;
|
||||||
//Completer<GoogleMapController> _controller = Completer();
|
|
||||||
//Uint8List? selectedMarkerIcon;
|
|
||||||
late ValueNotifier<List<GeoPointDTO>> _geoPoints = ValueNotifier<List<GeoPointDTO>>([]);
|
late ValueNotifier<List<GeoPointDTO>> _geoPoints = ValueNotifier<List<GeoPointDTO>>([]);
|
||||||
|
bool _showListView = false;
|
||||||
|
|
||||||
/*Future<Uint8List> getBytesFromAsset(ByteData data, int width) async {
|
/*Future<Uint8List> getBytesFromAsset(ByteData data, int width) async {
|
||||||
//ByteData data = await rootBundle.load(path);
|
//ByteData data = await rootBundle.load(path);
|
||||||
@ -72,6 +72,39 @@ class _MapPage extends State<MapPage> {
|
|||||||
tilt: 59.440717697143555,
|
tilt: 59.440717697143555,
|
||||||
zoom: 59.151926040649414);*/
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
//final mapContext = Provider.of<MapContext>(context);
|
//final mapContext = Provider.of<MapContext>(context);
|
||||||
@ -104,15 +137,15 @@ class _MapPage extends State<MapPage> {
|
|||||||
ValueListenableBuilder<List<GeoPointDTO>>(
|
ValueListenableBuilder<List<GeoPointDTO>>(
|
||||||
valueListenable: _geoPoints,
|
valueListenable: _geoPoints,
|
||||||
builder: (context, value, _) {
|
builder: (context, value, _) {
|
||||||
|
if (_showListView) {
|
||||||
|
return _buildListView(value, visitAppContext);
|
||||||
|
}
|
||||||
switch(mapDTO!.mapProvider) {
|
switch(mapDTO!.mapProvider) {
|
||||||
case MapProvider.Google:
|
case MapProvider.Google:
|
||||||
return GoogleMapView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO!, icons: widget.icons);
|
return GoogleMapView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO!, icons: widget.icons);
|
||||||
case MapProvider.MapBox:
|
case MapProvider.MapBox:
|
||||||
return MapBoxView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO, icons: widget.icons);
|
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:
|
default:
|
||||||
// By default google
|
|
||||||
return GoogleMapView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO!, icons: widget.icons);
|
return GoogleMapView(language: appContext.getContext().language, geoPoints: value, mapDTO: mapDTO!, icons: widget.icons);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -125,7 +158,7 @@ class _MapPage extends State<MapPage> {
|
|||||||
filteredPoints: (value) {
|
filteredPoints: (value) {
|
||||||
_geoPoints.value = value!;
|
_geoPoints.value = value!;
|
||||||
}),
|
}),
|
||||||
MarkerViewWidget(),
|
if (!_showListView) MarkerViewWidget(),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 35,
|
top: 35,
|
||||||
left: 10,
|
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(
|
/*floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: _goToTheLake,
|
onPressed: _goToTheLake,
|
||||||
|
|||||||
@ -1,15 +1,26 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||||
import 'package:manager_api_new/api.dart';
|
import 'package:manager_api_new/api.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Components/video_viewer.dart';
|
||||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||||
import 'package:mymuseum_visitapp/app_context.dart';
|
import 'package:mymuseum_visitapp/app_context.dart';
|
||||||
import 'package:mymuseum_visitapp/constants.dart';
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
//import 'package:youtube_player_iframe/youtube_player_iframe.dart' as iframe;
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
import 'package:youtube_player_flutter/youtube_player_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 {
|
class VideoPage extends StatefulWidget {
|
||||||
final VideoDTO section;
|
final VideoDTO section;
|
||||||
@ -20,95 +31,92 @@ class VideoPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _VideoPage extends State<VideoPage> {
|
class _VideoPage extends State<VideoPage> {
|
||||||
//iframe.YoutubePlayer? _videoViewWeb;
|
iframe.YoutubePlayerController? _youtubeController;
|
||||||
YoutubePlayer? _videoView;
|
WebViewController? _vimeoController;
|
||||||
VideoDTO? videoDTO;
|
_VideoSourceType? _sourceType;
|
||||||
late YoutubePlayerController _controller;
|
|
||||||
bool isFullScreen = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
//print(widget.section!.data);
|
super.initState();
|
||||||
//videoDTO = VideoDTO.fromJson(jsonDecode(widget.section!.data!));
|
final source = widget.section.source_;
|
||||||
//print(videoDTO);
|
if (source == null || source.isEmpty) return;
|
||||||
videoDTO= widget.section;
|
|
||||||
|
|
||||||
String? videoId;
|
_sourceType = _detectSourceType(source);
|
||||||
if (videoDTO!.source_ != null && videoDTO!.source_!.isNotEmpty) {
|
|
||||||
videoId = YoutubePlayer.convertUrlToId(videoDTO!.source_!);
|
|
||||||
|
|
||||||
/*if (false) {
|
if (_sourceType == _VideoSourceType.youtube) {
|
||||||
final _controllerWeb = iframe.YoutubePlayerController(
|
_youtubeController = iframe.YoutubePlayerController(
|
||||||
params: iframe.YoutubePlayerParams(
|
params: const iframe.YoutubePlayerParams(
|
||||||
mute: false,
|
mute: false,
|
||||||
showControls: true,
|
showControls: true,
|
||||||
showFullscreenButton: false,
|
showFullscreenButton: true,
|
||||||
loop: true,
|
loop: true,
|
||||||
showVideoAnnotations: false,
|
showVideoAnnotations: false,
|
||||||
strictRelatedVideos: false,
|
strictRelatedVideos: false,
|
||||||
enableKeyboard: false,
|
enableKeyboard: false,
|
||||||
enableCaption: false,
|
enableCaption: false,
|
||||||
pointerEvents: iframe.PointerEvents.auto
|
pointerEvents: iframe.PointerEvents.auto,
|
||||||
),
|
userAgent: _browserUserAgent,
|
||||||
);
|
),
|
||||||
|
);
|
||||||
_controllerWeb.loadVideo(videoDTO!.source_!);
|
_youtubeController!.loadVideo(source);
|
||||||
|
_youtubeController!.listen((value) {
|
||||||
_videoViewWeb = iframe.YoutubePlayer(
|
if (mounted) {
|
||||||
controller: _controllerWeb,
|
setState(() {
|
||||||
//showVideoProgressIndicator: false,
|
isFullScreen = value.fullScreenOption.enabled;
|
||||||
/*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;
|
|
||||||
});
|
});
|
||||||
|
} 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_videoView = null;
|
_youtubeController?.close();
|
||||||
super.dispose();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Size size = MediaQuery.of(context).size;
|
Size size = MediaQuery.of(context).size;
|
||||||
@ -127,32 +135,19 @@ class _VideoPage extends State<VideoPage> {
|
|||||||
color: kMainGrey,
|
color: kMainGrey,
|
||||||
spreadRadius: 0.5,
|
spreadRadius: 0.5,
|
||||||
blurRadius: 5,
|
blurRadius: 5,
|
||||||
offset: Offset(0, 1), // changes position of shadow
|
offset: Offset(0, 1),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
begin: Alignment.centerRight,
|
begin: Alignment.centerRight,
|
||||||
end: Alignment.centerLeft,
|
end: Alignment.centerLeft,
|
||||||
colors: [
|
colors: [kMainColor0, kMainColor1, kMainColor2],
|
||||||
/*Color(0xFFDD79C2),
|
|
||||||
Color(0xFFB65FBE),
|
|
||||||
Color(0xFF9146BA),
|
|
||||||
Color(0xFF7633B8),
|
|
||||||
Color(0xFF6528B6),
|
|
||||||
Color(0xFF6025B6)*/
|
|
||||||
kMainColor0, //Color(0xFFf6b3c4)
|
|
||||||
kMainColor1,
|
|
||||||
kMainColor2,
|
|
||||||
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
image: widget.section.imageSource != null ? DecorationImage(
|
image: widget.section.imageSource != null ? DecorationImage(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
opacity: 0.65,
|
opacity: 0.65,
|
||||||
image: NetworkImage(
|
image: NetworkImage(widget.section.imageSource!),
|
||||||
widget.section.imageSource!,
|
) : null,
|
||||||
),
|
|
||||||
): null,
|
|
||||||
),
|
),
|
||||||
) : const SizedBox(),
|
) : const SizedBox(),
|
||||||
Column(
|
Column(
|
||||||
@ -167,12 +162,11 @@ class _VideoPage extends State<VideoPage> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 22.0),
|
padding: const EdgeInsets.only(top: 22.0),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: size.width *0.7,
|
width: size.width * 0.7,
|
||||||
child: HtmlWidget(
|
child: HtmlWidget(
|
||||||
cleanedTitle,
|
cleanedTitle,
|
||||||
textStyle: const TextStyle(color: Colors.white, fontFamily: 'Roboto', fontSize: 20),
|
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"};
|
return {'text-align': 'center', 'font-family': "Roboto", '-webkit-line-clamp': "2"};
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -183,80 +177,74 @@ class _VideoPage extends State<VideoPage> {
|
|||||||
top: 35,
|
top: 35,
|
||||||
left: 10,
|
left: 10,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () => Navigator.of(context).pop(),
|
||||||
_controller.dispose();
|
child: Container(
|
||||||
_videoView = null;
|
decoration: const BoxDecoration(
|
||||||
Navigator.of(context).pop();
|
color: kMainColor,
|
||||||
},
|
shape: BoxShape.circle,
|
||||||
child: Container(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: kMainColor,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.arrow_back, size: 23, color: Colors.white)
|
|
||||||
),
|
),
|
||||||
)
|
child: const Icon(Icons.arrow_back, size: 23, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
): const SizedBox(),
|
) : const SizedBox(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(top: 0),
|
margin: const EdgeInsets.only(top: 0),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: kMainGrey,
|
color: kMainGrey,
|
||||||
spreadRadius: 0.5,
|
spreadRadius: 0.5,
|
||||||
blurRadius: 2,
|
blurRadius: 2,
|
||||||
offset: Offset(0, 1), // changes position of shadow
|
offset: Offset(0, 1),
|
||||||
),
|
|
||||||
],
|
|
||||||
color: kBackgroundColor,
|
|
||||||
borderRadius: BorderRadius.only(
|
|
||||||
topLeft: Radius.circular(30),
|
|
||||||
topRight: Radius.circular(30),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
color: kBackgroundColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(30),
|
||||||
|
topRight: Radius.circular(30),
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
),
|
||||||
borderRadius: !isFullScreen ? const BorderRadius.only(
|
child: ClipRRect(
|
||||||
topLeft: Radius.circular(30),
|
borderRadius: !isFullScreen ? const BorderRadius.only(
|
||||||
topRight: Radius.circular(30),
|
topLeft: Radius.circular(30),
|
||||||
): BorderRadius.zero,
|
topRight: Radius.circular(30),
|
||||||
child: videoDTO!.source_ != null && videoDTO!.source_!.isNotEmpty ?
|
) : BorderRadius.zero,
|
||||||
_videoView :
|
child: _buildPlayer(),
|
||||||
const Center(child: Text("La vidéo ne peut pas être affichée, l'url est incorrecte", style: TextStyle(fontSize: kNoneInfoOrIncorrect))))
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
isFullScreen ? Positioned(
|
if (isFullScreen)
|
||||||
top: 35,
|
Positioned(
|
||||||
left: 10,
|
top: 35,
|
||||||
child: SizedBox(
|
left: 10,
|
||||||
|
child: SizedBox(
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_controller.toggleFullScreenMode();
|
_youtubeController?.exitFullScreen();
|
||||||
_controller.dispose();
|
|
||||||
_videoView = null;
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: kMainColor,
|
color: kMainColor,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.arrow_back, size: 23, color: Colors.white)
|
child: const Icon(Icons.arrow_back, size: 23, color: Colors.white),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
): const SizedBox(),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,34 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:auto_size_text/auto_size_text.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.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:manager_api_new/api.dart';
|
||||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
|
||||||
import 'package:mymuseum_visitapp/Models/weatherData.dart';
|
import 'package:mymuseum_visitapp/Models/weatherData.dart';
|
||||||
import 'package:mymuseum_visitapp/app_context.dart';
|
import 'package:mymuseum_visitapp/app_context.dart';
|
||||||
import 'package:mymuseum_visitapp/constants.dart';
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
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 {
|
class WeatherPage extends StatefulWidget {
|
||||||
final WeatherDTO section;
|
final WeatherDTO section;
|
||||||
WeatherPage({required this.section});
|
const WeatherPage({super.key, required this.section});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<WeatherPage> createState() => _WeatherPageState();
|
State<WeatherPage> createState() => _WeatherPageState();
|
||||||
@ -24,359 +36,354 @@ class WeatherPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _WeatherPageState extends State<WeatherPage> {
|
class _WeatherPageState extends State<WeatherPage> {
|
||||||
WeatherDTO weatherDTO = WeatherDTO();
|
WeatherDTO weatherDTO = WeatherDTO();
|
||||||
WeatherData? weatherData = null;
|
WeatherData? weatherData;
|
||||||
int nbrNextHours = 5;
|
List<_DaySummary> _days = [];
|
||||||
|
int _selectedDayIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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();
|
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);
|
for (final f in allForecasts) {
|
||||||
|
final date = DateTime.fromMillisecondsSinceEpoch(f.dt! * 1000);
|
||||||
// Determine the date format based on the application language
|
final key = DateTime(date.year, date.month, date.day).millisecondsSinceEpoch;
|
||||||
String dateFormat = appContext.getContext().language.toString().toUpperCase() == "EN" ?
|
byDay.putIfAbsent(key, () => []).add(f);
|
||||||
'MM/dd/yyyy HH:mm'
|
|
||||||
: 'dd/MM/yyyy HH:mm';
|
|
||||||
|
|
||||||
if(isHourOnly) {
|
|
||||||
dateFormat = 'HH:mm';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(isDateOnly) {
|
return byDay.entries.take(6).map((entry) {
|
||||||
dateFormat = dateFormat.replaceAll('/yyyy HH:mm', '');
|
final forecasts = entry.value;
|
||||||
}
|
final representative = forecasts.firstWhere(
|
||||||
|
(f) => DateTime.fromMillisecondsSinceEpoch(f.dt! * 1000).hour == 12,
|
||||||
String formattedDate = DateFormat(dateFormat).format(dateTime);
|
orElse: () => forecasts.last,
|
||||||
|
);
|
||||||
return formattedDate;
|
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) {
|
String _shortDayLabel(int dt, AppContext appContext) {
|
||||||
DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
final now = DateTime.now();
|
||||||
|
final date = DateTime.fromMillisecondsSinceEpoch(dt * 1000);
|
||||||
String dayToPrint = "";
|
if (date.day == now.day && date.month == now.month) {
|
||||||
|
final lang = appContext.getContext().language?.toString().toUpperCase() ?? 'FR';
|
||||||
print("dateTime.weekday");
|
return lang == 'EN' ? 'Today' : lang == 'NL' ? 'Vandaag' : lang == 'DE' ? 'Heute' : 'Auj.';
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return _weekdayName(date.weekday, appContext, short: true);
|
||||||
return isDate ? "${dayToPrint} ${formatTimestamp(timestamp, appContext, false, true)}" : dayToPrint;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<WeatherForecast> getNextFiveDaysForecast(List<WeatherForecast> allForecasts) {
|
String _fullDayLabel(int dt, AppContext appContext) {
|
||||||
List<WeatherForecast> nextFiveDaysForecast = [];
|
final now = DateTime.now();
|
||||||
DateTime today = DateTime.now();
|
final date = DateTime.fromMillisecondsSinceEpoch(dt * 1000);
|
||||||
|
final lang = appContext.getContext().language?.toString().toUpperCase() ?? 'FR';
|
||||||
List<WeatherForecast> nextDay1All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 1))).day).toList();
|
if (date.day == now.day && date.month == now.month) {
|
||||||
List<WeatherForecast> nextDay2All = allForecasts.where((af) => (DateTime.fromMillisecondsSinceEpoch(af.dt! * 1000)).day == (today.add(Duration(days: 2))).day).toList();
|
return lang == 'EN' ? 'Today' : lang == 'NL' ? 'Vandaag' : lang == 'DE' ? 'Heute' : "Aujourd'hui";
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
final dayName = _weekdayName(date.weekday, appContext, short: false);
|
||||||
var nextDay2MiddayTest = nextDay2All.where((nd) => (DateTime.fromMillisecondsSinceEpoch(nd.dt! * 1000)).hour == 12).firstOrNull;
|
final locale = lang == 'EN' ? 'en_US' : lang == 'NL' ? 'nl_NL' : lang == 'DE' ? 'de_DE' : 'fr_FR';
|
||||||
if(nextDay2All.isNotEmpty) {
|
try {
|
||||||
WeatherForecast nextDay2Midday = nextDay2MiddayTest ?? nextDay2All.last;
|
final monthDay = lang == 'EN'
|
||||||
nextFiveDaysForecast.add(nextDay2Midday);
|
? 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;
|
String _weekdayName(int weekday, AppContext appContext, {required bool short}) {
|
||||||
if(nextDay3All.isNotEmpty) {
|
final ctx = appContext.getContext();
|
||||||
WeatherForecast nextDay3Midday = nextDay3MiddayTest ?? nextDay3All.last;
|
final keys = {
|
||||||
nextFiveDaysForecast.add(nextDay3Midday);
|
1: 'monday',
|
||||||
}
|
2: 'tuesday',
|
||||||
|
3: 'wednesday',
|
||||||
var nextDay4MiddayTest = nextDay4All.where((nd) => (DateTime.fromMillisecondsSinceEpoch(nd.dt! * 1000)).hour == 12).firstOrNull;
|
4: 'thursday',
|
||||||
if(nextDay4All.isNotEmpty) {
|
5: 'friday',
|
||||||
WeatherForecast nextDay4Midday = nextDay4MiddayTest ?? nextDay4All.last;
|
6: 'saturday',
|
||||||
nextFiveDaysForecast.add(nextDay4Midday);
|
7: 'sunday',
|
||||||
}
|
};
|
||||||
|
final full = TranslationHelper.getFromLocale(keys[weekday]!, ctx);
|
||||||
var nextDay5MiddayTest = nextDay5All.where((nd) => (DateTime.fromMillisecondsSinceEpoch(nd.dt! * 1000)).hour == 12).firstOrNull;
|
return short && full.length > 3 ? '${full.substring(0, 3)}.' : full;
|
||||||
if(nextDay5All.isNotEmpty) {
|
|
||||||
WeatherForecast nextDay5Midday = nextDay5MiddayTest ?? nextDay5All.last;
|
|
||||||
nextFiveDaysForecast.add(nextDay5Midday);
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextFiveDaysForecast;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Size size = MediaQuery.of(context).size;
|
|
||||||
final appContext = Provider.of<AppContext>(context);
|
final appContext = Provider.of<AppContext>(context);
|
||||||
VisitAppContext visitAppContext = appContext.getContext();
|
final visitAppContext = appContext.getContext();
|
||||||
|
final primaryColor = visitAppContext.configuration?.primaryColor != null
|
||||||
var primaryColor = visitAppContext.configuration != null ? visitAppContext.configuration!.primaryColor != null ? Color(int.parse(visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16)) : kSecondColor : kSecondColor;
|
? Color(int.parse(
|
||||||
|
visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0],
|
||||||
|
radix: 16))
|
||||||
|
: kSecondColor;
|
||||||
|
final roundedValue =
|
||||||
|
visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
weatherData == null ? const Center(child: Text("Aucune donnée à afficher")) : Container( // TODO translate ?
|
weatherData == null || _days.isEmpty
|
||||||
decoration: BoxDecoration(
|
? const Center(child: Text("Aucune donnée météo"))
|
||||||
borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0)),
|
: _buildContent(appContext, primaryColor, roundedValue),
|
||||||
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")),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 35,
|
top: 35,
|
||||||
left: 10,
|
left: 10,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () => Navigator.of(context).pop(),
|
||||||
Navigator.of(context).pop();
|
child: Container(
|
||||||
},
|
decoration: BoxDecoration(color: primaryColor, shape: BoxShape.circle),
|
||||||
child: Container(
|
child: const Icon(Icons.arrow_back, size: 23, color: Colors.white),
|
||||||
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!));
|
Uint8List fileData = await http.readBytes(Uri.parse(mapDTO.iconSource!));
|
||||||
selectedMarkerIcon = resizeImage(fileData, 40);
|
selectedMarkerIcon = resizeImage(fileData, 40);
|
||||||
} else {
|
} else {
|
||||||
File? localIcon = await _checkIfLocalResourceExists(visitAppContext, mapDTO.iconResourceId!);
|
File? localIcon = mapDTO.iconResourceId != null
|
||||||
|
? await _checkIfLocalResourceExists(visitAppContext, mapDTO.iconResourceId!)
|
||||||
|
: null;
|
||||||
if(localIcon == null) {
|
if(localIcon == null) {
|
||||||
final ByteData imageData = await NetworkAssetBundle(Uri.parse(mapDTO.iconSource!)).load("");
|
final ByteData imageData = await NetworkAssetBundle(Uri.parse(mapDTO.iconSource!)).load("");
|
||||||
selectedMarkerIcon = await getBytesFromAsset(imageData, 50);
|
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({
|
Future<AssistantResponse> chat({
|
||||||
required String message,
|
required String message,
|
||||||
String? configurationId,
|
String? configurationId,
|
||||||
|
}) => chatWithAppType(message: message, configurationId: configurationId);
|
||||||
|
|
||||||
|
Future<AssistantResponse> chatWithAppType({
|
||||||
|
required String message,
|
||||||
|
String? configurationId,
|
||||||
|
AppType appType = AppType.Mobile,
|
||||||
|
bool isVoice = false,
|
||||||
}) async {
|
}) async {
|
||||||
final request = AiChatRequest(
|
final request = AiChatRequest(
|
||||||
message: message,
|
message: message,
|
||||||
instanceId: visitAppContext.instanceId,
|
instanceId: visitAppContext.instanceId,
|
||||||
appType: AppType.Mobile,
|
appType: appType,
|
||||||
configurationId: configurationId,
|
configurationId: configurationId,
|
||||||
language: visitAppContext.language?.toUpperCase() ?? 'FR',
|
language: visitAppContext.language?.toUpperCase() ?? 'FR',
|
||||||
history: List.from(history),
|
history: List.from(history),
|
||||||
|
isVoice: isVoice,
|
||||||
);
|
);
|
||||||
|
|
||||||
final response = await visitAppContext.clientAPI.aiApi!.aiChat(request);
|
final response = await visitAppContext.clientAPI.aiApi!.aiChat(request);
|
||||||
|
|||||||
@ -148,7 +148,7 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
|
|||||||
if(sections!.isNotEmpty) {
|
if(sections!.isNotEmpty) {
|
||||||
|
|
||||||
List<SectionDTO> sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, widget.configuration.id!);
|
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!));
|
sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!));
|
||||||
int newOrder = 0;
|
int newOrder = 0;
|
||||||
@ -390,7 +390,7 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
|
|||||||
if(sections!.isNotEmpty) {
|
if(sections!.isNotEmpty) {
|
||||||
|
|
||||||
List<SectionDTO> sectionsInDB = await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, configuration.id!);
|
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!));
|
sectionsToKeep.sort((a,b) => a.order!.compareTo(b.order!));
|
||||||
int newOrder = 0;
|
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');
|
.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 {
|
static Future<void> _showLocalNotification(RemoteMessage message) async {
|
||||||
final notification = message.notification;
|
final notification = message.notification;
|
||||||
if (notification == null) return;
|
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';
|
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
|
// 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
|
// 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',
|
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/Models/articleRead.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
|
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/splash_screen.dart';
|
import 'package:mymuseum_visitapp/Screens/splash_screen.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/flutter_tts_engine.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/Glasses/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/Services/pushNotificationService.dart';
|
||||||
import 'package:mymuseum_visitapp/l10n/app_localizations.dart';
|
import 'package:mymuseum_visitapp/l10n/app_localizations.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@ -75,6 +86,22 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
|||||||
|
|
||||||
localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey ?? (kApiKey.isNotEmpty ? kApiKey : null));
|
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
|
// Push notifications — subscribe to instance topic if enabled
|
||||||
if (!Platform.isWindows && localContext.instanceId != null) {
|
if (!Platform.isWindows && localContext.instanceId != null) {
|
||||||
try {
|
try {
|
||||||
@ -87,6 +114,13 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
|||||||
localContext.localPath = localPath;
|
localContext.localPath = localPath;
|
||||||
print("Local path $localPath");
|
print("Local path $localPath");
|
||||||
|
|
||||||
|
// Glasses init — initialize only here, startSession() est déclenché dans _MyAppState
|
||||||
|
// 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(
|
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||||
systemNavigationBarColor: Colors.transparent,
|
systemNavigationBarColor: Colors.transparent,
|
||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
@ -116,7 +150,76 @@ class MyApp extends StatefulWidget {
|
|||||||
_MyAppState createState() => _MyAppState();
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@ -35,7 +35,14 @@ List<Translation> translations = [
|
|||||||
"thursday": "Jeudi",
|
"thursday": "Jeudi",
|
||||||
"friday": "Vendredi",
|
"friday": "Vendredi",
|
||||||
"saturday": "Samedi",
|
"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: {
|
Translation(language: "EN", data: {
|
||||||
"visitTitle": "List of tours",
|
"visitTitle": "List of tours",
|
||||||
@ -71,7 +78,14 @@ List<Translation> translations = [
|
|||||||
"thursday": "Thursday",
|
"thursday": "Thursday",
|
||||||
"friday": "Friday",
|
"friday": "Friday",
|
||||||
"saturday": "Saturday",
|
"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: {
|
Translation(language: "DE", data: {
|
||||||
"visitTitle": "Liste der Touren",
|
"visitTitle": "Liste der Touren",
|
||||||
@ -107,7 +121,14 @@ List<Translation> translations = [
|
|||||||
"thursday": "Donnerstag",
|
"thursday": "Donnerstag",
|
||||||
"friday": "Freitag",
|
"friday": "Freitag",
|
||||||
"saturday": "Samstag",
|
"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: {
|
Translation(language: "NL", data: {
|
||||||
"visitTitle": "Lijst met rondleidingen",
|
"visitTitle": "Lijst met rondleidingen",
|
||||||
@ -143,7 +164,14 @@ List<Translation> translations = [
|
|||||||
"thursday": "Donderdag",
|
"thursday": "Donderdag",
|
||||||
"friday": "Vrijdag",
|
"friday": "Vrijdag",
|
||||||
"saturday": "Zaterdag",
|
"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: {
|
Translation(language: "IT", data: {
|
||||||
"visitTitle": "Elenco dei tour",
|
"visitTitle": "Elenco dei tour",
|
||||||
@ -179,7 +207,14 @@ List<Translation> translations = [
|
|||||||
"thursday": "Giovedì",
|
"thursday": "Giovedì",
|
||||||
"friday": "Venerdì",
|
"friday": "Venerdì",
|
||||||
"saturday": "Sabato",
|
"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: {
|
Translation(language: "ES", data: {
|
||||||
"visitTitle": "Lista de recorridos",
|
"visitTitle": "Lista de recorridos",
|
||||||
@ -215,7 +250,14 @@ List<Translation> translations = [
|
|||||||
"thursday": "Jueves",
|
"thursday": "Jueves",
|
||||||
"friday": "Viernes",
|
"friday": "Viernes",
|
||||||
"saturday": "Sábado",
|
"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: {
|
Translation(language: "PL", data: {
|
||||||
"visitTitle": "Lista wycieczek",
|
"visitTitle": "Lista wycieczek",
|
||||||
@ -251,7 +293,14 @@ List<Translation> translations = [
|
|||||||
"thursday": "Czwartek",
|
"thursday": "Czwartek",
|
||||||
"friday": "Piątek",
|
"friday": "Piątek",
|
||||||
"saturday": "Sobota",
|
"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: {
|
Translation(language: "CN", data: {
|
||||||
"visitTitle": "旅游清单",
|
"visitTitle": "旅游清单",
|
||||||
@ -287,7 +336,14 @@ List<Translation> translations = [
|
|||||||
"thursday": "星期四",
|
"thursday": "星期四",
|
||||||
"friday": "星期五",
|
"friday": "星期五",
|
||||||
"saturday": "星期六",
|
"saturday": "星期六",
|
||||||
"sunday": "星期日"
|
"sunday": "星期日",
|
||||||
|
"beaconFound": "附近内容",
|
||||||
|
"beaconFoundBody": "在您附近发现了内容。",
|
||||||
|
"voice.noConfig": "请先在应用中选择一个参观。",
|
||||||
|
"voice.noQrFound": "我没有找到任何二维码。",
|
||||||
|
"voice.photoCaptured": "照片已拍摄。",
|
||||||
|
"voice.photoFailed": "我无法拍摄照片。",
|
||||||
|
"voice.cameraUnavailable": "我无法访问摄像头。"
|
||||||
}),
|
}),
|
||||||
Translation(language: "UK", data: {
|
Translation(language: "UK", data: {
|
||||||
"visitTitle": "Список турів",
|
"visitTitle": "Список турів",
|
||||||
@ -323,7 +379,14 @@ List<Translation> translations = [
|
|||||||
"thursday": "Четвер",
|
"thursday": "Четвер",
|
||||||
"friday": "П'ятниця",
|
"friday": "П'ятниця",
|
||||||
"saturday": "Субота",
|
"saturday": "Субота",
|
||||||
"sunday": "Неділя"
|
"sunday": "Неділя",
|
||||||
|
"beaconFound": "Контент поруч",
|
||||||
|
"beaconFoundBody": "Поруч з вами виявлено контент.",
|
||||||
|
"voice.noConfig": "Спочатку виберіть відвідування в застосунку.",
|
||||||
|
"voice.noQrFound": "Я не знайшов жодного QR-коду.",
|
||||||
|
"voice.photoCaptured": "Фото зроблено.",
|
||||||
|
"voice.photoFailed": "Мені не вдалося зробити фото.",
|
||||||
|
"voice.cameraUnavailable": "Я не можу отримати доступ до камери."
|
||||||
}),
|
}),
|
||||||
Translation(language: "AR", data: {
|
Translation(language: "AR", data: {
|
||||||
"visitTitle": "قائمة الجولات",
|
"visitTitle": "قائمة الجولات",
|
||||||
@ -359,6 +422,13 @@ List<Translation> translations = [
|
|||||||
"thursday": "الخميس",
|
"thursday": "الخميس",
|
||||||
"friday": "الجمعة",
|
"friday": "الجمعة",
|
||||||
"saturday": "السبت",
|
"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 "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_sound/flutter_sound_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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 =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_sound
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
102
pubspec.lock
102
pubspec.lock
@ -65,6 +65,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
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:
|
benchmark:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -181,10 +213,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -390,6 +422,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.2"
|
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:
|
flutter_inappwebview:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -515,6 +555,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.26"
|
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:
|
flutter_staggered_grid_view:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -536,6 +600,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -936,26 +1008,34 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
mgrs_dart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1501,10 +1581,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.11"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1858,5 +1938,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.0-0 <4.0.0"
|
dart: ">=3.10.0-0 <4.0.0"
|
||||||
flutter: ">=3.24.0"
|
flutter: ">=3.24.0"
|
||||||
|
|||||||
13
pubspec.yaml
13
pubspec.yaml
@ -61,7 +61,7 @@ dependencies:
|
|||||||
http: ^1.2.0
|
http: ^1.2.0
|
||||||
sqflite: #not in web
|
sqflite: #not in web
|
||||||
just_audio_cache: ^0.1.2 #not in web
|
just_audio_cache: ^0.1.2 #not in web
|
||||||
#flutter_beacon: ^0.5.1 #not in web
|
beacon_scanner: ^0.0.4 #not in web
|
||||||
flutter_staggered_grid_view: ^0.7.0
|
flutter_staggered_grid_view: ^0.7.0
|
||||||
|
|
||||||
smooth_page_indicator: ^1.2.1
|
smooth_page_indicator: ^1.2.1
|
||||||
@ -81,6 +81,14 @@ dependencies:
|
|||||||
firebase_messaging: ^15.1.3
|
firebase_messaging: ^15.1.3
|
||||||
flutter_local_notifications: ^17.2.2
|
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:
|
manager_api_new:
|
||||||
path: ../manager-app/manager_api_new
|
path: ../manager-app/manager_api_new
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
@ -100,6 +108,7 @@ dev_dependencies:
|
|||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^1.0.0
|
flutter_lints: ^1.0.0
|
||||||
|
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
@ -119,6 +128,8 @@ flutter:
|
|||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/images/old/
|
- assets/images/old/
|
||||||
- assets/files/
|
- assets/files/
|
||||||
|
- assets/wake_words/
|
||||||
|
- assets/sounds/
|
||||||
#- assets/animations/
|
#- assets/animations/
|
||||||
#- assets/files/
|
#- assets/files/
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user