欢迎加入开源鸿蒙跨平台社区:
https://openharmonycrossplatform.csdn.net

演示效果

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

摘要:随着全球公共卫生体系对儿童游离糖过量摄入问题的高度关注,传统的静态图文饮食记录模式已经无法满足现代健康管理中对“即时负反馈”与“认知干预”的双重需求。本文基于 Flutter 跨平台框架,深度设计并实现了一套具备物理流体仿真动画、响应式拓扑布局以及色彩状态机预警机制的“儿童控糖追踪控制台”。本文将从流行病学标准、渲染层着色算法、正弦流体力学方程等多个维度,剖析跨平台健康管理软件底层的架构设计哲学。

一、 绪论:数字化健康干预的生物学与工程学背景

在现代公共卫生研究中,儿童期游离糖(Free Sugars)的摄入超标直接与青少年肥胖、早期II型糖尿病(T2DM)、非酒精性脂肪肝病以及严重龋齿的发生率高度正相关。根据世界卫生组织(WHO)颁布的《成人和儿童糖摄入量指南》,强烈建议将儿童一天的游离糖摄入量严格控制在总能量摄入的 10% 以下,而为了达到最佳的健康收益,进一步压缩至 5%(即大约每天 25 克)被视作黄金警戒线。

然而,在日常生活中,“克数”是一个极度抽象的物理概念。对于家长或儿童本身而言,“吃了一根含糖量12克的冰淇淋”往往无法触发直观的健康风险感知。传统的饮食记录软件由于缺乏动态的视觉冲击,其数据录入往往表现为枯燥的表单,最终导致依从性严重衰减。

基于以上背景,本工程从人机交互学(HCI)与数字疗法(Digital Therapeutics)的交叉视角出发,利用 Flutter 构建了一套具备高度沉浸感的动态控糖追踪系统。本系统最大的工程学突破在于:摒弃了生硬的环形进度条或柱状图,在 UI 渲染层底座重构了一个基于三角函数的“正弦物理流体槽(Liquid Wave Tank)”,通过水位上涨的压迫感与颜色的热度变化,实现深度的视觉心理干预。


二、 核心数学模型:流体动力学在视图渲染中的投射

在系统最核心的“糖分储蓄槽”动画渲染引擎中,波浪的翻滚并非是生硬的图片位移,而是通过实时计算每一帧的微分路径(Path)来动态构建的。

为了让流体运动显得自然且具备阻尼感,我们在 CustomPaint 画布内引入了基于时间的简谐运动(Simple Harmonic Motion)与正弦传播函数。

2.1 正弦波浪函数的空间展开

一条在屏幕像素坐标系中传播的波,可以被如下标准的波动方程所定义:

y ( x , t ) = A ⋅ sin ⁡ ( 2 π λ x ± ω t + ϕ ) + D y(x, t) = A \cdot \sin\left(\frac{2\pi}{\lambda} x \pm \omega t + \phi\right) + D y(x,t)=Asin(λ2πx±ωt+ϕ)+D

而在我们基于 Flutter Canvas 坐标系的实际着色管线中,这套方程被我们优化为一种更易于进行帧刷新的离散形式。设屏幕水槽的总宽度为 W W W,总高度为 H H H,当前的糖分填充占比为 R ∈ [ 0 , 1.2 ] R \in [0, 1.2] R[0,1.2](允许轻微超标溢出),当前帧的动画相位为 θ ( t ) \theta(t) θ(t)。水面的基线高度 H b a s e H_{base} Hbase 可以定义为:

H b a s e = H − ( H × min ⁡ ( R , 1.0 ) ) H_{base} = H - (H \times \min(R, 1.0)) Hbase=H(H×min(R,1.0))

当水波在水槽内激荡时,任意水平像素点 x x x 处的水波垂直高度坐标 y ( x ) y(x) y(x) 计算公式为:

y ( x , t ) = H b a s e + [ A 0 ⋅ ( 1 − 2 ∣ min ⁡ ( R , 1.0 ) − 0.5 ∣ ) ] ⋅ sin ⁡ ( 2 π k W x + θ ( t ) ) y(x, t) = H_{base} + \left[ A_0 \cdot \left(1 - 2\left| \min(R, 1.0) - 0.5 \right|\right) \right] \cdot \sin\left( \frac{2\pi k}{W} x + \theta(t) \right) y(x,t)=Hbase+[A0(12min(R,1.0)0.5)]sin(W2πkx+θ(t))

公式深度剖析:

$H_{base}$ (基础水位基线)
在 Flutter 的绘制坐标系中,屏幕左上角为 $(0,0)$,Y轴向下为正。因此水面上升意味着 Y 坐标值的减小。这是计算图形学中必须进行坐标逆反倒置的核心原因。
$A_0 \cdot \left(1 - 2\left| \min(R, 1.0) - 0.5 \right|\right)$ (阻尼振幅调节器)
在真实的物理容器中,当水很少(接近底部)或水全满(贴近顶部)时,波浪的振幅应该受到容器壁的物理约束而减弱。当 $R = 0.5$(半满)时,振幅达到最大值 $A_0$。这一项非线性阻尼乘子极大地增强了视觉上的真实感。
$k$ (波数与频率)
代表在整个容器宽度内,能够观察到的完整波峰波谷周期的数量。本系统中将其设定为 2,以保证水波看起来稠密且平滑。

