zyc 689fa8936b Integrate Volcengine realtime voice + Live2D mouth driving
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:39:23 +08:00

268 lines
8.9 KiB
Dart

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<VoiceChatSheet> createState() => _VoiceChatSheetState();
}
class _VoiceChatSheetState extends State<VoiceChatSheet> {
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)),
);
}
}