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(