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.
975 lines
37 KiB
975 lines
37 KiB
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:intl/intl.dart'; // 用于 DateFormat
|
|
import '../controllers/mobile_elf_controller.dart';
|
|
|
|
class MobileElfView extends GetView<MobileElfController> {
|
|
const MobileElfView({Key? key}) : super(key: key);
|
|
|
|
// ── 颜色常量 ──────────────────────────────────────────────────────────────
|
|
static const _blue = Color(0xFF2E7CF6);
|
|
static const _green = Color(0xFF34C759);
|
|
static const _greenDark = Color(0xFF16A34A);
|
|
static const _red = Color(0xFFEF4444);
|
|
static const _orange = Color(0xFFF79009);
|
|
static const _bgLight = Color(0xFFF4F6F9);
|
|
static const _surfaceLight = Colors.white;
|
|
static const _textPrimary = Color(0xFF101828);
|
|
static const _textSecondary = Color(0xFF667085);
|
|
static const _border = Color(0xFFEAECF0);
|
|
|
|
// 渐变复用
|
|
static const _avatarGradient = LinearGradient(
|
|
colors: [Color(0xFF667EEA), Color(0xFF764BA2)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
return Scaffold(
|
|
backgroundColor: isDark ? const Color(0xFF0D0D0D) : _bgLight,
|
|
appBar: _buildAppBar(isDark),
|
|
// 顶层 Listener 捕获 PTT 松开事件(确保 widget 树重建后仍能接收 pointerUp)
|
|
body: Listener(
|
|
behavior: HitTestBehavior.translucent,
|
|
onPointerUp: (_) {
|
|
if (controller.pttRecording.value) controller.stopPtt();
|
|
},
|
|
child: Obx(() {
|
|
if (controller.credentialError.value.isNotEmpty) {
|
|
return _buildCredentialError();
|
|
}
|
|
return Column(
|
|
children: [
|
|
Expanded(child: _buildMessageList(isDark)),
|
|
_buildBottomBar(isDark),
|
|
],
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// AppBar
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
PreferredSizeWidget _buildAppBar(bool isDark) {
|
|
return AppBar(
|
|
backgroundColor: isDark ? const Color(0xFF1A1A1A) : _surfaceLight,
|
|
elevation: 0,
|
|
surfaceTintColor: Colors.transparent,
|
|
bottom: PreferredSize(
|
|
preferredSize: const Size.fromHeight(1),
|
|
child: Container(
|
|
color: isDark ? const Color(0xFF222222) : _border, height: 1),
|
|
),
|
|
leading: IconButton(
|
|
icon: Icon(Icons.arrow_back_ios,
|
|
size: 18.sp, color: isDark ? Colors.white : _textSecondary),
|
|
onPressed: () => Get.back(),
|
|
),
|
|
titleSpacing: 0,
|
|
title: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_gradientCircle(32.w, '晓', 13.sp),
|
|
SizedBox(width: 10.w),
|
|
Text('移动精灵',
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
fontWeight: FontWeight.w600,
|
|
color: isDark ? Colors.white : _textPrimary)),
|
|
SizedBox(width: 8.w),
|
|
Obx(() => _buildStatusPill()),
|
|
],
|
|
),
|
|
actions: [
|
|
// 播报开关
|
|
Obx(() => IconButton(
|
|
icon: Icon(
|
|
controller.ttsEnabled.value
|
|
? Icons.volume_up_rounded
|
|
: Icons.volume_off_rounded,
|
|
size: 20.sp,
|
|
color: controller.ttsEnabled.value
|
|
? _blue
|
|
: (isDark ? Colors.grey[500] : _textSecondary),
|
|
),
|
|
onPressed: () => controller.ttsEnabled.toggle(),
|
|
tooltip: controller.ttsEnabled.value ? '关闭播报' : '开启播报',
|
|
)),
|
|
PopupMenuButton<String>(
|
|
icon: Icon(Icons.more_vert,
|
|
size: 20.sp, color: isDark ? Colors.white : _textSecondary),
|
|
onSelected: (value) {
|
|
if (value == 'clear') {
|
|
Get.dialog(
|
|
AlertDialog(
|
|
title: Text('clearChatHistory'.tr.isNotEmpty ? 'clearChatHistory'.tr : '清空记录'),
|
|
content: Text('confirmClearChat'.tr.isNotEmpty ? 'confirmClearChat'.tr : '确定清空所有聊天记录吗?'),
|
|
actions: [
|
|
TextButton(onPressed: () => Get.back(), child: Text('cancel'.tr.isNotEmpty ? 'cancel'.tr : '取消')),
|
|
TextButton(
|
|
onPressed: () {
|
|
controller.clearMessages();
|
|
Get.back();
|
|
},
|
|
child: Text('confirm'.tr.isNotEmpty ? 'confirm'.tr : '确定', style: const TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
},
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem(
|
|
value: 'clear',
|
|
child: Row(children: [
|
|
Icon(Icons.delete_outline, size: 18.sp, color: Colors.red),
|
|
SizedBox(width: 8.w),
|
|
Text('清空记录', style: TextStyle(fontSize: 14.sp, color: Colors.red)),
|
|
]),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 右上角状态胶囊 + 通话计时
|
|
Widget _buildStatusPill() {
|
|
final s = controller.status.value;
|
|
final inCall = controller.inCall.value;
|
|
final cfg = _pillConfig(s, inCall);
|
|
return Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 3.h),
|
|
decoration: BoxDecoration(
|
|
color: cfg.bg,
|
|
borderRadius: BorderRadius.circular(20.r),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(cfg.label,
|
|
style: TextStyle(
|
|
fontSize: 11.sp,
|
|
color: cfg.fg,
|
|
fontWeight: FontWeight.w500)),
|
|
if (inCall) ...[
|
|
SizedBox(width: 4.w),
|
|
Obx(() {
|
|
final sec = controller.callSeconds.value;
|
|
return Text(_fmtDuration(sec),
|
|
style: TextStyle(
|
|
fontSize: 11.sp,
|
|
color: cfg.fg,
|
|
fontWeight: FontWeight.w400));
|
|
}),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
_PillCfg _pillConfig(MobileElfStatus s, bool inCall) {
|
|
switch (s) {
|
|
case MobileElfStatus.connecting:
|
|
return const _PillCfg(Color(0xFFFFFAEB), Color(0xFFB54708), '连接中…');
|
|
case MobileElfStatus.recording:
|
|
return const _PillCfg(Color(0xFFECFDF3), _greenDark, '聆听中');
|
|
case MobileElfStatus.thinking:
|
|
return const _PillCfg(Color(0xFFEFF8FF), Color(0xFF175CD3), '处理中');
|
|
case MobileElfStatus.speaking:
|
|
return const _PillCfg(Color(0xFFECFDF3), _greenDark, '朗读中');
|
|
case MobileElfStatus.error:
|
|
return const _PillCfg(Color(0xFFFEF2F2), _red, '错误');
|
|
case MobileElfStatus.idle:
|
|
return inCall
|
|
? const _PillCfg(Color(0xFFECFDF3), _greenDark, '通话中')
|
|
: const _PillCfg(Color(0xFFF2F4F7), _textSecondary, '空闲');
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// 凭证错误
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
Widget _buildCredentialError() {
|
|
return Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32.w),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.wifi_off_rounded, size: 56.sp, color: Colors.red[300]),
|
|
SizedBox(height: 16.h),
|
|
Obx(() => Text(controller.credentialError.value,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 13.sp, color: Colors.red[400]))),
|
|
SizedBox(height: 20.h),
|
|
ElevatedButton.icon(
|
|
onPressed: controller.retryCredentials,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('重试'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// 消息列表
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
Widget _buildMessageList(bool isDark) {
|
|
return Obx(() {
|
|
final msgs = controller.messages;
|
|
final rt = controller.realtimeText.value;
|
|
if (msgs.isEmpty && rt.isEmpty) return _buildEmptyState(isDark);
|
|
return ListView.builder(
|
|
controller: controller.scrollController,
|
|
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h),
|
|
itemCount: msgs.length + (rt.isNotEmpty ? 1 : 0),
|
|
itemBuilder: (_, i) {
|
|
if (i < msgs.length) return _buildBubble(msgs[i], isDark);
|
|
return _buildRealtimeBubble(rt);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _buildEmptyState(bool isDark) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_gradientCircle(80.w, '晓', 32.sp),
|
|
SizedBox(height: 20.h),
|
|
Text('移动精灵',
|
|
style: TextStyle(
|
|
fontSize: 20.sp,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDark ? Colors.white : _textPrimary)),
|
|
SizedBox(height: 8.h),
|
|
Text('点击右下角按钮,即可与晓晓对话',
|
|
style: TextStyle(
|
|
fontSize: 13.sp,
|
|
color: isDark ? Colors.grey[500] : _textSecondary)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── 消息气泡 ──────────────────────────────────────────────────────────────
|
|
|
|
Widget _buildBubble(MobileElfMessage msg, bool isDark) {
|
|
final isUser = msg.isUser;
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: 14.h),
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
children: [
|
|
// AI 头像 + 名字
|
|
if (!isUser) ...[
|
|
Row(children: [
|
|
_gradientCircle(28.w, '晓', 11.sp),
|
|
SizedBox(width: 6.w),
|
|
Text('晓晓',
|
|
style: TextStyle(fontSize: 11.sp, color: _textSecondary)),
|
|
]),
|
|
SizedBox(height: 4.h),
|
|
],
|
|
// 气泡体(长按弹出菜单)
|
|
Row(
|
|
mainAxisAlignment:
|
|
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
|
children: [
|
|
if (!isUser) SizedBox(width: 34.w),
|
|
Flexible(
|
|
child: GestureDetector(
|
|
onLongPressStart: (details) => _showMessageMenu(details, msg, isDark),
|
|
child: Container(
|
|
padding:
|
|
EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
|
|
decoration: BoxDecoration(
|
|
color: isUser
|
|
? _blue
|
|
: (isDark ? const Color(0xFF2A2A2A) : _surfaceLight),
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(18.r),
|
|
topRight: Radius.circular(18.r),
|
|
bottomLeft: Radius.circular(isUser ? 18.r : 4.r),
|
|
bottomRight: Radius.circular(isUser ? 4.r : 18.r),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
blurRadius: 6,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Text(msg.text,
|
|
style: TextStyle(
|
|
fontSize: 15.sp,
|
|
height: 1.55,
|
|
color: isUser
|
|
? Colors.white
|
|
: (isDark ? Colors.white : _textPrimary))),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// 时间 + AI 标签
|
|
SizedBox(height: 3.h),
|
|
Padding(
|
|
padding: EdgeInsets.only(left: isUser ? 0 : 34.w),
|
|
child: Row(
|
|
mainAxisAlignment:
|
|
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
|
children: [
|
|
Text(_formatTimestamp(msg.time),
|
|
style: TextStyle(
|
|
fontSize: 10.sp, color: isDark ? Colors.grey[500] : const Color(0xFFD0D5DD))),
|
|
if (!isUser) ...[
|
|
SizedBox(width: 8.w),
|
|
Text('generatedByAi'.tr.isNotEmpty ? 'generatedByAi'.tr : '由AI生成',
|
|
style: TextStyle(
|
|
fontSize: 10.sp,
|
|
color: Colors.blue[600],
|
|
fontWeight: FontWeight.bold,
|
|
fontStyle: FontStyle.italic)),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 长按消息弹出菜单
|
|
void _showMessageMenu(LongPressStartDetails details, MobileElfMessage msg, bool isDark) {
|
|
HapticFeedback.mediumImpact();
|
|
final overlay = Overlay.of(Get.context!).context.findRenderObject() as RenderBox?;
|
|
if (overlay == null) return;
|
|
final position = RelativeRect.fromRect(
|
|
Rect.fromLTWH(details.globalPosition.dx, details.globalPosition.dy, 0, 0),
|
|
Offset.zero & overlay.size,
|
|
);
|
|
showMenu(
|
|
context: Get.context!,
|
|
position: position,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.r)),
|
|
color: isDark ? const Color(0xFF2A2A2A) : Colors.white,
|
|
items: [
|
|
PopupMenuItem(
|
|
onTap: () {
|
|
Clipboard.setData(ClipboardData(text: msg.text));
|
|
Get.snackbar('success'.tr.isNotEmpty ? 'success'.tr : '成功',
|
|
'copySuccess'.tr.isNotEmpty ? 'copySuccess'.tr : '已复制',
|
|
backgroundColor: Colors.blue.withValues(alpha: 0.8), colorText: Colors.white);
|
|
},
|
|
child: Row(children: [
|
|
Icon(Icons.copy, size: 18.sp, color: isDark ? Colors.white : Colors.black87),
|
|
SizedBox(width: 8.w),
|
|
Text('copy'.tr.isNotEmpty ? 'copy'.tr : '复制',
|
|
style: TextStyle(fontSize: 14.sp, color: isDark ? Colors.white : Colors.black87)),
|
|
]),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 智能时间格式化(与 agent 模块一致)
|
|
String _formatTimestamp(DateTime t) {
|
|
final now = DateTime.now();
|
|
final diff = now.difference(t);
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
final msgDate = DateTime(t.year, t.month, t.day);
|
|
|
|
if (diff.inMinutes < 1) return 'justNow'.tr.isNotEmpty ? 'justNow'.tr : '刚刚';
|
|
if (diff.inHours < 1) {
|
|
final m = diff.inMinutes.toString();
|
|
final text = 'minutesAgo'.trParams({'minutes': m});
|
|
return text.isNotEmpty && text != 'minutesAgo' ? text : '$m分钟前';
|
|
}
|
|
if (msgDate == today) return DateFormat('HH:mm').format(t);
|
|
if (msgDate == today.subtract(const Duration(days: 1))) {
|
|
return '${('yesterday'.tr.isNotEmpty ? 'yesterday'.tr : '昨天')} ${DateFormat('HH:mm').format(t)}';
|
|
}
|
|
if (diff.inDays < 7) {
|
|
return '${_weekdayName(t.weekday)} ${DateFormat('HH:mm').format(t)}';
|
|
}
|
|
if (t.year == now.year) return DateFormat('MM-dd HH:mm').format(t);
|
|
return DateFormat('yyyy-MM-dd HH:mm').format(t);
|
|
}
|
|
|
|
String _weekdayName(int w) {
|
|
const names = ['', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
|
|
return names[w];
|
|
}
|
|
|
|
Widget _buildRealtimeBubble(String text) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: 14.h),
|
|
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
|
Flexible(
|
|
child: Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
|
|
decoration: BoxDecoration(
|
|
color: _blue.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(18.r),
|
|
),
|
|
child: Text(text,
|
|
style: TextStyle(
|
|
fontSize: 15.sp, color: Colors.white, height: 1.55)),
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// 底部控制栏
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
Widget _buildBottomBar(bool isDark) {
|
|
return Obx(() {
|
|
final inCall = controller.inCall.value;
|
|
final s = controller.status.value;
|
|
final ptt = controller.pttRecording.value;
|
|
|
|
// 状态栏显示条件:
|
|
// - 通话模式:recording/thinking/speaking/connecting 都显示
|
|
// - 非通话非PTT:thinking/speaking 显示
|
|
// - PTT 中:不显示(状态在中间按钮体现)
|
|
final showStatusBar = !ptt && (
|
|
(inCall && s != MobileElfStatus.idle && s != MobileElfStatus.error) ||
|
|
(!inCall && (s == MobileElfStatus.speaking || s == MobileElfStatus.thinking))
|
|
);
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: isDark ? const Color(0xFF1A1A1A) : _surfaceLight,
|
|
border: Border(top: BorderSide(color: isDark ? const Color(0xFF222222) : _border)),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 12, offset: const Offset(0, -2)),
|
|
],
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (showStatusBar) _buildStatusBar(s, inCall, isDark),
|
|
if (inCall)
|
|
_buildInCallInputBar(isDark)
|
|
else
|
|
_buildNormalInputBar(isDark),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
// ── 状态提示栏 ────────────────────────────────────────────────────────────
|
|
|
|
Widget _buildStatusBar(MobileElfStatus s, bool inCall, bool isDark) {
|
|
Widget? content;
|
|
|
|
switch (s) {
|
|
case MobileElfStatus.speaking:
|
|
content = GestureDetector(
|
|
onTap: controller.interruptSpeaking,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
|
|
color: const Color(0xFFECFDF3),
|
|
child: Row(
|
|
children: [
|
|
_MiniWave(color: _greenDark),
|
|
SizedBox(width: 8.w),
|
|
Expanded(child: Text('晓晓正在朗读...', style: TextStyle(fontSize: 13.sp, color: _greenDark, fontWeight: FontWeight.w500))),
|
|
Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: _red),
|
|
borderRadius: BorderRadius.circular(16.r),
|
|
),
|
|
child: Text('× 打断', style: TextStyle(fontSize: 12.sp, color: _red, fontWeight: FontWeight.w500)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
break;
|
|
case MobileElfStatus.recording:
|
|
content = Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
|
|
color: const Color(0xFFECFDF3),
|
|
child: Row(
|
|
children: [
|
|
_MiniWave(color: _greenDark),
|
|
SizedBox(width: 8.w),
|
|
Expanded(child: Obx(() {
|
|
final rt = controller.realtimeText.value;
|
|
return Text(rt.isNotEmpty ? rt : '聆听中...', style: TextStyle(fontSize: 13.sp, color: _greenDark), maxLines: 1, overflow: TextOverflow.ellipsis);
|
|
})),
|
|
],
|
|
),
|
|
);
|
|
break;
|
|
case MobileElfStatus.connecting:
|
|
content = Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
|
|
color: const Color(0xFFFFFAEB),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(width: 14.w, height: 14.w, child: CircularProgressIndicator(strokeWidth: 2, color: _orange)),
|
|
SizedBox(width: 8.w),
|
|
Text('正在连接...', style: TextStyle(fontSize: 13.sp, color: const Color(0xFFB54708))),
|
|
],
|
|
),
|
|
);
|
|
break;
|
|
case MobileElfStatus.thinking:
|
|
content = Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
|
|
color: const Color(0xFFEFF8FF),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(width: 14.w, height: 14.w, child: CircularProgressIndicator(strokeWidth: 2, color: const Color(0xFF175CD3))),
|
|
SizedBox(width: 8.w),
|
|
Text('AI 思考中...', style: TextStyle(fontSize: 13.sp, color: const Color(0xFF175CD3))),
|
|
],
|
|
),
|
|
);
|
|
break;
|
|
default:
|
|
return const SizedBox.shrink();
|
|
}
|
|
return content;
|
|
}
|
|
|
|
// ── 通话模式底栏 ──────────────────────────────────────────────────────────
|
|
|
|
Widget _buildInCallInputBar(bool isDark) {
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 14.h),
|
|
child: Row(
|
|
children: [
|
|
// 通话状态 + 计时
|
|
Expanded(
|
|
child: Obx(() {
|
|
final s = controller.status.value;
|
|
final idle = s == MobileElfStatus.idle;
|
|
return Row(
|
|
children: [
|
|
Container(
|
|
width: 8.w, height: 8.w,
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: idle ? _green : _orange),
|
|
),
|
|
SizedBox(width: 6.w),
|
|
Text('通话中', style: TextStyle(fontSize: 13.sp, color: isDark ? Colors.white70 : _textSecondary)),
|
|
SizedBox(width: 4.w),
|
|
Obx(() => Text(_fmtDuration(controller.callSeconds.value),
|
|
style: TextStyle(fontSize: 13.sp, color: isDark ? Colors.white70 : _textSecondary))),
|
|
],
|
|
);
|
|
}),
|
|
),
|
|
// 音量指示器
|
|
Obx(() => _VolumeIndicator(
|
|
rms: controller.micRms.value,
|
|
alive: controller.micAlive.value,
|
|
isDark: isDark,
|
|
)),
|
|
SizedBox(width: 12.w),
|
|
// 挂断按钮
|
|
GestureDetector(
|
|
onTap: controller.disconnect,
|
|
child: Container(
|
|
width: 52.w, height: 52.w,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: _red,
|
|
boxShadow: [BoxShadow(color: _red.withValues(alpha: 0.3), blurRadius: 14, offset: const Offset(0, 4))],
|
|
),
|
|
child: Icon(Icons.call_end_rounded, color: Colors.white, size: 26.sp),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── 普通输入栏 (按住说话) ──────────────────────────────────────────────────
|
|
|
|
Widget _buildNormalInputBar(bool isDark) {
|
|
return Obx(() {
|
|
final s = controller.status.value;
|
|
final ptt = controller.pttRecording.value;
|
|
|
|
final disabled = !ptt && s != MobileElfStatus.idle && s != MobileElfStatus.error;
|
|
final pttClickable = s == MobileElfStatus.speaking;
|
|
final sideDisabled = ptt || (disabled && !pttClickable);
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(12.w, 6.h, 12.w, 14.h),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// ── PTT 实时识别文字(居中 + 渐变背景) ──
|
|
if (ptt)
|
|
Obx(() {
|
|
final rt = controller.realtimeText.value;
|
|
if (rt.isEmpty) return const SizedBox.shrink();
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
|
|
margin: EdgeInsets.only(bottom: 8.h),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: isDark
|
|
? [const Color(0xFF2A2A2A), const Color(0xFF1A2332)]
|
|
: [const Color(0xFFF0F4FF), const Color(0xFFE8F5E9)],
|
|
),
|
|
borderRadius: BorderRadius.circular(16.r),
|
|
border: Border.all(
|
|
color: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0),
|
|
width: 0.5,
|
|
),
|
|
),
|
|
child: Text(rt,
|
|
style: TextStyle(fontSize: 15.sp, color: isDark ? Colors.white : _textPrimary, height: 1.4),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 3, overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
}),
|
|
// ── 按钮行 ──
|
|
Row(
|
|
children: [
|
|
// 中间:PTT 按钮
|
|
Expanded(
|
|
child: ptt
|
|
? _buildPttRecordingCenter(isDark)
|
|
: IgnorePointer(
|
|
ignoring: disabled && !pttClickable,
|
|
child: Opacity(
|
|
opacity: (disabled && !pttClickable) ? 0.5 : 1.0,
|
|
child: _buildPttIdleButton(isDark),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
// 右侧:电话按钮
|
|
IgnorePointer(
|
|
ignoring: sideDisabled,
|
|
child: Opacity(
|
|
opacity: sideDisabled ? 0.4 : 1.0,
|
|
child: _roundButton(
|
|
onTap: controller.startCall,
|
|
gradient: const LinearGradient(
|
|
colors: [Color(0xFF34C759), Color(0xFF28A745)],
|
|
begin: Alignment.topLeft, end: Alignment.bottomRight,
|
|
),
|
|
shadow: _green.withValues(alpha: 0.4),
|
|
size: 44,
|
|
child: Icon(Icons.phone, color: Colors.white, size: 20.sp),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
/// 按住说话按钮(空闲状态)— 参考微信风格
|
|
Widget _buildPttIdleButton(bool isDark) {
|
|
return Listener(
|
|
onPointerDown: (_) => controller.startPtt(),
|
|
child: Container(
|
|
height: 42.h,
|
|
decoration: BoxDecoration(
|
|
color: isDark ? const Color(0xFF2A2A2A) : Colors.white,
|
|
borderRadius: BorderRadius.circular(21.r),
|
|
border: Border.all(color: isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD), width: 1),
|
|
),
|
|
child: Center(
|
|
child: Text('按住 说话',
|
|
style: TextStyle(
|
|
fontSize: 15.sp,
|
|
color: isDark ? Colors.white70 : _textPrimary,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 2.w,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// PTT 录音中:仅中间部分
|
|
/// 连接中=与按住说话相同样式,录音中=橙色
|
|
Widget _buildPttRecordingCenter(bool isDark) {
|
|
return Obx(() {
|
|
final s = controller.status.value;
|
|
final isConnecting = s == MobileElfStatus.connecting;
|
|
|
|
if (isConnecting) {
|
|
// 连接中:与按住说话空闲状态一样的外观 + 加载指示
|
|
return Container(
|
|
height: 42.h,
|
|
decoration: BoxDecoration(
|
|
color: isDark ? const Color(0xFF2A2A2A) : Colors.white,
|
|
borderRadius: BorderRadius.circular(21.r),
|
|
border: Border.all(color: isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD), width: 1),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SizedBox(width: 14.w, height: 14.w,
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: isDark ? Colors.white70 : _textSecondary)),
|
|
SizedBox(width: 8.w),
|
|
Text('连接中...', style: TextStyle(fontSize: 14.sp, color: isDark ? Colors.white70 : _textSecondary, fontWeight: FontWeight.w500)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// 录音中:橙色
|
|
return Container(
|
|
height: 42.h,
|
|
decoration: BoxDecoration(
|
|
gradient: const LinearGradient(colors: [Color(0xFFF79009), Color(0xFFDC6803)]),
|
|
borderRadius: BorderRadius.circular(21.r),
|
|
boxShadow: [BoxShadow(
|
|
color: _orange.withValues(alpha: 0.3),
|
|
blurRadius: 8, offset: const Offset(0, 2),
|
|
)],
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Obx(() => _RmsBars(rms: controller.micRms.value, color: Colors.white)),
|
|
SizedBox(width: 8.w),
|
|
Obx(() => Text('松开发送 · ${controller.pttSeconds.value}s',
|
|
style: TextStyle(fontSize: 14.sp, color: Colors.white, fontWeight: FontWeight.w500))),
|
|
],
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
// ── 通用圆形按钮 ──────────────────────────────────────────────────────────
|
|
|
|
Widget _roundButton({
|
|
required VoidCallback onTap,
|
|
required Widget child,
|
|
Color? color,
|
|
Gradient? gradient,
|
|
Color? shadow,
|
|
Color? border,
|
|
double size = 56,
|
|
}) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: size.w,
|
|
height: size.w,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: gradient == null ? color : null,
|
|
gradient: gradient,
|
|
border: border != null ? Border.all(color: border, width: 2) : null,
|
|
boxShadow: shadow != null
|
|
? [BoxShadow(color: shadow, blurRadius: 14, offset: const Offset(0, 4))]
|
|
: null,
|
|
),
|
|
child: Center(child: child),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// 工具方法
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
/// 渐变圆形头像
|
|
Widget _gradientCircle(double size, String text, double fontSize) {
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: _avatarGradient,
|
|
),
|
|
child: Center(
|
|
child: Text(text,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: fontSize,
|
|
fontWeight: FontWeight.w600)),
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
String _fmtDuration(int secs) {
|
|
final m = (secs ~/ 60).toString().padLeft(2, '0');
|
|
final s = (secs % 60).toString().padLeft(2, '0');
|
|
return '$m:$s';
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 辅助类
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class _PillCfg {
|
|
final Color bg, fg;
|
|
final String label;
|
|
const _PillCfg(this.bg, this.fg, this.label);
|
|
}
|
|
|
|
/// 3 条小波浪动画
|
|
class _MiniWave extends StatefulWidget {
|
|
final Color color;
|
|
const _MiniWave({required this.color});
|
|
|
|
@override
|
|
State<_MiniWave> createState() => _MiniWaveState();
|
|
}
|
|
|
|
class _MiniWaveState extends State<_MiniWave>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _ctrl;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ctrl = AnimationController(
|
|
vsync: this, duration: const Duration(milliseconds: 800))
|
|
..repeat();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_ctrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _ctrl,
|
|
builder: (_, __) => Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: List.generate(3, (i) {
|
|
final phase = (_ctrl.value + i * 0.2) % 1.0;
|
|
final h = 3.0 + 7.0 * (0.5 - (phase - 0.5).abs()) * 2;
|
|
return Container(
|
|
width: 2.5,
|
|
height: h.clamp(3.0, 10.0),
|
|
margin: const EdgeInsets.symmetric(horizontal: 1),
|
|
decoration: BoxDecoration(
|
|
color: widget.color,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 麦克风音量指示器(通话模式用)
|
|
class _VolumeIndicator extends StatelessWidget {
|
|
final double rms;
|
|
final bool alive;
|
|
final bool isDark;
|
|
const _VolumeIndicator({required this.rms, required this.alive, required this.isDark});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// rms 范围大约 0~6000,归一化到 0~1
|
|
final level = alive ? (rms / 5000).clamp(0.0, 1.0) : 0.0;
|
|
const barCount = 5;
|
|
return Container(
|
|
width: 44.w, height: 44.w,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF2F4F7),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: List.generate(barCount, (i) {
|
|
final threshold = (i + 1) / barCount;
|
|
final active = level >= threshold;
|
|
return Container(
|
|
width: 3.w,
|
|
height: (8 + i * 4).h,
|
|
margin: EdgeInsets.symmetric(horizontal: 1.w),
|
|
decoration: BoxDecoration(
|
|
color: active
|
|
? const Color(0xFF34C759)
|
|
: (isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD)),
|
|
borderRadius: BorderRadius.circular(1.5.r),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// PTT 录音时响应麦克风音量的波形条
|
|
class _RmsBars extends StatelessWidget {
|
|
final double rms;
|
|
final Color color;
|
|
const _RmsBars({required this.rms, required this.color});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// rms 范围约 0~6000,归一化到 0~1
|
|
final level = (rms / 5000).clamp(0.0, 1.0);
|
|
const barCount = 3;
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: List.generate(barCount, (i) {
|
|
final threshold = (i + 1) / barCount;
|
|
final h = level >= threshold ? 10.0 : 3.0;
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 80),
|
|
width: 2.5,
|
|
height: h,
|
|
margin: const EdgeInsets.symmetric(horizontal: 1),
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|