饮食管理

饮食页面概述

饮食管理页面 (DietPage) 是应用的核心功能模块之一,提供食物浏览、搜索、分类筛选和摄入记录功能。页面内置约 100 种中国常见食物的热量数据库,用户可快速记录每日饮食并追踪热量摄入。

页面布局

DietPage 采用三段式布局:头部信息区、食物列表区和添加按钮区:

+----------------------------------+
| 饮食记录              1580 kcal |  <-- DietHeader
| [搜索食物...]                    |
| [谷物] [肉类] [蔬菜] [水果] [乳]|  <-- 分类标签
+----------------------------------+
| 米饭             116 kcal/100g +|  <-- DietRecordList
| 馒头             221 kcal/100g +|
| 面条(熟)         110 kcal/100g +|
| 面包             312 kcal/100g +|
| ...                              |
+----------------------------------+

代码结构

@Builder
DietPage() {
  Column() {
    this.DietHeader()
    this.DietRecordList()
    this.DietAddButton()
  }
  .width('100%')
  .height('100%')
  .backgroundColor(Constants.COLOR_BG)
}

头部区域 (DietHeader)

布局结构

头部区域包含三行内容:标题行、搜索行和分类标签行。

+----------------------------------+
| 饮食记录              1580 kcal |  标题行
+----------------------------------+
| [搜索食物...]                    |  搜索行
+----------------------------------+
| [谷物] [肉类] [蔬菜] [水果] [乳]|  分类行
+----------------------------------+

标题行

左侧显示页面标题,右侧显示今日已摄入总热量:

Row() {
  Text('饮食记录')
    .fontSize(Constants.FONT_SIZE_TITLE)    // 24
    .fontWeight(FontWeight.Bold)
  Blank()
  Text(`${this.todayCalories} kcal`)
    .fontSize(Constants.FONT_SIZE_SUBTITLE) // 18
    .fontColor(Constants.COLOR_ORANGE)      // #FF9800
}
.width('100%')

搜索栏

使用 TextInput 组件实现实时搜索:

TextInput({ placeholder: '搜索食物...', text: this.dietSearchKeyword })
  .width('100%')
  .height(40)
  .fontSize(Constants.FONT_SIZE_BODY)
  .onChange((value: string) => {
    this.dietSearchKeyword = value;
  })

搜索通过 @State dietSearchKeyword 驱动,输入变化时自动刷新食物列表。

分类标签

使用 ForEach 渲染五个分类标签,支持切换和取消选择:

Row({ space: Constants.PADDING_TINY }) {
  ForEach(CalorieService.getInstance().getCategories(), (cat: string) => {
    Row() {
      Text(cat)
        .fontColor(this.selectedCategory === cat
          ? Constants.COLOR_TEXT_WHITE
          : Constants.COLOR_TEXT_PRIMARY)
    }
    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
    .backgroundColor(this.selectedCategory === cat
      ? Constants.COLOR_PRIMARY    // 选中: 蓝色背景白色文字
      : Constants.COLOR_BG)        // 未选: 灰色背景黑色文字
    .borderRadius(16)
    .onClick(() => {
      this.selectedCategory =
        this.selectedCategory === cat ? '' : cat;
    })
  }, (cat: string) => cat)
}

食物列表 (DietRecordList)

列表渲染

使用 List + ForEach 渲染食物条目,支持按分类或关键词筛选:

List({ space: Constants.PADDING_TINY }) {
  ForEach(this.getDietFoods(), (food: FoodItem) => {
    ListItem() {
      Row() {
        Column() {
          Text(food.name)
          Text(`${food.caloriesPer100g} kcal/100g`)
        }
        Blank()
        Text('+')
          .fontSize(20)
          .fontColor(Constants.COLOR_PRIMARY)
      }
      .onClick(() => {
        this.selectedFood = food;
        this.foodGrams = String(food.defaultGrams);
        this.showFoodDialog = true;
      })
    }
  }, (food: FoodItem) => food.id)
}
.width('100%')
.layoutWeight(1)