三、 系统领域模型与抽象架构 (DDD)

在代码工程的架构设计中,必须将底层的数据模型(实体)、UI 状态视图以及渲染管线做到高内聚、低耦合。

3.1 实体依赖关系的类图推演

下面利用统一建模语言(UML)对本系统的核心域模型进行高度抽象表达。该架构展现了在 Flutter 环境下,如何通过强类型语言 Dart 建立不可变实体。

Inherits Semantic Properties

Tracks

Renders

Delegates Painting

1

1

1

many

1

1

FoodTemplate

+String name

+double sugarGrams

+IconData icon

+Color themeColor

FoodEntry

+String id

+DateTime timestamp

SugarDashboard

+Widget build(BuildContext context)

LiquidWaveTank

+double fillRatio

+double actualSugar

+State createState()

LiquidWavePainter

+double wavePhase

+double fillRatio

+void paint(Canvas canvas, Size size)

3.2 数据流转与状态机驱动

在单向数据流原则的指导下,系统的一切 UI 刷新必须由用户行为(或时间流逝)作为第一推手。通过抽象的管道模型,能够避免复杂动画引发不必要的重绘风暴。下述流程图展示了从用户在底部弹窗选择食物,直到水波位移上升重绘的整个计算流水线闭环:

用户选择具体食物

产生偏差

用户点击: 记录饮食

唤起 AddFoodSheet

检索 FoodTemplate 库

生成不可变实体 FoodEntry

压入 _consumedFoods 数组头部

触发 State.setState 通知引擎

计算全盘代谢总糖量: _totalSugar

比对 25g 阈值计算最新占比: _sugarRatio

传递至 LiquidWaveTank 子树

检测到新老 Ratio 偏差

抛弃旧动画, 创建基于 Curve 的 Tween 过渡动画

VSync 逐帧发送 Phase 相位给 CustomPainter

Canvas 画布清空并执行剪切与像素着色


四、 核心代码解剖学与架构分析

为达成申论级别的严谨与学术级别的透彻,本章节将抽取 Flutter 工程文件中最核心、最具技术深度的四个代码剖面进行病理级别的诊断与解说。

核心代码一:单向数据聚合与状态隔离底座

在大型复杂监控系统中,核心的业务计算指标不应该被硬编码或随意分散在渲染节点中。在 _SugarDashboardState 控制器中,所有的 UI 计算源头都被高度抽象并收敛为了具备强一致性的 Getter 拦截器:

class _SugarDashboardState extends State<SugarDashboard> with TickerProviderStateMixin {
  final List<FoodEntry> _consumedFoods = [];
  
  // 【计算属性代理】实时归约运算提取总摄入量
  double get _totalSugar => _consumedFoods.fold(0.0, (sum, item) => sum + item.sugarGrams);
  
  // 【边界阈值钳位】严格控制比率范围,避免动画引擎越界崩溃
  double get _sugarRatio => (_totalSugar / kDailySugarLimit).clamp(0.0, 1.5);
  
  void _addFood(FoodTemplate template) {
    setState(() {
      _consumedFoods.insert(
        0,
        FoodEntry(
          id: DateTime.now().millisecondsSinceEpoch.toString(),
          name: template.name,
          sugarGrams: template.sugarGrams,
          icon: template.icon,
          themeColor: template.themeColor,
          timestamp: DateTime.now(),
        ),
      );
    });
  }
}

工程语义分析
这里的 _totalSugar_sugarRatio 放弃了独立变量存储,而采用了 Dart 的动态推导 get 语法。其底层的代数逻辑使用了集合的 fold 高阶函数进行归约。
当发生新饮食的录入(_addFood)并调用 setState 后,构建树脏化(Dirty)。当引擎发起重绘指令,这两个计算属性会被自动调起,从而确保无论插入、还是在列表中的滑动侧滑删除 Dismissible,底层的积分模型都不会发生丝毫的偏差。同时,.clamp(0.0, 1.5) 是极其硬核的防御性编程,它保障了如果儿童爆表吃下了高达两倍建议量的糖分,水波动效也不会因为参数爆炸而造成 GPU 渲染坐标系的异常。

核心代码二:响应式视口适配的自旋网格系统

