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>
454 lines
18 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|