Files
app/wei_ai_app/lib/screens/interaction/interaction_screen.dart
2026-02-03 21:41:25 +08:00

562 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/core.dart';
import 'voice_mode_overlay.dart';
import 'voice_session_controller.dart';
class InteractionScreen extends ConsumerStatefulWidget {
final String characterId;
const InteractionScreen({super.key, required this.characterId});
@override
ConsumerState<InteractionScreen> createState() => _InteractionScreenState();
}
class _InteractionScreenState extends ConsumerState<InteractionScreen> {
CharacterModel? _character;
List<ChatMessage> _messages = [];
final TextEditingController _controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
VoiceSessionController? _voiceController;
bool _isVoiceMode = false;
bool _isLoading = false;
bool _isTyping = false;
String _typingContent = '';
@override
void initState() {
super.initState();
_loadCharacterAndMessages();
}
@override
@override
void dispose() {
_voiceController?.dispose();
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _loadCharacterAndMessages() async {
// 加载角色信息
final character = await CharacterRepository.getCharacterById(widget.characterId);
if (character == null) {
debugPrint('❌ 角色不存在: ${widget.characterId}');
if (mounted) context.pop();
return;
}
// 加载本地聊天记录
final session = await ChatStorageService.getSession(widget.characterId);
setState(() {
_character = character;
_messages = session.messages;
});
// 如果没有消息,添加问候语
if (_messages.isEmpty) {
final greeting = ChatService.getGreeting(character);
final greetingMessage = ChatMessage.assistant(greeting);
setState(() {
_messages = [greetingMessage];
});
await ChatStorageService.addMessage(widget.characterId, greetingMessage);
}
_scrollToBottom();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
// 使用 reverse: true 后0.0 就是列表底部(最新消息处)
_scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
void _enterVoiceMode() {
FocusScope.of(context).unfocus();
_voiceController = VoiceSessionController(
character: _character!,
onUserMessage: (text) {
if (!mounted) return;
final userMsg = ChatMessage.user(text);
setState(() {
_messages = [..._messages, userMsg];
});
ChatStorageService.addMessage(widget.characterId, userMsg);
_scrollToBottom();
},
onAiMessage: (msg) {
if (!mounted) return;
setState(() {
_messages = [..._messages, msg];
});
ChatStorageService.addMessage(widget.characterId, msg);
_scrollToBottom();
},
);
setState(() => _isVoiceMode = true);
}
void _exitVoiceMode() {
_voiceController?.dispose();
_voiceController = null;
setState(() => _isVoiceMode = false);
}
Future<void> _sendMessage() async {
if (_controller.text.trim().isEmpty || _character == null || _isLoading) return;
final userText = _controller.text.trim();
_controller.clear();
// 添加用户消息
final userMessage = ChatMessage.user(userText);
setState(() {
_messages = [..._messages, userMessage];
_isLoading = true;
_isTyping = true;
_typingContent = '';
});
await ChatStorageService.addMessage(widget.characterId, userMessage);
_scrollToBottom();
try {
// 调用 AI
final response = await ChatService.sendMessage(
character: _character!,
messages: _messages,
userMessage: userText,
onStream: (content) {
if (mounted) {
setState(() {
_typingContent = content;
});
_scrollToBottom();
}
},
);
// 添加 AI 回复
final aiMessage = ChatMessage.assistant(response);
setState(() {
_messages = [..._messages, aiMessage];
_isTyping = false;
_typingContent = '';
});
await ChatStorageService.addMessage(widget.characterId, aiMessage);
_scrollToBottom();
} catch (e) {
debugPrint('❌ 发送消息失败: $e');
// 显示错误消息
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('发送失败: $e'),
backgroundColor: Colors.red,
),
);
setState(() {
_isTyping = false;
_typingContent = '';
});
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
if (_character == null) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(color: Color(0xFFA855F7)),
),
);
}
final avatarUrl = CharacterRepository.getAvatarUrl(_character!.avatarPath);
return Container(
decoration: const BoxDecoration(
color: Color(0xFF2E1065),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2E1065), Color(0xFF0F172A)],
),
),
child: Stack(
children: [
Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
flexibleSpace: ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(color: Colors.black.withOpacity(0.5)),
),
),
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
onPressed: () => context.pop(),
),
title: Row(
children: [
CircleAvatar(
backgroundImage: NetworkImage(avatarUrl),
radius: 16,
onBackgroundImageError: (_, __) {},
child: _character!.avatarPath == null
? const Icon(LucideIcons.user, size: 16, color: Colors.white54)
: null,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_character!.name,
style: const TextStyle(fontSize: 16, color: Colors.white),
overflow: TextOverflow.ellipsis,
),
Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: _getStatusColor(_character!.status),
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
_getStatusText(_character!.status),
style: TextStyle(fontSize: 10, color: Colors.white.withOpacity(0.7)),
),
],
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(LucideIcons.trash2, color: Colors.white54, size: 20),
onPressed: _clearChat,
tooltip: '清除聊天记录',
),
IconButton(
icon: const Icon(LucideIcons.moreVertical, color: Colors.white),
onPressed: () {},
),
],
),
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Column(
children: [
Expanded(
child: ListView.separated(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
reverse: true,
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
itemCount: _messages.length + (_isTyping ? 1 : 0),
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (context, index) {
// reverse: true 模式下,索引 0 是列表的最底部
if (_isTyping) {
if (index == 0) return _buildTypingBubble();
final msg = _messages[_messages.length - index];
return _buildMessageBubble(msg, avatarUrl);
}
final msg = _messages[_messages.length - 1 - index];
return _buildMessageBubble(msg, avatarUrl);
},
),
),
// Input Area
_buildInputArea(),
],
),
),
),
if (_isVoiceMode && _character != null && _voiceController != null)
VoiceModeOverlay(
character: _character!,
controller: _voiceController!,
onClose: _exitVoiceMode,
),
],
),
);
}
Widget _buildMessageBubble(ChatMessage msg, String avatarUrl) {
final isMe = msg.isUser;
return Row(
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMe) ...[
CircleAvatar(
backgroundImage: NetworkImage(avatarUrl),
radius: 16,
onBackgroundImageError: (_, __) {},
),
const SizedBox(width: 8),
],
Flexible(
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.7,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isMe ? const Color(0xFFA855F7) : Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: isMe ? const Radius.circular(16) : const Radius.circular(2),
bottomRight: isMe ? const Radius.circular(2) : const Radius.circular(16),
),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: Text(
msg.content,
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.4),
),
),
),
if (isMe) const SizedBox(width: 8),
],
);
}
Widget _buildTypingBubble() {
final avatarUrl = CharacterRepository.getAvatarUrl(_character!.avatarPath);
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundImage: NetworkImage(avatarUrl),
radius: 16,
onBackgroundImageError: (_, __) {},
),
const SizedBox(width: 8),
Flexible(
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.7,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(2),
bottomRight: Radius.circular(16),
),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: _typingContent.isEmpty
? Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildDot(0),
_buildDot(1),
_buildDot(2),
],
)
: Text(
_typingContent,
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.4),
),
),
),
],
);
}
Widget _buildDot(int index) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.3, end: 1.0),
duration: Duration(milliseconds: 600 + index * 200),
curve: Curves.easeInOut,
builder: (context, value, child) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 2),
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.white.withOpacity(value),
shape: BoxShape.circle,
),
);
},
);
}
Widget _buildInputArea() {
return ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 10,
top: 10,
left: 16,
right: 16,
),
color: Colors.black.withOpacity(0.4),
child: Row(
children: [
GestureDetector(
onTap: _enterVoiceMode,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withOpacity(0.1),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: const Icon(LucideIcons.phone, color: Colors.white, size: 20),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _controller,
style: const TextStyle(color: Colors.white),
enabled: !_isLoading,
onSubmitted: (_) => _sendMessage(),
decoration: InputDecoration(
filled: true,
fillColor: Colors.white.withOpacity(0.05),
hintText: _isLoading ? 'AI 正在思考...' : '输入消息...',
hintStyle: TextStyle(color: Colors.white.withOpacity(0.3)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _isLoading ? null : _sendMessage,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: _isLoading
? null
: const LinearGradient(colors: [Color(0xFFA855F7), Color(0xFFEC4899)]),
color: _isLoading ? Colors.white.withOpacity(0.1) : null,
),
child: _isLoading
? const Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white54,
),
)
: const Icon(LucideIcons.send, color: Colors.white, size: 20),
),
),
],
),
),
),
);
}
Future<void> _clearChat() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF2E1065),
title: const Text('清除聊天记录', style: TextStyle(color: Colors.white)),
content: const Text('确定要清除所有聊天记录吗?此操作不可恢复。',
style: TextStyle(color: Colors.white70)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消', style: TextStyle(color: Colors.white54)),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('清除', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true) {
await ChatStorageService.clearSession(widget.characterId);
final greeting = ChatService.getGreeting(_character!);
final greetingMessage = ChatMessage.assistant(greeting);
setState(() {
_messages = [greetingMessage];
});
await ChatStorageService.addMessage(widget.characterId, greetingMessage);
}
}
Color _getStatusColor(CharacterStatus status) {
switch (status) {
case CharacterStatus.online:
return const Color(0xFF10B981);
case CharacterStatus.busy:
return const Color(0xFFF59E0B);
case CharacterStatus.offline:
return Colors.grey;
}
}
String _getStatusText(CharacterStatus status) {
switch (status) {
case CharacterStatus.online:
return '在线';
case CharacterStatus.busy:
return '忙碌';
case CharacterStatus.offline:
return '离线';
}
}
}