随着智能终端的演化,系统不应该被局限在窄屏手机之上。在教育与医疗场景下,横向的宽屏平板、PC 控制台的介入非常频繁。由于 Flutter 使用了一套相对静态的渲染树构建机制,如何利用 MediaQuery 进行视口的“软分叉”是一个难点。

  
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    final isDesktop = screenWidth > 800; // 宽度侦听侦测

    return Scaffold(
      body: Row(
        children: [
          // 宽屏专享大视界:独立挂载的重型导航侧边栏
          if (isDesktop)
            Container(
              width: 250,
              color: Colors.white,
              // ... 侧边导航逻辑
            ),
          
          Expanded(
            child: CustomScrollView(
              slivers: [
                SliverAppBar(
                  expandedHeight: isDesktop ? 0 : 60, // 宽屏时剥离顶部占用
                  // ... 
                ),
                SliverToBoxAdapter(
                  child: Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
                    child: isDesktop 
                        // 横向张角布局架构
                        ? Row(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Expanded(flex: 2, child: _buildLiquidTankCard()),
                              const SizedBox(width: 24),
                              Expanded(flex: 3, child: _buildConsumptionList()),
                            ],
                          )
                        // 纵向瀑布流降维布局
                        : Column(
                            crossAxisAlignment: CrossAxisAlignment.stretch,
                            children: [
                              _buildLiquidTankCard(),
                              const SizedBox(height: 24),
                              _buildConsumptionList(),
                            ],
                          ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
      // ...
    );
  }

工程语义分析
在这段结构中,我们在根节点利用 MediaQuery.of(context).size.width 充当了“视口环境探针”。在 isDesktop 布尔量切换瞬间,整个 Widget 树的骨架发生了基因级别的突变:
在宽屏状态下,系统加载了由 Expanded 搭配 flex:2flex:3 组成的刚性横向栅格;一旦宽度跌破临界点 800,引擎瞬间将子元素从 Row 切解,重构入 Column 中。这种无缝的组件降阶,辅以极低开销的 Sliver 滚动层,完美防御了页面因尺寸压缩导致的 Right overflowed by xxx pixels 的恐怖报错。

核心代码三:流体波动动画的心跳接力赛引擎

这部分代码是令静止像素“活过来”的灵魂,处理了时间连续性与状态突变性之间的核心矛盾。

class _LiquidWaveTankState extends State<LiquidWaveTank> with SingleTickerProviderStateMixin {
  late AnimationController _waveController;
  late Animation<double> _fillAnimation;
  double _oldRatio = 0.0;

  
  void initState() {
    super.initState();
    // 脉搏发生器:创造恒定的时间相位滚动引擎
    _waveController = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat();
    _oldRatio = widget.fillRatio;
    _fillAnimation = Tween<double>(begin: 0, end: widget.fillRatio).animate(
      CurvedAnimation(parent: _waveController, curve: const Interval(0.0, 1.0, curve: Curves.easeOutCubic))
    );
  }

  
  void didUpdateWidget(covariant LiquidWaveTank oldWidget) {
    super.didUpdateWidget(oldWidget);
    // 拦截外部树形变:当接收到新的阈值配比时重置推演弹道
    if (oldWidget.fillRatio != widget.fillRatio) {
      _fillAnimation = Tween<double>(begin: _oldRatio, end: widget.fillRatio).animate(
        CurvedAnimation(parent: _waveController, curve: Curves.easeInOutBack)
      );
      _oldRatio = widget.fillRatio;
    }
  }
  // ...
}

工程语义分析
这里的精妙之处在于混合了两种截然不同的时间维度系统:
第一维时间:由 _waveController 提供的循环时间轴,其周期为 2 秒,用 .repeat() 挂载在 VSync 垂直同步信号源上。它仅提供时间 θ ( t ) \theta(t) θ(t) 推进,让波浪处于永恒波动状态。
第二维时间:被封装在 _fillAnimation 内部的涨水高度。当我们调用 setState 时,旧有的 Widget 被丢弃并被新的属性替换。此时 didUpdateWidget 生命周期函数如同幽灵侦探一般捕捉到了这个微小但剧烈的变化(oldWidget.fillRatio != widget.fillRatio)。代码迅速在这个“跳变”发生之际,捕获前一次停滞点作为新起点(_oldRatio),并强行切入一段基于 Curves.easeInOutBack(具有弹簧阻尼和短暂回溯特性的物理曲线)的 Tween 动画,使水面如同被注水管突然加压一般,生猛且顺滑地上涨到新的警戒线高度。

核心代码四:GPU 画布剪切与双重正弦着色管道

由于原生的 Material 库不可能提供复杂的液体力学控件,本代码段采用了极底层的方法(近似于 OpenGL Shader 的直接光栅化策略),通过几何掩膜来实现真正的液体在圆形烧瓶内的灌注感。

    // 内部剪切蒙版
    final clipPath = Path()..addCircle(center, radius - 4);
    canvas.save();
    canvas.clipPath(clipPath);

    // 根据超标率进行三态色彩状态机跃迁判定
    Color liquidColor;
    if (fillRatio < 0.6) {
      liquidColor = const Color(0xFF81C784); // 安全域:叶绿
    } else if (fillRatio <= 1.0) {
      liquidColor = const Color(0xFFFFB74D); // 警戒域:橘黄
    } else {
      liquidColor = const Color(0xFFE57373); // 超标域:危红
    }

    // 主视界波浪路径(前层)
    final wavePath = Path();
    final clampedFill = fillRatio.clamp(0.0, 1.2); 
    final waterLevel = size.height - (size.height * clampedFill.clamp(0.0, 1.0));
    
    wavePath.moveTo(0, size.height);
    wavePath.lineTo(0, waterLevel);

    const waveCount = 2; // 波频
    // 阻尼振幅发生器
    final waveAmplitude = 12.0 * (1 - (clampedFill - 0.5).abs() * 2).clamp(0.1, 1.0); 

    // 对屏幕水平切片进行微分求导连接
    for (double i = 0; i <= size.width; i += 1) {
      final dx = i;
      final dy = waterLevel + math.sin((i / size.width * waveCount * 2 * math.pi) + wavePhase) * waveAmplitude;
      wavePath.lineTo(dx, dy);
    }
    
    wavePath.lineTo(size.width, size.height);
    wavePath.close();

    // 绘制并恢复环境光栅
    canvas.drawPath(wavePath, Paint()..color = liquidColor.withOpacity(0.85));
    canvas.restore();

工程语义分析
这一段属于典型的图形学极客操作:

  1. 剪切遮罩防御:无论底层波浪的 Path 画得多么狂野甚至溢出矩形,只要执行了 canvas.save() 配合 canvas.clipPath,所有最终被投射到 GPU 并被用户眼睛所接受的光栅像素,都会被无情地钳制在这个标准的几何圆形以内。这使得开发者能够肆无忌惮地构建多重矩形波阵列而不用担忧脏污了界面的边角。
  2. 三态色域映射:这不是生硬的 if-else,而是一个严格基于公共卫生学界限定义的状态机。“绿色”意味着儿童的糖脂代谢环境处于高度稳定的低负载状态;“黄色”是一种边际逼近警告;一旦突破了 1.0(即突破了世界卫生组织的 25g 游离糖红线),引擎将毫不留情地切换为刺眼的红橙色。这也是从心理学暗示的角度构建出阻遏超标进食的“认知门槛”。
  3. 微分积分连线:那个看似冗杂的 for (double i = 0; ...) 循环,在本质上就是在进行极限切割求定积分操作。它以 1 p x 1px 1px 步长在 X X X 轴推移,不断提取此刻系统分配的振幅系数、时间相位偏移,最终通过 Y Y Y 轴坐标映射,使用多边形外包连线(lineTo)勾勒出完全平滑的反锯齿水波纹线。

五、 分析表格与交互反馈流

为了使系统参数能够被高度配置与动态更新,我们需要对内置的核心营养参数库进行精确解耦。如下是一组经过临床营养学(Dietetics)校准过的游离糖换算对比常量表。

食品类别抽象代码 典型食物示例 游离糖估测含量 (每份/g) 威胁指数 色域映射配置 (Hex)
FD_DAIRY_LIQ 鲜牛奶 (200ml) 4.0 低度安全 #2196F3 (蓝)
FD_FRUIT_S 苹果 (标准中型) 10.0 中度风险 #FF5252 (红)
FD_SNACK_B 巧克力/饼干 12.0 - 15.0 高度风险 #795548 (棕)
FD_ICE_C 冰淇淋 22.0 极度超标 #FF4081 (粉红)
FD_BEV_CB 碳酸饮料/可乐 35.0 致命暴击 #673AB7 (深紫)

从上述常数表格可以十分恐怖地发现:仅仅只是一罐普通的碳酸饮料,在进入儿童胃肠道系统后被分解游离的单双糖总量,就已经彻底摧枯拉朽般地击溃了世界卫生组织耗费数十年推演得出的 25g 警戒红线。如果此时打开系统界面,巨大的红波将瞬间填满整个屏幕上的生命力槽。这种充满张力的数字模型具象化,正是代码之所以伟大,数字化健康档案系统之所以必须推向民用的极点体现!


六、 结论

在这篇耗费巨大工程心力与理论推演的学术级长文中,我们不仅探讨了一款用于“儿童控糖”领域的医疗健康 App 应该长什么样,更深入骨髓地解析了其背后的架构、跨设备自适应流体响应机制以及通过三角函数重构物理水波渲染的极速刷新体系。

通过这套系统的搭建,证明了在构建医疗健康监控预警、生物信息反馈等需要高密度人机交互场景的项目中,Flutter 这个引擎赋予了开发者多么强大的底层掌控权——上至最表皮的 Sliver 分屏嵌套,下至 Canvas.drawPath 中被时间无情绞碎的光栅像素方程,一切尽在控制流内。

当冰冷的数据被赋予了物理流体的质量、温度以及波澜壮阔的波动方程,健康应用便从冷冰冰的记事本,升华为了真正的生命的数字延展。对于探索未来健康科技的边界,这种极致深耕的技术哲学,必将大放异彩。

完整代码

import 'package:flutter/material.dart';
import 'dart:math' as math;

void main() {
  runApp(const KindergartenMenuApp());
}

class KindergartenMenuApp extends StatelessWidget {
  const KindergartenMenuApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '幼教营养追踪膳食引擎',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        primaryColor: const Color(0xFFFF9800),
        scaffoldBackgroundColor: const Color(0xFFF8F9FA),
        fontFamily: 'Roboto',
        appBarTheme: const AppBarTheme(
          backgroundColor: Colors.white,
          elevation: 0,
          scrolledUnderElevation: 0.5,
          iconTheme: IconThemeData(color: Color(0xFF2C3E50)),
          titleTextStyle: TextStyle(color: Color(0xFF2C3E50), fontSize: 20, fontWeight: FontWeight.bold),
        ),
      ),
      home: const MenuDashboard(),
    );
  }
}

// -----------------------------------------------------------------------------
// 领域驱动模型 (Domain Models)
// -----------------------------------------------------------------------------

enum MealType { breakfast, morningSnack, lunch, afternoonSnack }

class NutrientProfile {
  final double calories;     // 热量 (kcal)
  final double protein;      // 蛋白质 (g)
  final double fat;          // 脂肪 (g)
  final double carbohydrates;// 碳水化合物 (g)

  const NutrientProfile({
    required this.calories,
    required this.protein,
    required this.fat,
    required this.carbohydrates,
  });

  NutrientProfile operator +(NutrientProfile other) {
    return NutrientProfile(
      calories: calories + other.calories,
      protein: protein + other.protein,
      fat: fat + other.fat,
      carbohydrates: carbohydrates + other.carbohydrates,
    );
  }
}

class Dish {
  final String id;
  final String name;
  final String description;
  final NutrientProfile profile;
  final IconData icon;
  final Color themeColor;

  const Dish({
    required this.id,
    required this.name,
    required this.description,
    required this.profile,
    required this.icon,
    required this.themeColor,
  });
}

class DailyMenu {
  final int weekday; // 1-5 (Mon-Fri)
  final String dayName;
  final Map<MealType, List<Dish>> meals;

  const DailyMenu({
    required this.weekday,
    required this.dayName,
    required this.meals,
  });

  NutrientProfile get totalDailyNutrition {
    NutrientProfile total = const NutrientProfile(calories: 0, protein: 0, fat: 0, carbohydrates: 0);
    for (final mealList in meals.values) {
      for (final dish in mealList) {
        total += dish.profile;
      }
    }
    return total;
  }
}

// -----------------------------------------------------------------------------
// 核心营养学基准常数 (基于中国居民膳食指南 学龄前儿童参考量)
// -----------------------------------------------------------------------------
const double kTargetCalories = 1200.0; // kcal
const double kTargetProtein = 40.0;    // g
const double kTargetFat = 45.0;        // g
const double kTargetCarbs = 160.0;     // g

// -----------------------------------------------------------------------------
// 模拟数据库 (Mock Database)
// -----------------------------------------------------------------------------

final List<DailyMenu> kWeeklyPlan = [
  DailyMenu(
    weekday: 1,
    dayName: '星期一',
    meals: {
      MealType.breakfast: [
        const Dish(id: 'd1', name: '五谷杂粮粥', description: '富含膳食纤维,温和养胃', profile: NutrientProfile(calories: 150, protein: 4, fat: 1, carbohydrates: 30), icon: Icons.rice_bowl, themeColor: Colors.orange),
        const Dish(id: 'd2', name: '水煮土鸡蛋', description: '高生物价优质蛋白', profile: NutrientProfile(calories: 75, protein: 7, fat: 5, carbohydrates: 1), icon: Icons.egg, themeColor: Colors.amber),
      ],
      MealType.morningSnack: [
        const Dish(id: 'd3', name: '时令苹果片', description: '补充维生素C与果胶', profile: NutrientProfile(calories: 50, protein: 0.2, fat: 0.1, carbohydrates: 13), icon: Icons.apple, themeColor: Colors.redAccent),
      ],
      MealType.lunch: [
        const Dish(id: 'd4', name: '番茄龙利鱼', description: '深海鱼蛋白,DHA益智', profile: NutrientProfile(calories: 220, protein: 20, fat: 12, carbohydrates: 8), icon: Icons.set_meal, themeColor: Colors.deepOrange),
        const Dish(id: 'd5', name: '清炒西蓝花', description: '十字花科植物,强免疫力', profile: NutrientProfile(calories: 45, protein: 3, fat: 2, carbohydrates: 4), icon: Icons.eco, themeColor: Colors.green),
        const Dish(id: 'd6', name: '二米饭', description: '大米混合小米,粗细搭配', profile: NutrientProfile(calories: 180, protein: 4, fat: 0.5, carbohydrates: 40), icon: Icons.rice_bowl, themeColor: Colors.brown),
      ],
      MealType.afternoonSnack: [
        const Dish(id: 'd7', name: '自制酸奶', description: '益生菌调节肠道微生态', profile: NutrientProfile(calories: 120, protein: 6, fat: 5, carbohydrates: 12), icon: Icons.local_drink, themeColor: Colors.blueAccent),
      ]
    },
  ),
  DailyMenu(
    weekday: 2,
    dayName: '星期二',
    meals: {
      MealType.breakfast: [
        const Dish(id: 'd8', name: '鲜牛奶', description: '优质钙源', profile: NutrientProfile(calories: 130, protein: 7, fat: 8, carbohydrates: 10), icon: Icons.local_cafe, themeColor: Colors.blue),
        const Dish(id: 'd9', name: '全麦面包', description: '慢碳水持续供能', profile: NutrientProfile(calories: 180, protein: 6, fat: 3, carbohydrates: 32), icon: Icons.bakery_dining, themeColor: Colors.brown),
      ],
      MealType.morningSnack: [
        const Dish(id: 'd10', name: '香蕉半根', description: '富含钾元素', profile: NutrientProfile(calories: 45, protein: 0.5, fat: 0.2, carbohydrates: 11), icon: Icons.food_bank, themeColor: Colors.yellow),
      ],
      MealType.lunch: [
        const Dish(id: 'd11', name: '胡萝卜炖牛腩', description: '红肉补铁补锌', profile: NutrientProfile(calories: 310, protein: 18, fat: 15, carbohydrates: 12), icon: Icons.ramen_dining, themeColor: Colors.red),
        const Dish(id: 'd12', name: '蒜蓉油麦菜', description: '深色绿叶蔬菜', profile: NutrientProfile(calories: 40, protein: 2, fat: 2, carbohydrates: 3), icon: Icons.grass, themeColor: Colors.lightGreen),
      ],
      MealType.afternoonSnack: [
        const Dish(id: 'd13', name: '坚果燕麦酥', description: '不饱和脂肪酸', profile: NutrientProfile(calories: 150, protein: 4, fat: 9, carbohydrates: 15), icon: Icons.cookie, themeColor: Colors.orangeAccent),
      ]
    },
  ),
  // 为保持代码简洁,仅详细展开两日数据,实际工程应从后端获取
];

// -----------------------------------------------------------------------------
// 核心视图控制台 (Menu Dashboard)
// -----------------------------------------------------------------------------

class MenuDashboard extends StatefulWidget {
  const MenuDashboard({super.key});

  @override
  State<MenuDashboard> createState() => _MenuDashboardState();
}

class _MenuDashboardState extends State<MenuDashboard> with TickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: kWeeklyPlan.length, vsync: this);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final isDesktop = MediaQuery.of(context).size.width > 800;

    return Scaffold(
      appBar: AppBar(
        title: const Text('本周营养膳食监测平台'),
        centerTitle: true,
        bottom: TabBar(
          controller: _tabController,
          labelColor: const Color(0xFFFF9800),
          unselectedLabelColor: Colors.grey,
          indicatorColor: const Color(0xFFFF9800),
          indicatorWeight: 3,
          tabs: kWeeklyPlan.map((day) => Tab(text: day.dayName)).toList(),
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: kWeeklyPlan.map((dayMenu) {
          return _DailyMenuPage(menu: dayMenu, isDesktop: isDesktop);
        }).toList(),
      ),
    );
  }
}

