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 { 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( 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: [ const _MiniWave(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), ), ); }), ), ); } }