筛选逻辑

getDietFoods() 方法根据用户操作返回不同结果,优先级为:分类 > 搜索 > 全部:

private getDietFoods(): FoodItem[] {
  const cs: CalorieService = CalorieService.getInstance();
  if (this.selectedCategory !== '') {
    return cs.getFoodsByCategory(this.selectedCategory);
  }
  if (this.dietSearchKeyword !== '') {
    return cs.searchFoods(this.dietSearchKeyword);
  }
  return cs.getAllFoods();
}

食物添加对话框 (FoodInputDialog)

点击食物条目的 “+” 按钮后,弹出模态对话框,用户可设置摄入量并确认添加。
g)

对话框布局

+----------------------------------+
|          (半透明遮罩)            |
|   +--------------------------+   |
|   | 鸡胸肉                   |   |
|   |                          |   |
|   | 重量(g): [   150    ]   |   |
|   |                          |   |
|   | 热量: 200 kcal          |   |
|   |                          |   |
|   | [取消]     [添加]       |   |
|   +--------------------------+   |
+----------------------------------+

对话框实现

@Builder
FoodInputDialog() {
  Column() {
    Column({ space: Constants.PADDING_SMALL }) {
      Text(this.selectedFood!.name)
        .fontSize(Constants.FONT_SIZE_SUBTITLE)
        .fontWeight(FontWeight.Bold)

      Row() {
        Text('重量(g):')
        TextInput({ text: this.foodGrams })
          .width(100)
          .height(40)
          .type(InputType.Number)
          .onChange((value: string) => { this.foodGrams = value; })
      }

      Text(`热量: ${this.selectedFood!.getCalories(parseInt(this.foodGrams))} kcal`)
        .fontColor(Constants.COLOR_ORANGE)

      Row({ space: Constants.PADDING_SMALL }) {
        Button('取消')
          .backgroundColor(Constants.COLOR_BG)
          .fontColor(Constants.COLOR_TEXT_PRIMARY)
          .onClick(() => { this.showFoodDialog = false; })
        Button('添加')
          .backgroundColor(Constants.COLOR_PRIMARY)
          .fontColor(Constants.COLOR_TEXT_WHITE)
          .onClick(() => { this.addDietRecord(); })
      }
    }
    .width('80%')
    .padding(Constants.PADDING_CARD)
    .backgroundColor(Constants.COLOR_CARD)
    .borderRadius(Constants.BORDER_RADIUS_CARD)
  }
  .width('100%')
  .height('100%')
  .backgroundColor('rgba(0,0,0,0.4)')
  .justifyContent(FlexAlign.Center)
  .position({ x: 0, y: 0 })
  .zIndex(999)
}

关键设计

  • 使用 position + zIndex 实现全屏遮罩覆盖
  • 半透明黑色背景 (rgba(0,0,0,0.4)) 突出对话框
  • 对话框宽度 80%,居中显示
  • 重量输入框限定为数字类型 (InputType.Number)
  • 热量实时计算,随重量输入动态更新

添加饮食记录流程

private addDietRecord(): void {
  if (this.selectedFood === undefined) return;
  const grams: number = parseInt(this.foodGrams);
  if (isNaN(grams) || grams <= 0) return;

  const record: DietRecord = DietRecord.create(this.selectedFood!, grams);
  const prefs: PreferencesService = PreferencesService.getInstance();
  const records: DietRecord[] = prefs.getDietRecords();
  records.push(record);
  prefs.saveDietRecords(records);
  this.todayCalories = prefs.getTodayCalories();
  this.showFoodDialog = false;
}

数据流

用户点击 "+"
  |
  v
设置 selectedFood + foodGrams + showFoodDialog = true
  |
  v
弹出 FoodInputDialog
  |
  v
用户修改重量 -> 实时计算热量
  |
  v