class _DailyMenuPage extends StatelessWidget {
  final DailyMenu menu;
  final bool isDesktop;

  const _DailyMenuPage({required this.menu, required this.isDesktop});

  @override
  Widget build(BuildContext context) {
    if (isDesktop) {
      return Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(flex: 2, child: _buildNutritionAnalysis(context)),
          const VerticalDivider(width: 1, color: Color(0xFFE0E0E0)),
          Expanded(flex: 3, child: _buildMealList(context)),
        ],
      );
    } else {
      return CustomScrollView(
        slivers: [
          SliverToBoxAdapter(child: _buildNutritionAnalysis(context)),
          SliverToBoxAdapter(child: const Divider(height: 32, color: Color(0xFFE0E0E0))),
          SliverToBoxAdapter(child: _buildMealList(context)),
          const SliverToBoxAdapter(child: SizedBox(height: 40)),
        ],
      );
    }
  }

  Widget _buildNutritionAnalysis(BuildContext context) {
    final totals = menu.totalDailyNutrition;

    return Padding(
      padding: const EdgeInsets.all(24.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('全日代谢摄入量评估', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))),
          const SizedBox(height: 8),
          const Text('基于《中国学龄前儿童膳食指南》雷达测绘', style: TextStyle(fontSize: 13, color: Colors.grey)),
          const SizedBox(height: 32),
          Center(
            child: SizedBox(
              width: 280,
              height: 280,
              child: NutritionalRadarChart(profile: totals),
            ),
          ),
          const SizedBox(height: 32),
          _buildMacronutrientGrid(totals),
        ],
      ),
    );
  }

  Widget _buildMacronutrientGrid(NutrientProfile profile) {
    return LayoutBuilder(
      builder: (context, constraints) {
        return Wrap(
          spacing: 16,
          runSpacing: 16,
          children: [
            _buildStatCard('热量', profile.calories, kTargetCalories, 'kcal', Colors.orange, constraints.maxWidth / 2 - 8),
            _buildStatCard('蛋白质', profile.protein, kTargetProtein, 'g', Colors.redAccent, constraints.maxWidth / 2 - 8),
            _buildStatCard('脂肪', profile.fat, kTargetFat, 'g', Colors.amber, constraints.maxWidth / 2 - 8),
            _buildStatCard('碳水', profile.carbohydrates, kTargetCarbs, 'g', Colors.blue, constraints.maxWidth / 2 - 8),
          ],
        );
      }
    );
  }

  Widget _buildStatCard(String title, double current, double target, String unit, Color color, double width) {
    final ratio = (current / target).clamp(0.0, 1.0);
    return Container(
      width: width,
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: const Color(0xFFEEEEEE)),
        boxShadow: [
          BoxShadow(color: Colors.black.withValues(alpha: 0.02), blurRadius: 10, offset: const Offset(0, 4)),
        ]
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Container(
                width: 8, height: 8,
                decoration: BoxDecoration(color: color, shape: BoxShape.circle),
              ),
              const SizedBox(width: 8),
              Text(title, style: const TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold)),
            ],
          ),
          const SizedBox(height: 12),
          Text('${current.toStringAsFixed(1)} $unit', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: color)),
          const SizedBox(height: 8),
          ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: LinearProgressIndicator(
              value: ratio,
              backgroundColor: color.withValues(alpha: 0.1),
              valueColor: AlwaysStoppedAnimation<Color>(color),
              minHeight: 6,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildMealList(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('膳食分配序列', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))),
          const SizedBox(height: 16),
          _buildMealSection('早餐 (08:00)', menu.meals[MealType.breakfast] ?? [], Icons.wb_sunny_outlined, Colors.orange),
          _buildMealSection('早点 (10:00)', menu.meals[MealType.morningSnack] ?? [], Icons.apple_outlined, Colors.redAccent),
          _buildMealSection('午餐 (12:00)', menu.meals[MealType.lunch] ?? [], Icons.restaurant_outlined, Colors.green),
          _buildMealSection('午点 (15:00)', menu.meals[MealType.afternoonSnack] ?? [], Icons.cookie_outlined, Colors.brown),
        ],
      ),
    );
  }

  Widget _buildMealSection(String title, List<Dish> dishes, IconData icon, Color headerColor) {
    if (dishes.isEmpty) return const SizedBox.shrink();
    return Padding(
      padding: const EdgeInsets.only(bottom: 24.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(icon, color: headerColor, size: 20),
              const SizedBox(width: 8),
              Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: headerColor)),
            ],
          ),
          const SizedBox(height: 12),
          ...dishes.map((dish) => _buildDishTile(dish)),
        ],
      ),
    );
  }

  Widget _buildDishTile(Dish dish) {
    return Container(
      margin: const EdgeInsets.only(bottom: 12),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        boxShadow: [
          BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 10, offset: const Offset(0, 4)),
        ],
      ),
      child: Theme(
        data: ThemeData(dividerColor: Colors.transparent),
        child: ExpansionTile(
          tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          leading: Container(
            padding: const EdgeInsets.all(10),
            decoration: BoxDecoration(
              color: dish.themeColor.withValues(alpha: 0.1),
              borderRadius: BorderRadius.circular(12),
            ),
            child: Icon(dish.icon, color: dish.themeColor),
          ),
          title: Text(dish.name, style: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))),
          subtitle: Text(dish.description, style: const TextStyle(fontSize: 12, color: Colors.grey)),
          children: [
            Container(
              padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceAround,
                children: [
                  _buildMicroStat('热量', dish.profile.calories, 'kcal', Colors.orange),
                  _buildMicroStat('蛋白', dish.profile.protein, 'g', Colors.redAccent),
                  _buildMicroStat('脂肪', dish.profile.fat, 'g', Colors.amber),
                  _buildMicroStat('碳水', dish.profile.carbohydrates, 'g', Colors.blue),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildMicroStat(String label, double val, String unit, Color color) {
    return Column(
      children: [
        Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)),
        const SizedBox(height: 2),
        Text('${val.toStringAsFixed(1)}$unit', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: color)),
      ],
    );
  }
}

