# HarmonyOS ArkTS 晨间习惯追踪应用源码逐段深度解析
一、引言:从晨光中开启高效一天
在移动互联网与智能穿戴设备高度普及的今天,"习惯养成"已经从一个模糊的自我管理理念,演变为一个可量化、可追踪、可视化的数字化产品赛道。越来越多的人希望借助手中的智能终端,把"早起、冥想、阅读、运动"这些抽象的自律行为,转化为一条条清晰的数据曲线和一张张可触摸的打卡卡片。而鸿蒙生态(HarmonyOS)凭借其跨设备协同、分布式软总线以及声明式 UI 开发框架 ArkUI,为这类注重"轻量化交互 + 即时反馈"的生活方式类应用提供了极为理想的生长土壤。

本应用正是一款面向"晨间场景"深度打磨的习惯追踪工具。它聚焦于每个人一天中 arguably 最关键的两到三个小时——清晨醒来后的那段时间,帮助用户把零散的晨间行为编排成一条结构化的"时间线",并通过多维度的数据统计和成就体系,持续激励用户坚持。从产品形态上看,它采用了经典的底部四 Tab 导航架构,分别是"晨间时间线"、“所有习惯”、"数据统计"和"我的"个人中心,几乎涵盖了习惯管理类应用最核心的四大功能域:时间编排、列表管理、数据洞察、个人激励。
从技术栈角度审视,本应用基于 HarmonyOS 的 ArkTS 语言开发。ArkTS 是在 TypeScript 基础上扩展而来的声明式编程语言,它保留了 TS 的类型系统优势,同时深度融入了 ArkUI 的声明式 UI 范式。应用大量使用了 @Entry、@Component、@State、@Builder、@Observed 等装饰器来构建响应式的组件树与状态管理链路。在视觉设计上,整个应用采用了一套以"天空蓝"为主色调的清新配色体系——主色 #0277BD、辅助色 #0288D1、背景色 #E1F5FE,搭配各功能分类的语义化颜色(身体-绿、心智-紫、计划-橙、学习-蓝),营造出一种沉静、专注又不失活力的视觉氛围,与"晨间"这一主题高度契合。
在数据组织上,应用通过 @Observed 装饰的可观察类 HabitEntry 来建模每一条习惯记录,并通过模块级的常量数组进行集中托管。所有 UI 渲染均由声明式 Builder 函数驱动,状态的变更会自动触发相关组件的重渲染。弹窗交互(添加、编辑、删除确认)则通过布尔状态变量配合 @Builder 模态层来实现,而非依赖系统原生弹窗,从而获得了更高的视觉定制自由度。下面,我们将按照代码的实际组织顺序,逐段拆解这份源码,深入剖析其设计思路、实现细节和工程考量。
二、主题配色与枚举常量定义
// 晨间习惯追踪 — 天空蓝 #0277BD | 浅蓝 #0288D1 | 背景 #E1F5FE
enum MorningTab { Timetable = 0, AllList = 1, Stats = 2, Profile = 3 }
enum CategoryKind { Body = 0, Mind = 1, Plan = 2, Learn = 3 }

文件最开头是一行注释,它既是整个配色体系的总纲,也是开发者在编码前就确立的视觉规范。天空蓝 #0277BD 作为主色,承载了顶部标题栏、底部激活态 Tab、主要按钮等"强调"角色;浅蓝 #0288D1 作为辅助色,用于次级强调和渐变过渡;背景色 #E1F5FE 则铺满了所有内容区域的底色,带来通透、轻盈的晨间质感。这种"三色定调"的做法在移动端 UI 设计中非常成熟——主色统领视觉焦点,辅色制造层次,背景色统一基调,三者协同避免了界面的杂乱感。
紧接着定义了两个枚举类型。MorningTab 将四个 Tab 页的索引语义化为 Timetable、AllList、Stats、Profile,分别对应晨间时间线、所有习惯列表、数据统计和个人中心。使用枚举而非"裸数字"是工程实践中的基本素养——当我们在状态变量中写下 this.curTab === 0 时,0 的含义并不直观,但配合枚举名就能立刻理解它代表的是"时间线页"。CategoryKind 则定义了习惯的四大分类:身体(Body)、心智(Mind)、计划(Plan)、学习(Learn)。这种四分法覆盖了晨间习惯的主要维度,既有物理层面的身体唤醒,也有精神层面的心智修炼,还有面向执行的计划梳理和面向成长的学习输入,分类逻辑自洽且互不重叠。
值得注意的一个细节是,这两个枚举虽然定义了,但在后续代码中实际使用频率并不高——开发者更多时候直接用数字字面量 0、1、2、3 进行判断和赋值。这在小型项目中尚可接受,但在团队协作场景下,推荐统一使用枚举名引用,以获得更好的可读性和重构安全性。
三、数据接口定义:图表与统计的契约
interface BarChartData {
label: string
value: number
maxVal: number
barColor: string
}
interface CategoryStats {
cat: number
catName: string
count: number
percentage: number
barColor: string
}
interface StreakRow {
nameVal: string
iconVal: string
streakNum: number
allDays: number
barColor: string
}

这里定义了三个 interface,它们本身不产生运行时对象,纯粹是 TypeScript 层面的类型契约,用于约束后续 @Builder 函数的入参类型。这种"先定义数据形状,再定义渲染逻辑"的做法,是声明式 UI 开发中推荐的模式——它让数据与视图之间形成清晰的边界,当数据结构发生变化时,编译器能立即在所有消费点报错,从而避免运行时的隐式 bug。
BarChartData 描述的是柱状图的单根柱子数据:label 是底部标签(如"一""二"代表星期),value 是实际数值,maxVal 是该柱子的满刻度值(用于计算柱子高度的比例),barColor 是柱子颜色。这种把"值"和"最大值"分开设计的方式,使得每根柱子可以拥有独立的刻度基准,虽然在当前实现中所有柱子共用 maxVal: 28,但接口的灵活性为未来的扩展预留了空间。
CategoryStats 描述的是分类分布统计的单行数据:cat 是分类编号,catName 是中文名称,count 是该分类下的习惯数量,percentage 是占比百分比,barColor 是进度条颜色。这组数据直接驱动了统计页的"分类分布"区块渲染。
StreakRow 描述的是"连续天数排行榜"的单行数据:nameVal 是习惯名称,iconVal 是 Emoji 图标,streakNum 是当前连续天数,allDays 是累计总天数,barColor 是进度条颜色。值得注意的是这里用 allDays / 360 来计算进度条宽度比例,暗示了排行榜以"一年(约360天)"为满刻度基准,这是一种巧妙的设计——既让进度条有了直观的年度参照,又避免了不同习惯之间因总天数差异过大而导致进度条长度悬殊的问题。
三个接口都包含 barColor 字段,体现了应用对"颜色语义化"的重视:每一条数据都自带它的视觉标识,渲染时无需再通过额外的映射函数去查颜色,实现了数据与样式的内聚。
四、核心数据模型:@Observed 可观察类 HabitEntry
@Observed
class HabitEntry {
idVal: number = 0
nameVal: string = ''
iconVal: string = ''
catVal: number = 0
minutesGoal: number = 0
bestSlot: string = ''
streakNum: number = 0
allDays: number = 0
done: boolean = false
sortOrder: number = 0
descVal: string = ''
weekDone: boolean[] = [false, false, false, false, false, false, false]
constructor(
idVal: number, nameVal: string, iconVal: string, catVal: number,
minutesGoal: number, bestSlot: string, streakNum: number,
allDays: number, done: boolean, sortOrder: number,
descVal: string, weekDone: boolean[]
) {
this.idVal = idVal
this.nameVal = nameVal
// ... 逐字段赋值
}
}

这是整个应用最核心的数据模型。@Observed 装饰器是 ArkUI V1 状态管理体系中的关键一环——被它装饰的类,其实例的属性变更能够被框架感知并驱动依赖该实例的 @Builder 或组件进行重渲染。换句话说,当某条习惯的 done 字段从 false 变为 true 时,所有引用了这条习惯的 UI 片段都会自动刷新,开发者无需手动调用任何更新方法。这种"数据驱动视图"的响应式机制,正是声明式 UI 相较于传统命令式 UI 的核心优势。
HabitEntry 类包含了十二个字段,覆盖了一条习惯的全部信息维度。idVal 是唯一标识符;nameVal 和 iconVal 分别是显示名称和 Emoji 图标,用 Emoji 作为图标是一种轻量化的设计选择——无需引入图标字体或图片资源,纯文本即可渲染出丰富的视觉符号,且天然支持跨平台。catVal 是分类编号,对应前文的 CategoryKind 枚举。minutesGoal 是目标时长(分钟),bestSlot 是最佳执行时段(如"6:00"),这两个字段共同支撑了时间线的编排逻辑。streakNum 是当前连续打卡天数,allDays 是累计总天数,二者分别服务于"即时激励"和"长期回顾"两种心理动机。done 是今日是否完成的布尔标记,sortOrder 是排序权重,descVal 是描述文案。weekDone 是一个长度为7的布尔数组,记录本周一到周日的完成情况,直接驱动了习惯卡片上的"周打卡点阵"渲染。
构造函数采用位置参数逐一赋值的方式。这种写法在参数较多时略显冗长(共12个参数),但好处是显式且类型安全。在实际工程中,如果参数进一步增加,可以考虑改用对象字面量(Options 模式)来提升可读性,例如 new HabitEntry({ idVal: 1, nameVal: '晨间冥想', ... })。不过当前的设计已经足够清晰,且每个字段都有默认值初始化,保证了即使构造时遗漏某参数,对象也处于合法状态。
五、分类辅助函数:名称、颜色与图标的映射
function catNameOf(cat: number): string {
if (cat === 0) { return '身体' }
if (cat === 1) { return '心智' }
if (cat === 2) { return '计划' }
return '学习'
}
function catColorOf(cat: number): string {
if (cat === 0) { return '#4CAF50' }
if (cat === 1) { return '#9C27B0' }
if (cat === 2) { return '#FF9800' }
return '#0277BD'
}
function catIconOf(cat: number): string {
if (cat === 0) { return '🏃' }
if (cat === 1) { return '🧠' }
if (cat === 2) { return '📋' }
return '📚'
}

这三个函数构成了分类系统的"映射层",它们分别负责将分类编号翻译为人类可读的中文名称、视觉可感知的颜色代码和直观的 Emoji 图标。这种将映射逻辑集中到独立函数中的做法,是"单一职责原则"在小型项目中的朴素体现——当分类体系调整时(比如新增一个"社交"分类),只需修改这三个函数即可,而不必在全代码库中搜索散落的硬编码。
catNameOf 返回四个分类的中文名:身体、心智、计划、学习。catColorOf 为每个分类分配了语义化的颜色:身体用绿色 #4CAF50(生命力、健康)、心智用紫色 #9C27B0(神秘、内省)、计划用橙色 #FF9800(活力、执行)、学习用蓝色 #0277BD(沉静、知识)。这套配色不仅美观,而且具有认知心理学层面的合理性——颜色与分类的语义关联帮助用户在浏览列表时快速识别习惯属性。catIconOf 则为每个分类配了代表性 Emoji。
三个函数都采用了 if 链式判断 + 末尾 return 兜底的写法。由于分类编号只有 0-3 四种取值,末尾的 return 实际上兜底的就是 cat === 3 的情况(学习)。这种写法比 switch-case 更紧凑,但在分类数量增多时可读性会下降。更健壮的写法可以用查表法(数组或 Map),但在此场景下当前的实现已足够简洁高效。
六、星期标签与习惯数据集
const dayLabels: string[] = ['一', '二', '三', '四', '五', '六', '日']
const habitEntries: HabitEntry[] = [
new HabitEntry(1, '晨间冥想', '🧘', CategoryKind.Mind, 10, '6:00', 45, 180, true, 1, '每天清晨冥想让心灵平静清晰', [true, true, true, true, true, false, false]),
new HabitEntry(2, '清晨拉伸', '🤸', CategoryKind.Body, 15, '5:30', 30, 120, true, 2, '唤醒身体告别僵硬', [true, true, true, false, true, false, false]),
// ... 共 28 条习惯
new HabitEntry(28, '拍打经络', '👐', CategoryKind.Body, 5, '8:45', 15, 55, false, 28, '按摩拍打手臂和腿部经络', [false, false, false, true, false, false, false])
]

dayLabels 是一个简单的常量数组,定义了周一到周日的单字标签,用于统计页柱状图的横轴。用单字"一二三四五六日"而非"周一周二…",是为了在有限的柱状图宽度内保持文字紧凑。
habitEntries 是整个应用的数据源核心——一个包含 28 条 HabitEntry 实例的常量数组。这 28 条习惯覆盖了四大分类的丰富场景:身体类有拉伸、冷水澡、瑜伽、晨跑、喝温水、整理床铺、刷牙等;心智类有冥想、日记、感恩、深呼吸、视觉化、自我肯定、回顾昨日等;计划类有制定今日计划、思维导图;学习类有阅读新闻、英语听力、阅读书籍、编程练习、日语学习等。每条习惯都配有目标时长、最佳时段、连续天数、累计天数、完成状态和周打卡数组,数据非常"丰满"。
这里使用模块级常量数组而非组件内的 @State 来托管数据,是一个值得讨论的设计决策。好处是数据全局共享,所有 Tab 页和 Builder 都能直接引用 habitEntries,无需通过 props 层层传递;而且因为 HabitEntry 被 @Observed 装饰,其属性变更仍能驱动 UI 更新。但代价是数据生命周期与应用进程绑定,无法实现持久化(应用重启后数据归零)。在真实产品中,通常会搭配 @AppStorage 或 preferences 持久化方案,将数据落盘到本地存储。
七、统计计算函数:完成数、完美早晨、评分与总时长
function allCompletedCount(): number {
return habitEntries.filter((e: HabitEntry): boolean => { return e.done }).length
}
function perfectMornings(): number {
return 28
}
function computeMorningGrade(): number {
return 82
}
function computeTotalMinutes(): number {
return habitEntries[0].minutesGoal + habitEntries[1].minutesGoal + ... + habitEntries[27].minutesGoal
}

