Files
voidea/flutter/lib/features/voice/screens/voice_input_screen.dart
T

238 lines
7.5 KiB
Dart

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<VoiceInputScreen> createState() => _VoiceInputScreenState();
}
class _VoiceInputScreenState extends ConsumerState<VoiceInputScreen>
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<void> _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<void> _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<void> _stopListening() async {
await _speech.stop();
if (_recognizedText.isNotEmpty) {
setState(() => _voiceState = VoiceState.processing);
await _sendToServer();
} else {
setState(() => _voiceState = VoiceState.idle);
}
}
Future<void> _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);
}
}