点击 "添加"
  |-- DietRecord.create(food, grams)  // 自动计算热量
  |-- PreferencesService.getDietRecords()  // 读取已有记录
  |-- records.push(newRecord)  // 追加新记录
  |-- PreferencesService.saveDietRecords(records)  // 持久化
  |-- 更新 todayCalories  // 刷新首页热量数据
  |-- showFoodDialog = false  // 关闭对话框

CalorieService 食物数据库

数据规模

内置 90 种常见食物,分为 5 大类别,每类 18-20 种:

谷物类 (20种)

ID 名称 热量(kcal/100g) 默认量(g)
g1 米饭 116 200
g2 馒头 221 100
g3 面条(熟) 110 200
g4 面包 312 60
g5 燕麦片 377 40
g6 包子 227 100
g7 饺子 196 100
g8 油条 386 50
g9 小米粥 46 300
g10 玉米 112 200
g11 红薯 99 200
g12 饼干 433 30
g13 蛋糕 348 80
g14 年糕 154 100
g15 煎饼 258 100
g16 烧饼 326 80
g17 花卷 211 100
g18 粥(白) 33 300
g19 烙饼 259 100
g20 杂粮饭 126 200

肉类 (20种)

ID 名称 热量(kcal/100g) 默认量(g)
m1 鸡胸肉 133 150
m2 猪瘦肉 143 100
m3 牛肉 125 100
m4 羊肉 203 100
m5 猪排骨 264 100
m6 鸡翅 194 100
m7 鸡腿 181 100
m8 鱼肉(草鱼) 113 150
m9 鱼肉(鲈鱼) 105 150
m10 虾仁 87 100
m11 鱼肉(三文鱼) 139 100
m12 猪肉(肥瘦) 395 80
m13 培根 541 30
m14 火腿肠 212 50
m15 鸭肉 240 100
m16 鹅肉 245 100
m17 驴肉 116 100
m18 兔肉 102 100
m19 鱼肉(带鱼) 127 100
m20 鱼肉(鲫鱼) 108 150

蔬菜类 (20种)

ID 名称 热量(kcal/100g) 默认量(g)
v1 西红柿 19 200
v2 黄瓜 15 200
v3 胡萝卜 37 150
v4 菠菜 24 150
v5 白菜 20 200
v6 土豆 76 200
v7 茄子 21 200
v8 青椒 22 100
v9 西兰花 34 150
v10 生菜 13 150
v15 豆腐 82 150
v20 紫甘蓝 25 150

水果类 (20种)

ID 名称 热量(kcal/100g) 默认量(g)
f1 苹果 53 200
f2 香蕉 93 120
f3 橙子 48 200
f5 西瓜 25 300
f9 猕猴桃 56 150
f16 火龙果 51 200
f17 蓝莓 57 100

乳制品类 (10种)

ID 名称 热量(kcal/100g) 默认量(g)
d1 牛奶 54 250
d2 酸奶 72 200
d3 奶酪 328 30
d4 鸡蛋(煮) 144 60
d5 豆浆 31 300
d6 黄油 888 10

热量计算示例

用户添加 150g 鸡胸肉的记录:

FoodItem: 鸡胸肉, 133 kcal/100g
用户输入: 150g

计算: 133 * 150 / 100 = 199.5 ≈ 200 kcal

DietRecord:
  foodId: 'm1'
  foodName: '鸡胸肉'
  grams: 150
  calories: 200
  timestamp: 1720797600000
  category: '肉类'

状态变量总结

状态变量 类型 用途
dietSearchKeyword string 搜索关键词
dietRecords DietRecord[] 饮食记录列表
selectedCategory string 当前选中分类
showFoodDialog boolean 是否显示食物对话框
selectedFood FoodItem | undefined 当前选中食物
foodGrams string 用户输入的重量
todayCalories number 今日总热量

饮食页面 Stack 布局设计

布局层级

DietPage 采用 Stack 叠加布局实现对话框覆盖效果。整体层级从底到顶为:

