You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1112 lines
40 KiB
1112 lines
40 KiB
import 'dart:async';
|
|
import 'dart:ui';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:haptic_feedback/haptic_feedback.dart';
|
|
import '../controllers/translation_controller.dart';
|
|
import '../models/translation_models.dart';
|
|
import '../models/translation_direction.dart';
|
|
import '../views/translation_history_view.dart';
|
|
import '../bindings/translation_history_binding.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/utils/logger.dart';
|
|
import '../../../routes/app_routes.dart';
|
|
import '../../../core/utils/background_session_notifier.dart';
|
|
import '../../common/widgets/language_selector_bar.dart';
|
|
|
|
// 导入拆分后的组件
|
|
import 'widgets/draggable_floating_button.dart'; // 可拖拽的悬浮按钮
|
|
import 'widgets/translation_item_widget.dart'; // 翻译项组件
|
|
import 'dialogs/language_selection_dialog.dart'; // 语言选择对话框
|
|
import 'dialogs/translation_mode_selector.dart'; // 翻译模式选择器
|
|
import 'dialogs/translation_long_press_menu.dart'; // 长按菜单
|
|
|
|
// import 'package:intl/intl.dart';
|
|
|
|
/// 翻译页退出操作(仅通话翻译且识别中才会弹出)
|
|
enum _TranslationExitAction { continueInBackground, end, cancel }
|
|
|
|
//主页面
|
|
class TranslationView extends GetView<TranslationController> {
|
|
|
|
/// 字幕小窗(PiP)的样式设置。
|
|
///
|
|
/// ⚠️ 这里只有两项,是因为 iOS 的 PiP 就只开放了这么点:
|
|
/// - **小窗实际大小由用户捏合手势决定**,App 无权直接设定。这里的「大小」
|
|
/// 改的是画布宽高比,系统会按新形状重新布局,观感上就是变宽/变窄。
|
|
/// - **透明度做不到透出下面的 App**。iOS 的 PiP 窗口本身不透明,系统没有
|
|
/// 开放任何接口。这个滑块只让卡片底色深浅不同。真要少挡内容就把它调小。
|
|
/// - 小窗上那两颗播放/暂停按钮的**尺寸也不可控**,是系统 chrome,
|
|
/// 它随小窗一起缩放——所以「按钮小一点」等价于「把小窗调小」。
|
|
void _showPipSettings(BuildContext context, bool isDarkMode) {
|
|
Get.bottomSheet(
|
|
Container(
|
|
padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 28.h),
|
|
decoration: BoxDecoration(
|
|
color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('pipSettings'.tr,
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDarkMode ? Colors.white : Colors.black87)),
|
|
SizedBox(height: 12.h),
|
|
_pipSlider(
|
|
label: 'pipSize'.tr,
|
|
isDarkMode: isDarkMode,
|
|
value: () => controller.pipWidthScale.value,
|
|
min: 0.5,
|
|
max: 2.0,
|
|
onChanged: controller.setPipWidthScale,
|
|
),
|
|
_pipSlider(
|
|
label: 'pipOpacity'.tr,
|
|
isDarkMode: isDarkMode,
|
|
value: () => controller.pipOpacity.value,
|
|
min: 0.2,
|
|
max: 1.0,
|
|
onChanged: controller.setPipOpacity,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
isScrollControlled: true,
|
|
);
|
|
}
|
|
|
|
Widget _pipSlider({
|
|
required String label,
|
|
required bool isDarkMode,
|
|
required double Function() value,
|
|
required double min,
|
|
required double max,
|
|
required Future<void> Function(double) onChanged,
|
|
}) {
|
|
return Obx(() => Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 72.w,
|
|
child: Text(label,
|
|
style: TextStyle(
|
|
fontSize: 13.sp,
|
|
color: isDarkMode ? Colors.white70 : Colors.black54)),
|
|
),
|
|
Expanded(
|
|
child: Slider(
|
|
value: value().clamp(min, max),
|
|
min: min,
|
|
max: max,
|
|
onChanged: (v) => onChanged(v),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 44.w,
|
|
child: Text('${(value() * 100).round()}%',
|
|
textAlign: TextAlign.end,
|
|
style: TextStyle(
|
|
fontSize: 12.sp,
|
|
color: isDarkMode ? Colors.white54 : Colors.black45)),
|
|
),
|
|
],
|
|
));
|
|
}
|
|
|
|
const TranslationView({Key? key}) : super(key: key);
|
|
|
|
// 本地交互状态:当前显示操作按钮的消息时间戳(不改动控制器)
|
|
static final Rx<DateTime?> _activeActionTimestamp = Rx<DateTime?>(null);
|
|
|
|
// 切换当前操作按钮展示的项(再次点击同一项则隐藏)
|
|
void _toggleActiveActionItem(TranslationItem item) {
|
|
final DateTime ts = item.timestamp;
|
|
final DateTime? current = _activeActionTimestamp.value;
|
|
_activeActionTimestamp.value =
|
|
(current != null && current.isAtSameMomentAs(ts)) ? null : ts;
|
|
}
|
|
|
|
void _deleteControllerAfterPop() {
|
|
Future.delayed(const Duration(milliseconds: 400), () {
|
|
if (Get.isRegistered<TranslationController>()) {
|
|
Get.delete<TranslationController>(force: true);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 通话翻译退出确认弹窗:识别中点返回(而不是点结束)时弹,
|
|
/// 「后台继续」不停识别、也不删这个 permanent controller。
|
|
Future<_TranslationExitAction?> _showExitConfirmDialog(bool isDarkMode) {
|
|
return Get.dialog<_TranslationExitAction>(
|
|
AlertDialog(
|
|
title: Text('translationCallExitTitle'.tr),
|
|
content: Text('translationCallExitMessage'.tr),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () =>
|
|
Get.back(result: _TranslationExitAction.cancel),
|
|
child: Text('cancel'.tr),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Get.back(result: _TranslationExitAction.end),
|
|
child: Text('translationCallEnd'.tr),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Get.back(
|
|
result: _TranslationExitAction.continueInBackground,
|
|
),
|
|
child: Text('translationContinueInBackground'.tr),
|
|
),
|
|
],
|
|
),
|
|
barrierDismissible: true,
|
|
);
|
|
}
|
|
|
|
/// 停止翻译,但**绝不让返回键卡死**。
|
|
///
|
|
/// ⚠️ 这个页面是 `PopScope(canPop: false)`,返回只能由这里的 `Get.back()` 放行。
|
|
/// 原来直接 `await controller.stopAll()`,而这条链末端是一串原生往返
|
|
/// (关 ASR 连接、收音频引擎、停录屏接收、归档),任何一处不返回,
|
|
/// 返回键就变成永久空操作 —— 用户的说法是「退不出去」。
|
|
/// 现在给它一个上限:超时就先退出去,收尾让它在后台继续跑完。
|
|
static const Duration _stopAllMaxWait = Duration(seconds: 3);
|
|
|
|
Future<void> _stopAllSafely() async {
|
|
try {
|
|
await controller.stopAll().timeout(_stopAllMaxWait);
|
|
} on TimeoutException {
|
|
Logger.w('Translation',
|
|
'停止翻译超过 ${_stopAllMaxWait.inSeconds}s 仍未返回,先退出页面,收尾继续在后台进行');
|
|
} catch (e) {
|
|
Logger.e('Translation', '停止翻译失败(忽略,继续退出): $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _handlePop(bool isDarkMode) async {
|
|
// 只有通话翻译在识别中才需要问;其它模式、或通话翻译还没开始识别,
|
|
// 跟以前一样直接停止退出。
|
|
final isActiveCall = controller.currentMode.value == 'call' &&
|
|
(controller.isRecognizing.value || controller.isPreparing.value);
|
|
if (!isActiveCall) {
|
|
await _stopAllSafely();
|
|
Get.back();
|
|
_deleteControllerAfterPop();
|
|
return;
|
|
}
|
|
|
|
final action = await _showExitConfirmDialog(isDarkMode);
|
|
switch (action) {
|
|
case _TranslationExitAction.end:
|
|
await _stopAllSafely();
|
|
Get.back();
|
|
_deleteControllerAfterPop();
|
|
return;
|
|
case _TranslationExitAction.continueInBackground:
|
|
final route = Get.currentRoute;
|
|
final arguments = Get.arguments as Map<String, dynamic>? ??
|
|
{'translationId': 'call'};
|
|
Get.back();
|
|
unawaited(BackgroundSessionNotifier.show(
|
|
id: kCallTranslationNotificationId,
|
|
title: 'callTranslation'.tr,
|
|
body: 'backgroundTranslationIndicatorBody'.tr,
|
|
route: route,
|
|
arguments: arguments,
|
|
));
|
|
return;
|
|
case _TranslationExitAction.cancel:
|
|
case null:
|
|
return;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// 从后台通知/其它页面点回来了,之前挂着的"还在后台翻译"提示可以撤了。
|
|
if (controller.currentMode.value == 'call') {
|
|
unawaited(BackgroundSessionNotifier.cancel(kCallTranslationNotificationId));
|
|
}
|
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return Obx(() {
|
|
// 面对面翻译原来是上下分屏 + 两个「按住说话」按钮,
|
|
// 现在与同声翻译统一:同一套 Scaffold、同一个开始/停止按钮,
|
|
// 左右语言胶囊分别代表左耳 / 右耳。
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (didPop) return;
|
|
await _handlePop(isDarkMode);
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor:
|
|
isDarkMode ? AppColors.darkBackground : Colors.grey[50],
|
|
appBar: _buildAppBar(context, isDarkMode),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: Stack(
|
|
children: [
|
|
_buildBody(isDarkMode),
|
|
// 可拖拽的悬浮按钮
|
|
// DraggableFloatingButton(
|
|
// isDarkMode: isDarkMode,
|
|
// controller: controller,
|
|
// ),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
|
|
|
|
|
|
PreferredSizeWidget _buildAppBar(BuildContext context, bool isDarkMode) {
|
|
return AppBar(
|
|
backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white,
|
|
elevation: 1,
|
|
leading: IconButton(
|
|
icon: Icon(
|
|
Icons.arrow_back_ios,
|
|
color: isDarkMode ? Colors.white : Colors.black,
|
|
size: 20.sp,
|
|
),
|
|
// 走 maybePop 而不是直接 stopAll()+Get.back(),让上面的 PopScope
|
|
// 统一拦截判断:识别中需要弹「后台继续/结束」确认。
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
),
|
|
title: Obx(() => GestureDetector(
|
|
onTap: () {
|
|
TranslationModeSelector.show(isDarkMode);
|
|
},
|
|
child: Text(
|
|
controller.currentModeTitle.value.tr,
|
|
style: TextStyle(
|
|
fontSize: 18.sp,
|
|
fontWeight: FontWeight.w600,
|
|
color: isDarkMode ? Colors.white : Colors.black,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
)),
|
|
centerTitle: true,
|
|
actions: [
|
|
Obx(() {
|
|
if (controller.isRecording.value) {
|
|
// 录音中:显示红色计时器样式,点击可停止录音
|
|
return Padding(
|
|
padding: EdgeInsets.only(right: 16.w),
|
|
child: GestureDetector(
|
|
onTap: controller.toggleisRecord, // 点击结束录音
|
|
child: Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red,
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.fiber_manual_record,
|
|
color: Colors.white,
|
|
size: 18.sp,
|
|
),
|
|
SizedBox(width: 4.w),
|
|
Text(
|
|
controller.recordDurationText.value,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 14.sp,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
// 未录音:显示红色录音按钮 + 文件夹按钮 + 悬浮窗按钮
|
|
return Row(
|
|
children: [
|
|
// 悬浮窗切换按钮(仅在支持的模式下显示)
|
|
Obx(() {
|
|
if (controller.currentMode.value == 'call' ||
|
|
controller.currentMode.value == 'audioVideo') {
|
|
return IconButton(
|
|
icon: Icon(
|
|
controller.isFloatingWindowEnabled.value
|
|
? Icons.picture_in_picture
|
|
: Icons.picture_in_picture_alt_outlined,
|
|
color: controller.isFloatingWindowEnabled.value
|
|
? Colors.blue
|
|
: (isDarkMode ? Colors.white : Colors.black),
|
|
size: 24.sp,
|
|
),
|
|
onPressed: controller.toggleFloatingWindow,
|
|
tooltip: controller.isFloatingWindowEnabled.value
|
|
? 'disableFloatingWindow'.tr
|
|
: 'enableFloatingWindow'.tr,
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}),
|
|
// 小窗样式设置。单独一颗按钮而不是长按同一颗——长按是隐藏
|
|
// 交互,用户发现不了,而这两项恰恰是需要一边看效果一边调的。
|
|
Obx(() {
|
|
// ⚠️ 可观测变量必须在平台判断**之前**读。
|
|
// 写成 `Platform.isIOS && controller.currentMode.value == ...`
|
|
// 时,Dart 的 && 在安卓上短路,右半边压根不执行,这个 Obx 就
|
|
// 一个可观测变量都没订阅到——GetX 判定「用法不当」直接抛错,
|
|
// Flutter 用错误控件顶替整条 AppBar,表现为标题和返回键被一块
|
|
// 灰/红色的框盖住(release 是灰的,看着像一层阴影)。
|
|
// 只在安卓复现:iOS 上 Platform.isIOS 为真,右半边照常执行。
|
|
final mode = controller.currentMode.value;
|
|
if (Platform.isIOS &&
|
|
(mode == 'call' || mode == 'audioVideo')) {
|
|
return IconButton(
|
|
icon: Icon(
|
|
Icons.tune,
|
|
color: isDarkMode ? Colors.white70 : Colors.black54,
|
|
size: 20.sp,
|
|
),
|
|
onPressed: () => _showPipSettings(context, isDarkMode),
|
|
tooltip: 'pipSettings'.tr,
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.radio_button_checked,
|
|
color: Colors.red,
|
|
size: 24.sp,
|
|
),
|
|
onPressed: controller.toggleisRecord,
|
|
tooltip: 'enableRecording'.tr, // 是否开启录音
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.folder,
|
|
color: isDarkMode ? Colors.white : Colors.black,
|
|
size: 24.sp,
|
|
),
|
|
onPressed: () async {
|
|
await controller.stopAll();
|
|
Get.toNamed(Routes.translationList);
|
|
controller.setupScrollListener();
|
|
},
|
|
tooltip: 'viewAudioFiles'.tr, // 查看录音文件
|
|
),
|
|
// 历史记录按钮
|
|
// IconButton(
|
|
// icon: Icon(
|
|
// Icons.history,
|
|
// color: isDarkMode ? Colors.white : Colors.black,
|
|
// size: 24.sp,
|
|
// ),
|
|
// onPressed: () async {
|
|
// await controller.stopAll();
|
|
// Get.toNamed(Routes.translationHistory);
|
|
// },
|
|
// tooltip: 'translationHistory'.tr, // 翻译历史
|
|
// ),
|
|
],
|
|
);
|
|
}
|
|
}),
|
|
],
|
|
bottom: PreferredSize(
|
|
preferredSize: Size.fromHeight(
|
|
controller.currentMode.value == 'call' ? 70.h : 50.h,
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 统一通用语言选择栏组件
|
|
LanguageSelectorBar(
|
|
sourceText: () => controller.sourceLanguage.value.split(' ')[0],
|
|
targetText: () => controller.targetLanguage.value.split(' ')[0],
|
|
// 翻译进行中不开弹窗(见 controller.guardLanguageChange)
|
|
onTapSource: () {
|
|
if (!controller.guardLanguageChange()) return;
|
|
LanguageSelectionDialog.show(true, isDarkMode,
|
|
excludeLanguage: controller.targetLanguage.value,
|
|
translationController: controller);
|
|
},
|
|
onTapTarget: () {
|
|
if (!controller.guardLanguageChange()) return;
|
|
LanguageSelectionDialog.show(false, isDarkMode,
|
|
excludeLanguage: controller.sourceLanguage.value,
|
|
translationController: controller);
|
|
},
|
|
onSwap: controller.swapLanguages,
|
|
// 同声翻译 / 多媒体翻译是单向的,图标跟着变成单向箭头,
|
|
// 免得双向图标让人以为反着说也能翻(见 isOneWayTranslationMode)
|
|
swapIcon: () =>
|
|
isOneWayTranslationMode(controller.currentMode.value)
|
|
? Icons.arrow_forward
|
|
: Icons.swap_horiz,
|
|
translateLanguageNames: true, // 是否翻译语言名称
|
|
fontSizeSp: 15,
|
|
// 面对面翻译:左边胶囊 = 左耳,右边胶囊 = 右耳。
|
|
// 两只耳朵各自朝向一位说话人,所以要在语言旁边标清楚是哪一只。
|
|
sourceSuffix: controller.currentMode.value == 'faceToFace'
|
|
? _earTag('faceToFaceLeftEar'.tr, isDarkMode)
|
|
: null,
|
|
targetSuffix: controller.currentMode.value == 'call'
|
|
? Icon(
|
|
Icons.phone_in_talk,
|
|
size: 20.sp,
|
|
color: isDarkMode ? Colors.white70 : Colors.grey[600],
|
|
)
|
|
: controller.currentMode.value == 'faceToFace'
|
|
? _earTag('faceToFaceRightEar'.tr, isDarkMode)
|
|
: null,
|
|
),
|
|
|
|
// 通话模式下的“对方 / 我”标签行
|
|
if (controller.currentMode.value == 'call') ...[
|
|
SizedBox(height: 6.h),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'me'.tr, //我
|
|
style: TextStyle(
|
|
fontSize: 12.sp,
|
|
color: isDarkMode ? Colors.white70 : Colors.black54,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
// 预留与上方互换按钮大致等宽的间距,保证对齐
|
|
SizedBox(width: 44.w),
|
|
Expanded(
|
|
child: Text(
|
|
'counterPart'.tr, //对方
|
|
style: TextStyle(
|
|
fontSize: 12.sp,
|
|
color: isDarkMode ? Colors.white70 : Colors.black54,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 面对面翻译里标注这一侧对应哪只耳机
|
|
Widget _earTag(String text, bool isDarkMode) {
|
|
return Container(
|
|
margin: EdgeInsets.only(left: 4.w),
|
|
padding: EdgeInsets.symmetric(horizontal: 5.w, vertical: 1.h),
|
|
decoration: BoxDecoration(
|
|
color: (isDarkMode ? Colors.white : Colors.black).withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(4.r),
|
|
),
|
|
child: Text(
|
|
text,
|
|
style: TextStyle(
|
|
fontSize: 10.sp,
|
|
height: 1.2,
|
|
color: isDarkMode ? Colors.white70 : Colors.grey[700],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(bool isDarkMode) {
|
|
return
|
|
// GestureDetector(
|
|
// onPanUpdate: (details) {
|
|
// // 检测左滑手势
|
|
// if (details.delta.dx < -10) {
|
|
// // 左滑距离超过10像素时触发
|
|
// _handleSwipeLeft();
|
|
// }
|
|
// },
|
|
// child:
|
|
Column(
|
|
children: [
|
|
Expanded(
|
|
child: _buildTranslationContent(isDarkMode),
|
|
),
|
|
_buildControlBar(isDarkMode),
|
|
],
|
|
);
|
|
// );
|
|
}
|
|
|
|
// 处理左滑手势
|
|
// void _handleSwipeLeft() async {
|
|
// await controller.stopAll();
|
|
// Get.toNamed(Routes.translationHistory);
|
|
// }
|
|
|
|
Widget _buildTranslationContent(bool isDarkMode) {
|
|
return Obx(() {
|
|
// 其他模式使用原有的列表布局
|
|
final itemCount = controller.translationHistory.length +
|
|
(controller.hasMoreHistory.value ? 1 : 0);
|
|
|
|
return ListView.builder(
|
|
controller: controller.scrollController,
|
|
reverse: true, // 🔑 关键优化:采用reverse模式,参考Agent模块
|
|
padding: EdgeInsets.only(
|
|
left: 16.w,
|
|
right: 16.w,
|
|
top: 16.h,
|
|
bottom: 16.h,
|
|
),
|
|
itemCount: itemCount,
|
|
itemBuilder: (context, index) {
|
|
// 如果是最后一项且有更多历史记录,显示加载指示器(reverse模式下)
|
|
if (index == controller.translationHistory.length &&
|
|
controller.hasMoreHistory.value) {
|
|
return _buildLoadMoreIndicator();
|
|
}
|
|
|
|
// reverse模式下需要反转索引来获取正确的历史记录
|
|
final historyIndex = controller.translationHistory.length - 1 - index;
|
|
|
|
// 检查索引是否有效(reverse模式下的索引验证)
|
|
if (historyIndex < 0 ||
|
|
historyIndex >= controller.translationHistory.length) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
// 获取当前项
|
|
final historyItem = controller.translationHistory[historyIndex];
|
|
|
|
// 构建项目widget
|
|
return TranslationItemWidget(
|
|
item: historyItem,
|
|
activeActionTimestamp: _activeActionTimestamp,
|
|
onToggleActionItem: _toggleActiveActionItem,
|
|
onLongPressStart: _onLongPressTranslationItem,
|
|
onDeleteItemConfirm: _showDeleteItemConfirmDialog,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _buildControlBar(bool isDarkMode) {
|
|
// 使用GlobalKey来获取控制栏的高度
|
|
final controlBarKey = GlobalKey();
|
|
|
|
// 在构建完成后获取控制栏的高度并设置到控制器中
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (controlBarKey.currentContext != null) {
|
|
final RenderBox renderBox =
|
|
controlBarKey.currentContext!.findRenderObject() as RenderBox;
|
|
final height = renderBox.size.height;
|
|
controller.setBottomBarHeight(height);
|
|
}
|
|
});
|
|
|
|
return Container(
|
|
key: controlBarKey,
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h),
|
|
decoration: BoxDecoration(
|
|
color: isDarkMode ? AppColors.cardBackground : Colors.white,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withAlpha(isDarkMode ? 30 : 13),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 原有的按钮区域
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
// 主要控制按钮 - 开始/暂停语音识别
|
|
Obx(() {
|
|
final isRecognizing = controller.isRecognizing.value;
|
|
final isPreparing = controller.isPreparing.value;
|
|
return GestureDetector(
|
|
onTap: () {
|
|
if (isRecognizing) {
|
|
controller.stopAll();
|
|
} else {
|
|
controller.startRecognition();
|
|
}
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
width: 60.w,
|
|
height: 60.w,
|
|
transform: isPreparing
|
|
? (Matrix4.identity()..scale(1.08))
|
|
: Matrix4.identity(),
|
|
decoration: BoxDecoration(
|
|
color: isRecognizing
|
|
? (isDarkMode ? Colors.red[700] : Colors.red)
|
|
: (isDarkMode ? AppColors.primary : Colors.blue),
|
|
shape: BoxShape.circle,
|
|
boxShadow: isPreparing
|
|
? [
|
|
BoxShadow(
|
|
color: (isDarkMode
|
|
? AppColors.primary
|
|
: Colors.blue)
|
|
.withOpacity(0.5),
|
|
blurRadius: 12,
|
|
spreadRadius: 2,
|
|
)
|
|
]
|
|
: [],
|
|
),
|
|
child: Icon(
|
|
isRecognizing ? Icons.pause : Icons.play_arrow,
|
|
color: Colors.white,
|
|
size: 30.sp,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
|
|
// 添加间距
|
|
//SizedBox(height: 16.h),
|
|
|
|
// 语音识别结果显示框
|
|
// Container(
|
|
// width: double.infinity,
|
|
// padding: EdgeInsets.all(12.w),
|
|
// decoration: BoxDecoration(
|
|
// color: isDarkMode ? Colors.grey[800] : Colors.grey[100],
|
|
// borderRadius: BorderRadius.circular(8.r),
|
|
// border: Border.all(
|
|
// color: isDarkMode ? Colors.grey[600]! : Colors.grey[300]!,
|
|
// width: 1,
|
|
// ),
|
|
// ),
|
|
// child: Obx(() {
|
|
// // 显示最新的语音识别结果
|
|
// final recognitionText = controller.latestRecognitionText.value;
|
|
// final detectedLanguage = controller.detectedLanguageCode1.value;
|
|
|
|
// return Column(
|
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
|
// children: [
|
|
// // 标题
|
|
// Text(
|
|
// '麦头语音识别结果',
|
|
// style: TextStyle(
|
|
// fontSize: 12.sp,
|
|
// color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
|
|
// fontWeight: FontWeight.w500,
|
|
// ),
|
|
// ),
|
|
// SizedBox(height: 4.h),
|
|
// // 识别文本 - 这里显示 finalResult1 的内容
|
|
// Text(
|
|
// recognitionText.isEmpty ? '等待语音输入...' : recognitionText,
|
|
// style: TextStyle(
|
|
// fontSize: 14.sp,
|
|
// color: isDarkMode ? Colors.white : Colors.black87,
|
|
// height: 1.3,
|
|
// ),
|
|
// ),
|
|
// // 检测到的语言(如果有)
|
|
// if (detectedLanguage.isNotEmpty) ...[
|
|
// SizedBox(height: 4.h),
|
|
// Text(
|
|
// '检测语言: $detectedLanguage',
|
|
// style: TextStyle(
|
|
// fontSize: 10.sp,
|
|
// color:
|
|
// isDarkMode ? Colors.grey[500] : Colors.grey[500],
|
|
// ),
|
|
// ),
|
|
// ],
|
|
// ],
|
|
// );
|
|
// }),
|
|
// ),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
// 构建加载更多指示器
|
|
Widget _buildLoadMoreIndicator() {
|
|
return Obx(() {
|
|
if (controller.isLoadingMore.value) {
|
|
// 正在加载状态 - 增强视觉效果
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 24.w,
|
|
height: 24.h,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.5.w,
|
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
|
AppColors.primary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'loading'.tr, //加载中
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
color: Colors.grey[600],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
} else if (controller.isLoadingMoreError.value) {
|
|
// 加载失败状态 - 增强视觉效果
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
|
child: Center(
|
|
child: GestureDetector(
|
|
onTap: () => controller.loadMoreHistory(),
|
|
child: Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red[50],
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: Colors.red[200]!, width: 1),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.refresh,
|
|
size: 18.sp,
|
|
color: Colors.red[600],
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Text(
|
|
'loadFailedClickRetry'.tr,
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
color: Colors.red[600],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
// 默认状态(有更多记录但未加载)- 增强视觉效果
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[100],
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.keyboard_arrow_up,
|
|
size: 16.sp,
|
|
color: Colors.grey[500],
|
|
),
|
|
SizedBox(width: 4.w),
|
|
Text(
|
|
'swipeUpForMore'.tr,
|
|
style: TextStyle(
|
|
fontSize: 12.sp,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 处理翻译条目长按事件
|
|
void _onLongPressTranslationItem(
|
|
BuildContext context,
|
|
bool isDarkMode,
|
|
LongPressStartDetails details,
|
|
TranslationItem item,
|
|
) {
|
|
TranslationLongPressMenu.show(
|
|
context: context,
|
|
isDarkMode: isDarkMode,
|
|
details: details,
|
|
item: item,
|
|
onCopy: (item) => TranslationLongPressMenu.copyTranslationItem(item),
|
|
onDeleteItem: (item) => _deleteTranslationItem(item),
|
|
onClearHistory: _showClearHistoryConfirmDialog,
|
|
);
|
|
}
|
|
|
|
// 显示删除单条记录确认对话框
|
|
void _showDeleteItemConfirmDialog(TranslationItem item) {
|
|
final isDarkMode = Theme.of(Get.context!).brightness == Brightness.dark;
|
|
|
|
Get.dialog(
|
|
AlertDialog(
|
|
backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white,
|
|
title: Text(
|
|
'deleteTranslationRecord'.tr, // 删除翻译记录
|
|
style: TextStyle(
|
|
fontSize: 18.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDarkMode ? Colors.white : Colors.black,
|
|
),
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'deleteTranslationConfirm'.tr, // 确定要删除这条翻译记录吗?此操作无法撤销。
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: isDarkMode ? Colors.grey[300] : Colors.black87,
|
|
),
|
|
),
|
|
SizedBox(height: 12.h),
|
|
// 显示要删除的记录预览
|
|
Container(
|
|
padding: EdgeInsets.all(12.w),
|
|
decoration: BoxDecoration(
|
|
color: isDarkMode
|
|
? Colors.grey[800]?.withOpacity(0.5)
|
|
: Colors.grey[100],
|
|
borderRadius: BorderRadius.circular(8.r),
|
|
border: Border.all(
|
|
color: isDarkMode ? Colors.grey[700]! : Colors.grey[300]!,
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.sourceText,
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
color: isDarkMode ? Colors.white : Colors.black,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (item.translatedText.isNotEmpty) ...[
|
|
SizedBox(height: 4.h),
|
|
Text(
|
|
item.translatedText,
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
color: isDarkMode ? Colors.grey[300] : Colors.grey[700],
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Get.back(),
|
|
child: Text(
|
|
'cancel'.tr,
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[700],
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Get.back();
|
|
// 删除指定的翻译记录
|
|
_deleteTranslationItem(item);
|
|
},
|
|
child: Text(
|
|
'confirm'.tr,
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: Colors.red,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12.r),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 删除翻译记录的具体实现
|
|
void _deleteTranslationItem(TranslationItem item) {
|
|
try {
|
|
// 调用控制器的删除方法
|
|
controller.deleteTranslationItem(item.sessionId, item.timestamp);
|
|
|
|
// 显示删除成功提示
|
|
// Get.snackbar(
|
|
// 'success'.tr, // deleteSuccess
|
|
// '已删除该翻译记录', // deleteTranslationSuccess
|
|
// snackPosition: SnackPosition.BOTTOM,
|
|
// backgroundColor: Get.isDarkMode ? Colors.grey[800] : Colors.grey[200],
|
|
// colorText: Get.isDarkMode ? Colors.white : Colors.black,
|
|
// duration: const Duration(milliseconds: 800),
|
|
// );
|
|
} catch (e) {
|
|
// 显示删除失败提示
|
|
Get.snackbar(
|
|
'error'.tr,
|
|
'deleteTranslationRecordError'.trParams({'error': e.toString()}),
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
backgroundColor: Colors.red[400],
|
|
colorText: Colors.white,
|
|
duration: const Duration(milliseconds: 1500),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 显示删除所有记录确认对话框
|
|
void _showClearHistoryConfirmDialog() {
|
|
final isDarkMode = Get.isDarkMode;
|
|
|
|
Get.dialog(
|
|
AlertDialog(
|
|
backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white,
|
|
title: Text(
|
|
'clearHistory'.tr,
|
|
style: TextStyle(
|
|
fontSize: 18.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDarkMode ? Colors.white : Colors.black,
|
|
),
|
|
),
|
|
content: Text(
|
|
'clearHistoryConfirm'.tr,
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: isDarkMode ? Colors.grey[300] : Colors.black87,
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Get.back(),
|
|
child: Text(
|
|
'cancel'.tr,
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[700],
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
// 清除历史记录
|
|
controller.clearTranslationHistory();
|
|
Get.back();
|
|
},
|
|
child: Text(
|
|
'confirm'.tr,
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
color: Colors.red,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12.r),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 构建语言选择栏
|
|
Widget _buildLanguageSelectionBar(bool isDarkMode) {
|
|
return Container(
|
|
height: 44.h,
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 6.h),
|
|
decoration: BoxDecoration(
|
|
color: isDarkMode ? AppColors.cardBackground : Colors.white,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: LanguageSelectorBar(
|
|
sourceText: () => controller.sourceLanguage.value.split(' ')[0],
|
|
targetText: () => controller.targetLanguage.value.split(' ')[0],
|
|
onTapSource: () => LanguageSelectionDialog.show(true, isDarkMode,
|
|
excludeLanguage: controller.targetLanguage.value,
|
|
translationController: controller),
|
|
onTapTarget: () => LanguageSelectionDialog.show(false, isDarkMode,
|
|
excludeLanguage: controller.sourceLanguage.value,
|
|
translationController: controller),
|
|
onSwap: controller.swapLanguages,
|
|
translateLanguageNames: true, // 是否翻译语言名称
|
|
fontSizeSp: 15,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|