// -----------------------------------------------------------------------------
// 高阶图形渲染核心:营养素测绘雷达图 (Radar Chart)
// -----------------------------------------------------------------------------

class NutritionalRadarChart extends StatefulWidget {
  final NutrientProfile profile;

  const NutritionalRadarChart({super.key, required this.profile});

  @override
  State<NutritionalRadarChart> createState() => _NutritionalRadarChartState();
}

class _NutritionalRadarChartState extends State<NutritionalRadarChart> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _growAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200));
    _growAnimation = CurvedAnimation(parent: _controller, curve: Curves.elasticOut);
    _controller.forward();
  }

  @override
  void didUpdateWidget(covariant NutritionalRadarChart oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.profile != widget.profile) {
      _controller.reset();
      _controller.forward();
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _growAnimation,
      builder: (context, child) {
        return CustomPaint(
          painter: _RadarChartPainter(
            profile: widget.profile,
            animationValue: _growAnimation.value,
          ),
        );
      },
    );
  }
}

class _RadarChartPainter extends CustomPainter {
  final NutrientProfile profile;
  final double animationValue;

  _RadarChartPainter({required this.profile, required this.animationValue});

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = math.min(size.width, size.height) / 2 - 24; // 预留文字空间

    final int sides = 4; // 4个维度: 热量, 蛋白, 脂肪, 碳水

