This commit is contained in:
root
2026-10-16 13:51:00 +08:00
parent 6bf949949c
commit 8fc899d0a9
3830 changed files with 11228 additions and 188377 deletions
@@ -0,0 +1,312 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
import '../../services/upload_service.dart';
import '../../services/video_service.dart';
/// 分类 Tab(首页 Tab1):分类筛选 + 双列视频网格,点击进入播放页。
/// 数据源:/videos/categories + /videos/feed(category_id 过滤,游标分页)
class CategoryScreen extends ConsumerStatefulWidget {
const CategoryScreen({super.key});
@override
ConsumerState<CategoryScreen> createState() => _CategoryScreenState();
}
class _CategoryScreenState extends ConsumerState<CategoryScreen> {
List<VideoCategory> _categories = const [];
int? _selected; // null = 全部
List<VideoItem> _items = const [];
String? _cursor;
bool _loading = true;
bool _loadingMore = false;
bool _failed = false;
final _scroll = ScrollController();
@override
void initState() {
super.initState();
_bootstrap();
_scroll.addListener(_onScroll);
}
@override
void dispose() {
_scroll.removeListener(_onScroll);
_scroll.dispose();
super.dispose();
}
void _onScroll() {
if (_scroll.position.pixels >= _scroll.position.maxScrollExtent - 400) {
_loadMore();
}
}
Future<void> _bootstrap() async {
setState(() {
_loading = true;
_failed = false;
});
try {
final results = await Future.wait([
UploadService.fetchCategories(),
VideoService.fetchFeed(type: 'hot', categoryId: _selected, limit: 10),
]);
if (!mounted) return;
setState(() {
_categories = results[0] as List<VideoCategory>;
_items = (results[1] as FeedPage).items;
_cursor = (results[1] as FeedPage).nextCursor;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
Future<void> _selectCategory(int? id) async {
if (id == _selected) return;
setState(() => _selected = id);
await _reload();
}
Future<void> _reload() async {
setState(() {
_loading = true;
_failed = false;
});
try {
final page = await VideoService.fetchFeed(
type: 'hot', categoryId: _selected, limit: 10);
if (!mounted) return;
setState(() {
_items = page.items;
_cursor = page.nextCursor;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
Future<void> _loadMore() async {
if (_loadingMore || _loading || _failed || _cursor == null) return;
setState(() => _loadingMore = true);
try {
final page = await VideoService.fetchFeed(
type: 'hot', cursor: _cursor, categoryId: _selected, limit: 10);
if (!mounted) return;
setState(() {
_items = [..._items, ...page.items];
_cursor = page.nextCursor;
_loadingMore = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _loadingMore = false);
}
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
children: [
// 顶栏
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Text('分类',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: const Icon(Icons.search,
color: AppColors.textSecondary),
onPressed: () => context.push('/search'),
),
],
),
),
// 分类筛选条
SizedBox(
height: 44,
child: _categories.isEmpty
? const SizedBox.shrink()
: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
_chip('全部', null),
for (final c in _categories) _chip(c.name, c.categoryId),
],
),
),
Expanded(child: _buildBody()),
],
),
);
}
Widget _chip(String label, int? id) {
final active = id == _selected;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: active,
onSelected: (_) => _selectCategory(id),
selectedColor: AppColors.primary,
backgroundColor: AppColors.surface,
labelStyle: TextStyle(
color: active ? Colors.white : AppColors.textSecondary,
),
),
);
}
Widget _buildBody() {
if (_loading && _items.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: AppColors.primary));
}
if (_failed && _items.isEmpty) {
return Center(
child: TextButton(
onPressed: _selected == null ? _bootstrap : _reload,
child: const Text('加载失败,点击重试',
style: TextStyle(color: AppColors.primary)),
),
);
}
if (_items.isEmpty) {
return const Center(
child: Text('该分类暂无视频',
style: TextStyle(color: AppColors.textSecondary)));
}
return RefreshIndicator(
onRefresh: _reload,
color: AppColors.primary,
child: GridView.builder(
controller: _scroll,
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.72,
),
itemCount: _items.length + (_cursor != null ? 1 : 0),
itemBuilder: (_, index) {
if (index >= _items.length) {
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
return _CategoryCard(item: _items[index]);
},
),
);
}
}
class _CategoryCard extends StatelessWidget {
const _CategoryCard({required this.item});
final VideoItem item;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.push('/search/play', extra: item),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: AppColors.surface,
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
Icons.play_circle_outline,
color: AppColors.textWeak,
size: 40),
)
: const Icon(Icons.play_circle_outline,
color: AppColors.textWeak, size: 40),
),
// 观看数角标
Positioned(
right: 6,
top: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_arrow,
color: Colors.white, size: 12),
const SizedBox(width: 2),
Text(
_formatCount(item.viewCount),
style:
const TextStyle(color: Colors.white, fontSize: 10),
),
],
),
),
),
// 标题
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.all(6),
child: Text(
item.title.isNotEmpty ? item.title : '未命名视频',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
),
),
],
),
),
);
}
String _formatCount(int n) {
if (n >= 10000) return '${(n / 10000).toStringAsFixed(1)}w';
if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}k';
return '$n';
}
}