mymuseum-visitapp/lib/Components/VoiceModeSheet.dart
Thomas Fransolet e9e68942c9 Proactif câblé, M3 fermé, voix du CMS honorée, et trois services morts supprimés
Déclenchement proactif (lot F) — quatre branchements : la garde, le cycle de
vie accroché à VoiceController, les points GPS peuplés depuis la configuration,
et un réglage visiteur.

⚠️ La garde recommandée par le plan aurait coûté de l'argent. _trigger appelle
le LLM d'abord et ne parle qu'ensuite via activeVoiceOrchestrator?.ttsEngine,
dont le ?. avale le cas « aucun mode vocal actif ». Avec proactiveModeEnabled
seul, un visiteur traversant une zone avec l'app en poche et le vocal éteint
consommait des jetons Gemini facturés au client pour une phrase que personne
n'entend. La garde interroge l'orchestrateur : la vraie condition n'est pas
« quel matériel » mais « y a-t-il quelqu'un pour écouter ».

Il n'y avait pas de troisième mode à inventer : VoiceMode.voiceOnly existe déjà
et construit le même orchestrateur que le mode lunettes. Le proactif est donc un
sous-réglage, pas une tuile. glassesEnabled est supprimé, ses trois usages
étaient morts — dont une pastille « Lunettes / Déconnecté » jamais affichée.

M3 — meterZoneGPS devient le rayon par section, la constante n'étant plus qu'un
défaut. Deux pièges de format vérifiés : currentSections porte des maps JSON
brutes, pas des DTO, et latitude/longitude y sont des chaînes.

Voix du guide — le sélecteur Viva/Marco de manager-app écrivait dans le vide :
Instance.GuideVoiceId n'était lu nulle part ici, l'app utilisant une constante
de build dont le défaut était Algieba, une voix que personne n'a écoutée. Même
forme que W1. Et aucun APK ne parlait avec la voix du produit : GeminiTtsEngine
n'est choisi que si GEMINI_API_KEY est injectée, ce qui n'était fait nulle part,
donc repli silencieux sur la voix système. Le repli reste le défaut — les tests
ne coûtent alors ni jetons ni argent — mais il s'annonce dans les logs.

Code mort supprimé : wake_word_service.dart, glasses_qr_scanner_service.dart et
glasses_tts_service.dart, les ancêtres de Services/Glasses/. Ils portaient les 4
erreurs kElevenLabs* et l'APK se construisait quand même, personne ne les
important. flutter analyze lib rend désormais zéro erreur.
⚠️ android/.../WakeWordService.kt est un homonyme bien vivant, non touché.
impl/elevenlabs_tts_engine.dart est conservé comme option.

flutter build apk --debug --flavor dev : vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:57:44 +02:00

301 lines
11 KiB
Dart

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),
_ProactiveTile(
visitAppContext: visitAppContext,
enabled: controller.mode != VoiceMode.none,
),
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)),
],
),
);
},
);
}
}
/// Le mode proactif est un **sous-réglage**, pas une troisième tuile : il vaut aussi bien
/// aux lunettes qu'aux écouteurs, et l'opposer aux deux autres forcerait un choix entre
/// « je pose des questions » et « on me parle », alors que les deux vont ensemble.
///
/// Désactivé tant qu'aucun mode vocal n'est actif — sans sortie audio, l'activer ne
/// produirait que des appels au guide dont personne n'entendrait la réponse.
class _ProactiveTile extends StatefulWidget {
final VisitAppContext visitAppContext;
final bool enabled;
const _ProactiveTile({required this.visitAppContext, required this.enabled});
@override
State<_ProactiveTile> createState() => _ProactiveTileState();
}
class _ProactiveTileState extends State<_ProactiveTile> {
@override
Widget build(BuildContext context) {
final on = widget.visitAppContext.proactiveModeEnabled;
return Opacity(
opacity: widget.enabled ? 1 : 0.4,
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
value: on,
onChanged: widget.enabled
? (value) => setState(() => widget.visitAppContext.proactiveModeEnabled = value)
: null,
title: const Text(
'Me parler automatiquement',
style: TextStyle(color: Colors.white, fontSize: 15),
),
subtitle: Text(
widget.enabled
// On informe, on ne détecte pas : le visiteur a choisi son mode, c'est à lui
// de savoir s'il est dans une salle où le haut-parleur dérangerait.
? 'Le guide se déclenche à l\'approche d\'un point d\'intérêt. Écouteurs recommandés.'
: 'Activez d\'abord un mode vocal ci-dessus.',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
activeThumbColor: Colors.lightBlueAccent,
),
);
}
}
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,
),
);
}
}