这四个函数为 UI 层提供聚合统计能力。allCompletedCount 使用数组的 filter 方法筛选出所有 done 为 true 的习惯,再取 length,这是函数式编程中典型的"过滤-计数"模式。每次调用都会实时遍历数组,保证了统计结果与数据的同步性。perfectMornings 和 computeMorningGrade 目前返回固定值 28 和 82,属于占位实现——在真实应用中,它们应该基于历史打卡记录动态计算(例如"完美早晨"指的是所有习惯全部完成的天数,“晨间评分"则是完成率与时长完成度的加权综合分)。computeTotalMinutes 的实现方式颇为"原始”——它把 28 个元素的 minutesGoal 手动逐一相加,而不是用 reduce 一行搞定。这种写法虽然能运行,但可维护性差,若习惯数量增减需要同步修改函数体。更优雅的写法是 habitEntries.reduce((sum, e) => sum + e.minutesGoal, 0)。
这些函数被设计为"无副作用纯函数",每次调用都基于当前数据状态返回结果,不修改任何状态。这与 React/Vue 生态中"derived state(派生状态)"的理念一致——能从已有数据计算得出的值,就不要额外存一份,避免数据不一致。
八、主组件状态定义:MorningRoutineApp 的 @State 变量
@Entry
@Component
struct MorningRoutineApp {
@State curTab: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State pickIdx: number = -1
@State formName: string = ''
@State formIcon: string = '🧘'
@State formCat: number = 0
@State formMins: string = '10'
@State formSlot: string = '6:00'
@State formRemind: string = '启'
@State doneFilter: number = 0

@Entry 标记这是应用的入口组件,@Component 声明这是一个自定义组件。二者搭配,使得 MorningRoutineApp 成为整个应用的根节点,框架会以此为起点构建组件树并挂载到页面。
@State 装饰的变量是组件内部的响应式状态。当它们的值发生变化时,框架会自动找到所有引用了该状态的 UI 片段并触发重渲染。这里定义了 12 个状态变量,按职责可分为三组:
第一组是导航状态:curTab 记录当前激活的 Tab 索引(0-3),初始为 0(晨间时间线页)。
第二组是弹窗状态:addOpen、editOpen、delOpen 三个布尔值分别控制添加习惯弹窗、编辑习惯弹窗、删除确认弹窗的显隐。pickIdx 记录当前被选中的习惯在数组中的索引,用于编辑和删除场景下定位目标习惯。
第三组是表单状态:formName、formIcon、formCat、formMins、formSlot、formRemind 六个变量共同构成了"习惯表单"的内存模型。无论是新增还是编辑,都复用同一组表单状态,弹窗打开时先填充(新增时填默认值,编辑时填目标习惯的现有值),用户修改后保存。这种"共享表单状态"的设计减少了状态数量,但要求开发者在打开弹窗时务必正确初始化每个字段,否则会出现"上次编辑的残留数据"问题。
doneFilter 是"所有习惯"页的筛选状态(0=全部、1=已完成、2=未完成),它驱动了筛选标签的激活态切换。
九、模态背景与通用标题栏 Builder
@Builder modalBg(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.45)').onClick(onClose)
}
@Builder headerRow(title: string, sub: string) {
Row() {
Column() {
Text(title).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text(sub).fontSize(12).fontColor('#B3E5FC').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('+').fontSize(26).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.width(42).height(42).textAlign(TextAlign.Center)
.backgroundColor('#0288D1').borderRadius(21)
.onClick(() => {
this.formName = ''
this.formIcon = '🧘'
this.formCat = 0
this.formMins = '10'
this.formSlot = '6:00'
this.formRemind = '启'
this.addOpen = true
})
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 })
.backgroundColor('#0277BD').alignItems(VerticalAlign.Center)
}
@Builder 是 ArkUI 中用于定义可复用 UI 片段的装饰器。与 @Component 不同,@Builder 定义的函数不会创建独立的组件实例,而是直接"内联展开"到调用处,因此它没有独立的状态,但能访问所属组件的 this(包括 @State 变量)。这种特性使其非常适合封装"纯展示 + 事件绑定"的 UI 片段。
modalBg 是一个接受回调函数 onClose 的 Builder,它渲染一个铺满全屏的半透明黑色遮罩层(rgba(0,0,0,0.45)),点击时触发 onClose 回调关闭弹窗。这是模态弹窗的标准交互模式——点击遮罩区域关闭弹窗,提供了除"关闭按钮"之外的第二种退出路径,提升了交互容错性。
headerRow 是复用度极高的通用标题栏 Builder,接受 title 和 sub 两个字符串参数。它渲染一个蓝色背景的横向布局:左侧是标题(22px 白色粗体)和副标题(12px 浅蓝色),右侧是一个圆形的"+"添加按钮。点击添加按钮时,会将表单状态重置为默认值(名称清空、图标默认冥想、分类默认身体、时长默认10分钟、时段默认6:00、提醒默认开启),然后置 addOpen = true 打开添加弹窗。这里"先重置表单、再打开弹窗"的顺序至关重要,确保用户每次看到的都是干净的初始表单。headerRow 被晨间时间线页和所有习惯页共用,体现了 Builder 的复用价值。
十、习惯卡片行 Builder:habitRow
@Builder habitRow(h: HabitEntry) {
Row() {
Column() {
Text(h.iconVal).fontSize(26)
}
.width(50).height(50).backgroundColor(h.done ? '#E8F5E9' : '#FFF3E0')
.borderRadius(14).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Row() {
Text(h.nameVal).fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#01579B')
.decoration(h.done ? { type: TextDecorationType.LineThrough } : { type: TextDecorationType.None })
Column() {
Text(catNameOf(h.catVal)).fontSize(9).fontColor('#FFFFFF')
.backgroundColor(catColorOf(h.catVal)).borderRadius(6)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 6 })
}
}
Text(h.descVal).fontSize(11).fontColor('#90A4AE').margin({ top: 3 }).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
Column() {
Row() {
Text('🔥').fontSize(13)
Text(h.streakNum.toString() + '天').fontSize(11).fontColor('#FF8F00').fontWeight(FontWeight.Bold).margin({ left: 2 })
}
Text(h.bestSlot).fontSize(12).fontColor('#0277BD').fontWeight(FontWeight.Medium).margin({ top: 4 })
}.alignItems(HorizontalAlign.End)
Column() {
Text('✏️').fontSize(16).fontColor('#0288D1')
.onClick(() => {
this.pickIdx = habitEntries.indexOf(h)
this.formName = h.nameVal
this.formIcon = h.iconVal
this.formCat = h.catVal
this.formMins = h.minutesGoal.toString()
this.formSlot = h.bestSlot
this.editOpen = true
})
Text('🗑️').fontSize(14).fontColor('#EF5350').margin({ top: 10 })
.onClick(() => { this.pickIdx = habitEntries.indexOf(h); this.delOpen = true })
}.alignItems(HorizontalAlign.End)
}
.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(14).margin({ top: 4, bottom: 4, left: 12, right: 12 })
.shadow({ radius: 4, color: '#1A0277BD', offsetY: 2 })
}
habitRow 是整个应用中复用频率最高的 Builder——它在晨间时间线页和所有习惯页中被调用了数十次,每一次调用都渲染出一张完整的习惯卡片。这张卡片的信息密度很高,却在视觉上保持了清爽有序,体现了优秀的布局设计能力。
卡片整体是一个 Row(横向布局),白色圆角背景配以浅蓝色微阴影,营造出悬浮于浅蓝背景之上的"卡片"质感。从左到右分为四个区域:
第一区:图标圆角方块。 50x50 的圆角方块,背景色根据完成状态动态切换——已完成(done=true)时为浅绿 #E8F5E9,未完成时为浅橙 #FFF3E0。这种"用颜色区分状态"的设计让用户扫一眼就能判断打卡情况,无需阅读文字。方块内居中显示 26px 的 Emoji 图标。
第二区:名称与描述。 这是卡片的主体信息区,占据 layoutWeight(1) 的弹性宽度。名称行是一个 Row,包含习惯名称(15px 深蓝粗体)和分类标签(9px 白字,背景为分类颜色,圆角胶囊形)。名称的 decoration 属性会根据完成状态添加删除线——已完成时文字被划掉,强化"已完成"的视觉语义。描述文字 11px 灰色,maxLines(1) 限制单行,textOverflow: Ellipsis 超出部分以省略号截断,保证了卡片高度的一致性。
第三区:连续天数与时段。 右上显示"🔥+天数"(橙色粗体),右下显示最佳时段(蓝色中粗体),纵向右对齐排列。
第四区:操作按钮。 "✏️"编辑按钮和"🗑️"删除按钮纵向排列。点击编辑按钮时,先通过 habitEntries.indexOf(h) 找到当前习惯在数组中的索引并存入 pickIdx,然后将该习惯的现有值逐一填入表单状态,最后打开编辑弹窗。点击删除按钮时,同样记录索引并打开删除确认弹窗。这里用 indexOf 而非直接传索引,是因为 habitRow 接收的是 HabitEntry 对象引用,这种设计让 Builder 的调用者无需关心索引,只需传入对象即可,降低了使用心智负担。
十一、时间线时段分隔线 Builder:timeSlotLine
@Builder timeSlotLine(slot: string, rows: number) {
Row() {
Column() {
Text(slot).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#0277BD')
Column().width(3).height(32).backgroundColor('#81D4FA').borderRadius(2)
}.width(50).alignItems(HorizontalAlign.Center)
Column().width(16)
Column() {
if (rows === 0) {
Text('暂无安排').fontSize(11).fontColor('#B0BEC5')
}
if (rows >= 1) {
Row() {
Column().width(6).height(6).backgroundColor('#4CAF50').borderRadius(3)
Text('已完成习惯').fontSize(10).fontColor('#4CAF50').margin({ left: 4 })
}
}
if (rows >= 2) {
Row() {
Column().width(6).height(6).backgroundColor('#FF9800').borderRadius(3)
Text('部分完成').fontSize(10).fontColor('#FF9800').margin({ left: 4 })
}.margin({ top: 2 })
}
}.padding(10).backgroundColor('#F1F8FF').borderRadius(10)
}
.width('100%').padding({ left: 12, right: 12, top: 8, bottom: 4 })
}
timeSlotLine 是晨间时间线页的"时段分隔器"。它接受两个参数:slot 是时段字符串(如"5:30"),rows 是该时段下的习惯数量。它的作用是在时间线的不同时段之间插入一条视觉分隔,同时通过小圆点图例暗示该时段的完成情况。
布局上,左侧 50px 宽的区域显示时段文字和一条竖向的蓝色"时间轴"线条(3x32px,浅蓝圆角),中间 16px 间距,右侧是一个浅蓝背景的圆角信息块。信息块内根据 rows 值条件渲染图例:rows === 0 时显示"暂无安排"灰色提示文字;rows >= 1 时显示绿色圆点+"已完成习惯"图例;rows >= 2 时额外显示橙色圆点+"部分完成"图例。
这里的条件渲染使用了 if 语句而非三元表达式,这是 ArkUI 中条件分支渲染的标准写法。if 块内的 UI 只有条件为真时才会被创建并挂载,条件为假时会被销毁,这与 React 中的条件渲染行为一致。rows 参数实际上同时编码了"数量"和"状态"两种信息——值越大意味着该时段的习惯越多,也暗示了完成度可能更复杂。不过当前实现中图例文案是固定的(“已完成习惯”“部分完成”),并没有真正动态反映该时段下习惯的实际完成比例,属于示意性设计。
十二、晨间时间线页 Builder:morningTimelineTab
@Builder morningTimelineTab() {
Column() {
this.headerRow('☀️ 晨间', allCompletedCount() + '/' + habitEntries.length + ' 已完成 · 评分 ' + computeMorningGrade())
Scroll() {
Column() {
Row() {
this.metricBox('🌅 晨间评分', computeMorningGrade().toString(), '分', '#0277BD')
this.metricBox('⭐ 完美早晨', perfectMornings().toString(), '天', '#0288D1')
}.width('100%').margin({ bottom: 6 })
Row() {
this.metricBox('🔥 总连续天数', '95', '天', '#FF8F00')
this.metricBox('⏱️ 今日总时长', computeTotalMinutes().toString(), '分钟', '#4CAF50')
}.width('100%').margin({ bottom: 10 })
Text('⏰ 时间线 5:30 - 9:00').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ left: 16, top: 8, bottom: 4 })
this.timeSlotLine('5:30', 2);
this.habitRow(habitEntries[9]);
this.habitRow(habitEntries[1]);
this.habitRow(habitEntries[14])
// ... 按时段依次排列所有习惯
this.timeSlotLine('8:30', 1);
this.habitRow(habitEntries[26])
Column().height(20)
}.width('100%')
}
.layoutWeight(1).backgroundColor('#E1F5FE').scrollBar(BarState.Off)
}.width('100%').height('100%')
}
这是应用的首页,也是信息量最大的页面。它将一天中 5:30 到 9:00 这三个半小时的晨间时光,编排成一条纵向滚动的时间线,让用户一眼看到"什么时间该做什么"。
页面结构分为三层:顶部是通用标题栏(通过 headerRow 复用),副标题动态拼接了完成数/总数和评分;中间是可滚动的内容区(Scroll 包裹 Column),滚动条被隐藏(scrollBar(BarState.Off))以保持视觉洁净;底部留有 20px 的间距填充。
内容区顶部是 2x2 的指标卡矩阵:晨间评分、完美早晨天数、总连续天数、今日总时长。每张卡片通过 metricBox Builder 渲染,四个指标分别使用不同的主题色,形成色彩节奏。
指标卡下方是时间线主体。每个时段先调用 timeSlotLine 渲染分隔线,再依次调用 habitRow 渲染该时段下的习惯卡片。值得注意的是,习惯的排列顺序并非按数组索引,而是按 bestSlot(最佳时段)重新编排——例如 5:30 时段下排列的是 habitEntries[9](晨跑)、habitEntries[1](清晨拉伸)、habitEntries[14](编程练习),它们在数据中的 bestSlot 都是"5:30"。这种"按业务逻辑重排"的做法体现了时间线页的核心价值——它不是简单的列表,而是一张"晨间日程表"。
时间线从 5:30 一直延伸到 8:30,共覆盖 13 个时段,几乎将 28 条习惯全部归位到了对应的时间锚点上。用户在浏览这条时间线时,能清晰地感知到自己的早晨是如何被结构化的,哪些时段习惯密集、哪些时段相对空闲,这种"时间可视化"对于优化晨间流程极具参考价值。
十三、指标卡 Builder:metricBox
@Builder metricBox(icon: string, val: string, unit: string, clr: string) {
Column() {
Text(icon).fontSize(14)
Row() {
Text(val).fontSize(20).fontWeight(FontWeight.Bold).fontColor(clr)
Text(' ' + unit).fontSize(10).fontColor('#90A4AE')
}.margin({ top: 4 })
}.layoutWeight(1).padding({ top: 12, bottom: 12 }).backgroundColor('#FFFFFF').borderRadius(14)
.alignItems(HorizontalAlign.Center).margin({ left: 6, right: 6 })
.shadow({ radius: 3, color: '#1A0277BD', offsetY: 1 })
}
metricBox 是一个高度通用的指标卡 Builder,接受四个参数:icon(Emoji 图标)、val(数值文本)、unit(单位文本)、clr(数值颜色)。它渲染一个白色圆角卡片,内部纵向排列图标和"数值+单位"行。数值使用 20px 粗体并采用传入的主题色,单位使用 10px 灰色,二者大小对比鲜明,突出数值的视觉权重。
卡片使用 layoutWeight(1) 弹性占宽,配合外层的 margin({ left: 6, right: 6 }),使得一行内放置两张卡片时能自动均分宽度并保留间距。阴影参数 radius: 3, color: '#1A0277BD', offsetY: 1 是非常克制的微阴影——半径仅3、偏移仅1,颜色是带 10% 透明度的主题蓝,效果是让卡片"轻轻浮起"而非"高高悬空",符合 Material Design 中"elevation"的克制美学。
这个 Builder 在整个应用中被调用了十多次——晨间时间线页的 4 张指标卡、统计页的 4 张指标卡、个人中心页的 4 张指标卡,全部复用它。如果未来需要新增指标类型,只需多调用一次 metricBox 并传入不同参数即可,体现了 Builder 模式的强大复用能力。
十四、所有习惯列表页 Builder:allHabitsTab
@Builder allHabitsTab() {
Column() {
this.headerRow('📋 所有习惯', '共' + habitEntries.length + '个习惯 · 按分类筛选')
Row() {
Text('全部').fontSize(12).fontColor(this.doneFilter === 0 ? '#FFFFFF' : '#0277BD')
.backgroundColor(this.doneFilter === 0 ? '#0277BD' : '#E1F5FE')
.borderRadius(14).padding({ left: 14, right: 14, top: 6, bottom: 6 })
.onClick(() => { this.doneFilter = 0 })
Text('已完成').fontSize(12).fontColor(this.doneFilter === 1 ? '#FFFFFF' : '#0277BD')
.backgroundColor(this.doneFilter === 1 ? '#4CAF50' : '#E8F5E9')
.borderRadius(14).padding({ left: 14, right: 14, top: 6, bottom: 6 }).margin({ left: 8 })
.onClick(() => { this.doneFilter = 1 })
Text('未完成').fontSize(12).fontColor(this.doneFilter === 2 ? '#FFFFFF' : '#0277BD')
.backgroundColor(this.doneFilter === 2 ? '#FF9800' : '#FFF3E0')
.borderRadius(14).padding({ left: 14, right: 14, top: 6, bottom: 6 }).margin({ left: 8 })
.onClick(() => { this.doneFilter = 2 })
Row().layoutWeight(1)
}.width('100%').padding({ left: 16, right: 16, top: 8, bottom: 4 })
Scroll() {
Column() {
this.habitRow(habitEntries[0])
this.habitRow(habitEntries[1])
// ... 依次渲染全部 28 条习惯
this.habitRow(habitEntries[27])
Column().height(16)
}.width('100%')
}
.layoutWeight(1).backgroundColor('#E1F5FE').scrollBar(BarState.Off)
}.width('100%').height('100%')
}
如果说晨间时间线页是"按时间维度"组织习惯,那么所有习惯页就是"按列表维度"呈现全部习惯的完整索引。它复用了 headerRow 标题栏,副标题显示习惯总数和筛选提示。
标题栏下方是一组筛选标签:全部、已完成、未完成,三者互斥切换。每个标签的激活态通过三元表达式动态切换文字颜色和背景色——激活时白字+深色背景,未激活时深色字+浅色背景。三个标签分别对应蓝、绿、橙三种语义色,与 doneFilter 的 0/1/2 取值一一映射。点击标签时只需修改 doneFilter 状态,标签的视觉态会自动响应更新。末尾的 Row().layoutWeight(1) 是一个弹性占位,将标签推向左侧对齐。
筛选标签下方是可滚动的习惯列表,依次调用 28 次 habitRow 渲染所有习惯卡片。这里有一个值得注意的细节:虽然 UI 上提供了"已完成/未完成"筛选标签,但列表渲染时并没有真正根据 doneFilter 过滤数据——无论选哪个标签,显示的都是全部 28 条习惯。这说明筛选功能的视觉层已实现,但逻辑层尚未接通。在完整实现中,应该用 habitEntries.filter(e => doneFilter === 0 || (doneFilter === 1 && e.done) || (doneFilter === 2 && !e.done)) 来动态过滤。或者使用 ForEach 配合条件渲染来实现。
十五、统计页柱状图 Builder:weekBar
@Builder weekBar(data: BarChartData) {
Column() {
Column()
.width(30)
.height((data.value / data.maxVal) * 100)
.backgroundColor(data.barColor)
.borderRadius({ topLeft: 6, topRight: 6, bottomLeft: 0, bottomRight: 0 })
Text(data.label).fontSize(11).fontColor('#78909C').margin({ top: 6 })
Text(data.value.toString()).fontSize(10).fontColor('#0277BD').fontWeight(FontWeight.Bold)
}.alignItems(HorizontalAlign.Center)
}
weekBar 渲染统计页"本周完成情况"柱状图的单根柱子。它接收一个 BarChartData 对象,渲染出纵向排列的三层结构:顶部是柱子本体(30px 宽,高度按 value/maxVal*100 比例计算),中间是星期标签,底部是数值文本。
柱子的高度计算公式 (data.value / data.maxVal) * 100 是一种归一化处理——将实际值除以满刻度值再乘以 100,得到以像素为单位的高度值。当 maxVal = 28 时,value = 24 的柱子高度为约 85.7px,value = 5 的柱子高度约 17.9px。柱子顶部两角圆角6px、底部不圆角,模拟了真实柱状图的"上圆下平"视觉形态。
这种"用 Column 宽高模拟柱状图"的做法是纯 ArkUI 实现数据可视化的典型手段——无需引入第三方图表库,用基础组件的尺寸属性即可绘制出简洁的柱状图。虽然功能不及专业图表库丰富(没有动画、没有 tooltip),但胜在轻量、可控、无依赖,非常适合这类简单场景。
十六、分类分布进度条 Builder:catBreakdownBar
@Builder catBreakdownBar(stat: CategoryStats) {
Column() {
Row() {
Text(catIconOf(stat.cat) + ' ' + stat.catName).fontSize(13).fontColor('#37474F')
Row().layoutWeight(1)
Text(stat.count.toString() + '个 ' + stat.percentage.toString() + '%').fontSize(11).fontColor('#90A4AE')
}.width('100%')
Row() {
Column()
.width(stat.percentage + '%')
.height(12)
.backgroundColor(stat.barColor)
.borderRadius(6)
Row().layoutWeight(1)
}
.width('100%').height(12).backgroundColor('#E1F5FE').borderRadius(6).margin({ top: 6 })
}.width('100%').margin({ bottom: 12 })
}
catBreakdownBar 渲染统计页"分类分布"区块的单行进度条。它接收一个 CategoryStats 对象,渲染出两行结构:第一行是分类图标+名称(左对齐)和"数量+百分比"文本(右对齐),中间用 Row().layoutWeight(1) 弹性占位撑开;第二行是进度条本体——一个浅蓝底色的圆角凹槽,内嵌一个宽度为 percentage% 的彩色填充条。
进度条的宽度用百分比字符串 '43%' 直接设置,这是 ArkUI 支持的百分比布局——Column().width('43%') 会让该 Column 占据父容器宽度的 43%。外层凹槽固定高度 12px 并设圆角 6px(正好是高度的一半,形成完全圆角),内层填充条同样圆角 6px,二者叠合形成"胶囊形进度条"的视觉效果。
这种"外层凹槽 + 内层填充"的双层结构是进度条组件的标准实现模式。外层提供背景轨道,内层提供动态填充,填充宽度由数据驱动。在当前实现中,填充宽度是静态的(来自硬编码的 percentage 值),若要实现动画效果,可以给内层 Column 添加 .animation() 属性,使宽度变化时产生平滑过渡。
十七、连续天数排行榜 Builder:streakBar
@Builder streakBar(leader: StreakRow) {
Row() {
Text(leader.iconVal).fontSize(24)
Column() {
Row() {
Text(leader.nameVal).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#263238')
Text('🔥' + leader.streakNum.toString() + '天').fontSize(11).fontColor('#FF8F00').fontWeight(FontWeight.Bold).margin({ left: 6 })
}.width('100%').alignItems(VerticalAlign.Center)
Row() {
Column()
.width(((leader.allDays / 360) * 100).toString() + '%')
.height(8)
.backgroundColor(leader.barColor)
.borderRadius(4)
Row().layoutWeight(1)
}
.width('100%').height(8).backgroundColor('#E1F5FE').borderRadius(4).margin({ top: 4 })
}.layoutWeight(1).margin({ left: 10 })
Text(leader.allDays.toString() + '天').fontSize(12).fontColor('#78909C')
}.width('100%').padding({ top: 8, bottom: 8 })
}
streakBar 渲染统计页"连续天数排行榜"的单行条目。它接收一个 StreakRow 对象,渲染出横向布局:左侧是 24px 的 Emoji 图标,中间是名称+连续天数标签和进度条,右侧是累计总天数文本。
中间区域的信息分两层:上层是"习惯名称(深灰粗体)+🔥连续天数(橙色粗体)",下层是进度条。进度条的设计与前文 catBreakdownBar 类似,但填充宽度的计算逻辑不同——这里用 (leader.allDays / 360) * 100 计算百分比,即以 360 天(约一年)为满刻度。这种设计让排行榜的进度条具备了"年度完成度"的语义:累计 360 天的习惯进度条满格,累计 180 天的习惯进度条半格,用户能直观感知每条习惯坚持了多久。
排行榜数据中,排名靠前的习惯(刷牙 360 天、整理床铺 350 天、喝温水 300 天)都是"日常刚需"类行为,这与现实高度吻合——越基础的卫生/生活习惯,越容易长期坚持。排行榜的 barColor 从绿色到浅绿再到蓝色渐变,形成了一种"从高到低"的色彩梯度,辅助强化了排序的视觉层次。
十八、数据统计页 Builder:statsTab
@Builder statsTab() {
Column() {
Row() {
Text('📊 数据统计').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Row().layoutWeight(1)
Text('📈').fontSize(22)
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 })
.backgroundColor('#0277BD').alignItems(VerticalAlign.Center)
Scroll() {
Column() {
Row() {
this.metricBox('📊 习惯总数', habitEntries.length.toString(), '个', '#0277BD')
this.metricBox('✅ 今日完成', allCompletedCount().toString(), '/' + habitEntries.length, '#4CAF50')
}.width('100%').margin({ bottom: 6 })
Row() {
this.metricBox('🔥 最高连续', '95', '天', '#FF8F00')
this.metricBox('⭐ 完美次数', perfectMornings().toString(), '次', '#9C27B0')
}.width('100%').margin({ bottom: 8 })
Column() {
Text('📅 本周完成情况').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ top: 4, bottom: 10 })
Row() {
ForEach(dayLabels, (d: string, idx: number) => {
this.weekBar({
label: d,
value: [18, 22, 24, 20, 21, 8, 5][idx] ?? 0,
maxVal: 28,
barColor: ['#0277BD', '#0288D1', '#0277BD', '#0288D1', '#0277BD', '#81D4FA', '#E1F5FE'][idx] ?? '#E1F5FE'
} as BarChartData)
}, (d: string) => d)
}
.width('100%').height(120).alignItems(VerticalAlign.Bottom)
.padding({ top: 4 })
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ top: 6, bottom: 8 })
Column() {
Text('🏷️ 分类分布').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ top: 4, bottom: 10 })
this.catBreakdownBar({ cat: 0, catName: '身体', count: 12, percentage: 43, barColor: '#4CAF50' } as CategoryStats)
this.catBreakdownBar({ cat: 1, catName: '心智', count: 6, percentage: 21, barColor: '#9C27B0' } as CategoryStats)
this.catBreakdownBar({ cat: 2, catName: '计划', count: 3, percentage: 11, barColor: '#FF9800' } as CategoryStats)
this.catBreakdownBar({ cat: 3, catName: '学习', count: 7, percentage: 25, barColor: '#0277BD' } as CategoryStats)
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ bottom: 8 })
Column() {
Text('🏆 连续天数排行').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ top: 4, bottom: 10 })
this.streakBar({ nameVal: '刷牙', iconVal: '🪥', streakNum: 95, allDays: 360, barColor: '#4CAF50' } as StreakRow)
// ... 共 7 条排行数据
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ bottom: 16 })
}
.width('100%').padding({ left: 12, right: 12, top: 8, bottom: 16 })
}
.layoutWeight(1).backgroundColor('#E1F5FE').scrollBar(BarState.Off)
}.width('100%').height('100%')
}
数据统计页是整个应用的"数据洞察中心"。它没有复用 headerRow,而是自己渲染了一个独立的标题栏——左侧"📊 数据统计"标题,右侧"📈"图标,因为没有"添加"操作所以没有加号按钮。这个细节体现了 Builder 复用的灵活性:通用的用通用组件,特殊的就自己画。
页面内容分为四个区块,全部包裹在 Scroll 中以支持纵向滚动:
区块一:指标卡矩阵。 2x2 排列四张指标卡——习惯总数、今日完成、最高连续、完美次数。与晨间时间线页的指标卡类似但内容侧重不同:时间线页侧重"今天"(评分、完美早晨、总时长),统计页侧重"全局"(总数、完成数、最高连续、完美次数)。
区块二:本周完成情况柱状图。 这是应用中唯一使用 ForEach 的地方。ForEach 接收 dayLabels 数组作为数据源,对每个元素调用 weekBar 渲染一根柱子。柱子的数值和颜色通过内联数组 [18, 22, 24, 20, 21, 8, 5][idx] 和 ['#0277BD', '#0288D1', ...][idx] 按索引取值——周一到周五数值较高(18-24),周末骤降(8和5),反映了"工作日规律、周末松懈"的真实打卡规律。颜色也相应变化:工作日用深蓝、浅蓝交替,周末用更浅的蓝色,视觉上弱化周末的存在感。ForEach 的第三个参数是键值生成函数 (d: string) => d,用星期标签本身作为 key,保证了列表的高效 diff 和更新。柱状图容器 height(120).alignItems(VerticalAlign.Bottom) 让所有柱子底部对齐,高度向上生长,符合柱状图的视觉惯例。
区块三:分类分布。 调用四次 catBreakdownBar,分别渲染身体(43%)、心智(21%)、计划(11%)、学习(25%)四个分类的进度条。从数据可以看出,身体类习惯占比最高(近一半),这与晨间"唤醒身体"的核心诉求一致。
区块四:连续天数排行榜。 调用七次 streakBar,展示连续打卡天数 Top 7 的习惯。排行榜以"刷牙360天"居首,形成了"越基础越持久"的洞察。
每个区块都用白色圆角卡片包裹,配以统一的阴影样式 shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }),形成了"浅蓝背景上漂浮白色卡片"的视觉语言,这种"卡片化"设计让信息分组清晰、层次分明。
十九、个人中心页 Builder:profileTab
@Builder profileTab() {
Column() {
Row() {
Text('👤 我的').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Row().layoutWeight(1)
Text('⚙').fontSize(22).fontColor('#FFFFFF')
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 })
.backgroundColor('#0277BD').alignItems(VerticalAlign.Center)
Scroll() {
Column() {
Column() {
Row() {
Column() {
Text('🌅').fontSize(40)
}
.width(72).height(72).backgroundColor('#0288D1').borderRadius(36)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Text('晨间达人').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#01579B')
Text('Lv.7 · 习惯养成者').fontSize(12).fontColor('#78909C').margin({ top: 3 })
Row() {
Text('✧ 今日评分: ' + computeMorningGrade().toString()).fontSize(11).fontColor('#0277BD').fontWeight(FontWeight.Bold)
}.margin({ top: 5 })
}.margin({ left: 14 }).alignItems(HorizontalAlign.Start).layoutWeight(1)
}.width('100%')
}
.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ top: 8, bottom: 8 })
Row() {
this.metricBox('⭐ 完美早晨', perfectMornings().toString(), '天', '#0277BD')
this.metricBox('📅 累计天数', '360', '天', '#0288D1')
}.width('100%').margin({ bottom: 6 })
Row() {
this.metricBox('⏱️ 总时长', '892', '小时', '#4CAF50')
this.metricBox('🔥 当前连续', '95', '天', '#FF8F00')
}.width('100%').margin({ bottom: 8 })
Column() {
Text('🏅 成就徽章 · 已解锁 8 / 15').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ top: 4, bottom: 10 })
Row() {
Column() { Text('🌅').fontSize(30); Text('早起达人').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🧘').fontSize(30); Text('冥想30天').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('📖').fontSize(30); Text('阅读50本').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🔥').fontSize(30); Text('100天连续').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}.width('100%').margin({ bottom: 10 })
Row() {
Column() { Text('🏃').fontSize(30); Text('跑步达人').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('💧').fontSize(30); Text('水分充足').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🧠').fontSize(30); Text('心智导师').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🔒').fontSize(30); Text('待解锁').fontSize(10).fontColor('#B0BEC5').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}.width('100%')
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ bottom: 8 })
Column() {
Text('⚙️ 设置').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ top: 4, bottom: 4 })
Row() { Text('🔔').fontSize(18); Text('晨间提醒').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('5:30 AM').fontSize(12).fontColor('#90A4AE') }
.width('100%').padding({ top: 12, bottom: 12 }).alignItems(VerticalAlign.Center)
Row().width('100%').height(1).backgroundColor('#E1F5FE')
Row() { Text('📊').fontSize(18); Text('统计周期').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('每周').fontSize(12).fontColor('#90A4AE') }
.width('100%').padding({ top: 12, bottom: 12 }).alignItems(VerticalAlign.Center)
Row().width('100%').height(1).backgroundColor('#E1F5FE')
Row() { Text('📤').fontSize(18); Text('数据导出').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('›').fontColor('#B0BEC5') }
.width('100%').padding({ top: 12, bottom: 12 }).alignItems(VerticalAlign.Center)
}
.width('100%').padding({ left: 14, right: 14, bottom: 4 }).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ bottom: 16 })
}
.width('100%').padding({ left: 12, right: 12, top: 4, bottom: 16 })
}
.layoutWeight(1).backgroundColor('#E1F5FE').scrollBar(BarState.Off)
}.width('100%').height('100%')
}
个人中心页是应用的"激励与设置中心",承担着用户身份展示、成就激励和偏好配置三大职责。它的标题栏同样独立渲染,右侧是一个齿轮设置图标。
页面内容分为四个区块:
区块一:用户身份卡片。 左侧是一个 72x72 的圆形头像区(浅蓝底色,内嵌 40px 的"🌅"Emoji),右侧是用户昵称"晨间达人"、等级标签"Lv.7 · 习惯养成者"和今日评分。这里引入了"等级体系"概念——Lv.7 暗示用户已经坚持了一段时间,"习惯养成者"的称号赋予了身份认同感。这种"游戏化"设计是习惯类应用提升留存率的常用手段。
区块二:累计指标卡。 2x2 排列四张指标卡——完美早晨、累计天数(360天)、总时长(892小时)、当前连续(95天)。这些数据从"年度"视角呈现用户的坚持成果,与统计页的"即时"视角形成互补。
区块三:成就徽章。 标题"已解锁 8 / 15"明确告知用户进度——15 枚徽章中已获得 8 枚,还有 7 枚待解锁。徽章以 4 列网格排列,每枚徽章由 30px Emoji 图标和 10px 文字标签组成。已解锁的徽章用深灰色文字,未解锁的用浅灰色文字配"🔒"图标。最后一格的"待解锁"状态是一种精心的设计——它让用户知道"还有更多目标可以追求",制造了"未完成感"(Zeigarnik effect),激励用户继续使用。
区块四:设置列表。 三行设置项——晨间提醒(5:30 AM)、统计周期(每周)、数据导出。每行采用"图标+标题+右值"的标准列表布局,行间用 1px 浅蓝分隔线隔开。右侧的值有的是具体内容(“5:30 AM”“每周”),有的是箭头符号"›"(暗示可进入下一级页面)。这是 iOS 风格设置列表的经典范式,用户认知成本极低。
二十、添加习惯弹窗 Builder:addHabitModal
@Builder addHabitModal() {
Column() {
this.modalBg(() => { this.addOpen = false })
Column() {
Row() {
Text('➕ 添加习惯').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#01579B')
Row().layoutWeight(1)
Text('✕').fontSize(22).fontColor('#90A4AE')
.onClick(() => { this.addOpen = false })
}
.width('100%').padding({ left: 18, right: 18, top: 18, bottom: 8 })
Scroll() {
Column() {
Text('习惯名称').fontSize(12).fontColor('#78909C').margin({ top: 8, left: 4 })
TextInput({ text: this.formName, placeholder: '输入习惯名称' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formName = v })
Text('图标').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
Flex({ wrap: FlexWrap.Wrap }) {
Column() { Text('🧘').fontSize(22) }
.width(48).height(48).backgroundColor(this.formIcon === '🧘' ? '#E1F5FE' : '#F5F5F5')
.borderRadius(12).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.formIcon = '🧘' })
// ... 共 8 个可选图标
}.width('100%').margin({ top: 4 })
Text('分类').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
Row() {
Text('身体').fontSize(12).fontColor(this.formCat === 0 ? '#FFFFFF' : '#4CAF50')
.backgroundColor(this.formCat === 0 ? '#4CAF50' : '#E8F5F9')
.borderRadius(16).padding({ left: 14, right: 14, top: 8, bottom: 8 })
.onClick(() => { this.formCat = 0 })
// ... 共 4 个分类标签
}.margin({ top: 6 })
Text('目标时长(分钟)').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
TextInput({ text: this.formMins, placeholder: '如 10' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formMins = v })
Text('最佳时段').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
TextInput({ text: this.formSlot, placeholder: '如 6:00' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formSlot = v })
Text('提醒').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
Row() {
Text('开启').fontSize(12).fontColor(this.formRemind === '启' ? '#FFFFFF' : '#78909C')
.backgroundColor(this.formRemind === '启' ? '#0277BD' : '#F5F5F5')
.borderRadius(14).padding({ left: 16, right: 16, top: 8, bottom: 8 })
.onClick(() => { this.formRemind = '启' })
Text('关闭').fontSize(12).fontColor(this.formRemind === '关' ? '#FFFFFF' : '#78909C')
.backgroundColor(this.formRemind === '关' ? '#90A4AE' : '#F5F5F5')
.borderRadius(14).padding({ left: 16, right: 16, top: 8, bottom: 8 }).margin({ left: 8 })
.onClick(() => { this.formRemind = '关' })
}.margin({ top: 6 })
}
.width('100%').padding({ left: 18, right: 18, bottom: 16 })
}.layoutWeight(1)
Row() {
Text('取消').fontSize(14).fontColor('#78909C')
.backgroundColor('#F0F4F8').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.onClick(() => { this.addOpen = false })
Text('添加习惯').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#0277BD').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 }).margin({ left: 12 })
.onClick(() => { this.addOpen = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 18, right: 18, top: 12, bottom: 16 })
}
.width('90%').constraintSize({ maxHeight: '85%' })
.backgroundColor('#FFFFFF').borderRadius(18)
.alignItems(HorizontalAlign.Center)
.position({ x: '5%', y: '7%' })
.shadow({ radius: 24, color: '#33000000', offsetY: 6 })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
}
添加习惯弹窗是应用中交互最复杂的模态层。它没有使用系统原生的 Dialog 或 bindSheet,而是通过 @Builder + 状态变量 + zIndex 自定义实现,获得了完全的视觉控制权。
弹窗结构分为三层:最底层是 modalBg 半透明遮罩(点击关闭);中间是白色圆角弹窗主体(宽度90%,最大高度85%,通过 constraintSize 约束防止内容过多时溢出屏幕);最外层是一个全屏 Column,通过 position({ x: 0, y: 0 }) 和 zIndex(998) 脱离文档流覆盖在页面之上。
弹窗主体内部纵向分为三段:标题栏(标题+关闭按钮)、可滚动表单区(Scroll 包裹)、底部操作按钮(取消+添加)。表单区包含五个字段:
习惯名称 使用 TextInput 组件,通过 text 参数绑定 formName 状态,onChange 回调实时回写状态。这是 ArkUI 中受控输入组件的标准用法——状态是"单一数据源",输入框是状态的"视图投影"。
图标选择 使用 Flex({ wrap: FlexWrap.Wrap }) 实现自动换行的图标网格。8 个可选图标各自是一个 48x48 的圆角方块,背景色根据是否被选中(this.formIcon === '🧘')在浅蓝高亮和灰色默认之间切换。FlexWrap.Wrap 确保图标数量超过一行时自动折行。
分类选择 是四个互斥的胶囊标签,与"所有习惯页"的筛选标签设计语言一致,但颜色映射到各分类的语义色。
目标时长 和 最佳时段 都是 TextInput,分别接受数字和时段文本。
提醒开关 是两个互斥标签(开启/关闭),用字符串 '启'/'关' 而非布尔值存储状态——这种设计略显非典型,可能是为了与后端字段格式对齐。
底部按钮区使用 justifyContent(FlexAlign.Center) 居中排列,"取消"为浅灰背景,"添加习惯"为蓝色背景,视觉主次分明。当前两个按钮的点击事件都只是关闭弹窗(this.addOpen = false),尚未实现真正的数据写入逻辑——在完整实现中,"添加习惯"按钮应该创建一个新的 HabitEntry 实例并 push 到 habitEntries 数组中。
二十一、编辑习惯弹窗 Builder:editHabitModal
@Builder editHabitModal() {
Column() {
this.modalBg(() => { this.editOpen = false })
Column() {
Row() {
Text('✏️ 编辑习惯').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#01579B')
Row().layoutWeight(1)
Text('✕').fontSize(22).fontColor('#90A4AE')
.onClick(() => { this.editOpen = false })
}
.width('100%').padding({ left: 18, right: 18, top: 18, bottom: 8 })
Scroll() {
Column() {
Text('习惯名称').fontSize(12).fontColor('#78909C').margin({ top: 8, left: 4 })
TextInput({ text: this.formName, placeholder: '输入习惯名称' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formName = v })
Text('分类').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
Row() {
// 四个分类标签,与添加弹窗一致
}.margin({ top: 6 })
Text('目标时长(分钟)').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
TextInput({ text: this.formMins, placeholder: '如 10' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formMins = v })
Text('最佳时段').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
TextInput({ text: this.formSlot, placeholder: '如 6:00' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formSlot = v })
}
.width('100%').padding({ left: 18, right: 18, bottom: 16 })
}.layoutWeight(1)
Row() {
Text('保存').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#0277BD').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.onClick(() => { this.editOpen = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 18, right: 18, top: 12, bottom: 16 })
}
.width('90%').constraintSize({ maxHeight: '80%' })
.backgroundColor('#FFFFFF').borderRadius(18)
.alignItems(HorizontalAlign.Center)
.position({ x: '5%', y: '10%' })
.shadow({ radius: 24, color: '#33000000', offsetY: 6 })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
}
编辑弹窗与添加弹窗的结构高度相似,但有几处关键差异值得分析。
首先是字段精简:编辑弹窗移除了"图标选择"和"提醒开关"两个字段,只保留了名称、分类、目标时长和最佳时段。这可能是基于"编辑场景下用户通常只微调核心属性"的产品判断——图标和提醒设置在创建时一次性确定,后续很少改动。当然,从功能完整性角度,编辑弹窗保留所有字段会更一致。
其次是位置偏移:编辑弹窗的 position({ x: '5%', y: '10%' }) 比添加弹窗的 y: '7%' 略低,且 maxHeight 从 85% 降为 80%。这是因为编辑弹窗内容更少(少了两个字段),适当下移可以让弹窗在视觉上更居中。
最后是按钮设计:编辑弹窗底部只有一个"保存"按钮(居中蓝色),而添加弹窗有"取消"+"添加习惯"两个按钮。这种差异的逻辑是——添加操作需要用户明确确认(双按钮让用户有机会反悔),而编辑操作可以一键保存(用户已经在表单中修改了内容,保存是自然动作)。当然,取消可以通过点击遮罩或关闭按钮实现,功能上并不缺失。
编辑弹窗打开时,habitRow 中的编辑按钮已经将目标习惯的现有值填入了表单状态(参见前文 habitRow 的分析),因此弹窗一打开就显示当前习惯的信息,用户在此基础上修改即可。这种"先填充、再编辑"的模式是表单编辑的标准范式。
二十二、删除确认弹窗 Builder:deleteConfirmModal
@Builder deleteConfirmModal() {
Column() {
this.modalBg(() => { this.delOpen = false })
Column() {
Text('⚠️').fontSize(52).margin({ top: 24 })
Text('确认删除习惯?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('删除后数据不可恢复').fontSize(13).fontColor('#EF5350').margin({ top: 6 })
if (this.pickIdx >= 0) {
Row() {
Text(habitEntries[this.pickIdx].iconVal).fontSize(28)
Column() {
Text(habitEntries[this.pickIdx].nameVal).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
Text('🔥' + habitEntries[this.pickIdx].streakNum + '天连续 · ' + habitEntries[this.pickIdx].allDays + '天总计').fontSize(11).fontColor('#90A4AE').margin({ top: 2 })
}.margin({ left: 10 })
}
.backgroundColor('#FFF5F5').borderRadius(12)
.padding({ left: 16, right: 16, top: 12, bottom: 12 }).margin({ top: 16 })
}
Row() {
Text('取消').fontSize(14).fontColor('#78909C')
.backgroundColor('#F0F4F8').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.onClick(() => { this.delOpen = false })
Text('确认删除').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#EF5350').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 }).margin({ left: 12 })
.onClick(() => { this.delOpen = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 22, bottom: 22 })
}
.width('82%').backgroundColor('#FFFFFF').borderRadius(18)
.alignItems(HorizontalAlign.Center)
.position({ x: '9%', y: '35%' })
.shadow({ radius: 24, color: '#33000000', offsetY: 6 })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
删除确认弹窗是一个"警告型"对话框,其视觉语言与前两个弹窗明显不同——更小(宽度82%)、更居中(y: '35%')、zIndex 更高(999,确保盖在其它弹窗之上)。
弹窗内容自上而下依次是:大号警告图标"⚠️"(52px)、主标题"确认删除习惯?“(18px 深色粗体)、副标题"删除后数据不可恢复”(13px 红色警示)。这三层构成了标准的"警示-确认"信息层级,红色副标题明确告知后果的不可逆性,是破坏性操作的标准设计范式。
警示文字下方是目标习惯的预览卡片——浅红色背景(#FFF5F5)的圆角块内,左侧显示图标,右侧显示名称和"🔥连续天数 · 累计总天数"信息。这张预览卡片的目的是让用户在点击删除前,再次确认"我确实要删除这条习惯",避免误删。特别是它展示了连续天数信息——如果一条习惯已经连续坚持了 90 天,用户看到这个数字后可能会三思而后行,这种"情感阻力"的设计能有效降低误删率。
预览卡片使用 if (this.pickIdx >= 0) 条件渲染,防止索引为 -1 时(未选中任何习惯)访问数组越界。这是防御性编程的体现。
底部按钮区与添加弹窗一致(取消+确认双按钮),但"确认删除"按钮使用红色 #EF5350 背景而非蓝色,通过颜色暗示操作的破坏性。当前两个按钮的点击事件都只是关闭弹窗,在完整实现中,"确认删除"应该从 habitEntries 数组中移除对应索引的元素。
二十三、底部导航栏 Builder:bottomTabs
@Builder bottomTabs() {
Row() {
Column() {
Text('☀️').fontSize(22).opacity(this.curTab === 0 ? 1.0 : 0.45)
Text('晨间').fontSize(10)
.fontColor(this.curTab === 0 ? '#0277BD' : '#90A4AE')
.fontWeight(this.curTab === 0 ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.onClick(() => { this.curTab = 0 })
Column() {
Text('📋').fontSize(22).opacity(this.curTab === 1 ? 1.0 : 0.45)
Text('所有习惯').fontSize(10)
.fontColor(this.curTab === 1 ? '#0277BD' : '#90A4AE')
.fontWeight(this.curTab === 1 ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.onClick(() => { this.curTab = 1 })
Column() {
Text('📊').fontSize(22).opacity(this.curTab === 2 ? 1.0 : 0.45)
Text('统计').fontSize(10)
.fontColor(this.curTab === 2 ? '#0277BD' : '#90A4AE')
.fontWeight(this.curTab === 2 ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.onClick(() => { this.curTab = 2 })
Column() {
Text('👤').fontSize(22).opacity(this.curTab === 3 ? 1.0 : 0.45)
Text('我的').fontSize(10)
.fontColor(this.curTab === 3 ? '#0277BD' : '#90A4AE')
.fontWeight(this.curTab === 3 ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.onClick(() => { this.curTab = 3 })
}
.width('100%').padding({ top: 4, bottom: 4 })
.backgroundColor('#FFFFFF')
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
底部导航栏是应用的"脊柱",决定了用户在四个功能域之间的切换路径。它没有使用 ArkUI 内置的 Tabs 组件,而是用 Row + 四个 Column 手动搭建,获得了更精细的视觉控制。
四个 Tab 等宽分布(每个 layoutWeight(1)),各自包含图标(22px Emoji)和文字标签(10px)。激活态的视觉区分通过三个维度实现:图标不透明度(激活1.0 / 非激活0.45)、文字颜色(激活蓝色 / 非激活灰色)、文字粗细(激活粗体 / 非激活常规)。这种"多维度联合区分"比单一维度(仅颜色或仅粗细)更易识别,兼顾了视觉美感和无障碍可读性。
点击 Tab 时只需修改 this.curTab 状态,导航栏的激活态和页面内容的切换都会自动响应。shadow({ offsetY: -2 }) 让导航栏顶部有一道向上的微阴影,与内容区产生"浮起"的层次分离感,这是底部固定栏的标准视觉处理。
值得一提的是,这里没有使用 ForEach 遍历 Tab 配置数组,而是手动写了四个 Column。虽然代码量更大,但每个 Tab 的图标和文字都不同,手写反而更直观。如果 Tab 数量超过 5-6 个,用 ForEach + 配置数组会更合适。
二十四、build 方法:应用根布局与页面调度
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
Column() {
if (this.curTab === 0) { this.morningTimelineTab() }
if (this.curTab === 1) { this.allHabitsTab() }
if (this.curTab === 2) { this.statsTab() }
if (this.curTab === 3) { this.profileTab() }
}
.layoutWeight(1).width('100%')
this.bottomTabs()
}
.width('100%').height('100%')
if (this.addOpen) { this.addHabitModal() }
if (this.editOpen) { this.editHabitModal() }
if (this.delOpen) { this.deleteConfirmModal() }
}
.width('100%').height('100%').backgroundColor('#E1F5FE')
}
build() 是每个 @Component 必须实现的方法,它返回组件的 UI 树。作为 @Entry 入口组件,它的 build() 返回的就是整个页面的根节点。
根节点是一个 Stack(层叠布局),alignContent: Alignment.Center 设置子元素居中对齐。Stack 内包含两类子元素:
底层是主内容区——一个 Column 纵向排列"页面内容区"和"底部导航栏"。页面内容区又是一个 Column,内部通过四个 if 条件分支,根据 curTab 的值决定渲染哪个 Tab 页的 Builder。这种"if 条件渲染"实现页面切换的方式简单直接——每次只有一个 Tab 页被挂载,切换时旧页面销毁、新页面创建。与 Tabs 组件的"全部预创建+滑动切换"模式相比,这种方式内存占用更低(同时只有一个页面在内存中),但代价是切换时没有滑动动画,且页面状态不保留(每次进入都会重新渲染)。对于习惯追踪这类"每次进入看最新数据"的应用,这种取舍是合理的。
内容区使用 layoutWeight(1) 占据除导航栏外的全部剩余高度,导航栏固定在底部。
上层是弹窗层——三个 if 条件分别控制添加、编辑、删除三个弹窗的显隐。由于弹窗内部都通过 zIndex 和 position 脱离了文档流,它们会覆盖在主内容区之上。三个弹窗的 zIndex 分别是 998、998、999,删除弹窗最高,确保它盖在其它弹窗之上(虽然实际使用中不会同时打开多个弹窗,但这种层级设定是防御性的)。
整个 Stack 背景色设为 #E1F5FE(浅蓝),作为所有页面的统一底色。当 Tab 页内容不足以铺满屏幕时,这个底色会透出来,保证视觉一致性。
二十五、各模块关键技术点横向对比总结
下面通过一张表格,横向对比本应用四个 Tab 页(及三大弹窗)在核心功能、状态管理、关键组件和设计模式等维度的异同,帮助读者快速建立全局认知。
| 对比维度 | 晨间时间线页 (Tab 0) | 所有习惯页 (Tab 1) | 数据统计页 (Tab 2) | 个人中心页 (Tab 3) | 添加/编辑弹窗 | 删除确认弹窗 |
|---|---|---|---|---|---|---|
| 核心功能 | 按时段(5:30-8:30)编排晨间习惯时间线,展示当日完成进度与综合评分 | 全量习惯列表索引,支持全部/已完成/未完成三态筛选 | 多维数据可视化:本周柱状图、分类分布进度条、连续天数排行榜 | 用户身份展示、累计指标、成就徽章激励、偏好设置 | 习惯表单录入(名称/图标/分类/时长/时段/提醒) | 破坏性操作二次确认,含目标习惯预览与连续天数警示 |
| 数据来源 | habitEntries 按bestSlot重排 + 聚合函数 | habitEntries 全量遍历 | 硬编码统计数组 + ForEach 遍历 | 硬编码等级/徽章/设置项 | 共享表单状态变量 form* | habitEntries[pickIdx] 单条引用 |
| 状态管理要点 | 读取 curTab 驱动渲染 | doneFilter 驱动筛选标签态(视觉层已通、逻辑层待接) | 无独立状态,纯展示 | 无独立状态,纯展示 | addOpen/editOpen 控制显隐; form* 六变量双向绑定 | delOpen 控制显隐; pickIdx 定位目标 |
| 关键 Builder 组件 | headerRow, metricBox, timeSlotLine, habitRow | headerRow, habitRow | metricBox, weekBar, catBreakdownBar, streakBar | metricBox(自定义标题栏) | modalBg, TextInput, Flex图标网格 | modalBg, 条件渲染预览卡 |
| 滚动方案 | Scroll + scrollBar(Off) | Scroll + scrollBar(Off) | Scroll + scrollBar(Off) | Scroll + scrollBar(Off) | Scroll(表单区) + constraintSize限高 | 无滚动(内容固定) |
| 列表渲染方式 | 手动逐条调用 habitRow(按时段分组) | 手动逐条调用 habitRow(全量) | ForEach(dayLabels) 渲染柱状图 | 手动排列徽章/设置项 | Flex(Wrap) 渲染图标网格 | if 条件渲染单条预览 |
| 设计模式 | 时间轴模式(分隔线+卡片交替) | 筛选标签+列表模式 | 卡片化数据看板模式 | 个人资料+成就游戏化模式 | 模态表单(遮罩+居中卡片) | 警告对话框(警示+预览+确认) |
| 复用组件 | headerRow, metricBox, habitRow, timeSlotLine | headerRow, habitRow | metricBox, weekBar, catBreakdownBar, streakBar | metricBox | modalBg, TextInput统一样式 | modalBg |
| 视觉特色 | 蓝色时间轴竖线+时段图例 | 三色筛选胶囊标签 | 自绘柱状图+进度条+排行条 | 圆形头像+4列徽章网格+列表分隔线 | 90%宽卡片+85%限高+24px大阴影 | 82%宽+红色警示色+35%居中 |
| 交互反馈 | 点击"+"打开添加弹窗 | 点击筛选切换态(视觉) | 纯展示无交互 | 纯展示无交互 | TextInput双向绑定+图标/分类点击切换 | 双按钮(取消+红色确认删除) |
| zIndex 层级 | 0(基础层) | 0(基础层) | 0(基础层) | 0(基础层) | 998 | 999(最高) |
| 待完善点 | - | 筛选逻辑未接通(始终显示全部) | 统计数据为硬编码,未动态计算 | 设置项无跳转逻辑 | 保存按钮未写入数据 | 确认按钮未执行删除 |
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// 晨间习惯追踪 — 天空蓝 #0277BD | 浅蓝 #0288D1 | 背景 #E1F5FE
enum MorningTab { Timetable = 0, AllList = 1, Stats = 2, Profile = 3 }
enum CategoryKind { Body = 0, Mind = 1, Plan = 2, Learn = 3 }
interface BarChartData {
label: string
value: number
maxVal: number
barColor: string
}
interface CategoryStats {
cat: number
catName: string
count: number
percentage: number
barColor: string
}
interface StreakRow {
nameVal: string
iconVal: string
streakNum: number
allDays: number
barColor: string
}
@Observed
class HabitEntry {
idVal: number = 0
nameVal: string = ''
iconVal: string = ''
catVal: number = 0
minutesGoal: number = 0
bestSlot: string = ''
streakNum: number = 0
allDays: number = 0
done: boolean = false
sortOrder: number = 0
descVal: string = ''
weekDone: boolean[] = [false, false, false, false, false, false, false]
constructor(
idVal: number, nameVal: string, iconVal: string, catVal: number,
minutesGoal: number, bestSlot: string, streakNum: number,
allDays: number, done: boolean, sortOrder: number,
descVal: string, weekDone: boolean[]
) {
this.idVal = idVal
this.nameVal = nameVal
this.iconVal = iconVal
this.catVal = catVal
this.minutesGoal = minutesGoal
this.bestSlot = bestSlot
this.streakNum = streakNum
this.allDays = allDays
this.done = done
this.sortOrder = sortOrder
this.descVal = descVal
this.weekDone = weekDone
}
}
function catNameOf(cat: number): string {
if (cat === 0) { return '身体' }
if (cat === 1) { return '心智' }
if (cat === 2) { return '计划' }
return '学习'
}
function catColorOf(cat: number): string {
if (cat === 0) { return '#4CAF50' }
if (cat === 1) { return '#9C27B0' }
if (cat === 2) { return '#FF9800' }
return '#0277BD'
}
function catIconOf(cat: number): string {
if (cat === 0) { return '🏃' }
if (cat === 1) { return '🧠' }
if (cat === 2) { return '📋' }
return '📚'
}
const dayLabels: string[] = ['一', '二', '三', '四', '五', '六', '日']
const habitEntries: HabitEntry[] = [
new HabitEntry(1, '晨间冥想', '🧘', CategoryKind.Mind, 10, '6:00', 45, 180, true, 1, '每天清晨冥想让心灵平静清晰', [true, true, true, true, true, false, false]),
new HabitEntry(2, '清晨拉伸', '🤸', CategoryKind.Body, 15, '5:30', 30, 120, true, 2, '唤醒身体告别僵硬', [true, true, true, false, true, false, false]),
new HabitEntry(3, '阅读新闻', '📰', CategoryKind.Learn, 20, '6:30', 60, 200, true, 3, '了解今天的世界动态', [true, true, true, true, true, true, false]),
new HabitEntry(4, '冷水澡', '🚿', CategoryKind.Body, 5, '6:45', 15, 90, false, 4, '提神醒脑增强免疫力', [false, true, false, true, false, false, false]),
new HabitEntry(5, '晨间日记', '📓', CategoryKind.Mind, 15, '7:00', 35, 150, true, 5, '记录今日思绪与感悟', [true, true, true, true, true, false, false]),
new HabitEntry(6, '喝温水', '🥤', CategoryKind.Body, 5, '5:45', 80, 300, true, 6, '补充一夜流失的水分', [true, true, true, true, true, true, true]),
new HabitEntry(7, '瑜伽练习', '🧘♀️', CategoryKind.Body, 30, '6:00', 20, 100, false, 7, '舒展全身肌肉与关节', [false, true, false, true, false, false, false]),
new HabitEntry(8, '英语听力', '🎧', CategoryKind.Learn, 20, '7:00', 50, 220, true, 8, 'BBC新闻与Podcast磨耳朵', [true, true, true, true, true, false, false]),
new HabitEntry(9, '制定今日计划', '📋', CategoryKind.Plan, 10, '7:30', 40, 190, true, 9, '列出今日最重要的3件事', [true, true, true, true, true, false, false]),
new HabitEntry(10, '晨跑', '🏃', CategoryKind.Body, 30, '5:30', 25, 130, false, 10, '沿河边慢跑3公里', [false, true, false, true, false, false, false]),
new HabitEntry(11, '感恩日记', '💛', CategoryKind.Mind, 5, '7:45', 55, 165, true, 11, '写下今天值得感恩的事', [true, true, true, true, true, false, false]),
new HabitEntry(12, '阅读书籍', '📖', CategoryKind.Learn, 25, '6:15', 65, 250, true, 12, '每日阅读3章进度', [true, true, true, true, true, true, false]),
new HabitEntry(13, '深呼吸练习', '🌬️', CategoryKind.Mind, 5, '6:30', 10, 60, false, 13, '4-7-8呼吸法放松神经', [false, false, true, false, false, false, false]),
new HabitEntry(14, '早餐准备', '🍳', CategoryKind.Body, 20, '7:30', 70, 280, true, 14, '准备营养均衡的早餐', [true, true, true, true, true, true, true]),
new HabitEntry(15, '编程练习', '💻', CategoryKind.Learn, 45, '5:30', 42, 310, true, 15, '算法题每日一练', [true, true, true, true, true, false, false]),
new HabitEntry(16, '收听播客', '🎙️', CategoryKind.Learn, 30, '8:00', 38, 175, false, 16, '通勤路上收听行业播客', [false, true, false, true, false, false, false]),
new HabitEntry(17, '视觉化练习', '🔮', CategoryKind.Mind, 5, '6:50', 12, 45, false, 17, '想象今天的成功场景', [false, false, true, false, false, false, false]),
new HabitEntry(18, '正念行走', '🚶', CategoryKind.Body, 15, '7:15', 18, 85, false, 18, '在小花园正念行走10分钟', [false, true, false, true, false, false, false]),
new HabitEntry(19, '回顾昨日', '🔍', CategoryKind.Mind, 5, '7:40', 33, 140, true, 19, '回顾昨天的得失与进步', [true, true, true, true, false, false, false]),
new HabitEntry(20, '整理床铺', '🛏️', CategoryKind.Body, 3, '6:00', 90, 350, true, 20, '铺好床铺开启整洁一天', [true, true, true, true, true, true, true]),
new HabitEntry(21, '刷牙', '🪥', CategoryKind.Body, 5, '6:50', 95, 360, true, 21, '使用电动牙刷仔细清洁', [true, true, true, true, true, true, true]),
new HabitEntry(22, '日语学习', '🇯🇵', CategoryKind.Learn, 15, '6:20', 48, 205, true, 22, '每天学会5个新句型', [true, true, true, true, true, false, false]),
new HabitEntry(23, '思维导图', '🗺️', CategoryKind.Plan, 10, '8:00', 28, 110, false, 23, '用思维导图梳理今日任务', [false, true, true, false, false, false, false]),
new HabitEntry(24, '自我肯定', '✨', CategoryKind.Mind, 3, '6:40', 22, 80, true, 24, '对着镜子说出3句肯定语', [true, true, true, true, false, false, false]),
new HabitEntry(25, '喝黑咖啡', '☕', CategoryKind.Body, 10, '7:00', 75, 300, true, 25, '手冲一杯淺烘焙单品豆', [true, true, true, true, true, true, false]),
new HabitEntry(26, '肩颈放松', '💆', CategoryKind.Body, 8, '8:15', 20, 75, false, 26, '缓解久坐引起的肩颈酸痛', [false, true, false, false, false, false, false]),
new HabitEntry(27, '阅读行业报告', '📊', CategoryKind.Learn, 15, '8:30', 35, 120, false, 27, '阅读一篇行业分析文章', [false, false, true, false, false, false, false]),
new HabitEntry(28, '拍打经络', '👐', CategoryKind.Body, 5, '8:45', 15, 55, false, 28, '按摩拍打手臂和腿部经络', [false, false, false, true, false, false, false])
]
function allCompletedCount(): number {
return habitEntries.filter((e: HabitEntry): boolean => { return e.done }).length
}
function perfectMornings(): number {
return 28
}
function computeMorningGrade(): number {
return 82
}
function computeTotalMinutes(): number {
return habitEntries[0].minutesGoal + habitEntries[1].minutesGoal + habitEntries[2].minutesGoal + habitEntries[3].minutesGoal + habitEntries[4].minutesGoal + habitEntries[5].minutesGoal + habitEntries[6].minutesGoal + habitEntries[7].minutesGoal + habitEntries[8].minutesGoal + habitEntries[9].minutesGoal + habitEntries[10].minutesGoal + habitEntries[11].minutesGoal + habitEntries[12].minutesGoal + habitEntries[13].minutesGoal + habitEntries[14].minutesGoal + habitEntries[15].minutesGoal + habitEntries[16].minutesGoal + habitEntries[17].minutesGoal + habitEntries[18].minutesGoal + habitEntries[19].minutesGoal + habitEntries[20].minutesGoal + habitEntries[21].minutesGoal + habitEntries[22].minutesGoal + habitEntries[23].minutesGoal + habitEntries[24].minutesGoal + habitEntries[25].minutesGoal + habitEntries[26].minutesGoal + habitEntries[27].minutesGoal
}
@Entry
@Component
struct MorningRoutineApp {
@State curTab: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State pickIdx: number = -1
@State formName: string = ''
@State formIcon: string = '🧘'
@State formCat: number = 0
@State formMins: string = '10'
@State formSlot: string = '6:00'
@State formRemind: string = '启'
@State doneFilter: number = 0
@Builder modalBg(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.45)').onClick(onClose)
}
@Builder headerRow(title: string, sub: string) {
Row() {
Column() {
Text(title).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text(sub).fontSize(12).fontColor('#B3E5FC').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('+').fontSize(26).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.width(42).height(42).textAlign(TextAlign.Center)
.backgroundColor('#0288D1').borderRadius(21)
.onClick(() => {
this.formName = ''
this.formIcon = '🧘'
this.formCat = 0
this.formMins = '10'
this.formSlot = '6:00'
this.formRemind = '启'
this.addOpen = true
})
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 })
.backgroundColor('#0277BD').alignItems(VerticalAlign.Center)
}
@Builder habitRow(h: HabitEntry) {
Row() {
Column() {
Text(h.iconVal).fontSize(26)
}
.width(50).height(50).backgroundColor(h.done ? '#E8F5E9' : '#FFF3E0')
.borderRadius(14).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Row() {
Text(h.nameVal).fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#01579B')
.decoration(h.done ? { type: TextDecorationType.LineThrough } : { type: TextDecorationType.None })
Column() {
Text(catNameOf(h.catVal)).fontSize(9).fontColor('#FFFFFF')
.backgroundColor(catColorOf(h.catVal)).borderRadius(6)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 6 })
}
}
Text(h.descVal).fontSize(11).fontColor('#90A4AE').margin({ top: 3 }).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
Column() {
Row() {
Text('🔥').fontSize(13)
Text(h.streakNum.toString() + '天').fontSize(11).fontColor('#FF8F00').fontWeight(FontWeight.Bold).margin({ left: 2 })
}
Text(h.bestSlot).fontSize(12).fontColor('#0277BD').fontWeight(FontWeight.Medium).margin({ top: 4 })
}.alignItems(HorizontalAlign.End)
Column() {
Text('✏️').fontSize(16).fontColor('#0288D1')
.onClick(() => {
this.pickIdx = habitEntries.indexOf(h)
this.formName = h.nameVal
this.formIcon = h.iconVal
this.formCat = h.catVal
this.formMins = h.minutesGoal.toString()
this.formSlot = h.bestSlot
this.editOpen = true
})
Text('🗑️').fontSize(14).fontColor('#EF5350').margin({ top: 10 })
.onClick(() => { this.pickIdx = habitEntries.indexOf(h); this.delOpen = true })
}.alignItems(HorizontalAlign.End)
}
.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(14).margin({ top: 4, bottom: 4, left: 12, right: 12 })
.shadow({ radius: 4, color: '#1A0277BD', offsetY: 2 })
}
@Builder timeSlotLine(slot: string, rows: number) {
Row() {
Column() {
Text(slot).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#0277BD')
Column().width(3).height(32).backgroundColor('#81D4FA').borderRadius(2)
}.width(50).alignItems(HorizontalAlign.Center)
Column().width(16)
Column() {
if (rows === 0) {
Text('暂无安排').fontSize(11).fontColor('#B0BEC5')
}
if (rows >= 1) {
Row() {
Column().width(6).height(6).backgroundColor('#4CAF50').borderRadius(3)
Text('已完成习惯').fontSize(10).fontColor('#4CAF50').margin({ left: 4 })
}
}
if (rows >= 2) {
Row() {
Column().width(6).height(6).backgroundColor('#FF9800').borderRadius(3)
Text('部分完成').fontSize(10).fontColor('#FF9800').margin({ left: 4 })
}.margin({ top: 2 })
}
}.padding(10).backgroundColor('#F1F8FF').borderRadius(10)
}
.width('100%').padding({ left: 12, right: 12, top: 8, bottom: 4 })
}
@Builder morningTimelineTab() {
Column() {
this.headerRow('☀️ 晨间', allCompletedCount() + '/' + habitEntries.length + ' 已完成 · 评分 ' + computeMorningGrade())
Scroll() {
Column() {
Row() {
this.metricBox('🌅 晨间评分', computeMorningGrade().toString(), '分', '#0277BD')
this.metricBox('⭐ 完美早晨', perfectMornings().toString(), '天', '#0288D1')
}.width('100%').margin({ bottom: 6 })
Row() {
this.metricBox('🔥 总连续天数', '95', '天', '#FF8F00')
this.metricBox('⏱️ 今日总时长', computeTotalMinutes().toString(), '分钟', '#4CAF50')
}.width('100%').margin({ bottom: 10 })
Text('⏰ 时间线 5:30 - 9:00').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ left: 16, top: 8, bottom: 4 })
this.timeSlotLine('5:30', 2);
this.habitRow(habitEntries[9]);
this.habitRow(habitEntries[1]);
this.habitRow(habitEntries[14])
this.timeSlotLine('5:45', 1);
this.habitRow(habitEntries[5])
this.timeSlotLine('6:00', 2);
this.habitRow(habitEntries[0]);
this.habitRow(habitEntries[6]);
this.habitRow(habitEntries[19])
this.timeSlotLine('6:15', 1);
this.habitRow(habitEntries[11])
this.timeSlotLine('6:30', 2);
this.habitRow(habitEntries[2]);
this.habitRow(habitEntries[12])
this.timeSlotLine('6:45', 1);
this.habitRow(habitEntries[3])
this.timeSlotLine('6:50', 1);
this.habitRow(habitEntries[16]);
this.habitRow(habitEntries[20])
this.timeSlotLine('7:00', 3);
this.habitRow(habitEntries[4]);
this.habitRow(habitEntries[7]);
this.habitRow(habitEntries[24])
this.timeSlotLine('7:15', 1);
this.habitRow(habitEntries[17])
this.timeSlotLine('7:30', 2);
this.habitRow(habitEntries[8]);
this.habitRow(habitEntries[13])
this.timeSlotLine('7:45', 1);
this.habitRow(habitEntries[10])
this.timeSlotLine('8:00', 2);
this.habitRow(habitEntries[15]);
this.habitRow(habitEntries[22])
this.timeSlotLine('8:15', 1);
this.habitRow(habitEntries[25])
this.timeSlotLine('8:30', 1);
this.habitRow(habitEntries[26])
Column().height(20)
}.width('100%')
}
.layoutWeight(1).backgroundColor('#E1F5FE').scrollBar(BarState.Off)
}.width('100%').height('100%')
}
@Builder metricBox(icon: string, val: string, unit: string, clr: string) {
Column() {
Text(icon).fontSize(14)
Row() {
Text(val).fontSize(20).fontWeight(FontWeight.Bold).fontColor(clr)
Text(' ' + unit).fontSize(10).fontColor('#90A4AE')
}.margin({ top: 4 })
}.layoutWeight(1).padding({ top: 12, bottom: 12 }).backgroundColor('#FFFFFF').borderRadius(14)
.alignItems(HorizontalAlign.Center).margin({ left: 6, right: 6 })
.shadow({ radius: 3, color: '#1A0277BD', offsetY: 1 })
}
@Builder allHabitsTab() {
Column() {
this.headerRow('📋 所有习惯', '共' + habitEntries.length + '个习惯 · 按分类筛选')
Row() {
Text('全部').fontSize(12).fontColor(this.doneFilter === 0 ? '#FFFFFF' : '#0277BD')
.backgroundColor(this.doneFilter === 0 ? '#0277BD' : '#E1F5FE')
.borderRadius(14).padding({ left: 14, right: 14, top: 6, bottom: 6 })
.onClick(() => { this.doneFilter = 0 })
Text('已完成').fontSize(12).fontColor(this.doneFilter === 1 ? '#FFFFFF' : '#0277BD')
.backgroundColor(this.doneFilter === 1 ? '#4CAF50' : '#E8F5E9')
.borderRadius(14).padding({ left: 14, right: 14, top: 6, bottom: 6 }).margin({ left: 8 })
.onClick(() => { this.doneFilter = 1 })
Text('未完成').fontSize(12).fontColor(this.doneFilter === 2 ? '#FFFFFF' : '#0277BD')
.backgroundColor(this.doneFilter === 2 ? '#FF9800' : '#FFF3E0')
.borderRadius(14).padding({ left: 14, right: 14, top: 6, bottom: 6 }).margin({ left: 8 })
.onClick(() => { this.doneFilter = 2 })
Row().layoutWeight(1)
}.width('100%').padding({ left: 16, right: 16, top: 8, bottom: 4 })
Scroll() {
Column() {
this.habitRow(habitEntries[0])
this.habitRow(habitEntries[1])
this.habitRow(habitEntries[2])
this.habitRow(habitEntries[3])
this.habitRow(habitEntries[4])
this.habitRow(habitEntries[5])
this.habitRow(habitEntries[6])
this.habitRow(habitEntries[7])
this.habitRow(habitEntries[8])
this.habitRow(habitEntries[9])
this.habitRow(habitEntries[10])
this.habitRow(habitEntries[11])
this.habitRow(habitEntries[12])
this.habitRow(habitEntries[13])
this.habitRow(habitEntries[14])
this.habitRow(habitEntries[15])
this.habitRow(habitEntries[16])
this.habitRow(habitEntries[17])
this.habitRow(habitEntries[18])
this.habitRow(habitEntries[19])
this.habitRow(habitEntries[20])
this.habitRow(habitEntries[21])
this.habitRow(habitEntries[22])
this.habitRow(habitEntries[23])
this.habitRow(habitEntries[24])
this.habitRow(habitEntries[25])
this.habitRow(habitEntries[26])
this.habitRow(habitEntries[27])
Column().height(16)
}.width('100%')
}
.layoutWeight(1).backgroundColor('#E1F5FE').scrollBar(BarState.Off)
}.width('100%').height('100%')
}
@Builder weekBar(data: BarChartData) {
Column() {
Column()
.width(30)
.height((data.value / data.maxVal) * 100)
.backgroundColor(data.barColor)
.borderRadius({ topLeft: 6, topRight: 6, bottomLeft: 0, bottomRight: 0 })
Text(data.label).fontSize(11).fontColor('#78909C').margin({ top: 6 })
Text(data.value.toString()).fontSize(10).fontColor('#0277BD').fontWeight(FontWeight.Bold)
}.alignItems(HorizontalAlign.Center)
}
@Builder catBreakdownBar(stat: CategoryStats) {
Column() {
Row() {
Text(catIconOf(stat.cat) + ' ' + stat.catName).fontSize(13).fontColor('#37474F')
Row().layoutWeight(1)
Text(stat.count.toString() + '个 ' + stat.percentage.toString() + '%').fontSize(11).fontColor('#90A4AE')
}.width('100%')
Row() {
Column()
.width(stat.percentage + '%')
.height(12)
.backgroundColor(stat.barColor)
.borderRadius(6)
Row().layoutWeight(1)
}
.width('100%').height(12).backgroundColor('#E1F5FE').borderRadius(6).margin({ top: 6 })
}.width('100%').margin({ bottom: 12 })
}
@Builder streakBar(leader: StreakRow) {
Row() {
Text(leader.iconVal).fontSize(24)
Column() {
Row() {
Text(leader.nameVal).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#263238')
Text('🔥' + leader.streakNum.toString() + '天').fontSize(11).fontColor('#FF8F00').fontWeight(FontWeight.Bold).margin({ left: 6 })
}.width('100%').alignItems(VerticalAlign.Center)
Row() {
Column()
.width(((leader.allDays / 360) * 100).toString() + '%')
.height(8)
.backgroundColor(leader.barColor)
.borderRadius(4)
Row().layoutWeight(1)
}
.width('100%').height(8).backgroundColor('#E1F5FE').borderRadius(4).margin({ top: 4 })
}.layoutWeight(1).margin({ left: 10 })
Text(leader.allDays.toString() + '天').fontSize(12).fontColor('#78909C')
}.width('100%').padding({ top: 8, bottom: 8 })
}
@Builder statsTab() {
Column() {
Row() {
Text('📊 数据统计').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Row().layoutWeight(1)
Text('📈').fontSize(22)
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 })
.backgroundColor('#0277BD').alignItems(VerticalAlign.Center)
Scroll() {
Column() {
Row() {
this.metricBox('📊 习惯总数', habitEntries.length.toString(), '个', '#0277BD')
this.metricBox('✅ 今日完成', allCompletedCount().toString(), '/' + habitEntries.length, '#4CAF50')
}.width('100%').margin({ bottom: 6 })
Row() {
this.metricBox('🔥 最高连续', '95', '天', '#FF8F00')
this.metricBox('⭐ 完美次数', perfectMornings().toString(), '次', '#9C27B0')
}.width('100%').margin({ bottom: 8 })
Column() {
Text('📅 本周完成情况').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ top: 4, bottom: 10 })
Row() {
ForEach(dayLabels, (d: string, idx: number) => {
this.weekBar({
label: d,
value: [18, 22, 24, 20, 21, 8, 5][idx] ?? 0,
maxVal: 28,
barColor: ['#0277BD', '#0288D1', '#0277BD', '#0288D1', '#0277BD', '#81D4FA', '#E1F5FE'][idx] ?? '#E1F5FE'
} as BarChartData)
}, (d: string) => d)
}
.width('100%').height(120).alignItems(VerticalAlign.Bottom)
.padding({ top: 4 })
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ top: 6, bottom: 8 })
Column() {
Text('🏷️ 分类分布').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ top: 4, bottom: 10 })
this.catBreakdownBar({ cat: 0, catName: '身体', count: 12, percentage: 43, barColor: '#4CAF50' } as CategoryStats)
this.catBreakdownBar({ cat: 1, catName: '心智', count: 6, percentage: 21, barColor: '#9C27B0' } as CategoryStats)
this.catBreakdownBar({ cat: 2, catName: '计划', count: 3, percentage: 11, barColor: '#FF9800' } as CategoryStats)
this.catBreakdownBar({ cat: 3, catName: '学习', count: 7, percentage: 25, barColor: '#0277BD' } as CategoryStats)
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ bottom: 8 })
Column() {
Text('🏆 连续天数排行').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ top: 4, bottom: 10 })
this.streakBar({ nameVal: '刷牙', iconVal: '🪥', streakNum: 95, allDays: 360, barColor: '#4CAF50' } as StreakRow)
this.streakBar({ nameVal: '整理床铺', iconVal: '🛏️', streakNum: 90, allDays: 350, barColor: '#66BB6A' } as StreakRow)
this.streakBar({ nameVal: '喝温水', iconVal: '🥤', streakNum: 80, allDays: 300, barColor: '#81C784' } as StreakRow)
this.streakBar({ nameVal: '喝黑咖啡', iconVal: '☕', streakNum: 75, allDays: 300, barColor: '#A5D6A7' } as StreakRow)
this.streakBar({ nameVal: '早餐准备', iconVal: '🍳', streakNum: 70, allDays: 280, barColor: '#C8E6C9' } as StreakRow)
this.streakBar({ nameVal: '阅读书籍', iconVal: '📖', streakNum: 65, allDays: 250, barColor: '#0277BD' } as StreakRow)
this.streakBar({ nameVal: '阅读新闻', iconVal: '📰', streakNum: 60, allDays: 200, barColor: '#0288D1' } as StreakRow)
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ bottom: 16 })
}
.width('100%').padding({ left: 12, right: 12, top: 8, bottom: 16 })
}
.layoutWeight(1).backgroundColor('#E1F5FE').scrollBar(BarState.Off)
}.width('100%').height('100%')
}
@Builder profileTab() {
Column() {
Row() {
Text('👤 我的').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Row().layoutWeight(1)
Text('⚙').fontSize(22).fontColor('#FFFFFF')
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 })
.backgroundColor('#0277BD').alignItems(VerticalAlign.Center)
Scroll() {
Column() {
Column() {
Row() {
Column() {
Text('🌅').fontSize(40)
}
.width(72).height(72).backgroundColor('#0288D1').borderRadius(36)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Text('晨间达人').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#01579B')
Text('Lv.7 · 习惯养成者').fontSize(12).fontColor('#78909C').margin({ top: 3 })
Row() {
Text('✧ 今日评分: ' + computeMorningGrade().toString()).fontSize(11).fontColor('#0277BD').fontWeight(FontWeight.Bold)
}.margin({ top: 5 })
}.margin({ left: 14 }).alignItems(HorizontalAlign.Start).layoutWeight(1)
}.width('100%')
}
.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ top: 8, bottom: 8 })
Row() {
this.metricBox('⭐ 完美早晨', perfectMornings().toString(), '天', '#0277BD')
this.metricBox('📅 累计天数', '360', '天', '#0288D1')
}.width('100%').margin({ bottom: 6 })
Row() {
this.metricBox('⏱️ 总时长', '892', '小时', '#4CAF50')
this.metricBox('🔥 当前连续', '95', '天', '#FF8F00')
}.width('100%').margin({ bottom: 8 })
Column() {
Text('🏅 成就徽章 · 已解锁 8 / 15').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ top: 4, bottom: 10 })
Row() {
Column() { Text('🌅').fontSize(30); Text('早起达人').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🧘').fontSize(30); Text('冥想30天').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('📖').fontSize(30); Text('阅读50本').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🔥').fontSize(30); Text('100天连续').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}.width('100%').margin({ bottom: 10 })
Row() {
Column() { Text('🏃').fontSize(30); Text('跑步达人').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('💧').fontSize(30); Text('水分充足').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🧠').fontSize(30); Text('心智导师').fontSize(10).fontColor('#37474F').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() { Text('🔒').fontSize(30); Text('待解锁').fontSize(10).fontColor('#B0BEC5').margin({ top: 3 }) }
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}.width('100%')
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ bottom: 8 })
Column() {
Text('⚙️ 设置').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor('#01579B').width('100%').padding({ top: 4, bottom: 4 })
Row() { Text('🔔').fontSize(18); Text('晨间提醒').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('5:30 AM').fontSize(12).fontColor('#90A4AE') }
.width('100%').padding({ top: 12, bottom: 12 }).alignItems(VerticalAlign.Center)
Row().width('100%').height(1).backgroundColor('#E1F5FE')
Row() { Text('📊').fontSize(18); Text('统计周期').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('每周').fontSize(12).fontColor('#90A4AE') }
.width('100%').padding({ top: 12, bottom: 12 }).alignItems(VerticalAlign.Center)
Row().width('100%').height(1).backgroundColor('#E1F5FE')
Row() { Text('📤').fontSize(18); Text('数据导出').fontSize(13).layoutWeight(1).margin({ left: 10 }); Text('›').fontColor('#B0BEC5') }
.width('100%').padding({ top: 12, bottom: 12 }).alignItems(VerticalAlign.Center)
}
.width('100%').padding({ left: 14, right: 14, bottom: 4 }).backgroundColor('#FFFFFF').borderRadius(16)
.shadow({ radius: 6, color: '#1A0277BD', offsetY: 2 }).margin({ bottom: 16 })
}
.width('100%').padding({ left: 12, right: 12, top: 4, bottom: 16 })
}
.layoutWeight(1).backgroundColor('#E1F5FE').scrollBar(BarState.Off)
}.width('100%').height('100%')
}
@Builder addHabitModal() {
Column() {
this.modalBg(() => { this.addOpen = false })
Column() {
Row() {
Text('➕ 添加习惯').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#01579B')
Row().layoutWeight(1)
Text('✕').fontSize(22).fontColor('#90A4AE')
.onClick(() => { this.addOpen = false })
}
.width('100%').padding({ left: 18, right: 18, top: 18, bottom: 8 })
Scroll() {
Column() {
Text('习惯名称').fontSize(12).fontColor('#78909C').margin({ top: 8, left: 4 })
TextInput({ text: this.formName, placeholder: '输入习惯名称' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formName = v })
Text('图标').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
Flex({ wrap: FlexWrap.Wrap }) {
Column() { Text('🧘').fontSize(22) }
.width(48).height(48).backgroundColor(this.formIcon === '🧘' ? '#E1F5FE' : '#F5F5F5')
.borderRadius(12).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.formIcon = '🧘' })
Column() { Text('🏃').fontSize(22) }
.width(48).height(48).backgroundColor(this.formIcon === '🏃' ? '#E1F5FE' : '#F5F5F5')
.borderRadius(12).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.formIcon = '🏃' })
Column() { Text('📖').fontSize(22) }
.width(48).height(48).backgroundColor(this.formIcon === '📖' ? '#E1F5FE' : '#F5F5F5')
.borderRadius(12).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.formIcon = '📖' })
Column() { Text('🎧').fontSize(22) }
.width(48).height(48).backgroundColor(this.formIcon === '🎧' ? '#E1F5FE' : '#F5F5F5')
.borderRadius(12).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.formIcon = '🎧' })
Column() { Text('📋').fontSize(22) }
.width(48).height(48).backgroundColor(this.formIcon === '📋' ? '#E1F5FE' : '#F5F5F5')
.borderRadius(12).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.formIcon = '📋' })
Column() { Text('💛').fontSize(22) }
.width(48).height(48).backgroundColor(this.formIcon === '💛' ? '#E1F5FE' : '#F5F5F5')
.borderRadius(12).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.formIcon = '💛' })
Column() { Text('💻').fontSize(22) }
.width(48).height(48).backgroundColor(this.formIcon === '💻' ? '#E1F5FE' : '#F5F5F5')
.borderRadius(12).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.formIcon = '💻' })
Column() { Text('☕').fontSize(22) }
.width(48).height(48).backgroundColor(this.formIcon === '☕' ? '#E1F5FE' : '#F5F5F5')
.borderRadius(12).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.margin({ right: 6, bottom: 6 })
.onClick(() => { this.formIcon = '☕' })
}.width('100%').margin({ top: 4 })
Text('分类').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
Row() {
Text('身体').fontSize(12).fontColor(this.formCat === 0 ? '#FFFFFF' : '#4CAF50')
.backgroundColor(this.formCat === 0 ? '#4CAF50' : '#E8F5E9')
.borderRadius(16).padding({ left: 14, right: 14, top: 8, bottom: 8 })
.onClick(() => { this.formCat = 0 })
Text('心智').fontSize(12).fontColor(this.formCat === 1 ? '#FFFFFF' : '#9C27B0')
.backgroundColor(this.formCat === 1 ? '#9C27B0' : '#F3E5F5')
.borderRadius(16).padding({ left: 14, right: 14, top: 8, bottom: 8 }).margin({ left: 8 })
.onClick(() => { this.formCat = 1 })
Text('计划').fontSize(12).fontColor(this.formCat === 2 ? '#FFFFFF' : '#FF9800')
.backgroundColor(this.formCat === 2 ? '#FF9800' : '#FFF3E0')
.borderRadius(16).padding({ left: 14, right: 14, top: 8, bottom: 8 }).margin({ left: 8 })
.onClick(() => { this.formCat = 2 })
Text('学习').fontSize(12).fontColor(this.formCat === 3 ? '#FFFFFF' : '#0277BD')
.backgroundColor(this.formCat === 3 ? '#0277BD' : '#E1F5FE')
.borderRadius(16).padding({ left: 14, right: 14, top: 8, bottom: 8 }).margin({ left: 8 })
.onClick(() => { this.formCat = 3 })
}.margin({ top: 6 })
Text('目标时长(分钟)').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
TextInput({ text: this.formMins, placeholder: '如 10' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formMins = v })
Text('最佳时段').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
TextInput({ text: this.formSlot, placeholder: '如 6:00' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formSlot = v })
Text('提醒').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
Row() {
Text('开启').fontSize(12).fontColor(this.formRemind === '启' ? '#FFFFFF' : '#78909C')
.backgroundColor(this.formRemind === '启' ? '#0277BD' : '#F5F5F5')
.borderRadius(14).padding({ left: 16, right: 16, top: 8, bottom: 8 })
.onClick(() => { this.formRemind = '启' })
Text('关闭').fontSize(12).fontColor(this.formRemind === '关' ? '#FFFFFF' : '#78909C')
.backgroundColor(this.formRemind === '关' ? '#90A4AE' : '#F5F5F5')
.borderRadius(14).padding({ left: 16, right: 16, top: 8, bottom: 8 }).margin({ left: 8 })
.onClick(() => { this.formRemind = '关' })
}.margin({ top: 6 })
}
.width('100%').padding({ left: 18, right: 18, bottom: 16 })
}.layoutWeight(1)
Row() {
Text('取消').fontSize(14).fontColor('#78909C')
.backgroundColor('#F0F4F8').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.onClick(() => { this.addOpen = false })
Text('添加习惯').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#0277BD').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 }).margin({ left: 12 })
.onClick(() => { this.addOpen = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 18, right: 18, top: 12, bottom: 16 })
}
.width('90%').constraintSize({ maxHeight: '85%' })
.backgroundColor('#FFFFFF').borderRadius(18)
.alignItems(HorizontalAlign.Center)
.position({ x: '5%', y: '7%' })
.shadow({ radius: 24, color: '#33000000', offsetY: 6 })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
}
@Builder editHabitModal() {
Column() {
this.modalBg(() => { this.editOpen = false })
Column() {
Row() {
Text('✏️ 编辑习惯').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#01579B')
Row().layoutWeight(1)
Text('✕').fontSize(22).fontColor('#90A4AE')
.onClick(() => { this.editOpen = false })
}
.width('100%').padding({ left: 18, right: 18, top: 18, bottom: 8 })
Scroll() {
Column() {
Text('习惯名称').fontSize(12).fontColor('#78909C').margin({ top: 8, left: 4 })
TextInput({ text: this.formName, placeholder: '输入习惯名称' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formName = v })
Text('分类').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
Row() {
Text('身体').fontSize(12).fontColor(this.formCat === 0 ? '#FFFFFF' : '#4CAF50')
.backgroundColor(this.formCat === 0 ? '#4CAF50' : '#E8F5E9')
.borderRadius(16).padding({ left: 14, right: 14, top: 8, bottom: 8 })
.onClick(() => { this.formCat = 0 })
Text('心智').fontSize(12).fontColor(this.formCat === 1 ? '#FFFFFF' : '#9C27B0')
.backgroundColor(this.formCat === 1 ? '#9C27B0' : '#F3E5F5')
.borderRadius(16).padding({ left: 14, right: 14, top: 8, bottom: 8 }).margin({ left: 8 })
.onClick(() => { this.formCat = 1 })
Text('计划').fontSize(12).fontColor(this.formCat === 2 ? '#FFFFFF' : '#FF9800')
.backgroundColor(this.formCat === 2 ? '#FF9800' : '#FFF3E0')
.borderRadius(16).padding({ left: 14, right: 14, top: 8, bottom: 8 }).margin({ left: 8 })
.onClick(() => { this.formCat = 2 })
Text('学习').fontSize(12).fontColor(this.formCat === 3 ? '#FFFFFF' : '#0277BD')
.backgroundColor(this.formCat === 3 ? '#0277BD' : '#E1F5FE')
.borderRadius(16).padding({ left: 14, right: 14, top: 8, bottom: 8 }).margin({ left: 8 })
.onClick(() => { this.formCat = 3 })
}.margin({ top: 6 })
Text('目标时长(分钟)').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
TextInput({ text: this.formMins, placeholder: '如 10' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formMins = v })
Text('最佳时段').fontSize(12).fontColor('#78909C').margin({ top: 14, left: 4 })
TextInput({ text: this.formSlot, placeholder: '如 6:00' })
.placeholderColor('#B0BEC5').fontSize(14).height(42)
.backgroundColor('#F5F9FF').borderRadius(10)
.onChange((v: string) => { this.formSlot = v })
}
.width('100%').padding({ left: 18, right: 18, bottom: 16 })
}.layoutWeight(1)
Row() {
Text('保存').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#0277BD').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.onClick(() => { this.editOpen = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 18, right: 18, top: 12, bottom: 16 })
}
.width('90%').constraintSize({ maxHeight: '80%' })
.backgroundColor('#FFFFFF').borderRadius(18)
.alignItems(HorizontalAlign.Center)
.position({ x: '5%', y: '10%' })
.shadow({ radius: 24, color: '#33000000', offsetY: 6 })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
}
@Builder deleteConfirmModal() {
Column() {
this.modalBg(() => { this.delOpen = false })
Column() {
Text('⚠️').fontSize(52).margin({ top: 24 })
Text('确认删除习惯?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('删除后数据不可恢复').fontSize(13).fontColor('#EF5350').margin({ top: 6 })
if (this.pickIdx >= 0) {
Row() {
Text(habitEntries[this.pickIdx].iconVal).fontSize(28)
Column() {
Text(habitEntries[this.pickIdx].nameVal).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
Text('🔥' + habitEntries[this.pickIdx].streakNum + '天连续 · ' + habitEntries[this.pickIdx].allDays + '天总计').fontSize(11).fontColor('#90A4AE').margin({ top: 2 })
}.margin({ left: 10 })
}
.backgroundColor('#FFF5F5').borderRadius(12)
.padding({ left: 16, right: 16, top: 12, bottom: 12 }).margin({ top: 16 })
}
Row() {
Text('取消').fontSize(14).fontColor('#78909C')
.backgroundColor('#F0F4F8').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.onClick(() => { this.delOpen = false })
Text('确认删除').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
.backgroundColor('#EF5350').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 }).margin({ left: 12 })
.onClick(() => { this.delOpen = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 22, bottom: 22 })
}
.width('82%').backgroundColor('#FFFFFF').borderRadius(18)
.alignItems(HorizontalAlign.Center)
.position({ x: '9%', y: '35%' })
.shadow({ radius: 24, color: '#33000000', offsetY: 6 })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder bottomTabs() {
Row() {
Column() {
Text('☀️').fontSize(22).opacity(this.curTab === 0 ? 1.0 : 0.45)
Text('晨间').fontSize(10)
.fontColor(this.curTab === 0 ? '#0277BD' : '#90A4AE')
.fontWeight(this.curTab === 0 ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.onClick(() => { this.curTab = 0 })
Column() {
Text('📋').fontSize(22).opacity(this.curTab === 1 ? 1.0 : 0.45)
Text('所有习惯').fontSize(10)
.fontColor(this.curTab === 1 ? '#0277BD' : '#90A4AE')
.fontWeight(this.curTab === 1 ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.onClick(() => { this.curTab = 1 })
Column() {
Text('📊').fontSize(22).opacity(this.curTab === 2 ? 1.0 : 0.45)
Text('统计').fontSize(10)
.fontColor(this.curTab === 2 ? '#0277BD' : '#90A4AE')
.fontWeight(this.curTab === 2 ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.onClick(() => { this.curTab = 2 })
Column() {
Text('👤').fontSize(22).opacity(this.curTab === 3 ? 1.0 : 0.45)
Text('我的').fontSize(10)
.fontColor(this.curTab === 3 ? '#0277BD' : '#90A4AE')
.fontWeight(this.curTab === 3 ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.onClick(() => { this.curTab = 3 })
}
.width('100%').padding({ top: 4, bottom: 4 })
.backgroundColor('#FFFFFF')
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
Column() {
if (this.curTab === 0) { this.morningTimelineTab() }
if (this.curTab === 1) { this.allHabitsTab() }
if (this.curTab === 2) { this.statsTab() }
if (this.curTab === 3) { this.profileTab() }
}
.layoutWeight(1).width('100%')
this.bottomTabs()
}
.width('100%').height('100%')
if (this.addOpen) { this.addHabitModal() }
if (this.editOpen) { this.editHabitModal() }
if (this.delOpen) { this.deleteConfirmModal() }
}
.width('100%').height('100%').backgroundColor('#E1F5FE')
}
}
二十六、结语:从源码看 ArkTS 声明式 UI 的工程实践
通过对这份晨间习惯追踪应用源码的逐段拆解,我们可以清晰地看到 HarmonyOS ArkTS 在构建中等复杂度移动应用时的工程范式。整个应用以一个 @Entry @Component 为根,通过 @State 管理十二个响应式状态变量,以 @Builder 封装了十余个可复用 UI 片段,以 @Observed class 建模核心数据实体,最终在 build() 方法中通过 Stack 层叠布局将"页面层"与"弹窗层"有机融合。这种"状态驱动 + Builder 复用 + 条件渲染"的三位一体架构,正是 ArkUI 声明式范式的精髓所在。

从产品视角看,这个应用虽然是一个"原型级"实现(数据为内存常量、部分交互逻辑待接通),但其信息架构、视觉设计和交互流程已经相当成熟——四 Tab 导航清晰划分功能域,时间线页提供了差异化的"晨间日程"视角,统计页用纯组件自绘了三种数据图表,个人中心页引入了游戏化的等级与徽章体系,三大弹窗覆盖了增删改的完整 CRUD 链路。这些设计思路对于开发任何"列表管理 + 数据可视化 + 表单交互"类的 HarmonyOS 应用都具有直接的参考价值。
更多推荐



所有评论(0)