mymuseum-visitapp/lib/Components/GlassesDebugPanel.dart
Thomas Fransolet d39949a6bd Lunettes Meta, lecteur audio en onglet, et badge de version dans les Parametres
Ce commit boucle le travail en cours sur la branche (assistant lunettes Meta,
passage du lecteur audio flottant a un onglet, ajustements scanner / liste de
configurations / telechargement) et y ajoute le badge de version.

Le badge, en bas de la feuille Parametres, affiche « flavor . version . commit ».
Un APK pose sur une tablette du terrain n'etait rattachable a aucun commit
precis : la version du pubspec ne bougeait pas d'un build a l'autre et rien
n'indiquait le flavor reellement installe. kGitSha suit le meme schema que
kApiBaseUrl, injecte par --dart-define, et kFlavor expose le flavor deja calcule.

package_info_plus etait deja une dependance transitive ; il devient direct,
puisqu'il est desormais importe.

/!\ Un --dart-define modifie n'est PAS pris en compte sans `flutter clean` sur
ce projet : verifie a la sentinelle, le SHA restait absent de libapp.so tant que
le cache Dart n'etait pas vide. Un build de release destine au terrain doit donc
toujours passer par un clean, sinon le badge affiche le SHA du build precedent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 22:43:53 +02:00

454 lines
18 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_meta_wearables_dat/flutter_meta_wearables_dat.dart';
import 'package:mymuseum_visitapp/Services/Glasses/glasses_orchestrator.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
/// Panneau de debug pour l'intégration Ray-Ban Meta.
/// À ouvrir via un bouton discret dans l'app (ex: appui long sur le logo).
/// Ne pas inclure en production.
class GlassesDebugPanel extends StatefulWidget {
const GlassesDebugPanel({super.key});
static void show(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.grey[900],
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => const GlassesDebugPanel(),
);
}
@override
State<GlassesDebugPanel> createState() => _GlassesDebugPanelState();
}
class _GlassesDebugPanelState extends State<GlassesDebugPanel> {
String _log = '';
bool _busy = false;
bool _monitoring = false;
final List<StreamSubscription> _subs = [];
@override
void dispose() {
for (final s in _subs) { s.cancel(); }
super.dispose();
}
void _addLog(String msg) {
if (!mounted) return;
setState(() => _log = '${DateTime.now().toIso8601String().substring(11, 19)} $msg\n$_log');
}
void _toggleMonitor() {
if (_monitoring) {
for (final s in _subs) { s.cancel(); }
_subs.clear();
setState(() => _monitoring = false);
_addLog('⏹ Monitor arrêté');
return;
}
_subs.add(MetaWearablesDat.registrationStateStream().listen(
(s) => _addLog('📋 registration: $s'),
onError: (e) => _addLog('📋 registration error: $e'),
));
_subs.add(MetaWearablesDat.activeDeviceStream().listen(
(available) => _addLog('📱 activeDevice: $available'),
onError: (e) => _addLog('📱 activeDevice error: $e'),
));
_subs.add(MetaWearablesDat.streamSessionStateStream().listen(
(s) => _addLog('🎥 streamState: $s'),
onError: (e) => _addLog('🎥 streamState error: $e'),
));
_subs.add(MetaWearablesDat.streamSessionErrorStream().listen(
(e) => _addLog('⚠ streamError: $e'),
onError: (e) => _addLog('⚠ streamError stream error: $e'),
));
_subs.add(MetaWearablesDat.videoFramesStream().listen(
(f) => _addLog('🖼 videoFrame: ${f.bytes.length} bytes ${f.width}x${f.height}'),
onError: (e) => _addLog('🖼 videoFrame error: $e'),
));
_subs.add(MetaWearablesDat.deviceStateStream().listen(
(d) => _addLog('🌡 deviceState: $d'),
onError: (e) => _addLog('🌡 deviceState error: $e'),
));
setState(() => _monitoring = true);
_addLog('▶ Monitor démarré — interagis avec les lunettes');
}
Future<void> _run(String label, Future<void> Function() fn) async {
if (_busy) return;
setState(() => _busy = true);
_addLog('$label');
try {
await fn();
_addLog('$label');
} catch (e) {
_addLog('$label: $e');
} finally {
setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.75,
maxChildSize: 0.95,
builder: (_, scroll) => Column(
children: [
// Handle
Container(
margin: const EdgeInsets.symmetric(vertical: 8),
width: 40, height: 4,
decoration: BoxDecoration(
color: Colors.grey[600],
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Icon(Icons.bug_report, color: Colors.amber, size: 18),
const SizedBox(width: 8),
const Text('Glasses Debug',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
const Spacer(),
// État en temps réel
ValueListenableBuilder<GlassesState>(
valueListenable: MetaGlassesService.instance.state,
builder: (_, state, __) {
final color = state == GlassesState.streaming
? Colors.green
: state == GlassesState.connected
? Colors.lightGreen
: state == GlassesState.connecting
? Colors.orange
: Colors.red;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
border: Border.all(color: color),
borderRadius: BorderRadius.circular(12),
),
child: Text(
state.name.toUpperCase(),
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold),
),
);
},
),
],
),
),
const Divider(color: Colors.grey),
// Boutons actions
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
spacing: 8, runSpacing: 8,
children: [
_ActionButton(
label: 'Activer caméra',
icon: Icons.camera_alt,
onTap: () => _run('requestCameraPermission + startStream', () async {
final granted = await MetaWearablesDat.requestCameraPermission();
_addLog('camera granted: $granted');
await MetaGlassesService.instance.startStream();
}),
),
_ActionButton(
label: 'Start stream',
icon: Icons.videocam,
onTap: () => _run('startStream (direct)', () async {
await MetaGlassesService.instance.startStream();
_addLog('textureId: ${MetaGlassesService.instance.textureId}');
}),
),
_ActionButton(
label: 'Capture photo',
icon: Icons.photo_camera,
onTap: () => _run('capturePhoto', () async {
await MetaGlassesService.instance.requestPhotoCapture();
}),
),
_ActionButton(
label: 'Test TTS',
icon: Icons.volume_up,
onTap: () => _run('TTS test', () async {
final o = activeVoiceOrchestrator;
if (o == null) {
_addLog('⚠ Orchestrateur non initialisé');
return;
}
// Stoppe l'écoute wake word pendant le TTS pour éviter le conflit audio focus
await o.wakeWordEngine.stop();
try {
await o.ttsEngine.speak(
'Bonjour. Je suis votre assistant de visite. Bienvenue au musée.',
languageCode: 'fr-FR',
);
} finally {
await o.wakeWordEngine.start(
onDetected: () => o.triggerConversation(),
onDetectedWithCommand: (cmd) => o.triggerConversation(),
);
}
}),
),
_ActionButton(
label: _monitoring ? 'Stop monitor' : 'Event monitor',
icon: _monitoring ? Icons.sensors_off : Icons.sensors,
color: _monitoring ? Colors.orange : Colors.purple,
onTap: _toggleMonitor,
),
_ActionButton(
label: 'Stop stream',
icon: Icons.stop,
color: Colors.red,
onTap: () => _run('stopStream', () async {
await MetaGlassesService.instance.stopStream();
}),
),
_ActionButton(
label: 'Reconnect',
icon: Icons.refresh,
onTap: () => _run('disconnect + connect', () async {
await MetaGlassesService.instance.disconnect();
await Future.delayed(const Duration(milliseconds: 500));
await MetaGlassesService.instance.connect();
}),
),
],
),
),
const Divider(color: Colors.grey),
// Transcription en direct
if (activeVoiceOrchestrator != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ValueListenableBuilder<bool>(
valueListenable: activeVoiceOrchestrator!.isListeningForCommand,
builder: (_, listening, __) => ValueListenableBuilder<String>(
valueListenable: activeVoiceOrchestrator!.lastTranscription,
builder: (_, text, __) => Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: listening
? Colors.green.withValues(alpha: 0.15)
: Colors.grey[850],
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: listening ? Colors.green : Colors.grey[700]!,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(
listening ? Icons.mic : Icons.mic_none,
color: listening ? Colors.green : Colors.grey,
size: 14,
),
const SizedBox(width: 6),
Text(
listening ? 'Écoute en cours...' : 'Dernière transcription',
style: TextStyle(
color: listening ? Colors.green : Colors.grey,
fontSize: 11,
),
),
]),
if (text.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'"$text"',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
),
),
),
// Dernier texte TTS
if (activeVoiceOrchestrator != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ValueListenableBuilder<String>(
valueListenable: activeVoiceOrchestrator!.lastTtsText,
builder: (_, text, __) => text.isEmpty
? const SizedBox.shrink()
: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.volume_up, color: Colors.blue, size: 14),
const SizedBox(width: 6),
const Text('Dernier TTS',
style: TextStyle(color: Colors.blue, fontSize: 11)),
]),
const SizedBox(height: 4),
Text(
text,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
),
),
// 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),
// Log
Expanded(
child: SingleChildScrollView(
controller: scroll,
padding: const EdgeInsets.all(12),
child: _log.isEmpty
? const Text('Logs apparaîtront ici...',
style: TextStyle(color: Colors.grey, fontSize: 12))
: Text(
_log,
style: const TextStyle(
color: Colors.greenAccent,
fontSize: 11,
fontFamily: 'monospace',
),
),
),
),
],
),
);
}
}
class _ActionButton extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback onTap;
final Color color;
const _ActionButton({
required this.label,
required this.icon,
required this.onTap,
this.color = Colors.blue,
});
@override
Widget build(BuildContext context) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: color.withValues(alpha: 0.15),
foregroundColor: color,
side: BorderSide(color: color.withValues(alpha: 0.4)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
icon: Icon(icon, size: 16),
label: Text(label, style: const TextStyle(fontSize: 12)),
onPressed: onTap,
);
}
}