From a930a7a466e4685603b1cbbb514b13a8df14d0d6 Mon Sep 17 00:00:00 2001
From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com>
Date: Mon, 25 Aug 2025 10:35:48 +0530
Subject: [PATCH] refactor: voice input
---
android/app/src/main/AndroidManifest.xml | 6 +
.../chat/services/voice_input_service.dart | 148 +++-
lib/features/chat/views/chat_page.dart | 33 +-
.../chat/widgets/modern_chat_input.dart | 262 +++++-
.../chat/widgets/voice_input_sheet.dart | 780 ------------------
.../navigation/widgets/chats_drawer.dart | 62 +-
6 files changed, 453 insertions(+), 838 deletions(-)
delete mode 100644 lib/features/chat/widgets/voice_input_sheet.dart
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 754cc24..62d7792 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -12,6 +12,12 @@
+
+
+
+
+
+
l.localeId).join(', ')}',
+ );
final deviceTag = WidgetsBinding.instance.platformDispatcher.locale
.toLanguageTag();
+ debugPrint('DEBUG: Device locale: $deviceTag');
final match = _locales.firstWhere(
(l) => l.localeId.toLowerCase() == deviceTag.toLowerCase(),
orElse: () {
- final primary = deviceTag.split(RegExp('[-_]')).first.toLowerCase();
+ final primary = deviceTag
+ .split(RegExp('[-_]'))
+ .first
+ .toLowerCase();
return _locales.firstWhere(
(l) => l.localeId.toLowerCase().startsWith('$primary-'),
orElse: () => _locales.isNotEmpty
@@ -64,7 +83,9 @@ class VoiceInputService {
},
);
_selectedLocaleId = match.localeId;
- } catch (_) {
+ debugPrint('DEBUG: Selected locale: $_selectedLocaleId');
+ } catch (e) {
+ debugPrint('DEBUG: Error loading locales: $e');
_selectedLocaleId = null;
}
}
@@ -86,6 +107,83 @@ class VoiceInputService {
bool get isListening => _isListening;
bool get isAvailable => _isInitialized; // service usable (local or fallback)
bool get hasLocalStt => _localSttAvailable;
+
+ // Add a method to check if on-device STT is properly supported
+ Future checkOnDeviceSupport() async {
+ if (!isSupportedPlatform || !_isInitialized) return false;
+ if (_speech == null) return false;
+
+ try {
+ // Check if the speech engine supports on-device recognition
+ final result = await _speech!.initialize();
+ debugPrint('DEBUG: On-device support check - initialize result: $result');
+
+ if (result) {
+ // Note: getEngines() method is not available in speech_to_text 7.3.0
+ // The package handles engine selection internally
+ debugPrint(
+ 'DEBUG: SpeechToText initialized successfully - engine selection handled internally',
+ );
+ }
+
+ return result;
+ } catch (e) {
+ debugPrint('DEBUG: Error checking on-device support: $e');
+ return false;
+ }
+ }
+
+ // Test method to verify on-device STT functionality
+ Future testOnDeviceStt() async {
+ try {
+ debugPrint('DEBUG: Starting on-device STT test');
+
+ // First ensure we're initialized
+ await initialize();
+
+ if (!_localSttAvailable || _speech == null) {
+ return 'Local STT not available. Available: $_localSttAvailable, Speech: ${_speech != null}';
+ }
+
+ // Check microphone permission
+ final hasMic = await checkPermissions();
+ if (!hasMic) {
+ return 'Microphone permission not granted';
+ }
+
+ // Test if speech recognition is available
+ final isAvailable = await _speech!.isAvailable;
+ debugPrint('DEBUG: Speech recognition isAvailable: $isAvailable');
+
+ if (!isAvailable) {
+ return 'Speech recognition service is not available on this device';
+ }
+
+ // Check if listening is already active
+ final isListening = await _speech!.isListening;
+ debugPrint('DEBUG: Speech recognition isListening: $isListening');
+
+ if (isListening) {
+ await _speech!.stop();
+ await Future.delayed(const Duration(milliseconds: 500));
+ }
+
+ // Check if we can start listening
+ startListening();
+
+ // Wait a bit for initialization
+ await Future.delayed(const Duration(milliseconds: 100));
+
+ // Stop immediately after starting
+ await stopListening();
+
+ return 'On-device STT test completed successfully. Local STT available: $_localSttAvailable, Selected locale: $_selectedLocaleId';
+ } catch (e) {
+ debugPrint('DEBUG: On-device STT test failed: $e');
+ return 'On-device STT test failed: $e';
+ }
+ }
+
String? get selectedLocaleId => _selectedLocaleId;
List get locales => _locales;
@@ -107,8 +205,36 @@ class VoiceInputService {
_isListening = true;
_intensityController = StreamController.broadcast();
+ // Check if speech recognition is available before trying to use it
if (_localSttAvailable && _speech != null) {
+ // Schedule a check for speech recognition availability
+ Future.microtask(() async {
+ try {
+ final isStillAvailable = await _speech!.isAvailable;
+ if (!isStillAvailable && _isListening) {
+ debugPrint(
+ 'DEBUG: Speech recognition no longer available, falling back to recording',
+ );
+ _localSttAvailable = false;
+ // Restart with fallback method
+ _startRecordingProxyIntensity();
+ _autoStopTimer?.cancel();
+ _autoStopTimer = Timer(const Duration(seconds: 30), () {
+ if (_isListening) {
+ _stopListening();
+ }
+ });
+ return;
+ }
+ } catch (e) {
+ debugPrint('DEBUG: Error checking speech availability: $e');
+ }
+ });
+
// Local on-device STT path
+ debugPrint(
+ 'DEBUG: Starting on-device STT with locale: $_selectedLocaleId',
+ );
_autoStopTimer?.cancel();
// SpeechToText has its own end-of-speech handling; we still cap at 60s
_autoStopTimer = Timer(const Duration(seconds: 60), () {
@@ -116,13 +242,15 @@ class VoiceInputService {
_stopListening();
}
});
-
_speech!.listen(
localeId: _selectedLocaleId,
listenFor: const Duration(seconds: 60),
- pauseFor: const Duration(seconds: 5),
+ pauseFor: const Duration(seconds: 3),
onResult: (SpeechRecognitionResult result) {
if (!_isListening) return;
+ debugPrint(
+ 'DEBUG: Speech result: "${result.recognizedWords}" (final: ${result.finalResult})',
+ );
_currentText = result.recognizedWords;
_textStreamController?.add(_currentText);
if (result.finalResult) {
@@ -131,18 +259,20 @@ class VoiceInputService {
}
},
onSoundLevelChange: (level) {
+ debugPrint('DEBUG: Sound level: $level');
// level is roughly 0..1+; map to 0..10
final scaled = (level * 10).clamp(0, 10).round();
_intensityController?.add(scaled);
},
- listenOptions: stt.SpeechListenOptions(
- partialResults: true,
- cancelOnError: true,
- listenMode: stt.ListenMode.confirmation,
- ),
+ partialResults: true,
+ cancelOnError: true,
+ listenMode: stt.ListenMode.dictation,
+ onDevice: true,
);
+ debugPrint('DEBUG: SpeechToText.listen() called with onDevice: true');
} else {
// Fallback: record audio and signal file path for server transcription
+ debugPrint('DEBUG: Local STT not available, falling back to recording');
_startRecordingProxyIntensity();
_autoStopTimer?.cancel();
_autoStopTimer = Timer(const Duration(seconds: 30), () {
diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart
index 6ec045c..eec554c 100644
--- a/lib/features/chat/views/chat_page.dart
+++ b/lib/features/chat/views/chat_page.dart
@@ -17,7 +17,7 @@ import '../widgets/modern_chat_input.dart';
import '../widgets/user_message_bubble.dart';
import '../widgets/assistant_message_widget.dart' as assistant;
import '../widgets/file_attachment_widget.dart';
-import '../widgets/voice_input_sheet.dart';
+// import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input
import '../services/voice_input_service.dart';
import '../services/file_attachment_service.dart';
import '../../tools/providers/tools_providers.dart';
@@ -307,33 +307,7 @@ class _ChatPageState extends ConsumerState {
}
}
- void _handleVoiceInput() async {
- // TODO: Implement voice input functionality
- final isAvailable = await ref.read(voiceInputAvailableProvider.future);
-
- if (!isAvailable) {
- if (!mounted) return;
- return;
- }
-
- // Show voice input dialog
- if (!mounted) return;
- showModalBottomSheet(
- context: context,
- backgroundColor: Colors.transparent,
- isScrollControlled: true,
- builder: (context) => VoiceInputSheet(
- onTextReceived: (text) {
- if (text.isNotEmpty) {
- final selectedModel = ref.read(selectedModelProvider);
- if (selectedModel != null) {
- _handleMessageSend(text, selectedModel);
- }
- }
- },
- ),
- );
- }
+ // Inline voice input now handled directly inside ModernChatInput.
void _handleFileAttachment() async {
// Check if selected model supports file upload
@@ -1000,6 +974,7 @@ class _ChatPageState extends ConsumerState {
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
toolbarHeight: kToolbarHeight,
+ centerTitle: true,
titleSpacing: 0.0,
leading: _isSelectionMode
? IconButton(
@@ -1286,7 +1261,7 @@ class _ChatPageState extends ConsumerState {
(isOnline || ref.watch(reviewerModeProvider)),
onSendMessage: (text) =>
_handleMessageSend(text, selectedModel),
- onVoiceInput: _handleVoiceInput,
+ onVoiceInput: null,
onFileAttachment: _handleFileAttachment,
onImageAttachment: _handleImageAttachment,
onCameraCapture: () =>
diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart
index cf136db..d85474b 100644
--- a/lib/features/chat/widgets/modern_chat_input.dart
+++ b/lib/features/chat/widgets/modern_chat_input.dart
@@ -6,12 +6,13 @@ import '../../../shared/widgets/sheet_handle.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'dart:io' show Platform;
+import 'dart:io' show Platform, File;
import 'dart:async';
import '../providers/chat_providers.dart';
import '../../tools/widgets/unified_tools_modal.dart';
import '../../tools/providers/tools_providers.dart';
import '../../../core/providers/app_providers.dart';
+import '../../chat/services/voice_input_service.dart';
import '../../../shared/utils/platform_utils.dart';
import 'package:conduit/l10n/app_localizations.dart';
@@ -42,7 +43,7 @@ class _ModernChatInputState extends ConsumerState
with TickerProviderStateMixin {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
- final bool _isRecording = false;
+ bool _isRecording = false;
bool _isExpanded = true; // Start expanded for better UX
// TODO: Implement voice input functionality
// final String _voiceInputText = '';
@@ -52,10 +53,16 @@ class _ModernChatInputState extends ConsumerState
late AnimationController _pulseController;
Timer? _blurCollapseTimer;
bool _hasAutoFocusedOnce = false;
+ late VoiceInputService _voiceService;
+ StreamSubscription? _intensitySub;
+ StreamSubscription? _textSub;
+ int _intensity = 0; // 0..10 from service
+ String _baseTextAtStart = '';
@override
void initState() {
super.initState();
+ _voiceService = ref.read(voiceInputServiceProvider);
_expandController = AnimationController(
duration:
AnimationDuration.fast, // Faster animation for better responsiveness
@@ -130,6 +137,9 @@ class _ModernChatInputState extends ConsumerState
_pulseController.dispose();
_blurCollapseTimer?.cancel();
_voiceStreamSubscription?.cancel();
+ _intensitySub?.cancel();
+ _textSub?.cancel();
+ _voiceService.stopListening();
super.dispose();
}
@@ -201,6 +211,11 @@ class _ModernChatInputState extends ConsumerState
final webSearchEnabled = ref.watch(webSearchEnabledProvider);
final imageGenEnabled = ref.watch(imageGenerationEnabledProvider);
final imageGenAvailable = ref.watch(imageGenerationAvailableProvider);
+ final voiceAvailableAsync = ref.watch(voiceInputAvailableProvider);
+ final bool voiceAvailable = voiceAvailableAsync.maybeWhen(
+ data: (v) => v,
+ orElse: () => false,
+ );
return Container(
// Transparent wrapper so rounded corners are visible against page background
@@ -455,20 +470,102 @@ class _ModernChatInputState extends ConsumerState
imageGenEnabled,
),
const SizedBox(width: Spacing.sm),
- // Microphone button: call provided callback for premium voice UI
- _buildRoundButton(
- icon: Platform.isIOS
- ? CupertinoIcons.mic_fill
- : Icons.mic,
- onTap: widget.enabled
- ? widget.onVoiceInput
- : null,
- tooltip: AppLocalizations.of(
- context,
- )!.voiceInput,
- isActive: _isRecording,
+ // Microphone button: inline voice input toggle with animated intensity ring
+ Builder(
+ builder: (context) {
+ const double buttonSize =
+ TouchTarget.comfortable;
+ final double t = _isRecording
+ ? (_intensity.clamp(0, 10) / 10.0)
+ : 0.0;
+ final double ringMaxExtra = 16.0;
+ final double ringSize =
+ buttonSize + (ringMaxExtra * t);
+ final double ringOpacity =
+ 0.15 + (0.35 * t);
+
+ return SizedBox(
+ width: buttonSize,
+ height: buttonSize,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ AnimatedContainer(
+ duration: const Duration(
+ milliseconds: 120,
+ ),
+ width: ringSize,
+ height: ringSize,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: context
+ .conduitTheme
+ .buttonPrimary
+ .withValues(
+ alpha: ringOpacity,
+ ),
+ ),
+ ),
+ Transform.scale(
+ scale: _isRecording
+ ? 1.0 +
+ (_intensity.clamp(
+ 0,
+ 10,
+ ) /
+ 200)
+ : 1.0,
+ child: _buildRoundButton(
+ icon: Platform.isIOS
+ ? CupertinoIcons.mic_fill
+ : Icons.mic,
+ onTap:
+ (widget.enabled &&
+ voiceAvailable)
+ ? _toggleVoice
+ : null,
+ tooltip: AppLocalizations.of(
+ context,
+ )!.voiceInput,
+ isActive: _isRecording,
+ ),
+ ),
+ ],
+ ),
+ );
+ },
),
const SizedBox(width: Spacing.sm),
+ // Debug button for testing on-device STT (enable by changing false to true)
+ // ignore: dead_code
+ if (false) ...[
+ const SizedBox(width: Spacing.sm),
+ _buildRoundButton(
+ icon: Icons.bug_report,
+ onTap: widget.enabled
+ ? () async {
+ final result = await _voiceService
+ .testOnDeviceStt();
+ if (context.mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(
+ SnackBar(
+ content: Text(
+ 'STT Test: $result',
+ ),
+ duration: const Duration(
+ seconds: 5,
+ ),
+ ),
+ );
+ }
+ }
+ : null,
+ tooltip: 'Test On-Device STT',
+ ),
+ ],
+ const SizedBox(width: Spacing.sm),
// Primary action button (Send/Stop) when expanded
_buildPrimaryButton(
_hasText,
@@ -822,6 +919,143 @@ class _ModernChatInputState extends ConsumerState
);
}
+ // --- Inline Voice Input ---
+ Future _toggleVoice() async {
+ if (_isRecording) {
+ await _stopVoice();
+ } else {
+ await _startVoice();
+ }
+ }
+
+ Future _startVoice() async {
+ if (!widget.enabled) return;
+ try {
+ final ok = await _voiceService.initialize();
+ if (!ok) {
+ _showVoiceUnavailable(
+ AppLocalizations.of(context)?.errorMessage ??
+ 'Voice input unavailable',
+ );
+ return;
+ }
+ if (!_voiceService.hasLocalStt) {
+ final mic = await _voiceService.checkPermissions();
+ if (!mic) {
+ _showVoiceUnavailable(
+ AppLocalizations.of(context)?.errorMessage ??
+ 'Microphone permission required',
+ );
+ return;
+ }
+ }
+ setState(() {
+ _isRecording = true;
+ _baseTextAtStart = _controller.text;
+ });
+
+ final stream = _voiceService.startListening();
+ _intensitySub?.cancel();
+ _intensitySub = _voiceService.intensityStream.listen((value) {
+ if (!mounted) return;
+ setState(() => _intensity = value);
+ });
+ _textSub?.cancel();
+ _textSub = stream.listen(
+ (text) async {
+ if (text.startsWith('[[AUDIO_FILE_PATH]]:')) {
+ final path = text.split(':').skip(1).join(':');
+ await _transcribeRecordedFile(path);
+ } else {
+ final updated =
+ (_baseTextAtStart.isEmpty
+ ? ''
+ : (_baseTextAtStart.trimRight() + ' ')) +
+ text;
+ _controller.value = TextEditingValue(
+ text: updated,
+ selection: TextSelection.collapsed(offset: updated.length),
+ );
+ }
+ },
+ onDone: () {
+ if (!mounted) return;
+ setState(() => _isRecording = false);
+ _intensitySub?.cancel();
+ _intensitySub = null;
+ },
+ onError: (_) {
+ if (!mounted) return;
+ setState(() => _isRecording = false);
+ _intensitySub?.cancel();
+ _intensitySub = null;
+ },
+ );
+ _ensureFocusedIfEnabled();
+ } catch (_) {
+ _showVoiceUnavailable(
+ AppLocalizations.of(context)?.errorMessage ??
+ 'Failed to start voice input',
+ );
+ if (!mounted) return;
+ setState(() => _isRecording = false);
+ }
+ }
+
+ Future _stopVoice() async {
+ _intensitySub?.cancel();
+ _intensitySub = null;
+ await _voiceService.stopListening();
+ if (!mounted) return;
+ setState(() => _isRecording = false);
+ HapticFeedback.selectionClick();
+ }
+
+ Future _transcribeRecordedFile(String filePath) async {
+ try {
+ final api = ref.read(apiServiceProvider);
+ if (api == null) return;
+ final file = File(filePath);
+ final bytes = await file.readAsBytes();
+ String? language;
+ try {
+ language = WidgetsBinding.instance.platformDispatcher.locale
+ .toLanguageTag();
+ } catch (_) {
+ language = 'en-US';
+ }
+ final text = await api.transcribeAudio(
+ bytes.toList(),
+ language: language,
+ );
+ final updated =
+ (_baseTextAtStart.isEmpty
+ ? ''
+ : (_baseTextAtStart.trimRight() + ' ')) +
+ text;
+ if (!mounted) return;
+ _controller.value = TextEditingValue(
+ text: updated,
+ selection: TextSelection.collapsed(offset: updated.length),
+ );
+ } catch (_) {
+ } finally {
+ if (!mounted) return;
+ setState(() => _isRecording = false);
+ }
+ }
+
+ void _showVoiceUnavailable(String message) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(message),
+ behavior: SnackBarBehavior.floating,
+ duration: const Duration(seconds: 2),
+ ),
+ );
+ }
+
Widget _buildAttachmentOption({
required IconData icon,
required String label,
diff --git a/lib/features/chat/widgets/voice_input_sheet.dart b/lib/features/chat/widgets/voice_input_sheet.dart
deleted file mode 100644
index f1b87c2..0000000
--- a/lib/features/chat/widgets/voice_input_sheet.dart
+++ /dev/null
@@ -1,780 +0,0 @@
-import 'dart:async';
-import 'dart:io' show File, Platform;
-
-import 'package:flutter/cupertino.dart';
-import 'package:flutter/material.dart';
-import 'package:conduit/l10n/app_localizations.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-
-import '../../../core/providers/app_providers.dart';
-import '../../../core/services/platform_service.dart' as ps;
-import '../../../core/services/settings_service.dart';
-import '../../../shared/theme/theme_extensions.dart';
-import '../../../shared/widgets/conduit_components.dart';
-import '../../../shared/widgets/sheet_handle.dart';
-import '../services/voice_input_service.dart';
-
-class VoiceInputSheet extends ConsumerStatefulWidget {
- final void Function(String text) onTextReceived;
-
- const VoiceInputSheet({super.key, required this.onTextReceived});
-
- @override
- ConsumerState createState() => _VoiceInputSheetState();
-}
-
-class _VoiceInputSheetState extends ConsumerState {
- late final VoiceInputService _voiceService;
- StreamSubscription? _intensitySub;
- StreamSubscription? _textSub;
-
- bool _isListening = false;
- bool _isTranscribing = false;
- int _intensity = 0; // 0..10
- String _recognizedText = '';
- int _elapsedSeconds = 0;
- Timer? _elapsedTimer;
-
- bool _holdToTalk = false;
- bool _autoSendFinal = false;
- String _languageTag = 'en';
-
- // Simplified: remove explicit mode selector and rely on a single toggle
- // Hold-to-talk: true → push-to-talk; false → continuous
-
- @override
- void initState() {
- super.initState();
- _voiceService = ref.read(voiceInputServiceProvider);
-
- // Initialize language
- try {
- final preset = _voiceService.selectedLocaleId;
- _languageTag =
- (preset ??
- WidgetsBinding.instance.platformDispatcher.locale
- .toLanguageTag())
- .split(RegExp('[-_]'))
- .first
- .toLowerCase();
- } catch (_) {
- _languageTag = 'en';
- }
-
- // Load persisted voice settings
- final settings = ref.read(appSettingsProvider);
- _holdToTalk = settings.voiceHoldToTalk;
- _autoSendFinal = settings.voiceAutoSendFinal;
- if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) {
- _voiceService.setLocale(settings.voiceLocaleId);
- _languageTag = settings.voiceLocaleId!
- .split(RegExp('[-_]'))
- .first
- .toLowerCase();
- }
-
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- if (!_holdToTalk && !_isListening) {
- _startListening();
- }
- });
- }
-
- @override
- void dispose() {
- _intensitySub?.cancel();
- _textSub?.cancel();
- _elapsedTimer?.cancel();
- super.dispose();
- }
-
- Future _startListening() async {
- setState(() {
- _isListening = true;
- _recognizedText = '';
- _elapsedSeconds = 0;
- });
-
- final hapticEnabled = ref.read(hapticEnabledProvider);
- ps.PlatformService.hapticFeedbackWithSettings(
- type: ps.HapticType.medium,
- hapticEnabled: hapticEnabled,
- );
-
- try {
- final ok = await _voiceService.initialize();
- if (!ok) throw Exception('Voice service unavailable');
- if (!_voiceService.hasLocalStt) {
- final mic = await _voiceService.checkPermissions();
- if (!mic) throw Exception('Microphone permission not granted');
- }
-
- _elapsedTimer?.cancel();
- _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (t) {
- if (!mounted || !_isListening) {
- t.cancel();
- return;
- }
- setState(() => _elapsedSeconds += 1);
- });
-
- final stream = _voiceService.startListening();
- _intensitySub = _voiceService.intensityStream.listen((value) {
- if (!mounted) return;
- setState(() => _intensity = value);
- });
- _textSub = stream.listen(
- (text) {
- if (text.startsWith('[[AUDIO_FILE_PATH]]:')) {
- final path = text.split(':').skip(1).join(':');
- _transcribeRecordedFile(path);
- } else {
- setState(() => _recognizedText = text);
- }
- },
- onDone: () {
- setState(() => _isListening = false);
- _elapsedTimer?.cancel();
- if (_autoSendFinal && _recognizedText.trim().isNotEmpty) {
- _sendText();
- }
- },
- onError: (_) {
- setState(() => _isListening = false);
- _elapsedTimer?.cancel();
- final h = ref.read(hapticEnabledProvider);
- ps.PlatformService.hapticFeedbackWithSettings(
- type: ps.HapticType.warning,
- hapticEnabled: h,
- );
- },
- );
- } catch (_) {
- setState(() => _isListening = false);
- }
- }
-
- Future _stopListening() async {
- _intensitySub?.cancel();
- _intensitySub = null;
- await _voiceService.stopListening();
- _elapsedTimer?.cancel();
- if (mounted) setState(() => _isListening = false);
- final hapticEnabled = ref.read(hapticEnabledProvider);
- ps.PlatformService.hapticFeedbackWithSettings(
- type: ps.HapticType.selection,
- hapticEnabled: hapticEnabled,
- );
- }
-
- Future _transcribeRecordedFile(String filePath) async {
- try {
- setState(() => _isTranscribing = true);
- final api = ref.read(apiServiceProvider);
- if (api == null) throw Exception('API service unavailable');
- final bytes = await File(filePath).readAsBytes();
- String? language;
- try {
- language = WidgetsBinding.instance.platformDispatcher.locale
- .toLanguageTag();
- } catch (_) {
- language = 'en-US';
- }
- final text = await api.transcribeAudio(
- bytes.toList(),
- language: language,
- );
- if (!mounted) return;
- setState(() {
- _recognizedText = text;
- _isListening = false;
- });
- if (_autoSendFinal && _recognizedText.trim().isNotEmpty) {
- _sendText();
- }
- } catch (_) {
- if (!mounted) return;
- setState(() => _isListening = false);
- } finally {
- if (mounted) setState(() => _isTranscribing = false);
- }
- }
-
- void _sendText() {
- if (_recognizedText.trim().isEmpty) return;
- final hapticEnabled = ref.read(hapticEnabledProvider);
- ps.PlatformService.hapticFeedbackWithSettings(
- type: ps.HapticType.success,
- hapticEnabled: hapticEnabled,
- );
- widget.onTextReceived(_recognizedText.trim());
- Navigator.of(context).pop();
- }
-
- String _formatSeconds(int seconds) {
- final m = (seconds ~/ 60).toString().padLeft(1, '0');
- final s = (seconds % 60).toString().padLeft(2, '0');
- return '$m:$s';
- }
-
- Future _pickLanguage() async {
- if (!_voiceService.hasLocalStt) return;
- final locales = _voiceService.locales;
- if (locales.isEmpty || !mounted) return;
- final selected = await showModalBottomSheet(
- context: context,
- backgroundColor: Colors.transparent,
- builder: (context) {
- return Container(
- decoration: BoxDecoration(
- color: context.conduitTheme.surfaceBackground,
- borderRadius: const BorderRadius.vertical(
- top: Radius.circular(AppBorderRadius.bottomSheet),
- ),
- border: Border.all(
- color: context.conduitTheme.dividerColor,
- width: BorderWidth.regular,
- ),
- boxShadow: ConduitShadows.modal,
- ),
- padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
- child: SafeArea(
- top: false,
- child: ListView.separated(
- shrinkWrap: true,
- itemCount: locales.length,
- separatorBuilder: (_, __) =>
- Divider(height: 1, color: context.conduitTheme.dividerColor),
- itemBuilder: (ctx, i) {
- final l = locales[i];
- final isSelected = l.localeId == _voiceService.selectedLocaleId;
- return ListTile(
- title: Text(
- l.name,
- style: TextStyle(color: context.conduitTheme.textPrimary),
- ),
- subtitle: Text(
- l.localeId,
- style: TextStyle(color: context.conduitTheme.textSecondary),
- ),
- trailing: isSelected
- ? Icon(
- Icons.check,
- color: context.conduitTheme.buttonPrimary,
- )
- : null,
- onTap: () => Navigator.pop(ctx, l.localeId),
- );
- },
- ),
- ),
- );
- },
- );
-
- if (selected != null && mounted) {
- setState(() {
- _voiceService.setLocale(selected);
- _languageTag = selected.split(RegExp('[-_]')).first.toLowerCase();
- });
- await ref.read(appSettingsProvider.notifier).setVoiceLocaleId(selected);
- if (_isListening) {
- await _voiceService.stopListening();
- _startListening();
- }
- }
- }
-
- Widget _buildWaveform({required bool isCompact, required bool isUltra}) {
- final barCount = isUltra ? 10 : 12;
- final base = isUltra ? 4 : (isCompact ? 6 : 8);
- final range = isUltra ? 14 : (isCompact ? 18 : 24);
- return SizedBox(
- height: isUltra ? 18 : (isCompact ? 24 : 32),
- child: AnimatedSwitcher(
- duration: const Duration(milliseconds: 150),
- child: Row(
- key: ValueKey(_intensity),
- mainAxisAlignment: MainAxisAlignment.center,
- children: List.generate(barCount, (i) {
- final normalized = ((_intensity + i) % 10) / 10.0;
- final barHeight = base + (normalized * range);
- return Container(
- width: isUltra ? 2.5 : (isCompact ? 3 : 4),
- height: barHeight,
- margin: EdgeInsets.symmetric(
- horizontal: isUltra ? 1 : (isCompact ? 1.5 : 2),
- ),
- decoration: BoxDecoration(
- color: context.conduitTheme.buttonPrimary.withValues(
- alpha: 0.7,
- ),
- borderRadius: BorderRadius.circular(2),
- ),
- );
- }),
- ),
- ),
- );
- }
-
- // Mode selector removed for simplicity
-
- @override
- Widget build(BuildContext context) {
- final media = MediaQuery.of(context);
- final isCompact = media.size.height < 680;
-
- return Container(
- height: media.size.height * (isCompact ? 0.45 : 0.6),
- decoration: BoxDecoration(
- color: context.conduitTheme.surfaceBackground,
- borderRadius: const BorderRadius.vertical(
- top: Radius.circular(AppBorderRadius.bottomSheet),
- ),
- border: Border.all(color: context.conduitTheme.dividerColor, width: 1),
- boxShadow: ConduitShadows.modal,
- ),
- child: SafeArea(
- top: false,
- bottom: true,
- child: Padding(
- padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
- child: Column(
- children: [
- const SheetHandle(),
- Padding(
- padding: const EdgeInsets.only(
- top: Spacing.md,
- bottom: Spacing.md,
- ),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- _isTranscribing
- ? AppLocalizations.of(context)!.transcribing
- : _isListening
- ? (_voiceService.hasLocalStt
- ? AppLocalizations.of(context)!.listening
- : AppLocalizations.of(context)!.recording)
- : AppLocalizations.of(context)!.voiceInput,
- style: TextStyle(
- fontSize: AppTypography.headlineMedium,
- fontWeight: FontWeight.w600,
- color: context.conduitTheme.textPrimary,
- ),
- ),
- Row(
- children: [
- GestureDetector(
- onTap: _voiceService.hasLocalStt
- ? _pickLanguage
- : null,
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: Spacing.xs,
- vertical: 4,
- ),
- decoration: BoxDecoration(
- color: context.conduitTheme.surfaceBackground
- .withValues(alpha: 0.4),
- borderRadius: BorderRadius.circular(
- AppBorderRadius.badge,
- ),
- border: Border.all(
- color: context.conduitTheme.dividerColor,
- width: BorderWidth.thin,
- ),
- ),
- child: Row(
- children: [
- Text(
- _languageTag.toUpperCase(),
- style: TextStyle(
- fontSize: AppTypography.labelSmall,
- color: context.conduitTheme.textSecondary,
- fontWeight: FontWeight.w600,
- ),
- ),
- if (_voiceService.hasLocalStt) ...[
- const SizedBox(width: 4),
- Icon(
- Icons.arrow_drop_down,
- size: 16,
- color: context.conduitTheme.iconSecondary,
- ),
- ],
- ],
- ),
- ),
- ),
- const SizedBox(width: Spacing.sm),
- AnimatedOpacity(
- opacity: _isListening ? 1 : 0.6,
- duration: AnimationDuration.fast,
- child: Text(
- _formatSeconds(_elapsedSeconds),
- style: TextStyle(
- color: context.conduitTheme.textSecondary,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- const SizedBox(width: Spacing.sm),
- ConduitIconButton(
- icon: Platform.isIOS
- ? CupertinoIcons.xmark
- : Icons.close,
- tooltip: AppLocalizations.of(
- context,
- )!.closeButtonSemantic,
- isCompact: true,
- onPressed: () => Navigator.of(context).pop(),
- ),
- ],
- ),
- ],
- ),
- ),
-
- // Single-line controls
- Row(
- children: [
- ps.PlatformService.getPlatformSwitch(
- value: _holdToTalk,
- onChanged: (v) async {
- setState(() => _holdToTalk = v);
- await ref
- .read(appSettingsProvider.notifier)
- .setVoiceHoldToTalk(v);
- if (!_holdToTalk && !_isListening) {
- _startListening();
- }
- if (_holdToTalk && _isListening) {
- _stopListening();
- }
- },
- activeColor: context.conduitTheme.buttonPrimary,
- ),
- const SizedBox(width: Spacing.xs),
- Flexible(
- child: Text(
- AppLocalizations.of(context)!.holdToTalk,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: context.conduitTheme.textSecondary,
- ),
- ),
- ),
- const SizedBox(width: Spacing.sm),
- ps.PlatformService.getPlatformSwitch(
- value: _autoSendFinal,
- onChanged: (v) async {
- setState(() => _autoSendFinal = v);
- await ref
- .read(appSettingsProvider.notifier)
- .setVoiceAutoSendFinal(v);
- },
- activeColor: context.conduitTheme.buttonPrimary,
- ),
- const SizedBox(width: Spacing.xs),
- Flexible(
- child: Text(
- AppLocalizations.of(context)!.autoSend,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: context.conduitTheme.textSecondary,
- ),
- ),
- ),
- ],
- ),
-
- Expanded(
- child: LayoutBuilder(
- builder: (context, viewport) {
- final isUltra = media.size.height < 560;
- final double micSize = isUltra
- ? 72
- : (isCompact ? 88 : 104);
- final double micIconSize = isUltra
- ? 28
- : (isCompact ? 34 : 40);
- final double topPaddingForScale =
- ((micSize * 1.2) - micSize) / 2 + 8;
-
- final content = Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- SizedBox(height: isUltra ? Spacing.sm : Spacing.md),
- GestureDetector(
- onTapDown: _holdToTalk
- ? (_) {
- if (!_isListening) _startListening();
- }
- : null,
- onTapUp: _holdToTalk
- ? (_) {
- if (_isListening) _stopListening();
- }
- : null,
- onTapCancel: _holdToTalk
- ? () {
- if (_isListening) _stopListening();
- }
- : null,
- onTap: () => _holdToTalk
- ? null
- : (_isListening
- ? _stopListening()
- : _startListening()),
- child: Semantics(
- button: true,
- label: _isListening
- ? AppLocalizations.of(context)!.stopListening
- : AppLocalizations.of(
- context,
- )!.startListening,
- child: Stack(
- alignment: Alignment.center,
- children: [
- AnimatedContainer(
- duration: const Duration(milliseconds: 120),
- width:
- micSize + (_intensity * 2).toDouble(),
- height:
- micSize + (_intensity * 2).toDouble(),
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- boxShadow: _isListening
- ? [
- BoxShadow(
- color: context
- .conduitTheme
- .buttonPrimary
- .withValues(alpha: 0.25),
- blurRadius:
- 24 + _intensity.toDouble(),
- spreadRadius: 2,
- ),
- ]
- : null,
- ),
- ),
- // Middle ring removed for simpler look
- Container(
- width: micSize,
- height: micSize,
- decoration: BoxDecoration(
- color: _isListening
- ? context.conduitTheme.buttonPrimary
- .withValues(alpha: 0.15)
- : context
- .conduitTheme
- .surfaceBackground
- .withValues(
- alpha: Alpha.subtle,
- ),
- shape: BoxShape.circle,
- border: Border.all(
- color: _isListening
- ? context.conduitTheme.buttonPrimary
- : context.conduitTheme.dividerColor,
- width: 2,
- ),
- ),
- child: Icon(
- _isListening
- ? (Platform.isIOS
- ? CupertinoIcons.mic_fill
- : Icons.mic)
- : (Platform.isIOS
- ? CupertinoIcons.mic_off
- : Icons.mic_off),
- size: micIconSize,
- color: _isListening
- ? context.conduitTheme.buttonPrimary
- : context.conduitTheme.iconSecondary,
- ),
- ),
- ],
- ),
- ),
- ),
- const SizedBox(height: Spacing.sm),
- _buildWaveform(
- isCompact: isCompact,
- isUltra: isUltra,
- ),
- SizedBox(
- height: isUltra
- ? Spacing.sm
- : (isCompact ? Spacing.md : Spacing.xl),
- ),
- ConstrainedBox(
- constraints: BoxConstraints(
- maxHeight:
- media.size.height *
- (isUltra ? 0.13 : (isCompact ? 0.16 : 0.2)),
- minHeight: isUltra ? 56 : (isCompact ? 64 : 80),
- ),
- child: ConduitCard(
- isCompact: isCompact,
- padding: EdgeInsets.all(
- isCompact ? Spacing.md : Spacing.md,
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Row(
- children: [
- Text(
- AppLocalizations.of(
- context,
- )!.transcript,
- style: TextStyle(
- fontSize: AppTypography.labelSmall,
- fontWeight: FontWeight.w600,
- color: context
- .conduitTheme
- .textSecondary,
- ),
- ),
- const Spacer(),
- ConduitIconButton(
- icon: Icons.close,
- isCompact: true,
- tooltip: AppLocalizations.of(
- context,
- )!.clear,
- onPressed:
- _recognizedText.isNotEmpty &&
- !_isTranscribing
- ? () => setState(
- () => _recognizedText = '',
- )
- : null,
- ),
- ],
- ),
- const SizedBox(height: Spacing.xs),
- if (_isTranscribing)
- Center(
- child: Row(
- mainAxisAlignment:
- MainAxisAlignment.center,
- children: [
- ConduitLoadingIndicator(
- size: isUltra
- ? 14
- : (isCompact ? 16 : 18),
- isCompact: true,
- ),
- const SizedBox(width: Spacing.xs),
- Text(
- AppLocalizations.of(
- context,
- )!.transcribing,
- style: TextStyle(
- fontSize: isUltra
- ? 12
- : (isCompact ? 12 : 13),
- ),
- ),
- ],
- ),
- )
- else
- Flexible(
- child: SingleChildScrollView(
- child: Text(
- _recognizedText.isEmpty
- ? (_isListening
- ? (_voiceService.hasLocalStt
- ? AppLocalizations.of(
- context,
- )!.speakNow
- : AppLocalizations.of(
- context,
- )!.recording)
- : AppLocalizations.of(
- context,
- )!.typeBelowToBegin)
- : _recognizedText,
- style: TextStyle(
- fontSize: isUltra
- ? AppTypography.bodySmall
- : (isCompact
- ? AppTypography.bodyMedium
- : AppTypography
- .bodyLarge),
- color: _recognizedText.isEmpty
- ? context
- .conduitTheme
- .inputPlaceholder
- : context
- .conduitTheme
- .textPrimary,
- height: 1.4,
- ),
- textAlign: TextAlign.center,
- ),
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
- );
-
- return SingleChildScrollView(
- physics: const ClampingScrollPhysics(),
- padding: EdgeInsets.only(top: topPaddingForScale),
- child: ConstrainedBox(
- constraints: BoxConstraints(
- minHeight: viewport.maxHeight,
- ),
- child: content,
- ),
- );
- },
- ),
- ),
-
- const SizedBox(height: Spacing.md),
- Row(
- children: [
- Expanded(
- child: ConduitButton(
- text: _isListening
- ? AppLocalizations.of(context)!.stop
- : AppLocalizations.of(context)!.start,
- isSecondary: true,
- isCompact: isCompact,
- onPressed: _isListening
- ? _stopListening
- : _startListening,
- ),
- ),
- const SizedBox(width: Spacing.xs),
- Expanded(
- child: ConduitButton(
- text: AppLocalizations.of(context)!.send,
- isCompact: isCompact,
- onPressed: _recognizedText.isNotEmpty ? _sendText : null,
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- );
- }
-}
diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart
index 71511a5..5aaa23b 100644
--- a/lib/features/navigation/widgets/chats_drawer.dart
+++ b/lib/features/navigation/widgets/chats_drawer.dart
@@ -14,6 +14,7 @@ import '../../profile/views/profile_page.dart';
import '../../../shared/utils/ui_utils.dart';
import '../../../core/auth/auth_state_manager.dart';
import 'package:conduit/l10n/app_localizations.dart';
+import '../../../core/models/user.dart' as models;
class ChatsDrawer extends ConsumerStatefulWidget {
const ChatsDrawer({super.key});
@@ -1115,7 +1116,59 @@ class _ChatsDrawerState extends ConsumerState {
Widget _buildBottomSection(BuildContext context) {
final theme = context.conduitTheme;
- final user = ref.watch(authUserProvider);
+ final currentUserAsync = ref.watch(currentUserProvider);
+ final userFromProfile = currentUserAsync.maybeWhen(
+ data: (u) => u,
+ orElse: () => null,
+ );
+ final dynamic authUser = ref.watch(authUserProvider);
+ final user = userFromProfile ?? authUser;
+ String _displayName(dynamic u) {
+ if (u == null) return 'User';
+ if (u is models.User) {
+ return (u.name?.isNotEmpty == true ? u.name : u.username) ?? 'User';
+ }
+ if (u is Map) {
+ final Map m = u;
+ String? _asString(dynamic v) =>
+ v is String && v.trim().isNotEmpty ? v.trim() : null;
+ String? _pick(Map source) {
+ return _asString(source['name']) ??
+ _asString(source['display_name']) ??
+ _asString(source['preferred_username']) ??
+ _asString(source['username']);
+ }
+
+ final top = _pick(m);
+ if (top != null) return top;
+ final nestedUser = m['user'];
+ if (nestedUser is Map) {
+ final nested = _pick(nestedUser);
+ if (nested != null) return nested;
+ final nestedEmail = _asString(nestedUser['email']);
+ if (nestedEmail != null && nestedEmail.contains('@')) {
+ return nestedEmail.split('@').first;
+ }
+ }
+ final email = _asString(m['email']);
+ if (email != null && email.contains('@')) {
+ return email.split('@').first;
+ }
+ return 'User';
+ }
+ // Fallback to string representation if some other type
+ final s = u.toString();
+ return s.isNotEmpty ? s : 'User';
+ }
+
+ String _initial(String name) {
+ if (name.isEmpty) return 'U';
+ final ch = name.characters.first;
+ return ch.toUpperCase();
+ }
+
+ final displayName = _displayName(user);
+ final initial = _initial(displayName);
return Padding(
padding: const EdgeInsets.fromLTRB(Spacing.sm, 0, Spacing.sm, Spacing.sm),
child: Column(
@@ -1150,10 +1203,7 @@ class _ChatsDrawerState extends ConsumerState {
),
alignment: Alignment.center,
child: Text(
- (user.name ?? user.username ?? 'U')
- .toString()
- .substring(0, 1)
- .toUpperCase(),
+ initial,
style: AppTypography.bodyLargeStyle.copyWith(
color: theme.buttonPrimary,
fontWeight: FontWeight.w700,
@@ -1166,7 +1216,7 @@ class _ChatsDrawerState extends ConsumerState {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
- (user.name ?? user.username ?? 'User').toString(),
+ displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.bodyLargeStyle.copyWith(