feat: 角色卡 demo

This commit is contained in:
liqupan
2026-01-28 20:28:38 +08:00
parent a4e7898e94
commit c09fbf4808
35 changed files with 2773 additions and 329 deletions

View File

@@ -1,126 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import '../../data/mock_data.dart';
import '../../models/character.dart';
import '../../core/core.dart';
import '../../providers/providers.dart';
import '../../widgets/tab_content_layout.dart';
class DiscoveryScreen extends StatefulWidget {
class DiscoveryScreen extends ConsumerWidget {
const DiscoveryScreen({super.key});
@override
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
}
class _DiscoveryScreenState extends State<DiscoveryScreen> {
String _activeFilter = 'all';
final List<Map<String, String>> _filters = [
{'id': 'all', 'label': '全部'},
{'id': 'gentle', 'label': '温柔治愈'},
{'id': 'dom', 'label': '主导强势'},
{'id': 'wild', 'label': '反差/猎奇'},
{'id': 'voice', 'label': '语音陪聊'},
{'id': 'scenario', 'label': '场景扮演'},
{'id': 'exclusive', 'label': '会员限定'},
];
List<Character> get _filteredCharacters {
if (_activeFilter == 'all') return mockCharacters;
return mockCharacters.where((c) {
final tags = c.tags.join('');
if (_activeFilter == 'gentle') return tags.contains('治愈') || tags.contains('温顺') || tags.contains('医疗');
if (_activeFilter == 'dom') return tags.contains('强势') || tags.contains('调教') || tags.contains('指令');
if (_activeFilter == 'wild') return tags.contains('病娇') || tags.contains('神秘') || tags.contains('不稳定') || tags.contains('极乐');
if (_activeFilter == 'exclusive') return c.isLocked;
return false;
}).toList();
}
@override
Widget build(BuildContext context) {
const double bottomNavHeight = 90;
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(categoriesProvider);
final filteredCharactersAsync = ref.watch(filteredCharactersProvider);
final selectedCategory = ref.watch(selectedCategoryProvider);
const double bottomNavHeight = 90;
return TabContentLayout(
child: Column(
children: [
// 1. Sticky Filter Bar (simulated)
SizedBox(
height: 50,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: _filters.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final filter = _filters[index];
final isActive = _activeFilter == filter['id'];
return Center(
child: GestureDetector(
onTap: () => setState(() => _activeFilter = filter['id']!),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isActive ? Colors.white : Colors.white.withOpacity(0.1),
children: [
// 1. Filter Bar
SizedBox(
height: 50,
child: categoriesAsync.when(
data: (categories) => ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final category = categories[index];
final isActive = selectedCategory == category.code;
return Center(
child: GestureDetector(
onTap: () => ref.read(selectedCategoryProvider.notifier).setCategory(category.code),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isActive ? Colors.white : Colors.white.withOpacity(0.1),
),
),
child: Text(
category.label,
style: TextStyle(
fontSize: 14,
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: isActive ? const Color(0xFF2E1065) : Colors.white.withOpacity(0.7),
),
),
),
),
child: Text(
filter['label']!,
style: TextStyle(
fontSize: 14,
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
color: isActive ? const Color(0xFF2E1065) : Colors.white.withOpacity(0.7),
),
),
),
),
);
},
);
},
),
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
error: (e, _) => Center(child: Text('加载分类失败', style: TextStyle(color: Colors.white.withOpacity(0.5)))),
),
),
),
// 2. Grid Layout
Expanded(
child: _filteredCharacters.isEmpty
? Center(child: Text('暂无匹配角色', style: TextStyle(color: Colors.white.withOpacity(0.5))))
: GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, bottomNavHeight + 20),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 3 / 4,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: _filteredCharacters.length,
itemBuilder: (context, index) {
final char = _filteredCharacters[index];
return _CharacterCard(
character: char,
onTap: () {
if (!char.isLocked) {
// 2. Grid Layout
Expanded(
child: filteredCharactersAsync.when(
data: (characters) => characters.isEmpty
? Center(child: Text('暂无匹配角色', style: TextStyle(color: Colors.white.withOpacity(0.5))))
: GridView.builder(
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomNavHeight + 20),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 3 / 4,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: characters.length,
itemBuilder: (context, index) {
final char = characters[index];
return _CharacterCard(
character: char,
onTap: () {
if (!char.isLocked) {
context.push('/interaction/${char.id}');
}
},
)
.animate()
.fadeIn(duration: 400.ms, delay: (index * 100).ms)
.scale(begin: const Offset(0.9, 0.9));
},
}
},
)
.animate()
.fadeIn(duration: 400.ms, delay: (index * 100).ms)
.scale(begin: const Offset(0.9, 0.9));
},
),
loading: () => const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(strokeWidth: 2),
SizedBox(height: 16),
Text('正在加载角色...', style: TextStyle(color: Colors.white54)),
],
),
),
],
),
),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.alertCircle, color: Colors.red.withOpacity(0.7), size: 48),
const SizedBox(height: 16),
Text('加载失败', style: TextStyle(color: Colors.white.withOpacity(0.7))),
const SizedBox(height: 8),
Text(e.toString(), style: TextStyle(color: Colors.white.withOpacity(0.4), fontSize: 12)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.invalidate(charactersProvider),
child: const Text('重试'),
),
],
),
),
),
),
],
),
);
}
}
class _CharacterCard extends StatelessWidget {
final Character character;
final CharacterModel character;
final VoidCallback onTap;
const _CharacterCard({
@@ -130,6 +139,9 @@ class _CharacterCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 获取头像 URL
final avatarUrl = CharacterRepository.getAvatarUrl(character.avatarPath);
return GestureDetector(
onTap: onTap,
child: Container(
@@ -151,7 +163,7 @@ class _CharacterCard extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
// Clipped content area - ensures all elements respect border radius
// Clipped content area
ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Stack(
@@ -159,7 +171,7 @@ class _CharacterCard extends StatelessWidget {
children: [
// Background Image
Image.network(
character.avatar,
avatarUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
@@ -168,6 +180,22 @@ class _CharacterCard extends StatelessWidget {
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: const Color(0xFF2E1065),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.user, size: 48, color: Colors.white.withOpacity(0.3)),
const SizedBox(height: 8),
Text(
character.name,
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
),
],
),
);
},
),
// Gradient Overlay
@@ -179,44 +207,13 @@ class _CharacterCard extends StatelessWidget {
colors: [
Colors.transparent,
Colors.transparent,
Color(0xFF2E1065), // Deep purple at bottom
Color(0xFF2E1065),
],
stops: [0.0, 0.5, 1.0],
),
),
),
// Top Left: Popularity/Compatibility Badge
if (!character.isLocked)
Positioned(
top: 12,
left: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.4),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.flame, size: 12, color: Color(0xFFF472B6)),
const SizedBox(width: 4),
Text(
'${character.compatibility}%',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
color: Colors.white,
),
),
],
),
),
),
// Top Right: Lock Icon
if (character.isLocked)
Positioned(
@@ -270,7 +267,7 @@ class _CharacterCard extends StatelessWidget {
),
),
child: Text(
tag,
tag.name,
style: const TextStyle(fontSize: 10, color: Colors.white),
),
);
@@ -284,7 +281,7 @@ class _CharacterCard extends StatelessWidget {
),
),
// Border Overlay - Outside ClipRRect to ensure full visibility
// Border Overlay
Positioned.fill(
child: IgnorePointer(
child: Container(

View File

@@ -2,230 +2,515 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import '../../models/character.dart';
import '../../models/message.dart';
import '../../data/mock_data.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/core.dart';
import 'voice_mode_overlay.dart';
class InteractionScreen extends StatefulWidget {
class InteractionScreen extends ConsumerStatefulWidget {
final String characterId;
const InteractionScreen({super.key, required this.characterId});
@override
State<InteractionScreen> createState() => _InteractionScreenState();
ConsumerState<InteractionScreen> createState() => _InteractionScreenState();
}
class _InteractionScreenState extends State<InteractionScreen> {
late Character _character;
final List<Message> _messages = List.from(mockMessages);
class _InteractionScreenState extends ConsumerState<InteractionScreen> {
CharacterModel? _character;
List<ChatMessage> _messages = [];
final TextEditingController _controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
bool _isVoiceMode = false;
bool _isLoading = false;
bool _isTyping = false;
String _typingContent = '';
@override
void initState() {
super.initState();
_character = mockCharacters.firstWhere(
(c) => c.id == widget.characterId,
orElse: () => mockCharacters.first,
);
_loadCharacterAndMessages();
}
void _sendMessage() {
if (_controller.text.trim().isEmpty) return;
final newUserMsg = Message(
id: DateTime.now().toString(),
text: _controller.text,
sender: MessageSender.user,
type: MessageType.text,
timestamp: DateTime.now()
);
@override
void 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(() {
_messages.add(newUserMsg);
_controller.clear();
_character = character;
_messages = session.messages;
});
// Mock AI Reply
Future.delayed(const Duration(seconds: 2), () {
if (!mounted) return;
final newAiMsg = Message(
id: DateTime.now().toString(),
text: '我收到了你的信号: "${newUserMsg.text}"。这让我感觉很好...',
sender: MessageSender.ai,
type: MessageType.text,
timestamp: DateTime.now()
// 如果没有消息,添加问候语
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) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
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(() {
_messages.add(newAiMsg);
_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 Stack(
children: [
Scaffold(
extendBodyBehindAppBar: true,
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(_character.avatar),
radius: 16,
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_character.name, style: const TextStyle(fontSize: 16, color: Colors.white)),
Row(
children: [
Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFF10B981), shape: BoxShape.circle)),
const SizedBox(width: 4),
Text('Online', style: TextStyle(fontSize: 10, color: Colors.white.withOpacity(0.7))),
],
)
],
)
],
),
actions: [
IconButton(icon: const Icon(LucideIcons.moreVertical, color: Colors.white), onPressed: () {}),
],
),
body: Container(
decoration: const BoxDecoration(
color: Color(0xFF2E1065),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2E1065), Color(0xFF0F172A)],
)
),
child: Column(
children: [
Expanded(
child: ListView.separated(
padding: const EdgeInsets.only(top: 120, bottom: 20, left: 16, right: 16),
itemCount: _messages.length,
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (context, index) {
final msg = _messages[index];
final isMe = msg.sender == MessageSender.user;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
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.text,
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.4),
),
),
);
},
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)),
),
),
// Input Area
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: () => setState(() => _isVoiceMode = true),
child: Container(
width: 44,
height: 44,
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(
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),
decoration: InputDecoration(
filled: true,
fillColor: Colors.white.withOpacity(0.05),
hintText: 'Type a message...',
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),
color: _getStatusColor(_character!.status),
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _sendMessage,
child: Container(
width: 44,
height: 44,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: [Color(0xFFA855F7), Color(0xFFEC4899)])
),
child: const Icon(LucideIcons.send, color: Colors.white, size: 20),
const SizedBox(width: 4),
Text(
_getStatusText(_character!.status),
style: TextStyle(fontSize: 10, color: Colors.white.withOpacity(0.7)),
),
)
],
),
),
),
)
],
), // Column
), // Container
), // Scaffold
if (_isVoiceMode)
VoiceModeOverlay(
character: _character,
],
),
],
),
),
],
),
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: Container(
decoration: const BoxDecoration(
color: Color(0xFF2E1065),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2E1065), Color(0xFF0F172A)],
),
),
child: Column(
children: [
Expanded(
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.only(top: 120, bottom: 20, left: 16, right: 16),
itemCount: _messages.length + (_isTyping ? 1 : 0),
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (context, index) {
// 如果是正在输入的消息
if (_isTyping && index == _messages.length) {
return _buildTypingBubble();
}
final msg = _messages[index];
return _buildMessageBubble(msg, avatarUrl);
},
),
),
// Input Area
_buildInputArea(),
],
),
),
),
if (_isVoiceMode && _character != null)
VoiceModeOverlay(
character: _character!,
onClose: () => setState(() => _isVoiceMode = false),
),
],
);
}
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: () => setState(() => _isVoiceMode = true),
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 '离线';
}
}
}

View File

@@ -1,11 +1,10 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../models/character.dart';
import '../../core/core.dart';
class VoiceModeOverlay extends StatefulWidget {
final Character character;
final CharacterModel character;
final VoidCallback onClose;
const VoiceModeOverlay({
@@ -23,6 +22,8 @@ class _VoiceModeOverlayState extends State<VoiceModeOverlay> with SingleTickerPr
bool _isSpeakerOn = true;
late AnimationController _controller;
String get _avatarUrl => CharacterRepository.getAvatarUrl(widget.character.avatarPath);
@override
void initState() {
super.initState();
@@ -49,7 +50,7 @@ class _VoiceModeOverlayState extends State<VoiceModeOverlay> with SingleTickerPr
// Background Image with Blur
Positioned.fill(
child: Image.network(
widget.character.avatar,
_avatarUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(color: const Color(0xFF2E1065));
@@ -152,7 +153,7 @@ class _VoiceModeOverlayState extends State<VoiceModeOverlay> with SingleTickerPr
)
],
image: DecorationImage(
image: NetworkImage(widget.character.avatar),
image: NetworkImage(_avatarUrl),
fit: BoxFit.cover,
),
),