import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:speech_to_text/speech_to_text.dart'; import 'dart:async'; import '../../../core/theme/app_theme.dart'; import '../../../core/api/client.dart'; import '../../../core/api/endpoints.dart'; import '../../../core/constants/app_constants.dart'; enum VoiceState { idle, listening, processing, error } class VoiceInputScreen extends ConsumerStatefulWidget { const VoiceInputScreen({super.key}); @override ConsumerState createState() => _VoiceInputScreenState(); } class _VoiceInputScreenState extends ConsumerState with SingleTickerProviderStateMixin { final SpeechToText _speech = SpeechToText(); VoiceState _voiceState = VoiceState.idle; String _recognizedText = ''; String? _errorMessage; bool _available = false; late AnimationController _animController; @override void initState() { super.initState(); _animController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(reverse: true); _initSpeech(); } @override void dispose() { _animController.dispose(); _speech.stop(); super.dispose(); } Future _initSpeech() async { _available = await _speech.initialize( onError: (error) => setState(() { _voiceState = VoiceState.error; _errorMessage = error.errorMsg; }), onStatus: (status) { if (status == 'done' || status == 'notListening') { if (_voiceState == VoiceState.listening) { _stopListening(); } } }, ); if (!_available && mounted) { setState(() { _errorMessage = 'Распознавание речи недоступно на этом устройстве'; }); } } Future _startListening() async { if (!_available) return; setState(() { _voiceState = VoiceState.listening; _recognizedText = ''; _errorMessage = null; }); await _speech.listen( onResult: (result) { setState(() => _recognizedText = result.recognizedWords); }, listenFor: const Duration(seconds: AppConstants.maxVoiceDurationSeconds), pauseFor: const Duration(seconds: 3), partialResults: true, localeId: 'ru_RU', ); } Future _stopListening() async { await _speech.stop(); if (_recognizedText.isNotEmpty) { setState(() => _voiceState = VoiceState.processing); await _sendToServer(); } else { setState(() => _voiceState = VoiceState.idle); } } Future _sendToServer() async { try { final api = ref.read(apiClientProvider); final response = await api.post( Endpoints.voiceTranscribe, data: {'text': _recognizedText}, ); if (mounted) { setState(() { _voiceState = VoiceState.idle; if (response.data['response'] != null) { _recognizedText = response.data['response'] as String; } }); } } catch (e) { if (mounted) { setState(() { _voiceState = VoiceState.error; _errorMessage = 'Ошибка отправки'; }); } } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Голосовой ввод'), actions: [ IconButton( icon: const Icon(Icons.history), onPressed: () => context.push('/voice/sessions'), ), ], ), body: Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ GestureDetector( onTap: _voiceState == VoiceState.listening ? _stopListening : _voiceState == VoiceState.idle ? _startListening : null, child: _PulseAnimation( listenable: _animController, builder: (context, child) { final scale = _voiceState == VoiceState.listening ? 1.0 + (_animController.value * 0.15) : 1.0; return Transform.scale( scale: scale, child: Container( width: 120, height: 120, decoration: BoxDecoration( shape: BoxShape.circle, color: _voiceState == VoiceState.listening ? VoIdeaColors.error.withOpacity(0.1) : VoIdeaColors.primary.withOpacity(0.1), border: Border.all( color: _voiceState == VoiceState.listening ? VoIdeaColors.error : VoIdeaColors.primary, width: 3, ), ), child: Icon( _voiceState == VoiceState.listening ? Icons.mic : _voiceState == VoiceState.processing ? Icons.hourglass_top : Icons.mic_none, size: 48, color: _voiceState == VoiceState.listening ? VoIdeaColors.error : VoIdeaColors.primary, ), ), ); }, ), ), const SizedBox(height: 32), Text( _voiceState == VoiceState.idle ? 'Нажмите для начала записи' : _voiceState == VoiceState.listening ? 'Говорите...' : _voiceState == VoiceState.processing ? 'Обработка...' : 'Ошибка', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 24), if (_recognizedText.isNotEmpty) Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: VoIdeaColors.surface, borderRadius: BorderRadius.circular(12), ), child: Text( _recognizedText, style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, ), ), if (_errorMessage != null) Padding( padding: const EdgeInsets.only(top: 16), child: Text( _errorMessage!, style: const TextStyle(color: VoIdeaColors.error), textAlign: TextAlign.center, ), ), ], ), ), ), ); } } class _PulseAnimation extends AnimatedWidget { final Widget Function(BuildContext context, Widget? child) builder; const _PulseAnimation({ required super.listenable, required this.builder, }); @override Widget build(BuildContext context) { return builder(context, null); } }