此版本包含多项功能优化与修复: 1. 新增鸿蒙分层图标生成脚本,完善鸿蒙应用图标适配 2. 重构多处FutureProvider为NotifierProvider,修复ElementWithFuture异常 3. 更新flutter_tts依赖为鸿蒙适配版本,调整pubspec配置 4. 优化运势卡片样式文案,更新引导页功能介绍详情 5. 修复在线TTS服务Path正则匹配问题,支持含点号的路径 6. 重构通知权限、崩溃监控等状态管理逻辑 7. 更新翻译覆盖率统计,支持手动标注真实翻译进度 8. 优化编辑器工具栏、会话流页面交互细节 9. 新增日志筛选、导出CSV等增强功能 10. 调整设置页面文案,优化用户操作体验
446 lines
13 KiB
Dart
446 lines
13 KiB
Dart
/// ============================================================
|
||
/// 闲言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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|