饮食管理

饮食页面概述

饮食管理页面 (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米饭116200
g2馒头221100
g3面条(熟)110200
g4面包31260
g5燕麦片37740
g6包子227100
g7饺子196100
g8油条38650
g9小米粥46300
g10玉米112200
g11红薯99200
g12饼干43330
g13蛋糕34880
g14年糕154100
g15煎饼258100
g16烧饼32680
g17花卷211100
g18粥(白)33300
g19烙饼259100
g20杂粮饭126200

肉类 (20种)

ID名称热量(kcal/100g)默认量(g)
m1鸡胸肉133150
m2猪瘦肉143100
m3牛肉125100
m4羊肉203100
m5猪排骨264100
m6鸡翅194100
m7鸡腿181100
m8鱼肉(草鱼)113150
m9鱼肉(鲈鱼)105150
m10虾仁87100
m11鱼肉(三文鱼)139100
m12猪肉(肥瘦)39580
m13培根54130
m14火腿肠21250
m15鸭肉240100
m16鹅肉245100
m17驴肉116100
m18兔肉102100
m19鱼肉(带鱼)127100
m20鱼肉(鲫鱼)108150

蔬菜类 (20种)

ID名称热量(kcal/100g)默认量(g)
v1西红柿19200
v2黄瓜15200
v3胡萝卜37150
v4菠菜24150
v5白菜20200
v6土豆76200
v7茄子21200
v8青椒22100
v9西兰花34150
v10生菜13150
v15豆腐82150
v20紫甘蓝25150

水果类 (20种)

ID名称热量(kcal/100g)默认量(g)
f1苹果53200
f2香蕉93120
f3橙子48200
f5西瓜25300
f9猕猴桃56150
f16火龙果51200
f17蓝莓57100

乳制品类 (10种)

ID名称热量(kcal/100g)默认量(g)
d1牛奶54250
d2酸奶72200
d3奶酪32830
d4鸡蛋(煮)14460
d5豆浆31300
d6黄油88810

热量计算示例

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

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

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

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

状态变量总结

状态变量类型用途
dietSearchKeywordstring搜索关键词
dietRecordsDietRecord[]饮食记录列表
selectedCategorystring当前选中分类
showFoodDialogboolean是否显示食物对话框
selectedFoodFoodItem | undefined当前选中食物
foodGramsstring用户输入的重量
todayCaloriesnumber今日总热量

饮食页面 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();
}

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

搜索场景示例

用户操作selectedCategorydietSearchKeyword结果
初始状态‘’‘’全部 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‘’用户点击分类标签否
showFoodDialogfalse点击食物/点击取消否
selectedFoodundefined点击食物条目否
foodGrams‘’对话框中输入否
todayCalories0aboutToAppear / 添加记录是(通过Preferences)
dietRecords[]aboutToAppear / 添加记录是

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

饮食记录数据流完整图

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

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

更多推荐