/* * 文件: 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 createState() => _SearchPageState(); } class _SearchPageState extends State { late final SearchController _searchController; final TextEditingController _textEditingController = TextEditingController(); final FocusNode _focusNode = FocusNode(); @override void initState() { super.initState(); _searchController = Get.find(); _focusNode.requestFocus(); _checkInitialKeyword(); } void _checkInitialKeyword() { final args = Get.arguments; if (args is Map && 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, ), ), ], ), ), ); } }