介绍
本篇Codelab介绍了如何实现一个简单的健康生活应用,主要功能包括:
- 用户可以创建最多6个健康生活任务(早起,喝水,吃苹果,每日微笑,刷牙,早睡),并设置任务目标、是否开启提醒、提醒时间、每周任务频率。
- 用户可以在主页面对设置的健康生活任务进行打卡,其中早起、每日微笑、刷牙和早睡只需打卡一次即可完成任务,喝水、吃苹果需要根据任务目标量多次打卡完成。
- 主页可显示当天的健康生活任务完成进度,当天所有任务都打卡完成后,进度为100%,并且用户的连续打卡天数加一。
- 当用户连续打卡天数达到3、7、30、50、73、99天时,可以获得相应的成就。成就在获得时会以动画形式弹出,并可以在“成就”页面查看。
- 用户可以查看以前的健康生活任务完成情况。
本应用的运行效果如下图所示:
相关概念
- @Observed 和 @ObjectLink:@Observed适用于类,表示类中的数据变化由UI页面管理;@ObjectLink应用于被@Observed装饰类的对象。
- @Consume 和 @Provide:@Provide作为数据提供者,可以更新子节点的数据,触发页面渲染。@Consume检测到@Provide数据更新后,会发起当前视图的重新渲染。
- Flex:一个功能强大的容器组件,支持横向布局,竖向布局,子组件均分和流式换行布局。
- List:List是很常用的滚动类容器组件之一,它按照水平或者竖直方向线性排列子组件, List的子组件必须是ListItem,它的宽度默认充满List的宽度。
- TimePicker:TimePicker是选择时间的滑动选择器组件,默认以00:00至23:59的时间区创建滑动选择器。
- Toggle:组件提供勾选框样式、状态按钮样式及开关样式。
- 后台代理提醒:使用后台代理提醒能力后,应用可以被冻结或退出,计时和弹出提醒的功能将被后台系统服务代理。
- 关系型数据库(Relational Database,RDB):一种基于关系模型来管理数据的数据库。
环境搭建
软件要求
- DevEco Studio版本:DevEco Studio 3.1 Release。
- OpenHarmony SDK版本:API version 9。
硬件要求
- 开发板类型:润和RK3568开发板。
- OpenHarmony系统:3.2 Release。
环境搭建
完成本篇Codelab我们首先要完成开发环境的搭建,本示例以RK3568开发板为例,参照以下步骤进行:
- 获取OpenHarmony系统版本:标准系统解决方案(二进制)。以3.2 Release版本为例:
2.搭建烧录环境。
- 完成DevEco Device Tool的安装
- 完成RK3568开发板的烧录
3.搭建开发环境。
- 开始前请参考工具准备,完成DevEco Studio的安装和开发环境配置。
- 开发环境配置完成后,请参考使用工程向导创建工程(模板选择“Empty Ability”),选择JS或者eTS语言开发。
-
工程创建完成后,选择使用真机进行调测。
代码结构解读
本篇Codelab只对核心代码进行讲解。
├─entry/src/main/ets // 代码区
│ ├─common
│ │ ├─constants
│ │ │ └─CommonConstants.ets // 公共常量
│ │ ├─database
│ │ │ ├─rdb // 数据库
│ │ │ │ ├─RdbHelper.ets
│ │ │ │ ├─RdbHelperImp.ets
│ │ │ │ ├─RdbUtil.ets
│ │ │ │ └─TableHelper.ets
│ │ │ └─tables // 数据库接口
│ │ │ ├─DayInfoApi.ets
│ │ │ ├─GlobalInfoApi.ets
│ │ │ └─TaskInfoApi.ets
│ │ └─utils
│ │ ├─BroadCast.ets // 通知
│ │ ├─GlobalContext.ets // 全局上下文
│ │ ├─HealthDataSrcMgr.ets // 数据管理单例
│ │ ├─Logger.ets // 日志类
│ │ └─Utils.ets // 工具类
│ ├─entryability
│ │ └─EntryAbility.ets // 程序入口类
│ ├─model // model
│ │ ├─AchieveModel.ets
│ │ ├─DatabaseModel.ets // 数据库model
│ │ ├─Mine.ets
│ │ ├─NavItemModel.ets // 菜单栏model
│ │ ├─RdbColumnModel.ets // 数据库表数据
│ │ ├─TaskInitList.ets
│ │ └─WeekCalendarModel.ets // 日历model
│ ├─pages
│ │ ├─AdvertisingPage.ets // 广告页
│ │ ├─MainPage.ets // 应用主页面
│ │ ├─MinePage.ets // 我的页面
│ │ ├─SplashPage.ets // 启动页
│ │ ├─TaskEditPage.ets // 任务编辑页面
│ │ └─TaskListPage.ets // 任务列表页面
│ ├─service
│ │ └─ReminderAgent.ets // 后台提醒
│ ├─view
│ │ ├─dialog // 弹窗组件
│ │ │ ├─AchievementDialog.ets // 成就弹窗
│ │ │ ├─CustomDialogView.ets // 自定义弹窗
│ │ │ ├─TaskDetailDialog.ets // 打卡弹窗
│ │ │ ├─TaskDialogView.ets // 任务对话框
│ │ │ ├─TaskSettingDialog.ets // 任务编辑相关弹窗
│ │ │ └─UserPrivacyDialog.ets
│ │ ├─home // 主页面相关组件
│ │ │ ├─AddBtnComponent.ets // 添加任务按钮组件
│ │ │ ├─HomeTopComponent.ets // 首页顶部组件
│ │ │ ├─TaskCardComponent.ets // 任务item组件件
│ │ │ └─WeekCalendarComponent.ets // 日历组件
│ │ ├─task // 任务相关组件
│ │ │ ├─TaskDetailComponent.ets // 任务编辑详情组件
│ │ │ ├─TaskEditListItem.ets // 任务编辑行内容
│ │ │ └─TaskListComponent.ets // 任务列表组件
│ │ ├─AchievementComponent.ets // 成就页面
│ │ ├─BadgeCardComponent.ets // 勋章卡片组件
│ │ ├─BadgePanelComponent.ets // 勋章面板组件
│ │ ├─HealthTextComponent.ets // 自定义text组件
│ │ ├─HomeComponent.ets // 首页页面
│ │ ├─ListInfo.ets // 用户信息列表
│ │ ├─TitleBarComponent.ets // 成就标题组件
│ │ └─UserBaseInfo.ets // 用户基本信息
│ └─viewmodel // viewmodel
│ ├─AchievementInfo.ets // 成就信息
│ ├─AchievementMapInfo.ets // 成就map信息
│ ├─AchievementViewModel.ets // 成就相关模块
│ ├─BroadCastCallBackInfo.ets // 通知回调信息
│ ├─CalendarViewModel.ets // 日历相关模块
│ ├─CardInfo.ets // 成就卡片信息
│ ├─ColumnInfo.ets // 数据库表结构
│ ├─CommonConstantsInfo.ets // 公共常量信息
│ ├─DayInfo.ets // 每日信息
│ ├─GlobalInfo.ets // 全局信息
│ ├─HomeViewModel.ets // 首页相关模块
│ ├─PublishReminderInfo.ets // 发布提醒信息
│ ├─ReminderInfo.ets // 提醒信息
│ ├─TaskInfo.ets // 任务信息
│ ├─TaskViewModel.ets // 任务设置相关模块
│ ├─WeekCalendarInfo.ets // 日历信息
│ └─WeekCalendarMethodInfo.ets // 日历方法信息
└─entry/src/main/resources // 资源文件夹
应用架构分析
本应用的基本架构如下图所示,数据库为其他服务提供基础的用户数据,主要业务包括:用户可以查看和编辑自己的健康任务并进行打卡、查看成就。UI层提供了承载上述业务的UI界面。
应用主页面
本节将介绍如何给应用添加一个启动页,设计应用的主界面,以及首页的界面开发和数据展示。
启动页
首先我们需要给应用添加一个启动页,启动页里我们需要用到一个定时器来实现启动页展示固定时间后跳转应用主页的功能,效果图如下:
打开应用时会进入此页面,具体实现逻辑是:
通过修改/entry/src/main/ets/entryability里的loadContent路径可以改变应用的入口文件,我们需要把入口文件改为我们写的SplashPage启动页面。
// EntryAbility.ets
windowStage.loadContent('pages/SplashPage', (err, data) => { if (err.code) {...} Logger.info('windowStage','Succeeded in loading the content. Data: ' + JSON.stringify(data));
});
在SplashPage启动页的文件里通过首选项来实现是否需要弹“权限管理”的弹窗,如果需要弹窗的情况下,用户点击同意权限后通过首选项对用户的操作做持久化保存。相关代码如下:
// SplashPage.ets
import data_preferences from '@ohos.data.preferences';
onConfirm() {let preferences = data_preferences.getPreferences(this.context, H_STORE);preferences.then((res) => {res.put(IS_PRIVACY, true).then(() => {res.flush();Logger.info('SplashPage','isPrivacy is put success');}).catch((err: Error) => {Logger.info('SplashPage','isPrivacy put failed. Cause:' + err);});})this.jumpAdPage();
}
exitApp() {this.context.terminateSelf();
}
jumpAdPage() {setTimeout(() => {router.replaceUrl({ url: 'pages/AdvertisingPage' });}, Const.LAUNCHER_DELAY_TIME);
}
aboutToAppear() {let preferences = data_preferences.getPreferences(this.context, H_STORE);preferences.then((res) => {res.get(IS_PRIVACY, false).then((isPrivate) => {if (isPrivate === true) {this.jumpAdPage();} else {this.dialogController.open();}});});
}
APP功能入口
我们需要给APP添加底部菜单栏,用于切换不同的应用模块,由于各个模块之间属于完全独立的情况,并且不需要每次切换都进行界面的刷新,所以我们用到了Tabs,TabContent组件。
本应用一共有首页(HomeIndex),成就(AchievementIndex)和我的(MineIndex)三个模块,分别对应Tabs组件的三个子组件TabContent。
// MainPage.ets
TabContent() {HomeIndex({ homeStore: $homeStore, editedTaskInfo: $editedTaskInfo, editedTaskID: $editedTaskID }).borderWidth({ bottom: 1 }).borderColor($r('app.color.primaryBgColor'))
}
.tabBar(this.TabBuilder(TabId.HOME))
.align(Alignment.Start)
TabContent() {AchievementIndex()
}
.tabBar(this.TabBuilder(TabId.ACHIEVEMENT))
TabContent() {MineIndex().borderWidth({ bottom: 1 }).borderColor($r('app.color.primaryBgColor'))
}
.tabBar(this.TabBuilder(TabId.MINE))
首页
首页包含了任务信息的所有入口,包含任务列表的展示,任务的编辑和新增,上下滚动的过程中顶部导航栏的渐变,日期的切换以及随着日期切换界面任务列表跟着同步的功能,效果图如下:
具体代码实现我们将在下边分模块进行说明:
- 导航栏背景渐变
Scroll滚动的过程中,在它的onScroll方法里我们通过计算它Y轴的偏移量来改变当前界面的@State修饰的naviAlpha变量值,进而改变顶部标题的背景色,代码实现如下:
// HomeComponent.ets
// 视图滚动的过程中处理导航栏的透明度
onScrollAction() { this.yOffset = this.scroller.currentOffset().yOffset; if (this.yOffset > Const.DEFAULT_56) { this.naviAlpha = 1; } else { this.naviAlpha = this.yOffset / Const.DEFAULT_56;}
}
2.日历组件
日历组件主要用到的是一个横向滑动的Scroll组件。
// WeekCalendarComponent.etsbuild() { Row() { Column() { Row() {...} Scroll(this.scroller) { Row() { ForEach(this.homeStore.dateArr, (item: WeekDateModel, index?: number) => { Column() { Text(item.weekTitle) .fontColor(sameDate(item.date, this.homeStore.showDate) ? $r('app.color.blueColor') : $r('app.color.titleColor')) Divider().color(sameDate(item.date, this.homeStore.showDate) ? $r('app.color.blueColor') : $r('app.color.white')) Image(this.getProgressImg(item)) } .onClick(() => WeekCalendarMethods.calenderItemClickAction(item, index, this.homeStore)) }) } }... .onScrollEdge((event) => this.onScrollEdgeAction(event)) }... }... }
手动滑动页面时,我们通过在onScrollEnd方法里计算Scroll的偏移量来实现分页的效果,同时Scroll有提供scrollPage()方法可供我们点击左右按钮的时候来进行页面切换。
// WeekCalendarComponent.ets
import display from '@ohos.display';
...
// scroll滚动停止时通过判断偏移量进行分页处理
onScrollEndAction() {if (this.isPageScroll === false) {let page = Math.round(this.scroller.currentOffset().xOffset / this.scrollWidth);page = (this.isLoadMore === true) ? page + 1 : page;if (this.scroller.currentOffset().xOffset % this.scrollWidth != 0 || this.isLoadMore === true) {let xOffset = page * this.scrollWidth;this.scroller.scrollTo({ xOffset, yOffset: 0 } as ScrollTo);this.isLoadMore = false;}this.currentPage = this.homeStore.dateArr.length / Const.WEEK_DAY_NUM - page - 1;Logger.info('HomeIndex', 'onScrollEnd: page ' + page + ', listLength ' + this.homeStore.dateArr.length);let dayModel: WeekDateModel = this.homeStore.dateArr[Const.WEEK_DAY_NUM * page+this.homeStore.selectedDay];Logger.info('HomeIndex', 'currentItem: ' + JSON.stringify(dayModel) + ', selectedDay ' + this.homeStore.selectedDay);this.homeStore!.setSelectedShowDate(dayModel!.date!.getTime());}this.isPageScroll = false;
}
我们在需要在Scroll滑动到左边边缘的时候去请求更多的历史数据以便Scroll能一直滑动,通过Scroll的onScrollEdge方法我们可以判断它是否已滑到边缘位置。
// WeekCalendarComponent.ets
onScrollEdgeAction(side: Edge) {if (side === Edge.Top && this.isPageScroll === false) {Logger.info('HomeIndex', 'onScrollEdge: currentPage ' + this.currentPage);if ((this.currentPage + 2) * Const.WEEK_DAY_NUM >= this.homeStore.dateArr.length) {Logger.info('HomeIndex', 'onScrollEdge: load more data');let date: Date = new Date(this.homeStore.showDate);date.setDate(date.getDate() - Const.WEEK_DAY_NUM);this.homeStore.getPreWeekData(date, () => {});this.isLoadMore = true;}}
}
homeStore主要是请求数据库的数据并对数据进行处理进而渲染到界面上。
// HomeViewModel.ets
public getPreWeekData(date: Date, callback: Function) {let weekCalendarInfo: WeekCalendarInfo = getPreviousWeek(date);// 请求数据库数据DayInfoApi.queryList(weekCalendarInfo.strArr, (res: DayInfo[]) => {// 数据处理... this.dateArr = weekCalendarInfo.arr.concat(...this.dateArr);})
}
同时我们还需要知道怎么根据当天的日期计算出本周内的所有日期数据。
// WeekCalendarModel.ets
export function getPreviousWeek(showDate: Date): WeekCalendarInfo {Logger.debug('WeekCalendarModel', 'get week date by date: ' + showDate.toDateString());let weekCalendarInfo: WeekCalendarInfo = new WeekCalendarInfo();let arr: Array<WeekDateModel> = [];let strArr: Array<string> = [];let currentDay = showDate.getDay() - 1;// 由于date的getDay()方法返回的是0-6代表周日到周六,我们界面上展示的周一-周日为一周,所以这里要将getDay()数据偏移一天let currentDay = showDate.getDay() - 1;if (showDate.getDay() === 0) {currentDay = 6;}// 将日期设置为当前周第一天的数据(周一)showDate.setDate(showDate.getDate() - currentDay);for (let index = WEEK_DAY_NUM; index > 0; index--) {let tempDate = new Date(showDate);tempDate.setDate(showDate.getDate() - index);let dateStr = dateToStr(tempDate);strArr.push(dateStr);arr.push(new WeekDateModel(WEEK_TITLES[tempDate.getDay()], dateStr, tempDate));}Logger.debug('WeekCalendarModel', JSON.stringify(arr));weekCalendarInfo.arr = arr;weekCalendarInfo.strArr = strArr;return weekCalendarInfo;
}
- 悬浮按钮
由于首页右下角有一个悬浮按钮,所以首页整体我们用了一个Stack组件,将右下角的悬浮按钮和顶部的title放在滚动组件层的上边。
// HomeComponent.ets
build() { Stack() { Scroll(this.scroller) { Column() { ... // 上部界面组件Column() { ForEach(this.homeStore.getTaskListOfDay(), (item: TaskInfo) => { TaskCard({taskInfoStr: JSON.stringify(item),clickAction: (isClick: boolean) => this.taskItemAction(item, isClick)})...}, (item: TaskInfo) => JSON.stringify(item))} } }}.onScroll(() => {this.onScrollAction()})// 悬浮按钮AddBtn({ clickAction: () => {this.editTaskAction()} }) // 顶部title Row() {Text($r('app.string.EntryAbility_label')).titleTextStyle().fontSize($r('app.float.default_24')).padding({ left: Const.THOUSANDTH_66 })}.width(Const.THOUSANDTH_1000).height(Const.DEFAULT_56).position({ x: 0, y: 0 }).backgroundColor(`rgba(${WHITE_COLOR_0X},${WHITE_COLOR_0X},${WHITE_COLOR_0X},${this.naviAlpha})`)CustomDialogView()
}
.allSize()
.backgroundColor($r('app.color.primaryBgColor'))
4.界面跳转及传参
首页任务列表长按时需要跳转到对应的任务编辑界面,同时点击悬浮按钮时需要跳转到任务列表页面。
页面跳转需要在头部引入router。
// HomeComponent.ets import router from '@ohos.router';
- 任务item的点击事件代码如下
-
// HomeComponent.ets taskItemAction(item: TaskInfo, isClick: boolean): void { if (!this.homeStore.checkCurrentDay()) { return; } if (isClick) { // 点击任务打卡 let callback: CustomDialogCallback = { confirmCallback: (taskTemp: TaskInfo) => { this.onConfirm(taskTemp) }, cancelCallback: () => { } }; this.broadCast.emit(BroadCastType.SHOW_TASK_DETAIL_DIALOG, [item, callback]); } else { // 长按编辑任务 let editTaskStr: string = JSON.stringify(TaskMapById[item.taskID - 1]); let editTask: ITaskItem = JSON.parse(editTaskStr); ... router.pushUrl({ url: 'pages/TaskEditPage', params: { params: JSON.stringify(editTask) } }); } }
任务创建与编辑
本节将介绍如何创建和编辑健康生活任务。
功能概述
用户点击悬浮按钮进入任务列表页,点击任务列表可进入对应任务编辑的页面中,对任务进行详细的设置,之后点击完成按钮编辑任务后将返回首页。实现效果如下图:
任务列表与编辑任务
这里主要为大家介绍添加任务列表页的实现、任务编辑的实现、以及具体弹窗设置和编辑完成功能的逻辑实现。
任务列表页
任务列表页由包括上部分的标题、返回按钮以及正中间的任务列表组成。实现效果如图:
使用Navigation以及List组件构成元素,ForEach遍历生成具体列表。这里是Navigation构成页面导航:
// TaskListPage.ets
Navigation() {Column() {// 页面中间的列表TaskList() }.width(Const.THOUSANDTH_1000).justifyContent(FlexAlign.Center)
}
.size({ width: Const.THOUSANDTH_1000, height: Const.THOUSANDTH_1000 })
.title(Const.ADD_TASK_TITLE)
.titleMode(NavigationTitleMode.Mini)
列表右侧有一个判断是否开启的文字标识,点击某个列表需要跳转到对应的任务编辑页里。具体的列表实现如下:
// TaskListComponent.ets@Component
export default struct TaskList {...build() {List({ space: Const.LIST_ITEM_SPACE }) {ForEach(this.taskList, (item: ITaskItem) => {ListItem() {Row() {Row() {Image(item?.icon)...Text(item?.taskName).fontSize(Const.DEFAULT_20).fontColor($r('app.color.titleColor'))}.width(Const.THOUSANDTH_500)Blank()...// 状态改变if (item?.isOpen) {Text($r('app.string.already_open'))...}Image($r('app.media.ic_right_grey'))...}...}....onClick(() => {router.pushUrl({url: 'pages/TaskEditPage',params: {params: formatParams(item),}})})...}, (item: ITaskItem) => JSON.stringify(item))}...}
}
任务编辑页
任务编辑页由上方的“编辑任务”标题以及返回按钮,主体内容的List配置项和下方的完成按钮组成,实现效果如图:
由于每一个配置项功能不相同,且逻辑复杂,故将其拆分为五个独立的组件。
这是任务编辑页面,由Navigation和一个自定义组件TaskDetail构成:
// TaskEditPage.ets
Navigation() {Column() {TaskDetail()}.width(Const.THOUSANDTH_1000).height(Const.THOUSANDTH_1000)
}
.size({ width: Const.THOUSANDTH_1000, height: Const.THOUSANDTH_1000 })
.title(Const.EDIT_TASK_TITLE)
.titleMode(NavigationTitleMode.Mini)
自定义组件由List以及其子组件ListItem构成:
// TaskDetailComponent.ets
List({ space: Const.LIST_ITEM_SPACE }) {ListItem() {TaskChooseItem()}...ListItem() {TargetSetItem()}...ListItem() {OpenRemindItem()}...ListItem() {RemindTimeItem()}...ListItem() {FrequencyItem()}...
}
.width(Const.THOUSANDTH_940)
其中做了禁用判断,需要任务打开才可以点击编辑:
// TaskDetailComponent.ets
.enabled(this.settingParams?.isOpen
)
一些特殊情况的禁用,如每日微笑、每日刷牙的目标设置不可编辑:
// TaskDetailComponent.ets
.enabled(this.settingParams?.isOpen&& this.settingParams?.taskID !== taskType.smile&& this.settingParams?.taskID !== taskType.brushTeeth
)
提醒时间在开启提醒打开之后才可以编辑:
// TaskDetailComponent.ets
.enabled(this.settingParams?.isOpen && this.settingParams?.isAlarm)
设置完成之后,点击完成按钮,会向数据库更新现在进行改变的状态信息,并执行之后的逻辑判断:
// TaskDetailComponent.ets
addTask(taskInfo, context).then((res: number) => {GlobalContext.getContext().setObject('taskListChange', true);// 成功的状态,成功后跳转首页router.back({url: 'pages/MainPage', params: {editTask: this.backIndexParams(),}})Logger.info('addTaskFinished', JSON.stringify(res));
}).catch((error: Error) => {// 失败的状态,失败后弹出提示,并打印错误日志prompt.showToast({message: Const.SETTING_FINISH_FAILED_MESSAGE})Logger.error('addTaskFailed', JSON.stringify(error));
})
任务编辑弹窗
弹窗由封装的自定义组件CustomDialogView注册事件,并在点击对应的编辑项时进行触发,从而打开弹窗。
CustomDialogView引入实例并注册事件:
// TaskDialogView.ets
targetSettingDialog: CustomDialogController = new CustomDialogController({ builder: TargetSettingDialog(),autoCancel: true,alignment: DialogAlignment.Bottom,offset: { dx: Const.ZERO, dy: Const.MINUS_20 }
});
...// 注册事件
this.broadCast.on(BroadCastType.SHOW_TARGET_SETTING_DIALOG, () => {this.targetSettingDialog.open();
})
点击对应的编辑项进行触发:
// TaskDetailComponent.ets
.onClick(() => {this.broadCast.emit(BroadCastType.SHOW_TARGET_SETTING_DIALOG);
})
自定义弹窗的实现:
任务目标设置的弹窗较为特殊,故单独拿出来说明。
因为任务目标设置有三种类型:
- 早睡早起的时间
- 喝水的量度
- 吃苹果的个数
如下图所示:
故根据任务的ID进行区分,将同一弹窗复用:
// TaskSettingDialog.ets
if ([taskType.getup, taskType.sleepEarly].indexOf(this.settingParams?.taskID) > Const.HAS_NO_INDEX) {TimePicker({selected: new Date(`${new Date().toDateString()} 8:00:00`),}).height(Const.THOUSANDTH_800).useMilitaryTime(true).onChange((value: TimePickerResult) => {this.currentTime = formatTime(value);})
} else {TextPicker({ range: this.settingParams?.taskID === taskType.drinkWater ? this.drinkRange : this.appleRange }).width(Const.THOUSANDTH_900,).height(Const.THOUSANDTH_800,).onChange((value) => {this.currentValue = value?.split(' ')[0];})
}
弹窗确认的时候将修改好的值赋予该项设置,如不符合规则,将弹出提示:
// TaskSettingDialog.ets
// 校验规则
compareTime(startTime: string, endTime: string) {if (returnTimeStamp(this.currentTime) < returnTimeStamp(startTime) ||returnTimeStamp(this.currentTime) > returnTimeStamp(endTime)) {prompt.showToast({message: Const.CHOOSE_TIME_OUT_RANGE})return false;}return true;
}
// 设置修改项
setTargetValue() {if (this.settingParams?.taskID === taskType.getup) {if (!this.compareTime(Const.GET_UP_EARLY_TIME, Const.GET_UP_LATE_TIME)) {return;}this.settingParams.targetValue = this.currentTime;return;}if (this.settingParams?.taskID === taskType.sleepEarly) {if (!this.compareTime(Const.SLEEP_EARLY_TIME, Const.SLEEP_LATE_TIME)) {return;}this.settingParams.targetValue = this.currentTime;return;}this.settingParams.targetValue = this.currentValue;
}
其余弹窗实现基本类似,这里不再赘述。
后台代理提醒
健康生活App中提供了任务提醒功能,我们用系统提供的后台代理提醒reminderAgent接口完成相关的开发。
说明: 后台代理提醒接口需要在module.json5中申请ohos.permission.PUBLISH_AGENT_REMINDER权限,代码如下:
// module.json5
"requestPermissions": [{"name": "ohos.permission.PUBLISH_AGENT_REMINDER"}
]
后台代理提醒entry\src\main\ets\service\ReminderAgent.ts文件中提供了发布提醒任务、查询提醒任务、删除提醒任务三个接口供任务编辑页面调用,跟随任务提醒的开关增加、更改、删除相关后台代理提醒,代码如下:
// ReminderAgent.ets
import reminderAgent from '@ohos.reminderAgentManager';
import notification from '@ohos.notificationManager';
import preferences from '@ohos.data.preferences';
import Logger from '../common/utils/Logger';
import { CommonConstants as Const } from '../common/constants/CommonConstants';
import ReminderInfo from '../viewmodel/ReminderInfo';
import PublishReminderInfo from '../viewmodel/PublishReminderInfo';// 发布提醒
function publishReminder(params: PublishReminderInfo, context: Context) {if (!params) {Logger.error(Const.REMINDER_AGENT_TAG, 'publishReminder params is empty');return;}let notifyId: string = params.notificationId.toString();hasPreferencesValue(context, notifyId, (preferences: preferences.Preferences, hasValue: boolean) => {if (hasValue) {preferences.get(notifyId, -1, (error: Error, value: preferences.ValueType) => {if (typeof value !== 'number') {return;}if (value >= 0) {reminderAgent.cancelReminder(value).then(() => {processReminderData(params, preferences, notifyId);}).catch((err: Error) => {Logger.error(Const.REMINDER_AGENT_TAG, `cancelReminder err: ${err}`);});} else {Logger.error(Const.REMINDER_AGENT_TAG, 'preferences get value error ' + JSON.stringify(error));}});} else {processReminderData(params, preferences, notifyId);}});
}// 取消提醒
function cancelReminder(reminderId: number, context: Context) {if (!reminderId) {Logger.error(Const.REMINDER_AGENT_TAG, 'cancelReminder reminderId is empty');return;}let reminder: string = reminderId.toString();hasPreferencesValue(context, reminder, (preferences: preferences.Preferences, hasValue: boolean) => {if (!hasValue) {Logger.error(Const.REMINDER_AGENT_TAG, 'cancelReminder preferences value is empty');return;}getPreferencesValue(preferences, reminder);});
}// 可通知ID
function hasNotificationId(params: number) {if (!params) {Logger.error(Const.REMINDER_AGENT_TAG, 'hasNotificationId params is undefined');return;}return reminderAgent.getValidReminders().then((reminders) => {if (!reminders.length) {return false;}let notificationIdList: Array<number> = [];for (let i = 0; i < reminders.length; i++) {let notificationId = reminders[i].notificationId;if (notificationId) {notificationIdList.push(notificationId);}}const flag = notificationIdList.indexOf(params);return flag === -1 ? false : true;});
}function hasPreferencesValue(context: Context, hasKey: string, callback: Function) {let preferencesPromise = preferences.getPreferences(context, Const.H_STORE);preferencesPromise.then((preferences: preferences.Preferences) => {preferences.has(hasKey).then((hasValue: boolean) => {callback(preferences, hasValue);});});
}// 进程提醒数据
function processReminderData(params: PublishReminderInfo, preferences: preferences.Preferences, notifyId: string) {let timer = fetchData(params);reminderAgent.publishReminder(timer).then((reminderId: number) => {putPreferencesValue(preferences, notifyId, reminderId);}).catch((err: Error) => {Logger.error(Const.REMINDER_AGENT_TAG, `publishReminder err: ${err}`);});
}// 获取数据
function fetchData(params: PublishReminderInfo): reminderAgent.ReminderRequestAlarm {return {reminderType: reminderAgent.ReminderType.REMINDER_TYPE_ALARM,hour: params.hour || 0,minute: params.minute || 0,daysOfWeek: params.daysOfWeek || [],wantAgent: {pkgName: Const.PACKAGE_NAME,abilityName: Const.ENTRY_ABILITY},title: params.title || '',content: params.content || '',notificationId: params.notificationId || -1,slotType: notification.SlotType.SOCIAL_COMMUNICATION}
}function putPreferencesValue(preferences: preferences.Preferences, putKey: string, putValue: number) {preferences.put(putKey, putValue).then(() => {preferences.flush();}).catch((error: Error) => {Logger.error(Const.REMINDER_AGENT_TAG, 'preferences put value error ' + JSON.stringify(error));});
}function getPreferencesValue(preferences: preferences.Preferences, getKey: string) {preferences.get(getKey, -1).then((value: preferences.ValueType) => {if (typeof value !== 'number') {return;}if (value >= 0) {reminderAgent.cancelReminder(value).then(() => {Logger.info(Const.REMINDER_AGENT_TAG, 'cancelReminder promise success');}).catch((err: Error) => {Logger.error(Const.REMINDER_AGENT_TAG, `cancelReminder err: ${err}`);});}}).catch((error: Error) => {Logger.error(Const.REMINDER_AGENT_TAG, 'preferences get value error ' + JSON.stringify(error));});
}const reminder = {publishReminder: publishReminder,cancelReminder: cancelReminder,hasNotificationId: hasNotificationId
} as ReminderInfoexport default reminder;
实现打卡功能
首页会展示当前用户已经开启的任务列表,每条任务会显示对应的任务名称以及任务目标、当前任务完成情况。用户只可对当天任务进行打卡操作,用户可以根据需要对任务列表中相应的任务进行点击打卡。如果任务列表中的每个任务都在当天完成则为连续打卡一天,连续打卡多天会获得成就徽章。打卡效果如下图所示:
任务列表
使用List组件展示用户当前已经开启的任务,每条任务对应一个TaskCard组件,clickAction包装了点击和长按事件,用户点击任务卡时会触发弹起打卡弹窗,从而进行打卡操作;长按任务卡时会跳转至任务编辑界面,对相应的任务进行编辑处理。代码如下:
// HomeComponent.ets
// 任务列表
ForEach(this.homeStore.getTaskListOfDay(), (item: TaskInfo) => {TaskCard({taskInfoStr: JSON.stringify(item),clickAction: (isClick: boolean) => this.taskItemAction(item, isClick)}).margin({ bottom: Const.DEFAULT_12 }).height($r('app.float.default_64'))
}, (item: TaskInfo) => JSON.stringify(item))
...
CustomDialogView() // 自定义弹窗中间件
自定义弹窗中间件CustomDialogView
在组件CustomDialogView的aboutToAppear生命周期中注册SHOW_TASK_DETAIL_DIALOG的事件回调方法 ,当通过emit触发此事件时即触发回调方法执行。代码如下:
// CustomDialogView.ets
export class CustomDialogCallback {confirmCallback: Function = () => {};cancelCallback: Function = () => {};
}@Component
export struct CustomDialogView {@State isShow: boolean = false;@Provide achievementLevel: number = 0;@Consume broadCast: BroadCast;@Provide currentTask: TaskInfo = TaskItem;@Provide dialogCallBack: CustomDialogCallback = new CustomDialogCallback();// 成就对话框achievementDialog: CustomDialogController = new CustomDialogController({builder: AchievementDialog(),autoCancel: true,customStyle: true});// 任务时钟对话框taskDialog: CustomDialogController = new CustomDialogController({builder: TaskDetailDialog(),autoCancel: true,customStyle: true});aboutToAppear() {Logger.debug('CustomDialogView', 'aboutToAppear');// 成就对话框this.broadCast.on(BroadCastType.SHOW_ACHIEVEMENT_DIALOG, (achievementLevel: number) => {Logger.debug('CustomDialogView', 'SHOW_ACHIEVEMENT_DIALOG');this.achievementLevel = achievementLevel;this.achievementDialog.open();});// 任务时钟对话框this.broadCast.on(BroadCastType.SHOW_TASK_DETAIL_DIALOG,(currentTask: TaskInfo, dialogCallBack: CustomDialogCallback) => {Logger.debug('CustomDialogView', 'SHOW_TASK_DETAIL_DIALOG');this.currentTask = currentTask || TaskItem;this.dialogCallBack = dialogCallBack;this.taskDialog.open();});}aboutToDisappear() {Logger.debug('CustomDialogView', 'aboutToDisappear');}build() {}
}
点击任务卡片
点击任务卡片会emit触发 “SHOW_TASK_DETAIL_DIALOG” 事件,同时把当前任务,以及确认打卡回调方法传递下去。代码如下:
// HomeComponent.ets
// 任务卡片事件
taskItemAction(item: TaskInfo, isClick: boolean): void {...if (isClick) {// 点击任务打卡let callback: CustomDialogCallback = { confirmCallback: (taskTemp: TaskInfo) => {this.onConfirm(taskTemp)}, cancelCallback: () => {} };// 触发弹出打卡弹窗事件 并透传当前任务参数(item) 以及确认打卡回调this.broadCast.emit(BroadCastType.SHOW_TASK_DETAIL_DIALOG, [item, callback]);} else {// 长按编辑任务...}
}
// 确认打卡
onConfirm(task) {this.homeStore.taskClock(task).then((res: AchievementInfo) => {// 打卡成功后 根据连续打卡情况判断是否 弹出成就勋章 以及成就勋章级别if (res.showAchievement) {// 触发弹出成就勋章SHOW_ACHIEVEMENT_DIALOG 事件, 并透传勋章类型级别let achievementLevel = res.achievementLevel;if (achievementLevel) {this.broadCast.emit(BroadCastType.SHOW_ACHIEVEMENT_DIALOG, achievementLevel);} else {this.broadCast.emit(BroadCastType.SHOW_ACHIEVEMENT_DIALOG);}}})
}
打卡弹窗组件TaskDetailDialog
打卡弹窗组件根据当前任务的ID获取任务名称以及弹窗背景图片资源。
打卡弹窗组件由两个小组件构成,代码如下:
// TaskDetailDialog.ets
Column() {// 展示任务的基本信息TaskBaseInfo({taskName: TaskMapById[this.currentTask?.taskID - 1].taskName // 根据当前任务ID获取任务名称});// 打卡功能组件 (任务打卡、关闭弹窗)TaskClock({confirm: () => {this.dialogCallBack.confirmCallback(this.currentTask);this.controller.close();},cancel: () => {this.controller.close();},showButton: this.showButton})
}
...
TaskBaseInfo组件代码如下:
// TaskDetailDialog.ets
@Component
struct TaskBaseInfo {taskName: string | Resource = '';build() {Column({ space: Const.DEFAULT_8 }) {Text(this.taskName).fontSize($r('app.float.default_22')).fontWeight(FontWeight.Bold).fontFamily($r('app.string.HarmonyHeiTi_Bold')).taskTextStyle().margin({left: $r('app.float.default_12')})}.position({ y: $r('app.float.default_267') })}
}
TaskClock组件代码如下:
// TaskDetailDialog.ets
@Component
struct TaskClock {confirm: Function = () => {};cancel: Function = () => {};showButton: boolean = false;build() {Column({ space: Const.DEFAULT_12 }) {Button() {Text($r('app.string.clock_in')).height($r('app.float.default_42')).fontSize($r('app.float.default_20')).fontWeight(FontWeight.Normal).textStyle()}.width($r('app.float.default_220')).borderRadius($r('app.float.default_24')).backgroundColor('rgba(255,255,255,0.40)').onClick(() => {GlobalContext.getContext().setObject('taskListChange', true);this.confirm();}).visibility(!this.showButton ? Visibility.None : Visibility.Visible)Text($r('app.string.got_it')).fontSize($r('app.float.default_14')).fontWeight(FontWeight.Regular).textStyle().onClick(() => {this.cancel();})}}
}
打卡接口调用
// HomeViewModel.ets
public async taskClock(taskInfo: TaskInfo) {let taskItem = await this.updateTask(taskInfo);let dateStr = this.selectedDayInfo?.dateStr;// 更新任务失败if (!taskItem) {return {achievementLevel: 0,showAchievement: false} as AchievementInfo;}// 更新当前时间的任务列表this.selectedDayInfo.taskList = this.selectedDayInfo.taskList.map((item) => {return item.taskID === taskItem?.taskID ? taskItem : item;});let achievementLevel: number = 0;if(taskItem.isDone) {// 更新每日任务完成情况数据let dayInfo = await this.updateDayInfo();... // 当日任务完成数量等于总任务数量时 累计连续打卡一天// 更新成就勋章数据 判断是否弹出获得勋章弹出及勋章类型if (dayInfo && dayInfo?.finTaskNum === dayInfo?.targetTaskNum) {achievementLevel = await this.updateAchievement(this.selectedDayInfo.dayInfo);}}...return {achievementLevel: achievementLevel,showAchievement: ACHIEVEMENT_LEVEL_LIST.includes(achievementLevel)} as AchievementInfo;
}
// HomeViewModel.ets
// 更新当天任务列表
updateTask(task: TaskInfo): Promise<TaskInfo> {return new Promise((resolve, reject) => {let taskID = task.taskID;let targetValue = task.targetValue;let finValue = task.finValue;let updateTask = new TaskInfo(task.id, task.date, taskID, targetValue, task.isAlarm, task.startTime,task.endTime, task.frequency, task.isDone, finValue, task.isOpen);let step = TaskMapById[taskID - 1].step; // 任务步长let hasExceed = updateTask.isDone;if (step === 0) { // 任务步长为0 打卡一次即完成该任务updateTask.isDone = true; // 打卡一次即完成该任务updateTask.finValue = targetValue;} else {let value = Number(finValue) + step; // 任务步长非0 打卡一次 步长与上次打卡进度累加updateTask.isDone = updateTask.isDone || value >= Number(targetValue); // 判断任务是否完成updateTask.finValue = updateTask.isDone ? targetValue : `${value}`;}TaskInfoTableApi.updateDataByDate(updateTask, (res: number) => { // 更新数据库if (!res || hasExceed) {Logger.error('taskClock-updateTask', JSON.stringify(res));reject(res);}resolve(updateTask);})})
}
为了帮助大家更深入有效的学习到鸿蒙开发知识点,小编特意给大家准备了一份全套最新版的HarmonyOS NEXT学习资源,获取完整版方式请点击→《HarmonyOS教学视频》
HarmonyOS教学视频:语法ArkTS、TypeScript、ArkUI等…视频教程
鸿蒙生态应用开发白皮书V2.0PDF:
获取完整版白皮书方式请点击→《鸿蒙生态应用开发白皮书V2.0PDF》
鸿蒙 (Harmony OS)开发学习手册
一、入门必看
- 应用开发导读(ArkTS)
- .……
二、HarmonyOS 概念
- 系统定义
- 技术架构
- 技术特性
- 系统安全
- …
三、如何快速入门?《鸿蒙基础入门学习指南》
- 基本概念
- 构建第一个ArkTS应用
- .……
四、开发基础知识
- 应用基础知识
- 配置文件
- 应用数据管理
- 应用安全管理
- 应用隐私保护
- 三方应用调用管控机制
- 资源分类与访问
- 学习ArkTS语言
- .……
五、基于ArkTS 开发
- Ability开发
- UI开发
- 公共事件与通知
- 窗口管理
- 媒体
- 安全
- 7.网络与链接
- 电话服务
- 数据管理
- 后台任务(Background Task)管理
- 设备管理
- 设备使用信息统计
- DFX
- 国际化开发
- 折叠屏系列
- .……
更多了解更多鸿蒙开发的相关知识可以参考:《鸿蒙 (Harmony OS)开发学习手册》