568 lines
22 KiB
Dart
568 lines
22 KiB
Dart
// 时间: 2026-03-22
|
||
// 功能: 全站诗词搜索页(收藏入口可跳转)
|
||
// 介绍: 调用 search.php,配合 NetworkListenerService 上报加载与搜索完成事件
|
||
// 最新变化: 2026-04-02 支持深色模式
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:get/get.dart';
|
||
import '../../constants/app_constants.dart';
|
||
import '../../services/network_listener_service.dart';
|
||
import '../../services/get/theme_controller.dart';
|
||
import '../../utils/http/poetry_api.dart';
|
||
import '../../widgets/main_navigation.dart';
|
||
|
||
/// 诗词搜索页(独立路由栈页面)
|
||
class ActiveSearchPage extends StatefulWidget {
|
||
const ActiveSearchPage({super.key, this.initialQuery});
|
||
|
||
final String? initialQuery;
|
||
|
||
@override
|
||
State<ActiveSearchPage> createState() => _ActiveSearchPageState();
|
||
}
|
||
|
||
class _ActiveSearchPageState extends State<ActiveSearchPage>
|
||
with NetworkListenerMixin {
|
||
static const String _loadKey = 'active_search';
|
||
|
||
final TextEditingController _controller = TextEditingController();
|
||
final FocusNode _focusNode = FocusNode();
|
||
final ThemeController _themeController = Get.find<ThemeController>();
|
||
|
||
String _field = '';
|
||
int _page = 1;
|
||
final int _pageSize = 20;
|
||
List<PoetryData> _items = [];
|
||
int _total = 0;
|
||
String? _error;
|
||
bool _initialized = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller.addListener(() => setState(() {}));
|
||
if (widget.initialQuery != null && widget.initialQuery!.trim().isNotEmpty) {
|
||
_controller.text = widget.initialQuery!.trim();
|
||
}
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!_initialized && _controller.text.trim().isNotEmpty) {
|
||
_initialized = true;
|
||
_runSearch(reset: true);
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
_focusNode.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _runSearch({required bool reset}) async {
|
||
final q = _controller.text.trim();
|
||
if (q.isEmpty) {
|
||
setState(() {
|
||
_error = '请输入关键词';
|
||
_items = [];
|
||
_total = 0;
|
||
});
|
||
return;
|
||
}
|
||
|
||
final nextPage = reset ? 1 : _page + 1;
|
||
if (!reset && !_hasMore) return;
|
||
|
||
setState(() => _error = null);
|
||
startNetworkLoading(_loadKey);
|
||
try {
|
||
final result = await PoetryApi.searchPoetry(
|
||
q: q,
|
||
field: _field,
|
||
page: nextPage,
|
||
limit: _pageSize,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
if (reset) {
|
||
_items = List<PoetryData>.from(result.list);
|
||
} else {
|
||
_items = [..._items, ...result.list];
|
||
}
|
||
_page = result.page;
|
||
_total = result.total;
|
||
});
|
||
sendSearchEvent(keyword: q);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _error = e.toString());
|
||
} finally {
|
||
endNetworkLoading(_loadKey);
|
||
}
|
||
}
|
||
|
||
bool get _hasMore => _items.length < _total;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final loading = isNetworkLoading(_loadKey);
|
||
|
||
bool isInTabBarView = false;
|
||
try {
|
||
final ancestor = context.findAncestorWidgetOfExactType<TabBarView>();
|
||
isInTabBarView = ancestor != null;
|
||
} catch (e) {
|
||
isInTabBarView = false;
|
||
}
|
||
|
||
return Obx(() {
|
||
final isDark = _themeController.isDarkModeRx.value;
|
||
|
||
return Scaffold(
|
||
backgroundColor: isDark ? const Color(0xFF121212) : Colors.grey[50],
|
||
appBar: !isInTabBarView
|
||
? AppBar(
|
||
title: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.travel_explore,
|
||
size: 20,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
'诗词搜索',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
backgroundColor: isDark ? Colors.grey[900] : Colors.white,
|
||
elevation: 1,
|
||
actions: [
|
||
IconButton(
|
||
icon: Icon(
|
||
Icons.more_vert,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
onPressed: () {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
backgroundColor: isDark
|
||
? Colors.grey[850]
|
||
: Colors.white,
|
||
builder: (context) => Container(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ListTile(
|
||
leading: Icon(
|
||
Icons.history,
|
||
color: isDark
|
||
? Colors.grey[300]
|
||
: Colors.black87,
|
||
),
|
||
title: Text(
|
||
'搜索历史(开发中)',
|
||
style: TextStyle(
|
||
color: isDark
|
||
? Colors.white
|
||
: Colors.black87,
|
||
),
|
||
),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: Icon(
|
||
Icons.settings,
|
||
color: isDark
|
||
? Colors.grey[300]
|
||
: Colors.black87,
|
||
),
|
||
title: Text(
|
||
'搜索设置(开发中)',
|
||
style: TextStyle(
|
||
color: isDark
|
||
? Colors.white
|
||
: Colors.black87,
|
||
),
|
||
),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
tooltip: '更多',
|
||
),
|
||
],
|
||
)
|
||
: null,
|
||
body: SafeArea(
|
||
top: !isInTabBarView,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
AppConstants.pageHorizontalPadding,
|
||
8,
|
||
AppConstants.pageHorizontalPadding,
|
||
4,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Material(
|
||
color: isDark ? Colors.grey[800] : Colors.grey[100],
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: TextField(
|
||
controller: _controller,
|
||
focusNode: _focusNode,
|
||
textInputAction: TextInputAction.search,
|
||
onSubmitted: (_) => _runSearch(reset: true),
|
||
style: TextStyle(
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
decoration: InputDecoration(
|
||
hintText: '输入关键词,搜标题 / 标签 / 译文…',
|
||
hintStyle: TextStyle(
|
||
color: isDark
|
||
? Colors.grey[500]
|
||
: Colors.grey,
|
||
),
|
||
prefixIcon: Icon(
|
||
Icons.search,
|
||
color: isDark
|
||
? Colors.grey[400]
|
||
: Colors.grey,
|
||
),
|
||
suffixIcon: _controller.text.isNotEmpty
|
||
? IconButton(
|
||
icon: Icon(
|
||
Icons.clear,
|
||
color: isDark
|
||
? Colors.grey[400]
|
||
: Colors.grey,
|
||
),
|
||
onPressed: () {
|
||
_controller.clear();
|
||
setState(() {
|
||
_items = [];
|
||
_total = 0;
|
||
_error = null;
|
||
});
|
||
},
|
||
)
|
||
: IconButton(
|
||
icon: Icon(
|
||
Icons.arrow_forward,
|
||
color: isDark
|
||
? Colors.grey[400]
|
||
: Colors.grey,
|
||
),
|
||
tooltip: '搜索',
|
||
onPressed: () =>
|
||
_runSearch(reset: true),
|
||
),
|
||
border: InputBorder.none,
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 12,
|
||
),
|
||
),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
),
|
||
Wrap(
|
||
spacing: 4,
|
||
runSpacing: 4,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
ChoiceChip(
|
||
label: Text(
|
||
'全部',
|
||
style: TextStyle(
|
||
color: _field.isEmpty
|
||
? Colors.white
|
||
: (isDark
|
||
? Colors.grey[300]
|
||
: Colors.black87),
|
||
),
|
||
),
|
||
selected: _field.isEmpty,
|
||
selectedColor: AppConstants.primaryColor,
|
||
backgroundColor: isDark
|
||
? Colors.grey[800]
|
||
: Colors.grey[200],
|
||
onSelected: (_) {
|
||
setState(() => _field = '');
|
||
if (_controller.text.trim().isNotEmpty) {
|
||
_runSearch(reset: true);
|
||
}
|
||
},
|
||
),
|
||
ChoiceChip(
|
||
label: Text(
|
||
'标题',
|
||
style: TextStyle(
|
||
color: _field == 'name'
|
||
? Colors.white
|
||
: (isDark
|
||
? Colors.grey[300]
|
||
: Colors.black87),
|
||
),
|
||
),
|
||
selected: _field == 'name',
|
||
selectedColor: AppConstants.primaryColor,
|
||
backgroundColor: isDark
|
||
? Colors.grey[800]
|
||
: Colors.grey[200],
|
||
onSelected: (_) {
|
||
setState(() => _field = 'name');
|
||
if (_controller.text.trim().isNotEmpty) {
|
||
_runSearch(reset: true);
|
||
}
|
||
},
|
||
),
|
||
ChoiceChip(
|
||
label: Text(
|
||
'标签',
|
||
style: TextStyle(
|
||
color: _field == 'keywords'
|
||
? Colors.white
|
||
: (isDark
|
||
? Colors.grey[300]
|
||
: Colors.black87),
|
||
),
|
||
),
|
||
selected: _field == 'keywords',
|
||
selectedColor: AppConstants.primaryColor,
|
||
backgroundColor: isDark
|
||
? Colors.grey[800]
|
||
: Colors.grey[200],
|
||
onSelected: (_) {
|
||
setState(() => _field = 'keywords');
|
||
if (_controller.text.trim().isNotEmpty) {
|
||
_runSearch(reset: true);
|
||
}
|
||
},
|
||
),
|
||
ChoiceChip(
|
||
label: Text(
|
||
'译文',
|
||
style: TextStyle(
|
||
color: _field == 'introduce'
|
||
? Colors.white
|
||
: (isDark
|
||
? Colors.grey[300]
|
||
: Colors.black87),
|
||
),
|
||
),
|
||
selected: _field == 'introduce',
|
||
selectedColor: AppConstants.primaryColor,
|
||
backgroundColor: isDark
|
||
? Colors.grey[800]
|
||
: Colors.grey[200],
|
||
onSelected: (_) {
|
||
setState(() => _field = 'introduce');
|
||
if (_controller.text.trim().isNotEmpty) {
|
||
_runSearch(reset: true);
|
||
}
|
||
},
|
||
),
|
||
],
|
||
),
|
||
if (_error != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(
|
||
_error!,
|
||
style: const TextStyle(
|
||
color: Colors.red,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: RefreshIndicator(
|
||
color: AppConstants.primaryColor,
|
||
onRefresh: () async {
|
||
await _runSearch(reset: true);
|
||
},
|
||
child: _buildListBody(loading, isDark),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
floatingActionButton: !isInTabBarView
|
||
? FloatingActionButton(
|
||
onPressed: () {
|
||
if (Navigator.of(context).canPop()) {
|
||
Navigator.of(context).pop();
|
||
} else {
|
||
Navigator.of(context).pushReplacement(
|
||
MaterialPageRoute(builder: (_) => const MainNavigation()),
|
||
);
|
||
}
|
||
},
|
||
backgroundColor: AppConstants.primaryColor,
|
||
foregroundColor: Colors.white,
|
||
tooltip: '返回上一页',
|
||
child: const Icon(Icons.arrow_back),
|
||
)
|
||
: null,
|
||
);
|
||
});
|
||
}
|
||
|
||
Widget _buildListBody(bool loading, bool isDark) {
|
||
if (loading && _items.isEmpty) {
|
||
return ListView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
children: const [
|
||
SizedBox(height: 120),
|
||
Center(child: CircularProgressIndicator()),
|
||
],
|
||
);
|
||
}
|
||
|
||
if (_items.isEmpty && !loading) {
|
||
return ListView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
children: [
|
||
SizedBox(height: MediaQuery.of(context).size.height * 0.15),
|
||
Icon(
|
||
Icons.manage_search,
|
||
size: 64,
|
||
color: isDark ? Colors.grey[500] : Colors.grey[400],
|
||
),
|
||
const SizedBox(height: 12),
|
||
Center(
|
||
child: Text(
|
||
_controller.text.trim().isEmpty ? '输入关键词开始搜索' : '暂无结果',
|
||
style: TextStyle(
|
||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||
fontSize: 16,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
return ListView.builder(
|
||
padding: EdgeInsets.fromLTRB(
|
||
AppConstants.pageHorizontalPadding,
|
||
0,
|
||
AppConstants.pageHorizontalPadding,
|
||
24,
|
||
),
|
||
itemCount: _items.length + (_hasMore ? 1 : 0),
|
||
itemBuilder: (context, index) {
|
||
if (index >= _items.length) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
child: Center(
|
||
child: loading
|
||
? const CircularProgressIndicator()
|
||
: TextButton.icon(
|
||
onPressed: () => _runSearch(reset: false),
|
||
icon: Icon(
|
||
Icons.expand_more,
|
||
color: isDark ? Colors.grey[400] : Colors.black87,
|
||
),
|
||
label: Text(
|
||
'加载更多',
|
||
style: TextStyle(
|
||
color: isDark ? Colors.grey[400] : Colors.black87,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
final p = _items[index];
|
||
return Card(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
elevation: 1,
|
||
color: isDark ? Colors.grey[850] : Colors.white,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 8,
|
||
),
|
||
title: Text(
|
||
p.name.isNotEmpty ? p.name : p.url,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
),
|
||
subtitle: Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (p.alias.isNotEmpty)
|
||
Text(
|
||
'📜 ${p.alias}',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: isDark ? Colors.grey[400] : Colors.grey[700],
|
||
),
|
||
),
|
||
if (p.introduce.isNotEmpty)
|
||
Text(
|
||
p.introduce,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: isDark ? Colors.grey[300] : Colors.grey[800],
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'👍 ${p.like} · 🔥 ${p.hitsTotal}',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
isThreeLine: true,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|