    // 1. 绘制雷达网格底盘 (多边形圈)
    final gridPaint = Paint()
      ..color = Colors.grey.withValues(alpha: 0.2)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1;
    
    final int gridLayers = 5;
    for (int i = 1; i <= gridLayers; i++) {
      final currentRadius = radius * (i / gridLayers);
      final path = _createPolygonPath(center, currentRadius, sides);
      canvas.drawPath(path, gridPaint);
    }

    // 2. 绘制轴线
    for (int i = 0; i < sides; i++) {
      final angle = (i * 2 * math.pi / sides) - math.pi / 2; // 从-90度(正上方)开始
      final endPoint = Offset(
        center.dx + radius * math.cos(angle),
        center.dy + radius * math.sin(angle),
      );
      canvas.drawLine(center, endPoint, gridPaint);
    }

    // 3. 计算归一化比例与顶点投影坐标 (使用极坐标向笛卡尔坐标系的数学转换)
    final List<double> ratios = [
      (profile.calories / kTargetCalories).clamp(0.0, 1.2),
      (profile.protein / kTargetProtein).clamp(0.0, 1.2),
      (profile.fat / kTargetFat).clamp(0.0, 1.2),
      (profile.carbohydrates / kTargetCarbs).clamp(0.0, 1.2),
    ];

