@tduncan / Cortana / commits / 8076bad

feat: implement voice-to-voice conversation mode

abdulmominsakib committed Jul 28, 2026 at 11:47 UTC 8076bad7f24f0ce9e31358ebd1dd9b6f54918147
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"