18 KiB
功能轮播组件实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在主页创建一个功能完整的轮播组件,替换现有的营养卡片,提供六个功能入口。
Architecture: 使用 PageView.builder + Timer 实现自动轮播,每个轮播项采用渐变背景卡片设计,支持手动滑动和自动播放切换。
Tech Stack: Flutter, GetX, Cupertino 组件, url_launcher
文件结构
lib/src/widgets/carousel/
└── feature_carousel_card.dart # 新建:主轮播组件
修改文件:
├── lib/src/pages/home/home_page.dart:656 # 替换 NutritionDashboardCard
└── CHANGELOG.md # 记录变更
Task 1: 创建轮播组件基础结构
Files:
-
Create:
lib/src/widgets/carousel/feature_carousel_card.dart -
Step 1: 创建轮播组件文件框架
/*
* 文件: feature_carousel_card.dart
* 名称: 功能轮播卡片
* 作用: 首页功能入口轮播组件,包含今日营养、应用推荐等功能
* 创建: 2026-04-14
* 更新: 2026-04-14 初始创建
*/
import 'dart:async';
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/config/app_routes.dart';
import 'package:mom_kitchen/src/controllers/data/meal_record_controller.dart';
import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
import 'package:mom_kitchen/src/widgets/custom_widgets.dart';
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
class FeatureCarouselCard extends StatefulWidget {
const FeatureCarouselCard({super.key});
@override
State<FeatureCarouselCard> createState() => _FeatureCarouselCardState();
}
class _FeatureCarouselCardState extends State<FeatureCarouselCard> {
final PageController _pageController = PageController();
Timer? _autoPlayTimer;
int _currentPage = 0;
bool _isUserInteracting = false;
static const int _itemCount = 6;
static const Duration _autoPlayInterval = Duration(seconds: 3);
static const Duration _animationDuration = Duration(milliseconds: 350);
@override
void initState() {
super.initState();
_startAutoPlay();
}
@override
void dispose() {
_autoPlayTimer?.cancel();
_pageController.dispose();
super.dispose();
}
void _startAutoPlay() {
_autoPlayTimer = Timer.periodic(_autoPlayInterval, (_) {
if (!_isUserInteracting && mounted) {
final nextPage = (_currentPage + 1) % _itemCount;
_pageController.animateToPage(
nextPage,
duration: _animationDuration,
curve: Curves.easeInOutCubic,
);
}
});
}
void _onPageChanged(int page) {
setState(() => _currentPage = page);
}
void _onUserInteraction() {
if (!_isUserInteracting) {
setState(() => _isUserInteracting = true);
Future.delayed(const Duration(seconds: 3), () {
if (mounted) setState(() => _isUserInteracting = false);
});
}
}
@override
Widget build(BuildContext context) {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
return Container(
margin: const EdgeInsets.symmetric(horizontal: DesignTokens.space4),
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusLg,
boxShadow: DesignTokens.shadowsSm,
),
child: ClipRRect(
borderRadius: DesignTokens.borderRadiusLg,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 180,
child: GestureDetector(
onPanDown: (_) => _onUserInteraction(),
child: PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: _itemCount,
itemBuilder: (context, index) => _buildCarouselItem(index, isDark),
),
),
),
_buildPageIndicator(isDark),
],
),
),
);
}
Widget _buildCarouselItem(int index, bool isDark) {
switch (index) {
case 0:
return _buildNutritionItem(isDark);
case 1:
return _buildAppRecommendItem(isDark);
case 2:
return _buildCookingTipsItem(isDark);
case 3:
return _buildStatsDashboardItem(isDark);
case 4:
return _buildRandomRecipeItem(isDark);
case 5:
return _buildComingSoonItem(isDark);
default:
return const SizedBox.shrink();
}
}
Widget _buildPageIndicator(bool isDark) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_itemCount, (index) {
final isActive = index == _currentPage;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 8 : 6,
height: isActive ? 8 : 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? DesignTokens.dynamicPrimary
: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.4),
),
);
}),
),
);
}
Widget _buildNutritionItem(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFF007AFF), Color(0xFF5AC8FA)],
child: _buildNutritionContent(isDark),
);
}
Widget _buildNutritionContent(bool isDark) {
try {
final controller = Get.find<MealRecordController>();
return Obx(() {
final calories = controller.dayNutrition['calories'] ?? 0;
final protein = controller.dayNutrition['protein'] ?? 0;
final fat = controller.dayNutrition['fat'] ?? 0;
final carbs = controller.dayNutrition['carbs'] ?? 0;
return Column(
children: [
Padding(
padding: const EdgeInsets.all(DesignTokens.space3),
child: Row(
children: [
const Text('📊', style: TextStyle(fontSize: 20)),
const SizedBox(width: DesignTokens.space2),
const Text(
'今日营养',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const Spacer(),
GestureDetector(
onTap: () => Get.toNamed('/nutrition'),
child: Row(
children: const [
Text(
'详情',
style: TextStyle(fontSize: 12, color: Colors.white70),
),
Icon(
CupertinoIcons.chevron_right,
size: 14,
color: Colors.white70,
),
],
),
),
],
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
NutritionRing(
progress: controller.caloriesPercent,
size: 56,
strokeWidth: 6,
color: Colors.white,
centerLabel: 'kcal',
centerValue: '${calories.toInt()}',
bottomLabel: '热量',
),
NutritionRing(
progress: controller.proteinPercent,
size: 56,
strokeWidth: 6,
color: Colors.white,
centerLabel: 'g',
centerValue: '${protein.toInt()}',
bottomLabel: '蛋白质',
),
NutritionRing(
progress: controller.fatPercent,
size: 56,
strokeWidth: 6,
color: Colors.white,
centerLabel: 'g',
centerValue: '${fat.toInt()}',
bottomLabel: '脂肪',
),
NutritionRing(
progress: controller.carbsPercent,
size: 56,
strokeWidth: 6,
color: Colors.white,
centerLabel: 'g',
centerValue: '${carbs.toInt()}',
bottomLabel: '碳水',
),
],
),
),
],
);
});
} catch (_) {
return _buildNutritionPlaceholder(isDark);
}
}
Widget _buildNutritionPlaceholder(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFF007AFF), Color(0xFF5AC8FA)],
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CupertinoActivityIndicator(radius: 12, color: Colors.white),
const SizedBox(width: DesignTokens.space3),
Text(
'正在加载营养数据…',
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: Colors.white.withValues(alpha: 0.8),
),
),
],
),
),
);
}
Widget _buildAppRecommendItem(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFF9C27B0), Color(0xFFE040FB)],
onTap: () => _showAppRecommendDialog(context),
child: _buildFeatureContent(
icon: '🌟',
title: '应用推荐',
subtitle: '发现更多优质应用',
badge: 'NEW',
),
);
}
void _showAppRecommendDialog(BuildContext context) {
showCupertinoDialog(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: const Text('应用推荐'),
content: const Text('即将跳转至浏览器访问推荐应用页面,是否继续?'),
actions: [
CupertinoDialogAction(
child: const Text('取消'),
onPressed: () => Navigator.pop(ctx),
),
CupertinoDialogAction(
isDefaultAction: true,
child: const Text('确认'),
onPressed: () async {
Navigator.pop(ctx);
final uri = Uri.parse('https://poe.vogov.cn/app.html');
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
ToastService.show(message: '无法打开链接 🥲');
}
},
),
],
),
);
}
Widget _buildCookingTipsItem(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFFFF9500), Color(0xFFFFCC00)],
onTap: () => Get.toNamed(AppRoutes.cookingTips),
child: _buildFeatureContent(
icon: '👨🍳',
title: '厨艺技巧',
subtitle: '提升你的烹饪技能',
badge: null,
),
);
}
Widget _buildStatsDashboardItem(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFF34C759), Color(0xFF30D158)],
onTap: () => Get.toNamed(AppRoutes.statsDashboard),
child: _buildFeatureContent(
icon: '📈',
title: '运营数据',
subtitle: '查看热门排行榜',
badge: null,
),
);
}
Widget _buildRandomRecipeItem(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFFFF3B30), Color(0xFFFF6961)],
onTap: _randomRecipe,
child: _buildFeatureContent(
icon: '🎲',
title: '今天吃什么',
subtitle: '随机推荐一道美味',
badge: null,
),
);
}
Future<void> _randomRecipe() async {
try {
ToastService.show(message: '正在随机选择菜谱… 🎲');
final repo = RecipeRepository();
final result = await repo.fetchList(limit: 50);
if (result.items.isNotEmpty) {
final random = result.items[Random().nextInt(result.items.length)];
Get.toNamed('/recipe-detail', arguments: '${random.id}');
} else {
ToastService.show(message: '暂无菜谱数据 🥲');
}
} catch (e) {
ToastService.show(message: '随机推荐失败: $e 🔄');
}
}
Widget _buildComingSoonItem(bool isDark) {
return _CarouselItemContainer(
gradient: [
isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
],
child: _buildFeatureContent(
icon: '🎁',
title: '敬请期待',
subtitle: '更多功能正在开发中',
badge: null,
),
);
}
Widget _buildFeatureContent({
required String icon,
required String title,
required String subtitle,
String? badge,
}) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 48)),
const SizedBox(height: DesignTokens.space2),
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: DesignTokens.space1),
Text(
subtitle,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: Colors.white.withValues(alpha: 0.8),
),
),
if (badge != null) ...[
const SizedBox(height: DesignTokens.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(12),
),
child: Text(
badge,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
],
),
);
}
}
class _CarouselItemContainer extends StatelessWidget {
final List<Color> gradient;
final Widget child;
final VoidCallback? onTap;
const _CarouselItemContainer({
required this.gradient,
required this.child,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradient,
),
),
child: child,
),
);
}
}
- Step 2: 检查语法错误
运行: flutter analyze lib/src/widgets/carousel/feature_carousel_card.dart
预期: 无错误
Task 2: 更新主页引用
Files:
-
Modify:
lib/src/pages/home/home_page.dart:656 -
Step 1: 更新 import 和替换组件
找到第 656 行附近的代码:
SliverToBoxAdapter(child: const NutritionDashboardCard()),
替换为:
SliverToBoxAdapter(child: const FeatureCarouselCard()),
- Step 2: 添加 import
在文件顶部的 import 区域添加:
import 'package:mom_kitchen/src/widgets/carousel/feature_carousel_card.dart';
- Step 3: 检查语法错误
运行: flutter analyze lib/src/pages/home/home_page.dart
预期: 无错误
Task 3: 更新 CHANGELOG
Files:
-
Modify:
CHANGELOG.md -
Step 1: 添加版本变更记录
在 CHANGELOG.md 顶部添加新版本记录:
## [0.70.0] - 2026-04-14
### 新增
- 功能轮播组件 `FeatureCarouselCard`,替换原有的营养卡片
- 今日营养:展示营养数据环形图
- 应用推荐:弹窗确认后跳转浏览器
- 厨艺技巧:跳转烹饪技巧页面
- 运营数据:跳转数据大屏页面
- 今天吃什么:随机推荐菜谱
- 敬请期待:占位项
- 支持自动轮播(3秒间隔)和手动滑动
- 底部进度指示器
- 渐变背景卡片设计
### 变更
- 主页营养卡片替换为功能轮播组件
- Step 2: 更新 pubspec.yaml 版本号
将 pubspec.yaml 中的版本号从 0.69.0+69 更新为 0.70.0+70
Task 4: 验证功能
- Step 1: 运行应用
运行: flutter run
预期: 应用正常启动,主页显示轮播组件
- Step 2: 验证轮播功能
手动验证:
- 自动轮播是否每 3 秒切换
- 手动滑动是否流畅
- 底部指示器是否正确显示当前页
- 深色模式是否正常显示
- Step 3: 验证点击事件
手动验证:
- 点击"应用推荐"是否弹出确认对话框
- 点击"厨艺技巧"是否跳转正确页面
- 点击"运营数据"是否跳转正确页面
- 点击"今天吃什么"是否随机推荐菜谱
完成标准
- 轮播组件正常显示在主页
- 六个轮播项都能正确展示
- 自动轮播和手动滑动都正常工作
- 所有点击事件正确触发
- 深色模式适配正常
- 无语法错误和运行时错误
- CHANGELOG 已更新