Stack
  |-- Column (主内容区)
  |     |-- DietHeader
  |     |-- DietRecordList
  |     |-- DietAddButton
  |
  |-- FoodInputDialog (条件渲染, showFoodDialog = true 时显示)
       |-- 半透明遮罩
       |-- 对话框卡片

Stack 的优势在于对话框可以自然覆盖在主内容之上,无需额外的弹窗组件。当 showFoodDialog 为 false 时,FoodInputDialog 不参与渲染,不影响主内容的布局计算。

今日记录区域

饮食页面包含"今日记录"区域,展示用户今日已添加的饮食记录列表。每条记录显示食物名称、摄入重量和热量值:

+----------------------------------+
| 今日记录                         |
| +------------------------------+ |
| | 鸡胸肉  150g    200 kcal    | |
| | 米饭    200g    232 kcal    | |
| | 苹果    200g    106 kcal    | |
| +------------------------------+ |
+----------------------------------+

今日记录通过筛选 DietRecord 中 timestamp 属于当日的条目生成:

private getTodayRecords(): DietRecord[] {
  const records: DietRecord[] = PreferencesService.getInstance().getDietRecords();
  const todayStart: number = new Date(
    new Date().getFullYear(),
    new Date().getMonth(),
    new Date().getDate()
  ).getTime();
  const result: DietRecord[] = [];
  for (let i = 0; i < records.length; i++) {
    if (records[i].timestamp >= todayStart) {
      result.push(records[i]);
    }
  }
  return result;
}

Stack 对话框实现细节

对话框使用条件渲染 if 语句控制显示,而非 AlertDialog 或 CustomDialog。原因如下:

  1. AlertDialog 样式定制能力有限,无法实现自定义布局(如重量输入框和实时热量计算)
  2. CustomDialog 需要单独的 @Component 定义,在单文件架构下增加跨组件通信成本
  3. Stack + 条件渲染方案简单直接,@State showFoodDialog 变化即触发 UI 更新
if (this.showFoodDialog && this.selectedFood !== undefined) {
  this.FoodInputDialog()
}

遮罩层使用 position({ x: 0, y: 0 }) 实现全屏覆盖,zIndex(999) 确保在其他元素之上。点击遮罩区域(对话框外部)可关闭对话框,通过在遮罩 Column 上设置 onClick 实现:

Column() {
  // 遮罩区域
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.4)')
.onClick(() => { this.showFoodDialog = false; })

搜索功能深度解析

实时搜索机制

搜索通过 @State dietSearchKeyword 驱动,TextInput 的 onChange 回调实时更新关键词:

TextInput({ placeholder: '搜索食物...', text: this.dietSearchKeyword })
  .onChange((value: string) => {
    this.dietSearchKeyword = value;
  })

每次键入都会触发 @State 更新,导致依赖 dietSearchKeyword 的 getDietFoods() 重新计算,列表自动刷新。这种"即时搜索"体验在 100 条数据规模下性能无忧。

搜索与分类的优先级

getDietFoods() 方法中,分类筛选优先于搜索:

private getDietFoods(): FoodItem[] {
  const cs: CalorieService = CalorieService.getInstance();
  if (this.selectedCategory !== '') {
    return cs.getFoodsByCategory(this.selectedCategory);
  }
  if (this.dietSearchKeyword !== '') {
    return cs.searchFoods(this.dietSearchKeyword);
  }
  return cs.getAllFoods();
}

设计理由:用户选择分类时,通常是在浏览特定类别的食物,搜索框内容可能是上一次搜索的残留,不应干扰分类浏览。只有未选择分类时,搜索才生效。

搜索场景示例

