Files
kitchen/lib/src/pages/home/home_card_carousel.dart
2026-04-11 02:02:23 +08:00

844 lines
28 KiB
Dart

/*
* 文件: home_card_carousel.dart
* 说明: 首页卡片式横向滚动组件。支持左右滑动、过渡动画和卡片布局。
* 作用: 提供美观的卡片信息流浏览体验。
* 作者: 前端工程师
* 更新时间: 2026-04-10
* 上次更新: 添加 Badge 显示"新"/"热"标签
*/
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:mom_kitchen/src/controllers/home_controller.dart';
import 'package:mom_kitchen/src/controllers/shopping_list_controller.dart';
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
import 'package:mom_kitchen/src/models/shopping_item_model.dart';
import 'package:mom_kitchen/src/services/ui/theme_service.dart';
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
class HomeCardCarousel extends StatefulWidget {
const HomeCardCarousel({super.key});
@override
State<HomeCardCarousel> createState() => _HomeCardCarouselState();
}
class _HomeCardCarouselState extends State<HomeCardCarousel> {
late PageController _pageController;
final _currentIndex = 0.obs;
late double _viewportFraction;
late bool _lastIsLandscape;
@override
void initState() {
super.initState();
_viewportFraction = 0.85;
_lastIsLandscape = false;
_pageController = PageController(
viewportFraction: _viewportFraction,
initialPage: 0,
);
}
void _updatePageController(bool isLandscape) {
if (_lastIsLandscape != isLandscape) {
_lastIsLandscape = isLandscape;
final newFraction = isLandscape ? 0.30 : 0.85;
if (_viewportFraction != newFraction) {
_viewportFraction = newFraction;
_pageController.dispose();
_pageController = PageController(
viewportFraction: _viewportFraction,
initialPage: _currentIndex.value,
);
}
}
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final homeController = Get.find<HomeController>();
final themeService = Get.find<ThemeService>();
final isLandscape =
MediaQuery.of(context).orientation == Orientation.landscape;
_updatePageController(isLandscape);
return Obx(() {
final recipes = homeController.filteredRecipes;
if (homeController.isLoading.value) {
return Center(child: CupertinoActivityIndicator(radius: 30));
}
if (recipes.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('🔍', style: TextStyle(fontSize: 48)),
const SizedBox(height: 16),
Text(
'暂无菜谱',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: themeService.textColor.value,
),
),
const SizedBox(height: 8),
Text(
'试试其他搜索或分类',
style: TextStyle(
fontSize: 14,
color: themeService.textColor.value.withValues(alpha: 0.6),
),
),
],
),
);
}
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Container(
decoration: BoxDecoration(
color: themeService.backgroundColor.value.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: themeService.textColor.value.withValues(alpha: 0.1),
),
),
child: CupertinoSearchTextField(
placeholder: '搜索菜谱...',
style: TextStyle(color: themeService.textColor.value),
placeholderStyle: TextStyle(
color: themeService.textColor.value.withValues(alpha: 0.5),
),
decoration: null,
onChanged: homeController.search,
),
),
),
Expanded(
child: Obx(() {
final isVertical =
themeService.cardScrollDirection.value ==
CardScrollDirection.vertical;
if (isVertical) {
return ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
itemCount: recipes.length,
itemBuilder: (context, index) {
final recipe = recipes[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildVerticalRecipeCard(
recipe,
themeService,
homeController,
),
);
},
);
}
return PageView.builder(
controller: _pageController,
onPageChanged: (index) {
_currentIndex.value = index;
},
itemCount: recipes.length,
itemBuilder: (context, index) {
final recipe = recipes[index];
final padding = isLandscape
? const EdgeInsets.symmetric(horizontal: 4, vertical: 8)
: const EdgeInsets.symmetric(horizontal: 8, vertical: 16);
return Padding(
padding: padding,
child: _buildRecipeCard(
recipe,
themeService,
homeController,
isCompact: isLandscape,
),
);
},
);
}),
),
],
);
});
}
Widget _buildRecipeCard(
RecipeModel recipe,
ThemeService themeService,
HomeController homeController, {
bool isCompact = false,
}) {
return GestureDetector(
onTap: () {},
child: Container(
decoration: BoxDecoration(
color: themeService.backgroundColor.value,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: themeService.textColor.value.withValues(alpha: 0.1),
width: 0.5,
),
boxShadow: [
BoxShadow(
color: themeService.textColor.value.withValues(alpha: 0.1),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: isCompact
? _buildCompactRecipeCard(recipe, themeService, homeController)
: _buildFullRecipeCard(recipe, themeService, homeController),
),
);
}
Widget _buildVerticalRecipeCard(
RecipeModel recipe,
ThemeService themeService,
HomeController homeController,
) {
return GestureDetector(
onTap: () {},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: themeService.backgroundColor.value,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: themeService.textColor.value.withValues(alpha: 0.1),
width: 0.5,
),
boxShadow: [
BoxShadow(
color: themeService.textColor.value.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: themeService.primaryColor.value.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
CupertinoIcons.book,
size: 28,
color: themeService.primaryColor.value.withValues(alpha: 0.6),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: themeService.textColor.value,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
if (recipe.intro != null && recipe.intro!.isNotEmpty)
Text(
recipe.intro!,
style: TextStyle(
fontSize: 12,
color: themeService.textColor.value.withAlpha(153),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
_buildStatsRow(recipe, themeService),
],
),
),
const SizedBox(width: 8),
if (recipe.ingredients.isNotEmpty)
GestureDetector(
onTap: () => _addToShoppingList(recipe),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: themeService.primaryColor.value.withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
CupertinoIcons.cart_badge_plus,
color: themeService.primaryColor.value,
size: 18,
),
),
),
],
),
),
);
}
Widget _buildFullRecipeCard(
RecipeModel recipe,
ThemeService themeService,
HomeController homeController,
) {
final badgeInfo = _getRecipeBadge(recipe);
return Column(
children: [
Expanded(
flex: 2,
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
child: Container(
color: themeService.primaryColor.value.withValues(alpha: 0.1),
child: Center(
child: Icon(
CupertinoIcons.book,
size: 64,
color: themeService.primaryColor.value.withValues(
alpha: 0.6,
),
),
),
),
),
if (badgeInfo != null)
Positioned(
top: 10,
left: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: badgeInfo['color'],
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: (badgeInfo['color'] as Color).withValues(
alpha: 0.3,
),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Text(
badgeInfo['label'],
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: themeService.textColor.value,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
if (recipe.intro != null && recipe.intro!.isNotEmpty)
Text(
recipe.intro!,
style: TextStyle(
fontSize: 12,
color: themeService.textColor.value.withAlpha(153),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildStatsRow(recipe, themeService),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (recipe.ingredients.isNotEmpty)
GestureDetector(
onTap: () => _addToShoppingList(recipe),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: themeService.primaryColor.value
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
CupertinoIcons.cart_badge_plus,
color: themeService.primaryColor.value,
size: 20,
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
ToastService.show(message: '${recipe.title} 👀');
},
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: themeService.primaryColor.value,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: themeService.primaryColor.value
.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
CupertinoIcons.eye,
color: CupertinoColors.white,
size: 20,
),
),
),
],
),
],
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Obx(
() => SizedBox(
height: 32,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: homeController.categoryNames.length,
itemBuilder: (context, index) {
final name = homeController.categoryNames[index];
final isAll = index == 0;
final isSelected = isAll
? homeController.selectedCategory.value == null
: homeController.selectedCategory.value?.name == name;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: GestureDetector(
onTap: () {
homeController.selectCategoryByName(isAll ? '' : name);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: isSelected
? themeService.primaryColor.value
: themeService.textColor.value.withValues(
alpha: 0.08,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? themeService.primaryColor.value
: themeService.textColor.value.withValues(
alpha: 0.15,
),
width: 0.5,
),
),
child: Center(
child: Text(
name,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isSelected
? CupertinoColors.white
: themeService.textColor.value,
),
),
),
),
),
);
},
),
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Obx(
() => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
homeController.filteredRecipes.length,
(index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: _currentIndex.value == index ? 18 : 6,
height: 6,
decoration: BoxDecoration(
color: _currentIndex.value == index
? themeService.primaryColor.value
: themeService.textColor.value.withValues(
alpha: 0.25,
),
borderRadius: BorderRadius.circular(3),
),
),
),
),
),
),
),
],
);
}
Widget _buildCompactRecipeCard(
RecipeModel recipe,
ThemeService themeService,
HomeController homeController,
) {
final badgeInfo = _getRecipeBadge(recipe);
return Column(
children: [
Expanded(
flex: 2,
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
child: Container(
color: themeService.primaryColor.value.withValues(alpha: 0.1),
child: Center(
child: Icon(
CupertinoIcons.book,
size: 40,
color: themeService.primaryColor.value.withValues(
alpha: 0.6,
),
),
),
),
),
if (badgeInfo != null)
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: badgeInfo['color'],
borderRadius: BorderRadius.circular(8),
),
child: Text(
badgeInfo['label'],
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 9,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: themeService.textColor.value,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: _buildStatsRow(recipe, themeService)),
GestureDetector(
onTap: () {
ToastService.show(message: '${recipe.title} 👀');
},
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: themeService.primaryColor.value,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: themeService.primaryColor.value.withValues(
alpha: 0.3,
),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
CupertinoIcons.eye,
color: CupertinoColors.white,
size: 16,
),
),
),
],
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Obx(
() => SizedBox(
height: 26,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: homeController.categoryNames.length,
itemBuilder: (context, index) {
final name = homeController.categoryNames[index];
final isAll = index == 0;
final isSelected = isAll
? homeController.selectedCategory.value == null
: homeController.selectedCategory.value?.name == name;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: GestureDetector(
onTap: () {
homeController.selectCategoryByName(isAll ? '' : name);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: isSelected
? themeService.primaryColor.value
: themeService.textColor.value.withValues(
alpha: 0.08,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? themeService.primaryColor.value
: themeService.textColor.value.withValues(
alpha: 0.15,
),
width: 0.5,
),
),
child: Center(
child: Text(
name,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: isSelected
? CupertinoColors.white
: themeService.textColor.value,
),
),
),
),
),
);
},
),
),
),
),
],
);
}
Widget _buildStatsRow(RecipeModel recipe, ThemeService themeService) {
final stats = recipe.statistics;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
CupertinoIcons.eye,
size: 14,
color: themeService.textColor.value.withValues(alpha: 0.5),
),
const SizedBox(width: 2),
Text(
'${stats?.views ?? 0}',
style: TextStyle(
fontSize: 12,
color: themeService.textColor.value.withValues(alpha: 0.6),
),
),
const SizedBox(width: 8),
Icon(
CupertinoIcons.heart,
size: 14,
color: themeService.textColor.value.withValues(alpha: 0.5),
),
const SizedBox(width: 2),
Text(
'${stats?.likes ?? 0}',
style: TextStyle(
fontSize: 12,
color: themeService.textColor.value.withValues(alpha: 0.6),
),
),
const SizedBox(width: 8),
Icon(
CupertinoIcons.star,
size: 14,
color: themeService.textColor.value.withValues(alpha: 0.5),
),
const SizedBox(width: 2),
Text(
'${stats?.recommends ?? 0}',
style: TextStyle(
fontSize: 12,
color: themeService.textColor.value.withValues(alpha: 0.6),
),
),
],
);
}
Map<String, dynamic>? _getRecipeBadge(RecipeModel recipe) {
final stats = recipe.statistics;
final createdAt = recipe.createdAt;
final bool isHot = (stats?.views ?? 0) > 1000 || (stats?.likes ?? 0) > 100;
bool isNew = false;
if (createdAt != null) {
try {
final created = DateTime.parse(createdAt);
final now = DateTime.now();
final diff = now.difference(created).inDays;
isNew = diff <= 7;
} catch (_) {}
}
if (isNew) {
return {'label': '✨ 新', 'color': const Color(0xFF34C759)};
}
if (isHot) {
return {'label': '🔥 热', 'color': const Color(0xFFFF3B30)};
}
return null;
}
void _addToShoppingList(RecipeModel recipe) {
final ctrl = Get.find<ShoppingListController>();
final items = recipe.ingredients.map((ing) {
return ShoppingItemModel(
name: ing.name,
amount: ing.amount,
unit: ing.unit,
category: ShoppingCategory.other.name,
recipeId: recipe.id,
createdAt: DateTime.now().toIso8601String(),
);
}).toList();
ctrl.addItemsFromRecipe(recipe.id, recipe.title, items);
}
}