14 files changed
+961
-424
assets/sounds/connected.mp3
Binary files /dev/null and b/assets/sounds/connected.mp3 differ
assets/sounds/disconnected.mp3
Binary files /dev/null and b/assets/sounds/disconnected.mp3 differ
assets/sounds/generating.mp3
Binary files /dev/null and b/assets/sounds/generating.mp3 differ
assets/sounds/start_listening.mp3
Binary files /dev/null and b/assets/sounds/start_listening.mp3 differ
lib/features/stt/providers/stt_providers.dart
+12
@@ -3,6 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:speech_to_text/speech_to_text.dart';
4
import '../../../core/logger/app_logger.dart';
5
6
+/// Double in dB-ish units (iOS) or 0..1-ish (Android) reported by the
7
+/// speech_to_text plugin via `onSoundLevelChange`. Higher = louder.
8
+typedef SoundLevelChange = void Function(double level);
9
+
10
class SttState {
11
final bool isListening;
12
final bool isAvailable;
@@ -89,6 +93,7 @@ class SttNotifier extends Notifier<SttState> {
93
Future<void> startListening({
94
required void Function(String) onResult,
95
void Function(String)? onFinal,
96
+ SoundLevelChange? onSoundLevelChange,
97
}) async {
98
final available = await initSpeech();
99
if (!available) {
@@ -115,6 +120,13 @@ class SttNotifier extends Notifier<SttState> {
120
onFinal(result.recognizedWords);
121
}
122
},
123
+ onSoundLevelChange: (level) {
124
+ // iOS reports negative dB (-2 quiet, 10 loud), Android ~0..1.
125
+ final normalised = level <= 0
126
+ ? ((level + 2) / 12).clamp(0.0, 1.0)
127
+ : level.clamp(0.0, 1.0);
128
+ onSoundLevelChange?.call(normalised);
129
+ },
130
listenOptions: SpeechListenOptions(
131
listenFor: const Duration(seconds: 30),
132
pauseFor: const Duration(seconds: 5),
lib/features/tts/providers/tts_providers.dart
+21
@@ -39,6 +39,13 @@ class TtsState {
39
final bool isSeekPending;
40
final double playbackSpeed;
41
42
+ /// Text chunks currently being read aloud, in order. Cleared when
43
+ /// playback stops. Useful for word-level progress UI.
44
+ final List<String> chunks;
45
+
46
+ /// Index of the chunk currently being read. -1 when nothing is playing.
47
+ final int currentChunkIndex;
48
+
49
const TtsState({
50
this.isSpeaking = false,
51
this.isPaused = false,
@@ -54,6 +61,8 @@ class TtsState {
61
this.canSeek = false,
62
this.isSeekPending = false,
63
this.playbackSpeed = 1.0,
64
+ this.chunks = const [],
65
+ this.currentChunkIndex = -1,
66
});
67
68
TtsState copyWith({
@@ -73,6 +82,8 @@ class TtsState {
82
double? playbackSpeed,
83
bool clearPlayingTarget = false,
84
bool resetProgress = false,
85
+ List<String>? chunks,
86
+ int? currentChunkIndex,
87
}) {
88
return TtsState(
89
isSpeaking: isSpeaking ?? this.isSpeaking,
@@ -92,6 +103,8 @@ class TtsState {
103
canSeek: canSeek ?? this.canSeek,
104
isSeekPending: isSeekPending ?? this.isSeekPending,
105
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
106
+ chunks: chunks ?? this.chunks,
107
+ currentChunkIndex: currentChunkIndex ?? this.currentChunkIndex,
108
);
109
}
110
}
@@ -191,6 +204,7 @@ class TtsNotifier extends Notifier<TtsState> {
204
_currentIndexSubscription = _player.currentIndexStream.listen((index) {
205
if (index != null && index != _currentChunkIndex) {
206
_currentChunkIndex = index;
207
+ state = state.copyWith(currentChunkIndex: index);
208
_updateChunkOffset();
209
_onChunkChanged.add(null);
210
_enqueueNextChunks();
@@ -419,6 +433,8 @@ class TtsNotifier extends Notifier<TtsState> {
433
isPreview: false,
434
clearPlayingTarget: true,
435
resetProgress: true,
436
+ chunks: const [],
437
+ currentChunkIndex: -1,
438
canSeek: false,
439
);
440
// Hand the iOS audio session back to record so a follow-up
@@ -707,6 +723,7 @@ class TtsNotifier extends Notifier<TtsState> {
723
_resetProgress();
724
_chunks = _splitText(text);
725
_currentChunkIndex = 0;
726
+ state = state.copyWith(chunks: _chunks, currentChunkIndex: 0);
727
_currentVoice = resolvedVoiceId;
728
_synthesizedChunks.clear();
729
_lastEnqueuedChunk = -1;
@@ -886,6 +903,7 @@ class TtsNotifier extends Notifier<TtsState> {
903
await _player.seek(Duration.zero);
904
}
905
_currentChunkIndex = targetChunk;
906
+ state = state.copyWith(currentChunkIndex: targetChunk);
907
_updateChunkOffset();
908
_updatePlaybackProgress();
909
} finally {
@@ -990,6 +1008,8 @@ class TtsNotifier extends Notifier<TtsState> {
1008
isPreview: false,
1009
clearPlayingTarget: true,
1010
resetProgress: true,
1011
+ chunks: const [],
1012
+ currentChunkIndex: -1,
1013
canSeek: false,
1014
);
1015
_configureAudioSessionForSpeech();
@@ -1037,6 +1057,7 @@ class TtsNotifier extends Notifier<TtsState> {
1057
_resetProgress();
1058
_chunks = List<String>.from(cache.chunks);
1059
_currentChunkIndex = 0;
1060
+ state = state.copyWith(chunks: _chunks, currentChunkIndex: 0);
1061
_currentVoice = cache.voice;
1062
_synthesizedChunks.clear();
1063
_synthesizedChunks.addAll(cache.synthesizedChunks);
lib/features/voice_mode/providers/voice_mode_provider.dart
+44
-1
@@ -3,6 +3,7 @@ import 'dart:async';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
5
import '../../../core/logger/app_logger.dart';
6
+import '../../../services/voice_feedback_service.dart';
7
import '../../chat/providers/chat_providers.dart';
8
import '../../stt/providers/stt_providers.dart';
9
import '../../tts/providers/tts_providers.dart';
@@ -34,6 +35,10 @@ class VoiceModeState {
35
final bool isMuted;
36
final String? error;
37
38
+ /// Live microphone input level in 0..1, set from the STT
39
+ /// `onSoundLevelChange` callback. 0 when not listening.
40
+ final double micLevel;
41
+
42
const VoiceModeState({
43
this.isActive = false,
44
this.phase = VoiceModePhase.idle,
@@ -42,6 +47,7 @@ class VoiceModeState {
47
this.autoListen = true,
48
this.isMuted = false,
49
this.error,
50
+ this.micLevel = 0,
51
});
52
53
VoiceModeState copyWith({
@@ -53,6 +59,7 @@ class VoiceModeState {
59
bool? isMuted,
60
String? error,
61
bool clearError = false,
62
+ double? micLevel,
63
}) {
64
return VoiceModeState(
65
isActive: isActive ?? this.isActive,
@@ -62,6 +69,7 @@ class VoiceModeState {
69
autoListen: autoListen ?? this.autoListen,
70
isMuted: isMuted ?? this.isMuted,
71
error: clearError ? null : (error ?? this.error),
72
+ micLevel: micLevel ?? this.micLevel,
73
);
74
}
75
}
@@ -78,6 +86,12 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
86
bool _active = false;
87
bool _isSendingTranscript = false;
88
89
+ /// Whether the "generating" feedback cue has already fired for the
90
+ /// current streamed response. Reset on session start; consumed once
91
+ /// in `_handleStreamingComplete` so haptic only fires on the first
92
+ /// chunk arrival, not on every token.
93
+ bool _generatingFired = false;
94
+
95
@override
96
VoiceModeState build() {
97
// Listen to chat streaming state changes.
@@ -101,6 +115,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
115
phase: VoiceModePhase.error,
116
error: _mapSttError(next),
117
);
118
+ ref.read(voiceFeedbackProvider).playDisconnected();
119
},
120
);
121
@@ -114,7 +129,9 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
129
/// Begin the voice mode session. Called when the overlay opens.
130
void startSession() {
131
_active = true;
132
+ _generatingFired = false;
133
state = const VoiceModeState(isActive: true, phase: VoiceModePhase.idle);
134
+ ref.read(voiceFeedbackProvider).playConnected();
135
// Auto-start listening.
136
startListening();
137
}
@@ -129,9 +146,12 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
146
phase: VoiceModePhase.listening,
147
transcript: '',
148
response: '',
149
+ micLevel: 0,
150
clearError: true,
151
);
152
153
+ ref.read(voiceFeedbackProvider).playListening();
154
+
155
final stt = ref.read(sttProvider.notifier);
156
await stt.startListening(
157
onResult: (words) {
@@ -151,6 +171,13 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
171
_isSendingTranscript = true;
172
await stopListeningAndSend();
173
},
174
+ onSoundLevelChange: (level) {
175
+ if (!_active) return;
176
+ // Skip the rebuild when the value hasn't materially changed
177
+ // (riverpod notifies listeners on every distinct copyWith).
178
+ if ((state.micLevel - level).abs() < 0.01) return;
179
+ state = state.copyWith(micLevel: level);
180
+ },
181
);
182
}
183
@@ -171,7 +198,13 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
198
return;
199
}
200
174
- state = state.copyWith(phase: VoiceModePhase.processing, response: '');
201
+ // Reset the mic level so the waveform visualizer doesn't keep
202
+ // dancing on a stale value once we leave the listening phase.
203
+ state = state.copyWith(
204
+ phase: VoiceModePhase.processing,
205
+ response: '',
206
+ micLevel: 0,
207
+ );
208
209
// Send the transcript as a regular chat message. The streaming
210
// listener will handle the transition to speaking phase.
@@ -197,6 +230,9 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
230
Future<void> endSession() async {
231
_active = false;
232
_isSendingTranscript = false;
233
+ _generatingFired = false;
234
+
235
+ ref.read(voiceFeedbackProvider).playDisconnected();
236
237
// Stop STT if still listening.
238
try {
@@ -272,6 +308,13 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
308
response: responseText,
309
);
310
311
+ // Fire the generating cue only on the first chunk of this response —
312
+ // repeated taps during streaming feel glitchy.
313
+ if (!_generatingFired) {
314
+ _generatingFired = true;
315
+ ref.read(voiceFeedbackProvider).playGenerating();
316
+ }
317
+
318
// Trigger TTS to speak the response. Pass message/conversation IDs so
319
// TTS can cache and resume playback against the right message.
320
final tts = ref.read(ttsProvider.notifier);
lib/features/voice_mode/views/components/voice_mode_controls.dart
+66
-66
@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
2
import 'package:hugeicons/hugeicons.dart';
3
4
import '../../providers/voice_mode_provider.dart';
5
+import '../../voice_mode_palette.dart';
6
7
/// Bottom control bar for voice mode overlay:
7
-/// end call button, mute toggle, auto-listen toggle.
8
+/// auto-listen toggle, end-call button, primary mic action.
9
class VoiceModeControls extends StatelessWidget {
10
final VoiceModePhase phase;
11
final bool autoListen;
@@ -27,33 +28,30 @@ class VoiceModeControls extends StatelessWidget {
28
29
@override
30
Widget build(BuildContext context) {
30
- final theme = Theme.of(context);
31
- final isDark = theme.brightness == Brightness.dark;
31
+ final isDark = Theme.of(context).brightness == Brightness.dark;
32
33
return Padding(
34
- padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
34
+ padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 8),
35
child: Row(
36
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
36
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
37
+ crossAxisAlignment: CrossAxisAlignment.center,
38
children: [
38
- // Auto-listen toggle.
39
- _ControlButton(
39
+ _PillControl(
40
icon: autoListen
41
? HugeIcons.strokeRoundedRepeat
42
: HugeIcons.strokeRoundedRepeatOff,
43
label: autoListen ? 'Auto' : 'Manual',
44
isActive: autoListen,
45
- activeColor: isDark
46
- ? const Color(0xFF6366F1)
47
- : const Color(0xFF4F46E5),
48
- isDark: isDark,
45
+ activeColor: VoiceModePalette.accentFor(
46
+ VoiceModePhase.speaking,
47
+ isDark: isDark,
48
+ ),
49
onTap: onToggleAutoListen,
50
),
51
52
- // Main action — end session (red).
52
_EndCallButton(onTap: onEnd),
53
55
- // Send / interrupt based on phase.
56
- _ControlButton(
54
+ _PillControl(
55
icon: phase == VoiceModePhase.listening
56
? HugeIcons.strokeRoundedSent
57
: phase == VoiceModePhase.speaking
@@ -66,10 +64,10 @@ class VoiceModeControls extends StatelessWidget {
64
: 'Speak',
65
isActive: phase == VoiceModePhase.listening ||
66
phase == VoiceModePhase.speaking,
69
- activeColor: isDark
70
- ? const Color(0xFF34D399)
71
- : const Color(0xFF059669),
72
- isDark: isDark,
67
+ activeColor: VoiceModePalette.accentFor(
68
+ VoiceModePhase.listening,
69
+ isDark: isDark,
70
+ ),
71
onTap: onTapCenter,
72
),
73
],
@@ -88,15 +86,15 @@ class _EndCallButton extends StatelessWidget {
86
return GestureDetector(
87
onTap: onTap,
88
child: Container(
91
- width: 64,
92
- height: 64,
89
+ width: 68,
90
+ height: 68,
91
decoration: BoxDecoration(
92
color: const Color(0xFFEF4444),
93
shape: BoxShape.circle,
94
boxShadow: [
95
BoxShadow(
98
- color: const Color(0xFFEF4444).withValues(alpha: 0.4),
99
- blurRadius: 16,
96
+ color: const Color(0xFFEF4444).withValues(alpha: 0.45),
97
+ blurRadius: 18,
98
spreadRadius: 2,
99
),
100
],
@@ -104,7 +102,7 @@ class _EndCallButton extends StatelessWidget {
102
child: const Center(
103
child: HugeIcon(
104
icon: HugeIcons.strokeRoundedCallEnd01,
107
- size: 28,
105
+ size: 30,
106
color: Colors.white,
107
),
108
),
@@ -113,76 +111,78 @@ class _EndCallButton extends StatelessWidget {
111
}
112
}
113
116
-class _ControlButton extends StatelessWidget {
114
+class _PillControl extends StatelessWidget {
115
final List<List<dynamic>> icon;
116
final String label;
117
final bool isActive;
118
final Color activeColor;
121
- final bool isDark;
119
final VoidCallback onTap;
120
124
- const _ControlButton({
121
+ const _PillControl({
122
required this.icon,
123
required this.label,
124
required this.isActive,
125
required this.activeColor,
129
- required this.isDark,
126
required this.onTap,
127
});
128
129
@override
130
Widget build(BuildContext context) {
131
+ final isDark = Theme.of(context).brightness == Brightness.dark;
132
final bgColor = isActive
136
- ? activeColor.withValues(alpha: 0.15)
133
+ ? activeColor.withValues(alpha: 0.14)
134
: (isDark
138
- ? Colors.white.withValues(alpha: 0.06)
139
- : Colors.black.withValues(alpha: 0.05));
135
+ ? Colors.white.withValues(alpha: 0.05)
136
+ : Colors.black.withValues(alpha: 0.04));
137
138
final iconColor = isActive
139
? activeColor
140
: (isDark
144
- ? Colors.white.withValues(alpha: 0.6)
145
- : Colors.black.withValues(alpha: 0.5));
141
+ ? Colors.white.withValues(alpha: 0.7)
142
+ : Colors.black.withValues(alpha: 0.55));
143
144
return GestureDetector(
145
+ behavior: HitTestBehavior.opaque,
146
onTap: onTap,
149
- child: Column(
150
- mainAxisSize: MainAxisSize.min,
151
- children: [
152
- Container(
153
- width: 52,
154
- height: 52,
155
- decoration: BoxDecoration(
156
- color: bgColor,
157
- shape: BoxShape.circle,
158
- border: Border.all(
159
- color: isActive
160
- ? activeColor.withValues(alpha: 0.3)
161
- : Colors.transparent,
162
- width: 1.5,
163
- ),
147
+ child: AnimatedContainer(
148
+ duration: const Duration(milliseconds: 220),
149
+ curve: Curves.easeOut,
150
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
151
+ decoration: BoxDecoration(
152
+ color: bgColor,
153
+ borderRadius: BorderRadius.circular(999),
154
+ border: Border.all(
155
+ color: isActive
156
+ ? activeColor.withValues(alpha: 0.3)
157
+ : Colors.transparent,
158
+ width: 1.2,
159
+ ),
160
+ ),
161
+ child: Column(
162
+ mainAxisSize: MainAxisSize.min,
163
+ children: [
164
+ HugeIcon(
165
+ icon: icon,
166
+ size: 22,
167
+ color: iconColor,
168
),
165
- child: Center(
166
- child: HugeIcon(
167
- icon: icon,
168
- size: 24,
169
- color: iconColor,
169
+ const SizedBox(height: 4),
170
+ Text(
171
+ label,
172
+ style: TextStyle(
173
+ fontSize: 11,
174
+ fontWeight: FontWeight.w600,
175
+ letterSpacing: 0.2,
176
+ color: isActive
177
+ ? activeColor
178
+ : (isDark
179
+ ? Colors.white.withValues(alpha: 0.55)
180
+ : Colors.black.withValues(alpha: 0.5)),
181
),
182
),
172
- ),
173
- const SizedBox(height: 6),
174
- Text(
175
- label,
176
- style: TextStyle(
177
- fontSize: 11,
178
- fontWeight: FontWeight.w500,
179
- color: isDark
180
- ? Colors.white.withValues(alpha: 0.5)
181
- : Colors.black.withValues(alpha: 0.45),
182
- ),
183
- ),
184
- ],
183
+ ],
184
+ ),
185
),
186
);
187
}
188
-}
188
+}
\ No newline at end of file
lib/features/voice_mode/views/components/voice_transcript.dart
+352
-65
@@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
4
import '../../../tts/providers/tts_providers.dart';
5
import '../../providers/voice_mode_provider.dart';
6
+import '../../voice_mode_palette.dart';
7
7
-/// Displays the current voice phase badge and auto-scrolling response text container.
8
+/// Displays the current voice phase badge and auto-scrolling response text
9
+/// container. Designed to sit below the waveform visualizer.
10
class VoiceTranscript extends ConsumerStatefulWidget {
11
final VoiceModePhase phase;
12
final String transcript;
@@ -55,50 +57,43 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
57
58
@override
59
Widget build(BuildContext context) {
58
- final theme = Theme.of(context);
59
- final isDark = theme.brightness == Brightness.dark;
60
+ final isDark = Theme.of(context).brightness == Brightness.dark;
61
final isTtsInitializing = ref.watch(
62
ttsProvider.select((s) => s.isInitializing),
63
);
64
64
- final (label, badgeColor, isSynthesizing) = _resolveStatusBadge(
65
- isTtsInitializing,
66
- );
65
+ final (label, badgeColor, isSynthesizing) =
66
+ _resolveStatusBadge(isTtsInitializing);
67
68
return Column(
69
mainAxisSize: MainAxisSize.min,
70
children: [
71
- // Status Badge Pill
71
AnimatedSwitcher(
72
duration: const Duration(milliseconds: 300),
73
+ transitionBuilder: (child, animation) => FadeTransition(
74
+ opacity: animation,
75
+ child: ScaleTransition(
76
+ scale: Tween<double>(begin: 0.92, end: 1.0).animate(animation),
77
+ child: child,
78
+ ),
79
+ ),
80
child: Container(
81
key: ValueKey(label),
82
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
83
decoration: BoxDecoration(
78
- color: badgeColor.withValues(alpha: isDark ? 0.15 : 0.1),
79
- borderRadius: BorderRadius.circular(20),
84
+ color: badgeColor.withValues(alpha: isDark ? 0.16 : 0.12),
85
+ borderRadius: BorderRadius.circular(999),
86
border: Border.all(
81
- color: badgeColor.withValues(alpha: isDark ? 0.3 : 0.25),
87
+ color: badgeColor.withValues(alpha: isDark ? 0.32 : 0.28),
88
width: 1,
89
),
90
),
91
child: Row(
92
mainAxisSize: MainAxisSize.min,
93
children: [
88
- Container(
89
- width: 7,
90
- height: 7,
91
- decoration: BoxDecoration(
92
- shape: BoxShape.circle,
93
- color: badgeColor,
94
- boxShadow: [
95
- BoxShadow(
96
- color: badgeColor.withValues(alpha: 0.6),
97
- blurRadius: 6,
98
- spreadRadius: 1,
99
- ),
100
- ],
101
- ),
94
+ _PulsingDot(
95
+ color: badgeColor,
96
+ active: widget.phase != VoiceModePhase.idle,
97
),
98
const SizedBox(width: 8),
99
Text(
@@ -106,7 +101,7 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
101
style: TextStyle(
102
fontSize: 11,
103
fontWeight: FontWeight.w700,
109
- letterSpacing: 1.3,
104
+ letterSpacing: 1.4,
105
color: badgeColor,
106
),
107
),
@@ -115,29 +110,26 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
110
),
111
),
112
118
- const SizedBox(height: 20),
119
-
120
- // Main text content — auto-scrolling square container for RESPONDING/PROCESSING,
121
- // hidden when SPEAKING.
122
- if (widget.phase != VoiceModePhase.speaking)
123
- AnimatedSwitcher(
124
- duration: const Duration(milliseconds: 300),
125
- switchInCurve: Curves.easeOut,
126
- switchOutCurve: Curves.easeIn,
127
- transitionBuilder: (child, animation) {
128
- return FadeTransition(
129
- opacity: animation,
130
- child: SlideTransition(
131
- position: Tween<Offset>(
132
- begin: const Offset(0, 0.08),
133
- end: Offset.zero,
134
- ).animate(animation),
135
- child: child,
136
- ),
137
- );
138
- },
139
- child: _buildTextContent(context, isDark, isSynthesizing),
140
- ),
113
+ const SizedBox(height: 18),
114
+
115
+ AnimatedSwitcher(
116
+ duration: const Duration(milliseconds: 300),
117
+ switchInCurve: Curves.easeOut,
118
+ switchOutCurve: Curves.easeIn,
119
+ transitionBuilder: (child, animation) {
120
+ return FadeTransition(
121
+ opacity: animation,
122
+ child: SlideTransition(
123
+ position: Tween<Offset>(
124
+ begin: const Offset(0, 0.08),
125
+ end: Offset.zero,
126
+ ).animate(animation),
127
+ child: child,
128
+ ),
129
+ );
130
+ },
131
+ child: _buildTextContent(context, isDark, isSynthesizing),
132
+ ),
133
],
134
);
135
}
@@ -147,29 +139,37 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
139
return ('SYNTHESIZING VOICE', const Color(0xFFF59E0B), true);
140
}
141
142
+ final isDark = Theme.of(context).brightness == Brightness.dark;
143
+
144
switch (widget.phase) {
145
case VoiceModePhase.listening:
152
- return ('LISTENING', const Color(0xFF10B981), false);
146
+ return ('LISTENING',
147
+ VoiceModePalette.accentFor(VoiceModePhase.listening, isDark: isDark),
148
+ false);
149
case VoiceModePhase.processing:
150
final hasResponse = widget.response.trim().isNotEmpty;
151
if (isTtsInitializing) {
152
return ('SYNTHESIZING VOICE', const Color(0xFFF59E0B), true);
153
}
154
return (
159
- hasResponse ? 'RESPONDING' : 'PROCESSING',
160
- const Color(0xFF8B5CF6),
155
+ hasResponse ? 'RESPONDING' : 'THINKING',
156
+ VoiceModePalette.accentFor(VoiceModePhase.processing, isDark: isDark),
157
false,
158
);
159
case VoiceModePhase.speaking:
164
- return ('SPEAKING', const Color(0xFF6366F1), false);
160
+ return ('SPEAKING',
161
+ VoiceModePalette.accentFor(VoiceModePhase.speaking, isDark: isDark),
162
+ false);
163
case VoiceModePhase.idle:
166
- return ('READY', Colors.grey, false);
164
+ return ('READY',
165
+ VoiceModePalette.accentFor(VoiceModePhase.idle, isDark: isDark),
166
+ false);
167
case VoiceModePhase.error:
168
final errorText = widget.error ?? '';
169
final isSpeechError = errorText.toLowerCase().contains('speech');
170
return (
171
isSpeechError ? 'NO SPEECH DETECTED' : 'ERROR',
172
- const Color(0xFFEF4444),
172
+ VoiceModePalette.accentFor(VoiceModePhase.error, isDark: isDark),
173
false,
174
);
175
}
@@ -200,7 +200,6 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
200
);
201
}
202
203
- // Auto-scrolling square container for generating text
203
return Container(
204
key: const ValueKey('responding-container'),
205
width: double.infinity,
@@ -226,8 +225,8 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
225
fontSize: 14,
226
fontWeight: FontWeight.w400,
227
color: isDark
229
- ? Colors.white.withValues(alpha: 0.7)
230
- : Colors.black.withValues(alpha: 0.65),
228
+ ? Colors.white.withValues(alpha: 0.75)
229
+ : Colors.black.withValues(alpha: 0.7),
230
height: 1.45,
231
),
232
textAlign: TextAlign.start,
@@ -240,18 +239,21 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
239
widget.transcript.isEmpty ? 'Listening...' : widget.transcript;
240
return Text(
241
displayText,
243
- key: ValueKey('listening-${displayText.hashCode}'),
242
+ key: const ValueKey('listening'),
243
style: TextStyle(
245
- fontSize: 18,
244
+ fontSize: 19,
245
fontWeight: FontWeight.w500,
246
color: isDark
248
- ? Colors.white.withValues(alpha: 0.85)
249
- : Colors.black.withValues(alpha: 0.8),
247
+ ? Colors.white.withValues(alpha: 0.9)
248
+ : Colors.black.withValues(alpha: 0.85),
249
height: 1.4,
250
),
251
textAlign: TextAlign.center,
252
);
253
254
+ case VoiceModePhase.speaking:
255
+ return _buildSpeakingContent(isDark);
256
+
257
case VoiceModePhase.idle:
258
return Text(
259
'Tap to start speaking',
@@ -276,15 +278,300 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
278
key: ValueKey('error-${errorText.hashCode}'),
279
style: TextStyle(
280
fontSize: 16,
279
- fontWeight: FontWeight.w400,
280
- color: const Color(0xFFEF4444).withValues(alpha: 0.9),
281
+ fontWeight: FontWeight.w500,
282
+ color: const Color(0xFFEF4444).withValues(alpha: 0.95),
283
height: 1.4,
284
),
285
textAlign: TextAlign.center,
286
);
287
+ }
288
+ }
289
286
- case VoiceModePhase.speaking:
287
- return const SizedBox.shrink();
290
+ /// Renders the assistant's response with the currently-spoken word
291
+ /// highlighted. Estimate per-chunk timing proportionally from chunk
292
+ /// character counts (TTS exposes real per-chunk durations internally,
293
+ /// but not via [TtsState], so character-ratio is a good approximation).
294
+ Widget _buildSpeakingContent(bool isDark) {
295
+ final response = widget.response;
296
+ if (response.isEmpty) {
297
+ return Text(
298
+ '...',
299
+ key: const ValueKey('speaking'),
300
+ style: TextStyle(
301
+ fontSize: 16,
302
+ fontWeight: FontWeight.w400,
303
+ color: isDark
304
+ ? Colors.white.withValues(alpha: 0.7)
305
+ : Colors.black.withValues(alpha: 0.7),
306
+ height: 1.45,
307
+ ),
308
+ textAlign: TextAlign.center,
309
+ );
310
}
311
+
312
+ final tts = ref.watch(ttsProvider);
313
+ final accent = VoiceModePalette.accentFor(
314
+ VoiceModePhase.speaking,
315
+ isDark: isDark,
316
+ );
317
+ final mutedColor = isDark
318
+ ? Colors.white.withValues(alpha: 0.7)
319
+ : Colors.black.withValues(alpha: 0.7);
320
+
321
+ final highlightedWordIndex = _activeWordIndex(response, tts);
322
+
323
+ final base = TextStyle(
324
+ fontSize: 16,
325
+ fontWeight: FontWeight.w400,
326
+ color: mutedColor,
327
+ height: 1.45,
328
+ );
329
+
330
+ final spans = _buildWordSpans(
331
+ response: response,
332
+ activeIndex: highlightedWordIndex,
333
+ base: base,
334
+ active: base.copyWith(
335
+ color: accent,
336
+ fontWeight: FontWeight.w700,
337
+ ),
338
+ );
339
+
340
+ return RichText(
341
+ key: const ValueKey('speaking'),
342
+ textAlign: TextAlign.center,
343
+ maxLines: 4,
344
+ overflow: TextOverflow.ellipsis,
345
+ text: TextSpan(style: base, children: spans),
346
+ );
347
+ }
348
+
349
+ /// Returns the index of the word currently being spoken, or -1 if no
350
+ /// word is currently active (e.g. between sentences, before TTS has
351
+ /// populated `chunks`).
352
+ int _activeWordIndex(String response, TtsState tts) {
353
+ if (!tts.isSpeaking ||
354
+ tts.chunks.isEmpty ||
355
+ tts.currentChunkIndex < 0 ||
356
+ tts.currentChunkIndex >= tts.chunks.length) {
357
+ return -1;
358
+ }
359
+
360
+ final chunkStartChars = _chunkCharStarts(response, tts.chunks);
361
+ final charIdx = _globalCharIndex(
362
+ chunkIndex: tts.currentChunkIndex,
363
+ chunkStartChars: chunkStartChars,
364
+ chunks: tts.chunks,
365
+ duration: tts.duration,
366
+ position: tts.position,
367
+ );
368
+ if (charIdx < 0) return -1;
369
+
370
+ return _wordIndexAtChar(response, charIdx);
371
+ }
372
+
373
+ /// Maps each TTS chunk to a starting character offset within the
374
+ /// original response string so we can locate the spoken character.
375
+ /// Falls back to char-proportional split when the TTS chunks don't
376
+ /// map cleanly (e.g. markdown was stripped, text was rephrased).
377
+ List<int> _chunkCharStarts(String response, List<String> chunks) {
378
+ final starts = <int>[];
379
+ int cursor = 0;
380
+ for (final chunk in chunks) {
381
+ final idx = response.indexOf(chunk, cursor);
382
+ starts.add(idx >= 0 ? idx : cursor);
383
+ cursor = (idx >= 0 ? idx : cursor) + chunk.length;
384
+ }
385
+ return starts;
386
+ }
387
+
388
+ /// Approximates the global character index that's currently being
389
+ /// spoken, by combining the chunk-of-the-moment's start offset with
390
+ /// the chunk's share of the total `duration` and the live `position`.
391
+ int _globalCharIndex({
392
+ required int chunkIndex,
393
+ required List<int> chunkStartChars,
394
+ required List<String> chunks,
395
+ required Duration duration,
396
+ required Duration position,
397
+ }) {
398
+ if (chunks.isEmpty) return -1;
399
+
400
+ final totalChars = chunks.fold<int>(0, (sum, c) => sum + c.length);
401
+ if (totalChars == 0) return -1;
402
+
403
+ final prevChunkChars = <int>[0];
404
+ for (var i = 0; i < chunks.length; i++) {
405
+ prevChunkChars.add(prevChunkChars.last + chunks[i].length);
406
+ }
407
+
408
+ final safeDuration = duration.inMilliseconds > 0
409
+ ? duration.inMilliseconds
410
+ : totalChars * 60;
411
+ final safePosition = position.inMilliseconds.clamp(0, safeDuration);
412
+ final globalCharIdx =
413
+ ((safePosition / safeDuration) * totalChars).round().clamp(0, totalChars);
414
+
415
+ // Within which chunk does that character fall?
416
+ int targetChunk = chunks.length - 1;
417
+ for (var i = 0; i < chunks.length; i++) {
418
+ if (globalCharIdx < prevChunkChars[i + 1]) {
419
+ targetChunk = i;
420
+ break;
421
+ }
422
+ }
423
+
424
+ final chunkStart = chunkStartChars[targetChunk];
425
+ final charInChunk = (globalCharIdx - prevChunkChars[targetChunk])
426
+ .clamp(0, chunks[targetChunk].length);
427
+ return chunkStart + charInChunk;
428
+ }
429
+
430
+ /// Builds the highlighted word index for a text by walking whitespace-
431
+ /// separated tokens and returning the index of the one containing
432
+ /// [charIdx].
433
+ int _wordIndexAtChar(String text, int charIdx) {
434
+ if (charIdx < 0 || charIdx >= text.length) return -1;
435
+ var inWord = false;
436
+ var wordIndex = -1;
437
+ for (var i = 0; i < text.length; i++) {
438
+ final isWordChar = _isWordChar(text.codeUnitAt(i));
439
+ if (isWordChar && !inWord) {
440
+ wordIndex++;
441
+ inWord = true;
442
+ } else if (!isWordChar && inWord) {
443
+ inWord = false;
444
+ }
445
+ if (i == charIdx && inWord) {
446
+ return wordIndex;
447
+ }
448
+ }
449
+ // If charIdx is the last char and is inside a word.
450
+ return inWord ? wordIndex : -1;
451
+ }
452
+
453
+ bool _isWordChar(int codeUnit) {
454
+ // Letters, digits, and apostrophes (don't break "don't" into two).
455
+ final isAlnum = (codeUnit >= 0x30 && codeUnit <= 0x39) || // 0-9
456
+ (codeUnit >= 0x41 && codeUnit <= 0x5A) || // A-Z
457
+ (codeUnit >= 0x61 && codeUnit <= 0x7A) || // a-z
458
+ (codeUnit >= 0xC0 && codeUnit <= 0x024F); // Latin extended
459
+ final isApostrophe =
460
+ codeUnit == 0x27 || codeUnit == 0x2019; // ' '
461
+ return isAlnum || isApostrophe;
462
+ }
463
+
464
+ List<InlineSpan> _buildWordSpans({
465
+ required String response,
466
+ required int activeIndex,
467
+ required TextStyle base,
468
+ required TextStyle active,
469
+ }) {
470
+ final spans = <InlineSpan>[];
471
+ var wordIndex = -1;
472
+ var buffer = StringBuffer();
473
+ var inWord = false;
474
+
475
+ void flushBuffer({required bool trailingWord}) {
476
+ if (buffer.isEmpty) return;
477
+ final text = buffer.toString();
478
+ spans.add(TextSpan(
479
+ text: text,
480
+ style: trailingWord && wordIndex == activeIndex ? active : base,
481
+ ));
482
+ buffer = StringBuffer();
483
+ }
484
+
485
+ for (var i = 0; i < response.length; i++) {
486
+ final code = response.codeUnitAt(i);
487
+ final isWord = _isWordChar(code);
488
+ if (isWord && !inWord) {
489
+ // Flush preceding whitespace.
490
+ flushBuffer(trailingWord: false);
491
+ wordIndex++;
492
+ inWord = true;
493
+ } else if (!isWord && inWord) {
494
+ // End of word — flush with active/inactive style.
495
+ flushBuffer(trailingWord: true);
496
+ inWord = false;
497
+ }
498
+ buffer.write(response[i]);
499
+ }
500
+ if (inWord) {
501
+ flushBuffer(trailingWord: true);
502
+ } else {
503
+ flushBuffer(trailingWord: false);
504
+ }
505
+ return spans;
506
}
507
}
508
+
509
+/// Tiny pulsing dot used inside the status badge.
510
+class _PulsingDot extends StatefulWidget {
511
+ final Color color;
512
+ final bool active;
513
+
514
+ const _PulsingDot({required this.color, required this.active});
515
+
516
+ @override
517
+ State<_PulsingDot> createState() => _PulsingDotState();
518
+}
519
+
520
+class _PulsingDotState extends State<_PulsingDot>
521
+ with SingleTickerProviderStateMixin {
522
+ late final AnimationController _controller;
523
+
524
+ @override
525
+ void initState() {
526
+ super.initState();
527
+ _controller = AnimationController(
528
+ vsync: this,
529
+ duration: const Duration(milliseconds: 1400),
530
+ );
531
+ if (widget.active) _controller.repeat(reverse: true);
532
+ }
533
+
534
+ @override
535
+ void didUpdateWidget(_PulsingDot oldWidget) {
536
+ super.didUpdateWidget(oldWidget);
537
+ if (widget.active && !_controller.isAnimating) {
538
+ _controller.repeat(reverse: true);
539
+ } else if (!widget.active && _controller.isAnimating) {
540
+ _controller.stop();
541
+ _controller.value = 0;
542
+ }
543
+ }
544
+
545
+ @override
546
+ void dispose() {
547
+ _controller.dispose();
548
+ super.dispose();
549
+ }
550
+
551
+ @override
552
+ Widget build(BuildContext context) {
553
+ return AnimatedBuilder(
554
+ animation: _controller,
555
+ builder: (context, _) {
556
+ final t = widget.active ? _controller.value : 0.0;
557
+ return Container(
558
+ width: 8,
559
+ height: 8,
560
+ decoration: BoxDecoration(
561
+ shape: BoxShape.circle,
562
+ color: widget.color,
563
+ boxShadow: widget.active
564
+ ? [
565
+ BoxShadow(
566
+ color: widget.color.withValues(alpha: 0.5 + 0.4 * t),
567
+ blurRadius: 5 + 5 * t,
568
+ spreadRadius: 0.5 + 1.5 * t,
569
+ ),
570
+ ]
571
+ : null,
572
+ ),
573
+ );
574
+ },
575
+ );
576
+ }
577
+}
\ No newline at end of file
lib/features/voice_mode/views/components/voice_visualizer.dart
+163
-221
@@ -1,20 +1,29 @@
1
-import 'dart:math';
1
+import 'dart:math' as math;
2
3
import 'package:flutter/material.dart';
4
5
import '../../providers/voice_mode_provider.dart';
6
-
7
-/// Animated visual indicator that changes appearance based on the
8
-/// current [VoiceModePhase]. Uses [CustomPainter] for smooth,
9
-/// GPU-friendly rendering.
6
+import '../../voice_mode_palette.dart';
7
+
8
+/// Real-time waveform visualizer.
9
+///
10
+/// While [VoiceModePhase.listening] the bars animate from the live
11
+/// microphone level. While [VoiceModePhase.speaking] (AI playback) we
12
+/// drive a procedural envelope because we can't read amplitude from the
13
+/// TTS audio stream.
14
class VoiceVisualizer extends StatefulWidget {
15
final VoiceModePhase phase;
16
final double size;
17
18
+ /// Current microphone input level in 0..1 (only meaningful while
19
+ /// listening).
20
+ final double micLevel;
21
+
22
const VoiceVisualizer({
23
super.key,
24
required this.phase,
25
this.size = 220,
26
+ this.micLevel = 0,
27
});
28
29
@override
@@ -22,88 +31,43 @@ class VoiceVisualizer extends StatefulWidget {
31
}
32
33
class _VoiceVisualizerState extends State<VoiceVisualizer>
25
- with TickerProviderStateMixin {
26
- late AnimationController _pulseController;
27
- late AnimationController _orbitController;
28
- late AnimationController _waveController;
34
+ with SingleTickerProviderStateMixin {
35
+ /// Always running — even during listening we need per-frame ticks so the
36
+ /// procedural noise modulation keeps moving while the user speaks. During
37
+ /// speaking the same controller drives the AI envelope.
38
+ late final AnimationController _ticker;
39
40
@override
41
void initState() {
42
super.initState();
33
-
34
- _pulseController = AnimationController(
43
+ _ticker = AnimationController(
44
vsync: this,
45
duration: const Duration(milliseconds: 1800),
37
- );
38
-
39
- _orbitController = AnimationController(
40
- vsync: this,
41
- duration: const Duration(milliseconds: 2400),
42
- );
43
-
44
- _waveController = AnimationController(
45
- vsync: this,
46
- duration: const Duration(milliseconds: 1200),
47
- );
48
-
49
- _syncAnimations(widget.phase);
50
- }
51
-
52
- @override
53
- void didUpdateWidget(VoiceVisualizer oldWidget) {
54
- super.didUpdateWidget(oldWidget);
55
- if (oldWidget.phase != widget.phase) {
56
- _syncAnimations(widget.phase);
57
- }
58
- }
59
-
60
- void _syncAnimations(VoiceModePhase phase) {
61
- // Stop all first.
62
- _pulseController.stop();
63
- _orbitController.stop();
64
- _waveController.stop();
65
-
66
- switch (phase) {
67
- case VoiceModePhase.listening:
68
- _pulseController.repeat(reverse: true);
69
- case VoiceModePhase.processing:
70
- _orbitController.repeat();
71
- case VoiceModePhase.speaking:
72
- _waveController.repeat();
73
- case VoiceModePhase.idle:
74
- case VoiceModePhase.error:
75
- // Gentle idle pulse.
76
- _pulseController.repeat(reverse: true);
77
- }
46
+ )..repeat();
47
}
48
49
@override
50
void dispose() {
82
- _pulseController.dispose();
83
- _orbitController.dispose();
84
- _waveController.dispose();
51
+ _ticker.dispose();
52
super.dispose();
53
}
54
55
@override
56
Widget build(BuildContext context) {
57
+ final isDark = Theme.of(context).brightness == Brightness.dark;
58
+
59
return SizedBox(
60
width: widget.size,
61
height: widget.size,
62
child: AnimatedBuilder(
94
- animation: Listenable.merge([
95
- _pulseController,
96
- _orbitController,
97
- _waveController,
98
- ]),
63
+ animation: _ticker,
64
builder: (context, _) {
65
return CustomPaint(
101
- painter: _VoicePainter(
66
+ painter: _WaveformPainter(
67
phase: widget.phase,
103
- pulseValue: _pulseController.value,
104
- orbitValue: _orbitController.value,
105
- waveValue: _waveController.value,
106
- isDark: Theme.of(context).brightness == Brightness.dark,
68
+ micLevel: widget.micLevel.clamp(0.0, 1.0),
69
+ aiValue: _ticker.value,
70
+ isDark: isDark,
71
),
72
);
73
},
@@ -112,196 +76,174 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
76
}
77
}
78
115
-class _VoicePainter extends CustomPainter {
79
+class _WaveformPainter extends CustomPainter {
80
+ static const _barCount = 28;
81
+
82
final VoiceModePhase phase;
117
- final double pulseValue;
118
- final double orbitValue;
119
- final double waveValue;
83
+ final double micLevel;
84
+ final double aiValue;
85
final bool isDark;
86
122
- _VoicePainter({
87
+ // Pre-allocated scratch values to keep the per-frame loop tight.
88
+ static final Paint _barPaint = Paint()..strokeCap = StrokeCap.round;
89
+ static final Paint _haloPaint = Paint()
90
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 28);
91
+ static final Paint _corePaint = Paint()
92
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
93
+
94
+ _WaveformPainter({
95
required this.phase,
124
- required this.pulseValue,
125
- required this.orbitValue,
126
- required this.waveValue,
96
+ required this.micLevel,
97
+ required this.aiValue,
98
required this.isDark,
99
});
100
101
@override
102
void paint(Canvas canvas, Size size) {
103
final center = Offset(size.width / 2, size.height / 2);
133
- final maxRadius = size.width / 2;
104
+ final accent = VoiceModePalette.accentFor(phase, isDark: isDark);
105
+
106
+ // The halo keeps the surface feeling alive even when there's no audio.
107
+ _paintHalo(canvas, center, size, accent);
108
109
switch (phase) {
110
case VoiceModePhase.listening:
137
- _paintListening(canvas, center, maxRadius);
138
- case VoiceModePhase.processing:
139
- _paintProcessing(canvas, center, maxRadius);
111
+ _paintBars(canvas, size, accent, amp: micLevel);
112
case VoiceModePhase.speaking:
141
- _paintSpeaking(canvas, center, maxRadius);
113
+ _paintBars(canvas, size, accent, amp: _aiEnvelope());
114
+ case VoiceModePhase.processing:
115
+ _paintProcessingBars(canvas, size, accent);
116
case VoiceModePhase.idle:
143
- _paintIdle(canvas, center, maxRadius);
117
+ _paintBars(canvas, size, accent, amp: 0.06 + 0.04 * aiValue);
118
case VoiceModePhase.error:
145
- _paintError(canvas, center, maxRadius);
119
+ _paintBars(canvas, size, accent, amp: 0.05);
120
}
121
}
122
149
- void _paintListening(Canvas canvas, Offset center, double maxRadius) {
150
- final baseColor = isDark
151
- ? const Color(0xFF6366F1) // Indigo
152
- : const Color(0xFF4F46E5);
153
-
154
- // Concentric pulsing rings.
155
- for (int i = 0; i < 4; i++) {
156
- final progress = (pulseValue + i * 0.2) % 1.0;
157
- final radius = maxRadius * 0.3 + maxRadius * 0.6 * progress;
158
- final alpha = (1.0 - progress) * 0.4;
159
-
160
- final paint = Paint()
161
- ..color = baseColor.withValues(alpha: alpha)
162
- ..style = PaintingStyle.stroke
163
- ..strokeWidth = 2.5 - (progress * 1.5);
164
-
165
- canvas.drawCircle(center, radius, paint);
123
+ void _paintBars(
124
+ Canvas canvas,
125
+ Size size,
126
+ Color color, {
127
+ required double amp,
128
+ }) {
129
+ const half = _barCount ~/ 2;
130
+ final width = size.width;
131
+ final height = size.height;
132
+ final centerY = height / 2;
133
+ final maxBarHeight = height * 0.42;
134
+ final minBarHeight = height * 0.04;
135
+ final barWidth = (width / (_barCount * 2)).clamp(2.0, 4.0);
136
+ final gap = (width - barWidth * _barCount * 2) / (_barCount * 2 + 1);
137
+ final baseX = centerY - (half * (barWidth + gap)) + gap / 2;
138
+ final stride = barWidth + gap;
139
+ final t = aiValue * 2 * math.pi;
140
+ final ampScale = 0.45 + amp * 1.35;
141
+
142
+ _barPaint.strokeWidth = barWidth;
143
+
144
+ for (int i = 0; i < _barCount; i++) {
145
+ final dist = ((i - half).abs() / half).clamp(0.0, 1.0);
146
+ final envelope = math.cos(dist * math.pi / 2);
147
+ final noise = _waveNoise(i - half, phase, t);
148
+ final normalised = (envelope * ampScale * (0.6 + 0.4 * noise))
149
+ .clamp(0.0, 1.0);
150
+ final barHeight = minBarHeight + (maxBarHeight - minBarHeight) * normalised;
151
+
152
+ _barPaint.color = color.withValues(alpha: 0.35 + 0.55 * normalised);
153
+ canvas.drawLine(
154
+ Offset(baseX + i * stride, centerY - barHeight / 2),
155
+ Offset(baseX + i * stride, centerY + barHeight / 2),
156
+ _barPaint,
157
+ );
158
}
159
168
- // Inner glowing core.
169
- final corePaint = Paint()
170
- ..color = baseColor.withValues(alpha: 0.6 + pulseValue * 0.3)
171
- ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
172
- canvas.drawCircle(center, maxRadius * 0.18, corePaint);
173
-
174
- final solidCore = Paint()..color = baseColor;
175
- canvas.drawCircle(center, maxRadius * 0.12, solidCore);
160
+ _corePaint.color = color.withValues(alpha: 0.6 + 0.4 * amp);
161
+ canvas.drawCircle(
162
+ Offset(width / 2, height / 2),
163
+ 4 + 6 * amp,
164
+ _corePaint,
165
+ );
166
}
167
178
- void _paintProcessing(Canvas canvas, Offset center, double maxRadius) {
179
- final baseColor = isDark
180
- ? const Color(0xFFA78BFA) // Violet
181
- : const Color(0xFF7C3AED);
182
-
183
- // Orbiting dots.
184
- const dotCount = 8;
185
- for (int i = 0; i < dotCount; i++) {
186
- final angle = (orbitValue * 2 * pi) + (i * 2 * pi / dotCount);
187
- final orbitRadius = maxRadius * 0.45;
188
- final dotX = center.dx + cos(angle) * orbitRadius;
189
- final dotY = center.dy + sin(angle) * orbitRadius;
190
-
191
- final trailAlpha = 0.3 + (0.7 * (i / dotCount));
192
- final dotRadius = 4.0 + (3.0 * (i / dotCount));
193
-
194
- final paint = Paint()
195
- ..color = baseColor.withValues(alpha: trailAlpha)
196
- ..maskFilter = MaskFilter.blur(BlurStyle.normal, dotRadius * 0.5);
197
-
198
- canvas.drawCircle(Offset(dotX, dotY), dotRadius, paint);
168
+ void _paintProcessingBars(Canvas canvas, Size size, Color color) {
169
+ const half = _barCount ~/ 2;
170
+ final centerY = size.height / 2;
171
+ final maxBarHeight = size.height * 0.28;
172
+ final minBarHeight = size.height * 0.03;
173
+ final barWidth = (size.width / (_barCount * 2)).clamp(2.0, 4.0);
174
+ final gap = (size.width - barWidth * _barCount * 2) / (_barCount * 2 + 1);
175
+ final baseX = centerY - (half * (barWidth + gap)) + gap / 2;
176
+ final stride = barWidth + gap;
177
+
178
+ _barPaint.strokeWidth = barWidth;
179
+
180
+ for (int i = 0; i < _barCount; i++) {
181
+ final dist = ((i - half).abs() / half).clamp(0.0, 1.0);
182
+ final progress = ((aiValue + dist * 0.6) % 1.0);
183
+ final wave = math.sin(progress * math.pi);
184
+ final h = minBarHeight + (maxBarHeight - minBarHeight) * wave;
185
+
186
+ _barPaint.color = color.withValues(alpha: 0.25 + 0.55 * wave);
187
+ canvas.drawLine(
188
+ Offset(baseX + i * stride, centerY - h / 2),
189
+ Offset(baseX + i * stride, centerY + h / 2),
190
+ _barPaint,
191
+ );
192
}
193
201
- // Second ring going the opposite direction.
202
- for (int i = 0; i < 5; i++) {
203
- final angle = -(orbitValue * 1.5 * 2 * pi) + (i * 2 * pi / 5);
204
- final orbitRadius = maxRadius * 0.65;
205
- final dotX = center.dx + cos(angle) * orbitRadius;
206
- final dotY = center.dy + sin(angle) * orbitRadius;
207
-
208
- final trailAlpha = 0.15 + (0.35 * (i / 5));
209
-
210
- final paint = Paint()
211
- ..color = baseColor.withValues(alpha: trailAlpha)
212
- ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
213
-
214
- canvas.drawCircle(Offset(dotX, dotY), 3, paint);
215
- }
216
-
217
- // Center pulse.
218
- final corePulse = (sin(orbitValue * 2 * pi) + 1) / 2;
219
- final corePaint = Paint()
220
- ..color = baseColor.withValues(alpha: 0.3 + corePulse * 0.2)
221
- ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16);
222
- canvas.drawCircle(center, maxRadius * 0.15, corePaint);
194
+ final breath = 0.5 + 0.5 * math.sin(aiValue * 2 * math.pi);
195
+ _corePaint
196
+ ..color = color.withValues(alpha: 0.5 + 0.4 * breath)
197
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
198
+ canvas.drawCircle(
199
+ Offset(size.width / 2, size.height / 2),
200
+ 6 + 4 * breath,
201
+ _corePaint,
202
+ );
203
+ // Restore the original blur radius for the next caller.
204
+ _corePaint.maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
205
}
206
225
- void _paintSpeaking(Canvas canvas, Offset center, double maxRadius) {
226
- final baseColor = isDark
227
- ? const Color(0xFF34D399) // Emerald
228
- : const Color(0xFF059669);
229
-
230
- // Equalizer-style bars arranged in a circular pattern.
231
- const barCount = 24;
232
- for (int i = 0; i < barCount; i++) {
233
- final angle = (i * 2 * pi / barCount) - pi / 2;
234
- final phaseOffset = sin(waveValue * 2 * pi + i * 0.5);
235
- final barHeight = maxRadius * 0.15 + maxRadius * 0.3 * ((phaseOffset + 1) / 2);
236
-
237
- final innerRadius = maxRadius * 0.3;
238
- final outerRadius = innerRadius + barHeight;
239
-
240
- final startX = center.dx + cos(angle) * innerRadius;
241
- final startY = center.dy + sin(angle) * innerRadius;
242
- final endX = center.dx + cos(angle) * outerRadius;
243
- final endY = center.dy + sin(angle) * outerRadius;
244
-
245
- final barAlpha = 0.3 + 0.6 * ((phaseOffset + 1) / 2);
246
-
247
- final paint = Paint()
248
- ..color = baseColor.withValues(alpha: barAlpha)
249
- ..strokeWidth = 3.5
250
- ..strokeCap = StrokeCap.round;
251
-
252
- canvas.drawLine(Offset(startX, startY), Offset(endX, endY), paint);
253
- }
254
-
255
- // Inner glow.
256
- final corePaint = Paint()
257
- ..color = baseColor.withValues(alpha: 0.4)
258
- ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 14);
259
- canvas.drawCircle(center, maxRadius * 0.2, corePaint);
260
-
261
- final solidCore = Paint()..color = baseColor.withValues(alpha: 0.8);
262
- canvas.drawCircle(center, maxRadius * 0.1, solidCore);
207
+ void _paintHalo(
208
+ Canvas canvas,
209
+ Offset center,
210
+ Size size,
211
+ Color color,
212
+ ) {
213
+ _haloPaint.color = color.withValues(alpha: isDark ? 0.18 : 0.12);
214
+ canvas.drawCircle(center, size.width * 0.32, _haloPaint);
215
}
216
265
- void _paintIdle(Canvas canvas, Offset center, double maxRadius) {
266
- final baseColor = isDark
267
- ? Colors.white.withValues(alpha: 0.3)
268
- : Colors.black.withValues(alpha: 0.15);
269
-
270
- // Gentle breathing circle.
271
- final radius = maxRadius * 0.2 + maxRadius * 0.05 * pulseValue;
272
- final paint = Paint()
273
- ..color = baseColor
274
- ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
275
- canvas.drawCircle(center, radius, paint);
276
-
277
- // Thin outer ring.
278
- final ringPaint = Paint()
279
- ..color = baseColor.withValues(alpha: 0.15 + pulseValue * 0.1)
280
- ..style = PaintingStyle.stroke
281
- ..strokeWidth = 1.5;
282
- canvas.drawCircle(center, maxRadius * 0.5, ringPaint);
217
+ /// Two-sine envelope that mimics natural speech rhythm.
218
+ double _waveNoise(int barIndex, VoiceModePhase phase, double t) {
219
+ final phaseOffset = barIndex * 0.45;
220
+ switch (phase) {
221
+ case VoiceModePhase.listening:
222
+ return 0.5 + 0.5 * math.sin(t * 0.6 + phaseOffset);
223
+ case VoiceModePhase.speaking:
224
+ final a = math.sin(t * 1.3 + phaseOffset);
225
+ final b = math.sin(t * 2.7 + phaseOffset * 1.7);
226
+ return 0.5 + 0.35 * a + 0.25 * b;
227
+ case VoiceModePhase.idle:
228
+ return 0.5 + 0.5 * math.sin(t + phaseOffset);
229
+ case VoiceModePhase.error:
230
+ case VoiceModePhase.processing:
231
+ return 0.5;
232
+ }
233
}
234
285
- void _paintError(Canvas canvas, Offset center, double maxRadius) {
286
- final baseColor = const Color(0xFFEF4444);
287
-
288
- // Pulsing red glow.
289
- final radius = maxRadius * 0.2 + maxRadius * 0.05 * pulseValue;
290
- final paint = Paint()
291
- ..color = baseColor.withValues(alpha: 0.4 + pulseValue * 0.2)
292
- ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
293
- canvas.drawCircle(center, radius, paint);
294
-
295
- final solidCore = Paint()..color = baseColor.withValues(alpha: 0.7);
296
- canvas.drawCircle(center, maxRadius * 0.1, solidCore);
235
+ double _aiEnvelope() {
236
+ final t = aiValue;
237
+ final wave = 0.55 + 0.45 * math.sin(t * 2 * math.pi * 4);
238
+ final breath = 0.4 + 0.6 * math.sin(t * 2 * math.pi);
239
+ return (wave * breath).clamp(0.05, 1.0);
240
}
241
242
@override
300
- bool shouldRepaint(covariant _VoicePainter oldDelegate) {
243
+ bool shouldRepaint(covariant _WaveformPainter oldDelegate) {
244
return phase != oldDelegate.phase ||
302
- pulseValue != oldDelegate.pulseValue ||
303
- orbitValue != oldDelegate.orbitValue ||
304
- waveValue != oldDelegate.waveValue ||
245
+ micLevel != oldDelegate.micLevel ||
246
+ aiValue != oldDelegate.aiValue ||
247
isDark != oldDelegate.isDark;
248
}
307
-}
249
+}
\ No newline at end of file
lib/features/voice_mode/views/voice_mode_overlay.dart
+156
-71
@@ -2,6 +2,7 @@ import 'dart:ui';
2
3
import 'package:flutter/material.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+import 'package:go_router/go_router.dart';
6
import 'package:haptic_feedback/haptic_feedback.dart';
7
import 'package:hugeicons/hugeicons.dart';
8
@@ -10,14 +11,15 @@ import '../../../../core/providers/app_providers.dart';
11
import '../../chat/providers/chat_notifier.dart';
12
import '../../tts/views/tts_model_manager_screen.dart';
13
import '../providers/voice_mode_provider.dart';
14
+import '../voice_mode_palette.dart';
15
import 'components/voice_mode_controls.dart';
16
import 'components/voice_transcript.dart';
17
import 'components/voice_visualizer.dart';
18
19
/// Full-screen voice-to-voice conversation overlay.
20
///
19
-/// Presents an animated visual indicator and transcript text across
20
-/// Listen → Process → Speak phases, looping hands-free when
21
+/// Presents a real-time waveform visualizer and auto-scrolling transcript
22
+/// across Listen → Process → Speak phases, looping hands-free when
23
/// auto-listen is enabled.
24
///
25
/// Usage:
@@ -33,7 +35,7 @@ class VoiceModeOverlay extends ConsumerStatefulWidget {
35
context: context,
36
barrierDismissible: false,
37
barrierColor: Colors.transparent,
36
- transitionDuration: const Duration(milliseconds: 400),
38
+ transitionDuration: const Duration(milliseconds: 420),
39
transitionBuilder: (context, animation, secondaryAnimation, child) {
40
final curve = CurvedAnimation(
41
parent: animation,
@@ -42,7 +44,7 @@ class VoiceModeOverlay extends ConsumerStatefulWidget {
44
return FadeTransition(
45
opacity: curve,
46
child: ScaleTransition(
45
- scale: Tween<double>(begin: 0.92, end: 1.0).animate(curve),
47
+ scale: Tween<double>(begin: 0.94, end: 1.0).animate(curve),
48
child: child,
49
),
50
);
@@ -68,30 +70,24 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
70
}
71
72
Future<void> _endSession() async {
71
- Haptics.vibrate(HapticsType.medium);
73
await ref.read(voiceModeProvider.notifier).endSession();
74
if (mounted) {
74
- Navigator.of(context).pop();
75
+ context.pop();
76
}
77
}
78
79
void _handleCenterAction() {
80
final phase = ref.read(voiceModeProvider).phase;
80
- Haptics.vibrate(HapticsType.light);
81
82
switch (phase) {
83
case VoiceModePhase.listening:
84
- // Send current transcript.
84
ref.read(voiceModeProvider.notifier).stopListeningAndSend();
85
case VoiceModePhase.speaking:
87
- // Interrupt and potentially re-listen.
86
ref.read(voiceModeProvider.notifier).interrupt();
87
case VoiceModePhase.idle:
88
case VoiceModePhase.error:
91
- // Start listening.
89
ref.read(voiceModeProvider.notifier).startListening();
90
case VoiceModePhase.processing:
94
- // Can't interrupt processing — do nothing.
91
break;
92
}
93
}
@@ -120,18 +116,18 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
116
builder: (_) => const TtsModelManagerScreen(),
117
);
118
},
123
- borderRadius: BorderRadius.circular(20),
119
+ borderRadius: BorderRadius.circular(999),
120
child: Container(
125
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
121
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
122
decoration: BoxDecoration(
123
color: isDark
128
- ? Colors.white.withValues(alpha: 0.08)
129
- : Colors.black.withValues(alpha: 0.06),
130
- borderRadius: BorderRadius.circular(20),
124
+ ? Colors.white.withValues(alpha: 0.06)
125
+ : Colors.black.withValues(alpha: 0.05),
126
+ borderRadius: BorderRadius.circular(999),
127
border: Border.all(
128
color: isDark
133
- ? Colors.white.withValues(alpha: 0.12)
134
- : Colors.black.withValues(alpha: 0.1),
129
+ ? Colors.white.withValues(alpha: 0.10)
130
+ : Colors.black.withValues(alpha: 0.08),
131
),
132
),
133
child: Row(
@@ -142,23 +138,24 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
138
size: 13,
139
color: theme.colorScheme.primary,
140
),
145
- const SizedBox(width: 5),
141
+ const SizedBox(width: 6),
142
Text(
143
label,
144
style: TextStyle(
145
fontSize: 12,
146
fontWeight: FontWeight.w600,
147
+ letterSpacing: 0.1,
148
color: isDark
149
? Colors.white.withValues(alpha: 0.85)
150
: Colors.black87,
151
),
152
),
156
- const SizedBox(width: 3),
153
+ const SizedBox(width: 2),
154
Icon(
158
- Icons.chevron_right,
159
- size: 14,
155
+ Icons.chevron_right_rounded,
156
+ size: 16,
157
color: isDark
161
- ? Colors.white.withValues(alpha: 0.4)
158
+ ? Colors.white.withValues(alpha: 0.45)
159
: Colors.black.withValues(alpha: 0.4),
160
),
161
],
@@ -173,9 +170,9 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
170
final theme = Theme.of(context);
171
final isDark = theme.brightness == Brightness.dark;
172
176
- final bgColor = isDark
177
- ? const Color(0xFF0A0A0A)
178
- : const Color(0xFFFAFAFA);
173
+ final bgColor = isDark ? const Color(0xFF0B0B10) : const Color(0xFFF7F7FB);
174
+
175
+ final accent = VoiceModePalette.accentFor(state.phase, isDark: isDark);
176
177
return PopScope(
178
canPop: false,
@@ -184,87 +181,109 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
181
},
182
child: Scaffold(
183
backgroundColor: Colors.transparent,
187
- body: Container(
188
- decoration: BoxDecoration(
189
- color: bgColor.withValues(alpha: 0.92),
190
- ),
191
- child: BackdropFilter(
192
- filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
193
- child: SafeArea(
184
+ body: Stack(
185
+ children: [
186
+ // RepaintBoundary so the gradient + blur aren't re-rasterised
187
+ // when the lower subtree (transcript/visualizer/controls)
188
+ // rebuilds on streamed tokens or mic-level changes.
189
+ Positioned.fill(
190
+ child: RepaintBoundary(
191
+ child: Stack(
192
+ children: [
193
+ AnimatedContainer(
194
+ duration: const Duration(milliseconds: 600),
195
+ decoration: BoxDecoration(
196
+ gradient: RadialGradient(
197
+ center: const Alignment(0, -0.2),
198
+ radius: 1.1,
199
+ colors: [
200
+ accent.withValues(alpha: isDark ? 0.18 : 0.22),
201
+ bgColor,
202
+ ],
203
+ stops: const [0.0, 0.85],
204
+ ),
205
+ ),
206
+ ),
207
+ Positioned.fill(
208
+ child: BackdropFilter(
209
+ filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40),
210
+ child: Container(
211
+ color: bgColor.withValues(alpha: 0.55),
212
+ ),
213
+ ),
214
+ ),
215
+ ],
216
+ ),
217
+ ),
218
+ ),
219
+
220
+ SafeArea(
221
child: Column(
222
children: [
196
- // Top bar — title, TTS chip, close button.
223
Padding(
198
- padding: const EdgeInsets.symmetric(
199
- horizontal: 16,
200
- vertical: 8,
201
- ),
224
+ padding: const EdgeInsets.fromLTRB(20, 12, 12, 8),
225
child: Row(
226
children: [
204
- // Title.
227
+ Container(
228
+ width: 8,
229
+ height: 8,
230
+ decoration: BoxDecoration(
231
+ shape: BoxShape.circle,
232
+ color: accent,
233
+ boxShadow: [
234
+ BoxShadow(
235
+ color: accent.withValues(alpha: 0.6),
236
+ blurRadius: 8,
237
+ spreadRadius: 1,
238
+ ),
239
+ ],
240
+ ),
241
+ ),
242
+ const SizedBox(width: 10),
243
Text(
244
'Voice Mode',
245
style: TextStyle(
208
- fontSize: 16,
246
+ fontSize: 15,
247
fontWeight: FontWeight.w600,
248
+ letterSpacing: 0.2,
249
color: isDark
211
- ? Colors.white.withValues(alpha: 0.7)
212
- : Colors.black.withValues(alpha: 0.6),
250
+ ? Colors.white.withValues(alpha: 0.85)
251
+ : Colors.black.withValues(alpha: 0.8),
252
),
253
),
254
const Spacer(),
216
- // TTS Model Chip.
255
_buildTtsChip(context, theme, isDark),
218
- const SizedBox(width: 8),
219
- // Close button.
220
- IconButton(
221
- onPressed: _endSession,
222
- icon: Icon(
223
- Icons.close,
224
- color: isDark
225
- ? Colors.white.withValues(alpha: 0.6)
226
- : Colors.black.withValues(alpha: 0.5),
227
- ),
228
- ),
256
+ const SizedBox(width: 4),
257
+ _CloseButton(isDark: isDark, onPressed: _endSession),
258
],
259
),
260
),
261
233
- // Expanding space.
262
const Spacer(flex: 2),
263
236
- // Animated visualizer.
264
GestureDetector(
265
onTap: _handleCenterAction,
266
child: VoiceVisualizer(
267
phase: state.phase,
241
- size: 220,
268
+ micLevel: state.micLevel,
269
+ size: 240,
270
),
271
),
272
245
- const SizedBox(height: 32),
273
+ const SizedBox(height: 28),
274
247
- // Transcript / status text.
275
Padding(
276
padding: const EdgeInsets.symmetric(horizontal: 32),
250
- child: VoiceTranscript(
277
+ child: _StreamingTranscript(
278
phase: state.phase,
279
transcript: state.transcript,
253
- response: state.phase == VoiceModePhase.processing
254
- ? (ref.watch(
255
- chatProvider.select(
256
- (s) => s.streamingMessage?.content,
257
- ),
258
- ) ??
259
- state.response)
260
- : state.response,
280
+ response: state.response,
281
error: state.error,
282
),
283
),
284
285
const Spacer(flex: 3),
286
267
- // Bottom controls.
287
VoiceModeControls(
288
phase: state.phase,
289
autoListen: state.autoListen,
@@ -274,16 +293,82 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
293
ref.read(voiceModeProvider.notifier).toggleMute();
294
},
295
onToggleAutoListen: () {
277
- Haptics.vibrate(HapticsType.light);
296
ref.read(voiceModeProvider.notifier).toggleAutoListen();
297
},
298
onTapCenter: _handleCenterAction,
299
),
300
283
- const SizedBox(height: 16),
301
+ const SizedBox(height: 18),
302
],
303
),
304
),
305
+ ],
306
+ ),
307
+ ),
308
+ );
309
+ }
310
+}
311
+
312
+/// Subscribes to the chat streaming message and feeds its content into
313
+/// [VoiceTranscript] during the processing phase. Lives in its own
314
+/// Consumer so streamed tokens only rebuild this subtree, not the
315
+/// gradient/BackdropFilter above it.
316
+class _StreamingTranscript extends ConsumerWidget {
317
+ final VoiceModePhase phase;
318
+ final String transcript;
319
+ final String response;
320
+ final String? error;
321
+
322
+ const _StreamingTranscript({
323
+ required this.phase,
324
+ required this.transcript,
325
+ required this.response,
326
+ required this.error,
327
+ });
328
+
329
+ @override
330
+ Widget build(BuildContext context, WidgetRef ref) {
331
+ final streamed = phase == VoiceModePhase.processing
332
+ ? (ref.watch(chatProvider.select((s) => s.streamingMessage?.content)) ??
333
+ response)
334
+ : response;
335
+ return VoiceTranscript(
336
+ phase: phase,
337
+ transcript: transcript,
338
+ response: streamed,
339
+ error: error,
340
+ );
341
+ }
342
+}
343
+
344
+class _CloseButton extends StatelessWidget {
345
+ final bool isDark;
346
+ final VoidCallback onPressed;
347
+
348
+ const _CloseButton({required this.isDark, required this.onPressed});
349
+
350
+ @override
351
+ Widget build(BuildContext context) {
352
+ return Material(
353
+ color: Colors.transparent,
354
+ child: InkWell(
355
+ customBorder: const CircleBorder(),
356
+ onTap: onPressed,
357
+ child: Container(
358
+ width: 36,
359
+ height: 36,
360
+ decoration: BoxDecoration(
361
+ shape: BoxShape.circle,
362
+ color: isDark
363
+ ? Colors.white.withValues(alpha: 0.06)
364
+ : Colors.black.withValues(alpha: 0.05),
365
+ ),
366
+ child: Icon(
367
+ Icons.close_rounded,
368
+ size: 18,
369
+ color: isDark
370
+ ? Colors.white.withValues(alpha: 0.7)
371
+ : Colors.black.withValues(alpha: 0.55),
372
),
373
),
374
),
lib/features/voice_mode/voice_mode_palette.dart
new
+33
@@ -0,0 +1,33 @@
1
+import 'package:flutter/material.dart';
2
+
3
+import 'providers/voice_mode_provider.dart';
4
+
5
+/// Accent color palette used across the voice-mode UI (overlay header,
6
+/// waveform visualizer, transcript badge, control buttons).
7
+///
8
+/// Centralised so the same phase always renders with the same colour
9
+/// regardless of which surface paints it.
10
+class VoiceModePalette {
11
+ const VoiceModePalette._();
12
+
13
+ static Color accentFor(VoiceModePhase phase, {required bool isDark}) {
14
+ switch (phase) {
15
+ case VoiceModePhase.listening:
16
+ return isDark ? const Color(0xFF34D399) : const Color(0xFF059669);
17
+ case VoiceModePhase.processing:
18
+ return isDark ? const Color(0xFFA78BFA) : const Color(0xFF7C3AED);
19
+ case VoiceModePhase.speaking:
20
+ return isDark ? const Color(0xFF818CF8) : const Color(0xFF4F46E5);
21
+ case VoiceModePhase.idle:
22
+ return isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B);
23
+ case VoiceModePhase.error:
24
+ return const Color(0xFFEF4444);
25
+ }
26
+ }
27
+
28
+ /// Subtle background used behind the centred phase icon / pill.
29
+ static Color subtleBg(BuildContext context, {double opacity = 0.08}) {
30
+ final isDark = Theme.of(context).brightness == Brightness.dark;
31
+ return (isDark ? Colors.white : Colors.black).withValues(alpha: opacity);
32
+ }
33
+}
lib/services/voice_feedback_service.dart
new
+113
@@ -0,0 +1,113 @@
1
+import 'dart:async';
2
+
3
+import 'package:audioplayers/audioplayers.dart';
4
+import 'package:flutter/services.dart';
5
+import 'package:flutter_riverpod/flutter_riverpod.dart';
6
+
7
+/// Sound + haptic feedback for the voice-to-voice overlay lifecycle.
8
+///
9
+/// Holds four preloaded [AudioPlayer]s (one per cue) so playback is
10
+/// zero-latency at the moment of a phase transition. Each public method
11
+/// plays its cue and fires the matching haptic in one call.
12
+///
13
+/// All playback errors are swallowed — feedback is best-effort and must
14
+/// never break the voice pipeline.
15
+class VoiceFeedbackService {
16
+ final AudioPlayer _connectedPlayer = AudioPlayer();
17
+ final AudioPlayer _disconnectedPlayer = AudioPlayer();
18
+ final AudioPlayer _listeningPlayer = AudioPlayer();
19
+ final AudioPlayer _generatingPlayer = AudioPlayer();
20
+
21
+ bool _disposed = false;
22
+
23
+ /// Preload every cue into memory so the first call has no asset-load
24
+ /// latency. Safe to call multiple times — audioplayers reuses the
25
+ /// existing source on subsequent `setSourceAsset` calls.
26
+ Future<void> preload() async {
27
+ await Future.wait([
28
+ _load(_connectedPlayer, 'sounds/connected.mp3'),
29
+ _load(_disconnectedPlayer, 'sounds/disconnected.mp3'),
30
+ _load(_listeningPlayer, 'sounds/start_listening.mp3'),
31
+ _load(_generatingPlayer, 'sounds/generating.mp3'),
32
+ ]);
33
+ }
34
+
35
+ Future<void> _load(AudioPlayer player, String assetPath) async {
36
+ if (_disposed) return;
37
+ try {
38
+ await player.setReleaseMode(ReleaseMode.stop);
39
+ await player.setSource(AssetSource(assetPath));
40
+ } catch (_) {
41
+ // Swallow — see class doc.
42
+ }
43
+ }
44
+
45
+ Future<void> _play(AudioPlayer player) async {
46
+ if (_disposed) return;
47
+ try {
48
+ await player.seek(Duration.zero);
49
+ await player.resume();
50
+ } catch (_) {
51
+ // Swallow — see class doc.
52
+ }
53
+ }
54
+
55
+ /// Session opened, mic is live. Confident, affirming.
56
+ Future<void> playConnected() async {
57
+ await _play(_connectedPlayer);
58
+ await _safeHaptic(HapticFeedback.mediumImpact);
59
+ }
60
+
61
+ /// Session closed gracefully. Soft, not alarming.
62
+ Future<void> playDisconnected() async {
63
+ await _play(_disconnectedPlayer);
64
+ await _safeHaptic(HapticFeedback.lightImpact);
65
+ }
66
+
67
+ /// Mic activated, awaiting user speech. Crisp micro-click.
68
+ Future<void> playListening() async {
69
+ await _play(_listeningPlayer);
70
+ await _safeHaptic(HapticFeedback.selectionClick);
71
+ }
72
+
73
+ /// LLM is streaming. Single tap on first chunk only — repeated
74
+ /// haptics during streaming feel glitchy.
75
+ Future<void> playGenerating() async {
76
+ await _play(_generatingPlayer);
77
+ await _safeHaptic(HapticFeedback.lightImpact);
78
+ }
79
+
80
+ Future<void> _safeHaptic(Future<void> Function() haptic) async {
81
+ if (_disposed) return;
82
+ try {
83
+ await haptic();
84
+ } catch (_) {
85
+ // Swallow — haptics unsupported on some devices / emulators.
86
+ }
87
+ }
88
+
89
+ /// Release every player. Called by [voiceFeedbackProvider]'s
90
+ /// `ref.onDispose`.
91
+ Future<void> dispose() async {
92
+ if (_disposed) return;
93
+ _disposed = true;
94
+ await Future.wait([
95
+ _connectedPlayer.dispose(),
96
+ _disconnectedPlayer.dispose(),
97
+ _listeningPlayer.dispose(),
98
+ _generatingPlayer.dispose(),
99
+ ]);
100
+ }
101
+}
102
+
103
+/// Singleton feedback service. Not autoDispose — preloaded sounds stay
104
+/// warm for the whole app lifetime so the first phase transition has no
105
+/// asset-load delay.
106
+final voiceFeedbackProvider = Provider<VoiceFeedbackService>((ref) {
107
+ final service = VoiceFeedbackService();
108
+ // Fire-and-forget: callers don't block on preload.
109
+ // ignore: discarded_futures
110
+ service.preload();
111
+ ref.onDispose(service.dispose);
112
+ return service;
113
+});
\ No newline at end of file
pubspec.yaml
+1
@@ -117,6 +117,7 @@ flutter:
117
assets:
118
- assets/google_fonts/
119
- assets/images/
120
+ - assets/sounds/
121
# An image asset can refer to one or more resolution-specific "variants", see
122
# https://flutter.dev/to/resolution-aware-images
123