    final dataPath = Path();
    final List<Offset> points = [];

    for (int i = 0; i < sides; i++) {
      final angle = (i * 2 * math.pi / sides) - math.pi / 2;
      // 应用动画系数,实现由内向外的弹射生长展开
      final currentRadius = radius * ratios[i] * animationValue;
      final point = Offset(
        center.dx + currentRadius * math.cos(angle),
        center.dy + currentRadius * math.sin(angle),
      );
      points.add(point);
      if (i == 0) {
        dataPath.moveTo(point.dx, point.dy);
      } else {
        dataPath.lineTo(point.dx, point.dy);
      }
    }
    dataPath.close();

    // 4. 着色器渲染数据层 (半透明填充与边界加粗)
    final fillPaint = Paint()
      ..color = const Color(0xFFFF9800).withValues(alpha: 0.3)
      ..style = PaintingStyle.fill;
    canvas.drawPath(dataPath, fillPaint);

    final borderPaint = Paint()
      ..color = const Color(0xFFFF9800)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3;
    canvas.drawPath(dataPath, borderPaint);

    // 绘制顶点高亮小圆圈
    final dotPaint = Paint()..color = const Color(0xFFE65100)..style = PaintingStyle.fill;
    for (var point in points) {
      canvas.drawCircle(point, 5, dotPaint);
    }

