import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import '../controllers/mobile_elf_controller.dart'; class MobileElfView extends GetView { const MobileElfView({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final bool isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( backgroundColor: isDark ? Colors.black : const Color(0xFFF9FAFC), appBar: _buildAppBar(isDark), body: Obx(() { final err = controller.credentialError.value; if (err.isNotEmpty) { return Center( child: Padding( padding: EdgeInsets.all(24.w), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 48.sp, color: Colors.red), SizedBox(height: 12.h), Text(err, textAlign: TextAlign.center, style: TextStyle(fontSize: 13.sp, color: Colors.red)), SizedBox(height: 16.h), ElevatedButton( onPressed: controller.retryCredentials, child: const Text('重试'), ), ], ), ), ); } return Column( children: [ _buildStatusBanner(), Expanded(child: _buildMessageList(isDark)), _buildBottomBar(isDark), ], ); }), ); } PreferredSizeWidget _buildAppBar(bool isDark) { return AppBar( backgroundColor: isDark ? const Color(0xFF1A1A1A) : Colors.white, elevation: 0.5, leading: IconButton( icon: Icon(Icons.arrow_back_ios, size: 18.sp, color: isDark ? Colors.white : Colors.grey[800]), onPressed: () => Get.back(), ), title: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 32.w, height: 32.w, decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF0091EA), Color(0xFF00BFA5)], ), borderRadius: BorderRadius.circular(8.r), ), child: Icon(Icons.cell_tower, color: Colors.white, size: 18.sp), ), SizedBox(width: 8.w), Text( '移动精灵', style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.bold, color: isDark ? Colors.white : Colors.grey[800], ), ), ], ), actions: [ Obx(() => Padding( padding: EdgeInsets.only(right: 16.w), child: _buildStatusDot(), )), ], ); } Widget _buildStatusDot() { final color = _statusColor(controller.status.value); return Row( children: [ Container( width: 8.w, height: 8.w, decoration: BoxDecoration( color: color, shape: BoxShape.circle, boxShadow: [ BoxShadow(color: color.withOpacity(0.4), blurRadius: 4) ], ), ), SizedBox(width: 4.w), Text( _statusLabel(controller.status.value), style: TextStyle(fontSize: 11.sp, color: color), ), ], ); } Widget _buildStatusBanner() { return Obx(() { final s = controller.status.value; if (s == MobileElfStatus.idle) return const SizedBox.shrink(); final color = _statusColor(s); final label = _statusBannerLabel(s); return Container( width: double.infinity, padding: EdgeInsets.symmetric(vertical: 6.h), color: color.withOpacity(0.12), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (s == MobileElfStatus.thinking || s == MobileElfStatus.connecting) SizedBox( width: 12.w, height: 12.w, child: CircularProgressIndicator( strokeWidth: 1.5, color: color, ), ), if (s == MobileElfStatus.recording) Icon(Icons.mic, size: 14.sp, color: color), if (s == MobileElfStatus.speaking) Icon(Icons.volume_up, size: 14.sp, color: color), if (s == MobileElfStatus.error) Icon(Icons.error_outline, size: 14.sp, color: color), SizedBox(width: 6.w), Text(label, style: TextStyle( fontSize: 12.sp, color: color, fontWeight: FontWeight.w500)), ], ), ); }); } Widget _buildMessageList(bool isDark) { return Obx(() { if (controller.messages.isEmpty) { return _buildEmptyHint(isDark); } return ListView.builder( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), reverse: false, itemCount: controller.messages.length, itemBuilder: (_, i) => _buildBubble(controller.messages[i], isDark), ); }); } Widget _buildEmptyHint(bool isDark) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 72.w, height: 72.w, decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF0091EA), Color(0xFF00BFA5)], ), shape: BoxShape.circle, ), child: Icon(Icons.cell_tower, color: Colors.white, size: 36.sp), ), SizedBox(height: 16.h), Text( '移动精灵', style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.bold, color: isDark ? Colors.white : Colors.grey[800], ), ), SizedBox(height: 8.h), Text( '按住麦克风按钮开始语音对话', style: TextStyle( fontSize: 13.sp, color: isDark ? Colors.grey[400] : Colors.grey[500], ), ), ], ), ); } Widget _buildBubble(MobileElfMessage msg, bool isDark) { final isUser = msg.isUser; return Padding( padding: EdgeInsets.only(bottom: 12.h), child: Row( mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isUser) ...[ _buildAvatar(isUser: false), SizedBox(width: 8.w), ], Flexible( child: Container( padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), decoration: BoxDecoration( color: isUser ? const Color(0xFF0091EA) : (isDark ? const Color(0xFF2A2A2A) : Colors.white), borderRadius: BorderRadius.only( topLeft: Radius.circular(16.r), topRight: Radius.circular(16.r), bottomLeft: isUser ? Radius.circular(16.r) : Radius.circular(4.r), bottomRight: isUser ? Radius.circular(4.r) : Radius.circular(16.r), ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.06), blurRadius: 6, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( msg.text, style: TextStyle( fontSize: 14.sp, color: isUser ? Colors.white : (isDark ? Colors.white : Colors.grey[800]), height: 1.4, ), ), if (!isUser && msg.ttsUrl != null) ...[ SizedBox(height: 6.h), GestureDetector( onTap: () => controller.playTts(msg.ttsUrl!), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.play_circle_outline, size: 16.sp, color: const Color(0xFF0091EA), ), SizedBox(width: 4.w), Text( '重播语音', style: TextStyle( fontSize: 11.sp, color: const Color(0xFF0091EA), ), ), ], ), ), ], ], ), ), ), if (isUser) ...[ SizedBox(width: 8.w), _buildAvatar(isUser: true), ], ], ), ); } Widget _buildAvatar({required bool isUser}) { return Container( width: 32.w, height: 32.w, decoration: BoxDecoration( color: isUser ? const Color(0xFF0091EA).withOpacity(0.15) : const Color(0xFF00BFA5).withOpacity(0.15), shape: BoxShape.circle, ), child: Icon( isUser ? Icons.person : Icons.cell_tower, size: 16.sp, color: isUser ? const Color(0xFF0091EA) : const Color(0xFF00BFA5), ), ); } Widget _buildBottomBar(bool isDark) { return Container( padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 24.h), decoration: BoxDecoration( color: isDark ? const Color(0xFF1A1A1A) : Colors.white, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), blurRadius: 8, offset: const Offset(0, -2), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 实时识别字幕 Obx(() { final text = controller.realtimeText.value; if (text.isEmpty) return const SizedBox.shrink(); return Padding( padding: EdgeInsets.only(bottom: 8.h), child: Text( text, style: TextStyle( fontSize: 13.sp, color: isDark ? Colors.grey[400] : Colors.grey[500], fontStyle: FontStyle.italic, ), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, ), ); }), // 错误提示 Obx(() { final err = controller.errorMsg.value; if (err.isEmpty) return const SizedBox.shrink(); return Padding( padding: EdgeInsets.only(bottom: 8.h), child: Text( err, style: TextStyle( fontSize: 12.sp, color: Colors.red[400]), textAlign: TextAlign.center, ), ); }), // 麦克风按钮 Obx(() => _buildMicButton(controller.status.value)), ], ), ); } Widget _buildMicButton(MobileElfStatus status) { final bool isThinking = status == MobileElfStatus.thinking || status == MobileElfStatus.connecting; final Color btnColor = _statusColor(status); final IconData icon = _micIcon(status); final String label = _micLabel(status); return Column( children: [ GestureDetector( onTap: isThinking ? null : controller.onMicTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), width: 68.w, height: 68.w, decoration: BoxDecoration( shape: BoxShape.circle, color: isThinking ? Colors.grey.withOpacity(0.2) : btnColor.withOpacity(0.15), border: Border.all( color: isThinking ? Colors.grey.withOpacity(0.3) : btnColor, width: 2, ), ), child: isThinking ? Center( child: SizedBox( width: 24.w, height: 24.w, child: CircularProgressIndicator( strokeWidth: 2.5, color: Colors.grey), ), ) : Icon(icon, size: 30.sp, color: btnColor), ), ), SizedBox(height: 6.h), Text( label, style: TextStyle( fontSize: 11.sp, color: isThinking ? Colors.grey : btnColor, ), ), ], ); } // ── 辅助 ────────────────────────────────────────────────────────────────── Color _statusColor(MobileElfStatus s) { switch (s) { case MobileElfStatus.connecting: return Colors.orange; case MobileElfStatus.recording: return const Color(0xFF0066CC); case MobileElfStatus.thinking: return Colors.orange; case MobileElfStatus.speaking: return const Color(0xFF4CAF50); case MobileElfStatus.error: return Colors.red; case MobileElfStatus.idle: return const Color(0xFF0091EA); } } String _statusLabel(MobileElfStatus s) { switch (s) { case MobileElfStatus.connecting: return '连接中'; case MobileElfStatus.recording: return '录音中'; case MobileElfStatus.thinking: return '思考中'; case MobileElfStatus.speaking: return '播报中'; case MobileElfStatus.error: return '错误'; case MobileElfStatus.idle: return '就绪'; } } String _statusBannerLabel(MobileElfStatus s) { switch (s) { case MobileElfStatus.connecting: return '正在连接移动精灵...'; case MobileElfStatus.recording: return '正在聆听,请说话'; case MobileElfStatus.thinking: return 'AI 思考中,请稍候'; case MobileElfStatus.speaking: return '正在播报回答'; case MobileElfStatus.error: return controller.errorMsg.value; case MobileElfStatus.idle: return ''; } } IconData _micIcon(MobileElfStatus s) { switch (s) { case MobileElfStatus.recording: return Icons.stop_circle_outlined; case MobileElfStatus.speaking: return Icons.stop_circle_outlined; case MobileElfStatus.error: return Icons.refresh; default: return Icons.mic; } } String _micLabel(MobileElfStatus s) { switch (s) { case MobileElfStatus.recording: return '点击停止'; case MobileElfStatus.speaking: return '点击停止播报'; case MobileElfStatus.error: return '重试'; case MobileElfStatus.thinking: return '思考中...'; case MobileElfStatus.connecting: return '连接中...'; default: return '点击说话'; } } }