17 files changed
+1691
-43
.gitignore
+2
@@ -54,3 +54,5 @@ skills-lock.json
54
notes.txt
55
docs/raw_ss/
56
docs/marketing/
57
+.puku-cli/
58
+.puku/
\ No newline at end of file
lib/core/providers/app_providers.dart
+2
@@ -99,6 +99,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
99
_update(state.copyWith(kittenTtsModelVariant: value));
100
void setAutoSpeakEnabled(bool value) =>
101
_update(state.copyWith(autoSpeakEnabled: value));
102
+ void setConciseVoiceResponsesEnabled(bool value) =>
103
+ _update(state.copyWith(conciseVoiceResponsesEnabled: value));
104
void setTtsProcessMarkdown(bool value) =>
105
_update(state.copyWith(ttsProcessMarkdown: value));
106
void setTtsSkipSeconds(int value) =>
lib/core/services/data_backup_service.dart
+1
@@ -40,6 +40,7 @@ class DataBackupService {
40
'ttsSpeed',
41
'kittenTtsModelVariant',
42
'autoSpeakEnabled',
43
+ 'conciseVoiceResponsesEnabled',
44
'ttsProcessMarkdown',
45
'ttsSkipSeconds',
46
'smartReplyEnabled',
lib/features/chat/providers/chat_notifier.dart
+50
-3
@@ -39,6 +39,9 @@ import 'message_selection_provider.dart';
39
import 'model_selection_providers.dart';
40
import 'tooling_providers.dart';
41
import '../utils/message_variants.dart';
42
+import '../../tts/providers/tts_providers.dart';
43
+import '../../stt/providers/stt_providers.dart' as stt;
44
+import '../../voice_mode/providers/voice_mode_provider.dart';
45
46
class PendingToolApproval {
47
final ParsedToolCall toolCall;
@@ -740,6 +743,17 @@ class ChatNotifier extends Notifier<ChatState> {
743
return;
744
}
745
746
+ await _abortStreamImmediately();
747
+
748
+ // Stop the STT listener so the mic doesn't stay red while the model
749
+ // thinks. The chat input bar's `ref.listen` will re-start it once TTS
750
+ // (if any) finishes.
751
+ try {
752
+ ref.read(stt.sttProvider.notifier).stopListening();
753
+ } catch (_) {
754
+ // STT may not be initialised — non-fatal.
755
+ }
756
+
757
final trimmedContent = content.trim();
758
759
final titleSource = trimmedContent.isNotEmpty
@@ -844,9 +858,6 @@ class ChatNotifier extends Notifier<ChatState> {
858
859
ref.read(chatBackgroundServiceProvider).start();
860
847
- await _abortStreamImmediately();
848
- ref.read(isStreamingProvider.notifier).setStreaming(true);
849
-
861
_resetStreamMetrics();
862
_resetCheckpointMetrics();
863
_updateSavedMetrics(assistantMessage);
@@ -1154,6 +1165,18 @@ class ChatNotifier extends Notifier<ChatState> {
1165
_lastCheckpointTime = null;
1166
_lastSavedContentLength = 0;
1167
_lastSavedReasoningLength = 0;
1168
+
1169
+ // Auto-speak: read the response aloud if the setting
1170
+ // is enabled and voice mode is not already handling TTS.
1171
+ final autoSpeak = ref.read(settingsProvider).autoSpeakEnabled;
1172
+ final voiceActive = ref.read(voiceModeProvider).phase != VoiceModePhase.idle;
1173
+ if (autoSpeak && !voiceActive && finalMessage.content.trim().isNotEmpty) {
1174
+ ref.read(ttsProvider.notifier).speak(
1175
+ finalMessage.content,
1176
+ messageId: finalMessage.id,
1177
+ conversationId: finalMessage.conversationId,
1178
+ );
1179
+ }
1180
}
1181
},
1182
onError: (error) async {
@@ -1316,6 +1339,15 @@ class ChatNotifier extends Notifier<ChatState> {
1339
? 'You are LocalMind, a helpful AI assistant. Provide clear, accurate, and concise responses.'
1340
: null);
1341
1342
+ final isVoiceActive = ref.read(voiceModeProvider).isActive;
1343
+ if (isVoiceActive && settings.conciseVoiceResponsesEnabled) {
1344
+ const conciseInstruction =
1345
+ 'Respond concisely in a single short paragraph and end with a relevant follow-up question to keep the spoken conversation engaging.';
1346
+ systemContent = (systemContent == null || systemContent.trim().isEmpty)
1347
+ ? conciseInstruction
1348
+ : '$systemContent\n$conciseInstruction';
1349
+ }
1350
+
1351
if (shouldDisableThinking) {
1352
// Hybrid reasoning models (Qwen3 and similar) key off this literal
1353
// token in the prompt to skip their <think> block — send it whenever
@@ -2033,6 +2065,21 @@ class ChatNotifier extends Notifier<ChatState> {
2065
server: server,
2066
selectedModel: selectedModel,
2067
);
2068
+
2069
+ // Auto-speak: read the response aloud if the setting is
2070
+ // enabled and voice mode is not already handling TTS.
2071
+ final autoSpeak = ref.read(settingsProvider).autoSpeakEnabled;
2072
+ final voiceActive = ref.read(voiceModeProvider).phase !=
2073
+ VoiceModePhase.idle;
2074
+ if (autoSpeak &&
2075
+ !voiceActive &&
2076
+ finalMessage.content.trim().isNotEmpty) {
2077
+ ref.read(ttsProvider.notifier).speak(
2078
+ finalMessage.content,
2079
+ messageId: finalMessage.id,
2080
+ conversationId: finalMessage.conversationId,
2081
+ );
2082
+ }
2083
},
2084
onError: (Object error, StackTrace stackTrace) async {
2085
Log.error('Stream error: $error');
lib/features/chat/providers/smart_reply_providers.dart
+6
-2
@@ -6,6 +6,7 @@ import 'package:localmind/core/providers/app_providers.dart';
6
import 'package:localmind/features/conversations/providers/conversation_providers.dart'
7
as conv;
8
import 'package:localmind/features/servers/providers/server_providers.dart';
9
+import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart';
10
import 'chat_notifier.dart';
11
import 'chat_params_providers.dart';
12
import 'chat_service_providers.dart';
@@ -14,8 +15,10 @@ import 'model_selection_providers.dart';
15
final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
16
final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
17
final settings = ref.watch(settingsProvider);
18
+ final voiceState = ref.watch(voiceModeProvider);
19
20
if (isStreaming) return [];
21
+ if (voiceState.isActive || voiceState.phase != VoiceModePhase.idle) return [];
22
23
final messages = ref.watch(chatProvider.select((s) => s.messages));
24
if (messages.isEmpty) return [];
@@ -50,8 +53,9 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
53
modelId: selectedModel.id,
54
messages: messages,
55
params: chatParams,
53
- personaSystemPrompt:
54
- settings.smartRepliesUsePersona ? chatParams.systemPrompt : null,
56
+ personaSystemPrompt: settings.smartRepliesUsePersona
57
+ ? chatParams.systemPrompt
58
+ : null,
59
);
60
if (!ref.mounted) return [];
61
}
lib/features/chat/views/components/chat_input_bar.dart
+121
-27
@@ -16,6 +16,8 @@ import '../../../../l10n/app_localizations.dart';
16
import '../../../saved_messages/views/components/saved_message_picker_sheet.dart';
17
import '../../../servers/providers/server_providers.dart';
18
import '../../../stt/providers/stt_providers.dart';
19
+import '../../../tts/providers/tts_providers.dart';
20
+import '../../../voice_mode/providers/voice_mode_provider.dart';
21
import '../../providers/chat_providers.dart';
22
import '../../utils/attachment_helpers.dart';
23
import '../../utils/image_upload_utils.dart';
@@ -23,6 +25,8 @@ import '../../../models/views/model_picker_sheet.dart';
25
import 'attach_sheet.dart';
26
import 'image_preview_dialog.dart';
27
import 'model_chip.dart';
28
+import '../../../on_device/providers/on_device_providers.dart';
29
+import '../../../voice_mode/views/voice_mode_overlay.dart';
30
31
class ChatInputBar extends ConsumerStatefulWidget {
32
const ChatInputBar({
@@ -310,24 +314,23 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
314
} else {
315
_preSpeechText = _controller.text;
316
313
- await stt.startListening(
314
- onResult: (words) {
315
- if (words.isNotEmpty && mounted) {
316
- setState(() {
317
- if (_preSpeechText.isEmpty) {
318
- _controller.text = words;
319
- } else {
320
- _controller.text = '$_preSpeechText $words';
321
- }
317
+ await stt.startListening(onResult: _onSpeechResult);
318
+ }
319
+ }
320
+
321
+ void _onSpeechResult(String words) {
322
+ if (words.isEmpty || !mounted) return;
323
+ setState(() {
324
+ if (_preSpeechText.isEmpty) {
325
+ _controller.text = words;
326
+ } else {
327
+ _controller.text = '$_preSpeechText $words';
328
+ }
329
323
- _controller.selection = TextSelection.fromPosition(
324
- TextPosition(offset: _controller.text.length),
325
- );
326
- });
327
- }
328
- },
330
+ _controller.selection = TextSelection.fromPosition(
331
+ TextPosition(offset: _controller.text.length),
332
);
330
- }
333
+ });
334
}
335
336
Widget _buildMicButton(bool isListening, ThemeData theme) {
@@ -393,6 +396,58 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
396
);
397
}
398
399
+ Widget _buildVoiceModeButton(ThemeData theme) {
400
+ final isDark = theme.brightness == Brightness.dark;
401
+ final onDeviceLoaded = ref.watch(
402
+ onDeviceEngineProvider.select(
403
+ (s) => s.status == OnDeviceEngineStatus.loaded,
404
+ ),
405
+ );
406
+ final connectionStatus = ref.watch(connectionStatusProvider);
407
+ final isConnected = connectionStatus == ConnectionStatus.connected;
408
+ final hasRemoteModel =
409
+ isConnected && ref.watch(selectedModelProvider) != null;
410
+ final modelReady = onDeviceLoaded || hasRemoteModel;
411
+ final l10n = AppLocalizations.of(context)!;
412
+
413
+ return Container(
414
+ width: 36,
415
+ height: 36,
416
+ decoration: BoxDecoration(
417
+ color: isDark
418
+ ? const Color(0xFF6366F1).withValues(alpha: 0.15)
419
+ : const Color(0xFF4F46E5).withValues(alpha: 0.1),
420
+ shape: BoxShape.circle,
421
+ ),
422
+ child: IconButton(
423
+ padding: EdgeInsets.zero,
424
+ icon: HugeIcon(
425
+ icon: HugeIcons.strokeRoundedAudioWave01,
426
+ size: 20,
427
+ color: isDark ? const Color(0xFF6366F1) : const Color(0xFF4F46E5),
428
+ ),
429
+ onPressed: !modelReady
430
+ ? () {
431
+ Haptics.vibrate(HapticsType.light);
432
+ ScaffoldMessenger.of(context).showSnackBar(
433
+ SnackBar(content: Text(l10n.model_required_toast)),
434
+ );
435
+ showModalBottomSheet(
436
+ context: context,
437
+ isScrollControlled: true,
438
+ useSafeArea: true,
439
+ builder: (_) => const ModelPickerSheet(),
440
+ );
441
+ }
442
+ : () {
443
+ Haptics.vibrate(HapticsType.light);
444
+ VoiceModeOverlay.show(context);
445
+ },
446
+ tooltip: modelReady ? 'Voice mode' : l10n.model_required_toast,
447
+ ),
448
+ );
449
+ }
450
+
451
Widget _buildAddButton(bool isConnected, ThemeData theme) {
452
final l10n = AppLocalizations.of(context)!;
453
final isDark = theme.brightness == Brightness.dark;
@@ -475,9 +530,9 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
530
bool _ensureModelSelected() {
531
if (ref.read(selectedModelProvider) != null) return true;
532
final l10n = AppLocalizations.of(context)!;
478
- ScaffoldMessenger.of(context).showSnackBar(
479
- SnackBar(content: Text(l10n.model_required_toast)),
480
- );
533
+ ScaffoldMessenger.of(
534
+ context,
535
+ ).showSnackBar(SnackBar(content: Text(l10n.model_required_toast)));
536
showModalBottomSheet(
537
context: context,
538
isScrollControlled: true,
@@ -520,8 +575,8 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
575
setState(() => _sendAsAssistant = !_sendAsAssistant);
576
}
577
: null,
523
- icon: HugeIcon(icon:
524
- HugeIcons.strokeRoundedExchange01,
578
+ icon: HugeIcon(
579
+ icon: HugeIcons.strokeRoundedExchange01,
580
size: 20,
581
color: _sendAsAssistant
582
? theme.colorScheme.primary
@@ -582,8 +637,10 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
637
top: 6,
638
bottom: 6,
639
),
585
- child: HugeIcon(icon:
586
- enabled ? HugeIcons.strokeRoundedTick01 : HugeIcons.strokeRoundedSquare01,
640
+ child: HugeIcon(
641
+ icon: enabled
642
+ ? HugeIcons.strokeRoundedTick01
643
+ : HugeIcons.strokeRoundedSquare01,
644
size: 18,
645
color: fgColor,
646
),
@@ -861,6 +918,11 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
918
919
ref.listen<String?>(sttProvider.select((s) => s.error), (previous, next) {
920
if (next != null) {
921
+ final voiceState = ref.read(voiceModeProvider);
922
+ final voiceActive =
923
+ voiceState.isActive || voiceState.phase != VoiceModePhase.idle;
924
+ if (voiceActive) return;
925
+
926
final message = _mapSttError(next);
927
928
if (message != null) {
@@ -871,6 +933,34 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
933
}
934
});
935
936
+ // Voice-to-voice: when the LLM stops producing output and any auto-TTS
937
+ // playback has finished (or was never scheduled), restart the mic so the
938
+ // user can speak again without pressing the mic button.
939
+ ref.listen<bool>(isStreamingProvider, (previous, next) async {
940
+ if (previous == true && next == false && mounted) {
941
+ if (!ref.read(settingsProvider).autoSpeakEnabled) return;
942
+ final voiceState = ref.read(voiceModeProvider);
943
+ final voiceActive =
944
+ voiceState.isActive || voiceState.phase != VoiceModePhase.idle;
945
+ if (voiceActive) return;
946
+ final ttsSpeaking = ref.read(ttsProvider).isSpeaking;
947
+ final sttActive = ref.read(sttProvider).isListening;
948
+ if (ttsSpeaking || sttActive) return;
949
+
950
+ // Small delay so the OS finishes releasing the audio session before
951
+ // we ask for the microphone again.
952
+ await Future.delayed(const Duration(milliseconds: 200));
953
+ if (!mounted) return;
954
+ if (ref.read(sttProvider).isListening) return;
955
+ if (ref.read(isStreamingProvider)) return;
956
+
957
+ _preSpeechText = _controller.text;
958
+ await ref.read(sttProvider.notifier).startListening(
959
+ onResult: _onSpeechResult,
960
+ );
961
+ }
962
+ });
963
+
964
final canSend =
965
widget.enabled &&
966
isConnected &&
@@ -966,8 +1056,8 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1056
.colorScheme
1057
.surfaceContainerHighest,
1058
969
- child: HugeIcon(icon:
970
- HugeIcons.strokeRoundedFile01,
1059
+ child: HugeIcon(
1060
+ icon: HugeIcons.strokeRoundedFile01,
1061
1062
size: 22,
1063
@@ -998,8 +1088,8 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1088
shape: BoxShape.circle,
1089
),
1090
1001
- child: const HugeIcon(icon:
1002
- HugeIcons.strokeRoundedCancel01,
1091
+ child: const HugeIcon(
1092
+ icon: HugeIcons.strokeRoundedCancel01,
1093
1094
size: 10,
1095
@@ -1173,6 +1263,10 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1263
1264
_buildMicButton(isListening, theme),
1265
1266
+ const SizedBox(width: 4),
1267
+
1268
+ _buildVoiceModeButton(theme),
1269
+
1270
const SizedBox(width: 8),
1271
1272
_buildActionButton(canSend, theme),
lib/features/cloud_sync/data/models/cloud_sync_models.dart
+1
@@ -229,6 +229,7 @@ class CloudSyncSnapshot {
229
'ttsSpeed',
230
'kittenTtsModelVariant',
231
'autoSpeakEnabled',
232
+ 'conciseVoiceResponsesEnabled',
233
'ttsProcessMarkdown',
234
'ttsSkipSeconds',
235
'smartReplyEnabled',
lib/features/settings/data/models/app_settings.dart
+8
@@ -38,6 +38,7 @@ class AppSettings {
38
final double ttsSpeed;
39
final KittenTtsModelVariant kittenTtsModelVariant;
40
final bool autoSpeakEnabled;
41
+ final bool conciseVoiceResponsesEnabled;
42
final bool ttsProcessMarkdown;
43
final int ttsSkipSeconds;
44
final bool smartReplyEnabled;
@@ -81,6 +82,7 @@ class AppSettings {
82
this.ttsSpeed = 1.0,
83
this.kittenTtsModelVariant = KittenTtsModelVariant.nanoInt8,
84
this.autoSpeakEnabled = false,
85
+ this.conciseVoiceResponsesEnabled = true,
86
this.ttsProcessMarkdown = true,
87
this.ttsSkipSeconds = 10,
88
this.smartReplyEnabled = true,
@@ -125,6 +127,7 @@ class AppSettings {
127
double? ttsSpeed,
128
KittenTtsModelVariant? kittenTtsModelVariant,
129
bool? autoSpeakEnabled,
130
+ bool? conciseVoiceResponsesEnabled,
131
bool? ttsProcessMarkdown,
132
int? ttsSkipSeconds,
133
bool? smartReplyEnabled,
@@ -174,6 +177,8 @@ class AppSettings {
177
kittenTtsModelVariant:
178
kittenTtsModelVariant ?? this.kittenTtsModelVariant,
179
autoSpeakEnabled: autoSpeakEnabled ?? this.autoSpeakEnabled,
180
+ conciseVoiceResponsesEnabled:
181
+ conciseVoiceResponsesEnabled ?? this.conciseVoiceResponsesEnabled,
182
ttsProcessMarkdown: ttsProcessMarkdown ?? this.ttsProcessMarkdown,
183
ttsSkipSeconds: ttsSkipSeconds ?? this.ttsSkipSeconds,
184
smartReplyEnabled: smartReplyEnabled ?? this.smartReplyEnabled,
@@ -232,6 +237,7 @@ class AppSettings {
237
'ttsSpeed': ttsSpeed,
238
'kittenTtsModelVariant': kittenTtsModelVariant.name,
239
'autoSpeakEnabled': autoSpeakEnabled,
240
+ 'conciseVoiceResponsesEnabled': conciseVoiceResponsesEnabled,
241
'ttsProcessMarkdown': ttsProcessMarkdown,
242
'ttsSkipSeconds': ttsSkipSeconds,
243
'smartReplyEnabled': smartReplyEnabled,
@@ -278,6 +284,8 @@ class AppSettings {
284
ttsSpeed: map['ttsSpeed']?.toDouble() ?? 1.0,
285
kittenTtsModelVariant: _parseKittenVariant(map['kittenTtsModelVariant']),
286
autoSpeakEnabled: map['autoSpeakEnabled'] ?? false,
287
+ conciseVoiceResponsesEnabled:
288
+ map['conciseVoiceResponsesEnabled'] ?? true,
289
ttsProcessMarkdown: map['ttsProcessMarkdown'] ?? true,
290
ttsSkipSeconds: _parseTtsSkipSeconds(map['ttsSkipSeconds']),
291
smartReplyEnabled: map['smartReplyEnabled'] ?? true,
lib/features/settings/views/settings_screen.dart
+9
@@ -175,6 +175,15 @@ class SettingsViews extends ConsumerWidget {
175
.read(settingsProvider.notifier)
176
.setTtsProcessMarkdown(value),
177
),
178
+ _ToggleSetting(
179
+ label: 'Concise Voice Mode Responses',
180
+ description:
181
+ 'Keep LLM responses brief (1 short paragraph) and ask follow-up questions in voice mode.',
182
+ value: settings.conciseVoiceResponsesEnabled,
183
+ onChanged: (value) => ref
184
+ .read(settingsProvider.notifier)
185
+ .setConciseVoiceResponsesEnabled(value),
186
+ ),
187
_TtsSkipSecondsSetting(
188
value: settings.ttsSkipSeconds,
189
onChanged: (value) => ref
lib/features/stt/providers/stt_providers.dart
+34
-4
@@ -35,6 +35,12 @@ class SttState {
35
class SttNotifier extends Notifier<SttState> {
36
late SpeechToText _speech;
37
bool _isInit = false;
38
+ // Monotonically increasing counter; status callbacks from a previous
39
+ // session cannot clobber state set by a newer one.
40
+ int _session = 0;
41
+ // Snapshot of the most recently issued session id. Set to -1 when no
42
+ // listen() is in flight.
43
+ int _activeSession = -1;
44
45
@override
46
SttState build() {
@@ -59,6 +65,10 @@ class SttNotifier extends Notifier<SttState> {
65
},
66
onStatus: (val) {
67
Log.debug('STT status: $val');
68
+ // Only honour status updates from the active session; stale
69
+ // callbacks from a previous listen() (or callbacks fired while
70
+ // we've already stopped the current one) must not clobber state.
71
+ if (_activeSession != _session) return;
72
if (val == 'listening') {
73
state = state.copyWith(isListening: true);
74
} else if (val == 'notListening' || val == 'done') {
@@ -76,20 +86,34 @@ class SttNotifier extends Notifier<SttState> {
86
}
87
}
88
79
- Future<void> startListening({required void Function(String) onResult}) async {
89
+ Future<void> startListening({
90
+ required void Function(String) onResult,
91
+ void Function(String)? onFinal,
92
+ }) async {
93
final available = await initSpeech();
94
if (!available) {
82
- state = state.copyWith(error: 'Speech recognition not available or permission denied');
95
+ state = state.copyWith(
96
+ error: 'Speech recognition not available or permission denied',
97
+ );
98
return;
99
}
100
86
- state = state.copyWith(isListening: true, recognizedWords: '', clearError: true);
87
-
101
+ state = state.copyWith(
102
+ isListening: true,
103
+ recognizedWords: '',
104
+ clearError: true,
105
+ );
106
+ _session++;
107
+ _activeSession = _session;
108
+
109
try {
110
await _speech.listen(
111
onResult: (result) {
112
state = state.copyWith(recognizedWords: result.recognizedWords);
113
onResult(result.recognizedWords);
114
+ if (result.finalResult && onFinal != null) {
115
+ onFinal(result.recognizedWords);
116
+ }
117
},
118
listenOptions: SpeechListenOptions(
119
listenFor: const Duration(seconds: 30),
@@ -105,6 +129,10 @@ class SttNotifier extends Notifier<SttState> {
129
}
130
131
Future<void> stopListening() async {
132
+ // Invalidate the current session so any in-flight status callbacks
133
+ // are ignored.
134
+ _session++;
135
+ _activeSession = -1;
136
try {
137
await _speech.stop();
138
state = state.copyWith(isListening: false);
@@ -114,6 +142,8 @@ class SttNotifier extends Notifier<SttState> {
142
}
143
144
Future<void> cancelListening() async {
145
+ _session++;
146
+ _activeSession = -1;
147
try {
148
await _speech.cancel();
149
state = state.copyWith(isListening: false);
lib/features/tts/providers/tts_providers.dart
+36
-6
@@ -130,6 +130,7 @@ class TtsNotifier extends Notifier<TtsState> {
130
final Map<int, AudioSource> _playlistBuffer = {};
131
int _nextPlaylistIndexToAdd = 0;
132
StreamSubscription<int?>? _currentIndexSubscription;
133
+ StreamSubscription<PlaybackEvent>? _playerEventSubscription;
134
StreamSubscription<Duration>? _positionSubscription;
135
StreamSubscription<Duration?>? _durationSubscription;
136
Timer? _systemProgressTimer;
@@ -177,6 +178,16 @@ class TtsNotifier extends Notifier<TtsState> {
178
_onPlaybackFinished();
179
});
180
181
+ _playerEventSubscription = _player.playbackEventStream.listen(
182
+ (_) {},
183
+ onError: (Object error) {
184
+ Log.warning('TTS player event error: $error');
185
+ if (state.isSpeaking && !_isStopping) {
186
+ _onPlaybackFinished();
187
+ }
188
+ },
189
+ );
190
+
191
_currentIndexSubscription = _player.currentIndexStream.listen((index) {
192
if (index != null && index != _currentChunkIndex) {
193
_currentChunkIndex = index;
@@ -203,6 +214,7 @@ class TtsNotifier extends Notifier<TtsState> {
214
_workerIsolate?.kill();
215
_mainReceivePort.close();
216
_playerCompleteSubscription?.cancel();
217
+ _playerEventSubscription?.cancel();
218
_currentIndexSubscription?.cancel();
219
_positionSubscription?.cancel();
220
_durationSubscription?.cancel();
@@ -375,12 +387,16 @@ class TtsNotifier extends Notifier<TtsState> {
387
388
void _deleteChunkFile(int sessionId, int chunkIndex) {
389
if (chunkIndex < 0) return;
378
- try {
379
- final tempFile = File(
380
- '${Directory.systemTemp.path}/tts_chunk_${sessionId}_$chunkIndex.wav',
381
- );
382
- if (tempFile.existsSync()) tempFile.deleteSync();
383
- } catch (_) {}
390
+ Future.microtask(() async {
391
+ try {
392
+ final tempFile = File(
393
+ '${Directory.systemTemp.path}/tts_chunk_${sessionId}_$chunkIndex.wav',
394
+ );
395
+ if (await tempFile.exists()) {
396
+ await tempFile.delete();
397
+ }
398
+ } catch (_) {}
399
+ });
400
}
401
402
void _cleanupSessionFiles(int sessionId, int chunkCount) {
@@ -405,6 +421,19 @@ class TtsNotifier extends Notifier<TtsState> {
421
resetProgress: true,
422
canSeek: false,
423
);
424
+ // Hand the iOS audio session back to record so a follow-up
425
+ // STT startListening() can capture audio immediately.
426
+ _configureAudioSessionForSpeech();
427
+ }
428
+
429
+ void _configureAudioSessionForSpeech() {
430
+ try {
431
+ AudioSession.instance.then((session) {
432
+ session.configure(const AudioSessionConfiguration.speech());
433
+ });
434
+ } catch (e) {
435
+ Log.error('Error configuring audio session for speech: $e');
436
+ }
437
}
438
439
List<String> _splitText(String text) {
@@ -963,6 +992,7 @@ class TtsNotifier extends Notifier<TtsState> {
992
resetProgress: true,
993
canSeek: false,
994
);
995
+ _configureAudioSessionForSpeech();
996
_isStopping = false;
997
}
998
lib/features/voice_mode/providers/voice_mode_provider.dart
new
+343
@@ -0,0 +1,343 @@
1
+import 'dart:async';
2
+
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+
5
+import '../../../core/logger/app_logger.dart';
6
+import '../../chat/providers/chat_providers.dart';
7
+import '../../stt/providers/stt_providers.dart';
8
+import '../../tts/providers/tts_providers.dart';
9
+
10
+/// The phase of the voice-to-voice conversation loop.
11
+enum VoiceModePhase {
12
+ /// Overlay is open but idle — waiting for the user to start.
13
+ idle,
14
+
15
+ /// Actively listening for user speech via STT.
16
+ listening,
17
+
18
+ /// User speech captured; waiting for LLM response.
19
+ processing,
20
+
21
+ /// LLM response complete; TTS is speaking.
22
+ speaking,
23
+
24
+ /// An error occurred in one of the phases.
25
+ error,
26
+}
27
+
28
+class VoiceModeState {
29
+ final bool isActive;
30
+ final VoiceModePhase phase;
31
+ final String transcript;
32
+ final String response;
33
+ final bool autoListen;
34
+ final bool isMuted;
35
+ final String? error;
36
+
37
+ const VoiceModeState({
38
+ this.isActive = false,
39
+ this.phase = VoiceModePhase.idle,
40
+ this.transcript = '',
41
+ this.response = '',
42
+ this.autoListen = true,
43
+ this.isMuted = false,
44
+ this.error,
45
+ });
46
+
47
+ VoiceModeState copyWith({
48
+ bool? isActive,
49
+ VoiceModePhase? phase,
50
+ String? transcript,
51
+ String? response,
52
+ bool? autoListen,
53
+ bool? isMuted,
54
+ String? error,
55
+ bool clearError = false,
56
+ }) {
57
+ return VoiceModeState(
58
+ isActive: isActive ?? this.isActive,
59
+ phase: phase ?? this.phase,
60
+ transcript: transcript ?? this.transcript,
61
+ response: response ?? this.response,
62
+ autoListen: autoListen ?? this.autoListen,
63
+ isMuted: isMuted ?? this.isMuted,
64
+ error: clearError ? null : (error ?? this.error),
65
+ );
66
+ }
67
+}
68
+
69
+final voiceModeProvider = NotifierProvider<VoiceModeNotifier, VoiceModeState>(
70
+ () {
71
+ return VoiceModeNotifier();
72
+ },
73
+);
74
+
75
+class VoiceModeNotifier extends Notifier<VoiceModeState> {
76
+ /// Tracks whether we are currently orchestrating a voice session so
77
+ /// listeners don't fire after the session has been torn down.
78
+ bool _active = false;
79
+ bool _isSendingTranscript = false;
80
+
81
+ @override
82
+ VoiceModeState build() {
83
+ // Listen to chat streaming state changes.
84
+ ref.listen<bool>(
85
+ isStreamingProvider,
86
+ (previous, next) => _onStreamingChanged(previous ?? false, next),
87
+ );
88
+
89
+ // Listen to TTS speaking state changes.
90
+ ref.listen<TtsState>(
91
+ ttsProvider,
92
+ (previous, next) => _onTtsStateChanged(previous, next),
93
+ );
94
+
95
+ // Listen to STT errors and show them directly on the voice screen.
96
+ ref.listen<String?>(
97
+ sttProvider.select((s) => s.error),
98
+ (previous, next) {
99
+ if (!_active || next == null) return;
100
+ state = state.copyWith(
101
+ phase: VoiceModePhase.error,
102
+ error: _mapSttError(next),
103
+ );
104
+ },
105
+ );
106
+
107
+ return const VoiceModeState();
108
+ }
109
+
110
+ // ──────────────────────────────────────────────────────
111
+ // Public API
112
+ // ──────────────────────────────────────────────────────
113
+
114
+ /// Begin the voice mode session. Called when the overlay opens.
115
+ void startSession() {
116
+ _active = true;
117
+ state = const VoiceModeState(isActive: true, phase: VoiceModePhase.idle);
118
+ // Auto-start listening.
119
+ startListening();
120
+ }
121
+
122
+ /// Start listening for user speech.
123
+ Future<void> startListening() async {
124
+ if (!_active) return;
125
+
126
+ _isSendingTranscript = false;
127
+ state = state.copyWith(
128
+ isActive: true,
129
+ phase: VoiceModePhase.listening,
130
+ transcript: '',
131
+ response: '',
132
+ clearError: true,
133
+ );
134
+
135
+ final stt = ref.read(sttProvider.notifier);
136
+ await stt.startListening(
137
+ onResult: (words) {
138
+ if (!_active) return;
139
+ if (words.isNotEmpty) {
140
+ state = state.copyWith(transcript: words);
141
+ }
142
+ },
143
+ onFinal: (finalWords) async {
144
+ if (!_active) return;
145
+ if (!state.autoListen) return;
146
+ if (_isSendingTranscript) return;
147
+ final text = finalWords.trim().isNotEmpty
148
+ ? finalWords
149
+ : state.transcript;
150
+ if (text.trim().isEmpty) return;
151
+ _isSendingTranscript = true;
152
+ await stopListeningAndSend();
153
+ },
154
+ );
155
+ }
156
+
157
+ /// Stop listening and send the captured transcript to the LLM.
158
+ Future<void> stopListeningAndSend() async {
159
+ if (!_active) return;
160
+
161
+ final stt = ref.read(sttProvider.notifier);
162
+ await stt.stopListening();
163
+
164
+ final transcript = state.transcript.trim();
165
+ if (transcript.isEmpty) {
166
+ // Nothing was recognized — show error state directly on screen.
167
+ state = state.copyWith(
168
+ phase: VoiceModePhase.error,
169
+ error: 'No speech recognized. Tap to try again.',
170
+ );
171
+ return;
172
+ }
173
+
174
+ state = state.copyWith(phase: VoiceModePhase.processing, response: '');
175
+
176
+ // Send the transcript as a regular chat message. The streaming
177
+ // listener will handle the transition to speaking phase.
178
+ try {
179
+ await ref.read(chatProvider.notifier).sendMessage(transcript);
180
+ } catch (e) {
181
+ Log.error('Voice mode send error: $e');
182
+ state = state.copyWith(phase: VoiceModePhase.error, error: e.toString());
183
+ }
184
+ }
185
+
186
+ /// Toggle auto-listen after TTS completes.
187
+ void toggleAutoListen() {
188
+ state = state.copyWith(autoListen: !state.autoListen);
189
+ }
190
+
191
+ /// Toggle mute (pause listening without ending session).
192
+ void toggleMute() {
193
+ state = state.copyWith(isMuted: !state.isMuted);
194
+ }
195
+
196
+ /// End the entire voice session and reset everything.
197
+ Future<void> endSession() async {
198
+ _active = false;
199
+ _isSendingTranscript = false;
200
+
201
+ // Stop STT if still listening.
202
+ try {
203
+ final stt = ref.read(sttProvider.notifier);
204
+ await stt.cancelListening();
205
+ } catch (e) {
206
+ Log.error('Voice mode STT cancel error: $e');
207
+ }
208
+
209
+ // Stop TTS if still speaking.
210
+ try {
211
+ final tts = ref.read(ttsProvider.notifier);
212
+ await tts.stop();
213
+ } catch (e) {
214
+ Log.error('Voice mode TTS stop error: $e');
215
+ }
216
+
217
+ state = const VoiceModeState();
218
+ }
219
+
220
+ /// Manually interrupt speaking and re-listen.
221
+ Future<void> interrupt() async {
222
+ if (!_active) return;
223
+ try {
224
+ final tts = ref.read(ttsProvider.notifier);
225
+ await tts.stop();
226
+ } catch (_) {}
227
+
228
+ if (state.autoListen) {
229
+ startListening();
230
+ } else {
231
+ state = state.copyWith(phase: VoiceModePhase.idle);
232
+ }
233
+ }
234
+
235
+ // ──────────────────────────────────────────────────────
236
+ // Internal listeners
237
+ // ──────────────────────────────────────────────────────
238
+
239
+ void _onStreamingChanged(bool wasStreaming, bool isStreaming) {
240
+ if (!_active) return;
241
+ if (state.phase != VoiceModePhase.processing) return;
242
+
243
+ if (wasStreaming && !isStreaming) {
244
+ // Streaming just completed — grab the last assistant message content.
245
+ _handleStreamingComplete();
246
+ }
247
+ }
248
+
249
+ void _handleStreamingComplete() {
250
+ if (!_active) return;
251
+
252
+ final chatState = ref.read(chatProvider);
253
+ final messages = chatState.messages;
254
+ if (messages.isEmpty) return;
255
+
256
+ // The last message should be the assistant's response.
257
+ final lastMessage = messages.last;
258
+ final responseText = lastMessage.content.trim();
259
+
260
+ if (responseText.isEmpty) {
261
+ // No real content — try again or go idle.
262
+ if (state.autoListen) {
263
+ startListening();
264
+ } else {
265
+ state = state.copyWith(phase: VoiceModePhase.idle);
266
+ }
267
+ return;
268
+ }
269
+
270
+ state = state.copyWith(
271
+ phase: VoiceModePhase.speaking,
272
+ response: responseText,
273
+ );
274
+
275
+ // Trigger TTS to speak the response. Pass message/conversation IDs so
276
+ // TTS can cache and resume playback against the right message.
277
+ final tts = ref.read(ttsProvider.notifier);
278
+ () async {
279
+ try {
280
+ await tts.speak(
281
+ responseText,
282
+ messageId: lastMessage.id,
283
+ conversationId: lastMessage.conversationId,
284
+ );
285
+ } catch (e) {
286
+ if (!_active) return;
287
+ Log.error('Voice mode TTS speak failed: $e');
288
+ state = state.copyWith(
289
+ phase: VoiceModePhase.error,
290
+ error: e.toString(),
291
+ );
292
+ }
293
+ }();
294
+ }
295
+
296
+ void _onTtsStateChanged(TtsState? previous, TtsState next) {
297
+ if (!_active) return;
298
+ if (state.phase != VoiceModePhase.speaking) return;
299
+
300
+ final wasSpeaking = previous?.isSpeaking ?? false;
301
+ final isSpeaking = next.isSpeaking;
302
+
303
+ if (wasSpeaking && !isSpeaking && !next.isPaused) {
304
+ // TTS just finished speaking.
305
+ _onSpeakingComplete();
306
+ }
307
+ }
308
+
309
+ void _onSpeakingComplete() {
310
+ if (!_active) return;
311
+
312
+ if (state.autoListen) {
313
+ // Restart listening for the next turn.
314
+ startListening();
315
+ } else {
316
+ state = state.copyWith(phase: VoiceModePhase.idle);
317
+ }
318
+ }
319
+}
320
+
321
+String _mapSttError(String error) {
322
+ switch (error) {
323
+ case 'error_no_match':
324
+ return 'No speech recognized. Tap to try again.';
325
+ case 'error_speech_timeout':
326
+ return 'No speech detected. Tap to try again.';
327
+ case 'error_permission':
328
+ return 'Microphone permission denied.';
329
+ case 'error_busy':
330
+ return 'Speech recognition is busy. Please try again.';
331
+ case 'error_network':
332
+ case 'error_network_timeout':
333
+ return 'Network error. Please check your connection.';
334
+ case 'error_audio':
335
+ return 'Audio recording error. Please check your microphone.';
336
+ default:
337
+ if (error.startsWith('error_')) {
338
+ final cleanName = error.replaceFirst('error_', '').replaceAll('_', ' ');
339
+ return 'Speech recognition error: $cleanName';
340
+ }
341
+ return error;
342
+ }
343
+}
lib/features/voice_mode/views/components/voice_mode_controls.dart
new
+188
@@ -0,0 +1,188 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:hugeicons/hugeicons.dart';
3
+
4
+import '../../providers/voice_mode_provider.dart';
5
+
6
+/// Bottom control bar for voice mode overlay:
7
+/// end call button, mute toggle, auto-listen toggle.
8
+class VoiceModeControls extends StatelessWidget {
9
+ final VoiceModePhase phase;
10
+ final bool autoListen;
11
+ final bool isMuted;
12
+ final VoidCallback onEnd;
13
+ final VoidCallback onToggleMute;
14
+ final VoidCallback onToggleAutoListen;
15
+ final VoidCallback onTapCenter;
16
+
17
+ const VoiceModeControls({
18
+ super.key,
19
+ required this.phase,
20
+ required this.autoListen,
21
+ required this.isMuted,
22
+ required this.onEnd,
23
+ required this.onToggleMute,
24
+ required this.onToggleAutoListen,
25
+ required this.onTapCenter,
26
+ });
27
+
28
+ @override
29
+ Widget build(BuildContext context) {
30
+ final theme = Theme.of(context);
31
+ final isDark = theme.brightness == Brightness.dark;
32
+
33
+ return Padding(
34
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
35
+ child: Row(
36
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
37
+ children: [
38
+ // Auto-listen toggle.
39
+ _ControlButton(
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,
49
+ onTap: onToggleAutoListen,
50
+ ),
51
+
52
+ // Main action — end session (red).
53
+ _EndCallButton(onTap: onEnd),
54
+
55
+ // Send / interrupt based on phase.
56
+ _ControlButton(
57
+ icon: phase == VoiceModePhase.listening
58
+ ? HugeIcons.strokeRoundedSent
59
+ : phase == VoiceModePhase.speaking
60
+ ? HugeIcons.strokeRoundedStop
61
+ : HugeIcons.strokeRoundedMic01,
62
+ label: phase == VoiceModePhase.listening
63
+ ? 'Send'
64
+ : phase == VoiceModePhase.speaking
65
+ ? 'Stop'
66
+ : 'Speak',
67
+ isActive: phase == VoiceModePhase.listening ||
68
+ phase == VoiceModePhase.speaking,
69
+ activeColor: isDark
70
+ ? const Color(0xFF34D399)
71
+ : const Color(0xFF059669),
72
+ isDark: isDark,
73
+ onTap: onTapCenter,
74
+ ),
75
+ ],
76
+ ),
77
+ );
78
+ }
79
+}
80
+
81
+class _EndCallButton extends StatelessWidget {
82
+ final VoidCallback onTap;
83
+
84
+ const _EndCallButton({required this.onTap});
85
+
86
+ @override
87
+ Widget build(BuildContext context) {
88
+ return GestureDetector(
89
+ onTap: onTap,
90
+ child: Container(
91
+ width: 64,
92
+ height: 64,
93
+ decoration: BoxDecoration(
94
+ color: const Color(0xFFEF4444),
95
+ shape: BoxShape.circle,
96
+ boxShadow: [
97
+ BoxShadow(
98
+ color: const Color(0xFFEF4444).withValues(alpha: 0.4),
99
+ blurRadius: 16,
100
+ spreadRadius: 2,
101
+ ),
102
+ ],
103
+ ),
104
+ child: const Center(
105
+ child: HugeIcon(
106
+ icon: HugeIcons.strokeRoundedCallEnd01,
107
+ size: 28,
108
+ color: Colors.white,
109
+ ),
110
+ ),
111
+ ),
112
+ );
113
+ }
114
+}
115
+
116
+class _ControlButton extends StatelessWidget {
117
+ final List<List<dynamic>> icon;
118
+ final String label;
119
+ final bool isActive;
120
+ final Color activeColor;
121
+ final bool isDark;
122
+ final VoidCallback onTap;
123
+
124
+ const _ControlButton({
125
+ required this.icon,
126
+ required this.label,
127
+ required this.isActive,
128
+ required this.activeColor,
129
+ required this.isDark,
130
+ required this.onTap,
131
+ });
132
+
133
+ @override
134
+ Widget build(BuildContext context) {
135
+ final bgColor = isActive
136
+ ? activeColor.withValues(alpha: 0.15)
137
+ : (isDark
138
+ ? Colors.white.withValues(alpha: 0.06)
139
+ : Colors.black.withValues(alpha: 0.05));
140
+
141
+ final iconColor = isActive
142
+ ? activeColor
143
+ : (isDark
144
+ ? Colors.white.withValues(alpha: 0.6)
145
+ : Colors.black.withValues(alpha: 0.5));
146
+
147
+ return GestureDetector(
148
+ 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
+ ),
164
+ ),
165
+ child: Center(
166
+ child: HugeIcon(
167
+ icon: icon,
168
+ size: 24,
169
+ color: iconColor,
170
+ ),
171
+ ),
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
+ ],
185
+ ),
186
+ );
187
+ }
188
+}
lib/features/voice_mode/views/components/voice_transcript.dart
new
+290
@@ -0,0 +1,290 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
4
+import '../../../tts/providers/tts_providers.dart';
5
+import '../../providers/voice_mode_provider.dart';
6
+
7
+/// Displays the current voice phase badge and auto-scrolling response text container.
8
+class VoiceTranscript extends ConsumerStatefulWidget {
9
+ final VoiceModePhase phase;
10
+ final String transcript;
11
+ final String response;
12
+ final String? error;
13
+
14
+ const VoiceTranscript({
15
+ super.key,
16
+ required this.phase,
17
+ required this.transcript,
18
+ required this.response,
19
+ this.error,
20
+ });
21
+
22
+ @override
23
+ ConsumerState<VoiceTranscript> createState() => _VoiceTranscriptState();
24
+}
25
+
26
+class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
27
+ final ScrollController _scrollController = ScrollController();
28
+
29
+ @override
30
+ void didUpdateWidget(VoiceTranscript oldWidget) {
31
+ super.didUpdateWidget(oldWidget);
32
+ if (widget.response != oldWidget.response &&
33
+ widget.phase == VoiceModePhase.processing) {
34
+ _scrollToBottom();
35
+ }
36
+ }
37
+
38
+ void _scrollToBottom() {
39
+ WidgetsBinding.instance.addPostFrameCallback((_) {
40
+ if (_scrollController.hasClients) {
41
+ _scrollController.animateTo(
42
+ _scrollController.position.maxScrollExtent,
43
+ duration: const Duration(milliseconds: 150),
44
+ curve: Curves.easeOut,
45
+ );
46
+ }
47
+ });
48
+ }
49
+
50
+ @override
51
+ void dispose() {
52
+ _scrollController.dispose();
53
+ super.dispose();
54
+ }
55
+
56
+ @override
57
+ Widget build(BuildContext context) {
58
+ final theme = Theme.of(context);
59
+ final isDark = theme.brightness == Brightness.dark;
60
+ final isTtsInitializing = ref.watch(
61
+ ttsProvider.select((s) => s.isInitializing),
62
+ );
63
+
64
+ final (label, badgeColor, isSynthesizing) = _resolveStatusBadge(
65
+ isTtsInitializing,
66
+ );
67
+
68
+ return Column(
69
+ mainAxisSize: MainAxisSize.min,
70
+ children: [
71
+ // Status Badge Pill
72
+ AnimatedSwitcher(
73
+ duration: const Duration(milliseconds: 300),
74
+ child: Container(
75
+ key: ValueKey(label),
76
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
77
+ decoration: BoxDecoration(
78
+ color: badgeColor.withValues(alpha: isDark ? 0.15 : 0.1),
79
+ borderRadius: BorderRadius.circular(20),
80
+ border: Border.all(
81
+ color: badgeColor.withValues(alpha: isDark ? 0.3 : 0.25),
82
+ width: 1,
83
+ ),
84
+ ),
85
+ child: Row(
86
+ mainAxisSize: MainAxisSize.min,
87
+ 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
+ ),
102
+ ),
103
+ const SizedBox(width: 8),
104
+ Text(
105
+ label,
106
+ style: TextStyle(
107
+ fontSize: 11,
108
+ fontWeight: FontWeight.w700,
109
+ letterSpacing: 1.3,
110
+ color: badgeColor,
111
+ ),
112
+ ),
113
+ ],
114
+ ),
115
+ ),
116
+ ),
117
+
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
+ ),
141
+ ],
142
+ );
143
+ }
144
+
145
+ (String, Color, bool) _resolveStatusBadge(bool isTtsInitializing) {
146
+ if (widget.phase == VoiceModePhase.speaking && isTtsInitializing) {
147
+ return ('SYNTHESIZING VOICE', const Color(0xFFF59E0B), true);
148
+ }
149
+
150
+ switch (widget.phase) {
151
+ case VoiceModePhase.listening:
152
+ return ('LISTENING', const Color(0xFF10B981), false);
153
+ case VoiceModePhase.processing:
154
+ final hasResponse = widget.response.trim().isNotEmpty;
155
+ if (isTtsInitializing) {
156
+ return ('SYNTHESIZING VOICE', const Color(0xFFF59E0B), true);
157
+ }
158
+ return (
159
+ hasResponse ? 'RESPONDING' : 'PROCESSING',
160
+ const Color(0xFF8B5CF6),
161
+ false,
162
+ );
163
+ case VoiceModePhase.speaking:
164
+ return ('SPEAKING', const Color(0xFF6366F1), false);
165
+ case VoiceModePhase.idle:
166
+ return ('READY', Colors.grey, 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),
173
+ false,
174
+ );
175
+ }
176
+ }
177
+
178
+ Widget _buildTextContent(
179
+ BuildContext context,
180
+ bool isDark,
181
+ bool isSynthesizing,
182
+ ) {
183
+ switch (widget.phase) {
184
+ case VoiceModePhase.processing:
185
+ final hasResponse = widget.response.trim().isNotEmpty;
186
+ if (!hasResponse) {
187
+ return Text(
188
+ 'Thinking...',
189
+ key: const ValueKey('thinking'),
190
+ style: TextStyle(
191
+ fontSize: 17,
192
+ fontWeight: FontWeight.w400,
193
+ fontStyle: FontStyle.italic,
194
+ color: isDark
195
+ ? Colors.white.withValues(alpha: 0.6)
196
+ : Colors.black.withValues(alpha: 0.5),
197
+ height: 1.4,
198
+ ),
199
+ textAlign: TextAlign.center,
200
+ );
201
+ }
202
+
203
+ // Auto-scrolling square container for generating text
204
+ return Container(
205
+ key: const ValueKey('responding-container'),
206
+ width: double.infinity,
207
+ constraints: const BoxConstraints(maxHeight: 130),
208
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
209
+ decoration: BoxDecoration(
210
+ color: isDark
211
+ ? Colors.white.withValues(alpha: 0.05)
212
+ : Colors.black.withValues(alpha: 0.04),
213
+ borderRadius: BorderRadius.circular(16),
214
+ border: Border.all(
215
+ color: isDark
216
+ ? Colors.white.withValues(alpha: 0.08)
217
+ : Colors.black.withValues(alpha: 0.06),
218
+ ),
219
+ ),
220
+ child: SingleChildScrollView(
221
+ controller: _scrollController,
222
+ physics: const BouncingScrollPhysics(),
223
+ child: Text(
224
+ widget.response,
225
+ style: TextStyle(
226
+ fontSize: 14,
227
+ fontWeight: FontWeight.w400,
228
+ color: isDark
229
+ ? Colors.white.withValues(alpha: 0.7)
230
+ : Colors.black.withValues(alpha: 0.65),
231
+ height: 1.45,
232
+ ),
233
+ textAlign: TextAlign.start,
234
+ ),
235
+ ),
236
+ );
237
+
238
+ case VoiceModePhase.listening:
239
+ final displayText =
240
+ widget.transcript.isEmpty ? 'Listening...' : widget.transcript;
241
+ return Text(
242
+ displayText,
243
+ key: ValueKey('listening-${displayText.hashCode}'),
244
+ style: TextStyle(
245
+ fontSize: 18,
246
+ fontWeight: FontWeight.w500,
247
+ color: isDark
248
+ ? Colors.white.withValues(alpha: 0.85)
249
+ : Colors.black.withValues(alpha: 0.8),
250
+ height: 1.4,
251
+ ),
252
+ textAlign: TextAlign.center,
253
+ );
254
+
255
+ case VoiceModePhase.idle:
256
+ return Text(
257
+ 'Tap to start speaking',
258
+ key: const ValueKey('idle'),
259
+ style: TextStyle(
260
+ fontSize: 16,
261
+ fontWeight: FontWeight.w400,
262
+ color: isDark
263
+ ? Colors.white.withValues(alpha: 0.5)
264
+ : Colors.black.withValues(alpha: 0.4),
265
+ height: 1.4,
266
+ ),
267
+ textAlign: TextAlign.center,
268
+ );
269
+
270
+ case VoiceModePhase.error:
271
+ final errorText = (widget.error != null && widget.error!.isNotEmpty)
272
+ ? widget.error!
273
+ : 'No speech recognized. Tap to try again.';
274
+ return Text(
275
+ errorText,
276
+ key: ValueKey('error-${errorText.hashCode}'),
277
+ style: TextStyle(
278
+ fontSize: 16,
279
+ fontWeight: FontWeight.w400,
280
+ color: const Color(0xFFEF4444).withValues(alpha: 0.9),
281
+ height: 1.4,
282
+ ),
283
+ textAlign: TextAlign.center,
284
+ );
285
+
286
+ case VoiceModePhase.speaking:
287
+ return const SizedBox.shrink();
288
+ }
289
+ }
290
+}
lib/features/voice_mode/views/components/voice_visualizer.dart
new
+307
@@ -0,0 +1,307 @@
1
+import 'dart: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.
10
+class VoiceVisualizer extends StatefulWidget {
11
+ final VoiceModePhase phase;
12
+ final double size;
13
+
14
+ const VoiceVisualizer({
15
+ super.key,
16
+ required this.phase,
17
+ this.size = 220,
18
+ });
19
+
20
+ @override
21
+ State<VoiceVisualizer> createState() => _VoiceVisualizerState();
22
+}
23
+
24
+class _VoiceVisualizerState extends State<VoiceVisualizer>
25
+ with TickerProviderStateMixin {
26
+ late AnimationController _pulseController;
27
+ late AnimationController _orbitController;
28
+ late AnimationController _waveController;
29
+
30
+ @override
31
+ void initState() {
32
+ super.initState();
33
+
34
+ _pulseController = AnimationController(
35
+ vsync: this,
36
+ 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
+ }
78
+ }
79
+
80
+ @override
81
+ void dispose() {
82
+ _pulseController.dispose();
83
+ _orbitController.dispose();
84
+ _waveController.dispose();
85
+ super.dispose();
86
+ }
87
+
88
+ @override
89
+ Widget build(BuildContext context) {
90
+ return SizedBox(
91
+ width: widget.size,
92
+ height: widget.size,
93
+ child: AnimatedBuilder(
94
+ animation: Listenable.merge([
95
+ _pulseController,
96
+ _orbitController,
97
+ _waveController,
98
+ ]),
99
+ builder: (context, _) {
100
+ return CustomPaint(
101
+ painter: _VoicePainter(
102
+ phase: widget.phase,
103
+ pulseValue: _pulseController.value,
104
+ orbitValue: _orbitController.value,
105
+ waveValue: _waveController.value,
106
+ isDark: Theme.of(context).brightness == Brightness.dark,
107
+ ),
108
+ );
109
+ },
110
+ ),
111
+ );
112
+ }
113
+}
114
+
115
+class _VoicePainter extends CustomPainter {
116
+ final VoiceModePhase phase;
117
+ final double pulseValue;
118
+ final double orbitValue;
119
+ final double waveValue;
120
+ final bool isDark;
121
+
122
+ _VoicePainter({
123
+ required this.phase,
124
+ required this.pulseValue,
125
+ required this.orbitValue,
126
+ required this.waveValue,
127
+ required this.isDark,
128
+ });
129
+
130
+ @override
131
+ void paint(Canvas canvas, Size size) {
132
+ final center = Offset(size.width / 2, size.height / 2);
133
+ final maxRadius = size.width / 2;
134
+
135
+ switch (phase) {
136
+ case VoiceModePhase.listening:
137
+ _paintListening(canvas, center, maxRadius);
138
+ case VoiceModePhase.processing:
139
+ _paintProcessing(canvas, center, maxRadius);
140
+ case VoiceModePhase.speaking:
141
+ _paintSpeaking(canvas, center, maxRadius);
142
+ case VoiceModePhase.idle:
143
+ _paintIdle(canvas, center, maxRadius);
144
+ case VoiceModePhase.error:
145
+ _paintError(canvas, center, maxRadius);
146
+ }
147
+ }
148
+
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);
166
+ }
167
+
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);
176
+ }
177
+
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);
199
+ }
200
+
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);
223
+ }
224
+
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);
263
+ }
264
+
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);
283
+ }
284
+
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);
297
+ }
298
+
299
+ @override
300
+ bool shouldRepaint(covariant _VoicePainter oldDelegate) {
301
+ return phase != oldDelegate.phase ||
302
+ pulseValue != oldDelegate.pulseValue ||
303
+ orbitValue != oldDelegate.orbitValue ||
304
+ waveValue != oldDelegate.waveValue ||
305
+ isDark != oldDelegate.isDark;
306
+ }
307
+}
lib/features/voice_mode/views/voice_mode_overlay.dart
new
+292
@@ -0,0 +1,292 @@
1
+import 'dart:ui';
2
+
3
+import 'package:flutter/material.dart';
4
+import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+import 'package:haptic_feedback/haptic_feedback.dart';
6
+import 'package:hugeicons/hugeicons.dart';
7
+
8
+import '../../../../core/models/enums.dart';
9
+import '../../../../core/providers/app_providers.dart';
10
+import '../../chat/providers/chat_notifier.dart';
11
+import '../../tts/views/tts_model_manager_screen.dart';
12
+import '../providers/voice_mode_provider.dart';
13
+import 'components/voice_mode_controls.dart';
14
+import 'components/voice_transcript.dart';
15
+import 'components/voice_visualizer.dart';
16
+
17
+/// Full-screen voice-to-voice conversation overlay.
18
+///
19
+/// Presents an animated visual indicator and transcript text across
20
+/// Listen → Process → Speak phases, looping hands-free when
21
+/// auto-listen is enabled.
22
+///
23
+/// Usage:
24
+/// ```dart
25
+/// VoiceModeOverlay.show(context);
26
+/// ```
27
+class VoiceModeOverlay extends ConsumerStatefulWidget {
28
+ const VoiceModeOverlay({super.key});
29
+
30
+ /// Show the voice mode overlay as a full-screen modal dialog.
31
+ static Future<void> show(BuildContext context) {
32
+ return showGeneralDialog(
33
+ context: context,
34
+ barrierDismissible: false,
35
+ barrierColor: Colors.transparent,
36
+ transitionDuration: const Duration(milliseconds: 400),
37
+ transitionBuilder: (context, animation, secondaryAnimation, child) {
38
+ final curve = CurvedAnimation(
39
+ parent: animation,
40
+ curve: Curves.easeOutCubic,
41
+ );
42
+ return FadeTransition(
43
+ opacity: curve,
44
+ child: ScaleTransition(
45
+ scale: Tween<double>(begin: 0.92, end: 1.0).animate(curve),
46
+ child: child,
47
+ ),
48
+ );
49
+ },
50
+ pageBuilder: (context, animation, secondaryAnimation) {
51
+ return const VoiceModeOverlay();
52
+ },
53
+ );
54
+ }
55
+
56
+ @override
57
+ ConsumerState<VoiceModeOverlay> createState() => _VoiceModeOverlayState();
58
+}
59
+
60
+class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
61
+ @override
62
+ void initState() {
63
+ super.initState();
64
+ // Start the voice session once the overlay is mounted.
65
+ WidgetsBinding.instance.addPostFrameCallback((_) {
66
+ ref.read(voiceModeProvider.notifier).startSession();
67
+ });
68
+ }
69
+
70
+ Future<void> _endSession() async {
71
+ Haptics.vibrate(HapticsType.medium);
72
+ await ref.read(voiceModeProvider.notifier).endSession();
73
+ if (mounted) {
74
+ Navigator.of(context).pop();
75
+ }
76
+ }
77
+
78
+ void _handleCenterAction() {
79
+ final phase = ref.read(voiceModeProvider).phase;
80
+ Haptics.vibrate(HapticsType.light);
81
+
82
+ switch (phase) {
83
+ case VoiceModePhase.listening:
84
+ // Send current transcript.
85
+ ref.read(voiceModeProvider.notifier).stopListeningAndSend();
86
+ case VoiceModePhase.speaking:
87
+ // Interrupt and potentially re-listen.
88
+ ref.read(voiceModeProvider.notifier).interrupt();
89
+ case VoiceModePhase.idle:
90
+ case VoiceModePhase.error:
91
+ // Start listening.
92
+ ref.read(voiceModeProvider.notifier).startListening();
93
+ case VoiceModePhase.processing:
94
+ // Can't interrupt processing — do nothing.
95
+ break;
96
+ }
97
+ }
98
+
99
+ Widget _buildTtsChip(BuildContext context, ThemeData theme, bool isDark) {
100
+ final settings = ref.watch(settingsProvider);
101
+ final String label;
102
+
103
+ switch (settings.ttsEngine) {
104
+ case EngineId.system:
105
+ label = 'System TTS';
106
+ case EngineId.piper:
107
+ final voice = voiceFromSettings(settings.ttsVoiceId, EngineId.piper);
108
+ label = 'Piper • ${voice?.name ?? 'Ryan'}';
109
+ case EngineId.kitten:
110
+ label = 'Kitten • ${settings.kittenTtsModelVariant.displayName}';
111
+ }
112
+
113
+ return InkWell(
114
+ onTap: () {
115
+ Haptics.vibrate(HapticsType.light);
116
+ showModalBottomSheet(
117
+ context: context,
118
+ isScrollControlled: true,
119
+ useSafeArea: true,
120
+ builder: (_) => const TtsModelManagerScreen(),
121
+ );
122
+ },
123
+ borderRadius: BorderRadius.circular(20),
124
+ child: Container(
125
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
126
+ decoration: BoxDecoration(
127
+ color: isDark
128
+ ? Colors.white.withValues(alpha: 0.08)
129
+ : Colors.black.withValues(alpha: 0.06),
130
+ borderRadius: BorderRadius.circular(20),
131
+ border: Border.all(
132
+ color: isDark
133
+ ? Colors.white.withValues(alpha: 0.12)
134
+ : Colors.black.withValues(alpha: 0.1),
135
+ ),
136
+ ),
137
+ child: Row(
138
+ mainAxisSize: MainAxisSize.min,
139
+ children: [
140
+ HugeIcon(
141
+ icon: HugeIcons.strokeRoundedVolumeHigh,
142
+ size: 13,
143
+ color: theme.colorScheme.primary,
144
+ ),
145
+ const SizedBox(width: 5),
146
+ Text(
147
+ label,
148
+ style: TextStyle(
149
+ fontSize: 12,
150
+ fontWeight: FontWeight.w600,
151
+ color: isDark
152
+ ? Colors.white.withValues(alpha: 0.85)
153
+ : Colors.black87,
154
+ ),
155
+ ),
156
+ const SizedBox(width: 3),
157
+ Icon(
158
+ Icons.chevron_right,
159
+ size: 14,
160
+ color: isDark
161
+ ? Colors.white.withValues(alpha: 0.4)
162
+ : Colors.black.withValues(alpha: 0.4),
163
+ ),
164
+ ],
165
+ ),
166
+ ),
167
+ );
168
+ }
169
+
170
+ @override
171
+ Widget build(BuildContext context) {
172
+ final state = ref.watch(voiceModeProvider);
173
+ final theme = Theme.of(context);
174
+ final isDark = theme.brightness == Brightness.dark;
175
+
176
+ final bgColor = isDark
177
+ ? const Color(0xFF0A0A0A)
178
+ : const Color(0xFFFAFAFA);
179
+
180
+ return PopScope(
181
+ canPop: false,
182
+ onPopInvokedWithResult: (didPop, _) {
183
+ if (!didPop) _endSession();
184
+ },
185
+ child: Scaffold(
186
+ 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(
194
+ child: Column(
195
+ children: [
196
+ // Top bar — title, TTS chip, close button.
197
+ Padding(
198
+ padding: const EdgeInsets.symmetric(
199
+ horizontal: 16,
200
+ vertical: 8,
201
+ ),
202
+ child: Row(
203
+ children: [
204
+ // Title.
205
+ Text(
206
+ 'Voice Mode',
207
+ style: TextStyle(
208
+ fontSize: 16,
209
+ fontWeight: FontWeight.w600,
210
+ color: isDark
211
+ ? Colors.white.withValues(alpha: 0.7)
212
+ : Colors.black.withValues(alpha: 0.6),
213
+ ),
214
+ ),
215
+ const Spacer(),
216
+ // TTS Model Chip.
217
+ _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
+ ),
229
+ ],
230
+ ),
231
+ ),
232
+
233
+ // Expanding space.
234
+ const Spacer(flex: 2),
235
+
236
+ // Animated visualizer.
237
+ GestureDetector(
238
+ onTap: _handleCenterAction,
239
+ child: VoiceVisualizer(
240
+ phase: state.phase,
241
+ size: 220,
242
+ ),
243
+ ),
244
+
245
+ const SizedBox(height: 32),
246
+
247
+ // Transcript / status text.
248
+ Padding(
249
+ padding: const EdgeInsets.symmetric(horizontal: 32),
250
+ child: VoiceTranscript(
251
+ phase: state.phase,
252
+ 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,
261
+ error: state.error,
262
+ ),
263
+ ),
264
+
265
+ const Spacer(flex: 3),
266
+
267
+ // Bottom controls.
268
+ VoiceModeControls(
269
+ phase: state.phase,
270
+ autoListen: state.autoListen,
271
+ isMuted: state.isMuted,
272
+ onEnd: _endSession,
273
+ onToggleMute: () {
274
+ ref.read(voiceModeProvider.notifier).toggleMute();
275
+ },
276
+ onToggleAutoListen: () {
277
+ Haptics.vibrate(HapticsType.light);
278
+ ref.read(voiceModeProvider.notifier).toggleAutoListen();
279
+ },
280
+ onTapCenter: _handleCenterAction,
281
+ ),
282
+
283
+ const SizedBox(height: 16),
284
+ ],
285
+ ),
286
+ ),
287
+ ),
288
+ ),
289
+ ),
290
+ );
291
+ }
292
+}
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.5.5+60
19
+version: 1.5.6+61
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"