238 lines
7.5 KiB
Dart
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);
|
|
}
|
|
}
|