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(