Files
kitchen/lib/src/pages/home/search_page.dart
2026-04-11 07:07:13 +08:00

731 lines
25 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.
/*
* 文件: search_page.dart
* 名称: 搜索页面
* 作用: iOS 26 风格的菜谱搜索页面,支持搜索历史、热门搜索、实时搜索
* 更新: 2026-04-10 完全重写优化UI和交互体验
*/
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Colors;
import 'package:get/get.dart';
import '../../controllers/search_controller.dart';
import '../../config/design_tokens.dart';
import '../../models/recipe/recipe_model.dart';
import '../../widgets/recipe_image.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
late final SearchController _searchController;
final TextEditingController _textEditingController = TextEditingController();
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_searchController = Get.find<SearchController>();
_focusNode.requestFocus();
_checkInitialKeyword();
}
void _checkInitialKeyword() {
final args = Get.arguments;
if (args is Map<String, dynamic> && args.containsKey('keyword')) {
final keyword = args['keyword'] as String;
if (keyword.isNotEmpty) {
_textEditingController.text = keyword;
_searchController.search(keyword);
_focusNode.unfocus();
}
} else if (args is String && args.isNotEmpty) {
_textEditingController.text = args;
_searchController.search(args);
_focusNode.unfocus();
}
}
@override
void dispose() {
_textEditingController.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
return CupertinoPageScaffold(
backgroundColor: isDark
? DarkDesignTokens.background
: DesignTokens.background,
navigationBar: CupertinoNavigationBar(
middle: _buildSearchBar(isDark),
backgroundColor: isDark
? DarkDesignTokens.background.withValues(alpha: 0.9)
: DesignTokens.background.withValues(alpha: 0.9),
border: null,
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: Icon(
CupertinoIcons.back,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
onPressed: () => Get.back(),
),
),
child: SafeArea(child: Obx(() => _buildContent(isDark))),
);
}
Widget _buildSearchBar(bool isDark) {
return Container(
height: 36,
decoration: BoxDecoration(
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const SizedBox(width: 8),
Icon(
CupertinoIcons.search,
size: 18,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
const SizedBox(width: 6),
Expanded(
child: CupertinoTextField(
controller: _textEditingController,
focusNode: _focusNode,
placeholder: '搜索菜谱、食材...',
placeholderStyle: TextStyle(
fontSize: 15,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
style: TextStyle(
fontSize: 15,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
decoration: const BoxDecoration(border: null),
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
_searchController.search(value);
_focusNode.unfocus();
}
},
onChanged: (value) {
if (value.isEmpty) {
_searchController.clearResults();
}
},
),
),
if (_textEditingController.text.isNotEmpty)
CupertinoButton(
minimumSize: Size.zero,
padding: const EdgeInsets.all(4),
onPressed: () {
_textEditingController.clear();
_searchController.clearResults();
_focusNode.requestFocus();
},
child: Icon(
CupertinoIcons.xmark_circle_fill,
size: 20,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
)
else
const SizedBox(width: 8),
],
),
);
}
Widget _buildContent(bool isDark) {
if (_searchController.searchQuery.value.isEmpty) {
return _buildInitialView(isDark);
} else if (_searchController.isLoading.value) {
return _buildLoadingView(isDark);
} else if (_searchController.searchResults.isEmpty) {
return _buildEmptyView(isDark);
} else {
return _buildResultsView(isDark);
}
}
Widget _buildInitialView(bool isDark) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space4,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 搜索历史
Obx(() {
if (_searchController.searchHistory.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'🕐 搜索历史',
style: TextStyle(
fontSize: DesignTokens.fontLg,
fontWeight: FontWeight.w600,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
CupertinoButton(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
onPressed: () => _searchController.clearSearchHistory(),
child: Text(
'清空',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark
? DarkDesignTokens.primary
: DesignTokens.primary,
),
),
),
],
),
const SizedBox(height: DesignTokens.space3),
Wrap(
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space2,
children: _searchController.searchHistory.map((history) {
return GestureDetector(
onTap: () {
_textEditingController.text = history;
_searchController.search(history);
},
onLongPress: () =>
_showRemoveHistoryDialog(history, isDark),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color:
(isDark
? DarkDesignTokens.text3
: DesignTokens.text3)
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
),
child: Text(
history,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
),
),
);
}).toList(),
),
const SizedBox(height: DesignTokens.space5),
],
);
}),
// 热门搜索
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'🔥 热门搜索',
style: TextStyle(
fontSize: DesignTokens.fontLg,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
const SizedBox(height: DesignTokens.space3),
Wrap(
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space2,
children: _searchController.hotSearches.map((keyword) {
return GestureDetector(
onTap: () {
_textEditingController.text = keyword;
_searchController.search(keyword);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
(isDark
? DarkDesignTokens.primary
: DesignTokens.primary)
.withValues(alpha: 0.12),
(isDark
? DarkDesignTokens.secondary
: DesignTokens.secondary)
.withValues(alpha: 0.08),
],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color:
(isDark
? DarkDesignTokens.primary
: DesignTokens.primary)
.withValues(alpha: 0.2),
width: 0.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
keyword,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark
? DarkDesignTokens.primary
: DesignTokens.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}).toList(),
),
],
),
],
),
);
}
void _showRemoveHistoryDialog(String history, bool isDark) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: const Text('删除记录'),
content: Text('确定要删除"$history"吗?'),
actions: [
CupertinoDialogAction(
isDefaultAction: true,
child: const Text('取消'),
onPressed: () => Navigator.pop(context),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: const Text('删除'),
onPressed: () {
_searchController.removeFromHistory(history);
Navigator.pop(context);
},
),
],
),
);
}
Widget _buildLoadingView(bool isDark) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CupertinoActivityIndicator(radius: 16),
const SizedBox(height: DesignTokens.space3),
Text(
'正在搜索"${_searchController.searchQuery.value}"...',
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
],
),
);
}
Widget _buildEmptyView(bool isDark) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space6,
),
child: Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(
CupertinoIcons.search,
size: 40,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
const SizedBox(height: DesignTokens.space4),
Text(
'未找到"${_searchController.searchQuery.value}"相关菜谱',
style: TextStyle(
fontSize: DesignTokens.fontLg,
fontWeight: FontWeight.w500,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
textAlign: TextAlign.center,
),
const SizedBox(height: DesignTokens.space2),
Text(
'试试其他关键词,如"红烧肉"、"糖醋排骨"',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
const SizedBox(height: DesignTokens.space5),
SizedBox(
width: 200,
height: 44,
child: CupertinoButton.filled(
borderRadius: BorderRadius.circular(22),
onPressed: () {
_textEditingController.clear();
_searchController.clearResults();
_focusNode.requestFocus();
},
child: const Text(
'重新搜索',
style: TextStyle(fontWeight: FontWeight.w500),
),
),
),
Obx(() {
if (!_searchController.hasSimilarResults.value ||
_searchController.similarResults.isEmpty) {
return const SizedBox();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: DesignTokens.space6),
Row(
children: [
Icon(
CupertinoIcons.lightbulb,
size: 18,
color: isDark
? DarkDesignTokens.primary
: DesignTokens.primary,
),
const SizedBox(width: 6),
Text(
'相似推荐',
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark
? DarkDesignTokens.primary
: DesignTokens.primary,
),
),
],
),
const SizedBox(height: DesignTokens.space3),
..._searchController.similarResults.map(
(recipe) => _buildSimilarItem(recipe, isDark),
),
],
);
}),
],
),
);
}
Widget _buildSimilarItem(RecipeModel recipe, bool isDark) {
return GestureDetector(
onTap: () {
Get.toNamed('/recipe-detail', arguments: '${recipe.id}');
},
child: Container(
margin: const EdgeInsets.only(bottom: DesignTokens.space2),
padding: const EdgeInsets.all(DesignTokens.space3),
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusMd,
border: Border.all(
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.1),
),
),
child: Row(
children: [
ClipRRect(
borderRadius: DesignTokens.borderRadiusSm,
child: RecipeImage(
recipeId: recipe.id,
coverUrl: recipe.cover,
width: 48,
height: 48,
fit: BoxFit.cover,
mode: RecipeImageMode.thumbnail,
),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w500,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (recipe.intro != null && recipe.intro!.isNotEmpty)
Text(
recipe.intro!,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(
CupertinoIcons.chevron_forward,
size: 16,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
],
),
),
);
}
Widget _buildResultsView(bool isDark) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 结果统计栏
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space2,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.1),
),
),
),
child: Text(
'找到 ${_searchController.searchResults.length} 个结果 · "${_searchController.searchQuery.value}"',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
),
// 结果列表
Expanded(
child: ListView.separated(
padding: const EdgeInsets.all(DesignTokens.space4),
itemCount: _searchController.searchResults.length,
separatorBuilder: (_, _) =>
const SizedBox(height: DesignTokens.space3),
itemBuilder: (context, index) {
final recipe = _searchController.searchResults[index];
return _buildRecipeItem(recipe, isDark);
},
),
),
],
);
}
Widget _buildRecipeItem(dynamic recipe, bool isDark) {
final title = recipe.title ?? '未知菜谱';
final intro = recipe.intro ?? '';
final category = recipe.categoryName;
final cover = recipe.cover;
final recipeId = recipe.id;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
try {
debugPrint('Tapped recipe: $title (ID: $recipeId)');
if (recipeId == null || recipeId <= 0) {
Get.snackbar('提示', '菜谱 ID 无效', snackPosition: SnackPosition.BOTTOM);
return;
}
Get.toNamed('/recipe-detail', arguments: '$recipeId');
} catch (e, stackTrace) {
debugPrint('Open recipe detail error: $e');
debugPrint('Stack trace: $stackTrace');
Get.snackbar(
'错误',
'无法打开菜谱详情: $e',
snackPosition: SnackPosition.BOTTOM,
);
}
},
child: Container(
padding: const EdgeInsets.all(DesignTokens.space3),
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: BorderRadius.circular(DesignTokens.radiusMd),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面图
ClipRRect(
borderRadius: BorderRadius.circular(DesignTokens.radiusSm),
child: RecipeImage(
recipeId: recipeId ?? 0,
coverUrl: cover,
width: 90,
height: 90,
fit: BoxFit.cover,
mode: RecipeImageMode.thumbnail,
),
),
const SizedBox(width: DesignTokens.space3),
// 信息区
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (category != null && category!.isNotEmpty) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color:
(isDark
? DarkDesignTokens.primary
: DesignTokens.primary)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'📂 $category',
style: TextStyle(
fontSize: 11,
color: isDark
? DarkDesignTokens.primary
: DesignTokens.primary,
),
),
),
],
if (intro.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
intro,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark
? DarkDesignTokens.text2
: DesignTokens.text2,
height: 1.3,
),
),
],
],
),
),
// 箭头
Padding(
padding: const EdgeInsets.only(left: 8, top: 4),
child: Icon(
CupertinoIcons.chevron_right,
size: 16,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
],
),
),
);
}
}