Files
xianyan/lib/shared/widgets/plugin/voice_translate_sheet.dart
Developer 3e68f7dc2a chore: 完成v5.10.0版本迭代更新
此版本包含多项功能优化与修复:
1. 新增鸿蒙分层图标生成脚本,完善鸿蒙应用图标适配
2. 重构多处FutureProvider为NotifierProvider,修复ElementWithFuture异常
3. 更新flutter_tts依赖为鸿蒙适配版本,调整pubspec配置
4. 优化运势卡片样式文案,更新引导页功能介绍详情
5. 修复在线TTS服务Path正则匹配问题,支持含点号的路径
6. 重构通知权限、崩溃监控等状态管理逻辑
7. 更新翻译覆盖率统计,支持手动标注真实翻译进度
8. 优化编辑器工具栏、会话流页面交互细节
9. 新增日志筛选、导出CSV等增强功能
10. 调整设置页面文案,优化用户操作体验
2026-05-26 08:24:44 +08:00

446 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言APP — 语音翻译底部弹窗
/// 创建时间: 2026-05-25
/// 更新时间: 2026-05-26
/// 作用: 语音输入自动翻译基于SpeechService + 翻译引擎
/// 上次更新: 迁移showCupertinoModalPopup到AppBottomSheet.showActions
/// ============================================================
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xianyan/core/theme/app_theme.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
import 'package:xianyan/core/theme/app_radius.dart';
import 'package:xianyan/core/theme/app_typography.dart';
import 'package:xianyan/core/utils/logger.dart';
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
import 'package:xianyan/core/services/input/speech_service.dart';
import 'package:xianyan/features/mine/settings/providers/plugin_provider.dart';
import 'package:xianyan/features/mine/settings/providers/translate_engine_provider.dart';
import 'package:xianyan/shared/widgets/containers/bottom_sheet.dart';
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
// ============================================================
// 语音翻译弹窗
// ============================================================
class VoiceTranslateSheet extends ConsumerStatefulWidget {
const VoiceTranslateSheet({super.key});
/// 弹出语音翻译弹窗
static Future<void> show(BuildContext context) {
return AppBottomSheet.showCustom(
context: context,
builder: (ctx) => const VoiceTranslateSheet(),
);
}
@override
ConsumerState<VoiceTranslateSheet> createState() =>
_VoiceTranslateSheetState();
}
// ============================================================
// 语音翻译状态
// ============================================================
class _VoiceTranslateSheetState extends ConsumerState<VoiceTranslateSheet> {
final SpeechService _speech = SpeechService.instance;
bool _isAvailable = false;
bool _isListening = false;
String _recognizedText = '';
String _translatedText = '';
bool _isTranslating = false;
String _selectedLocale = 'zh_CN';
StreamSubscription<SpeechServiceState>? _stateSub;
StreamSubscription<SpeechResult>? _resultSub;
StreamSubscription<String>? _errorSub;
@override
void initState() {
super.initState();
_initSpeech();
}
// ============================================================
// 初始化语音服务
// ============================================================
Future<void> _initSpeech() async {
_stateSub = _speech.onStateChanged.listen(_onStateChanged);
_resultSub = _speech.onResult.listen(_onSpeechResult);
_errorSub = _speech.onError.listen(_onSpeechError);
if (_speech.isAvailable) {
_isAvailable = true;
setState(() {});
return;
}
final available = await _speech.init();
if (mounted) {
setState(() => _isAvailable = available);
}
}
void _onStateChanged(SpeechServiceState state) {
if (!mounted) return;
setState(() {
_isListening = state == SpeechServiceState.listening;
});
}
void _onSpeechResult(SpeechResult result) {
if (!mounted) return;
setState(() {
_recognizedText = result.text;
});
if (result.isFinal && result.text.isNotEmpty) {
_translateRecognizedText();
}
}
void _onSpeechError(String error) {
if (!mounted) return;
Log.w('语音识别错误: $error');
setState(() => _isListening = false);
}
// ============================================================
// 语音控制
// ============================================================
void _startListening() {
if (!_isAvailable) {
AppToast.showWarning('语音识别不可用');
return;
}
setState(() {
_recognizedText = '';
_translatedText = '';
});
_speech.startListening(localeId: _selectedLocale);
}
void _stopListening() {
_speech.stopListening();
setState(() => _isListening = false);
if (_recognizedText.isNotEmpty) {
_translateRecognizedText();
}
}
// ============================================================
// 翻译逻辑
// ============================================================
Future<void> _translateRecognizedText() async {
if (_recognizedText.isEmpty) return;
setState(() => _isTranslating = true);
try {
final pluginState = ref.read(pluginProvider);
final engines = ref.read(translateEngineRegistryProvider);
final engineId = pluginState.translateEngineId;
final engine = engines[engineId];
if (engine == null) {
setState(() {
_translatedText = '翻译引擎不可用';
_isTranslating = false;
});
return;
}
final result = await engine.translate(
text: _recognizedText,
targetLang: pluginState.translateTargetLang,
sourceLang: pluginState.autoDetectLang ? 'auto' : 'zh',
);
setState(() {
_translatedText = result.translatedText;
_isTranslating = false;
});
} catch (e) {
Log.e('语音翻译失败: $e');
setState(() {
_translatedText = '翻译失败: $e';
_isTranslating = false;
});
}
}
// ============================================================
// 语言选择
// ============================================================
void _showLocalePicker() {
AppBottomSheet.showActions<void>(
context: context,
title: '选择识别语言',
options: [
BottomSheetOption(
title: '🇨🇳 中文',
icon: CupertinoIcons.flag_fill,
onTap: () {
setState(() => _selectedLocale = 'zh_CN');
_speech.setLocale('zh_CN');
},
),
BottomSheetOption(
title: '🇺🇸 English',
icon: CupertinoIcons.flag,
onTap: () {
setState(() => _selectedLocale = 'en_US');
_speech.setLocale('en_US');
},
),
],
);
}
@override
void dispose() {
_stateSub?.cancel();
_resultSub?.cancel();
_errorSub?.cancel();
if (_isListening) {
_speech.cancelListening();
}
super.dispose();
}
AppThemeExtension get ext => AppTheme.ext(context);
// ============================================================
// UI构建
// ============================================================
@override
Widget build(BuildContext context) {
final pluginState = ref.watch(pluginProvider);
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
decoration: BoxDecoration(
color: ext.bgPrimary,
borderRadius: const BorderRadius.vertical(top: Radius.circular(36)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildTitle(pluginState),
Divider(height: 0.5, color: ext.textHint.withValues(alpha: 0.15)),
// 麦克风按钮
_buildMicButton(),
// 识别结果
if (_recognizedText.isNotEmpty || _isListening)
_buildRecognizedSection(),
const SizedBox(height: AppSpacing.md),
// 翻译结果
if (_isTranslating)
const Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.md),
child: CupertinoActivityIndicator(),
)
else if (_translatedText.isNotEmpty)
_buildTranslatedSection(pluginState),
// Web平台警告
if (pu.isWeb) _buildWebWarning(),
SizedBox(height: MediaQuery.of(context).padding.bottom + 8),
],
),
);
}
// ============================================================
// 子组件
// ============================================================
Widget _buildTitle(PluginState pluginState) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: Row(
children: [
Text(
'🎙️ 语音翻译',
style: AppTypography.title3.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: Size.zero,
onPressed: _showLocalePicker,
child: Text(
_selectedLocale == 'zh_CN' ? '🇨🇳 中文' : '🇺🇸 EN',
style: AppTypography.subhead.copyWith(color: ext.accent),
),
),
],
),
);
}
Widget _buildMicButton() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg),
child: GestureDetector(
onTap: _isListening ? _stopListening : _startListening,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isListening ? ext.destructiveColor : ext.accent,
boxShadow: _isListening
? [
BoxShadow(
color: ext.destructiveColor.withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 5,
),
]
: null,
),
child: Icon(
_isListening ? CupertinoIcons.stop_fill : CupertinoIcons.mic_fill,
size: 32,
color: CupertinoColors.white,
),
),
),
);
}
Widget _buildRecognizedSection() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'识别结果',
style: AppTypography.caption1.copyWith(
color: ext.textHint,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.xs),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.08),
borderRadius: AppRadius.mdBorder,
),
child: Text(
_recognizedText.isEmpty ? '正在聆听...' : _recognizedText,
style: AppTypography.body.copyWith(color: ext.textPrimary),
),
),
],
),
);
}
Widget _buildTranslatedSection(PluginState pluginState) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'翻译结果 (${pluginState.translateTargetLang})',
style: AppTypography.caption1.copyWith(
color: ext.textHint,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.xs),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.mdBorder,
),
child: Row(
children: [
Expanded(
child: Text(
_translatedText,
style: AppTypography.body.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
),
CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () {
Clipboard.setData(ClipboardData(text: _translatedText));
AppToast.showSuccess('已复制');
},
child: Icon(
CupertinoIcons.doc_on_clipboard,
size: 18,
color: ext.accent,
),
),
],
),
),
],
),
);
}
Widget _buildWebWarning() {
return Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.destructiveColor.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: Row(
children: [
Icon(
CupertinoIcons.info_circle,
size: 18,
color: ext.destructiveColor,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'Web平台暂不支持语音识别',
style: AppTypography.caption1.copyWith(
color: ext.destructiveColor,
),
),
),
],
),
),
);
}
}