up
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user