用户操作 selectedCategory dietSearchKeyword 结果
初始状态 ‘’ ‘’ 全部 100 种食物
选择"肉类" ‘肉类’ ‘’ 20 种肉类
选择"肉类" + 输入"鸡" ‘肉类’ ‘鸡’ 20 种肉类(搜索被忽略)
取消分类 + 输入"鸡" ‘’ ‘鸡’ 鸡胸肉、鸡翅、鸡腿等
输入"鱼" ‘’ ‘鱼’ 草鱼、鲈鱼、三文鱼、带鱼、鲫鱼

分类标签交互设计

标签切换逻辑

分类标签支持选中/取消切换,再次点击已选中的标签即取消选择:

.onClick(() => {
  this.selectedCategory =
    this.selectedCategory === cat ? '' : cat;
})

这种 toggle 行为符合用户预期:点击"肉类"筛选肉类,再次点击"肉类"恢复全部。

标签样式

状态 背景色 文字色
选中 COLOR_PRIMARY (#4A90D9) COLOR_TEXT_WHITE (#FFFFFF)
未选中 COLOR_BG (#F5F5F5) COLOR_TEXT_PRIMARY (#333333)

标签使用 borderRadius(16) 实现胶囊形状,padding 水平 12vp、垂直 6vp,文字大小与正文一致 (14fp)。

食物列表交互详解

点击食物条目

点击食物条目时,设置三个状态变量并弹出对话框:

.onClick(() => {
  this.selectedFood = food;
  this.foodGrams = String(food.defaultGrams);
  this.showFoodDialog = true;
})
  • selectedFood: 记录用户选择的食物,对话框中显示食物名称和单位热量
  • foodGrams: 初始化为食物的默认摄入量(如米饭 200g、鸡蛋 60g),减少用户输入
  • showFoodDialog: 设为 true 触发对话框渲染

默认摄入量设计

每种食物的 defaultGrams 根据常见食用份量预设:

食物 defaultGrams 依据
米饭 200g 一碗米饭约 200g
馒头 100g 一个馒头约 100g
鸡蛋 60g 一个鸡蛋约 60g
牛奶 250g 一杯牛奶约 250ml
黄油 10g 涂抹量
西瓜 300g 两片西瓜

默认值让大多数场景下用户只需点击"添加"即可,无需手动输入重量。

热量实时计算

对话框中的热量值随重量输入动态更新:

Text(`热量: ${this.selectedFood!.getCalories(parseInt(this.foodGrams))} kcal`)

每次 foodGrams 变化(通过 TextInput.onChange)都会触发 @State 更新,UI 重新渲染显示最新的热量值。这让用户在调整重量时直观看到热量变化,帮助做出合理的摄入决策。

食物数据库设计思路

为什么硬编码而非外部数据

100 种食物数据直接写在 CalorieService 的构造器中,而非从 JSON 文件或数据库加载。原因如下:

  1. 数据量小(约 100 条),硬编码不会显著增加应用体积
  2. 避免文件 I/O 延迟,食物数据在首次调用时立即可用
  3. ArkTS 严格模式下,读取 rawfile 需要异步操作,增加代码复杂度
  4. 数据更新频率低(食物热量值基本不变),不需要动态加载

热量数据准确性

食物热量值参考中国食物成分表,但需注意以下限制:

  • 同种食物因品种、产地、成熟度不同,热量值存在浮动
  • 烹饪方式影响显著(油炸 vs 水煮热量差异可达 3-5 倍)
  • 数据为每 100g 原料热量,成品(如饺子)已包含综合成分
  • 用户应将热量值视为参考估计,而非精确值

分类体系

五分类体系基于中国居民膳食指南的食物分类:

类别 覆盖范围 主要营养素
谷物 主食类 碳水化合物
肉类 禽畜鱼虾 蛋白质、脂肪
蔬菜 菜蔬豆制品 膳食纤维、维生素
水果 鲜果干果 果糖、维生素
乳制品 奶蛋豆奶 钙、蛋白质

添加饮食记录的边界情况

输入验证

addDietRecord() 方法对用户输入进行基本验证:

private addDietRecord(): void {
  if (this.selectedFood === undefined) return;
  const grams: number = parseInt(this.foodGrams);
  if (isNaN(grams) || grams <= 0) return;
  // ...
}

验证项:

  1. selectedFood 不能为 undefined(理论上不会发生,因为对话框仅在选中食物后显示)
  2. 重量必须为有效数字且大于 0

未验证的情况(当前版本):

  • 重量上限(用户可输入 99999g,产生不合理热量值)
  • 非整数输入(parseInt 会截断小数部分,“150.5” -> 150)

记录持久化时机

const records: DietRecord[] = prefs.getDietRecords();
records.push(record);
prefs.saveDietRecords(records);
this.todayCalories = prefs.getTodayCalories();

每次添加记录都会完整读取、追加、写回整个记录数组。这种"读取-修改-写入"模式在小数据量下简单可靠,但存在并发风险(理论上两个快速连续的添加操作可能丢失其中一个)。在单用户单线程的移动应用场景下,此风险可忽略。

首页热量同步

添加记录后更新 todayCalories:

this.todayCalories = prefs.getTodayCalories();

由于 todayCalories 是 @State 变量,更新后首页的 CalorieSummaryCard 会自动刷新。这是单文件架构下 @State 共享的天然优势——无需事件总线或观察者模式,状态变更自动传播到所有绑定组件。

食物对话框用户体验设计

对话框宽度与位置

对话框宽度设为屏幕宽度的 80%,居中显示。这一比例确保对话框在手机屏幕上有足够的操作区域,同时两侧留白让用户感知到下方有主内容(暗示对话框可关闭)。

重量输入限定

TextInput({ text: this.foodGrams })
  .type(InputType.Number)

InputType.Number 限定输入键盘为数字键盘,减少误输入。但需要注意,InputType.Number 在不同设备上的行为可能略有差异(如是否允许小数点)。

取消与添加按钮

两个按钮的视觉权重不同:

  • "取消"按钮:灰色背景(COLOR_BG),黑色文字,视觉弱化
  • "添加"按钮:蓝色背景(COLOR_PRIMARY),白色文字,视觉强调

这种设计引导用户优先点击"添加",同时保留"取消"的可达性。按钮间距使用 PADDING_SMALL (8vp) 分隔。

饮食页面状态变量生命周期

状态变量 初始值 更新时机 持久化
dietSearchKeyword ‘’ 用户输入 否(不持久化,重启清空)
selectedCategory ‘’ 用户点击分类标签
showFoodDialog false 点击食物/点击取消
selectedFood undefined 点击食物条目
foodGrams ‘’ 对话框中输入
todayCalories 0 aboutToAppear / 添加记录 是(通过Preferences)
dietRecords [] aboutToAppear / 添加记录

dietSearchKeyword 和 selectedCategory 不持久化,意味着用户每次进入饮食页面时都从默认状态开始。这是合理的设计——搜索和筛选是临时操作,无需跨会话保留。

饮食记录数据流完整图

用户进入饮食页面
  |
  +-- aboutToAppear() 加载 todayCalories (从Preferences)
  |
  +-- 渲染头部 (显示今日总热量)
  |
  +-- 渲染分类标签 (默认无选中)
  |
  +-- 渲染食物列表 (显示全部100种)
  |
  +-- 用户操作
       |
       +-- 选择分类 -> 列表刷新为该分类食物
       |     |
       |     +-- 再次点击 -> 取消选择, 恢复全部
       |
       +-- 输入搜索词 -> 列表实时刷新
       |     |
       |     +-- 清空搜索词 -> 恢复全部/分类结果
       |
       +-- 点击食物 "+" -> 弹出对话框
            |
            +-- 修改重量 -> 实时显示热量
            |     |
            |     +-- 点击"取消" -> 关闭对话框
            |     +-- 点击"添加" -> 创建记录, 持久化, 刷新热量
            |
            +-- 点击遮罩 -> 关闭对话框
Logo

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

更多推荐