refactor: voice input
This commit is contained in:
@@ -12,6 +12,12 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.speech.RecognitionService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:label="Conduit"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -34,8 +34,10 @@ class VoiceInputService {
|
||||
// Prepare local speech recognizer
|
||||
try {
|
||||
_speech = stt.SpeechToText();
|
||||
debugPrint('DEBUG: Initializing speech_to_text...');
|
||||
_localSttAvailable = await _speech!.initialize(
|
||||
onStatus: (status) {
|
||||
debugPrint('DEBUG: SpeechToText status: $status');
|
||||
// When platform end-of-speech triggers, ensure we stop timer/streams
|
||||
if (status.toLowerCase().contains('notListening') ||
|
||||
status.toLowerCase().contains('done')) {
|
||||
@@ -43,18 +45,35 @@ class VoiceInputService {
|
||||
}
|
||||
},
|
||||
onError: (SpeechRecognitionError error) {
|
||||
debugPrint('DEBUG: SpeechToText error: ${error.errorMsg}');
|
||||
debugPrint('DEBUG: SpeechToText error permanent: ${error.permanent}');
|
||||
// If error is permanent, mark local STT as unavailable
|
||||
if (error.permanent) {
|
||||
debugPrint('DEBUG: Permanent error detected, disabling local STT');
|
||||
_localSttAvailable = false;
|
||||
}
|
||||
// If any error, we keep fallback available; no throws here.
|
||||
},
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: SpeechToText initialization result: $_localSttAvailable',
|
||||
);
|
||||
if (_localSttAvailable) {
|
||||
try {
|
||||
_locales = await _speech!.locales();
|
||||
debugPrint(
|
||||
'DEBUG: Available locales: ${_locales.map((l) => 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<bool> 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<String> 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<stt.LocaleName> get locales => _locales;
|
||||
|
||||
@@ -107,8 +205,36 @@ class VoiceInputService {
|
||||
_isListening = true;
|
||||
_intensityController = StreamController<int>.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), () {
|
||||
|
||||
@@ -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<ChatPage> {
|
||||
}
|
||||
}
|
||||
|
||||
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<ChatPage> {
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
toolbarHeight: kToolbarHeight,
|
||||
centerTitle: true,
|
||||
titleSpacing: 0.0,
|
||||
leading: _isSelectionMode
|
||||
? IconButton(
|
||||
@@ -1286,7 +1261,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
(isOnline || ref.watch(reviewerModeProvider)),
|
||||
onSendMessage: (text) =>
|
||||
_handleMessageSend(text, selectedModel),
|
||||
onVoiceInput: _handleVoiceInput,
|
||||
onVoiceInput: null,
|
||||
onFileAttachment: _handleFileAttachment,
|
||||
onImageAttachment: _handleImageAttachment,
|
||||
onCameraCapture: () =>
|
||||
|
||||
@@ -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<ModernChatInput>
|
||||
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<ModernChatInput>
|
||||
late AnimationController _pulseController;
|
||||
Timer? _blurCollapseTimer;
|
||||
bool _hasAutoFocusedOnce = false;
|
||||
late VoiceInputService _voiceService;
|
||||
StreamSubscription<int>? _intensitySub;
|
||||
StreamSubscription<String>? _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<ModernChatInput>
|
||||
_pulseController.dispose();
|
||||
_blurCollapseTimer?.cancel();
|
||||
_voiceStreamSubscription?.cancel();
|
||||
_intensitySub?.cancel();
|
||||
_textSub?.cancel();
|
||||
_voiceService.stopListening();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -201,6 +211,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
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<ModernChatInput>
|
||||
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<ModernChatInput>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inline Voice Input ---
|
||||
Future<void> _toggleVoice() async {
|
||||
if (_isRecording) {
|
||||
await _stopVoice();
|
||||
} else {
|
||||
await _startVoice();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _stopVoice() async {
|
||||
_intensitySub?.cancel();
|
||||
_intensitySub = null;
|
||||
await _voiceService.stopListening();
|
||||
if (!mounted) return;
|
||||
setState(() => _isRecording = false);
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
|
||||
@@ -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<VoiceInputSheet> createState() => _VoiceInputSheetState();
|
||||
}
|
||||
|
||||
class _VoiceInputSheetState extends ConsumerState<VoiceInputSheet> {
|
||||
late final VoiceInputService _voiceService;
|
||||
StreamSubscription<int>? _intensitySub;
|
||||
StreamSubscription<String>? _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<void> _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<void> _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<void> _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<void> _pickLanguage() async {
|
||||
if (!_voiceService.hasLocalStt) return;
|
||||
final locales = _voiceService.locales;
|
||||
if (locales.isEmpty || !mounted) return;
|
||||
final selected = await showModalBottomSheet<String>(
|
||||
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<int>(_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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ChatsDrawer> {
|
||||
|
||||
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<ChatsDrawer> {
|
||||
),
|
||||
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<ChatsDrawer> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
(user.name ?? user.username ?? 'User').toString(),
|
||||
displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTypography.bodyLargeStyle.copyWith(
|
||||
|
||||
Reference in New Issue
Block a user