import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import '../../../core/theme/app_palette.dart'; import '../../../data/services/bailian_multimodal_service.dart'; import '../controllers/emai_controller.dart'; import 'widgets/emai_bubble.dart'; import 'widgets/emai_level_bar.dart'; /// EMAI 助手对话页。 /// /// 结构自上而下:状态条 → 消息列表 → 输入区(按住说话 / 文字输入二选一)。 /// 所有 AI 能力都在阿里百炼多模态应用里,页面本身只管采音、播音和展示。 class EmaiView extends GetView { const EmaiView({super.key}); @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( backgroundColor: AppPalette.background(isDark), appBar: _buildAppBar(isDark), body: SafeArea( child: Column( children: [ _buildStatusStrip(isDark), Expanded( child: Stack( children: [ _buildMessageList(isDark), _buildJumpToLatest(isDark), ], ), ), _buildInputArea(isDark), ], ), ), ); } PreferredSizeWidget _buildAppBar(bool isDark) { return AppBar( backgroundColor: AppPalette.surface(isDark), elevation: 0.5, centerTitle: true, leading: IconButton( icon: Icon(Icons.arrow_back_ios, size: 20.sp, color: AppPalette.textPrimary(isDark)), onPressed: () => Get.back(), ), title: Text( 'agentEmai'.tr, style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.w600, color: AppPalette.textPrimary(isDark), ), ), actions: [ IconButton( tooltip: 'emaiClearChat'.tr, icon: Icon(Icons.delete_outline, size: 21.sp, color: AppPalette.textSecondary(isDark)), onPressed: () => _confirmClear(isDark), ), ], ); } void _confirmClear(bool isDark) { if (controller.messages.isEmpty) return; Get.dialog( AlertDialog( backgroundColor: AppPalette.surface(isDark), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.r)), title: Text('emaiClearChat'.tr, style: TextStyle(fontSize: 16.sp)), content: Text('emaiClearConfirm'.tr, style: TextStyle(fontSize: 14.sp)), actions: [ TextButton( onPressed: () => Get.back(), child: Text('cancel'.tr), ), TextButton( onPressed: () { controller.clearMessages(); Get.back(); }, child: Text('confirm'.tr, style: const TextStyle(color: AppPalette.error)), ), ], ), ); } // ---------------- 状态条 ---------------- Widget _buildStatusStrip(bool isDark) { return Obx(() { final link = controller.linkState.value; final dialog = controller.dialogState.value; late final String label; late final Color color; late final IconData icon; VoidCallback? action; switch (link) { case EmaiLinkState.unconfigured: label = 'emaiNotConfigured'.tr; color = AppPalette.warning; icon = Icons.info_outline; // 冷启动时 AppConfig 可能还没拉回来,给个重试入口, // 不然用户只能退出重进页面 action = controller.reconnect; break; case EmaiLinkState.failed: label = 'emaiConnectFailed'.tr; color = AppPalette.error; icon = Icons.error_outline; action = controller.reconnect; break; case EmaiLinkState.connecting: label = 'emaiConnecting'.tr; color = AppPalette.textSecondary(isDark); icon = Icons.sync; break; case EmaiLinkState.onEarphone: label = 'emaiOnEarphone'.tr; color = AppPalette.accent; icon = Icons.headset_mic; // 没有这个入口的话,耳机会话不结束页面就永远停在这个状态 action = controller.takeBackFromEarphone; break; case EmaiLinkState.idle: label = 'emaiOffline'.tr; color = AppPalette.textSecondary(isDark); icon = Icons.cloud_off; action = controller.reconnect; break; case EmaiLinkState.ready: switch (dialog) { case BailianDialogState.listening: // 「正在聆听」只在麦克风真的开着时显示。 // // 服务端在播报期间、以及播报刚结束时也会把对话状态推成 listening, // 但那时本地麦克风并没有开(本页是「按住说话」,isTalking 才代表开麦)。 // 照着服务端状态无脑显示"正在聆听",会让用户以为 App 一直在录他说话。 if (controller.isTalking.value) { label = 'emaiListening'.tr; color = AppPalette.accent; icon = Icons.hearing; } else { label = 'emaiReady'.tr; color = AppPalette.success; icon = Icons.check_circle_outline; } break; case BailianDialogState.thinking: label = 'emaiThinking'.tr; color = AppPalette.warning; icon = Icons.psychology_outlined; break; case BailianDialogState.responding: label = 'emaiResponding'.tr; color = AppPalette.success; icon = Icons.graphic_eq; break; case BailianDialogState.idle: label = 'emaiReady'.tr; color = AppPalette.success; icon = Icons.check_circle_outline; break; } } return Container( width: double.infinity, padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 7.h), color: color.withValues(alpha: isDark ? 0.14 : 0.08), child: Row( children: [ Icon(icon, size: 15.sp, color: color), SizedBox(width: 6.w), Expanded( child: Text( label, style: TextStyle(fontSize: 12.sp, color: color), overflow: TextOverflow.ellipsis, ), ), if (action != null) GestureDetector( onTap: action, child: Text( link == EmaiLinkState.onEarphone ? 'emaiTakeBack'.tr : 'emaiReconnect'.tr, style: TextStyle( fontSize: 12.sp, color: color, fontWeight: FontWeight.w600, decoration: TextDecoration.underline, ), ), ), ], ), ); }); } // ---------------- 消息列表 ---------------- Widget _buildMessageList(bool isDark) { return Obx(() { if (controller.messages.isEmpty) return _buildEmptyState(isDark); return ListView.builder( controller: controller.scrollController, padding: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 12.h), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, itemCount: controller.messages.length, itemBuilder: (_, i) => EmaiBubble(message: controller.messages[i], isDark: isDark), ); }); } /// 用户往上翻历史时,右下角给一个回到最新的入口。 /// 不然翻上去之后要一路拖回来——历史可能有几百条。 Widget _buildJumpToLatest(bool isDark) { return Obx(() { if (controller.isPinnedToBottom.value || controller.messages.isEmpty) { return const SizedBox.shrink(); } return Positioned( right: 14.w, bottom: 14.h, child: GestureDetector( onTap: controller.jumpToLatest, child: Container( width: 36.w, height: 36.w, decoration: BoxDecoration( color: AppPalette.surface(isDark), shape: BoxShape.circle, border: Border.all(color: AppPalette.border(isDark)), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.12), blurRadius: 6, offset: const Offset(0, 2), ), ], ), child: Icon(Icons.keyboard_double_arrow_down, size: 19.sp, color: AppPalette.textSecondary(isDark)), ), ), ); }); } Widget _buildEmptyState(bool isDark) { return Center( child: Padding( padding: EdgeInsets.symmetric(horizontal: 40.w), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 72.w, height: 72.w, decoration: const BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( colors: [Color(0xFF5B8DEF), Color(0xFF7E57C2)], ), ), child: Icon(Icons.auto_awesome, size: 34.sp, color: Colors.white), ), SizedBox(height: 16.h), Text( 'agentEmai'.tr, style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.w600, color: AppPalette.textPrimary(isDark), ), ), SizedBox(height: 8.h), Text( 'emaiEmptyHint'.tr, textAlign: TextAlign.center, style: TextStyle( fontSize: 13.sp, height: 1.5, color: AppPalette.textSecondary(isDark), ), ), ], ), ), ); } // ---------------- 输入区 ---------------- Widget _buildInputArea(bool isDark) { return Container( decoration: BoxDecoration( color: AppPalette.surface(isDark), border: Border(top: BorderSide(color: AppPalette.border(isDark))), ), padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 10.h), child: Obx(() { return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ IconButton( padding: EdgeInsets.zero, constraints: BoxConstraints(minWidth: 38.w, minHeight: 38.w), icon: Icon( controller.isTextMode.value ? Icons.mic_none : Icons.keyboard, size: 24.sp, color: AppPalette.textSecondary(isDark), ), tooltip: controller.isTextMode.value ? 'emaiSwitchToVoice'.tr : 'emaiSwitchToText'.tr, onPressed: controller.toggleInputMode, ), SizedBox(width: 6.w), Expanded( child: controller.isTextMode.value ? _buildTextField(isDark) : _buildTalkButton(isDark), ), SizedBox(width: 6.w), _buildTrailingButton(isDark), ], ); }), ); } Widget _buildTextField(bool isDark) { return Container( constraints: BoxConstraints(minHeight: 40.h, maxHeight: 120.h), padding: EdgeInsets.symmetric(horizontal: 14.w), decoration: BoxDecoration( color: AppPalette.background(isDark), borderRadius: BorderRadius.circular(20.r), ), child: TextField( controller: controller.textController, maxLines: null, textInputAction: TextInputAction.send, onSubmitted: (_) => controller.sendTextMessage(), style: TextStyle( fontSize: 15.sp, color: AppPalette.textPrimary(isDark), ), decoration: InputDecoration( border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.symmetric(vertical: 11.h), hintText: 'emaiInputHint'.tr, hintStyle: TextStyle( fontSize: 15.sp, color: AppPalette.textHint(isDark), ), ), ), ); } /// 语音输入按钮。两种用法共用这一个控件: /// /// - **点一下** → 进入/退出「连续对话」(服务端 VAD 切句,麦克风一直开着) /// - **按住不放** → 单句模式(客户端划段),上滑取消 /// /// 点击与按住的区分放在 controller 的 [EmaiController.endTalk] 里做 /// (按下到松开短于 250ms 算点击)。放在那边是因为按下的瞬间就已经开麦并 /// 开了一段语音,判成点击时要把那一段取消掉——这是 controller 的事。 Widget _buildTalkButton(bool isDark) { final enabled = controller.canTalk; final continuous = controller.isContinuous.value; // 连续对话中:整条就是一个「点击结束」的开关,不再有按住/上滑那套语义 if (continuous) { return GestureDetector( onTap: controller.toggleContinuous, child: Container( height: 40.h, alignment: Alignment.center, decoration: BoxDecoration( color: AppPalette.accentDark, borderRadius: BorderRadius.circular(20.r), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ EmaiLevelBar(color: Colors.white, level: controller.level.value), SizedBox(width: 10.w), Text( 'emaiContinuousOn'.tr, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w500, color: Colors.white), ), ], ), ), ); } final talking = controller.isTalking.value; final cancelling = controller.isCancelPending.value; final Color bg = !enabled ? AppPalette.border(isDark) : cancelling ? AppPalette.error : talking ? AppPalette.accentDark : AppPalette.accent; final String label = !enabled ? 'emaiOffline'.tr : cancelling ? 'emaiReleaseToCancel'.tr : talking ? 'emaiReleaseToSend'.tr // 空闲态展示「点击开始连续对话」而不是「按住说话」: // 点击是这个按钮现在的主用法,按住说话仍然可用但不再是主推的 : 'emaiTapForContinuous'.tr; return Listener( onPointerDown: enabled ? (_) => controller.startTalk() : null, onPointerMove: (e) { if (!controller.isTalking.value) return; // 上滑超过按钮高度的一半就进入"松开取消" controller.updateCancelPending(e.localPosition.dy < -20); }, onPointerUp: (_) => controller.endTalk(), onPointerCancel: (_) => controller.endTalk(), child: Container( height: 40.h, alignment: Alignment.center, decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(20.r), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (talking) ...[ EmaiLevelBar(color: Colors.white, level: controller.level.value), SizedBox(width: 10.w), ], Text( label, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w500, color: enabled ? Colors.white : AppPalette.textHint(isDark), ), ), ], ), ), ); } /// 文字模式下是发送键;语音模式下、助手正在说话时是打断键 Widget _buildTrailingButton(bool isDark) { if (controller.isTextMode.value) { final active = controller.hasInputText.value && controller.canTalk; return _circleButton( icon: Icons.arrow_upward, bg: active ? AppPalette.accent : AppPalette.border(isDark), fg: active ? Colors.white : AppPalette.textHint(isDark), onTap: active ? controller.sendTextMessage : null, ); } final responding = controller.isResponding; return _circleButton( icon: Icons.stop, bg: responding ? AppPalette.error : AppPalette.border(isDark), fg: responding ? Colors.white : AppPalette.textHint(isDark), onTap: responding ? controller.interrupt : null, ); } Widget _circleButton({ required IconData icon, required Color bg, required Color fg, VoidCallback? onTap, }) { return GestureDetector( onTap: onTap, child: Container( width: 40.w, height: 40.w, decoration: BoxDecoration(color: bg, shape: BoxShape.circle), child: Icon(icon, size: 20.sp, color: fg), ), ); } }