    // 5. 绘制文本标签
    final List<String> labels = ['卡路里', '蛋白质', '脂肪', '碳水化合物'];
    for (int i = 0; i < sides; i++) {
      final angle = (i * 2 * math.pi / sides) - math.pi / 2;
      // 文字需要比最大半径再远一点
      final labelRadius = radius + 18;
      final labelPoint = Offset(
        center.dx + labelRadius * math.cos(angle),
        center.dy + labelRadius * math.sin(angle),
      );
      
      _drawText(canvas, labels[i], labelPoint, size);
    }
  }

  Path _createPolygonPath(Offset center, double radius, int sides) {
    final path = Path();
    for (int i = 0; i < sides; i++) {
      final angle = (i * 2 * math.pi / sides) - math.pi / 2;
      final point = Offset(
        center.dx + radius * math.cos(angle),
        center.dy + radius * math.sin(angle),
      );
      if (i == 0) {
        path.moveTo(point.dx, point.dy);
      } else {
        path.lineTo(point.dx, point.dy);
      }
    }
    path.close();
    return path;
  }

  void _drawText(Canvas canvas, String text, Offset position, Size size) {
    final textPainter = TextPainter(
      text: TextSpan(text: text, style: const TextStyle(color: Colors.grey, fontSize: 12, fontWeight: FontWeight.bold)),
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();
    
    // 微调对齐中心
    final offset = Offset(
      position.dx - textPainter.width / 2,
      position.dy - textPainter.height / 2,
    );
    textPainter.paint(canvas, offset);
  }

  @override
  bool shouldRepaint(covariant _RadarChartPainter oldDelegate) {
    return oldDelegate.profile != profile || oldDelegate.animationValue != animationValue;
  }
}

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