This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
+358
View File
@@ -0,0 +1,358 @@
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 '../../providers/search_provider.dart';
/// 搜索页(《01-开发文档》2.5.8):
/// 默认页:本地搜索历史(最近 10 条)+ 热搜榜 Top10
/// 结果页:热度/最新排序切换 + 双列封面网格,点击进单视频播放页
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _inputController = TextEditingController();
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
ref.read(searchProvider.notifier).loadHot();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_inputController.dispose();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(searchProvider.notifier).loadMore();
}
}
void _submit(String keyword) =>
ref.read(searchProvider.notifier).search(keyword);
@override
Widget build(BuildContext context) {
final state = ref.watch(searchProvider);
final notifier = ref.read(searchProvider.notifier);
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 搜索输入栏
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 20),
onPressed: () => context.pop(),
),
Expanded(
child: TextField(
controller: _inputController,
autofocus: true,
maxLength: 30,
textInputAction: TextInputAction.search,
onSubmitted: _submit,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(
hintText: '搜索视频标题或标签',
counterText: '',
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
),
),
TextButton(
onPressed: () => _submit(_inputController.text),
child: const Text(
'搜索',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
// 内容:默认页 / 结果页
Expanded(
child: state.searched
? _buildResults(state, notifier)
: _buildDefault(state, notifier),
),
],
),
),
);
}
/// 默认页:搜索历史 + 热搜榜
Widget _buildDefault(SearchState state, SearchNotifier notifier) {
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
if (state.history.isNotEmpty) ...[
Row(
children: [
const Expanded(
child: Text(
'搜索历史',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: const Icon(Icons.delete_outline,
size: 20, color: AppColors.textSecondary),
onPressed: notifier.clearHistory,
),
],
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final word in state.history)
GestureDetector(
onTap: () {
_inputController.text = word;
_submit(word);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Text(
word,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
),
),
),
),
],
),
const SizedBox(height: 24),
],
const Text(
'热搜榜',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
if (state.hotWords.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'暂无热搜',
style: TextStyle(color: AppColors.textWeak),
),
),
)
else
for (var i = 0; i < state.hotWords.length; i++)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: SizedBox(
width: 24,
child: Text(
'${i + 1}',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
// 前三名品牌红,其余灰色
color: i < 3 ? AppColors.primary : AppColors.textWeak,
),
),
),
title: Text(state.hotWords[i]),
onTap: () {
_inputController.text = state.hotWords[i];
_submit(state.hotWords[i]);
},
),
],
);
}
/// 结果页:排序切换 + 双列网格
Widget _buildResults(SearchState state, SearchNotifier notifier) {
return Column(
children: [
// 排序切换
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
for (final s in SearchSort.values) ...[
GestureDetector(
onTap: () => notifier.search(state.keyword, sort: s),
child: Text(
s.label,
style: TextStyle(
fontSize: 14,
fontWeight: s == state.sort
? FontWeight.w700
: FontWeight.w400,
color: s == state.sort
? Colors.white
: AppColors.textSecondary,
),
),
),
const SizedBox(width: 20),
],
const Spacer(),
if (state.total > 0)
Text(
'共 ${state.total} 条',
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: state.isSearching
? const Center(
child: CircularProgressIndicator(color: AppColors.primary),
)
: state.items.isEmpty
? const Center(
child: Text(
'未找到相关视频',
style: TextStyle(color: AppColors.textSecondary),
),
)
: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.68,
),
itemCount:
state.items.length + (state.isLoadingMore ? 2 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
);
}
return VideoGridCard(item: state.items[index]);
},
),
),
],
);
}
}
/// 视频网格卡片:封面 + 标题 + 作者 + 播放量(搜索结果 / 标签视频页共用)
class VideoGridCard extends StatelessWidget {
const VideoGridCard({super.key, required this.item});
final VideoItem item;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.push('/search/play', extra: item),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: double.infinity,
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,
),
),
),
),
const SizedBox(height: 4),
Text(
item.title.isNotEmpty ? item.title : '未命名视频',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
Text(
'${item.author.nickname} · ${_formatCount(item.viewCount)}次播放',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 11,
),
),
],
),
);
}
static String _formatCount(int n) {
if (n >= 10000) return '${(n / 10000).toStringAsFixed(1)}w';
return n.toString();
}
}
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/search_provider.dart';
import '../../providers/tag_videos_provider.dart';
import 'search_screen.dart';
/// 标签视频页(《01-开发文档》5.1 /tags/:name,《03-接口文档》5.9,游客可):
/// 展示该标签下全部已发布视频,支持 热度/最新 排序切换 + 触底加载更多
class TagVideosScreen extends ConsumerStatefulWidget {
const TagVideosScreen({super.key, required this.tagName});
final String tagName;
@override
ConsumerState<TagVideosScreen> createState() => _TagVideosScreenState();
}
class _TagVideosScreenState extends ConsumerState<TagVideosScreen> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(tagVideosProvider(widget.tagName).notifier).loadMore();
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(tagVideosProvider(widget.tagName));
final notifier = ref.read(tagVideosProvider(widget.tagName).notifier);
return Scaffold(
appBar: AppBar(title: Text('# ${widget.tagName}')),
body: Column(
children: [
// 排序切换 + 总数
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
for (final s in SearchSort.values) ...[
GestureDetector(
onTap: () => notifier.load(sort: s),
child: Text(
s.label,
style: TextStyle(
fontSize: 14,
fontWeight: s == state.sort
? FontWeight.w700
: FontWeight.w400,
color: s == state.sort
? Colors.white
: AppColors.textSecondary,
),
),
),
const SizedBox(width: 20),
],
const Spacer(),
if (state.total > 0)
Text(
'共 ${state.total} 条',
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
],
),
),
Expanded(
child: state.isLoading
? const Center(
child: CircularProgressIndicator(color: AppColors.primary),
)
: state.items.isEmpty
? Center(
child: state.loadFailed
? TextButton(
onPressed: () => notifier.load(),
child: const Text(
'加载失败,点击重试',
style: TextStyle(color: AppColors.primary),
),
)
: const Text(
'该标签下暂无视频',
style: TextStyle(
color: AppColors.textSecondary),
),
)
: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.68,
),
itemCount:
state.items.length + (state.isLoadingMore ? 2 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
);
}
return VideoGridCard(item: state.items[index]);
},
),
),
],
),
);
}
}