import 'package:flutter/material.dart'; import 'chat_message.dart'; import 'voice_chat_controller.dart'; class VoiceChatSheet extends StatefulWidget { final VoiceChatController controller; const VoiceChatSheet({super.key, required this.controller}); @override State createState() => _VoiceChatSheetState(); } class _VoiceChatSheetState extends State { final ScrollController _listScroll = ScrollController(); bool _showDebug = false; @override void initState() { super.initState(); widget.controller.addListener(_onTick); } @override void dispose() { widget.controller.removeListener(_onTick); _listScroll.dispose(); super.dispose(); } void _onTick() { if (!mounted) return; setState(() {}); WidgetsBinding.instance.addPostFrameCallback((_) { if (_listScroll.hasClients) { _listScroll.animateTo( _listScroll.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } @override Widget build(BuildContext context) { return DraggableScrollableSheet( initialChildSize: 0.7, minChildSize: 0.4, maxChildSize: 0.95, builder: (context, scrollController) { return Container( decoration: const BoxDecoration( color: Color(0xE60F0F14), borderRadius: BorderRadius.vertical(top: Radius.circular(16)), border: Border(top: BorderSide(color: Color(0xFF333333))), ), child: Column( children: [ const SizedBox(height: 8), Container( width: 36, height: 4, decoration: BoxDecoration( color: Colors.white24, borderRadius: BorderRadius.circular(2), ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('AI 语音对话', style: TextStyle(color: Color(0xFF80C0FF), fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(width: 12), _stateBadge(widget.controller.state), ], ), ), const Divider(height: 1, color: Color(0xFF2A2A2F)), Expanded(child: _messageList()), if (widget.controller.lastError != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: SelectableText(widget.controller.lastError!, style: const TextStyle(color: Colors.redAccent, fontSize: 11)), ), if (_showDebug) _debugPanel(), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ TextButton.icon( onPressed: () => setState(() => _showDebug = !_showDebug), icon: Icon(_showDebug ? Icons.visibility_off : Icons.bug_report, size: 14, color: Colors.white54), label: Text(_showDebug ? '隐藏调试' : '调试日志', style: const TextStyle(color: Colors.white54, fontSize: 11)), ), ], ), ), SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: _bottomBar(), ), ), ], ), ); }, ); } Widget _messageList() { final msgs = widget.controller.messages; if (msgs.isEmpty) { return Center( child: Text( widget.controller.engineReady ? '按住下方按钮,开始说话' : '正在初始化引擎…', style: const TextStyle(color: Colors.white38, fontSize: 13), ), ); } return ListView.builder( controller: _listScroll, padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), itemCount: msgs.length, itemBuilder: (_, i) => _bubble(msgs[i]), ); } Widget _bubble(ChatMessage m) { final isUser = m.role == ChatRole.user; return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.72, ), decoration: BoxDecoration( color: isUser ? const Color(0xFF2766B6) : const Color(0xFF222228), borderRadius: BorderRadius.circular(10), border: Border.all( color: isUser ? Colors.transparent : const Color(0xFF333339)), ), child: Text( m.text.isEmpty ? '…' : m.text, style: TextStyle( color: isUser ? Colors.white : Colors.white.withValues(alpha: 0.92), fontSize: 13, height: 1.4, ), ), ), ); } Widget _debugPanel() { final log = widget.controller.debugLog; return Container( constraints: const BoxConstraints(maxHeight: 280), margin: const EdgeInsets.fromLTRB(12, 4, 12, 4), padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFF101015), borderRadius: BorderRadius.circular(6), border: Border.all(color: const Color(0xFF333339)), ), child: log.isEmpty ? const Text('调试日志(事件流将显示在这里)', style: TextStyle(color: Colors.white38, fontSize: 10)) : SingleChildScrollView( reverse: true, child: SelectableText( log.join('\n'), style: const TextStyle( color: Colors.white70, fontSize: 10, fontFamily: 'Menlo', height: 1.35, ), ), ), ); } /// 底部状态栏:左边显示当前状态文案,右边一个"结束会话"按钮 Widget _bottomBar() { final c = widget.controller; final stateText = !c.engineReady ? '正在初始化引擎…' : !c.connected && !c.connecting ? '未连接' : c.connecting ? '连接中…' : switch (c.state) { VoiceChatState.listening => '正在听你说…', VoiceChatState.thinking => '思考中…', VoiceChatState.speaking => 'AI 正在说话', VoiceChatState.error => '出错', VoiceChatState.idle => '已连接,开口说话即可', }; return Row( children: [ Container( width: 8, height: 8, decoration: BoxDecoration( color: c.connected ? const Color(0xFF1E9F4D) : Colors.white24, shape: BoxShape.circle, ), ), const SizedBox(width: 8), Expanded( child: Text( stateText, style: const TextStyle(color: Colors.white70, fontSize: 12), ), ), if (c.connected || c.connecting) TextButton.icon( onPressed: () => c.stopSession(), icon: const Icon(Icons.stop_circle_outlined, size: 16, color: Colors.redAccent), label: const Text('结束会话', style: TextStyle(color: Colors.redAccent, fontSize: 12)), ) else TextButton.icon( onPressed: () => c.startSession(), icon: const Icon(Icons.play_circle_outline, size: 16, color: Color(0xFF1E9F4D)), label: const Text('开始会话', style: TextStyle(color: Color(0xFF1E9F4D), fontSize: 12)), ), ], ); } static Widget _stateBadge(VoiceChatState s) { final (label, color) = switch (s) { VoiceChatState.idle => ('待机', Color(0xFF888888)), VoiceChatState.listening => ('倾听', Color(0xFFE25C5C)), VoiceChatState.thinking => ('思考', Color(0xFFFFB957)), VoiceChatState.speaking => ('说话', Color(0xFF4488DD)), VoiceChatState.error => ('错误', Color(0xFFCC4444)), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: color.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(8), border: Border.all(color: color.withValues(alpha: 0.5)), ), child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600)), ); } }