268 lines
8.9 KiB
Dart
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)),
|
|
);
|
|
}
|
|
}
|