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

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),
),
);
}),
);
}
}