Files
MookNote/lib/pages/online_search/online_search_page.dart
2026-08-09 22:07:55 +08:00

1105 lines
39 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../../utils/server_config.dart';
import '../../utils/user_prefs.dart';
import '../../utils/toast_util.dart';
import 'movie_detail_page.dart';
import 'book_detail_page.dart';
/// 在线搜索影视/书籍(带 Scaffold + AppBar
class OnlineSearchPage extends StatelessWidget {
const OnlineSearchPage({super.key});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: const Text('在线搜索'),
elevation: 0,
scrolledUnderElevation: 0,
),
body: const OnlineSearchPageBody(),
);
}
}
/// 在线搜索 body —— 可嵌入到统一搜索页 SearchHubPage
class OnlineSearchPageBody extends StatefulWidget {
const OnlineSearchPageBody({super.key});
@override
State<OnlineSearchPageBody> createState() => _OnlineSearchPageBodyState();
}
class _OnlineSearchPageBodyState extends State<OnlineSearchPageBody> {
final _searchController = TextEditingController();
final _focusNode = FocusNode();
final _movieScrollController = ScrollController();
final _bookScrollController = ScrollController();
final _userPrefs = UserPrefs();
String _query = '';
bool _hasSearched = false;
List<String> _history = [];
List<Map<String, dynamic>> _movieList = [];
int _moviePage = 1;
int _moviePageCount = 1;
bool _movieLoading = false;
bool _movieLoadingMore = false;
int _movieTotal = 0;
List<Map<String, dynamic>> _bookList = [];
int _bookPage = 1;
int _bookPageCount = 1;
bool _bookLoading = false;
bool _bookLoadingMore = false;
int _bookTotal = 0;
@override
void initState() {
super.initState();
_movieScrollController.addListener(_onMovieScroll);
_bookScrollController.addListener(_onBookScroll);
_history = _userPrefs.searchHistory;
_currentTab = _userPrefs.lastSearchTab;
}
@override
void dispose() {
_searchController.dispose();
_focusNode.dispose();
_movieScrollController.dispose();
_bookScrollController.dispose();
super.dispose();
}
void _onMovieScroll() {
if (_movieScrollController.position.pixels >=
_movieScrollController.position.maxScrollExtent - 200 &&
!_movieLoadingMore &&
_moviePage < _moviePageCount) {
_loadMoreMovies();
}
}
void _onBookScroll() {
if (_bookScrollController.position.pixels >=
_bookScrollController.position.maxScrollExtent - 200 &&
!_bookLoadingMore &&
_bookPage < _bookPageCount) {
_loadMoreBooks();
}
}
void _doSearch() {
final q = _searchController.text.trim();
if (q.isEmpty) return;
_focusNode.unfocus();
setState(() {
_query = q;
_hasSearched = true;
_movieList = [];
_moviePage = 1;
_moviePageCount = 1;
_movieTotal = 0;
_bookList = [];
_bookPage = 1;
_bookPageCount = 1;
_bookTotal = 0;
});
_userPrefs.addSearchHistory(q).then((_) {
setState(() {
_history = _userPrefs.searchHistory;
});
});
if (_currentTab == 0) {
_searchMovies(q, 1);
} else {
_searchBooks(q, 1);
}
}
Future<void> _searchMovies(String keyword, int page) async {
final token = UserPrefs().movieSearchToken;
if (token.isEmpty) {
if (mounted) ToastUtil.show(context, '请先在设置中配置影视搜索 Token');
return;
}
setState(() {
if (page == 1) {
_movieLoading = true;
} else {
_movieLoadingMore = true;
}
});
try {
final url =
'${ServerConfig.vipBaseUrl}/api/movie/list?movieName=${Uri.encodeComponent(keyword)}&token=$token&page=$page';
final resp =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 20));
if (!mounted) return;
if (resp.statusCode == 200) {
final data = json.decode(resp.body);
if (data['code'] == 0 && data['data'] != null) {
final list = (data['data']['list'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ??
[];
setState(() {
if (page == 1) {
_movieList = list;
} else {
_movieList.addAll(list);
}
_movieTotal = data['data']['total'] ?? 0;
_moviePage = data['data']['page'] ?? page;
_moviePageCount = data['data']['pagecount'] ?? page;
});
} else if (data['code'] == 401 ||
data['code'] == 403 ||
data['msg']?.toString().contains('token') == true ||
data['msg']?.toString().contains('过期') == true) {
if (mounted)
setState(() {
_movieLoading = false;
_movieLoadingMore = false;
});
_showTokenExpiredDialog();
return;
}
}
} catch (e) {
if (mounted) {
final msg = e.toString().contains('TimeoutException')
? '搜索超时,请稍后重试'
: '搜索失败,请检查网络';
ToastUtil.show(context, msg);
}
}
if (mounted) {
setState(() {
_movieLoading = false;
_movieLoadingMore = false;
});
}
}
void _loadMoreMovies() {
_searchMovies(_query, _moviePage + 1);
}
Future<void> _searchBooks(String keyword, int page) async {
final token = UserPrefs().bookSearchToken;
if (token.isEmpty) return;
setState(() {
if (page == 1) {
_bookLoading = true;
} else {
_bookLoadingMore = true;
}
});
try {
final url =
'${ServerConfig.vipBaseUrl}/api/book/list?title=${Uri.encodeComponent(keyword)}&token=$token&page=$page';
final resp =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 20));
if (!mounted) return;
if (resp.statusCode == 200) {
final data = json.decode(resp.body);
if (data['code'] == 0 && data['data'] != null) {
final list = (data['data']['list'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ??
[];
setState(() {
if (page == 1) {
_bookList = list;
} else {
_bookList.addAll(list);
}
_bookTotal = data['data']['total'] ?? 0;
_bookPage = data['data']['page'] ?? page;
_bookPageCount = data['data']['pagecount'] ?? page;
});
} else if (data['code'] == 401 ||
data['code'] == 403 ||
data['msg']?.toString().contains('token') == true ||
data['msg']?.toString().contains('过期') == true) {
if (mounted)
setState(() {
_bookLoading = false;
_bookLoadingMore = false;
});
_showTokenExpiredDialog();
return;
}
}
} catch (e) {
if (mounted) {
final msg = e.toString().contains('TimeoutException')
? '搜索超时,请稍后重试'
: '搜索失败,请检查网络';
ToastUtil.show(context, msg);
}
}
if (mounted) {
setState(() {
_bookLoading = false;
_bookLoadingMore = false;
});
}
}
void _loadMoreBooks() {
_searchBooks(_query, _bookPage + 1);
}
int _currentTab = 0;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surface,
child: Column(
children: [
// 搜索栏 + 搜索按钮
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Row(
children: [
Expanded(child: _buildSearchBar(colors)),
TextButton(
onPressed: _doSearch,
child: Text('搜索',
style: TextStyle(fontSize: 14, color: colors.primary)),
),
],
),
),
// 影视/书籍 tab 栏(搜索后显示)
if (_hasSearched)
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: colors.outlineVariant, width: 0.5))),
child: Row(children: [
_buildTabButton(colors, '影视', 0, _movieTotal),
_buildTabButton(colors, '书籍', 1, _bookTotal),
]),
),
Expanded(
child: _hasSearched
? (_currentTab == 0
? _buildMovieResults(colors)
: _buildBookResults(colors))
: _buildHistoryPanel(colors),
),
],
),
);
}
Widget _buildTabButton(
ColorScheme colors, String label, int index, int count) {
final selected = _currentTab == index;
return GestureDetector(
onTap: () {
if (_currentTab == index) return;
setState(() => _currentTab = index);
_userPrefs.setLastSearchTab(index);
// 切到新 tab 时,若该 tab 尚无数据则触发搜索
if (_hasSearched && _query.isNotEmpty) {
if (index == 0 && _movieList.isEmpty && !_movieLoading) {
_searchMovies(_query, 1);
} else if (index == 1 && _bookList.isEmpty && !_bookLoading) {
_searchBooks(_query, 1);
}
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? colors.primary : Colors.transparent,
width: 2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected
? colors.primary
: colors.onSurface.withValues(alpha: 0.4))),
if (count > 0) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: selected
? colors.primary.withValues(alpha: 0.1)
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text('$count',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: selected
? colors.primary
: colors.onSurface.withValues(alpha: 0.4))),
),
],
],
),
),
);
}
Widget _buildSearchBar(ColorScheme colors) {
return Container(
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Row(
children: [
const SizedBox(width: 10),
Icon(Icons.search,
size: 16, color: colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: _searchController,
focusNode: _focusNode,
style: TextStyle(fontSize: 13, color: colors.onSurface),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: '搜索影视、书籍...',
hintStyle: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.3)),
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
),
onSubmitted: (_) => _doSearch(),
),
),
if (_query.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
setState(() {
_query = '';
_hasSearched = false;
_movieList = [];
});
},
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(Icons.close,
size: 15, color: colors.onSurface.withValues(alpha: 0.3)),
),
),
if (_query.isEmpty) const SizedBox(width: 10),
],
),
);
}
void _showTokenExpiredDialog() {
showDialog(
context: context,
builder: (ctx) {
final c = Theme.of(ctx).colorScheme;
return AlertDialog(
backgroundColor: c.surface,
elevation: 0,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('Token 已过期',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: c.onSurface)),
content: Text('当前 Token 已过期,请重新获取',
style: TextStyle(
fontSize: 14,
color: c.onSurface.withValues(alpha: 0.6),
height: 1.5)),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(ctx),
style: ElevatedButton.styleFrom(
backgroundColor: c.primary,
foregroundColor: c.onPrimary,
elevation: 0,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: const Text('知道了', style: TextStyle(fontSize: 14)),
),
],
);
},
);
}
// ── 影视搜索结果 ──────────────────────────────────────────────
Widget _buildMovieResults(ColorScheme colors) {
if (!_hasSearched)
return _buildEmptyState(colors, '搜索你想看的影视作品', Icons.movie_outlined);
if (UserPrefs().movieSearchToken.isEmpty)
return _buildEmptyState(
colors, '填入 Token 后可正常使用该功能', Icons.vpn_key_outlined);
if (_movieLoading) return _buildLoadingState(colors);
if (_movieList.isEmpty)
return _buildEmptyState(colors, '未找到相关内容', Icons.search_off_outlined);
final hasMore = _moviePage < _moviePageCount;
final itemCount = _movieList.length + 1; // +1 for bottom indicator
return ListView.builder(
controller: _movieScrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: itemCount,
itemBuilder: (context, index) {
if (index == _movieList.length) {
return _buildBottomIndicator(colors, hasMore,
loadingMore: _movieLoadingMore);
}
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
MovieDetailPage(vodId: _movieList[index]['vod_id']))),
child: _buildMovieCard(colors, _movieList[index]),
),
);
},
);
}
Widget _buildMovieCard(ColorScheme colors, Map<String, dynamic> m) {
final name = m['vod_name'] ?? '';
final year = m['vod_year'] ?? '';
final area = m['vod_area'] ?? '';
final typeName = m['type_name'] ?? '';
final className = m['vod_class'] ?? '';
final director = m['vod_director'] ?? '';
final pic = m['vod_pic'] ?? '';
final isEnd = m['vod_isend'] ?? 0;
final isManual = m['is_manual_optimized'] ?? 0;
final tag = m['vod_tag'] ?? '';
return Container(
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 海报
SizedBox(
width: 100,
child: pic.toString().isNotEmpty
? Image.network(pic,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _posterPlaceholder(colors))
: _posterPlaceholder(colors),
),
// 信息
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:名称 + 完结状态
Row(
children: [
Expanded(
child: Text(name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colors.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 6),
_buildStatusTag(colors, isEnd),
],
),
const SizedBox(height: 6),
// 第二行:年份+地区+类型
_buildInfoRow(colors, [
if (year.toString().isNotEmpty) year.toString(),
if (area.toString().isNotEmpty) area.toString(),
if (typeName.toString().isNotEmpty) typeName.toString(),
]),
if (className.toString().isNotEmpty) ...[
const SizedBox(height: 3),
Text(className,
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4)),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 6),
// 第三行:官方优化 + 标签
if (isManual == 1 || tag.toString().isNotEmpty)
_buildTagRow(colors, isManual, tag.toString()),
// 第四行:导演
if (director.toString().isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
Icon(Icons.person_outline,
size: 12,
color: colors.onSurface.withValues(alpha: 0.35)),
const SizedBox(width: 4),
Expanded(
child: Text(director,
style: TextStyle(
fontSize: 11,
color: colors.onSurface
.withValues(alpha: 0.5)),
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
],
],
),
),
),
],
),
),
);
}
Widget _buildStatusTag(ColorScheme colors, int isEnd) {
final isFinished = isEnd == 1;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isFinished
? const Color(0xFF16A34A).withValues(alpha: 0.1)
: const Color(0xFFF59E0B).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
isFinished ? '已完结' : '连载中',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isFinished ? const Color(0xFF16A34A) : const Color(0xFFF59E0B),
),
),
);
}
Widget _buildInfoRow(ColorScheme colors, List<String> items) {
return Wrap(
spacing: 6,
children: items
.map((s) => Text(s,
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.5))))
.toList(),
);
}
Widget _buildTagRow(ColorScheme colors, int isManual, String tag) {
final tags = tag.split(',').where((t) => t.trim().isNotEmpty).toList();
return Wrap(
spacing: 4,
runSpacing: 4,
children: [
if (isManual == 1)
_buildSmallTag('官方优化', const Color(0xFF16A34A), isHighlight: true),
...tags.take(4).map((t) =>
_buildSmallTag(t.trim(), colors.onSurface.withValues(alpha: 0.4))),
if (tags.length > 4)
Text('+${tags.length - 4}',
style: TextStyle(
fontSize: 10,
color: colors.onSurface.withValues(alpha: 0.3))),
],
);
}
Widget _buildSmallTag(String text, Color textColor,
{bool isHighlight = false}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: isHighlight
? const Color(0xFF16A34A).withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(3),
border: Border.all(
color: isHighlight
? const Color(0xFF16A34A).withValues(alpha: 0.3)
: textColor.withValues(alpha: 0.2),
width: 0.5,
),
),
child: Text(text,
style: TextStyle(
fontSize: 10,
color: isHighlight ? const Color(0xFF16A34A) : textColor)),
);
}
Widget _posterPlaceholder(ColorScheme colors) {
return Container(
color: colors.surfaceContainerHighest,
child: Center(
child: Icon(Icons.movie_outlined,
size: 28, color: colors.onSurface.withValues(alpha: 0.2))),
);
}
// ── 书籍搜索结果 ──────────────────────────────────────────────
Widget _buildBookResults(ColorScheme colors) {
if (!_hasSearched)
return _buildEmptyState(colors, '搜索你想看的书籍', Icons.menu_book_outlined);
if (UserPrefs().bookSearchToken.isEmpty)
return _buildEmptyState(
colors, '填入 Token 后可正常使用该功能', Icons.vpn_key_outlined);
if (_bookLoading) return _buildLoadingState(colors);
if (_bookList.isEmpty)
return _buildEmptyState(colors, '未找到相关书籍', Icons.search_off_outlined);
final hasMore = _bookPage < _bookPageCount;
final itemCount = _bookList.length + 1;
return ListView.builder(
controller: _bookScrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: itemCount,
itemBuilder: (context, index) {
if (index == _bookList.length) {
return _buildBottomIndicator(colors, hasMore,
loadingMore: _bookLoadingMore);
}
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BookDetailPage(
bookId: _bookList[index]['id'].toString()))),
child: _buildBookCard(colors, _bookList[index]),
),
);
},
);
}
Widget _buildBookCard(ColorScheme colors, Map<String, dynamic> b) {
final title = b['title'] ?? '';
final author = b['author'] ?? '';
final press = b['press'] ?? '';
final year = b['publishedDate'] ?? '';
final cover = b['cover'] ?? '';
final isbn = b['isbn'] ?? '';
return Container(
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 80,
child: cover.toString().isNotEmpty
? Image.network(cover,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_bookCoverPlaceholder(colors))
: _bookCoverPlaceholder(colors),
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colors.onSurface),
maxLines: 2,
overflow: TextOverflow.ellipsis),
if (author.toString().isNotEmpty) ...[
const SizedBox(height: 3),
Text(author,
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.5)),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
if (press.toString().isNotEmpty) ...[
const SizedBox(height: 3),
Text(press,
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4)),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
if (year.toString().isNotEmpty) ...[
const SizedBox(height: 3),
Text('出版年份 $year',
style: TextStyle(
fontSize: 10,
color: colors.onSurface.withValues(alpha: 0.35))),
],
if (isbn.toString().isNotEmpty) ...[
const SizedBox(height: 3),
Text('ISBN $isbn',
style: TextStyle(
fontSize: 10,
color: colors.onSurface.withValues(alpha: 0.35))),
],
],
),
),
),
],
),
),
);
}
Widget _bookCoverPlaceholder(ColorScheme colors) {
return Container(
color: colors.surfaceContainerHighest,
child: Center(
child: Icon(Icons.menu_book_outlined,
size: 24, color: colors.onSurface.withValues(alpha: 0.2))),
);
}
// ── 通用状态 ──────────────────────────────────────────────────
Widget _buildLoadingState(ColorScheme colors) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2.5, color: colors.primary),
),
const SizedBox(height: 16),
Text('正在搜索...',
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.4))),
],
),
);
}
Widget _buildBottomIndicator(ColorScheme colors, bool hasMore,
{bool loadingMore = false}) {
if (hasMore && loadingMore) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: colors.primary)),
const SizedBox(width: 8),
Text('加载中...',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.4))),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(child: Container(height: 0.5, color: colors.outlineVariant)),
const SizedBox(width: 10),
Flexible(
child: Text(
'已经是所有数据啦,要是没有的话,请联系开发者添加哦~',
style: TextStyle(
fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 10),
Expanded(child: Container(height: 0.5, color: colors.outlineVariant)),
],
),
);
}
// ── 搜索历史 ──────────────────────────────────────────────────
Widget _buildHistoryPanel(ColorScheme colors) {
if (_history.isEmpty)
return _buildEmptyState(colors, '搜索你想看的影视/书籍作品', Icons.manage_search_outlined);
return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
children: [
Row(
children: [
Text('搜索历史',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
const Spacer(),
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (ctx) {
final c = Theme.of(ctx).colorScheme;
return AlertDialog(
backgroundColor: c.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
title: Text('清空搜索记录',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: c.onSurface)),
content: Text('确定删除全部搜索记录?',
style: TextStyle(
fontSize: 14,
color: c.onSurface.withValues(alpha: 0.6),
height: 1.5)),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(
foregroundColor:
c.onSurface.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8))),
child:
const Text('取消', style: TextStyle(fontSize: 14)),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
_userPrefs.clearSearchHistory();
setState(() {
_history = [];
});
},
style: ElevatedButton.styleFrom(
backgroundColor: c.error,
foregroundColor: c.onError,
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8))),
child:
const Text('清空', style: TextStyle(fontSize: 14)),
),
],
);
},
);
},
child: Text('清空',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.4))),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _history
.map((kw) => GestureDetector(
onTap: () {
_searchController.text = kw;
_doSearch();
},
onLongPress: () {
showDialog(
context: context,
builder: (ctx) {
final c = Theme.of(ctx).colorScheme;
return AlertDialog(
backgroundColor: c.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
title: Text('删除搜索记录',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: c.onSurface)),
content: Text('确定删除「$kw」?',
style: TextStyle(
fontSize: 14,
color: c.onSurface.withValues(alpha: 0.6),
height: 1.5)),
actionsPadding:
const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(
foregroundColor:
c.onSurface.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: const Text('取消',
style: TextStyle(fontSize: 14)),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
_userPrefs.removeSearchHistory(kw).then((_) {
if (mounted)
setState(() {
_history = _userPrefs.searchHistory;
});
});
},
style: ElevatedButton.styleFrom(
backgroundColor: c.error,
foregroundColor: c.onError,
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: const Text('删除',
style: TextStyle(fontSize: 14)),
),
],
);
},
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colors.outlineVariant, width: 0.5),
),
child: Text(kw,
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.6))),
),
))
.toList(),
),
],
);
}
Widget _buildEmptyState(ColorScheme colors, String hint, IconData icon) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Icon(icon,
size: 36, color: colors.onSurface.withValues(alpha: 0.2)),
),
const SizedBox(height: 20),
Text(hint,
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.35))),
const SizedBox(height: 4),
Text('输入关键词后点击搜索',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.2))),
],
),
);
}
}