Files
kitchen/docs/superpowers/plans/2026-04-14-feature-carousel.md
2026-04-14 05:35:30 +08:00

18 KiB
Raw Blame History

功能轮播组件实现计划

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: 验证轮播功能

手动验证:

  1. 自动轮播是否每 3 秒切换
  2. 手动滑动是否流畅
  3. 底部指示器是否正确显示当前页
  4. 深色模式是否正常显示
  • Step 3: 验证点击事件

手动验证:

  1. 点击"应用推荐"是否弹出确认对话框
  2. 点击"厨艺技巧"是否跳转正确页面
  3. 点击"运营数据"是否跳转正确页面
  4. 点击"今天吃什么"是否随机推荐菜谱

完成标准

  1. 轮播组件正常显示在主页
  2. 六个轮播项都能正确展示
  3. 自动轮播和手动滑动都正常工作
  4. 所有点击事件正确触发
  5. 深色模式适配正常
  6. 无语法错误和运行时错误
  7. CHANGELOG 已更新