multi wakeword + misc
This commit is contained in:
parent
c6526046c8
commit
0f2b4ed6bd
@ -61,13 +61,12 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
when (call.method) {
|
when (call.method) {
|
||||||
"start" -> {
|
"start" -> {
|
||||||
requestBatteryOptimizationExemption()
|
requestBatteryOptimizationExemption()
|
||||||
val modelName = call.argument<String>("modelName")
|
val modelNames = call.argument<String>("modelNames")
|
||||||
?: WakeWordService.DEFAULT_MODEL
|
?: WakeWordService.DEFAULT_MODEL
|
||||||
// activeInstance dans WakeWordService gère le cleanup de l'instance précédente
|
|
||||||
startService(
|
startService(
|
||||||
Intent(this, WakeWordService::class.java)
|
Intent(this, WakeWordService::class.java)
|
||||||
.setAction(WakeWordService.ACTION_START)
|
.setAction(WakeWordService.ACTION_START)
|
||||||
.putExtra(WakeWordService.EXTRA_MODEL_NAME, modelName)
|
.putExtra(WakeWordService.EXTRA_MODEL_NAMES, modelNames)
|
||||||
.putExtra(WakeWordService.EXTRA_CACHE_DIR, wakeWordCacheDir)
|
.putExtra(WakeWordService.EXTRA_CACHE_DIR, wakeWordCacheDir)
|
||||||
)
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
|
|||||||
@ -50,7 +50,7 @@ class WakeWordService : Service() {
|
|||||||
private var activeInstance: WakeWordService? = null
|
private var activeInstance: WakeWordService? = null
|
||||||
const val EVENT_ACTION = "be.unov.mymuseum.WAKE_WORD_EVENT"
|
const val EVENT_ACTION = "be.unov.mymuseum.WAKE_WORD_EVENT"
|
||||||
const val EXTRA_EVENT = "event"
|
const val EXTRA_EVENT = "event"
|
||||||
const val EXTRA_MODEL_NAME = "modelName"
|
const val EXTRA_MODEL_NAMES = "modelNames" // virgule-séparé : "hey_alba,hey_marco,hey_vasco"
|
||||||
const val DEFAULT_MODEL = "hey_visit"
|
const val DEFAULT_MODEL = "hey_visit"
|
||||||
const val EXTRA_CACHE_DIR = "cacheDir"
|
const val EXTRA_CACHE_DIR = "cacheDir"
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ class WakeWordService : Service() {
|
|||||||
private const val MEL_STRIDE = 8
|
private const val MEL_STRIDE = 8
|
||||||
private const val EMBEDDING_DIM = 96
|
private const val EMBEDDING_DIM = 96
|
||||||
private const val N_EMBEDDING_FRAMES = 16
|
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 DETECTION_THRESHOLD = 0.05f // Abaissé pour meilleure détection prononciation FR
|
||||||
private const val COOLDOWN_MS = 2000L
|
private const val COOLDOWN_MS = 2000L
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,12 +75,12 @@ class WakeWordService : Service() {
|
|||||||
private var ortEnv: OrtEnvironment? = null
|
private var ortEnv: OrtEnvironment? = null
|
||||||
private var melSession: OrtSession? = null
|
private var melSession: OrtSession? = null
|
||||||
private var embeddingInterpreter: Interpreter? = null
|
private var embeddingInterpreter: Interpreter? = null
|
||||||
private var classifierInterpreter: Interpreter? = null
|
private val classifiers = mutableMapOf<String, Interpreter>() // modelName → interpréteur
|
||||||
|
private val lastDetectionTimes = mutableMapOf<String, Long>() // cooldown par modèle
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
@Volatile private var requestedPause = false // demande depuis main thread
|
@Volatile private var requestedPause = false
|
||||||
private var isPaused = false // état réel — géré uniquement par le thread capture
|
private var isPaused = false
|
||||||
private var lastDetectionTime = 0L
|
private var modelNames: List<String> = listOf(DEFAULT_MODEL)
|
||||||
private var modelName = DEFAULT_MODEL
|
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
private val melBuffer = ArrayDeque<FloatArray>()
|
private val melBuffer = ArrayDeque<FloatArray>()
|
||||||
@ -104,7 +104,8 @@ class WakeWordService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
activeInstance = this
|
activeInstance = this
|
||||||
modelName = intent.getStringExtra(EXTRA_MODEL_NAME) ?: DEFAULT_MODEL
|
modelNames = (intent.getStringExtra(EXTRA_MODEL_NAMES) ?: DEFAULT_MODEL)
|
||||||
|
.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
modelCacheDir = intent.getStringExtra(EXTRA_CACHE_DIR) ?: ""
|
modelCacheDir = intent.getStringExtra(EXTRA_CACHE_DIR) ?: ""
|
||||||
startDetection()
|
startDetection()
|
||||||
}
|
}
|
||||||
@ -175,7 +176,9 @@ class WakeWordService : Service() {
|
|||||||
melSession?.close(); melSession = null
|
melSession?.close(); melSession = null
|
||||||
ortEnv?.close(); ortEnv = null
|
ortEnv?.close(); ortEnv = null
|
||||||
embeddingInterpreter?.close(); embeddingInterpreter = null
|
embeddingInterpreter?.close(); embeddingInterpreter = null
|
||||||
classifierInterpreter?.close(); classifierInterpreter = null
|
classifiers.values.forEach { it.close() }
|
||||||
|
classifiers.clear()
|
||||||
|
lastDetectionTimes.clear()
|
||||||
melBuffer.clear(); embeddingBuffer.clear()
|
melBuffer.clear(); embeddingBuffer.clear()
|
||||||
audioManager = null
|
audioManager = null
|
||||||
if (wakeLock?.isHeld == true) wakeLock?.release()
|
if (wakeLock?.isHeld == true) wakeLock?.release()
|
||||||
@ -192,9 +195,11 @@ class WakeWordService : Service() {
|
|||||||
|
|
||||||
// embedding + classifieur : TFLite (formes statiques, pas de problème)
|
// embedding + classifieur : TFLite (formes statiques, pas de problème)
|
||||||
embeddingInterpreter = loadTflite("embedding_model.tflite")
|
embeddingInterpreter = loadTflite("embedding_model.tflite")
|
||||||
classifierInterpreter = loadTflite("$modelName.tflite")
|
classifiers.clear()
|
||||||
|
for (name in modelNames) {
|
||||||
Log.d(TAG, "Models loaded — mel=ONNX, embedding+classifier=TFLite, model=$modelName")
|
classifiers[name] = loadTflite("$name.tflite")
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Models loaded — mel=ONNX, embedding=TFLite, classifiers=${modelNames}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadTflite(assetName: String): Interpreter {
|
private fun loadTflite(assetName: String): Interpreter {
|
||||||
@ -326,6 +331,8 @@ class WakeWordService : Service() {
|
|||||||
// ── Main loop ──────────────────────────────────────────────────────────────
|
// ── Main loop ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private var debugFrameCount = 0
|
private var debugFrameCount = 0
|
||||||
|
private val scoreHistory = mutableMapOf<String, FloatArray>() // 50 derniers scores par modèle
|
||||||
|
private val scoreHistoryPos = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
private fun runLoop() {
|
private fun runLoop() {
|
||||||
val pcm = ShortArray(CHUNK_SAMPLES)
|
val pcm = ShortArray(CHUNK_SAMPLES)
|
||||||
@ -367,15 +374,34 @@ class WakeWordService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (embeddingBuffer.size == N_EMBEDDING_FRAMES) {
|
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()
|
val now = SystemClock.elapsedRealtime()
|
||||||
if (score >= DETECTION_THRESHOLD && (now - lastDetectionTime) > COOLDOWN_MS) {
|
for ((name, interpreter) in classifiers) {
|
||||||
lastDetectionTime = now
|
val score = runClassifier(interpreter)
|
||||||
Log.d(TAG, "Wake word detected! score=$score model=$modelName")
|
|
||||||
broadcast("detected")
|
// Accumule les scores pour les stats
|
||||||
|
val hist = scoreHistory.getOrPut(name) { FloatArray(50) }
|
||||||
|
val pos = scoreHistoryPos.getOrDefault(name, 0)
|
||||||
|
hist[pos % 50] = score
|
||||||
|
scoreHistoryPos[name] = pos + 1
|
||||||
|
|
||||||
|
val lastTime = lastDetectionTimes[name] ?: 0L
|
||||||
|
if (score >= DETECTION_THRESHOLD && (now - lastTime) > COOLDOWN_MS) {
|
||||||
|
lastDetectionTimes[name] = now
|
||||||
|
Log.d(TAG, "Wake word detected! score=$score model=$name")
|
||||||
|
broadcast("detected:$name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats toutes les 50 itérations (~4s)
|
||||||
|
if (debugFrameCount % 50 == 0) {
|
||||||
|
for ((name, hist) in scoreHistory) {
|
||||||
|
val filled = minOf(scoreHistoryPos[name] ?: 0, 50)
|
||||||
|
if (filled == 0) continue
|
||||||
|
val window = hist.take(filled)
|
||||||
|
val avg = window.average()
|
||||||
|
val max = window.max()
|
||||||
|
Log.d(TAG, "STATS $name — avg=${"%.4f".format(avg)} max=${"%.4f".format(max)} threshold=$DETECTION_THRESHOLD")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -454,12 +480,12 @@ class WakeWordService : Service() {
|
|||||||
|
|
||||||
// ── Step 3 : classifier TFLite ────────────────────────────────────────────
|
// ── Step 3 : classifier TFLite ────────────────────────────────────────────
|
||||||
|
|
||||||
private fun runClassifier(): Float {
|
private fun runClassifier(interpreter: Interpreter): Float {
|
||||||
val inputBuf = ByteBuffer.allocateDirect(4 * N_EMBEDDING_FRAMES * EMBEDDING_DIM).order(ByteOrder.nativeOrder())
|
val inputBuf = ByteBuffer.allocateDirect(4 * N_EMBEDDING_FRAMES * EMBEDDING_DIM).order(ByteOrder.nativeOrder())
|
||||||
for (embedding in embeddingBuffer) for (v in embedding) inputBuf.putFloat(v)
|
for (embedding in embeddingBuffer) for (v in embedding) inputBuf.putFloat(v)
|
||||||
inputBuf.rewind()
|
inputBuf.rewind()
|
||||||
val output = Array(1) { FloatArray(1) }
|
val output = Array(1) { FloatArray(1) }
|
||||||
classifierInterpreter!!.run(inputBuf, output)
|
interpreter.run(inputBuf, output)
|
||||||
return output[0][0]
|
return output[0][0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
assets/files/hey_alba.onnx
Normal file
BIN
assets/files/hey_alba.onnx
Normal file
Binary file not shown.
BIN
assets/files/hey_alba.tflite
Normal file
BIN
assets/files/hey_alba.tflite
Normal file
Binary file not shown.
BIN
assets/files/hey_marco.onnx
Normal file
BIN
assets/files/hey_marco.onnx
Normal file
Binary file not shown.
BIN
assets/files/hey_marco.tflite
Normal file
BIN
assets/files/hey_marco.tflite
Normal file
Binary file not shown.
BIN
assets/files/hey_vasco.onnx
Normal file
BIN
assets/files/hey_vasco.onnx
Normal file
Binary file not shown.
BIN
assets/files/hey_vasco.tflite
Normal file
BIN
assets/files/hey_vasco.tflite
Normal file
Binary file not shown.
@ -128,7 +128,7 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
|
|||||||
MetaGlassesService.instance.isConnected &&
|
MetaGlassesService.instance.isConnected &&
|
||||||
response.reply.isNotEmpty) {
|
response.reply.isNotEmpty) {
|
||||||
final lang = widget.visitAppContext.language ?? 'FR';
|
final lang = widget.visitAppContext.language ?? 'FR';
|
||||||
activeOrchestrator?.ttsEngine.speak(
|
activeVoiceOrchestrator?.ttsEngine.speak(
|
||||||
response.reply,
|
response.reply,
|
||||||
languageCode: _toLangCode(lang),
|
languageCode: _toLangCode(lang),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:meta_wearables_dat/meta_wearables_dat.dart';
|
import 'package:meta_wearables_dat/meta_wearables_dat.dart';
|
||||||
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
|
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
|
||||||
@ -179,7 +180,7 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
|
|||||||
label: 'Test TTS',
|
label: 'Test TTS',
|
||||||
icon: Icons.volume_up,
|
icon: Icons.volume_up,
|
||||||
onTap: () => _run('TTS test', () async {
|
onTap: () => _run('TTS test', () async {
|
||||||
final o = activeOrchestrator;
|
final o = activeVoiceOrchestrator;
|
||||||
if (o == null) {
|
if (o == null) {
|
||||||
_addLog('⚠ Orchestrateur non initialisé');
|
_addLog('⚠ Orchestrateur non initialisé');
|
||||||
return;
|
return;
|
||||||
@ -227,13 +228,13 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
|
|||||||
),
|
),
|
||||||
const Divider(color: Colors.grey),
|
const Divider(color: Colors.grey),
|
||||||
// Transcription en direct
|
// Transcription en direct
|
||||||
if (activeOrchestrator != null)
|
if (activeVoiceOrchestrator != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
child: ValueListenableBuilder<bool>(
|
child: ValueListenableBuilder<bool>(
|
||||||
valueListenable: activeOrchestrator!.isListeningForCommand,
|
valueListenable: activeVoiceOrchestrator!.isListeningForCommand,
|
||||||
builder: (_, listening, __) => ValueListenableBuilder<String>(
|
builder: (_, listening, __) => ValueListenableBuilder<String>(
|
||||||
valueListenable: activeOrchestrator!.lastTranscription,
|
valueListenable: activeVoiceOrchestrator!.lastTranscription,
|
||||||
builder: (_, text, __) => Container(
|
builder: (_, text, __) => Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
@ -282,11 +283,11 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Dernier texte TTS
|
// Dernier texte TTS
|
||||||
if (activeOrchestrator != null)
|
if (activeVoiceOrchestrator != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
child: ValueListenableBuilder<String>(
|
child: ValueListenableBuilder<String>(
|
||||||
valueListenable: activeOrchestrator!.lastTtsText,
|
valueListenable: activeVoiceOrchestrator!.lastTtsText,
|
||||||
builder: (_, text, __) => text.isEmpty
|
builder: (_, text, __) => text.isEmpty
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Container(
|
: Container(
|
||||||
@ -316,6 +317,84 @@ class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Photos QR scan
|
||||||
|
if (activeVoiceOrchestrator != null)
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: activeVoiceOrchestrator!.lastQrScanPhoto,
|
||||||
|
builder: (_, path, __) {
|
||||||
|
final exists = path != null && File(path).existsSync();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
const Icon(Icons.qr_code_scanner, color: Colors.orange, size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Text('Dernier scan QR',
|
||||||
|
style: TextStyle(color: Colors.orange, fontSize: 11)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
exists
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Image.file(File(path!), height: 120, fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => const Text('Image non disponible',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 11))),
|
||||||
|
)
|
||||||
|
: const Text('Aucun scan effectué',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 11)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Photos visite
|
||||||
|
if (activeVoiceOrchestrator != null)
|
||||||
|
ValueListenableBuilder<List<String>>(
|
||||||
|
valueListenable: activeVoiceOrchestrator!.visitPhotosNotifier,
|
||||||
|
builder: (_, photos, __) {
|
||||||
|
if (photos.isEmpty) return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
child: Row(children: [
|
||||||
|
const Icon(Icons.photo_library, color: Colors.green, size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Text('Photos visite — aucune photo',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 11)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
const Icon(Icons.photo_library, color: Colors.green, size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('Photos visite (${photos.length})',
|
||||||
|
style: const TextStyle(color: Colors.green, fontSize: 11)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SizedBox(
|
||||||
|
height: 80,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: photos.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 4),
|
||||||
|
itemBuilder: (_, i) => ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Image.file(File(photos[i]), height: 80, width: 80,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(
|
||||||
|
width: 80, height: 80, color: Colors.grey[800])),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
const Divider(color: Colors.grey),
|
const Divider(color: Colors.grey),
|
||||||
// Log
|
// Log
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
86
lib/Components/GlassesStatusWidget.dart
Normal file
86
lib/Components/GlassesStatusWidget.dart
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/voice_controller.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
/// Icône circulaire positionnée en haut à droite de l'écran d'accueil.
|
||||||
|
///
|
||||||
|
/// Visible si :
|
||||||
|
/// - Les lunettes sont connectées (quel que soit le mode)
|
||||||
|
/// - OU un mode vocal est actif (voiceOnly ou glasses)
|
||||||
|
///
|
||||||
|
/// Tap → ouvre VoiceModeSheet.
|
||||||
|
class GlassesStatusWidget extends StatelessWidget {
|
||||||
|
final VisitAppContext visitAppContext;
|
||||||
|
|
||||||
|
const GlassesStatusWidget({super.key, required this.visitAppContext});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<VoiceController>(
|
||||||
|
builder: (context, controller, _) {
|
||||||
|
return ValueListenableBuilder<GlassesState>(
|
||||||
|
valueListenable: MetaGlassesService.instance.state,
|
||||||
|
builder: (context, glassesState, _) {
|
||||||
|
final isConnected = glassesState == GlassesState.connected ||
|
||||||
|
glassesState == GlassesState.streaming;
|
||||||
|
final modeActive = controller.mode != VoiceMode.none;
|
||||||
|
final featureAvailable = controller.isFeatureAvailable(visitAppContext);
|
||||||
|
|
||||||
|
if (!featureAvailable && !isConnected && !modeActive) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final Color iconColor = switch ((isConnected, controller.mode)) {
|
||||||
|
(true, VoiceMode.glasses) => Colors.greenAccent,
|
||||||
|
(_, VoiceMode.voiceOnly) => Colors.lightBlueAccent,
|
||||||
|
(true, _) => Colors.white70,
|
||||||
|
_ => Colors.white38,
|
||||||
|
};
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => VoiceModeSheet.show(context, visitAppContext: visitAppContext),
|
||||||
|
child: Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
controller.mode == VoiceMode.voiceOnly
|
||||||
|
? Icons.mic
|
||||||
|
: Icons.remove_red_eye_outlined,
|
||||||
|
color: iconColor,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
// Point vert en bas à droite si mode actif
|
||||||
|
if (modeActive)
|
||||||
|
Positioned(
|
||||||
|
bottom: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: controller.mode == VoiceMode.glasses
|
||||||
|
? Colors.greenAccent
|
||||||
|
: Colors.lightBlueAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.black54, width: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -67,11 +67,19 @@ class _SliderImagesWidget extends State<SliderImagesWidget> {
|
|||||||
enlargeCenterPage: true,
|
enlargeCenterPage: true,
|
||||||
reverse: false,
|
reverse: false,
|
||||||
),
|
),
|
||||||
items: resourcesInWidget.map<Widget>((i) {
|
items: resourcesInWidget.asMap().entries.map<Widget>((entry) {
|
||||||
|
int idx = entry.key;
|
||||||
|
ResourceModel? i = entry.value;
|
||||||
return Builder(
|
return Builder(
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
AppContext appContext = Provider.of<AppContext>(context);
|
AppContext appContext = Provider.of<AppContext>(context);
|
||||||
ContentDTO contentDTO = ContentDTO(resourceId: i!.id, resource: ResourceDTO(id: i.id, type: i.type, label: i.label, url: i.source));
|
ContentDTO? originalContentDTO = idx < widget.contentsDTO.length ? widget.contentsDTO[idx] : null;
|
||||||
|
ContentDTO contentDTO = ContentDTO(
|
||||||
|
resourceId: i!.id,
|
||||||
|
title: originalContentDTO?.title,
|
||||||
|
description: originalContentDTO?.description,
|
||||||
|
resource: ResourceDTO(id: i.id, type: i.type, label: i.label, url: i.source),
|
||||||
|
);
|
||||||
var resourcetoShow = getElementForResource(context, appContext, contentDTO, true);
|
var resourcetoShow = getElementForResource(context, appContext, contentDTO, true);
|
||||||
return resourcetoShow;
|
return resourcetoShow;
|
||||||
//print(widget.imagesDTO[currentIndex-1]);
|
//print(widget.imagesDTO[currentIndex-1]);
|
||||||
|
|||||||
247
lib/Components/VoiceModeSheet.dart
Normal file
247
lib/Components/VoiceModeSheet.dart
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/voice_controller.dart';
|
||||||
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
/// Bottom sheet de sélection et gestion du mode vocal.
|
||||||
|
///
|
||||||
|
/// Accessible depuis GlassesStatusWidget (icône top-right) ou depuis
|
||||||
|
/// les settings. Affiche :
|
||||||
|
/// - État de connexion des lunettes (avec bouton connect/disconnect)
|
||||||
|
/// - Tuiles de sélection de mode (vocal seul / lunettes)
|
||||||
|
/// - Bouton de désactivation si un mode est actif
|
||||||
|
class VoiceModeSheet extends StatelessWidget {
|
||||||
|
final VisitAppContext visitAppContext;
|
||||||
|
|
||||||
|
const VoiceModeSheet({super.key, required this.visitAppContext});
|
||||||
|
|
||||||
|
static Future<void> show(BuildContext context, {required VisitAppContext visitAppContext}) {
|
||||||
|
return showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => VoiceModeSheet(visitAppContext: visitAppContext),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<VoiceController>(
|
||||||
|
builder: (context, controller, _) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF1E1E2E),
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Handle
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40, height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white24,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Titre
|
||||||
|
const Text(
|
||||||
|
'Assistant vocal',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Section lunettes — toujours visible
|
||||||
|
_GlassesConnectionTile(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Modes — uniquement si isAssistant
|
||||||
|
if (controller.isFeatureAvailable(visitAppContext)) ...[
|
||||||
|
const Text(
|
||||||
|
'Mode',
|
||||||
|
style: TextStyle(color: Colors.white54, fontSize: 12, letterSpacing: 1),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_ModeTile(
|
||||||
|
icon: Icons.mic,
|
||||||
|
label: 'Mode vocal',
|
||||||
|
subtitle: 'Micro du téléphone',
|
||||||
|
targetMode: VoiceMode.voiceOnly,
|
||||||
|
controller: controller,
|
||||||
|
visitAppContext: visitAppContext,
|
||||||
|
enabled: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ValueListenableBuilder<GlassesState>(
|
||||||
|
valueListenable: MetaGlassesService.instance.state,
|
||||||
|
builder: (context, glassesState, _) {
|
||||||
|
final connected = glassesState == GlassesState.connected ||
|
||||||
|
glassesState == GlassesState.streaming;
|
||||||
|
return _ModeTile(
|
||||||
|
icon: Icons.remove_red_eye_outlined,
|
||||||
|
label: 'Mode lunettes',
|
||||||
|
subtitle: connected ? 'Ray-Ban Meta connectées' : 'Lunettes non connectées',
|
||||||
|
targetMode: VoiceMode.glasses,
|
||||||
|
controller: controller,
|
||||||
|
visitAppContext: visitAppContext,
|
||||||
|
enabled: connected,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Bouton désactiver
|
||||||
|
if (controller.mode != VoiceMode.none)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: controller.isActivating ? null : () => controller.deactivate(),
|
||||||
|
icon: const Icon(Icons.stop_circle_outlined, color: Colors.redAccent),
|
||||||
|
label: const Text('Désactiver', style: TextStyle(color: Colors.redAccent)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.redAccent),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
"L'assistant vocal n'est pas activé pour cette instance.",
|
||||||
|
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GlassesConnectionTile extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ValueListenableBuilder<GlassesState>(
|
||||||
|
valueListenable: MetaGlassesService.instance.state,
|
||||||
|
builder: (context, state, _) {
|
||||||
|
final label = switch (state) {
|
||||||
|
GlassesState.connected => 'Connectées',
|
||||||
|
GlassesState.streaming => 'Connectées (streaming)',
|
||||||
|
GlassesState.connecting => 'Connexion...',
|
||||||
|
GlassesState.disconnected => 'Non connectées',
|
||||||
|
};
|
||||||
|
final color = (state == GlassesState.connected || state == GlassesState.streaming)
|
||||||
|
? Colors.greenAccent
|
||||||
|
: state == GlassesState.connecting
|
||||||
|
? Colors.orangeAccent
|
||||||
|
: Colors.white38;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white10,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.remove_red_eye_outlined, color: color, size: 22),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Ray-Ban Meta', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
|
||||||
|
Text(label, style: TextStyle(color: color, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state == GlassesState.disconnected)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => MetaGlassesService.instance.connect(),
|
||||||
|
child: const Text('Connecter', style: TextStyle(color: kMainColor1)),
|
||||||
|
)
|
||||||
|
else if (state == GlassesState.connected || state == GlassesState.streaming)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => MetaGlassesService.instance.disconnect(),
|
||||||
|
child: const Text('Déconnecter', style: TextStyle(color: Colors.white54)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModeTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String subtitle;
|
||||||
|
final VoiceMode targetMode;
|
||||||
|
final VoiceController controller;
|
||||||
|
final VisitAppContext visitAppContext;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
const _ModeTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.targetMode,
|
||||||
|
required this.controller,
|
||||||
|
required this.visitAppContext,
|
||||||
|
required this.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isActive = controller.mode == targetMode;
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive ? kMainColor1.withValues(alpha: 0.2) : Colors.white10,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isActive ? kMainColor1 : Colors.transparent,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(icon, color: isActive ? kMainColor1 : enabled ? Colors.white70 : Colors.white30),
|
||||||
|
title: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: enabled ? Colors.white : Colors.white38,
|
||||||
|
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(subtitle, style: TextStyle(color: enabled ? Colors.white54 : Colors.white24, fontSize: 12)),
|
||||||
|
trailing: isActive
|
||||||
|
? const Icon(Icons.check_circle, color: kMainColor1)
|
||||||
|
: enabled
|
||||||
|
? const Icon(Icons.chevron_right, color: Colors.white38)
|
||||||
|
: const Icon(Icons.lock_outline, color: Colors.white24, size: 18),
|
||||||
|
onTap: (enabled && !controller.isActivating && !isActive)
|
||||||
|
? () => controller.activate(targetMode, visitAppContext)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,7 +19,7 @@ enum DatabaseTableType {
|
|||||||
|
|
||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
static const _databaseName = "visit_database.db";
|
static const _databaseName = "visit_database.db";
|
||||||
static const _databaseVersion = 1;
|
static const _databaseVersion = 3;
|
||||||
|
|
||||||
static const mainTable = 'visitAppContext';
|
static const mainTable = 'visitAppContext';
|
||||||
static const columnLanguage = 'language';
|
static const columnLanguage = 'language';
|
||||||
@ -38,6 +38,9 @@ class DatabaseHelper {
|
|||||||
static const columnLanguages = 'languages';
|
static const columnLanguages = 'languages';
|
||||||
static const columnPrimaryColor = 'primaryColor';
|
static const columnPrimaryColor = 'primaryColor';
|
||||||
static const columnSecondaryColor = 'secondaryColor';
|
static const columnSecondaryColor = 'secondaryColor';
|
||||||
|
static const columnConfigOrder = 'configOrder';
|
||||||
|
static const columnGridColSpan = 'gridColSpan';
|
||||||
|
static const columnGridRowSpan = 'gridRowSpan';
|
||||||
|
|
||||||
static const sectionsTable = 'sections';
|
static const sectionsTable = 'sections';
|
||||||
static const columnTitle = 'title';
|
static const columnTitle = 'title';
|
||||||
@ -78,7 +81,21 @@ class DatabaseHelper {
|
|||||||
Future<Database> _initDatabase() async {
|
Future<Database> _initDatabase() async {
|
||||||
String path = join(await getDatabasesPath(), _databaseName);
|
String path = join(await getDatabasesPath(), _databaseName);
|
||||||
return await openDatabase(path,
|
return await openDatabase(path,
|
||||||
version: _databaseVersion, onCreate: _onCreate);
|
version: _databaseVersion, onCreate: _onCreate, onUpgrade: _onUpgrade);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
await db.execute('ALTER TABLE $configurationsTable ADD COLUMN $columnConfigOrder INT');
|
||||||
|
// v2 avait une colonne 'weightMasonryGrid' (poids 1D) — abandonnée en v3
|
||||||
|
// au profit des spans bento. On ne la supprime pas (SQLite ne le permet pas
|
||||||
|
// simplement), elle reste inutilisée.
|
||||||
|
await db.execute('ALTER TABLE $configurationsTable ADD COLUMN weightMasonryGrid REAL');
|
||||||
|
}
|
||||||
|
if (oldVersion < 3) {
|
||||||
|
await db.execute('ALTER TABLE $configurationsTable ADD COLUMN $columnGridColSpan INT');
|
||||||
|
await db.execute('ALTER TABLE $configurationsTable ADD COLUMN $columnGridRowSpan INT');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQL code to create the database table
|
// SQL code to create the database table
|
||||||
@ -178,6 +195,9 @@ class DatabaseHelper {
|
|||||||
$columnDateCreation TEXT NOT NULL,
|
$columnDateCreation TEXT NOT NULL,
|
||||||
$columnPrimaryColor TEXT,
|
$columnPrimaryColor TEXT,
|
||||||
$columnSecondaryColor TEXT,
|
$columnSecondaryColor TEXT,
|
||||||
|
$columnConfigOrder INT,
|
||||||
|
$columnGridColSpan INT,
|
||||||
|
$columnGridRowSpan INT,
|
||||||
$columnIsOffline BOOLEAN NOT NULL CHECK ($columnIsOffline IN (0,1))
|
$columnIsOffline BOOLEAN NOT NULL CHECK ($columnIsOffline IN (0,1))
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|||||||
@ -38,11 +38,13 @@ class AssistantResponse {
|
|||||||
final String reply;
|
final String reply;
|
||||||
final List<AiCard>? cards;
|
final List<AiCard>? cards;
|
||||||
final AssistantNavigationAction? navigation;
|
final AssistantNavigationAction? navigation;
|
||||||
|
final bool expectsReply;
|
||||||
|
|
||||||
const AssistantResponse({
|
const AssistantResponse({
|
||||||
required this.reply,
|
required this.reply,
|
||||||
this.cards,
|
this.cards,
|
||||||
this.navigation,
|
this.navigation,
|
||||||
|
this.expectsReply = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AssistantResponse.fromJson(Map<String, dynamic> json) =>
|
factory AssistantResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
@ -55,5 +57,6 @@ class AssistantResponse {
|
|||||||
? AssistantNavigationAction.fromJson(
|
? AssistantNavigationAction.fromJson(
|
||||||
json['navigation'] as Map<String, dynamic>)
|
json['navigation'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
|
expectsReply: json['expectsReply'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,13 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:auto_size_text/auto_size_text.dart';
|
import 'package:auto_size_text/auto_size_text.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.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:myinfomate_layout/myinfomate_layout.dart';
|
||||||
import 'package:mymuseum_visitapp/Components/AssistantChatSheet.dart';
|
import 'package:mymuseum_visitapp/Components/AssistantChatSheet.dart';
|
||||||
import 'package:mymuseum_visitapp/Components/CustomAppBar.dart';
|
import 'package:mymuseum_visitapp/Components/CustomAppBar.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Components/GlassesStatusWidget.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart';
|
||||||
import 'package:mymuseum_visitapp/Components/AdminPopup.dart';
|
import 'package:mymuseum_visitapp/Components/AdminPopup.dart';
|
||||||
import 'package:mymuseum_visitapp/Components/ScannerBouton.dart';
|
import 'package:mymuseum_visitapp/Components/ScannerBouton.dart';
|
||||||
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
|
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
|
||||||
@ -29,6 +31,24 @@ import 'package:mymuseum_visitapp/client.dart';
|
|||||||
import 'package:mymuseum_visitapp/constants.dart';
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class _WeightedConfig {
|
||||||
|
final ConfigurationDTO configuration;
|
||||||
|
final int colSpan;
|
||||||
|
final int rowSpan;
|
||||||
|
final int? order;
|
||||||
|
|
||||||
|
const _WeightedConfig({required this.configuration, this.colSpan = 1, this.rowSpan = 1, this.order});
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? _parseStoredColor(String? raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
try {
|
||||||
|
return Color(int.parse(raw.split('(0x')[1].split(')')[0], radix: 16));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class HomePage3 extends StatefulWidget {
|
class HomePage3 extends StatefulWidget {
|
||||||
const HomePage3({Key? key}) : super(key: key);
|
const HomePage3({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@ -40,10 +60,11 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
int currentIndex = 0;
|
int currentIndex = 0;
|
||||||
|
|
||||||
late List<ConfigurationDTO> configurations = [];
|
late List<ConfigurationDTO> configurations = [];
|
||||||
|
List<_WeightedConfig> weightedConfigs = [];
|
||||||
List<String?> alreadyDownloaded = [];
|
List<String?> alreadyDownloaded = [];
|
||||||
late VisitAppContext visitAppContext;
|
late VisitAppContext visitAppContext;
|
||||||
|
|
||||||
late Future<List<ConfigurationDTO>?> _futureConfigurations;
|
late Future<List<_WeightedConfig>?> _futureConfigurations;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -53,15 +74,19 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
_futureConfigurations = getConfigurationsCall(appContext);
|
_futureConfigurations = getConfigurationsCall(appContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCard(BuildContext context, int index) {
|
Widget _buildCard(BuildContext context, ConfigurationDTO config) {
|
||||||
final lang = visitAppContext.language ?? "FR";
|
final lang = visitAppContext.language ?? "FR";
|
||||||
final config = configurations[index];
|
|
||||||
final titleEntry = config.title?.firstWhere(
|
final titleEntry = config.title?.firstWhere(
|
||||||
(t) => t.language == lang,
|
(t) => t.language == lang,
|
||||||
orElse: () => config.title!.first,
|
orElse: () => config.title!.first,
|
||||||
);
|
);
|
||||||
final cleanedTitle = (titleEntry?.value ?? '').replaceAll('\n', ' ').replaceAll('<br>', ' ');
|
final cleanedTitle = (titleEntry?.value ?? '').replaceAll('\n', ' ').replaceAll('<br>', ' ');
|
||||||
|
|
||||||
|
final fallbackBaseColor = _parseStoredColor(config.primaryColor) ??
|
||||||
|
_parseStoredColor(visitAppContext.applicationInstanceDTO?.primaryColor) ??
|
||||||
|
kMainColor1;
|
||||||
|
final fallbackColor = Color.lerp(fallbackBaseColor, Colors.white, 0.35)!;
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@ -80,7 +105,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: kSecondGrey,
|
color: fallbackColor,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@ -115,11 +140,15 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Title at bottom-left
|
// Title at bottom-left — hauteur bornée + clip : empêche le débordement
|
||||||
|
// même si le moteur HTML natif ignore -webkit-line-clamp (contenu Quill).
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 10,
|
bottom: 10,
|
||||||
left: 10,
|
left: 10,
|
||||||
right: 28,
|
right: 28,
|
||||||
|
child: ClipRect(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 40,
|
||||||
child: HtmlWidget(
|
child: HtmlWidget(
|
||||||
cleanedTitle,
|
cleanedTitle,
|
||||||
textStyle: const TextStyle(
|
textStyle: const TextStyle(
|
||||||
@ -134,6 +163,8 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
// Chevron
|
// Chevron
|
||||||
const Positioned(
|
const Positioned(
|
||||||
bottom: 10,
|
bottom: 10,
|
||||||
@ -178,6 +209,21 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
// Entrée assistant vocal (si feature disponible)
|
||||||
|
if (visitAppContext.applicationInstanceDTO?.isAssistant == true) ...[
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.mic, color: kMainColor1),
|
||||||
|
title: const Text('Assistant vocal', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: const Text('Lunettes ou micro téléphone'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
VoiceModeSheet.show(ctx, visitAppContext: visitAppContext);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
],
|
||||||
const Text('Langue', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
|
const Text('Langue', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
@ -256,13 +302,10 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
if (snapshot.connectionState == ConnectionState.done) {
|
if (snapshot.connectionState == ConnectionState.done) {
|
||||||
final mobileConfigIds = visitAppContext.applicationInstanceDTO
|
final mobileConfigIds = visitAppContext.applicationInstanceDTO
|
||||||
?.configurations?.map((c) => c.configurationId).toSet() ?? {};
|
?.configurations?.map((c) => c.configurationId).toSet() ?? {};
|
||||||
configurations = List<ConfigurationDTO>.from(snapshot.data)
|
weightedConfigs = List<_WeightedConfig>.from(snapshot.data)
|
||||||
.where((c) => mobileConfigIds.isEmpty || mobileConfigIds.contains(c.id))
|
.where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id))
|
||||||
.toList();
|
.toList();
|
||||||
|
configurations = weightedConfigs.map((w) => w.configuration).toList();
|
||||||
final layoutType = visitAppContext.applicationInstanceDTO?.layoutMainPage;
|
|
||||||
final isMasonry = layoutType == null ||
|
|
||||||
layoutType.value == LayoutMainPageType.MasonryGrid.value;
|
|
||||||
|
|
||||||
final lang = visitAppContext.language ?? "FR";
|
final lang = visitAppContext.language ?? "FR";
|
||||||
final headerTitleEntry = configurations.isNotEmpty
|
final headerTitleEntry = configurations.isNotEmpty
|
||||||
@ -415,6 +458,12 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
// Icône lunettes / mode vocal — à gauche du bouton settings
|
||||||
|
Positioned(
|
||||||
|
top: 35,
|
||||||
|
right: 68,
|
||||||
|
child: GlassesStatusWidget(visitAppContext: visitAppContext),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 35,
|
top: 35,
|
||||||
right: 10,
|
right: 10,
|
||||||
@ -477,29 +526,49 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 8.0, right: 8.0, top: 8.0, bottom: 20.0),
|
left: 8.0, right: 8.0, top: 8.0, bottom: 20.0),
|
||||||
sliver: isMasonry
|
sliver: SliverToBoxAdapter(
|
||||||
? SliverMasonryGrid.count(
|
child: Builder(builder: (context) {
|
||||||
crossAxisCount: 2,
|
const columns = 2; // grille mobile
|
||||||
mainAxisSpacing: 12,
|
const gap = 12.0;
|
||||||
crossAxisSpacing: 12,
|
final gridWidth = size.width - 16.0;
|
||||||
childCount: configurations.length,
|
final cellWidth = (gridWidth - (columns - 1) * gap) / columns;
|
||||||
itemBuilder: (context, index) => SizedBox(
|
final cellHeight = cellWidth * 0.9;
|
||||||
height: 160 + (index % 3) * 50,
|
|
||||||
child: _buildCard(context, index),
|
final result = weightedConfigs.isNotEmpty
|
||||||
),
|
? bentoLayout(
|
||||||
|
weightedConfigs
|
||||||
|
.map((w) => BentoItem(
|
||||||
|
id: w.configuration.id!,
|
||||||
|
colSpan: w.colSpan.clamp(1, columns),
|
||||||
|
rowSpan: w.rowSpan.clamp(1, 2),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
columns,
|
||||||
)
|
)
|
||||||
: SliverGrid(
|
: const BentoResult(placements: [], rowCount: 0);
|
||||||
gridDelegate:
|
final totalHeight = result.rowCount <= 0
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
? 0.0
|
||||||
crossAxisCount: 2,
|
: result.rowCount * cellHeight + (result.rowCount - 1) * gap;
|
||||||
crossAxisSpacing: 12,
|
final configById = {
|
||||||
mainAxisSpacing: 12,
|
for (final w in weightedConfigs) w.configuration.id!: w.configuration,
|
||||||
childAspectRatio: 0.82,
|
};
|
||||||
),
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
return SizedBox(
|
||||||
_buildCard,
|
height: totalHeight,
|
||||||
childCount: configurations.length,
|
child: Stack(
|
||||||
|
children: result.placements.map((p) {
|
||||||
|
final config = configById[p.id]!;
|
||||||
|
return Positioned(
|
||||||
|
left: p.col * (cellWidth + gap),
|
||||||
|
top: p.row * (cellHeight + gap),
|
||||||
|
width: p.colSpan * cellWidth + (p.colSpan - 1) * gap,
|
||||||
|
height: p.rowSpan * cellHeight + (p.rowSpan - 1) * gap,
|
||||||
|
child: _buildCard(context, config),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -555,7 +624,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<ConfigurationDTO>?> getConfigurationsCall(AppContext appContext) async {
|
Future<List<_WeightedConfig>?> getConfigurationsCall(AppContext appContext) async {
|
||||||
bool isOnline = await hasNetwork();
|
bool isOnline = await hasNetwork();
|
||||||
VisitAppContext visitAppContext = appContext.getContext();
|
VisitAppContext visitAppContext = appContext.getContext();
|
||||||
|
|
||||||
@ -588,7 +657,21 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
visitAppContext.beaconSections = beaconSections;
|
visitAppContext.beaconSections = beaconSections;
|
||||||
//appContext.setContext(visitAppContext);
|
//appContext.setContext(visitAppContext);
|
||||||
|
|
||||||
return configurations;
|
// Récupère order/spans en brut (pas exposés sur ConfigurationDTO, qui
|
||||||
|
// vivent sur le lien section↔instance) pour que le layout hors-ligne
|
||||||
|
// corresponde à ce que le visiteur verrait en ligne.
|
||||||
|
final rawRows = await DatabaseHelper.instance.queryAllRows(DatabaseTableType.configurations);
|
||||||
|
final rawById = {for (final row in rawRows) row[DatabaseHelper.columnId] as String: row};
|
||||||
|
|
||||||
|
final cached = configurations.map((c) {
|
||||||
|
final raw = rawById[c.id];
|
||||||
|
final colSpan = (raw?[DatabaseHelper.columnGridColSpan] as int?) ?? 1;
|
||||||
|
final rowSpan = (raw?[DatabaseHelper.columnGridRowSpan] as int?) ?? 1;
|
||||||
|
final order = raw?[DatabaseHelper.columnConfigOrder] as int?;
|
||||||
|
return _WeightedConfig(configuration: c, colSpan: colSpan, rowSpan: rowSpan, order: order);
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||||
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(visitAppContext.beaconSections == null) {
|
if(visitAppContext.beaconSections == null) {
|
||||||
@ -646,12 +729,34 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
final links = await visitAppContext.clientAPI.applicationInstanceApi!
|
final links = await visitAppContext.clientAPI.applicationInstanceApi!
|
||||||
.applicationInstanceGetAllApplicationLinkFromApplicationInstance(
|
.applicationInstanceGetAllApplicationLinkFromApplicationInstance(
|
||||||
visitAppContext.applicationInstanceDTO!.id!);
|
visitAppContext.applicationInstanceDTO!.id!);
|
||||||
final configs = links
|
final weightedConfigs = links
|
||||||
?.where((l) => l.configuration != null)
|
?.where((l) => l.configuration != null)
|
||||||
.map((l) => l.configuration!)
|
.map((l) => _WeightedConfig(
|
||||||
|
configuration: l.configuration!,
|
||||||
|
colSpan: l.gridColSpan ?? 1,
|
||||||
|
rowSpan: l.gridRowSpan ?? 1,
|
||||||
|
order: l.order,
|
||||||
|
))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[];
|
[];
|
||||||
return configs;
|
weightedConfigs.sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||||
|
|
||||||
|
// Best-effort : garde order/spans en cache local pour que le rendu
|
||||||
|
// hors-ligne reste cohérent avec ce qui vient d'être vu en ligne.
|
||||||
|
for (final w in weightedConfigs) {
|
||||||
|
try {
|
||||||
|
await DatabaseHelper.instance.insert(DatabaseTableType.configurations, {
|
||||||
|
DatabaseHelper.columnId: w.configuration.id,
|
||||||
|
DatabaseHelper.columnConfigOrder: w.order,
|
||||||
|
DatabaseHelper.columnGridColSpan: w.colSpan,
|
||||||
|
DatabaseHelper.columnGridRowSpan: w.rowSpan,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
print("Could not cache order/spans for configuration ${w.configuration.id}: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return weightedConfigs;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Could not load configurations from app instance links: $e");
|
print("Could not load configurations from app instance links: $e");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,7 +83,6 @@ class _EventPageState extends State<EventPage> {
|
|||||||
widget.section.baseSectionMapId != null ||
|
widget.section.baseSectionMapId != null ||
|
||||||
widget.section.latitude != null ||
|
widget.section.latitude != null ||
|
||||||
_paths.isNotEmpty;
|
_paths.isNotEmpty;
|
||||||
final hasDescription = widget.section.description?.isNotEmpty ?? false;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF111111),
|
backgroundColor: const Color(0xFF111111),
|
||||||
@ -96,7 +95,6 @@ class _EventPageState extends State<EventPage> {
|
|||||||
children: [
|
children: [
|
||||||
if (hasProgramme) _buildProgrammeSection(),
|
if (hasProgramme) _buildProgrammeSection(),
|
||||||
if (hasMapSection) _buildMapParcoursSection(context, annotations),
|
if (hasMapSection) _buildMapParcoursSection(context, annotations),
|
||||||
if (hasDescription) _buildDescriptionSection(),
|
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -744,34 +742,6 @@ class _EventPageState extends State<EventPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Description ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Widget _buildDescriptionSection() {
|
|
||||||
final desc = _safeTranslate(widget.section.description);
|
|
||||||
if (desc.isEmpty) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 28, 16, 0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.info_outline, color: kMainColor, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'À propos',
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(desc, style: TextStyle(color: Colors.grey[300], fontSize: 14, height: 1.6)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Widget _sectionHeader(IconData icon, String label) => Padding(
|
Widget _sectionHeader(IconData icon, String label) => Padding(
|
||||||
|
|||||||
@ -9,7 +9,6 @@ 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';
|
||||||
@ -277,55 +276,6 @@ class _GamePage extends State<GamePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent(VisitAppContext visitAppContext) {
|
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 (gameDTO.gameType == GameTypes.SlidingPuzzle) {
|
||||||
if (slidingTileIndices.isEmpty) return Center(child: LoadingCommon());
|
if (slidingTileIndices.isEmpty) return Center(child: LoadingCommon());
|
||||||
|
|
||||||
|
|||||||
181
lib/Screens/Sections/GuidedPath/guided_path_audio_player.dart
Normal file
181
lib/Screens/Sections/GuidedPath/guided_path_audio_player.dart
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
|
/// Lecteur audio inline (play/pause, waveform cliquable, durée), équivalent du
|
||||||
|
/// lecteur custom de visitapp-web (`ParcoursSection` / `ProgressView`).
|
||||||
|
class GuidedPathAudioPlayer extends StatefulWidget {
|
||||||
|
final String url;
|
||||||
|
final Color accentColor;
|
||||||
|
final bool isGame;
|
||||||
|
|
||||||
|
const GuidedPathAudioPlayer({
|
||||||
|
Key? key,
|
||||||
|
required this.url,
|
||||||
|
required this.accentColor,
|
||||||
|
required this.isGame,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GuidedPathAudioPlayer> createState() => _GuidedPathAudioPlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GuidedPathAudioPlayerState extends State<GuidedPathAudioPlayer> {
|
||||||
|
final AudioPlayer _player = AudioPlayer();
|
||||||
|
Duration _position = Duration.zero;
|
||||||
|
Duration _duration = Duration.zero;
|
||||||
|
bool _playing = false;
|
||||||
|
|
||||||
|
static const _bars = [4, 8, 13, 10, 16, 11, 7, 14, 9, 5, 12, 8, 10, 15, 7, 11, 13, 6, 14, 9, 5, 12, 10, 7, 13];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
try {
|
||||||
|
await _player.setUrl(widget.url);
|
||||||
|
} catch (_) {}
|
||||||
|
_player.durationStream.listen((d) {
|
||||||
|
if (d != null && mounted) setState(() => _duration = d);
|
||||||
|
});
|
||||||
|
_player.positionStream.listen((p) {
|
||||||
|
if (mounted) setState(() => _position = p);
|
||||||
|
});
|
||||||
|
_player.playerStateStream.listen((s) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (s.processingState == ProcessingState.completed) {
|
||||||
|
_player.pause();
|
||||||
|
_player.seek(Duration.zero);
|
||||||
|
setState(() => _playing = false);
|
||||||
|
} else {
|
||||||
|
setState(() => _playing = s.playing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_player.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggle() {
|
||||||
|
if (_playing) {
|
||||||
|
_player.pause();
|
||||||
|
} else {
|
||||||
|
_player.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _seekFraction(double f) {
|
||||||
|
if (_duration.inMilliseconds > 0) {
|
||||||
|
_player.seek(Duration(milliseconds: (f * _duration.inMilliseconds).round()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmt(Duration d) {
|
||||||
|
final m = d.inMinutes;
|
||||||
|
final s = d.inSeconds % 60;
|
||||||
|
return '$m:${s.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isGame = widget.isGame;
|
||||||
|
final progress = _duration.inMilliseconds > 0
|
||||||
|
? _position.inMilliseconds / _duration.inMilliseconds
|
||||||
|
: 0.0;
|
||||||
|
final muted = isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isGame ? Colors.white.withOpacity(0.06) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: isGame
|
||||||
|
? const Color(0xFFD4AF37).withOpacity(0.2)
|
||||||
|
: const Color(0xFFE8E3DA)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _toggle,
|
||||||
|
child: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isGame ? const Color(0xFFD4AF37) : const Color(0xFF1E2A33),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_playing ? Icons.pause : Icons.play_arrow,
|
||||||
|
color: isGame ? const Color(0xFF1a1305) : Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: (d) =>
|
||||||
|
_seekFraction((d.localPosition.dx / constraints.maxWidth).clamp(0.0, 1.0)),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 20,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < _bars.length; i++)
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||||
|
child: Container(
|
||||||
|
height: _bars[i].toDouble(),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(1.5),
|
||||||
|
color: (i / _bars.length) <= progress
|
||||||
|
? widget.accentColor
|
||||||
|
: const Color(0x33646E78),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
_duration.inMilliseconds > 0 ? _fmt(_duration) : '--:--',
|
||||||
|
style: TextStyle(color: muted, fontSize: 12.5, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_duration.inMilliseconds > 0)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress.clamp(0.0, 1.0),
|
||||||
|
minHeight: 3,
|
||||||
|
backgroundColor:
|
||||||
|
isGame ? Colors.white.withOpacity(0.06) : const Color(0xFFF0EBE3),
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(widget.accentColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,17 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
|
||||||
import 'package:manager_api_new/api.dart';
|
import 'package:manager_api_new/api.dart';
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
|
import 'package:mymuseum_visitapp/Components/SliderImages.dart';
|
||||||
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/ResponseSubDTO.dart';
|
import 'package:mymuseum_visitapp/Models/resourceModel.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_path_audio_player.dart';
|
||||||
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_end_view.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_challenge.dart';
|
||||||
|
import 'package:mymuseum_visitapp/constants.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).
|
||||||
|
/// Miroir de `ProgressView` (non-carte) de visitapp-web.
|
||||||
class GuidedPathContentProgressionPage extends StatefulWidget {
|
class GuidedPathContentProgressionPage extends StatefulWidget {
|
||||||
final GuidedPathDTO path;
|
final GuidedPathDTO path;
|
||||||
final VisitAppContext visitAppContext;
|
final VisitAppContext visitAppContext;
|
||||||
@ -30,15 +31,20 @@ class GuidedPathContentProgressionPage extends StatefulWidget {
|
|||||||
class _GuidedPathContentProgressionPageState
|
class _GuidedPathContentProgressionPageState
|
||||||
extends State<GuidedPathContentProgressionPage> {
|
extends State<GuidedPathContentProgressionPage> {
|
||||||
late List<GuidedStepDTO> _steps;
|
late List<GuidedStepDTO> _steps;
|
||||||
int _currentStepIndex = 0;
|
int _index = 0;
|
||||||
final Set<String> _completedStepIds = {};
|
final Set<String> _completedStepIds = {};
|
||||||
|
|
||||||
List<QuestionSubDTO> _stepQuestions = [];
|
GuidedStepChallengeController? _challenge;
|
||||||
bool _quizCompleted = false;
|
bool _expiredShown = false;
|
||||||
bool _quizPassed = false;
|
|
||||||
|
|
||||||
bool _inGeoZone = false;
|
static const _gold = Color(0xFFD4AF37);
|
||||||
StreamSubscription<Position>? _positionSub;
|
bool get _isGame => widget.path.isGameMode == true;
|
||||||
|
Color get _accent => _isGame ? _gold : kMainColor;
|
||||||
|
Color get _bg => _isGame ? const Color(0xFF0B1018) : const Color(0xFFF6F3EE);
|
||||||
|
Color get _ink => _isGame ? const Color(0xFFF4ECD8) : const Color(0xFF1E2A33);
|
||||||
|
Color get _muted => _isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86);
|
||||||
|
|
||||||
|
GuidedStepDTO? get _currentStep => _steps.isEmpty ? null : _steps[_index];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -46,407 +52,403 @@ class _GuidedPathContentProgressionPageState
|
|||||||
_steps = [...(widget.path.steps ?? [])]
|
_steps = [...(widget.path.steps ?? [])]
|
||||||
..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||||
_initStepState();
|
_initStepState();
|
||||||
_startLocationTracking();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_positionSub?.cancel();
|
_challenge?.removeListener(_onChallengeChanged);
|
||||||
|
_challenge?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void _initStepState() {
|
void _initStepState() {
|
||||||
|
_challenge?.removeListener(_onChallengeChanged);
|
||||||
|
_challenge?.dispose();
|
||||||
|
_expiredShown = false;
|
||||||
|
|
||||||
final step = _currentStep;
|
final step = _currentStep;
|
||||||
if (step == null) return;
|
if (step == null) return;
|
||||||
_quizCompleted = false;
|
|
||||||
_quizPassed = false;
|
|
||||||
_inGeoZone = false;
|
|
||||||
|
|
||||||
if (step.quizQuestions?.isNotEmpty == true) {
|
final questions = GuidedStepChallengeController.buildQuestions(step);
|
||||||
_stepQuestions = step.quizQuestions!
|
final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0;
|
||||||
.map((q) => QuestionSubDTO(
|
|
||||||
chosen: null,
|
_challenge = GuidedStepChallengeController(
|
||||||
label: q.label,
|
questions: questions,
|
||||||
responsesSubDTO: ResponseSubDTO().fromJSON(q.responses),
|
isGame: _isGame,
|
||||||
resourceId: q.resourceId,
|
requireSuccess: widget.path.requireSuccessToAdvance ?? false,
|
||||||
resourceUrl: q.resource?.url,
|
hasTimer: hasTimer,
|
||||||
order: q.order,
|
timerSeconds: step.timerSeconds ?? 0,
|
||||||
))
|
visitAppContext: widget.visitAppContext,
|
||||||
.toList();
|
)..addListener(_onChallengeChanged);
|
||||||
} else {
|
|
||||||
_stepQuestions = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GuidedStepDTO? get _currentStep =>
|
void _onChallengeChanged() {
|
||||||
_steps.isEmpty ? null : _steps[_currentStepIndex];
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
final c = _challenge;
|
||||||
|
final step = _currentStep;
|
||||||
|
if (c != null && c.expired && !_expiredShown) {
|
||||||
|
final msg = TranslationHelper.get(step?.timerExpiredMessage, widget.visitAppContext);
|
||||||
|
if (msg.isNotEmpty) {
|
||||||
|
_expiredShown = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _showExpiredModal(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _translate(List<TranslationDTO>? list) =>
|
String _translate(List<TranslationDTO>? list) =>
|
||||||
TranslationHelper.get(list, widget.visitAppContext);
|
TranslationHelper.get(list, widget.visitAppContext);
|
||||||
|
|
||||||
bool _hasGeoTrigger(GuidedStepDTO step) =>
|
bool get _canAdvance => _challenge?.canAdvance ?? true;
|
||||||
(step.geometry?.type == 'Point') ||
|
|
||||||
(step.triggerGeoPointId != null && step.triggerGeoPoint != null);
|
|
||||||
|
|
||||||
bool get _canAdvance {
|
|
||||||
final step = _currentStep;
|
|
||||||
if (step == null || step.isStepLocked == true) return false;
|
|
||||||
if (_hasGeoTrigger(step) && !_inGeoZone) return false;
|
|
||||||
if ((widget.path.requireSuccessToAdvance ?? false) &&
|
|
||||||
step.quizQuestions?.isNotEmpty == true &&
|
|
||||||
!_quizPassed) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _advance() {
|
void _advance() {
|
||||||
final step = _currentStep;
|
final step = _currentStep;
|
||||||
if (step == null || !_canAdvance) return;
|
if (step == null || !_canAdvance) return;
|
||||||
if (step.id != null) _completedStepIds.add(step.id!);
|
if (step.id != null) _completedStepIds.add(step.id!);
|
||||||
if (_currentStepIndex < _steps.length - 1) {
|
if (_index < _steps.length - 1) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentStepIndex++;
|
_index++;
|
||||||
_initStepState();
|
_initStepState();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
_showCompletionDialog();
|
_showEnd();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goBack() {
|
void _goBack() {
|
||||||
if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false)) {
|
if (_index > 0 && !(widget.path.isLinear ?? false)) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentStepIndex--;
|
_index--;
|
||||||
_initStepState();
|
_initStepState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showCompletionDialog() {
|
void _openChallenge() {
|
||||||
|
final started = _challenge?.openChallenge() ?? false;
|
||||||
|
if (started) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Le chrono a démarré !'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
backgroundColor: _isGame ? const Color(0xFF141a24) : const Color(0xFF1E2A33),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showExpiredModal(String html) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => AlertDialog(
|
builder: (_) => Dialog(
|
||||||
title: const Text('Parcours terminé !'),
|
backgroundColor: _isGame ? const Color(0xFF0B1018) : Colors.white,
|
||||||
content: const Text('Vous avez complété toutes les étapes.'),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
actions: [
|
child: Padding(
|
||||||
TextButton(
|
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
||||||
onPressed: () {
|
child: Column(
|
||||||
Navigator.of(context).pop();
|
mainAxisSize: MainAxisSize.min,
|
||||||
Navigator.of(context).pop();
|
children: [
|
||||||
},
|
Container(
|
||||||
child: const Text('Fermer'),
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: const Color(0xFFD14343).withOpacity(0.14),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.timer_off, size: 30, color: Color(0xFFD14343)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Temps écoulé !',
|
||||||
|
style: TextStyle(color: _ink, fontSize: 19, fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
HtmlWidget(html, textStyle: TextStyle(color: _muted, fontSize: 14.5, height: 1.6)),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _accent,
|
||||||
|
foregroundColor: _isGame ? const Color(0xFF1a1305) : Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: const Text('Fermer',
|
||||||
|
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.w700)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Géoloc ───────────────────────────────────────────────────────────────
|
void _showEnd() {
|
||||||
|
final outro = _stripHtml(
|
||||||
Future<void> _startLocationTracking() async {
|
TranslationHelper.getWithResource(widget.path.gameMessageFin, widget.visitAppContext));
|
||||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
if (!serviceEnabled) return;
|
builder: (_) => GuidedPathEndView(
|
||||||
LocationPermission permission = await Geolocator.checkPermission();
|
isGame: _isGame,
|
||||||
if (permission == LocationPermission.denied) {
|
primaryColor: kMainColor,
|
||||||
permission = await Geolocator.requestPermission();
|
pathTitle: _translate(widget.path.title),
|
||||||
if (permission == LocationPermission.denied) return;
|
stepsCount: _steps.length,
|
||||||
}
|
estimatedDurationMinutes: widget.path.estimatedDurationMinutes,
|
||||||
if (permission == LocationPermission.deniedForever) return;
|
gameOutro: outro,
|
||||||
|
onBack: () {
|
||||||
_positionSub = Geolocator.getPositionStream(
|
Navigator.of(context).pop(); // ferme l'écran de fin
|
||||||
locationSettings:
|
Navigator.of(context).pop(); // revient à la liste des parcours
|
||||||
const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5),
|
},
|
||||||
).listen((pos) => _checkGeoZone(LatLng(pos.latitude, pos.longitude)));
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _checkGeoZone(LatLng position) {
|
String _stripHtml(String html) => html
|
||||||
if (!mounted) return;
|
.replaceAll(RegExp(r'<[^>]*>'), ' ')
|
||||||
final step = _currentStep;
|
.replaceAll(' ', ' ')
|
||||||
if (step == null || !_hasGeoTrigger(step)) return;
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.trim();
|
||||||
LatLng? center;
|
|
||||||
double radius = step.zoneRadiusMeters ?? 30;
|
|
||||||
|
|
||||||
if (step.geometry?.type == 'Point') {
|
|
||||||
final c = step.geometry!.coordinates as List;
|
|
||||||
center = LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble());
|
|
||||||
} else if (step.triggerGeoPoint?.geometry?.type == 'Point') {
|
|
||||||
final c = step.triggerGeoPoint!.geometry!.coordinates as List;
|
|
||||||
center = LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (center == null) return;
|
|
||||||
final dist = const Distance().distance(position, center);
|
|
||||||
final inZone = dist <= radius;
|
|
||||||
if (inZone && !_inGeoZone) {
|
|
||||||
PushNotificationService.showGeoZoneNotification(
|
|
||||||
stepTitle: _translate(_currentStep!.title),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (inZone != _inGeoZone) {
|
|
||||||
setState(() => _inGeoZone = inZone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Build ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final step = _currentStep;
|
final step = _currentStep;
|
||||||
if (step == null) return const Scaffold(body: Center(child: Text('Aucune étape')));
|
if (step == null) {
|
||||||
|
return const Scaffold(body: Center(child: Text('Aucune étape')));
|
||||||
|
}
|
||||||
|
|
||||||
final title = _translate(step.title);
|
final challenge = _challenge!;
|
||||||
final desc = _translate(step.description);
|
final hasQuiz = challenge.hasQuiz;
|
||||||
final hasQuiz = step.quizQuestions?.isNotEmpty == true;
|
|
||||||
final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0;
|
|
||||||
final hasGeo = _hasGeoTrigger(step);
|
|
||||||
final isLocked = step.isStepLocked == true;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
backgroundColor: _bg,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// ── Header ──────────────────────────────────────────────────
|
_buildHeader(step),
|
||||||
Padding(
|
Expanded(child: _buildContent(step)),
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
|
_buildBottomBar(hasQuiz),
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Progress indicator
|
|
||||||
Text(
|
|
||||||
'Étape ${_currentStepIndex + 1} / ${_steps.length}',
|
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 13),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GuidedStepChallengeOverlay(
|
||||||
|
controller: challenge,
|
||||||
|
stepIndex: _index,
|
||||||
|
accentColor: _accent,
|
||||||
|
isGame: _isGame,
|
||||||
|
visitAppContext: widget.visitAppContext,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Dot progress
|
Widget _buildHeader(GuidedStepDTO step) {
|
||||||
Padding(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||||
|
color: _isGame ? const Color(0xFF0B1018) : const Color(0xFFF6F3EE),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
children: [
|
||||||
children: List.generate(_steps.length, (i) {
|
GestureDetector(
|
||||||
final done = _completedStepIds.contains(_steps[i].id);
|
onTap: () => Navigator.of(context).pop(),
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 8,
|
width: 40,
|
||||||
height: 8,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: done
|
color: _isGame ? Colors.white.withOpacity(0.08) : Colors.white,
|
||||||
? Colors.green
|
),
|
||||||
: i == _currentStepIndex
|
child: Icon(Icons.arrow_back_ios_new,
|
||||||
? Colors.blue
|
size: 16, color: _isGame ? _gold : const Color(0xFF1E2A33)),
|
||||||
: Colors.grey[300],
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(_steps.length, (i) {
|
||||||
|
return Expanded(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
color: i < _index
|
||||||
|
? (_isGame ? _gold : const Color(0xFF2E9E6B))
|
||||||
|
: i == _index
|
||||||
|
? _accent
|
||||||
|
: (_isGame
|
||||||
|
? Colors.white.withOpacity(0.1)
|
||||||
|
: const Color(0xFFE2DCD2)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(11),
|
||||||
|
color: _isGame ? Colors.white.withOpacity(0.08) : Colors.white,
|
||||||
|
),
|
||||||
|
child: Text('${_index + 1} / ${_steps.length}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: _isGame ? _gold : const Color(0xFF46555F))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contenu scrollable ───────────────────────────────────────
|
Widget _buildContent(GuidedStepDTO step) {
|
||||||
Expanded(
|
final title = _translate(step.title);
|
||||||
child: SingleChildScrollView(
|
final desc = _translate(step.description);
|
||||||
padding: const EdgeInsets.all(16),
|
final hasContents = step.contents?.isNotEmpty == true;
|
||||||
|
|
||||||
|
final audioUrl = () {
|
||||||
|
final list = step.audioIds ?? [];
|
||||||
|
if (list.isEmpty) return null;
|
||||||
|
final match = list.firstWhere(
|
||||||
|
(a) => a.language == widget.visitAppContext.language,
|
||||||
|
orElse: () => list.first,
|
||||||
|
);
|
||||||
|
final v = match.value;
|
||||||
|
return (v != null && v.startsWith('http')) ? v : null;
|
||||||
|
}();
|
||||||
|
|
||||||
|
final contentResources = hasContents
|
||||||
|
? step.contents!
|
||||||
|
.map((c) => ResourceModel(
|
||||||
|
id: c.resourceId, source: c.resource?.url, type: ResourceType.Image))
|
||||||
|
.toList()
|
||||||
|
: <ResourceModel>[];
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Image
|
if (hasContents)
|
||||||
if (step.imageUrl != null)
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Image.network(
|
child: SizedBox(
|
||||||
step.imageUrl!,
|
|
||||||
height: 200,
|
height: 200,
|
||||||
width: double.infinity,
|
child: SliderImagesWidget(
|
||||||
fit: BoxFit.cover,
|
resources: contentResources,
|
||||||
|
height: 200,
|
||||||
|
contentsDTO: step.contents!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
if (step.imageUrl != null) const SizedBox(height: 16),
|
else if (step.imageUrl != null)
|
||||||
|
ClipRRect(
|
||||||
// Locked
|
borderRadius: BorderRadius.circular(16),
|
||||||
if (isLocked)
|
child: Image.network(step.imageUrl!,
|
||||||
|
height: 200, width: double.infinity, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
if (hasContents || step.imageUrl != null) const SizedBox(height: 16),
|
||||||
|
Text(title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _ink, fontSize: 22, fontWeight: FontWeight.w600, height: 1.15)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[100],
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: _isGame ? _gold.withOpacity(0.12) : _accent.withOpacity(0.1),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Text(_translate(widget.path.title).toUpperCase(),
|
||||||
children: [
|
style: TextStyle(
|
||||||
const Icon(Icons.lock, color: Colors.grey),
|
color: _accent,
|
||||||
const SizedBox(width: 8),
|
fontSize: 11.5,
|
||||||
Text('Étape verrouillée', style: TextStyle(color: Colors.grey[600])),
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.3)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
if (audioUrl != null)
|
||||||
|
GuidedPathAudioPlayer(
|
||||||
|
key: ValueKey('audio-${step.id}'),
|
||||||
|
url: audioUrl,
|
||||||
|
accentColor: _accent,
|
||||||
|
isGame: _isGame,
|
||||||
|
),
|
||||||
|
if (desc.isNotEmpty)
|
||||||
|
HtmlWidget(desc,
|
||||||
|
textStyle: TextStyle(color: _muted, fontSize: 15.5, height: 1.65)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Description
|
Widget _buildBottomBar(bool hasQuiz) {
|
||||||
if (desc.isNotEmpty && !isLocked)
|
return Container(
|
||||||
Text(desc, style: const TextStyle(fontSize: 15, height: 1.6)),
|
padding: EdgeInsets.fromLTRB(18, 12, 18, 18 + MediaQuery.of(context).padding.bottom),
|
||||||
|
color: _bg,
|
||||||
if (desc.isNotEmpty) const SizedBox(height: 16),
|
child: Row(
|
||||||
|
children: [
|
||||||
// Timer (barre)
|
if (_index > 0 && !(widget.path.isLinear ?? false)) ...[
|
||||||
if (hasTimer && !isLocked)
|
GestureDetector(
|
||||||
GuidedStepTimer(
|
onTap: _goBack,
|
||||||
key: ValueKey(step.id),
|
child: Container(
|
||||||
seconds: step.timerSeconds!,
|
width: 50,
|
||||||
expiredMessage: _translate(step.timerExpiredMessage),
|
height: 50,
|
||||||
showAsBar: true,
|
|
||||||
onExpired: () => setState(() {}),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (hasTimer) const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Géo
|
|
||||||
if (hasGeo && !isLocked)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _inGeoZone
|
borderRadius: BorderRadius.circular(14),
|
||||||
? Colors.green.withOpacity(0.1)
|
color: _isGame ? Colors.white.withOpacity(0.06) : Colors.white,
|
||||||
: Colors.orange.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _inGeoZone ? Colors.green : Colors.orange),
|
color: _isGame ? _gold.withOpacity(0.3) : const Color(0xFFE2DCD2),
|
||||||
|
width: 1.5),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Icon(Icons.chevron_left,
|
||||||
children: [
|
color: _isGame ? _gold : const Color(0xFF54636E)),
|
||||||
Icon(
|
|
||||||
_inGeoZone ? Icons.location_on : Icons.location_searching,
|
|
||||||
color: _inGeoZone ? Colors.green : Colors.orange,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
_inGeoZone
|
|
||||||
? 'Vous êtes dans la zone ✓'
|
|
||||||
: 'Approchez-vous du point indiqué',
|
|
||||||
style: TextStyle(
|
|
||||||
color: _inGeoZone ? Colors.green[700] : Colors.orange[700],
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
],
|
],
|
||||||
|
if (hasQuiz) ...[
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _openChallenge,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: _accent,
|
||||||
|
backgroundColor: _isGame ? Colors.white.withOpacity(0.04) : Colors.white,
|
||||||
|
side: BorderSide(
|
||||||
|
color: _isGame ? _gold.withOpacity(0.4) : const Color(0xFFD4C9B8),
|
||||||
|
width: 1.5),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
||||||
|
),
|
||||||
|
icon: Icon(Icons.star, size: 16, color: _accent),
|
||||||
|
label: const Text('Voir le défi',
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
if (hasGeo) const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Quiz
|
|
||||||
if (hasQuiz && !isLocked && !_quizCompleted)
|
|
||||||
SizedBox(
|
|
||||||
height: 340,
|
|
||||||
child: QuestionsListWidget(
|
|
||||||
questionsSubDTO: _stepQuestions,
|
|
||||||
isShowResponse: false,
|
|
||||||
onShowResponse: () {
|
|
||||||
final good = _stepQuestions
|
|
||||||
.where((q) =>
|
|
||||||
q.chosen != null &&
|
|
||||||
q.chosen ==
|
|
||||||
q.responsesSubDTO!
|
|
||||||
.indexWhere((r) => r.isGood == true))
|
|
||||||
.length;
|
|
||||||
setState(() {
|
|
||||||
_quizCompleted = true;
|
|
||||||
_quizPassed = good == _stepQuestions.length;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
orientation: MediaQuery.of(context).orientation,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (hasQuiz && _quizCompleted)
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_quizPassed ? Icons.check_circle : Icons.cancel,
|
|
||||||
color: _quizPassed ? Colors.green : Colors.red,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
_quizPassed ? 'Quiz réussi !' : 'Quiz échoué',
|
|
||||||
style: TextStyle(
|
|
||||||
color: _quizPassed ? Colors.green : Colors.red,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!_quizPassed) ...[
|
|
||||||
const Spacer(),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => setState(() {
|
|
||||||
_quizCompleted = false;
|
|
||||||
_quizPassed = false;
|
|
||||||
_stepQuestions = _currentStep!.quizQuestions!
|
|
||||||
.map((q) => QuestionSubDTO(
|
|
||||||
chosen: null,
|
|
||||||
label: q.label,
|
|
||||||
responsesSubDTO:
|
|
||||||
ResponseSubDTO().fromJSON(q.responses),
|
|
||||||
resourceId: q.resourceId,
|
|
||||||
resourceUrl: q.resource?.url,
|
|
||||||
order: q.order,
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
}),
|
|
||||||
child: const Text('Réessayer'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
Expanded(
|
||||||
),
|
flex: 2,
|
||||||
],
|
child: ElevatedButton(
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Navigation ───────────────────────────────────────────────
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false))
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _goBack,
|
|
||||||
icon: const Icon(Icons.arrow_back, size: 16),
|
|
||||||
label: const Text('Précédent'),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: _canAdvance ? _advance : null,
|
onPressed: _canAdvance ? _advance : null,
|
||||||
icon: Icon(
|
style: ElevatedButton.styleFrom(
|
||||||
_currentStepIndex < _steps.length - 1
|
backgroundColor: _accent,
|
||||||
? Icons.arrow_forward
|
foregroundColor: _isGame ? const Color(0xFF1a1305) : Colors.white,
|
||||||
: Icons.flag,
|
disabledBackgroundColor: _accent.withOpacity(0.4),
|
||||||
size: 16,
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
||||||
|
elevation: 0,
|
||||||
),
|
),
|
||||||
label: Text(
|
child: Text(_index < _steps.length - 1 ? 'Étape suivante' : 'Terminer',
|
||||||
_currentStepIndex < _steps.length - 1 ? 'Suivant' : 'Terminer',
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
198
lib/Screens/Sections/GuidedPath/guided_path_end_view.dart
Normal file
198
lib/Screens/Sections/GuidedPath/guided_path_end_view.dart
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Écran de fin de parcours (équivalent `EndView` de visitapp-web) : version
|
||||||
|
/// escape game (badge + message de fin) et version découverte (stats).
|
||||||
|
class GuidedPathEndView extends StatelessWidget {
|
||||||
|
final bool isGame;
|
||||||
|
final Color primaryColor;
|
||||||
|
final String pathTitle;
|
||||||
|
final int stepsCount;
|
||||||
|
final int? estimatedDurationMinutes;
|
||||||
|
final String gameOutro;
|
||||||
|
final VoidCallback onBack;
|
||||||
|
|
||||||
|
const GuidedPathEndView({
|
||||||
|
Key? key,
|
||||||
|
required this.isGame,
|
||||||
|
required this.primaryColor,
|
||||||
|
required this.pathTitle,
|
||||||
|
required this.stepsCount,
|
||||||
|
required this.estimatedDurationMinutes,
|
||||||
|
required this.gameOutro,
|
||||||
|
required this.onBack,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
static const _gold = Color(0xFFD4AF37);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: isGame
|
||||||
|
? const RadialGradient(
|
||||||
|
center: Alignment(0, -0.6),
|
||||||
|
radius: 1.1,
|
||||||
|
colors: [Color(0xFF1a2436), Color(0xFF0B1018), Color(0xFF05070b)],
|
||||||
|
stops: [0.0, 0.6, 1.0],
|
||||||
|
)
|
||||||
|
: const RadialGradient(
|
||||||
|
center: Alignment(0, -1),
|
||||||
|
radius: 1.1,
|
||||||
|
colors: [Colors.white, Color(0xFFF6F3EE)],
|
||||||
|
stops: [0.0, 0.6],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 34, vertical: 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: isGame ? _game(context) : _normal(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _game(BuildContext context) => [
|
||||||
|
Container(
|
||||||
|
width: 110,
|
||||||
|
height: 110,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: _gold.withOpacity(0.5), width: 1.5),
|
||||||
|
boxShadow: [BoxShadow(color: _gold.withOpacity(0.35), blurRadius: 60, spreadRadius: 4)],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.star, size: 50, color: Color(0xFFE7C765)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text('ÉNIGME RÉSOLUE',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _gold, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 4)),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
const Text('Le secret est à vous',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFFF4ECD8),
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.1)),
|
||||||
|
if (gameOutro.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text('« $gameOutro »',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFb9ac8c),
|
||||||
|
fontSize: 15,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
height: 1.6)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
border: Border.all(color: _gold.withOpacity(0.4)),
|
||||||
|
color: _gold.withOpacity(0.08),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.emoji_events, size: 16, color: Color(0xFFE7C765)),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Badge obtenu',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFFE7C765), fontSize: 13.5, fontWeight: FontWeight.w700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_button('Terminer l\'aventure'),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Widget> _normal(BuildContext context) => [
|
||||||
|
Container(
|
||||||
|
width: 110,
|
||||||
|
height: 110,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: const Color(0xFF2E9E6B),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF2E9E6B).withOpacity(0.4),
|
||||||
|
blurRadius: 32,
|
||||||
|
offset: const Offset(0, 16))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.check, size: 44, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text('Parcours terminé',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF1E2A33),
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.4)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'Vous avez exploré $stepsCount étape${stepsCount > 1 ? 's' : ''} de « $pathTitle ».',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Color(0xFF6B7B86), fontSize: 15, height: 1.55),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_statCard('$stepsCount', 'étapes'),
|
||||||
|
if (estimatedDurationMinutes != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_statCard('$estimatedDurationMinutes min', 'estimé'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_button('Retour à la section'),
|
||||||
|
];
|
||||||
|
|
||||||
|
Widget _statCard(String value, String label) => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: const Color(0xFFEEE9E0)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22, fontWeight: FontWeight.w600, color: primaryColor)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF8A969F))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _button(String label) => SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: onBack,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: isGame ? _gold : primaryColor,
|
||||||
|
foregroundColor: isGame ? const Color(0xFF1a1305) : Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 17),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: Text(label,
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: isGame ? FontWeight.w800 : FontWeight.w700)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
61
lib/Screens/Sections/GuidedPath/guided_path_geo.dart
Normal file
61
lib/Screens/Sections/GuidedPath/guided_path_geo.dart
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:manager_api_new/api.dart';
|
||||||
|
|
||||||
|
class StepShape {
|
||||||
|
final String type; // 'LineString' | 'Polygon'
|
||||||
|
final List<LatLng> positions;
|
||||||
|
const StepShape(this.type, this.positions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Géométrie des GuidedStep : les coordonnées sont stockées en `[lat, lng]`
|
||||||
|
/// (et NON en GeoJSON `[lng, lat]`), en miroir du `map_geometry_picker` de
|
||||||
|
/// manager-app et de visitapp-web (voir `visitapp-web/src/lib/geo.ts`).
|
||||||
|
class GuidedPathGeo {
|
||||||
|
static List<LatLng> _pairs(dynamic points) {
|
||||||
|
if (points is! List) return const [];
|
||||||
|
final result = <LatLng>[];
|
||||||
|
for (final p in points) {
|
||||||
|
if (p is List && p.length >= 2 && p[0] is num && p[1] is num) {
|
||||||
|
result.add(LatLng((p[0] as num).toDouble(), (p[1] as num).toDouble()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static LatLng? _average(List<LatLng> pts) {
|
||||||
|
if (pts.isEmpty) return null;
|
||||||
|
final lat = pts.map((p) => p.latitude).reduce((a, b) => a + b) / pts.length;
|
||||||
|
final lng = pts.map((p) => p.longitude).reduce((a, b) => a + b) / pts.length;
|
||||||
|
return LatLng(lat, lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point représentatif : Point → lui-même ; LineString/Polygon → centroïde.
|
||||||
|
static LatLng? center(GeometryDTO? geometry) {
|
||||||
|
final coords = geometry?.coordinates;
|
||||||
|
if (coords is! List || coords.isEmpty) return null;
|
||||||
|
if (geometry?.type == 'LineString') return _average(_pairs(coords));
|
||||||
|
if (geometry?.type == 'Polygon') return _average(_pairs(coords[0]));
|
||||||
|
if (coords[0] is num && coords[1] is num) {
|
||||||
|
return LatLng((coords[0] as num).toDouble(), (coords[1] as num).toDouble());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracé réel d'un LineString/Polygon (pour dessiner sur la carte) ;
|
||||||
|
/// null pour un Point.
|
||||||
|
static StepShape? shape(GeometryDTO? geometry) {
|
||||||
|
final coords = geometry?.coordinates;
|
||||||
|
if (coords is! List) return null;
|
||||||
|
if (geometry?.type == 'LineString') {
|
||||||
|
final pos = _pairs(coords);
|
||||||
|
return pos.length > 1 ? StepShape('LineString', pos) : null;
|
||||||
|
}
|
||||||
|
if (geometry?.type == 'Polygon') {
|
||||||
|
final pos = _pairs(coords.isNotEmpty ? coords[0] : null);
|
||||||
|
return pos.length > 2 ? StepShape('Polygon', pos) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool hasPoint(GeometryDTO? geometry) => center(geometry) != null;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
246
lib/Screens/Sections/GuidedPath/guided_path_puzzle.dart
Normal file
246
lib/Screens/Sections/GuidedPath/guided_path_puzzle.dart
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Puzzle en pièces éparpillées, déplacées par glisser-déposer avec
|
||||||
|
/// accrochage ("snap") à la bonne position. Équivalent de `PuzzleGame.tsx`
|
||||||
|
/// (web) — la variante non-sliding (`isSlidingPuzzle == false`).
|
||||||
|
class GuidedPathPuzzle extends StatefulWidget {
|
||||||
|
final String imageUrl;
|
||||||
|
final int rows;
|
||||||
|
final int cols;
|
||||||
|
final Color accentColor;
|
||||||
|
final bool isGame;
|
||||||
|
final VoidCallback onSolved;
|
||||||
|
|
||||||
|
const GuidedPathPuzzle({
|
||||||
|
Key? key,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.rows,
|
||||||
|
required this.cols,
|
||||||
|
required this.accentColor,
|
||||||
|
required this.isGame,
|
||||||
|
required this.onSolved,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GuidedPathPuzzle> createState() => _GuidedPathPuzzleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Piece {
|
||||||
|
final int row;
|
||||||
|
final int col;
|
||||||
|
Offset pos; // position courante (px depuis le coin haut-gauche du board)
|
||||||
|
bool placed = false;
|
||||||
|
double z;
|
||||||
|
|
||||||
|
_Piece({required this.row, required this.col, required this.pos, this.z = 0});
|
||||||
|
|
||||||
|
int get id => row * 1000 + col;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GuidedPathPuzzleState extends State<GuidedPathPuzzle> {
|
||||||
|
List<_Piece> _pieces = [];
|
||||||
|
bool _showHint = false;
|
||||||
|
bool _solved = false;
|
||||||
|
double _topZ = 1;
|
||||||
|
int? _draggingId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initPieces(double board) {
|
||||||
|
if (_pieces.isNotEmpty) return;
|
||||||
|
final pw = board / widget.cols;
|
||||||
|
final ph = board / widget.rows;
|
||||||
|
final rand = Random();
|
||||||
|
final list = <_Piece>[];
|
||||||
|
var z = 1.0;
|
||||||
|
for (int r = 0; r < widget.rows; r++) {
|
||||||
|
for (int c = 0; c < widget.cols; c++) {
|
||||||
|
list.add(_Piece(
|
||||||
|
row: r,
|
||||||
|
col: c,
|
||||||
|
pos: Offset(rand.nextDouble() * (board - pw), rand.nextDouble() * (board - ph)),
|
||||||
|
z: z++,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_pieces = list;
|
||||||
|
_topZ = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanStart(_Piece piece) {
|
||||||
|
if (piece.placed || _solved) return;
|
||||||
|
setState(() {
|
||||||
|
_draggingId = piece.id;
|
||||||
|
piece.z = _topZ++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanUpdate(_Piece piece, DragUpdateDetails details, double board) {
|
||||||
|
if (piece.placed || _solved) return;
|
||||||
|
setState(() {
|
||||||
|
final pw = board / widget.cols;
|
||||||
|
final ph = board / widget.rows;
|
||||||
|
piece.pos = Offset(
|
||||||
|
(piece.pos.dx + details.delta.dx).clamp(0.0, board - pw),
|
||||||
|
(piece.pos.dy + details.delta.dy).clamp(0.0, board - ph),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPanEnd(_Piece piece, double board) {
|
||||||
|
if (piece.placed || _solved) return;
|
||||||
|
final pw = board / widget.cols;
|
||||||
|
final ph = board / widget.rows;
|
||||||
|
final targetX = piece.col * pw;
|
||||||
|
final targetY = piece.row * ph;
|
||||||
|
final dx = piece.pos.dx - targetX;
|
||||||
|
final dy = piece.pos.dy - targetY;
|
||||||
|
final threshold = min(pw, ph) * 0.35;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_draggingId = null;
|
||||||
|
if (dx.abs() < threshold && dy.abs() < threshold) {
|
||||||
|
piece.pos = Offset(targetX, targetY);
|
||||||
|
piece.placed = true;
|
||||||
|
piece.z = 0;
|
||||||
|
}
|
||||||
|
if (_pieces.every((p) => p.placed)) {
|
||||||
|
_solved = true;
|
||||||
|
Future.delayed(const Duration(milliseconds: 400), () {
|
||||||
|
if (mounted) widget.onSolved();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final board = constraints.maxWidth.isFinite
|
||||||
|
? constraints.maxWidth
|
||||||
|
: MediaQuery.of(context).size.width - 64;
|
||||||
|
_initPieces(board);
|
||||||
|
final pw = board / widget.cols;
|
||||||
|
final ph = board / widget.rows;
|
||||||
|
final sorted = [..._pieces]..sort((a, b) => a.z.compareTo(b.z));
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: board,
|
||||||
|
height: board,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
// Cadre du plateau (toujours visible)
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.black.withOpacity(0.18), width: 1.5),
|
||||||
|
color: Colors.white.withOpacity(0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_showHint && !_solved)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.55,
|
||||||
|
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final piece in sorted)
|
||||||
|
Positioned(
|
||||||
|
left: piece.pos.dx,
|
||||||
|
top: piece.pos.dy,
|
||||||
|
width: pw,
|
||||||
|
height: ph,
|
||||||
|
child: GestureDetector(
|
||||||
|
onPanStart: (_) => _onPanStart(piece),
|
||||||
|
onPanUpdate: (d) => _onPanUpdate(piece, d, board),
|
||||||
|
onPanEnd: (_) => _onPanEnd(piece, board),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: Colors.black.withOpacity(0.15)),
|
||||||
|
boxShadow: piece.placed
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(
|
||||||
|
_draggingId == piece.id ? 0.35 : 0.2),
|
||||||
|
blurRadius: _draggingId == piece.id ? 12 : 6,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
transform: _draggingId == piece.id
|
||||||
|
? (Matrix4.identity()..scaleByDouble(1.06, 1.06, 1.06, 1.0))
|
||||||
|
: Matrix4.identity(),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: OverflowBox(
|
||||||
|
maxWidth: board,
|
||||||
|
maxHeight: board,
|
||||||
|
alignment: Alignment(
|
||||||
|
widget.cols == 1 ? 0.0 : -1.0 + (piece.col * 2 / (widget.cols - 1)),
|
||||||
|
widget.rows == 1 ? 0.0 : -1.0 + (piece.row * 2 / (widget.rows - 1)),
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: board,
|
||||||
|
height: board,
|
||||||
|
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_solved)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.35),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Text(
|
||||||
|
'Puzzle résolu !',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_solved)
|
||||||
|
Positioned(
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _showHint = !_showHint),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _showHint
|
||||||
|
? widget.accentColor
|
||||||
|
: (widget.isGame ? Colors.white.withOpacity(0.1) : Colors.white),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 8)],
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.remove_red_eye,
|
||||||
|
size: 18,
|
||||||
|
color: _showHint
|
||||||
|
? (widget.isGame ? const Color(0xFF1a1305) : Colors.white)
|
||||||
|
: (widget.isGame ? const Color(0xFFD4AF37) : const Color(0xFF54636E)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
232
lib/Screens/Sections/GuidedPath/guided_path_sliding_puzzle.dart
Normal file
232
lib/Screens/Sections/GuidedPath/guided_path_sliding_puzzle.dart
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Taquin classique (15-puzzle) : une case vide, seule une tuile adjacente à
|
||||||
|
/// la case vide peut glisser dedans. Équivalent de `SlidingPuzzle.tsx` (web).
|
||||||
|
class GuidedPathSlidingPuzzle extends StatefulWidget {
|
||||||
|
final String imageUrl;
|
||||||
|
final int rows;
|
||||||
|
final int cols;
|
||||||
|
final Color accentColor;
|
||||||
|
final bool isGame;
|
||||||
|
final VoidCallback onSolved;
|
||||||
|
|
||||||
|
const GuidedPathSlidingPuzzle({
|
||||||
|
Key? key,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.rows,
|
||||||
|
required this.cols,
|
||||||
|
required this.accentColor,
|
||||||
|
required this.isGame,
|
||||||
|
required this.onSolved,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GuidedPathSlidingPuzzle> createState() => _GuidedPathSlidingPuzzleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GuidedPathSlidingPuzzleState extends State<GuidedPathSlidingPuzzle> {
|
||||||
|
// tiles[slot] = pièce d'origine occupant ce slot ; -1 = case vide.
|
||||||
|
late List<int> _tiles;
|
||||||
|
bool _won = false;
|
||||||
|
bool _showHint = false;
|
||||||
|
|
||||||
|
int get _total => widget.rows * widget.cols;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(GuidedPathSlidingPuzzle old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (old.rows != widget.rows || old.cols != widget.cols) _shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _neighboursOf(int idx) {
|
||||||
|
final row = idx ~/ widget.cols;
|
||||||
|
final col = idx % widget.cols;
|
||||||
|
final list = <int>[];
|
||||||
|
if (row > 0) list.add(idx - widget.cols);
|
||||||
|
if (row < widget.rows - 1) list.add(idx + widget.cols);
|
||||||
|
if (col > 0) list.add(idx - 1);
|
||||||
|
if (col < widget.cols - 1) list.add(idx + 1);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shuffle() {
|
||||||
|
final arr = List.generate(_total - 1, (i) => i)..add(-1);
|
||||||
|
var emptyIdx = _total - 1;
|
||||||
|
final rand = Random();
|
||||||
|
for (int n = 0; n < 200; n++) {
|
||||||
|
final neighbours = _neighboursOf(emptyIdx);
|
||||||
|
final pick = neighbours[rand.nextInt(neighbours.length)];
|
||||||
|
final tmp = arr[emptyIdx];
|
||||||
|
arr[emptyIdx] = arr[pick];
|
||||||
|
arr[pick] = tmp;
|
||||||
|
emptyIdx = pick;
|
||||||
|
}
|
||||||
|
_tiles = arr;
|
||||||
|
_won = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTapSlot(int slotIdx) {
|
||||||
|
if (_won) return;
|
||||||
|
final emptyIdx = _tiles.indexOf(-1);
|
||||||
|
if (!_neighboursOf(emptyIdx).contains(slotIdx)) return;
|
||||||
|
setState(() {
|
||||||
|
final tmp = _tiles[emptyIdx];
|
||||||
|
_tiles[emptyIdx] = _tiles[slotIdx];
|
||||||
|
_tiles[slotIdx] = tmp;
|
||||||
|
final win = _tiles.asMap().entries.every(
|
||||||
|
(e) => e.key == _tiles.length - 1 ? e.value == -1 : e.value == e.key);
|
||||||
|
if (win) {
|
||||||
|
_won = true;
|
||||||
|
Future.delayed(const Duration(milliseconds: 400), () {
|
||||||
|
if (mounted) widget.onSolved();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final board = constraints.maxWidth.isFinite
|
||||||
|
? constraints.maxWidth
|
||||||
|
: MediaQuery.of(context).size.width - 64;
|
||||||
|
final tileW = board / widget.cols;
|
||||||
|
final tileH = board / widget.rows;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: board,
|
||||||
|
height: board,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (_showHint && !_won)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.25,
|
||||||
|
child: Image.network(widget.imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (int slot = 0; slot < _total; slot++)
|
||||||
|
if (_tiles[slot] != -1)
|
||||||
|
AnimatedPositioned(
|
||||||
|
key: ValueKey('slide-${_tiles[slot]}'),
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
left: (slot % widget.cols) * tileW,
|
||||||
|
top: (slot ~/ widget.cols) * tileH,
|
||||||
|
width: tileW,
|
||||||
|
height: tileH,
|
||||||
|
child: _SlidingTile(
|
||||||
|
piece: _tiles[slot],
|
||||||
|
rows: widget.rows,
|
||||||
|
cols: widget.cols,
|
||||||
|
boardSize: board,
|
||||||
|
imageUrl: widget.imageUrl,
|
||||||
|
onTap: () => _onTapSlot(slot),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_won)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.35),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Text(
|
||||||
|
'Puzzle résolu !',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_won)
|
||||||
|
Positioned(
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _showHint = !_showHint),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _showHint
|
||||||
|
? widget.accentColor
|
||||||
|
: (widget.isGame ? Colors.white.withOpacity(0.1) : Colors.white),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 8)],
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.remove_red_eye,
|
||||||
|
size: 18,
|
||||||
|
color: _showHint
|
||||||
|
? (widget.isGame ? const Color(0xFF1a1305) : Colors.white)
|
||||||
|
: (widget.isGame ? const Color(0xFFD4AF37) : const Color(0xFF54636E)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SlidingTile extends StatelessWidget {
|
||||||
|
final int piece;
|
||||||
|
final int rows;
|
||||||
|
final int cols;
|
||||||
|
final double boardSize;
|
||||||
|
final String imageUrl;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _SlidingTile({
|
||||||
|
required this.piece,
|
||||||
|
required this.rows,
|
||||||
|
required this.cols,
|
||||||
|
required this.boardSize,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final row = piece ~/ cols;
|
||||||
|
final col = piece % cols;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(1.5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 6)],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: OverflowBox(
|
||||||
|
maxWidth: boardSize,
|
||||||
|
maxHeight: boardSize,
|
||||||
|
alignment: Alignment(
|
||||||
|
cols == 1 ? 0.0 : -1.0 + (col * 2 / (cols - 1)),
|
||||||
|
rows == 1 ? 0.0 : -1.0 + (row * 2 / (rows - 1)),
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: boardSize,
|
||||||
|
height: boardSize,
|
||||||
|
child: Image.network(imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1016
lib/Screens/Sections/GuidedPath/guided_step_challenge.dart
Normal file
1016
lib/Screens/Sections/GuidedPath/guided_step_challenge.dart
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,108 +1,70 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class GuidedStepTimer extends StatefulWidget {
|
/// Barre de temps présentational — le décompte est piloté par le parent
|
||||||
final int seconds;
|
/// (GuidedStepChallengeController), en miroir de `StepTimer` de visitapp-web.
|
||||||
final String expiredMessage;
|
class GuidedStepTimer extends StatelessWidget {
|
||||||
final VoidCallback? onExpired;
|
final int totalSeconds;
|
||||||
/// true = barre de progression + texte MM:SS (mode escape)
|
final int remainingSeconds;
|
||||||
/// false = texte MM:SS seul (mode peek carte)
|
final bool isGame;
|
||||||
final bool showAsBar;
|
|
||||||
|
|
||||||
const GuidedStepTimer({
|
const GuidedStepTimer({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.seconds,
|
required this.totalSeconds,
|
||||||
required this.expiredMessage,
|
required this.remainingSeconds,
|
||||||
this.onExpired,
|
this.isGame = false,
|
||||||
this.showAsBar = false,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
static String _format(int s) {
|
||||||
State<GuidedStepTimer> createState() => _GuidedStepTimerState();
|
final m = s ~/ 60;
|
||||||
}
|
final r = s % 60;
|
||||||
|
return '$m:${r.toString().padLeft(2, '0')}';
|
||||||
class _GuidedStepTimerState extends State<GuidedStepTimer> {
|
|
||||||
late int _remaining;
|
|
||||||
Timer? _timer;
|
|
||||||
bool _expired = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_remaining = widget.seconds;
|
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _tick(Timer t) {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (_remaining <= 0) {
|
|
||||||
_timer?.cancel();
|
|
||||||
setState(() => _expired = true);
|
|
||||||
widget.onExpired?.call();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _remaining--);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_timer?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_expired) {
|
final pct = totalSeconds > 0 ? remainingSeconds / totalSeconds : 0.0;
|
||||||
return Text(
|
final color = pct > 0.5
|
||||||
widget.expiredMessage,
|
? const Color(0xFF16A34A)
|
||||||
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
: pct > 0.25
|
||||||
);
|
? const Color(0xFFF59E0B)
|
||||||
}
|
: const Color(0xFFDC2626);
|
||||||
|
|
||||||
final timeText = _formatTime(_remaining);
|
|
||||||
|
|
||||||
if (widget.showAsBar) {
|
|
||||||
final progress = widget.seconds > 0 ? _remaining / widget.seconds : 0.0;
|
|
||||||
final color = progress > 0.5
|
|
||||||
? Colors.green
|
|
||||||
: progress > 0.25
|
|
||||||
? Colors.orange
|
|
||||||
: Colors.red;
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.timer, size: 18),
|
Text(
|
||||||
const SizedBox(width: 6),
|
'Temps restant',
|
||||||
Text(timeText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isGame ? const Color(0xFF8a8068) : const Color(0xFF6B7B86),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_format(remainingSeconds),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
LinearProgressIndicator(
|
ClipRRect(
|
||||||
value: progress,
|
|
||||||
backgroundColor: Colors.grey[300],
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
|
||||||
minHeight: 8,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: pct.clamp(0.0, 1.0),
|
||||||
|
minHeight: 8,
|
||||||
|
backgroundColor:
|
||||||
|
isGame ? Colors.white.withOpacity(0.1) : const Color(0xFFE2DCD2),
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.timer, size: 14),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(timeText, style: const TextStyle(fontSize: 13)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatTime(int s) {
|
|
||||||
final m = s ~/ 60;
|
|
||||||
final sec = s % 60;
|
|
||||||
return '${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ 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/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/Map/geo_point_filter.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Map/geo_point_filter.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Map/map_box_view.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Map/map_box_view.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Map/marker_view.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Map/marker_view.dart';
|
||||||
@ -179,41 +178,6 @@ class _MapPage extends State<MapPage> {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (mapDTO?.guidedPaths?.isNotEmpty == true)
|
|
||||||
Positioned(
|
|
||||||
top: 35,
|
|
||||||
right: 10,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
||||||
),
|
|
||||||
builder: (_) => GuidedPathListSheet(
|
|
||||||
paths: mapDTO!.guidedPaths!,
|
|
||||||
mapDTO: mapDTO!,
|
|
||||||
visitAppContext: visitAppContext,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: primaryColor,
|
|
||||||
borderRadius: BorderRadius.circular(25),
|
|
||||||
),
|
|
||||||
child: const Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.route, size: 18, color: Colors.white),
|
|
||||||
SizedBox(width: 6),
|
|
||||||
Text('Parcours', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (mapDTO?.isListViewEnabled == true)
|
if (mapDTO?.isListViewEnabled == true)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 24,
|
bottom: 24,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import 'package:manager_api_new/api.dart';
|
|||||||
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
||||||
import 'package:mymuseum_visitapp/Components/show_element_for_resource.dart';
|
import 'package:mymuseum_visitapp/Components/show_element_for_resource.dart';
|
||||||
import 'package:mymuseum_visitapp/Helpers/ImageCustomProvider.dart';
|
import 'package:mymuseum_visitapp/Helpers/ImageCustomProvider.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/app_context.dart';
|
import 'package:mymuseum_visitapp/app_context.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
@ -518,10 +519,25 @@ getElementForResource(BuildContext context, AppContext appContext, ContentDTO i,
|
|||||||
borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0)),
|
borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 20.0)),
|
||||||
color: kBackgroundColor,
|
color: kBackgroundColor,
|
||||||
),
|
),
|
||||||
constraints: const BoxConstraints(minWidth: 250, minHeight: 250, maxHeight: 350),
|
constraints: const BoxConstraints(minWidth: 250, minHeight: 250, maxHeight: 550),
|
||||||
height: size.height * 0.6,
|
height: size.height * 0.75,
|
||||||
width: size.width * 0.85,
|
width: size.width * 0.85,
|
||||||
child: Container(
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (TranslationHelper.get(i.title, appContext.getContext()).isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
|
child: HtmlWidget(
|
||||||
|
TranslationHelper.get(i.title, appContext.getContext()),
|
||||||
|
textStyle: const TextStyle(fontSize: kArticleContentSize, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
height: size.height * 0.4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
//color: Colors.yellow,
|
//color: Colors.yellow,
|
||||||
borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 15.0)),
|
borderRadius: BorderRadius.all(Radius.circular(visitAppContext.currentAppConfigurationLink?.roundedValue?.toDouble() ?? 15.0)),
|
||||||
@ -537,6 +553,17 @@ getElementForResource(BuildContext context, AppContext appContext, ContentDTO i,
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (TranslationHelper.get(i.description, appContext.getContext()).isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: HtmlWidget(
|
||||||
|
TranslationHelper.get(i.description, appContext.getContext()),
|
||||||
|
textStyle: const TextStyle(fontSize: kArticleContentSize, fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
816
lib/Screens/Sections/Parcours/parcours_page.dart
Normal file
816
lib/Screens/Sections/Parcours/parcours_page.dart
Normal file
@ -0,0 +1,816 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:manager_api_new/api.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Models/visitContext.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart';
|
||||||
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
|
|
||||||
|
class ParcoursPage extends StatefulWidget {
|
||||||
|
final ParcoursDTO section;
|
||||||
|
final VisitAppContext visitAppContextIn;
|
||||||
|
|
||||||
|
const ParcoursPage({
|
||||||
|
Key? key,
|
||||||
|
required this.section,
|
||||||
|
required this.visitAppContextIn,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ParcoursPage> createState() => _ParcoursPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParcoursPageState extends State<ParcoursPage> {
|
||||||
|
List<GuidedPathDTO> _paths = [];
|
||||||
|
bool _loading = true;
|
||||||
|
MapDTO? _baseMap;
|
||||||
|
|
||||||
|
static const _bg = Color(0xFFF6F3EE);
|
||||||
|
static const _inkDark = Color(0xFF1E2A33);
|
||||||
|
static const _inkMid = Color(0xFF46555F);
|
||||||
|
static const _inkMuted = Color(0xFF6B7B86);
|
||||||
|
static const _borderLight = Color(0xFFEFEAE2);
|
||||||
|
static const _gold = Color(0xFFD4AF37);
|
||||||
|
|
||||||
|
Color get _primary => kMainColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPaths() async {
|
||||||
|
if (widget.section.id == null) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final api = widget.visitAppContextIn.clientAPI.sectionParcoursApi!;
|
||||||
|
final paths = await api.sectionParcoursGetAllGuidedPathFromSection(widget.section.id!);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_paths = (paths ?? [])..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
if (widget.section.showMap == true && widget.section.baseSectionMapId != null) {
|
||||||
|
_loadBaseMap();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadBaseMap() async {
|
||||||
|
try {
|
||||||
|
final rawMap = await widget.visitAppContextIn.clientAPI.sectionApi!
|
||||||
|
.sectionGetDetail(widget.section.baseSectionMapId!);
|
||||||
|
if (rawMap != null && mounted) {
|
||||||
|
setState(() => _baseMap = MapDTO.fromJson(rawMap));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _sectionTitle =>
|
||||||
|
TranslationHelper.get(widget.section.title, widget.visitAppContextIn);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: _bg,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildHero(context),
|
||||||
|
Expanded(child: _buildPathList()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHero(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 220,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
_primary.withOpacity(0.85),
|
||||||
|
_primary,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: widget.section.imageSource != null
|
||||||
|
? Image.network(
|
||||||
|
widget.section.imageSource!,
|
||||||
|
width: double.infinity,
|
||||||
|
height: 220,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: _primary.withOpacity(0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
errorBuilder: (_, __, ___) => const SizedBox(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 220,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.transparent, Colors.black.withOpacity(0.45)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 12,
|
||||||
|
left: 16,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 20,
|
||||||
|
bottom: 20,
|
||||||
|
right: 20,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_paths.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.92),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${_paths.length} parcours',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _primary,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_sectionTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPathList() {
|
||||||
|
if (_loading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (_paths.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.route_outlined, size: 48, color: _inkMuted),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Aucun parcours disponible',
|
||||||
|
style: TextStyle(color: _inkMuted, fontSize: 15),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||||
|
child: Text(
|
||||||
|
'Choisir un parcours',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _inkDark,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||||
|
itemCount: _paths.length,
|
||||||
|
itemBuilder: (_, i) => _buildPathCard(_paths[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPathCard(GuidedPathDTO path) {
|
||||||
|
final title = TranslationHelper.get(path.title, widget.visitAppContextIn);
|
||||||
|
final desc = TranslationHelper.get(path.description, widget.visitAppContextIn);
|
||||||
|
final stepCount = path.steps?.length ?? 0;
|
||||||
|
final isGame = path.isGameMode == true;
|
||||||
|
final durationMin = path.estimatedDurationMinutes;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _onPathTap(path),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: _borderLight),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(19),
|
||||||
|
bottomLeft: Radius.circular(19),
|
||||||
|
),
|
||||||
|
gradient: isGame
|
||||||
|
? const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF1a2436), Color(0xFF0B1018)],
|
||||||
|
)
|
||||||
|
: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
_primary.withOpacity(0.8),
|
||||||
|
_primary,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isGame ? Icons.lock_outlined : Icons.route_outlined,
|
||||||
|
color: isGame ? _gold : Colors.white.withOpacity(0.9),
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(13, 12, 12, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isGame
|
||||||
|
? _gold.withOpacity(0.15)
|
||||||
|
: _primary.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isGame ? 'ESCAPE GAME' : 'DÉCOUVERTE',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isGame ? _gold : _primary,
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: _inkDark,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (desc.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
desc,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: _inkMuted,
|
||||||
|
fontSize: 12.5,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.flag_outlined, size: 12, color: _inkMuted),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'$stepCount étape${stepCount > 1 ? 's' : ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: _inkMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (durationMin != null) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Icon(Icons.access_time_outlined, size: 12, color: _inkMuted),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'~$durationMin min',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: _inkMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 14),
|
||||||
|
child: Icon(Icons.arrow_forward_ios,
|
||||||
|
size: 14, color: _inkMuted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPathTap(GuidedPathDTO path) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => _ParcoursPathStartSheet(
|
||||||
|
path: path,
|
||||||
|
section: widget.section,
|
||||||
|
baseMap: _baseMap,
|
||||||
|
visitAppContext: widget.visitAppContextIn,
|
||||||
|
primaryColor: _primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParcoursPathStartSheet extends StatelessWidget {
|
||||||
|
final GuidedPathDTO path;
|
||||||
|
final ParcoursDTO section;
|
||||||
|
final MapDTO? baseMap;
|
||||||
|
final VisitAppContext visitAppContext;
|
||||||
|
final Color primaryColor;
|
||||||
|
|
||||||
|
static const _bg = Color(0xFFF6F3EE);
|
||||||
|
static const _inkDark = Color(0xFF1E2A33);
|
||||||
|
static const _inkMid = Color(0xFF46555F);
|
||||||
|
static const _inkMuted = Color(0xFF6B7B86);
|
||||||
|
static const _gold = Color(0xFFD4AF37);
|
||||||
|
|
||||||
|
const _ParcoursPathStartSheet({
|
||||||
|
Key? key,
|
||||||
|
required this.path,
|
||||||
|
required this.section,
|
||||||
|
required this.baseMap,
|
||||||
|
required this.visitAppContext,
|
||||||
|
required this.primaryColor,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
bool get _isGame => path.isGameMode == true;
|
||||||
|
bool get _showMap => section.showMap == true && baseMap != null;
|
||||||
|
|
||||||
|
String get _title => TranslationHelper.get(path.title, visitAppContext);
|
||||||
|
String get _desc => TranslationHelper.get(path.description, visitAppContext);
|
||||||
|
|
||||||
|
String get _gameIntro {
|
||||||
|
final msg = path.gameMessageDebut;
|
||||||
|
if (msg == null || msg.isEmpty) return '';
|
||||||
|
final found = msg.firstWhere(
|
||||||
|
(m) => m.language == visitAppContext.language,
|
||||||
|
orElse: () => msg.first,
|
||||||
|
);
|
||||||
|
return found.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _isGame ? _buildGameStart(context) : _buildNormalStart(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNormalStart(BuildContext context) {
|
||||||
|
final stepCount = path.steps?.length ?? 0;
|
||||||
|
final durationMin = path.estimatedDurationMinutes;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: _bg,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(26)),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 5,
|
||||||
|
margin: const EdgeInsets.only(top: 11, bottom: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFD8D2C8),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 180,
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [primaryColor.withOpacity(0.7), primaryColor],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _MapSketchPainter(primaryColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 14,
|
||||||
|
top: 12,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.92),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'DÉCOUVERTE LIBRE',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
color: Color(0xFF264863),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 14,
|
||||||
|
child: Text(
|
||||||
|
_title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (durationMin != null) ...[
|
||||||
|
Icon(Icons.access_time, size: 15, color: primaryColor),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text('~$durationMin min',
|
||||||
|
style: TextStyle(color: const Color(0xFF54636E), fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
],
|
||||||
|
Icon(Icons.map_outlined, size: 15, color: primaryColor),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text('$stepCount étape${stepCount > 1 ? 's' : ''}',
|
||||||
|
style: const TextStyle(color: Color(0xFF54636E), fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_desc.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||||
|
child: Text(
|
||||||
|
_desc,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: _inkMid,
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.55,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_startPath(context);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 17),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Commencer le parcours',
|
||||||
|
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGameStart(BuildContext context) {
|
||||||
|
final stepCount = path.steps?.length ?? 0;
|
||||||
|
final durationMin = path.estimatedDurationMinutes;
|
||||||
|
final intro = _gameIntro;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: RadialGradient(
|
||||||
|
center: const Alignment(0, -0.4),
|
||||||
|
radius: 1.2,
|
||||||
|
colors: [const Color(0xFF16202e), const Color(0xFF0B1018)],
|
||||||
|
),
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(26)),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 34,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 5,
|
||||||
|
margin: const EdgeInsets.only(top: 11, bottom: 20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _gold.withOpacity(0.4),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: _gold.withOpacity(0.5), width: 1.5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: _gold.withOpacity(0.2),
|
||||||
|
blurRadius: 30,
|
||||||
|
spreadRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.lock_open_outlined, color: _gold, size: 40),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
const Text(
|
||||||
|
'ESCAPE GAME',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _gold,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||||
|
child: Text(
|
||||||
|
_title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFF4ECD8),
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (intro.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||||
|
child: Text(
|
||||||
|
'« $intro »',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: const Color(0xFFb9ac8c),
|
||||||
|
fontSize: 15,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('$stepCount énigme${stepCount > 1 ? 's' : ''}',
|
||||||
|
style: const TextStyle(color: Color(0xFF8a8068), fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
|
if (durationMin != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('·', style: TextStyle(color: Color(0xFF8a8068))),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('~$durationMin min',
|
||||||
|
style: const TextStyle(color: Color(0xFF8a8068), fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_startPath(context);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _gold,
|
||||||
|
foregroundColor: const Color(0xFF1a1305),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
"Démarrer l'aventure",
|
||||||
|
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800, letterSpacing: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_isGame)
|
||||||
|
const Text(
|
||||||
|
'Activez le son pour une immersion totale',
|
||||||
|
style: TextStyle(color: Color(0xFF6f6655), fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPath(BuildContext context) {
|
||||||
|
if (_showMap) {
|
||||||
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
|
builder: (_) => GuidedPathMapProgressionPage(
|
||||||
|
path: path,
|
||||||
|
mapDTO: baseMap!,
|
||||||
|
visitAppContext: visitAppContext,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
|
builder: (_) => GuidedPathContentProgressionPage(
|
||||||
|
path: path,
|
||||||
|
visitAppContext: visitAppContext,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MapSketchPainter extends CustomPainter {
|
||||||
|
final Color primaryColor;
|
||||||
|
_MapSketchPainter(this.primaryColor);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final bgPaint = Paint()
|
||||||
|
..color = Colors.white.withOpacity(0.06)
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
final roadPaint = Paint()
|
||||||
|
..color = Colors.white.withOpacity(0.12)
|
||||||
|
..strokeWidth = 8
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
|
final routePaint = Paint()
|
||||||
|
..color = Colors.white.withOpacity(0.7)
|
||||||
|
..strokeWidth = 2
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
|
final road1 = Path()
|
||||||
|
..moveTo(-10, size.height * 0.45)
|
||||||
|
..lineTo(size.width * 0.35, size.height * 0.45)
|
||||||
|
..lineTo(size.width * 0.55, size.height * 0.2)
|
||||||
|
..lineTo(size.width + 10, size.height * 0.2);
|
||||||
|
|
||||||
|
final road2 = Path()
|
||||||
|
..moveTo(size.width * 0.2, -10)
|
||||||
|
..lineTo(size.width * 0.2, size.height * 0.55)
|
||||||
|
..lineTo(size.width * 0.4, size.height + 10);
|
||||||
|
|
||||||
|
canvas.drawPath(road1, roadPaint);
|
||||||
|
canvas.drawPath(road2, roadPaint);
|
||||||
|
|
||||||
|
final route = Path()
|
||||||
|
..moveTo(size.width * 0.15, size.height * 0.65)
|
||||||
|
..lineTo(size.width * 0.35, size.height * 0.35)
|
||||||
|
..lineTo(size.width * 0.65, size.height * 0.55)
|
||||||
|
..lineTo(size.width * 0.82, size.height * 0.35);
|
||||||
|
|
||||||
|
final dashPaint = Paint()
|
||||||
|
..color = Colors.white.withOpacity(0.8)
|
||||||
|
..strokeWidth = 2
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
|
_drawDashedPath(canvas, route, dashPaint, dashLength: 4, gapLength: 8);
|
||||||
|
|
||||||
|
final dotPaint = Paint()
|
||||||
|
..color = Colors.white
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
canvas.drawCircle(Offset(size.width * 0.15, size.height * 0.65), 5, dotPaint);
|
||||||
|
canvas.drawCircle(Offset(size.width * 0.82, size.height * 0.35), 5,
|
||||||
|
dotPaint..color = const Color(0xFF2E9E6B));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawDashedPath(Canvas canvas, Path path, Paint paint,
|
||||||
|
{required double dashLength, required double gapLength}) {
|
||||||
|
final metrics = path.computeMetrics();
|
||||||
|
for (final metric in metrics) {
|
||||||
|
double distance = 0;
|
||||||
|
while (distance < metric.length) {
|
||||||
|
final start = distance;
|
||||||
|
final end = (start + dashLength).clamp(0.0, metric.length);
|
||||||
|
canvas.drawPath(metric.extractPath(start, end), paint);
|
||||||
|
distance += dashLength + gapLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_MapSketchPainter old) => old.primaryColor != primaryColor;
|
||||||
|
}
|
||||||
@ -25,6 +25,7 @@ import 'package:mymuseum_visitapp/Screens/Sections/Video/video_page.dart';
|
|||||||
import 'package:mymuseum_visitapp/Screens/Sections/Weather/weather_page.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Weather/weather_page.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Event/event_page.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Event/event_page.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Web/web_page.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Web/web_page.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Screens/Sections/Parcours/parcours_page.dart';
|
||||||
import 'package:mymuseum_visitapp/app_context.dart';
|
import 'package:mymuseum_visitapp/app_context.dart';
|
||||||
import 'package:mymuseum_visitapp/client.dart';
|
import 'package:mymuseum_visitapp/client.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@ -159,6 +160,9 @@ class _SectionPageState extends State<SectionPage> {
|
|||||||
case SectionType.Event:
|
case SectionType.Event:
|
||||||
SectionEventDTO eventDTO = SectionEventDTO.fromJson(sectionResult)!;
|
SectionEventDTO eventDTO = SectionEventDTO.fromJson(sectionResult)!;
|
||||||
return EventPage(section: eventDTO, visitAppContextIn: visitAppContext);
|
return EventPage(section: eventDTO, visitAppContextIn: visitAppContext);
|
||||||
|
case SectionType.Parcours:
|
||||||
|
ParcoursDTO parcoursDTO = ParcoursDTO.fromJson(sectionResult)!;
|
||||||
|
return ParcoursPage(section: parcoursDTO, visitAppContextIn: visitAppContext);
|
||||||
default:
|
default:
|
||||||
return const Center(child: Text("Unsupported type"));
|
return const Center(child: Text("Unsupported type"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,11 +15,15 @@ class MyInfoMateLlmClient implements LlmClient {
|
|||||||
late final AssistantService _service;
|
late final AssistantService _service;
|
||||||
|
|
||||||
MyInfoMateLlmClient({required this.visitAppContext}) {
|
MyInfoMateLlmClient({required this.visitAppContext}) {
|
||||||
_service = AssistantService(visitAppContext: visitAppContext);
|
_service = AssistantService(
|
||||||
|
visitAppContext: visitAppContext,
|
||||||
|
maxHistory: 6,
|
||||||
|
inactivityTimeout: const Duration(minutes: 5),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> chat(
|
Future<({String reply, bool expectsReply})> chat(
|
||||||
String message, {
|
String message, {
|
||||||
String? configurationId,
|
String? configurationId,
|
||||||
String languageCode = 'FR',
|
String languageCode = 'FR',
|
||||||
@ -30,15 +34,13 @@ class MyInfoMateLlmClient implements LlmClient {
|
|||||||
'configId=$cfgId lang=$languageCode apiKey=${visitAppContext.apiKey != null ? "set" : "null"}');
|
'configId=$cfgId lang=$languageCode apiKey=${visitAppContext.apiKey != null ? "set" : "null"}');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// AppType.Mobile + isVoice:true → backend adapte le prompt (audio-friendly)
|
|
||||||
// sans navigation, sans markdown, dates en toutes lettres
|
|
||||||
final response = await _service.chatWithAppType(
|
final response = await _service.chatWithAppType(
|
||||||
message: message,
|
message: message,
|
||||||
configurationId: cfgId,
|
configurationId: cfgId,
|
||||||
appType: AppType.Mobile,
|
appType: AppType.Mobile,
|
||||||
isVoice: true,
|
isVoice: true,
|
||||||
);
|
);
|
||||||
return response.reply;
|
return (reply: response.reply, expectsReply: response.expectsReply);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[MyInfoMateLlmClient] error: $e');
|
debugPrint('[MyInfoMateLlmClient] error: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|||||||
@ -7,12 +7,9 @@ import 'package:permission_handler/permission_handler.dart';
|
|||||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
|
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
|
||||||
|
|
||||||
/// Wake word via OpenWakeWord — pipeline TFLite+ONNX natif Android.
|
/// Wake word via OpenWakeWord — pipeline TFLite+ONNX natif Android.
|
||||||
///
|
/// Supporte plusieurs modèles en parallèle (même pipeline mel→embedding partagé).
|
||||||
/// 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 {
|
class NativeWakeWordEngine implements WakeWordEngine {
|
||||||
final String modelName;
|
final List<String> modelNames;
|
||||||
|
|
||||||
static const MethodChannel _channel =
|
static const MethodChannel _channel =
|
||||||
MethodChannel('be.unov.mymuseum/wake_word');
|
MethodChannel('be.unov.mymuseum/wake_word');
|
||||||
@ -21,82 +18,86 @@ class NativeWakeWordEngine implements WakeWordEngine {
|
|||||||
|
|
||||||
StreamSubscription? _subscription;
|
StreamSubscription? _subscription;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
bool _nativeServiceAlive = false; // vrai si le foreground service tourne (même en pause)
|
bool _nativeServiceAlive = false;
|
||||||
|
|
||||||
NativeWakeWordEngine({this.modelName = 'hey_visit'});
|
NativeWakeWordEngine({
|
||||||
|
List<String>? modelNames,
|
||||||
|
String? modelName,
|
||||||
|
}) : modelNames = modelNames ?? (modelName != null ? [modelName] : ['hey_visit']);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> start({
|
Future<void> start({
|
||||||
required void Function() onDetected,
|
required void Function() onDetected,
|
||||||
void Function(String command)? onDetectedWithCommand,
|
void Function(String command)? onDetectedWithCommand,
|
||||||
}) async {
|
}) async {
|
||||||
if (_subscription != null) {
|
if (_subscription != null) return;
|
||||||
// Déjà abonné — rien à faire
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_nativeServiceAlive) {
|
if (_nativeServiceAlive) {
|
||||||
// Service natif en pause — reprendre l'AudioRecord et se re-souscrire
|
|
||||||
debugPrint('[NativeWakeWordEngine] Resuming native AudioRecord');
|
debugPrint('[NativeWakeWordEngine] Resuming native AudioRecord');
|
||||||
try { await _channel.invokeMethod('resume'); } catch (_) {}
|
try { await _channel.invokeMethod('resume'); } catch (_) {}
|
||||||
_running = true;
|
_running = true;
|
||||||
_subscription = _events.receiveBroadcastStream().listen((event) {
|
_subscription = _events.receiveBroadcastStream().listen((event) {
|
||||||
if (event == 'detected') {
|
_handleEvent(event as String, onDetected, onDetectedWithCommand);
|
||||||
debugPrint('[NativeWakeWordEngine] "$modelName" detected!');
|
|
||||||
onDetected();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permission micro requise avant de démarrer le foreground service (Android 14+)
|
|
||||||
final micStatus = await Permission.microphone.request();
|
final micStatus = await Permission.microphone.request();
|
||||||
if (!micStatus.isGranted) {
|
if (!micStatus.isGranted) {
|
||||||
debugPrint('[NativeWakeWordEngine] Microphone permission denied');
|
debugPrint('[NativeWakeWordEngine] Microphone permission denied');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extraire les modèles vers le cache natif (accessible par le Service)
|
|
||||||
await _extractModels();
|
await _extractModels();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _channel.invokeMethod('start', {'modelName': modelName});
|
await _channel.invokeMethod('start', {'modelNames': modelNames.join(',')});
|
||||||
_running = true;
|
_running = true;
|
||||||
_nativeServiceAlive = true;
|
_nativeServiceAlive = true;
|
||||||
|
|
||||||
_subscription = _events.receiveBroadcastStream().listen((event) {
|
_subscription = _events.receiveBroadcastStream().listen((event) {
|
||||||
if (event == 'detected') {
|
_handleEvent(event as String, onDetected, onDetectedWithCommand);
|
||||||
debugPrint('[NativeWakeWordEngine] "$modelName" detected!');
|
}, onError: (e) {
|
||||||
onDetected();
|
debugPrint('[NativeWakeWordEngine] Event error: $e');
|
||||||
} else if (event == 'error') {
|
|
||||||
debugPrint('[NativeWakeWordEngine] Service error — restarting listener');
|
|
||||||
// Tente de relancer après une erreur
|
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
if (_running) start(onDetected: onDetected, onDetectedWithCommand: onDetectedWithCommand);
|
if (_running) start(onDetected: onDetected, onDetectedWithCommand: onDetectedWithCommand);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
debugPrint('[NativeWakeWordEngine] Started — model: $modelName');
|
debugPrint('[NativeWakeWordEngine] Started — models: $modelNames');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NativeWakeWordEngine] start error: $e');
|
debugPrint('[NativeWakeWordEngine] start error: $e');
|
||||||
_running = false;
|
_running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause l'écoute côté Flutter ET libère l'AudioRecord natif pour que le STT puisse accéder au micro.
|
void _handleEvent(
|
||||||
/// Le foreground service reste vivant → le silent AudioTrack continue, MIUI ne mute pas.
|
String event,
|
||||||
|
void Function() onDetected,
|
||||||
|
void Function(String)? onDetectedWithCommand,
|
||||||
|
) {
|
||||||
|
if (event.startsWith('detected:')) {
|
||||||
|
final name = event.substring('detected:'.length);
|
||||||
|
debugPrint('[NativeWakeWordEngine] "$name" detected!');
|
||||||
|
if (onDetectedWithCommand != null) {
|
||||||
|
onDetectedWithCommand(name);
|
||||||
|
} else {
|
||||||
|
onDetected();
|
||||||
|
}
|
||||||
|
} else if (event == 'error') {
|
||||||
|
debugPrint('[NativeWakeWordEngine] Service error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
_subscription = null;
|
_subscription = null;
|
||||||
_running = false;
|
_running = false;
|
||||||
// Libère l'AudioRecord pour le STT — service et silent track restent vivants
|
|
||||||
try { await _channel.invokeMethod('pause'); } catch (_) {}
|
try { await _channel.invokeMethod('pause'); } catch (_) {}
|
||||||
debugPrint('[NativeWakeWordEngine] Paused — AudioRecord released for STT');
|
debugPrint('[NativeWakeWordEngine] Paused — AudioRecord released for STT');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arrêt complet du service natif (appeler uniquement à la fermeture de l'app).
|
|
||||||
Future<void> stopNativeService() async {
|
Future<void> stopNativeService() async {
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
_subscription = null;
|
_subscription = null;
|
||||||
@ -106,30 +107,27 @@ class NativeWakeWordEngine implements WakeWordEngine {
|
|||||||
debugPrint('[NativeWakeWordEngine] Native service stopped');
|
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 {
|
Future<void> _extractModels() async {
|
||||||
final dir = await getTemporaryDirectory();
|
final dir = await getTemporaryDirectory();
|
||||||
final models = [
|
final assets = [
|
||||||
'assets/files/melspectrogram.onnx',
|
'assets/files/melspectrogram.onnx',
|
||||||
'assets/files/embedding_model.tflite',
|
'assets/files/embedding_model.tflite',
|
||||||
'assets/files/$modelName.tflite',
|
...modelNames.map((n) => 'assets/files/$n.tflite'),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (final assetPath in models) {
|
for (final assetPath in assets) {
|
||||||
final fileName = assetPath.split('/').last;
|
final fileName = assetPath.split('/').last;
|
||||||
final dest = File('${dir.path}/$fileName');
|
final dest = File('${dir.path}/$fileName');
|
||||||
if (dest.existsSync()) continue; // déjà extrait
|
if (dest.existsSync()) continue;
|
||||||
try {
|
try {
|
||||||
final data = await rootBundle.load(assetPath);
|
final data = await rootBundle.load(assetPath);
|
||||||
await dest.writeAsBytes(data.buffer.asUint8List(), flush: true);
|
await dest.writeAsBytes(data.buffer.asUint8List(), flush: true);
|
||||||
debugPrint('[NativeWakeWordEngine] Extracted: $fileName → ${dir.path}');
|
debugPrint('[NativeWakeWordEngine] Extracted: $fileName');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[NativeWakeWordEngine] Failed to extract $fileName: $e');
|
debugPrint('[NativeWakeWordEngine] Failed to extract $fileName: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passe le chemin du cache au service natif
|
|
||||||
await _channel.invokeMethod('setCacheDir', {'path': dir.path});
|
await _channel.invokeMethod('setCacheDir', {'path': dir.path});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,23 +4,30 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
|
import 'package:mymuseum_visitapp/Services/Glasses/engines/wake_word_engine.dart';
|
||||||
|
|
||||||
/// Wake word via OpenWakeWord — service Android natif (foreground service).
|
/// Wake word via OpenWakeWord — service Android natif (foreground service).
|
||||||
/// Utilise ONNX Runtime pour l'inférence on-device du modèle hey_visit.onnx.
|
/// Supporte plusieurs modèles en parallèle : un seul AudioRecord + pipeline
|
||||||
/// Survit au background Android contrairement au STT continu.
|
/// mel→embedding partagée, N classifieurs TFLite tournant simultanément.
|
||||||
///
|
///
|
||||||
/// iOS non supporté — fallback sur SpeechToTextWakeWordEngine côté iOS.
|
/// Événements reçus : "detected:hey_marco", "detected:hey_alba", etc.
|
||||||
|
/// → onDetectedWithCommand("hey_marco") si fourni, sinon onDetected()
|
||||||
|
///
|
||||||
|
/// iOS non supporté — fallback sur SpeechToTextWakeWordEngine.
|
||||||
class OpenWakeWordEngine implements WakeWordEngine {
|
class OpenWakeWordEngine implements WakeWordEngine {
|
||||||
static const _methodChannel = MethodChannel('be.unov.mymuseum/wake_word');
|
static const _methodChannel = MethodChannel('be.unov.mymuseum/wake_word');
|
||||||
static const _eventChannel = EventChannel('be.unov.mymuseum/wake_word_events');
|
static const _eventChannel = EventChannel('be.unov.mymuseum/wake_word_events');
|
||||||
|
|
||||||
/// Nom du fichier .tflite dans assets/files (sans extension).
|
/// Un ou plusieurs modèles .tflite dans assets/files (sans extension).
|
||||||
/// Ex: "hey_visit", "hey_museum", "hey_guide"
|
/// Ex: ['hey_marco'] ou ['hey_alba', 'hey_marco', 'hey_vasco']
|
||||||
final String modelName;
|
final List<String> modelNames;
|
||||||
|
|
||||||
StreamSubscription<dynamic>? _subscription;
|
StreamSubscription<dynamic>? _subscription;
|
||||||
void Function()? _onDetected;
|
void Function()? _onDetected;
|
||||||
|
void Function(String modelName)? _onModelDetected;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
|
|
||||||
OpenWakeWordEngine({this.modelName = 'hey_visit'});
|
OpenWakeWordEngine({
|
||||||
|
List<String>? modelNames,
|
||||||
|
String? modelName,
|
||||||
|
}) : modelNames = modelNames ?? (modelName != null ? [modelName] : ['hey_visit']);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> start({
|
Future<void> start({
|
||||||
@ -34,22 +41,29 @@ class OpenWakeWordEngine implements WakeWordEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_onDetected = onDetected;
|
_onDetected = onDetected;
|
||||||
|
_onModelDetected = onDetectedWithCommand;
|
||||||
_running = true;
|
_running = true;
|
||||||
|
|
||||||
_subscription = _eventChannel.receiveBroadcastStream().listen(
|
_subscription = _eventChannel.receiveBroadcastStream().listen(
|
||||||
(event) {
|
(event) {
|
||||||
if (event == 'detected' && _running) {
|
if (!_running || event is! String) return;
|
||||||
debugPrint('[OpenWakeWordEngine] Wake word detected');
|
if (event.startsWith('detected:')) {
|
||||||
// OpenWakeWord ne capture pas la commande inline — l'orchestrateur
|
final name = event.substring('detected:'.length);
|
||||||
// lance un cycle STT séparé via onDetected()
|
debugPrint('[OpenWakeWordEngine] Wake word detected: $name');
|
||||||
|
if (_onModelDetected != null) {
|
||||||
|
_onModelDetected!(name);
|
||||||
|
} else {
|
||||||
_onDetected?.call();
|
_onDetected?.call();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (e) => debugPrint('[OpenWakeWordEngine] Event error: $e'),
|
onError: (e) => debugPrint('[OpenWakeWordEngine] Event error: $e'),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _methodChannel.invokeMethod('start', {'modelName': modelName});
|
await _methodChannel.invokeMethod('start', {
|
||||||
debugPrint('[OpenWakeWordEngine] Service started (model: $modelName)');
|
'modelNames': modelNames.join(','),
|
||||||
|
});
|
||||||
|
debugPrint('[OpenWakeWordEngine] Service started (models: $modelNames)');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -58,9 +72,7 @@ class OpenWakeWordEngine implements WakeWordEngine {
|
|||||||
_running = false;
|
_running = false;
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
_subscription = null;
|
_subscription = null;
|
||||||
if (_isAndroid) {
|
if (_isAndroid) await _methodChannel.invokeMethod('stop');
|
||||||
await _methodChannel.invokeMethod('stop');
|
|
||||||
}
|
|
||||||
debugPrint('[OpenWakeWordEngine] Service stopped');
|
debugPrint('[OpenWakeWordEngine] Service stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,9 @@
|
|||||||
/// - ClaudeClient (Anthropic API — upgrade futur)
|
/// - ClaudeClient (Anthropic API — upgrade futur)
|
||||||
/// - OpenAiClient (upgrade futur)
|
/// - OpenAiClient (upgrade futur)
|
||||||
abstract class LlmClient {
|
abstract class LlmClient {
|
||||||
/// Envoie un message et retourne la réponse complète.
|
/// Envoie un message et retourne la réponse avec un flag indiquant si le
|
||||||
Future<String> chat(
|
/// visiteur est attendu à répondre (false = info pure, fin de conversation).
|
||||||
|
Future<({String reply, bool expectsReply})> chat(
|
||||||
String message, {
|
String message, {
|
||||||
String? configurationId,
|
String? configurationId,
|
||||||
String languageCode = 'FR',
|
String languageCode = 'FR',
|
||||||
|
|||||||
@ -1,502 +1,6 @@
|
|||||||
import 'dart:async';
|
// Renommé en VoiceOrchestrator — ce fichier reste pour la compatibilité des imports existants.
|
||||||
import 'dart:io';
|
import 'package:mymuseum_visitapp/Services/Glasses/voice_orchestrator.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
export 'voice_orchestrator.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.
|
// Alias de compatibilité
|
||||||
/// Initialisée dans main.dart après la connexion lunettes.
|
typedef GlassesOrchestrator = VoiceOrchestrator;
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
408
lib/Services/Glasses/voice_orchestrator.dart
Normal file
408
lib/Services/Glasses/voice_orchestrator.dart
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
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.
|
||||||
|
/// Gérée par VoiceController — ne pas modifier directement.
|
||||||
|
VoiceOrchestrator? activeVoiceOrchestrator;
|
||||||
|
|
||||||
|
/// 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 VoiceOrchestrator {
|
||||||
|
final VisitAppContext visitAppContext;
|
||||||
|
final WakeWordEngine wakeWordEngine;
|
||||||
|
final SttEngine sttEngine;
|
||||||
|
final TtsEngine ttsEngine;
|
||||||
|
final LlmClient llmClient;
|
||||||
|
|
||||||
|
bool _running = false;
|
||||||
|
bool _inConversation = false;
|
||||||
|
|
||||||
|
final ValueNotifier<String> lastTranscription = ValueNotifier('');
|
||||||
|
final ValueNotifier<bool> isListeningForCommand = ValueNotifier(false);
|
||||||
|
final ValueNotifier<String> lastTtsText = ValueNotifier('');
|
||||||
|
final ValueNotifier<List<String>> visitPhotosNotifier = ValueNotifier([]);
|
||||||
|
final ValueNotifier<String?> lastQrScanPhoto = ValueNotifier(null);
|
||||||
|
|
||||||
|
final List<String> visitPhotos = [];
|
||||||
|
|
||||||
|
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();
|
||||||
|
final AudioPlayer _thinkingPlayer = AudioPlayer();
|
||||||
|
|
||||||
|
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]+)');
|
||||||
|
|
||||||
|
VoiceOrchestrator({
|
||||||
|
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('[VoiceOrchestrator] Started');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
await wakeWordEngine.stop();
|
||||||
|
await sttEngine.cancel();
|
||||||
|
await ttsEngine.stop();
|
||||||
|
_running = false;
|
||||||
|
debugPrint('[VoiceOrchestrator] Stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isRunning => _running;
|
||||||
|
bool get isInConversation => _inConversation;
|
||||||
|
bool get isListening => _running && !_inConversation;
|
||||||
|
|
||||||
|
Future<void> restartWakeWord() async {
|
||||||
|
if (!_running || _inConversation) return;
|
||||||
|
await wakeWordEngine.start(
|
||||||
|
onDetected: _onWakeWord,
|
||||||
|
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> triggerConversation() => _handleConversation();
|
||||||
|
Future<void> dispatchCommand(String command) => _dispatch(command);
|
||||||
|
|
||||||
|
Future<void> triggerQrScan(String imagePath) async {
|
||||||
|
final qr = await _tryDecodeQr(imagePath);
|
||||||
|
if (qr != null) await explainSection(qr.sectionId, configurationId: qr.configId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wake word ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _onWakeWord() async {
|
||||||
|
if (_inConversation) return;
|
||||||
|
_inConversation = true;
|
||||||
|
await wakeWordEngine.stop();
|
||||||
|
await _stopThinkingLoop();
|
||||||
|
_playWakeSound();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
try {
|
||||||
|
await _handleConversation();
|
||||||
|
} finally {
|
||||||
|
_inConversation = false;
|
||||||
|
if (_running) await wakeWordEngine.start(
|
||||||
|
onDetected: _onWakeWord,
|
||||||
|
onDetectedWithCommand: _onWakeWordWithCommand,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onWakeWordWithCommand(String inlineCommand) async {
|
||||||
|
if (_inConversation) return;
|
||||||
|
_inConversation = true;
|
||||||
|
await wakeWordEngine.stop();
|
||||||
|
await _stopThinkingLoop();
|
||||||
|
_playWakeSound();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
try {
|
||||||
|
if (inlineCommand.isNotEmpty) {
|
||||||
|
debugPrint('[VoiceOrchestrator] 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();
|
||||||
|
} catch (_) {
|
||||||
|
debugPrint('[VoiceOrchestrator] 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();
|
||||||
|
} catch (_) {
|
||||||
|
debugPrint('[VoiceOrchestrator] 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('[VoiceOrchestrator] 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('[VoiceOrchestrator] Annulé: "$command"');
|
||||||
|
await _stopThinkingLoop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isQrScanCommand(command)) { await _handleQrScan(); return; }
|
||||||
|
if (_isPhotoCommand(command)) { await _handlePhotoCapture(); return; }
|
||||||
|
|
||||||
|
if (_isRepeatCommand(command)) {
|
||||||
|
await ttsEngine.replay();
|
||||||
|
} else {
|
||||||
|
bool expectsReply = true;
|
||||||
|
try {
|
||||||
|
await _startThinkingLoop();
|
||||||
|
final result = await llmClient.chat(
|
||||||
|
command,
|
||||||
|
configurationId: visitAppContext.configuration?.id,
|
||||||
|
languageCode: lang,
|
||||||
|
);
|
||||||
|
expectsReply = result.expectsReply;
|
||||||
|
await _stopThinkingLoop();
|
||||||
|
if (result.reply.isNotEmpty) {
|
||||||
|
lastTtsText.value = result.reply;
|
||||||
|
await _playDoneSound();
|
||||||
|
await ttsEngine.speak(result.reply, languageCode: langCode);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await _stopThinkingLoop();
|
||||||
|
debugPrint('[VoiceOrchestrator] LLM error: $e');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!expectsReply) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (continueConversation) await _listenForFollowUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) return;
|
||||||
|
await _dispatch(followUp, continueConversation: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QR scan ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _handleQrScan() async {
|
||||||
|
final lang = _toLangCode(visitAppContext.language ?? 'FR');
|
||||||
|
final completer = Completer<String?>();
|
||||||
|
final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
|
||||||
|
MetaGlassesService.instance.onPhotoCaptured = (path) {
|
||||||
|
if (!completer.isCompleted) completer.complete(path.isEmpty ? null : path);
|
||||||
|
prevCallback?.call(path);
|
||||||
|
};
|
||||||
|
Timer(const Duration(seconds: 25), () {
|
||||||
|
if (!completer.isCompleted) completer.complete(null);
|
||||||
|
});
|
||||||
|
unawaited(MetaGlassesService.instance.requestPhotoCapture());
|
||||||
|
final photoPath = await completer.future;
|
||||||
|
MetaGlassesService.instance.onPhotoCaptured = prevCallback;
|
||||||
|
|
||||||
|
if (photoPath == null) {
|
||||||
|
lastTtsText.value = TranslationHelper.getFromLocale('voice.cameraUnavailable', visitAppContext);
|
||||||
|
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastQrScanPhoto.value != null && lastQrScanPhoto.value != photoPath) {
|
||||||
|
try { File(lastQrScanPhoto.value!).deleteSync(); } catch (_) {}
|
||||||
|
}
|
||||||
|
lastQrScanPhoto.value = photoPath;
|
||||||
|
|
||||||
|
final qr = await _tryDecodeQr(photoPath);
|
||||||
|
if (qr != null) {
|
||||||
|
await explainSection(qr.sectionId, configurationId: qr.configId);
|
||||||
|
} else {
|
||||||
|
lastTtsText.value = TranslationHelper.getFromLocale('voice.noQrFound', visitAppContext);
|
||||||
|
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handlePhotoCapture() async {
|
||||||
|
final completer = Completer<String?>();
|
||||||
|
final prevCallback = MetaGlassesService.instance.onPhotoCaptured;
|
||||||
|
MetaGlassesService.instance.onPhotoCaptured = (path) {
|
||||||
|
if (!completer.isCompleted) completer.complete(path);
|
||||||
|
prevCallback?.call(path);
|
||||||
|
};
|
||||||
|
Timer(const Duration(seconds: 25), () {
|
||||||
|
if (!completer.isCompleted) completer.complete(null);
|
||||||
|
});
|
||||||
|
unawaited(MetaGlassesService.instance.requestPhotoCapture());
|
||||||
|
final photoPath = await completer.future;
|
||||||
|
MetaGlassesService.instance.onPhotoCaptured = prevCallback;
|
||||||
|
|
||||||
|
if (photoPath == null || photoPath.isEmpty) {
|
||||||
|
final lang = _toLangCode(visitAppContext.language ?? 'FR');
|
||||||
|
lastTtsText.value = TranslationHelper.getFromLocale('voice.photoFailed', visitAppContext);
|
||||||
|
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final qr = await _tryDecodeQr(photoPath);
|
||||||
|
if (qr != null) {
|
||||||
|
await explainSection(qr.sectionId, configurationId: qr.configId);
|
||||||
|
} else {
|
||||||
|
visitPhotos.add(photoPath);
|
||||||
|
visitPhotosNotifier.value = List.unmodifiable(visitPhotos);
|
||||||
|
final lang = _toLangCode(visitAppContext.language ?? 'FR');
|
||||||
|
lastTtsText.value = TranslationHelper.getFromLocale('voice.photoCaptured', visitAppContext);
|
||||||
|
await ttsEngine.speak(lastTtsText.value, languageCode: lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<({String sectionId, String? configId})?> _tryDecodeQr(String imagePath) async {
|
||||||
|
final controller = MobileScannerController();
|
||||||
|
({String sectionId, String? configId})? result;
|
||||||
|
try {
|
||||||
|
final completer = Completer<({String sectionId, String? configId})?>();
|
||||||
|
final sub = controller.barcodes.listen((capture) {
|
||||||
|
for (final barcode in capture.barcodes) {
|
||||||
|
final raw = barcode.rawValue;
|
||||||
|
if (raw != null) {
|
||||||
|
final ids = _extractQrIds(raw);
|
||||||
|
if (ids != null && !completer.isCompleted) completer.complete(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await controller.analyzeImage(imagePath);
|
||||||
|
Timer(const Duration(seconds: 2), () {
|
||||||
|
if (!completer.isCompleted) completer.complete(null);
|
||||||
|
});
|
||||||
|
result = await completer.future;
|
||||||
|
await sub.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VoiceOrchestrator] QR decode error: $e');
|
||||||
|
} finally {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
({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));
|
||||||
|
if (visitAppContext.sectionIds != null) {
|
||||||
|
return visitAppContext.sectionIds!.contains(raw)
|
||||||
|
? (sectionId: raw, configId: visitAppContext.configuration?.id)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
final cfgId = configurationId ?? visitAppContext.configuration?.id;
|
||||||
|
final lang = visitAppContext.language ?? 'FR';
|
||||||
|
try {
|
||||||
|
final result = 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.',
|
||||||
|
configurationId: cfgId,
|
||||||
|
languageCode: lang,
|
||||||
|
);
|
||||||
|
if (result.reply.isNotEmpty) {
|
||||||
|
lastTtsText.value = result.reply;
|
||||||
|
await ttsEngine.speak(result.reply, languageCode: _toLangCode(lang));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VoiceOrchestrator] 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,17 +1,33 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.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/Models/AssistantResponse.dart';
|
import 'package:mymuseum_visitapp/Models/AssistantResponse.dart';
|
||||||
|
|
||||||
class AssistantService {
|
class AssistantService {
|
||||||
final VisitAppContext visitAppContext;
|
final VisitAppContext visitAppContext;
|
||||||
final List<AiChatMessage> history = [];
|
|
||||||
|
|
||||||
AssistantService({required this.visitAppContext});
|
/// Nombre maximum de messages conservés dans l'historique envoyé au backend.
|
||||||
|
final int maxHistory;
|
||||||
|
|
||||||
|
/// Durée d'inactivité après laquelle l'historique est automatiquement vidé.
|
||||||
|
/// null = pas de vidage automatique.
|
||||||
|
final Duration? inactivityTimeout;
|
||||||
|
|
||||||
|
final List<AiChatMessage> _history = [];
|
||||||
|
Timer? _inactivityTimer;
|
||||||
|
|
||||||
|
AssistantService({
|
||||||
|
required this.visitAppContext,
|
||||||
|
this.maxHistory = 10,
|
||||||
|
this.inactivityTimeout = const Duration(minutes: 5),
|
||||||
|
});
|
||||||
|
|
||||||
Future<AssistantResponse> chat({
|
Future<AssistantResponse> chat({
|
||||||
required String message,
|
required String message,
|
||||||
String? configurationId,
|
String? configurationId,
|
||||||
}) => chatWithAppType(message: message, configurationId: configurationId);
|
bool isVoice = false,
|
||||||
|
}) => chatWithAppType(message: message, configurationId: configurationId, isVoice: isVoice);
|
||||||
|
|
||||||
Future<AssistantResponse> chatWithAppType({
|
Future<AssistantResponse> chatWithAppType({
|
||||||
required String message,
|
required String message,
|
||||||
@ -19,13 +35,15 @@ class AssistantService {
|
|||||||
AppType appType = AppType.Mobile,
|
AppType appType = AppType.Mobile,
|
||||||
bool isVoice = false,
|
bool isVoice = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
_resetInactivityTimer();
|
||||||
|
|
||||||
final request = AiChatRequest(
|
final request = AiChatRequest(
|
||||||
message: message,
|
message: message,
|
||||||
instanceId: visitAppContext.instanceId,
|
instanceId: visitAppContext.instanceId,
|
||||||
appType: appType,
|
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,
|
isVoice: isVoice,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -35,7 +53,7 @@ class AssistantService {
|
|||||||
throw Exception('Empty response from assistant');
|
throw Exception('Empty response from assistant');
|
||||||
}
|
}
|
||||||
|
|
||||||
print("AI raw response: reply='${response.reply}' navigation.sectionId='${response.navigation?.sectionId}' navigation.sectionTitle='${response.navigation?.sectionTitle}' navigation.sectionType='${response.navigation?.sectionType}' cards=${response.cards?.length}");
|
debugPrint("AI raw response: reply='${response.reply}' navigation.sectionId='${response.navigation?.sectionId}' navigation.sectionTitle='${response.navigation?.sectionTitle}' navigation.sectionType='${response.navigation?.sectionType}' cards=${response.cards?.length}");
|
||||||
|
|
||||||
final result = AssistantResponse(
|
final result = AssistantResponse(
|
||||||
reply: response.reply ?? '',
|
reply: response.reply ?? '',
|
||||||
@ -54,12 +72,35 @@ class AssistantService {
|
|||||||
imageUrl: response.navigation!.imageUrl,
|
imageUrl: response.navigation!.imageUrl,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
expectsReply: response.expectsReply ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
history.add(AiChatMessage(role: 'user', content: message));
|
_history.add(AiChatMessage(role: 'user', content: message));
|
||||||
history.add(AiChatMessage(role: 'assistant', content: result.reply));
|
_history.add(AiChatMessage(role: 'assistant', content: result.reply));
|
||||||
|
|
||||||
|
// Cap local — inutile de garder plus que maxHistory côté client
|
||||||
|
if (_history.length > maxHistory) {
|
||||||
|
_history.removeRange(0, _history.length - maxHistory);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearHistory() => history.clear();
|
void _resetInactivityTimer() {
|
||||||
|
if (inactivityTimeout == null) return;
|
||||||
|
_inactivityTimer?.cancel();
|
||||||
|
_inactivityTimer = Timer(inactivityTimeout!, () {
|
||||||
|
debugPrint('[AssistantService] Inactivity timeout — clearing history');
|
||||||
|
clearHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearHistory() {
|
||||||
|
_history.clear();
|
||||||
|
_inactivityTimer?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_inactivityTimer?.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -203,11 +203,12 @@ class GeoBeaconTriggerService {
|
|||||||
final response = await _assistantService!.chat(
|
final response = await _assistantService!.chat(
|
||||||
message: prompt,
|
message: prompt,
|
||||||
configurationId: _visitAppContext?.configuration?.id,
|
configurationId: _visitAppContext?.configuration?.id,
|
||||||
|
isVoice: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.reply.isNotEmpty) {
|
if (response.reply.isNotEmpty) {
|
||||||
final lang = _visitAppContext?.language ?? 'FR';
|
final lang = _visitAppContext?.language ?? 'FR';
|
||||||
await activeOrchestrator?.ttsEngine.speak(
|
await activeVoiceOrchestrator?.ttsEngine.speak(
|
||||||
response.reply,
|
response.reply,
|
||||||
languageCode: _toLangCode(lang),
|
languageCode: _toLangCode(lang),
|
||||||
);
|
);
|
||||||
|
|||||||
119
lib/Services/voice_controller.dart
Normal file
119
lib/Services/voice_controller.dart
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Models/visitContext.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';
|
||||||
|
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/myinfomate_llm_client.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_stt.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/speech_to_text_wake_word.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/Glasses/engines/impl/whisper_stt_engine.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/Glasses/glasses_background_service.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/Glasses/voice_orchestrator.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
|
||||||
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
|
|
||||||
|
enum VoiceMode { none, voiceOnly, glasses }
|
||||||
|
|
||||||
|
class VoiceController extends ChangeNotifier {
|
||||||
|
VoiceMode _mode = VoiceMode.none;
|
||||||
|
VoiceMode get mode => _mode;
|
||||||
|
|
||||||
|
bool _isActivating = false;
|
||||||
|
bool get isActivating => _isActivating;
|
||||||
|
|
||||||
|
VoiceController() {
|
||||||
|
MetaGlassesService.instance.state.addListener(_onGlassesStateChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isFeatureAvailable(VisitAppContext ctx) =>
|
||||||
|
ctx.applicationInstanceDTO?.isAssistant == true;
|
||||||
|
|
||||||
|
bool get isGlassesConnected =>
|
||||||
|
MetaGlassesService.instance.state.value == GlassesState.connected ||
|
||||||
|
MetaGlassesService.instance.state.value == GlassesState.streaming;
|
||||||
|
|
||||||
|
/// Active le mode demandé. Si un mode est déjà actif, le stoppe d'abord.
|
||||||
|
Future<void> activate(VoiceMode targetMode, VisitAppContext ctx) async {
|
||||||
|
if (_isActivating) return;
|
||||||
|
_isActivating = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _stopOrchestrator();
|
||||||
|
|
||||||
|
if (targetMode == VoiceMode.glasses) {
|
||||||
|
await GlassesBackgroundService.initialize();
|
||||||
|
await GlassesBackgroundService.start();
|
||||||
|
await MetaGlassesService.instance.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildOrchestrator(ctx);
|
||||||
|
await activeVoiceOrchestrator!.start();
|
||||||
|
_mode = targetMode;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VoiceController] activate error: $e');
|
||||||
|
await _stopOrchestrator();
|
||||||
|
_mode = VoiceMode.none;
|
||||||
|
} finally {
|
||||||
|
_isActivating = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deactivate() async {
|
||||||
|
await _stopOrchestrator();
|
||||||
|
_mode = VoiceMode.none;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relance le wake word après un retour en foreground.
|
||||||
|
void restartWakeWordIfNeeded() {
|
||||||
|
final o = activeVoiceOrchestrator;
|
||||||
|
if (o != null && o.isRunning && !o.isInConversation) {
|
||||||
|
o.restartWakeWord();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _stopOrchestrator() async {
|
||||||
|
if (activeVoiceOrchestrator != null) {
|
||||||
|
await activeVoiceOrchestrator!.stop();
|
||||||
|
activeVoiceOrchestrator = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _buildOrchestrator(VisitAppContext ctx) {
|
||||||
|
activeVoiceOrchestrator = VoiceOrchestrator(
|
||||||
|
visitAppContext: ctx,
|
||||||
|
wakeWordEngine: Platform.isAndroid
|
||||||
|
? NativeWakeWordEngine(modelNames: ['hey_marco', 'hey_alba', 'hey_vasco'])
|
||||||
|
: SpeechToTextWakeWordEngine(keyword: 'visite'),
|
||||||
|
sttEngine: kWhisperApiKey.isNotEmpty
|
||||||
|
? WhisperSttEngine(apiKey: kWhisperApiKey, endpoint: kWhisperEndpoint)
|
||||||
|
: SpeechToTextSttEngine(),
|
||||||
|
ttsEngine: kGeminiApiKey.isNotEmpty
|
||||||
|
? GeminiTtsEngine(
|
||||||
|
apiKey: kGeminiApiKey,
|
||||||
|
voiceName: kGeminiTtsVoice,
|
||||||
|
voicePrompt: kGeminiTtsPrompt,
|
||||||
|
)
|
||||||
|
: FlutterTtsEngine(),
|
||||||
|
llmClient: MyInfoMateLlmClient(visitAppContext: ctx),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onGlassesStateChanged() {
|
||||||
|
if (_mode == VoiceMode.glasses &&
|
||||||
|
MetaGlassesService.instance.state.value == GlassesState.disconnected) {
|
||||||
|
debugPrint('[VoiceController] Glasses disconnected — deactivating');
|
||||||
|
deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
MetaGlassesService.instance.state.removeListener(_onGlassesStateChanged);
|
||||||
|
_stopOrchestrator();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -125,6 +125,7 @@ class WakeWordService {
|
|||||||
final response = await _assistantService!.chat(
|
final response = await _assistantService!.chat(
|
||||||
message: command,
|
message: command,
|
||||||
configurationId: _assistantService!.visitAppContext.configuration?.id,
|
configurationId: _assistantService!.visitAppContext.configuration?.id,
|
||||||
|
isVoice: true,
|
||||||
);
|
);
|
||||||
if (response.reply.isNotEmpty) {
|
if (response.reply.isNotEmpty) {
|
||||||
await GlassesTtsService.instance.speak(
|
await GlassesTtsService.instance.speak(
|
||||||
@ -133,6 +134,9 @@ class WakeWordService {
|
|||||||
voiceId: kElevenLabsVoiceId,
|
voiceId: kElevenLabsVoiceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!response.expectsReply) {
|
||||||
|
debugPrint('[WakeWordService] No follow-up expected, conversation ends here.');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[WakeWordService] dispatch error: $e');
|
debugPrint('[WakeWordService] dispatch error: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,9 @@ class Client {
|
|||||||
SectionEventApi? _sectionEventApi;
|
SectionEventApi? _sectionEventApi;
|
||||||
SectionEventApi? get sectionEventApi => _sectionEventApi;
|
SectionEventApi? get sectionEventApi => _sectionEventApi;
|
||||||
|
|
||||||
|
SectionParcoursApi? _sectionParcoursApi;
|
||||||
|
SectionParcoursApi? get sectionParcoursApi => _sectionParcoursApi;
|
||||||
|
|
||||||
Client(String path, {String? apiKey}) {
|
Client(String path, {String? apiKey}) {
|
||||||
_apiClient = ApiClient(basePath: path);
|
_apiClient = ApiClient(basePath: path);
|
||||||
if (apiKey != null) _apiClient!.addDefaultHeader('X-Api-Key', apiKey);
|
if (apiKey != null) _apiClient!.addDefaultHeader('X-Api-Key', apiKey);
|
||||||
@ -62,5 +65,6 @@ class Client {
|
|||||||
_sectionAgendaApi = SectionAgendaApi(_apiClient);
|
_sectionAgendaApi = SectionAgendaApi(_apiClient);
|
||||||
_sectionMapApi = SectionMapApi(_apiClient);
|
_sectionMapApi = SectionMapApi(_apiClient);
|
||||||
_sectionEventApi = SectionEventApi(_apiClient);
|
_sectionEventApi = SectionEventApi(_apiClient);
|
||||||
|
_sectionParcoursApi = SectionParcoursApi(_apiClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -11,17 +11,9 @@ 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/voice_orchestrator.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/meta_glasses_service.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Services/voice_controller.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';
|
||||||
@ -114,9 +106,8 @@ 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
|
// Glasses SDK init — toujours initialisé pour détecter la connexion BT.
|
||||||
// TODO: remplacer glassesEnabled par un vrai toggle UI
|
// L'orchestrateur vocal ne démarre PAS automatiquement — c'est VoiceController qui gère ça.
|
||||||
localContext.glassesEnabled = true;
|
|
||||||
if (!Platform.isWindows && (Platform.isAndroid || Platform.isIOS)) {
|
if (!Platform.isWindows && (Platform.isAndroid || Platform.isIOS)) {
|
||||||
await MetaGlassesService.instance.initialize();
|
await MetaGlassesService.instance.initialize();
|
||||||
}
|
}
|
||||||
@ -156,49 +147,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
if (widget.visitAppContext.glassesEnabled &&
|
// Le mode vocal ne démarre plus automatiquement — l'utilisateur choisit via VoiceModeSheet.
|
||||||
!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
|
@override
|
||||||
@ -207,14 +156,10 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
super.dispose();
|
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
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
final o = activeOrchestrator;
|
final o = activeVoiceOrchestrator;
|
||||||
// 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) {
|
if (o != null && o.isRunning && !o.isInConversation) {
|
||||||
o.restartWakeWord();
|
o.restartWakeWord();
|
||||||
}
|
}
|
||||||
@ -225,8 +170,15 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Get.put(RequirementStateController());
|
Get.put(RequirementStateController());
|
||||||
|
|
||||||
return ChangeNotifierProvider<AppContext>(
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<AppContext>(
|
||||||
create: (_) => AppContext(widget.visitAppContext),
|
create: (_) => AppContext(widget.visitAppContext),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<VoiceController>(
|
||||||
|
create: (_) => VoiceController(),
|
||||||
|
),
|
||||||
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
title: 'Carnaval de Marche', //'Musée de la fraise' // Autres // 'Fort Saint Héribert'
|
title: 'Carnaval de Marche', //'Musée de la fraise' // Autres // 'Fort Saint Héribert'
|
||||||
|
|||||||
15
pubspec.lock
15
pubspec.lock
@ -579,14 +579,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.30.0"
|
version: "9.30.0"
|
||||||
flutter_staggered_grid_view:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_staggered_grid_view
|
|
||||||
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.0"
|
|
||||||
flutter_svg:
|
flutter_svg:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1060,6 +1052,13 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.1"
|
version: "4.0.1"
|
||||||
|
myinfomate_layout:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../manager-app/myinfomate_layout"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "1.0.0"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -62,7 +62,6 @@ dependencies:
|
|||||||
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
|
||||||
beacon_scanner: ^0.0.4 #not in web
|
beacon_scanner: ^0.0.4 #not in web
|
||||||
flutter_staggered_grid_view: ^0.7.0
|
|
||||||
|
|
||||||
smooth_page_indicator: ^1.2.1
|
smooth_page_indicator: ^1.2.1
|
||||||
|
|
||||||
@ -91,6 +90,8 @@ dependencies:
|
|||||||
|
|
||||||
manager_api_new:
|
manager_api_new:
|
||||||
path: ../manager-app/manager_api_new
|
path: ../manager-app/manager_api_new
|
||||||
|
myinfomate_layout:
|
||||||
|
path: ../manager-app/myinfomate_layout
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.2
|
||||||
|
|||||||
1287
wakeword/Hey_marco_training_working.ipynb
Normal file
1287
wakeword/Hey_marco_training_working.ipynb
Normal file
File diff suppressed because one or more lines are too long
BIN
wakeword/hey_marco_v2.onnx
Normal file
BIN
wakeword/hey_marco_v2.onnx
Normal file
Binary file not shown.
BIN
wakeword/hey_marco_v2.tflite
Normal file
BIN
wakeword/hey_marco_v2.tflite
Normal file
Binary file not shown.
BIN
wakeword/hey_vasco.onnx
Normal file
BIN
wakeword/hey_vasco.onnx
Normal file
Binary file not shown.
BIN
wakeword/hey_vasco.tflite
Normal file
BIN
wakeword/hey_vasco.tflite
Normal file
Binary file not shown.
BIN
wakeword/hey_visit.onnx
Normal file
BIN
wakeword/hey_visit.onnx
Normal file
Binary file not shown.
BIN
wakeword/hey_visit.tflite
Normal file
BIN
wakeword/hey_visit.tflite
Normal file
Binary file not shown.
BIN
wakeword/hey_viva.onnx
Normal file
BIN
wakeword/hey_viva.onnx
Normal file
Binary file not shown.
BIN
wakeword/hey_viva.tflite
Normal file
BIN
wakeword/hey_viva.tflite
Normal file
Binary file not shown.
269
wakeword/wakeword_training_template.ipynb
Normal file
269
wakeword/wakeword_training_template.ipynb
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
{
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 0,
|
||||||
|
"metadata": {
|
||||||
|
"colab": {
|
||||||
|
"provenance": [],
|
||||||
|
"name": "wakeword_training_template.ipynb",
|
||||||
|
"gpuType": "T4"
|
||||||
|
},
|
||||||
|
"accelerator": "GPU",
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "Python 3",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"name": "python"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# Wake Word Training\n",
|
||||||
|
"\n",
|
||||||
|
"Basé sur `Automatic_model_training_simple_TFR`. \n",
|
||||||
|
"Génération multi-langue : EN/EN-GB/DE/NL/IT/ES + FR (phonétique dédiée).\n",
|
||||||
|
"\n",
|
||||||
|
"**Runtime recommandé : GPU (T4)** — Durée estimée : ~45-90 min"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"from google.colab import drive\n",
|
||||||
|
"drive.mount('/content/drive')\n",
|
||||||
|
"\n",
|
||||||
|
"import os\n",
|
||||||
|
"drive_output_dir = '/content/drive/My Drive/Colab Notebooks/Wakeword/openwakeword'\n",
|
||||||
|
"os.makedirs(drive_output_dir, exist_ok=True)\n",
|
||||||
|
"print(f'Models will be saved in: {drive_output_dir}')"
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"metadata": {},
|
||||||
|
"source": "# @title Configuration globale { display-mode: \"form\" }\n# @markdown ### Wake word\n# @markdown `PHRASE_DEFAULT` : prononciation EN (utilise _ entre les mots)\n# @markdown `PHRASE_FR` : prononciation FR (avec accents si besoin)\n\nPHRASE_DEFAULT = 'hey_marco' # @param {type:\"string\"}\nPHRASE_FR = 'hé marco' # @param {type:\"string\"}\n\n# @markdown ---\n# @markdown ### Paramètres d'entraînement\n\nnumber_of_examples = 10000 # @param {type:\"slider\", min:1000, max:50000, step:500}\nnumber_of_training_steps = 25000 # @param {type:\"slider\", min:1000, max:50000, step:500}\nfalse_activation_penalty = 1500 # @param {type:\"slider\", min:100, max:3000, step:100}\n\n# @markdown ---\n# @markdown ### Répartition FR / EN (%)\n# @markdown Le reste (100% - FR%) va en EN. Mettre FR=0 pour full EN.\n\nfr_pct = 35 # @param {type:\"slider\", min:0, max:100, step:5}\n\n# @markdown ---\n# @markdown ### Objectifs qualité (early stopping)\n\ntarget_accuracy = 0.7 # @param {type:\"slider\", min:0.1, max:1.0, step:0.05}\ntarget_recall = 0.5 # @param {type:\"slider\", min:0.1, max:1.0, step:0.05}\n\n# ── Calcul automatique ────────────────────────────────────────────────────────\nimport math\n\nen_pct = 100 - fr_pct\nlang_samples = {\n 'fr': math.floor(number_of_examples * fr_pct / 100),\n 'en': number_of_examples - math.floor(number_of_examples * fr_pct / 100),\n}\n\nMODEL_NAME = PHRASE_DEFAULT.replace(' ', '_')\nCLIPS_DIR = f'./{MODEL_NAME}_clips'\n\nprint(f'Wake word : \"{PHRASE_DEFAULT}\" (EN) | \"{PHRASE_FR}\" (FR)')\nprint(f'Model name : {MODEL_NAME}')\nprint(f'Total samples : {sum(lang_samples.values())}')\nprint(f' fr : {lang_samples[\"fr\"]} ({fr_pct}%)')\nprint(f' en : {lang_samples[\"en\"]} ({en_pct}%)')",
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Étape 1 — Piper sample generator + modèles TTS"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"import os, sys\n",
|
||||||
|
"from IPython.display import Audio, display\n",
|
||||||
|
"\n",
|
||||||
|
"if not os.path.exists('./piper-sample-generator'):\n",
|
||||||
|
" !git clone https://github.com/rhasspy/piper-sample-generator\n",
|
||||||
|
" !cd piper-sample-generator && git checkout 213d4d5\n",
|
||||||
|
" !pip install piper-tts piper-phonemize-cross\n",
|
||||||
|
" !pip install webrtcvad\n",
|
||||||
|
" !pip install torch==2.5.0 torchvision==0.20.0 torchaudio==2.5.0 --index-url https://download.pytorch.org/whl/cu121\n",
|
||||||
|
"\n",
|
||||||
|
"# Modèle EN\n",
|
||||||
|
"os.makedirs('piper-sample-generator/models', exist_ok=True)\n",
|
||||||
|
"if not os.path.exists('piper-sample-generator/models/en_US-libritts_r-medium.pt'):\n",
|
||||||
|
" !wget -q -O piper-sample-generator/models/en_US-libritts_r-medium.pt \\\n",
|
||||||
|
" 'https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/en_US-libritts_r-medium.pt'\n",
|
||||||
|
"\n",
|
||||||
|
"# Modèle FR via piper-tts .onnx\n",
|
||||||
|
"if not os.path.exists('fr_FR-siwis-medium.onnx'):\n",
|
||||||
|
" !wget -q -O fr_FR-siwis-medium.onnx \\\n",
|
||||||
|
" 'https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx'\n",
|
||||||
|
" !wget -q -O fr_FR-siwis-medium.onnx.json \\\n",
|
||||||
|
" 'https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json'\n",
|
||||||
|
"\n",
|
||||||
|
"if 'piper-sample-generator/' not in sys.path:\n",
|
||||||
|
" sys.path.append('piper-sample-generator/')\n",
|
||||||
|
"from generate_samples import generate_samples\n",
|
||||||
|
"\n",
|
||||||
|
"print('Piper prêt.')"
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# Test audio — vérifie les prononciations avant de lancer le training\n",
|
||||||
|
"generate_samples(text=PHRASE_DEFAULT, max_samples=1, output_dir='./', batch_size=1,\n",
|
||||||
|
" auto_reduce_batch_size=True, file_names=['test_en.wav'])\n",
|
||||||
|
"print(f'EN : \"{PHRASE_DEFAULT}\"')\n",
|
||||||
|
"display(Audio('test_en.wav', autoplay=False))\n",
|
||||||
|
"\n",
|
||||||
|
"!echo \"{PHRASE_FR}\" | piper --model fr_FR-siwis-medium.onnx --output_file test_fr.wav\n",
|
||||||
|
"print(f'FR : \"{PHRASE_FR}\"')\n",
|
||||||
|
"display(Audio('test_fr.wav', autoplay=False))"
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Étape 2 — Téléchargement des données"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"import locale\n",
|
||||||
|
"def getpreferredencoding(do_setlocale=True):\n",
|
||||||
|
" return 'UTF-8'\n",
|
||||||
|
"locale.getpreferredencoding = getpreferredencoding\n",
|
||||||
|
"\n",
|
||||||
|
"import os, shutil\n",
|
||||||
|
"\n",
|
||||||
|
"# Clone openWakeWord\n",
|
||||||
|
"if os.path.exists('./openwakeword'):\n",
|
||||||
|
" shutil.rmtree('./openwakeword')\n",
|
||||||
|
"!rm -rf ./openwakeword\n",
|
||||||
|
"!git clone https://github.com/dscripka/openwakeword\n",
|
||||||
|
"!pip install -e ./openwakeword --no-deps\n",
|
||||||
|
"\n",
|
||||||
|
"!pip install mutagen==1.47.0\n",
|
||||||
|
"!pip install torchinfo==1.8.0\n",
|
||||||
|
"!pip install torchmetrics==1.2.0\n",
|
||||||
|
"!pip install speechbrain==0.5.14\n",
|
||||||
|
"!pip install audiomentations==0.33.0\n",
|
||||||
|
"!pip install torch-audiomentations==0.11.0\n",
|
||||||
|
"!pip install acoustics==0.2.6\n",
|
||||||
|
"!pip install onnxruntime==1.22.1 ai_edge_litert==1.4.0 onnxsim\n",
|
||||||
|
"!pip install onnx2tf\n",
|
||||||
|
"!pip install onnx==1.19.1\n",
|
||||||
|
"!pip install onnx_graphsurgeon sng4onnx\n",
|
||||||
|
"!pip install pronouncing==0.2.0\n",
|
||||||
|
"!pip install datasets==2.14.6\n",
|
||||||
|
"!pip install deep-phonemizer==0.0.19\n",
|
||||||
|
"\n",
|
||||||
|
"os.makedirs('./openwakeword/openwakeword/resources/models', exist_ok=True)\n",
|
||||||
|
"base = 'https://github.com/dscripka/openWakeWord/releases/download/v0.5.1'\n",
|
||||||
|
"for f in ['embedding_model.onnx', 'embedding_model.tflite', 'melspectrogram.onnx', 'melspectrogram.tflite']:\n",
|
||||||
|
" !wget -q {base}/{f} -O ./openwakeword/openwakeword/resources/models/{f}\n",
|
||||||
|
"\n",
|
||||||
|
"print('Installation terminée.')"
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"import numpy as np, sys, yaml, datasets, scipy\n",
|
||||||
|
"from pathlib import Path\n",
|
||||||
|
"from tqdm import tqdm\n",
|
||||||
|
"\n",
|
||||||
|
"# MIT Room Impulse Responses\n",
|
||||||
|
"output_dir = './mit_rirs'\n",
|
||||||
|
"if not os.path.exists(output_dir):\n",
|
||||||
|
" os.mkdir(output_dir)\n",
|
||||||
|
" !git lfs install\n",
|
||||||
|
" !git clone https://huggingface.co/datasets/davidscripka/MIT_environmental_impulse_responses\n",
|
||||||
|
" rir_dataset = datasets.Dataset.from_dict({\n",
|
||||||
|
" 'audio': [str(i) for i in Path('./MIT_environmental_impulse_responses/16khz').glob('*.wav')]\n",
|
||||||
|
" }).cast_column('audio', datasets.Audio())\n",
|
||||||
|
" for row in tqdm(rir_dataset):\n",
|
||||||
|
" name = row['audio']['path'].split('/')[-1]\n",
|
||||||
|
" scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n",
|
||||||
|
"\n",
|
||||||
|
"# FMA music\n",
|
||||||
|
"output_dir = './fma'\n",
|
||||||
|
"if not os.path.exists(output_dir):\n",
|
||||||
|
" os.mkdir(output_dir)\n",
|
||||||
|
" fma_dataset = datasets.load_dataset('rudraml/fma', name='small', split='train', streaming=True)\n",
|
||||||
|
" fma_dataset = iter(fma_dataset.cast_column('audio', datasets.Audio(sampling_rate=16000)))\n",
|
||||||
|
" n_hours = 1\n",
|
||||||
|
" for i in tqdm(range(n_hours*3600//30)):\n",
|
||||||
|
" row = next(fma_dataset)\n",
|
||||||
|
" name = row['audio']['path'].split('/')[-1].replace('.mp3', '.wav')\n",
|
||||||
|
" scipy.io.wavfile.write(os.path.join(output_dir, name), 16000, (row['audio']['array']*32767).astype(np.int16))\n",
|
||||||
|
"\n",
|
||||||
|
"# Features pré-calculées openWakeWord\n",
|
||||||
|
"if not os.path.exists('./openwakeword_features_ACAV100M_2000_hrs_16bit.npy'):\n",
|
||||||
|
" !wget -q https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy\n",
|
||||||
|
"if not os.path.exists('validation_set_features.npy'):\n",
|
||||||
|
" !wget -q https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/validation_set_features.npy\n",
|
||||||
|
"\n",
|
||||||
|
"print('Données prêtes.')"
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Étape 3 — Génération des samples multi-langue"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"metadata": {},
|
||||||
|
"source": "import sys, os, glob\nimport scipy.io.wavfile as wav\nimport scipy.signal as signal\nimport numpy as np\nfrom tqdm import tqdm\n\nif 'piper-sample-generator/' not in sys.path:\n sys.path.append('piper-sample-generator/')\nfrom generate_samples import generate_samples\n\nos.makedirs(CLIPS_DIR, exist_ok=True)\n\n# ── EN via piper-sample-generator ────────────────────────────────────────────\nen_dir = f'{CLIPS_DIR}/en'\nos.makedirs(en_dir, exist_ok=True)\nexisting_en = len([f for f in os.listdir(en_dir) if f.endswith('.wav')])\nremaining_en = lang_samples['en'] - existing_en\n\nif remaining_en > 0:\n print(f' [en] génération {remaining_en} samples : \"{PHRASE_DEFAULT}\"')\n generate_samples(\n text=PHRASE_DEFAULT, max_samples=remaining_en,\n length_scales=[0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15],\n noise_scales=[0.4, 0.667, 1.0], noise_scale_ws=[0.8],\n output_dir=en_dir, batch_size=50, auto_reduce_batch_size=True,\n )\nelse:\n print(f' [en] déjà complet ({existing_en}/{lang_samples[\"en\"]})')\n\n# ── FR via piper CLI + rééchantillonnage 16kHz ───────────────────────────────\nfr_dir = f'{CLIPS_DIR}/fr'\nos.makedirs(fr_dir, exist_ok=True)\nexisting_fr = len([f for f in os.listdir(fr_dir) if f.endswith('.wav')])\nremaining_fr = lang_samples['fr'] - existing_fr\n\nif remaining_fr > 0:\n print(f' [fr] génération {remaining_fr} samples : \"{PHRASE_FR}\"')\n speeds = [0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15]\n n_per_speed = remaining_fr // len(speeds)\n remainder = remaining_fr % len(speeds)\n for i, speed in enumerate(tqdm(speeds)):\n n = n_per_speed + (1 if i < remainder else 0)\n with open('/tmp/fr_input.txt', 'w', encoding='utf-8') as f:\n f.write('\\n'.join([PHRASE_FR] * n))\n !piper --model fr_FR-siwis-medium.onnx --length_scale {speed} --output_dir {fr_dir} < /tmp/fr_input.txt\n\n resampled = 0\n for path in glob.glob(f'{fr_dir}/*.wav'):\n if os.path.getsize(path) == 0:\n os.remove(path)\n continue\n try:\n sr, data = wav.read(path)\n if sr != 16000:\n n_s = round(len(data) * 16000 / sr)\n wav.write(path, 16000, signal.resample(data, n_s).astype(np.int16))\n resampled += 1\n except Exception:\n os.remove(path)\n print(f' [fr] {resampled} clips rééchantillonnés à 16kHz')\nelif lang_samples['fr'] > 0:\n print(f' [fr] déjà complet ({existing_fr}/{lang_samples[\"fr\"]})')\nelse:\n print(' [fr] désactivé (fr_pct = 0)')\n\ntotal = sum(\n len([f for f in os.listdir(f'{CLIPS_DIR}/{lang}') if f.endswith('.wav')])\n for lang in ['en', 'fr'] if os.path.exists(f'{CLIPS_DIR}/{lang}')\n)\nprint(f'\\nTotal clips générés : {total}/{number_of_examples}')",
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Étape 4 — Training"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"metadata": {},
|
||||||
|
"source": "import yaml, os, sys, shutil, glob, random\nimport scipy.io.wavfile as wav\nimport scipy.signal as signal\nimport numpy as np\n\nconfig = yaml.load(open('openwakeword/examples/custom_model.yml', 'r').read(), yaml.Loader)\n\nPHRASE_DEFAULT_SPACE = PHRASE_DEFAULT.replace('_', ' ')\n\nconfig['target_phrase'] = [PHRASE_DEFAULT_SPACE, PHRASE_FR]\nconfig['model_name'] = MODEL_NAME\nconfig['n_samples'] = number_of_examples\nconfig['n_samples_val'] = max(500, number_of_examples // 10)\nconfig['steps'] = number_of_training_steps\nconfig['target_accuracy'] = target_accuracy\nconfig['target_recall'] = target_recall\nconfig['max_negative_weight'] = false_activation_penalty\n\nfinal_drive_output_dir = os.path.join(drive_output_dir, MODEL_NAME)\nos.makedirs(final_drive_output_dir, exist_ok=True)\n\nlocal_temp_dir = './openwakeword_training_temp'\nconfig['output_dir'] = local_temp_dir\nconfig['background_paths'] = ['./fma']\nconfig['false_positive_validation_data_path'] = 'validation_set_features.npy'\nconfig['feature_data_files'] = {'ACAV100M_sample': 'openwakeword_features_ACAV100M_2000_hrs_16bit.npy'}\n\nwith open('my_model.yaml', 'w') as f:\n yaml.dump(config, f)\n\n# Rééchantillonner les clips FR à 16kHz\nfor path in glob.glob(f'{CLIPS_DIR}/fr/*.wav'):\n if os.path.getsize(path) == 0:\n os.remove(path)\n continue\n try:\n sr, data = wav.read(path)\n if sr != 16000:\n n_s = round(len(data) * 16000 / sr)\n wav.write(path, 16000, signal.resample(data, n_s).astype(np.int16))\n except Exception:\n os.remove(path)\n\n# Setup des dossiers via --generate_clips\nprint('Setup des dossiers via --generate_clips...')\n!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --generate_clips\n\n# S'assurer que les dossiers existent\npositive_clips_dir = os.path.join(local_temp_dir, MODEL_NAME, 'positive_clips')\npositive_test_dir = os.path.join(local_temp_dir, MODEL_NAME, 'positive_test')\nos.makedirs(positive_clips_dir, exist_ok=True)\nos.makedirs(positive_test_dir, exist_ok=True)\n\n# Remplacer les clips EN par nos clips multi-langue\nfor f in glob.glob(os.path.join(positive_clips_dir, '*.wav')): os.remove(f)\nfor f in glob.glob(os.path.join(positive_test_dir, '*.wav')): os.remove(f)\n\nall_clips = glob.glob(f'{CLIPS_DIR}/**/*.wav', recursive=True)\nrandom.shuffle(all_clips)\nn_val = config['n_samples_val']\nfor wav_path in all_clips[:n_val]:\n lang = os.path.basename(os.path.dirname(wav_path))\n shutil.copy(wav_path, os.path.join(positive_test_dir, f\"{lang}_{os.path.basename(wav_path)}\"))\nfor wav_path in all_clips[n_val:]:\n lang = os.path.basename(os.path.dirname(wav_path))\n shutil.copy(wav_path, os.path.join(positive_clips_dir, f\"{lang}_{os.path.basename(wav_path)}\"))\n\nprint(f'Training : {len(all_clips) - n_val} clips | Validation : {n_val} clips')\n\n# Supprimer le cache .npy\nfor f in glob.glob(os.path.join(local_temp_dir, MODEL_NAME, '*.npy')):\n os.remove(f)\n print(f'Cache supprimé : {os.path.basename(f)}')\n\n# Augmentation + training\n!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --augment_clips\n!{sys.executable} openwakeword/openwakeword/train.py --training_config my_model.yaml --train_model",
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Étape 5 — Export ONNX → TFLite + sauvegarde Drive"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"import shutil, os\n",
|
||||||
|
"from google.colab import files\n",
|
||||||
|
"\n",
|
||||||
|
"local_onnx = f'{local_temp_dir}/{MODEL_NAME}.onnx'\n",
|
||||||
|
"drive_onnx = f'{final_drive_output_dir}/{MODEL_NAME}.onnx'\n",
|
||||||
|
"drive_tflite1 = f'{final_drive_output_dir}/{MODEL_NAME}_float32.tflite'\n",
|
||||||
|
"drive_tflite = f'{final_drive_output_dir}/{MODEL_NAME}.tflite'\n",
|
||||||
|
"\n",
|
||||||
|
"shutil.copy(local_onnx, drive_onnx)\n",
|
||||||
|
"print(f'ONNX copié : {drive_onnx}')\n",
|
||||||
|
"\n",
|
||||||
|
"!onnx2tf -i \"{drive_onnx}\" -o \"{final_drive_output_dir}\" -kat onnx____Flatten_0\n",
|
||||||
|
"!mv \"{drive_tflite1}\" \"{drive_tflite}\"\n",
|
||||||
|
"print(f'TFLite : {drive_tflite}')\n",
|
||||||
|
"\n",
|
||||||
|
"files.download(drive_onnx)\n",
|
||||||
|
"files.download(drive_tflite)"
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"execution_count": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user