feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user