refactor: voice input

This commit is contained in:
cogwheel0
2025-08-25 10:35:48 +05:30
parent e1ee94f2f3
commit a930a7a466
6 changed files with 453 additions and 838 deletions

View File

@@ -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), () {

View File

@@ -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: () =>

View File

@@ -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,

View File

@@ -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,
),
),
],
),
],
),
),
),
);
}
}