健康生活应用(ArkTS)

介绍

本篇Codelab介绍了如何实现一个简单的健康生活应用,主要功能包括:

  1. 用户可以创建最多6个健康生活任务(早起,喝水,吃苹果,每日微笑,刷牙,早睡),并设置任务目标、是否开启提醒、提醒时间、每周任务频率。
  2. 用户可以在主页面对设置的健康生活任务进行打卡,其中早起、每日微笑、刷牙和早睡只需打卡一次即可完成任务,喝水、吃苹果需要根据任务目标量多次打卡完成。
  3. 主页可显示当天的健康生活任务完成进度,当天所有任务都打卡完成后,进度为100%,并且用户的连续打卡天数加一。
  4. 当用户连续打卡天数达到3、7、30、50、73、99天时,可以获得相应的成就。成就在获得时会以动画形式弹出,并可以在“成就”页面查看。
  5. 用户可以查看以前的健康生活任务完成情况。
  6. 用户可通过长按添加2x2或2x4卡片查看任务完成情况,具体ArkTS卡片实现可以参考文档:健康生活卡片(ArkTS)

相关概念

  • AppStorage:应用程序中的单例对象,为应用程序范围内的可变状态属性提供中央存储。
  • @Observed和@ObjectLink:@Observed适用于类,表示类中的数据变化由UI页面管理;@ObjectLink应用于被@Observed装饰类的对象。
  • @Provide和@Consume:@Provide作为数据提供者,可以更新子节点的数据,触发页面渲染。@Consume检测到@Provide数据更新后,会发起当前视图的重新渲染。
  • Flex:一个功能强大的容器组件,支持横向布局,竖向布局,子组件均分和流式换行布局。
  • List:List是很常用的滚动类容器组件之一,它按照水平或者竖直方向线性排列子组件, List的子组件必须是ListItem,它的宽度默认充满List的宽度。
  • TimePicker:TimePicker是选择时间的滑动选择器组件,默认以00:00至23:59的时间区创建滑动选择器。
  • Toggle:组件提供勾选框样式、状态按钮样式及开关样式。
  • 关系型数据库(Relational Database,RDB):一种基于关系模型来管理数据的数据库。
  • 首选项:首选项为应用提供Key-Value键值型的数据处理能力,支持应用持久化轻量级数据,并对其修改和查询。
  • 后台代理提醒:后台代理提醒功能主要提供后台提醒通知发布接口,开发者可调用这些接口创建定时提醒,包括倒计时、日历、闹钟三种提醒类型。
  • ArkTS卡片:卡片框架的运作机制分三大模块:卡片使用方、卡片管理服务和卡片提供方。 
    • 卡片使用方:负责卡片的创建、删除、请求更新以及卡片服务通信。
    • 卡片管理服务:负责卡片的周期性刷新、卡片缓存管理、卡片生命周期管理以及卡片使用对象管理。
    • 卡片提供方:提供卡片显示内容的应用,控制卡片的显示内容、控件布局以及控件点击事件。

相关权限

本篇Codelab用到了任务提醒功能,需要在配置文件module.json5里添加后台代理提醒权限:ohos.permission.PUBLISH_AGENT_REMINDER。

完整示例

gitee源码地址

源码下载

健康生活应用(ArkTS).zip

环境搭建

我们首先需要完成HarmonyOS开发环境搭建,可参照如图步骤进行。

软件要求

硬件要求

  • 设备类型:华为手机或运行在DevEco Studio上的华为手机设备模拟器。
  • HarmonyOS系统:3.1.0 Developer Release版本。

环境搭建

  1. 安装DevEco Studio,详情请参考下载和安装软件
  2. 设置DevEco Studio开发环境,DevEco Studio开发环境需要依赖于网络环境,需要连接上网络才能确保工具的正常使用,可以根据如下两种情况来配置开发环境: 
    • 如果可以直接访问Internet,只需进行下载HarmonyOS SDK操作。
    • 如果网络不能直接访问Internet,需要通过代理服务器才可以访问,请参考配置开发环境
  3. 开发者可以参考以下链接,完成设备调试的相关配置: 

代码结构解读

本篇Codelab只对核心代码进行讲解,对于完整代码,我们会在源码下载或gitee中提供。

├──entry/src/main/ets                 // 代码区
│  ├──agency                          // 2x4 ArkTS卡片目录
│  │  └──pages
│  │     └──AgencyCard.ets            // 2x4 ArkTS卡片任务
│  ├──common
│  │  ├──constants
│  │  │  └──CommonConstants.ets       // 公共常量
│  │  ├──database
│  │  │  ├──rdb                       // 数据库封装类
│  │  │  │  ├──RdbHelper.ets
│  │  │  │  ├──RdbHelperImp.ets
│  │  │  │  ├──RdbUtils.ets
│  │  │  │  └──TableHelper.ets
│  │  │  └──tables                    // 数据表
│  │  │     ├──DayInfoApi.ets
│  │  │     ├──FormInfoApi.ets
│  │  │     ├──GlobalInfoApi.ets
│  │  │     └──TaskInfoApi.ets
│  │  └──utils
│  │     ├──BroadCast.ets             // 通知
│  │     ├──FormUtils.ets             // 卡片操作工具类
│  │     ├──GlobalContext.ets         
│  │     ├──HealthDataSrcMgr.ets      // 数据管理单例
│  │     ├──Logger.ets                // 日志类
│  │     └──Utils.ets                 // 工具类
│  ├──entryability
│  │  └──EntryAbility.ets             // 程序入口类
│  ├──entryformability
│  │  └──EntryFormAbility.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             // 任务列表页面
│  ├──progress                        // 2x2 ArkTS卡片目录
│  │  └──pages
│  │     └──ProgressCard.ets          // 2x2 ArkTS卡片任务进度
│  ├──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       // 成就勋章信息接口
│     ├──AchievementViewModel.ets     // 成就相关模块
│     ├──AgencyCardInfo.ets           // 2x4卡片信息接口
│     ├──BroadCastCallBackInfo.ets    // 回调相关接口
│     ├──CalendarViewModel.ets        // 日历相关模块
│     ├──CardInfo.ets                 // 卡片信息接口
│     ├──ColumnInfo.ets               // 数据表信息接口
│     ├──CommonConstantsInfo.ets      // 配置信息接口
│     ├──DayInfo.ets                  // 每日信息接口
│     ├──FormInfo.ets                 // 卡片信息接口
│     ├──GlobalInfo.ets               // 全局信息接口
│     ├──HomeViewModel.ets            // 首页相关模块
│     ├──ProgressCardInfo.ets         // 2x2卡片信息接口
│     ├──PublishReminderInfo.ets      // 提醒信息接口
│     ├──ReminderInfo.ets             // 提醒操作接口
│     ├──TaskInfo.ets                 // 任务信息接口
│     ├──TaskViewModel.ets            // 任务设置相关模块
│     ├──WeekCalendarInfo.ets         // 日期信息接口
│     └──WeekCalendarMethodInfo.ets   // 日期操作接口
└──entry/src/main/resources           // 资源文件目录

应用架构分析

本应用的基本架构如右图所示,数据库为其他服务提供基础的用户数据,主要业务包括:用户可以查看和编辑自己的健康任务并进行打卡、查看成就。UI层提供了承载上述业务的UI界面。

5.1 启动页

本节给应用添加一个启动页,启动页里需要用到定时器来实现启动页展示固定时间后跳转应用主页的功能。具体实现逻辑是:

  1. 通过修改entryability里的loadContent路径可以改变应用的入口文件,此处改为SplashPage。
  2. 在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.ets
import common from '@ohos.app.ability.common';
import data_preferences from '@ohos.data.preferences';

@Entry
@Component
struct SplashIndex {
  context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
  
  onConfirm() {
    let preferences = data_preferences.getPreferences(that.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();
        }
      });
    });
  }

  build() {
    ...
  }
}

5.2 应用入口

我们需要给APP添加底部菜单栏,用于切换不同的应用模块,由于各个模块之间属于完全独立的情况,并且不需要每次切换都进行界面的刷新,所以我们用到了Tabs,TabContent组件。

本应用一共有首页(HomeIndex),成就(AchievementIndex)和我的(MineIndex)三个模块,分别对应Tabs组件的三个子组件TabContent。

 
// MainPage.ets
Tabs({ barPosition: BarPosition.End, controller: this.tabController }) {
  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()
  }
  .tabBar(this.TabBuilder(TabId.MINE))
}

5.3 首页

首页包含了任务信息的所有入口,包含任务列表的展示,任务的编辑和新增,上下滚动的过程中顶部导航栏的渐变,日期的切换以及随着日期切换界面任务列表跟着同步的功能。具体代码实现我们将在下边分模块进行说明。

  • 导航栏背景渐变

Scroll滚动的过程中,在它的onScrollAction()方法里我们通过计算它Y轴的偏移量来改变当前界面的@State修饰的naviAlpha变量值,进而改变顶部标题的背景色。

// HomeComponent.ets
// 视图滚动的过程中处理导航栏的透明度
@State naviAlpha: number = 0;
...
onScrollAction() {  
  this.yOffset = this.scroller.currentOffset().yOffset;  
  if (this.yOffset > Const.DEFAULT_56) {    
    this.naviAlpha = 1;  
  } else {    
    this.naviAlpha = this.yOffset / Const.DEFAULT_56;  
  }
}
  •  日历组件

日历组件主要用到的是一个横向滑动的Scroll组件。手动滑动页面时,通过在onScrollEndAction()方法里计算Scroll的偏移量来实现分页的效果,同时Scroll有提供scrollPage()方法可供我们点击左右按钮的时候来进行页面切换。需要在Scroll滑动到左边边缘的时候去请求更多的历史数据以便Scroll能一直滑动,通过Scroll的onScrollEdge方法可以判断它是否已滑到边缘位置。homeStore主要是请求数据库的数据并对数据进行处理进而渲染到界面上。同时还需要知道怎么根据当天的日期计算出本周内的所有日期数据。

// WeekCalendarComponent.ets
build() {    
  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))            
          })          
         }       
        }               
        .onScrollStop(() => this.onScrollEndAction())        
        .onScrollEdge((event) => this.onScrollEdgeAction(event))      
      }
    }
  }
}

// WeekCalendarComponent.ets
// scroll滚动停止时通过判断偏移量进行分页处理
onScrollEndAction() {
  // 区分是否是手动滑动,点击左右箭头按钮导致Scroll滑动时不作处理,不然会引起死循环
  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;
    let dayModel: WeekDateModel = this.homeStore.dateArr[Const.WEEK_DAY_NUM * page + this.homeStore.selectedDay];
    this.homeStore!.setSelectedShowDate(dayModel!.date!.getTime());
  }
  this.isPageScroll = false;
}

// 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);
      that.homeStore.getPreWeekData(date, () => {});
      this.isLoadMore = true;
    }
  }
}

// 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 {
  let weekCalendarInfo: WeekCalendarInfo = new WeekCalendarInfo();
  let arr: Array<WeekDateModel> = [];
  let strArr: Array<string> = []; 

  // 由于date的getDay()方法返回的是0-6代表周日到周六,我们界面上展示的周一-周日为一周,所以这里要将getDay()数据偏移一天
  let currentDay = showDate.getDay() - 1;

  // 将日期设置为当前周第一天的数据(周一)
  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));
  }
  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'))
    }
    .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'))
}
  • 界面跳转及传参

首页任务列表长按时需要跳转到对应的任务编辑界面,同时点击悬浮按钮时需要跳转到任务列表页面。页面跳转需要在头部引入router。

// HomeComponent.ets
import router from '@ohos.router';

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);
    editTask.targetValue = item?.targetValue;
    editTask.isAlarm = item.isAlarm;
    editTask.startTime = item.startTime;
    editTask.frequency = item.frequency;
    editTask.isOpen = item.isOpen;
    router.pushUrl({ url: 'pages/TaskEditPage', params: { params: JSON.stringify(editTask) } });
  }
}

6.1 功能概述

用户点击悬浮按钮进入任务列表页,点击任务列表可进入对应任务编辑的页面中,对任务进行详细的设置,之后点击完成按钮编辑任务后将返回首页。实现效果如图所示。

6.2 任务列表

  • 任务列表页

任务列表页由上部分的标题、返回按钮以及正中间的任务列表组成。使用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
List({ space: Const.LIST_ITEM_SPACE }) {
  ForEach(this.taskList, (item: ITaskItem) => {
    ListItem() {
      Row() {
        Row() {
          Image(item?.icon)
          Text(item?.taskName)
            ...
        }
        .width(Const.THOUSANDTH_500)

        Blank()
          .layoutWeight(1)

        // 状态显示
        if (item?.isOpen) {
          Text($r('app.string.already_open'))
        }
        Image($r('app.media.ic_right_grey'))
          .width(Const.DEFAULT_8)
          .height(Const.DEFAULT_16)

      }
      ...
    }
    ...

    // 路由跳转到任务编辑页
    .onClick(() => {
      router.pushUrl({
        url: 'pages/TaskEditPage',
        params: {
          params: formatParams(item)
        }
      })
    })
    ...
  })
}

6.3 任务编辑

  • 任务编辑页

任务编辑页由上方的“编辑任务”标题以及返回按钮,主体内容的List配置项和下方的完成按钮组成。任务编辑页面,由Navigation和一个自定义组件TaskDetail构成。自定义组件由List以及其子组件ListItem构成,实现效果如图所示:

// 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)

// TaskDetailComponent.ets
List({ space: Const.LIST_ITEM_SPACE }) {
  ListItem() {
    TaskChooseItem()
  }
  .listItemStyle()

  ListItem() {
    TargetSetItem()
  }
  .listItemStyle()
  // 一些特殊情况的禁用,如每日微笑、每日刷牙的目标设置不可编辑
  .enabled( 
    this.settingParams?.isOpen
    && this.settingParams?.taskID !== taskType.smile
    && this.settingParams?.taskID !== taskType.brushTeeth
  )
  .onClick(() => {
    this.broadCast.emit(
      BroadCastType.SHOW_TARGET_SETTING_DIALOG);
  })

  ListItem() {
    OpenRemindItem()
  }
  .listItemStyle()
  // 其中做了禁用判断,需要任务打开才可以点击编辑
  .enabled(this.settingParams?.isOpen)

  ListItem() {
    RemindTimeItem()
  }
  .listItemStyle()
  // 提醒时间在开启提醒打开之后才可以编辑
  .enabled(this.settingParams?.isOpen && this.settingParams?.isAlarm)
  .onClick(() => {
    this.broadCast.emit(BroadCastType.SHOW_REMIND_TIME_DIALOG);
  })

  ListItem() {
    FrequencyItem()
  }
  .listItemStyle()
  .enabled(this.settingParams?.isOpen && this.settingParams?.isAlarm)
  .onClick(() => {
    this.broadCast.emit(BroadCastType.SHOW_FREQUENCY_DIALOG);
  })
}
.width(Const.THOUSANDTH_940)

// TaskDetailComponent.ets
addTask({
  // 相关参数
  ...
})
.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组件内注册打开弹窗的事件,当点击对应任务的编辑项时触发该事件,进而打开弹窗。

// 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_TARGETSETTING_DIALOG, () => {
  this.targetSettingDialog.open();
})

// HomeComponent.ets
taskItemAction(item: TaskInfo, isClick: boolean): void {
  ...
  if (isClick) {
    let callback: CustomDialogCallback ={ confirmCallback: (taskTemp: TaskInfo) => { this.onConfirm(taskTemp) }, cancelCallback: () => {} };
    this.broadCast.emit(BroadCastType.SHOW_TASK_DETAIL_DIALOG, [item, callback]);
  } else {
    ...
  }
}

任务目标设置有三种类型,早睡早起的时间、喝水的量度、吃苹果的个数。故根据任务的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`),
  })
  ...
} else {
  TextPicker({ 
    range: this.settingParams?.taskID === taskType.drinkWater ? 
      this.drinkRange : 
      this.appleRange 
  })
  ...
}

// TaskSettingDialog.ets
// 校验规则
compareTime(startTime: string, endTime: string) {
  if (returnTimeStamp(this.currentTime) < returnTimeStamp(startTime) || 
    returnTimeStamp(this.currentTime) > returnTimeStamp(endTime)) {
    // 弹出提示
    prompt.showToast({
      message: commonConst.CHOOSE_TIME_OUT_RANGE
    })
    return false;
  }
  return true;
}

// 设置修改项
setTargetValue() {
  ...
  if (this.settingParams?.taskID === taskType.sleepEarly) {
    if (!this.compareTime(commonConst.SLEEP_EARLY_TIME, commonConst.SLEEP_LATE_TIME)) {
      return;
    }
    this.settingParams.targetValue = this.currentTime;
    return;
  }
  this.settingParams.targetValue = this.currentValue;
}

7.1 任务列表

首页会展示当前用户已经开启的任务列表,每条任务会显示对应的任务名称以及任务目标、当前任务完成情况。用户只可对当天任务进行打卡操作,用户可以根据需要对任务列表中相应的任务进行点击打卡。如果任务列表中的每个任务都在当天完成则为连续打卡一天,连续打卡多天会获得成就徽章。打卡效果如图所示:

使用List组件展示用户当前已经开启的任务,每条任务对应一个TaskCard组件,clickAction包装了点击和长按事件,用户点击任务卡时会触发弹起打卡弹窗,从而进行打卡操作;长按任务卡时会跳转至任务编辑界面,对相应的任务进行编辑处理。

// HomeComponent.ets
Column({ space: Const.DEFAULT_8 }) {
  ForEach(this.homeStore.getTaskListOfDay(), (item: TaskInfo) => {
    TaskCard({
      taskInfoStr: JSON.stringify(item),
      clickAction: (isClick: boolean) => this.taskItemAction(item, isClick)
    })
  }, (item: TaskInfo) => JSON.stringify(item))
}
...
CustomDialogView() // 自定义弹窗中间件
  • 自定义弹窗中间件

在组件CustomDialogView的aboutToAppear生命周期中注册SHOW_TASK_DETAIL_DIALOG的事件回调方法 ,当通过emit触发此事件时即触发回调方法执行。

// CustomDialogView.ets
@Component
export struct CustomDialogView {
  @Consume broadCast: BroadCast;
  @Provide currentTask: TaskInfo = TaskItem;
  @Provide dialogCallBack: CustomDialogCallback = new CustomDialogCallback();

  // 任务打卡弹窗
  taskDialog: CustomDialogController = new CustomDialogController({
    builder: TaskDetailDialog(),
    autoCancel: true,
    customStyle: true
  });

  aboutToAppear() {
    // 任务打卡弹窗  注册 “SHOW_TASK_DETAIL_DIALOG” 事件回调
    this.broadCast.on(BroadCastType.SHOW_TASK_DETAIL_DIALOG, 
      (currentTask: TaskInfo, dialogCallBack: CustomDialogCallback) => {
      // 接收当前任务参数 以Provide Consume方式向子组件透传
      this.currentTask = currentTask || TaskItem;
      // 接收当前任务确认打卡回调 以Provide Consume方式向子组件透传
      this.dialogCallBack = dialogCallBack;
      this.taskDialog.open();
    });
  }
  ...
}
  • 点击任务卡片

点击任务卡片会emit触发 “SHOW_TASK_DETAIL_DIALOG” 事件,同时把当前任务,以及确认打卡回调方法传递下去。

// 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 {
    // 长按编辑任务
    ...
}

// 确认打卡
onConfirm(task: TaskInfo) {
  this.homeStore.taskClock(task).then((res: AchievementInfo) => {
    // 打卡成功后,根据连续打卡情况判断是否弹出成就勋章以及成就勋章级别
    if (res.showAchievement) {
      let achievementLevel = res.achievementLevel;
      // 触发弹出成就勋章SHOW_ACHIEVEMENT_DIALOG 事件, 并透传勋章类型级别
      if (achievementLevel) {
        this.broadCast.emit(BroadCastType.SHOW_ACHIEVEMENT_DIALOG, achievementLevel);
      } else {
        this.broadCast.emit(BroadCastType.SHOW_ACHIEVEMENT_DIALOG);
      }
    }
  })
}

7.2 打卡弹窗组件

弹窗组件由两个组件构成(TaskBaseInfo、TaskClock)会根据当前任务的ID获取任务名称以及弹窗背景图片资源。

// TaskDetailDialog.ets
Column() {
  // 展示任务的基本信息
  TaskBaseInfo({
    // 根据当前任务ID获取任务名称
    taskName: TaskMapById[this.currentTask?.taskID - 1].taskName
  });
  // 打卡功能组件(任务打卡、关闭弹窗)
  TaskClock({
    confirm: () => {
      // 任务打卡确认回调执行
      this.dialogCallBack.confirmCallback(this.currentTask); 
      this.controller.close();
    },
    cancel: () => {
      this.controller.close();
    },
    showButton: this.showButton
  })
}
...

// TaskDetailDialog.ets
@Component
struct TaskBaseInfo {
  taskName: string | Resource = '';
  build() {
    Column({ space: Const.DEFAULT_8 }) {
      Text(this.taskName)
        ...
    }
    ...
  }
}

// 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')) // 打卡
          ...
      }
      ...
      .onClick(() => {
         GlobalContext.getContext().setObject('taskListChange', true);
         this.confirm();
      })
      .visibility(!this.showButton ? Visibility.None : Visibility.Visible)
      Text($r('app.string.got_it')) // 知道了
        ...
        .onClick(() => {
          this.cancel();
        })
    }
  }
}

7.3 打卡接口调用

打卡成功后同步更新当天任务完成情况数据,以及判断累计打卡天数是否满足获得勋章条件,满足条件会弹出对应勋章,以及在成就页面查看已获得的勋章。

// 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;
}

// 更新当天任务列表
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;
    // 任务步长为0 打卡一次即完成该任务
    if (step === 0) { 
      // 打卡一次即完成该任务
      updateTask.isDone = true;
      updateTask.finValue = targetValue;
    } else {
      // 任务步长非0 打卡一次 步长与上次打卡进度累加
      let value = Number(finValue) + step;
      // 判断任务是否完成
      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);
    });
  })
}

8.1 功能概述

成就页面展示用户可以获取的所有勋章,当用户满足一定的条件时,将点亮本页面对应的勋章,没有得到的成就勋章处于熄灭状态。共有六种勋章,当用户连续完成任务打卡3天、7天、30天、50天、73天、99天时,可以获得对应的 “连续xx天达成”勋章。

8.2 页面组件实现

标题部分TitleBar是一个横向容器Row里包含一个子组件Text。

// TitleBarComponent.ets
@Component
export struct TitleBar {
  build() {
    Row() {
      Text($r('app.string.achievement')) 
      ... // 省略属性设置  
    }.width(Const.FULL_WIDTH)
  }
}

每个勋章卡片BadgeCard是由一个竖向容器Column、一个图片子组件Image、和一个文字子组件Text组成。

// BadgeCardComponent.ets
@Component
export struct BadgeCard {
  @Prop content: string = '';
  imgSrc: Resource = $r('app.string.empty');

  build() { 
    Column({ space: Const.DEFAULT_18 }) {
      Image(this.imgSrc)
      ... // 省略属性设置
      Text($r('app.string.task_achievement_level', Number(this.content))) 
      ... // 省略属性设置
    }
  }
}

整体的勋章面板使用Flex一个组件即可以实现均分和换行的功能。

// BadgePanelComponent.ets
@Component
export struct BadgePanel {
  @StorageProp(ACHIEVEMENT_LEVEL_KEY) successiveDays: number = 0;

  aboutToAppear() {
    getAchievementLevel();
  } 

  build() {
    Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { 
      ForEach(getBadgeCardItems(this.successiveDays), (item: CardInfo) => { 
        BadgeCard({ content: item.titleContent, imgSrc: item.achievement })
      })
    }
    .width(Const.FULL_WIDTH)
  }
}

8.3 获取数据

进入界面第一次获取数据在aboutToAppear()声明周期中从数据库GlobalInfo表中获取存储的勋章数据, 通过@StorageProp装饰器刷新界面,其他的地方只要通过AppStorage更新勋章数据即可。

// BadgePanelComponent.ets
aboutToAppear() {
  getAchievementLevel()
} 
  
// AchieveModel.ets
export function getAchievementLevel() {
  GlobalInfoApi.query((res: GlobalInfo) => {
    ... // 省略数据验证
    if (achievements.length > 0) {
      AppStorage.Set<Number>(ACHIEVEMENT_LEVEL_KEY, Number(achievements[achievements.length - 1]));
    }
  })
}
  
// BadgePanelComponent.ets
@StorageProp(ACHIEVEMENT_LEVEL_KEY) successiveDays: number = 0;

ForEach(getBadgeCardItems(this.successiveDays), (item: CardInfo) => { 
  BadgeCard({ content: item.titleContent, imgSrc: item.achievement } )
})

// AchievementViewModel.ets
export function getBadgeCardItems(successiveDays: number): Array<CardInfo> {
  let badgeMileStones = ACHIEVEMENT_LEVEL_LIST;
  let cardItems: Array<CardInfo> = [];
  for (let i = 0; i < badgeMileStones.length; i++) {
    ... // 省略数据拼装细节
    cardItems.push(cardInfo);
  }
  return cardItems;
}

后台代理提醒

本章节将介绍如何发布提醒任务和取消提醒任务,ReminderAgent文件提供了发布提醒任务、查询提醒任务、取消提醒任务三个接口供任务编辑页面调用。

9.1 发布提醒任务

在编辑任务页面中,开启提醒,选好提醒时间,点击保存,然后通过ReminderAgent.publishReminder方法发布提醒任务。在发布提醒任务之前,判断当前提醒任务Id是否存在,如不存在则通过reminderAgent.publishReminder发布提醒任务,否则先取消提醒任务再发布。

// ReminderAgent.ets
// 发布提醒任务
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 (haseValue) {
      ...
    } else {
      processReminderData(params, preferences, notifyId);
    }
  });
}

// 处理提醒任务数据
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}`);
  });
}

9.2 取消提醒任务

在编辑任务页面中,关闭提醒,点击保存,然后通过ReminderAgent.cancelReminder方法取消当前提醒任务。在取消提醒任务之前,判断当前提醒任务Id是否存在,如果存在则通过reminderAgent.cancelReminder方法取消提醒任务,否则说明当前任务未开启提醒。

// ReminderAgent.ets
// 取消提醒任务
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);
  });
}

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));
  });
}

10.1 创建数据库

要使用关系型数据库存储用户数据,首先要进行数据库的创建,并提供基本的增、删、查、改接口。如图所示,关系型数据库提供两个基本功能:

  1.  获取RdbStore
  2.  封装增、删、改、查接口

  •  获取RdbStore

首先要获取一个RdbStore来操作关系型数据库。

// RdbHelperImp.ets
getRdb(context: Context): Promise<RdbHelper> {
  this.storeConfig = {
    // 配置数据库文件名、安全级别
    name: this.mDatabaseName, securityLevel: dataRdb.SecurityLevel.S1
  };
  return new Promise<RdbHelper>((success, error) => {
    dataRdb.getRdbStore(context, this.storeConfig).then(dbStore => {
      this.rdbStore = dbStore;  // 获取RdbStore
      success(this);
    }).catch((err: Error) => {
      Logger.error(`initRdb err : ${JSON.stringify(err)}`);
      error(err);
    })
  })
}
  •  封装增、删、改、查接口

关系型数据库接口提供的增、删、改、查操作均有callback和Promise两种异步回调方式,本Codelab使用了callback异步回调。

// RdbHelperImp.ets
insert(tableName: string, values: dataRdb.ValuesBucket | Array<dataRdb.ValuesBucket>): Promise<number> {
  return new Promise<number>((success, error) => {
    Logger.info(`insert tableName : ${tableName}, values : ${JSON.stringify(values)}`);
    ...
    if (values instanceof Array) {  // 如果插入一组数据,则批量插入
      Logger.info(`insert values isArray = ${values.length}`);
      this.rdbStore.beginTransaction();
      this.saveArray(tableName, values).then(data => {
        Logger.info(`insert success, data : ${JSON.stringify(data)}`);
        success(data);
        this.rdbStore.commit();
      }).catch((err: Error) => {
        Logger.error(`insert failed, err : ${err}`);
        error(err);
        this.rdbStore.commit();
      })
    } else {
      this.rdbStore.insert(tableName, values).then(data => {  // 调用insert()接口插入数据
        Logger.info(`insert success id : ${data}`);
        success(data);
        this.rdbStore.commit();
      }).catch((err: Error) => {
        Logger.error(`insert failed, err : ${JSON.stringify(err)}`);
        error(err);
        this.rdbStore.commit();
      })
    }
  })
}

// 删除数据使用了delete()接口,实现代码如下
// RdbHelperImp.ets
delete(rdbPredicates: dataRdb.RdbPredicates): Promise<number> {
  Logger.info(`delete rdbPredicates : ${JSON.stringify(rdbPredicates)}`);
  return this.rdbStore.delete(rdbPredicates);
}

// 更新数据使用了update()接口,实现代码如下
// RdbHelperImp.ets
update(values: dataRdb.ValuesBucket, rdbPredicates: dataRdb.RdbPredicates): Promise<number> {
  return this.rdbStore.update(values, rdbPredicates);
}

// 查找数据使用了query()接口,实现代码如下
// RdbHelperImp.ets
query(rdbPredicates: dataRdb.RdbPredicates, columns?: Array<string>): Promise<dataRdb.ResultSet> {
  Logger.info(`query rdbPredicates : ${JSON.stringify(rdbPredicates)}`);
  return this.rdbStore.query(rdbPredicates, columns);
}

10.2 数据表定义

根据健康生活APP的使用场景和业务逻辑,定义了三个数据对象,并使用三张数据表来存储,分别是健康任务信息表、每日信息表和全局信息表。

  • 健康任务信息表

目前健康生活应用提供了6个基本的健康任务,分别是早起、喝水、吃苹果、每日微笑、睡前刷牙和早睡。用户可以选择开启或关闭某个任务,开启的任务可以选择是否开启提醒,在指定的时间段内提醒用户进行打卡。任务也可以选择开启的频率,如只在周一到周五开启等。需要记录每项任务的目标值和实际完成值,在用户打卡后判断任务是否已经完成,并记录在数据库中。因此,需要创建一张存储每天的健康任务信息的表,表头如图所示。

  • 每日信息表

在主页面,用户可以查看当天健康任务的完成进度,需要创建一张表记录当天开启的任务个数和已经完成的任务个数,表头如图所示。

  • 全局信息表

用户连续多日打卡完成所有创建的任务可以获得相应的成就,因此,需要有一张表记录连续打卡天数和已达成的成就项。另外,考虑应用多日未打开的情况,需要记录应用第一次打开的日期和最后一次打开的日期以向数据库回填数据,表头如图所示。

10.3 创建数据表

根据上文设计的表结构,创建对应的数据表,实现对相应数据的读写操作。

  • 健康任务信息数据表

在EntryAbility的onCreate方法中,通过RdbUtils.createTable方法创建相应的表结构和初始化数据。

// EntryAbility.ets
async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
  ...
  RdbUtils.createTable(Const.TASK_INFO.tableName ? Const.TASK_INFO.tableName : '', columnTaskInfoInfoList).then(() => {
    Logger.info(`RdbHelper createTable taskInfo success`);
  }).catch((err: Error) => {
    Logger.error(`RdbHelper taskInfo err : ${JSON.stringify(err)}`);
  });
  ...
}

// CommonConstants.ets
static readonly TASK_INFO = {
  tableName: 'taskInfo',
  columns: [
    'id',
    'date',
    'taskID',
    'targetValue',
    'isAlarm',
    'startTime',
    'endTime',
    'frequency',
    'isDone',
    'finValue',
    'isOpen'
  ]
} as CommonConstantsInfo

// RdbColumnModel.ets
export const columnTaskInfoInfoList: Array<ColumnInfo> = [
  new ColumnInfo('id', 'integer', -1, false, true, true),
  new ColumnInfo('date', 'TEXT', -1, false, false, false),
  new ColumnInfo('taskID', 'integer', -1, false, false, false),
  new ColumnInfo('targetValue', 'text', -1, false, false, false),
  new ColumnInfo('isAlarm', 'boolean', -1, false, false, false),
  new ColumnInfo('startTime', 'text', -1, false, false, false),
  new ColumnInfo('endTime', 'text', -1, false, false, false),
  new ColumnInfo('frequency', 'text', -1, false, false, false),
  new ColumnInfo('isDone', 'boolean', -1, true, false, false),
  new ColumnInfo('finValue', 'text', -1, false, false, false),
  new ColumnInfo('isOpen', 'boolean', -1, true, false, false)
];

// TableHelper.ets
createTableSql(tableName: string, columns: Array<ColumnInfo>): string {
  let sql = `create table if not exists ${tableName}(`;
  for (let column of columns) {
    sql = sql.concat(`${column.name} ${column.type}`);
    sql = sql.concat(`${column.length && column.length > 0 ? `(${column.length})` : ''}`);
    sql = sql.concat(`${column.primary ? ' primary key' : ''}`);
    sql = sql.concat(`${column.autoincrement ? ' autoincrement' : ''}`);
    sql = sql.concat(`${column.nullable ? '' : ' not null'}`);
    sql = sql.concat(', ');
  }
  sql = `${sql.substring(0, sql.length - 2)})`;
  return sql;
}

健康任务信息数据表需要提供插入数据的接口,以在用户当天第一次打开应用时创建当天的健康任务信息。

// TaskInfoApi.ets
insertData(taskInfo: TaskInfo, callback: Function): void {
  // 根据输入数据创建待插入的数据行
  const valueBucket = generateBucket(taskInfo);
  RdbUtils.insert('taskInfo', valueBucket).then((result: number) => {
    callback(result);
  });
  Logger.info('TaskInfoTable', `Insert taskInfo {${taskInfo.date}:${taskInfo.taskID}} finished.`);
}

function generateBucket(taskInfo: TaskInfo): dataRdb.ValuesBucket {
  let valueBucket = {} as dataRdb.ValuesBucket;
  Const.TASK_INFO.columns?.forEach((item: string) => {
    if (item !== 'id') {
      switch (item) {
        case 'date':
          valueBucket[item] = taskInfo.date;
          break;
        case 'taskID':
          valueBucket[item] = taskInfo.taskID;
          break;
        case 'targetValue':
          valueBucket[item] = taskInfo.targetValue;
          break;
        case 'isAlarm':
          valueBucket[item] = taskInfo.isAlarm;
          break;
        case 'startTime':
          valueBucket[item] = taskInfo.startTime;
          break;
        case 'endTime':
          valueBucket[item] = taskInfo.endTime;
          break;
        case 'frequency':
          valueBucket[item] = taskInfo.frequency;
          break;
        case 'isDone':
          valueBucket[item] = taskInfo.isDone;
          break;
        case 'finValue':
          valueBucket[item] = taskInfo.finValue;
          break;
        case 'isOpen':
          valueBucket[item] = taskInfo.isOpen;
          break;
        default:
          break;
      }
    }
  });
  return valueBucket;
}

// CommonConstants.ets
static readonly TASK_INFO = {
  tableName: 'taskInfo',
  columns: [
    'id',
    'date',
    'taskID',
    'targetValue',
    'isAlarm',
    'startTime',
    'endTime',
    'frequency',
    'isDone',
    'finValue',
    'isOpen'
  ]} as CommonConstantsInfo

用户开启和关闭任务,改变任务的目标值、提醒时间、频率等,用户打卡后修改任务的实际完成值都是通过更新数据接口来实现的。

// TaskInfoApi.ets
updateDataByDate(taskInfo: TaskInfo, callback: Function): void {
  const valueBucket = generateBucket(taskInfo);
  let tableName = Const.TASK_INFO.tableName;
  if (!tableName) {
    return;
  }
  let predicates = new dataRdb.RdbPredicates(tableName);

  // 根据date和taskID匹配要更新的数据行
  predicates.equalTo('date', taskInfo.date).and().equalTo('taskID', taskInfo.taskID);
  RdbUtils.update(valueBucket, predicates).then((result: number) => {
    callback(result);
  });
  Logger.info('TaskInfoTable', `Update data {${taskInfo.date}:${taskInfo.taskID}} finished.`);
}

用户可以查看当天和以前某日的健康任务信息,需要提供查找数据接口。

// TaskInfoApi.ets
query(date: string, isOpen: boolean = true, callback: Function): void {
  let tableName = Const.TASK_INFO.tableName;
  if (!tableName) {
    return;
  }
  let predicates = new dataRdb.RdbPredicates(tableName);
  predicates.equalTo('date', date);

  // 如果isOpen为true,则只查找开启的任务
  if (isOpen) {
    predicates.equalTo('isOpen', true);
  }
  predicates.orderByAsc('taskID');  // 查找结果按taskID排序
  RdbUtils.query(predicates).then(resultSet => {
    let count = resultSet.rowCount;
    // 查找结果为空则返回空数组,否则返回查找结果数组
    if (count === 0 || typeof count === 'string') {
      Logger.error('TaskInfoTable', `${date} query no results!`);
      const result: TaskInfo[] = [];
      callback(result);
    } else {
      resultSet.goToFirstRow();
      const result: TaskInfo[] = [];
      for (let i = 0; i < count; i++) {
        let tmp = new TaskInfo(0, '', 0, '', false, '', '', '', false, '');
        tmp.id = resultSet.getDouble(resultSet.getColumnIndex('id'));
        ...  // 省略赋值代码
        result[i] = tmp;
        resultSet.goToNextRow();
      }
      callback(result);
    }
  });
}
  • 每日信息数据表

在当天第一次打开应用时需要初始化每日信息数据,页面需要根据用户编辑任务和打卡的情况来更新当天目标任务个数和完成任务个数,所以需要提供插入数据和更新数据的接口,写法与上一条中相应接口类似,不再赘述。

// EntryAbility.ets
async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
  ...
  RdbUtils.createTable(Const.DAY_INFO.tableName ? Const.DAY_INFO.tableName : '', columnDayInfoList).then(() => {
    Logger.info(`RdbHelper createTable dayInfo success`);
  }).catch((err: Error) => {
    Logger.error(`RdbHelper dayInfo err : ${JSON.stringify(err)}`);
  });
  ...
}

// CommonConstants.ets
static readonly DAY_INFO = {
  tableName: 'dayInfo',
  columns: ['date', 'targetTaskNum', 'finTaskNum']
} as CommonConstantsInfo

// RdbColumnModel.ets
export const columnDayInfoList: Array<ColumnInfo> = [
  new ColumnInfo('date', 'text', -1, false, true, false),
  new ColumnInfo('targetTaskNum', 'integer', -1, true, false, false),
  new ColumnInfo('finTaskNum', 'integer', -1, true, false, false)
];

// TableHelper.ets
createTableSql(tableName: string, columns: Array<ColumnInfo>): string {
  let sql = `create table if not exists ${tableName}(`;
  for (let column of columns) {
    sql = sql.concat(`${column.name} ${column.type}`);
    sql = sql.concat(`${column.length && column.length > 0 ? `(${column.length})` : ''}`);
    sql = sql.concat(`${column.primary ? ' primary key' : ''}`);
    sql = sql.concat(`${column.autoincrement ? ' autoincrement' : ''}`);
    sql = sql.concat(`${column.nullable ? '' : ' not null'}`);
    sql = sql.concat(', ');
  }
  sql = `${sql.substring(0, sql.length - 2)})`;
  return sql;
}

页面需要查找对应日期的目标任务个数和完成任务个数用以在页面显示任务进度,因此需要查找数据的接口。且页面在打开时需要显示当周每天任务的完成情况,因此需要允许一次调用查找一周的每日任务信息。

// DayInfoApi.ets
queryList(dates: string[], callback: Function): void {
  let predicates: dataRdb.RdbPredicates = new dataRdb.RdbPredicates(Const.DAY_INFO.tableName ? Const.DAY_INFO.tableName : '');
  predicates.in('date', dates); // 匹配日期数组内的所有日期
  RdbUtils.query(predicates).then(resultSet => {
    let count = resultSet.rowCount;
    if (count === 0) {
      Logger.info('DayInfoTable', 'query no results.');
      let result: DayInfo[] = [];
      callback(result);
    } else {
      resultSet.goToFirstRow();
      let result: DayInfo[] = [];
      for (let i = 0; i < count; i++) {
        let tmp = new DayInfo('', 0, 0);
        ... // 省略赋值代码
        result[i] = tmp;
        resultSet.goToNextRow();
      }
      callback(result);
    }
  });
}
  • 全局信息数据表

全局信息数据表同样需要提供插入数据、更新数据和查找数据的接口,写法与本节前两条中相应接口类似,不再赘述。

// EntryAbility.ets
async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
  ...
  RdbUtils.createTable(Const.GLOBAL_INFO.tableName ? Const.GLOBAL_INFO.tableName : '', columnGlobalInfoList).then(() => {
    Logger.info(`RdbHelper createTable globalInfo success`);
  }).catch((err: Error) => {
    Logger.error(`RdbHelper globalInfo err : ${JSON.stringify(err)}`);
  });
  ...
}

// CommonConstants.ets
static readonly GLOBAL_INFO = {
  tableName: 'globalInfo',
  columns: ['id', 'firstDate', 'lastDate', 'checkInDays', 'achievements']
} as CommonConstantsInfo

// RdbColumnModel.ets
export const columnGlobalInfoList: Array<ColumnInfo> = [
  new ColumnInfo('id', 'integer', -1, true, true, false),
  new ColumnInfo('firstDate', 'text', -1, false, false, false),
  new ColumnInfo('lastDate', 'text', -1, false, false, false),
  new ColumnInfo('checkInDays', 'integer', -1, true, false, false),
  new ColumnInfo('achievements', 'text', -1, false, false, false)
];


// TableHelper.ets
createTableSql(tableName: string, columns: Array<ColumnInfo>): string {
  let sql = `create table if not exists ${tableName}(`;
  for (let column of columns) {
    sql = sql.concat(`${column.name} ${column.type}`);
    sql = sql.concat(`${column.length && column.length > 0 ? `(${column.length})` : ''}`);
    sql = sql.concat(`${column.primary ? ' primary key' : ''}`);
    sql = sql.concat(`${column.autoincrement ? ' autoincrement' : ''}`);
    sql = sql.concat(`${column.nullable ? '' : ' not null'}`);
    sql = sql.concat(', ');
  }
  sql = `${sql.substring(0, sql.length - 2)})`;
  return sql;
}

10.4 数据库初始化

应用首次打开时,数据库中没有数据,要做数据库的初始化,写入一组空数据。另外,如果用户连续几天没有打开APP,再次打开时需要将数据回写至数据库。因此需要实现一个数据库接口,在应用打开时调用,进行上述操作。

// DatabaseModel.ets
query(date: string, callback: Function) {
  let result: TaskInfo[] = [];
  let self = this;
  GlobalInfoApi.query((globalResult: GlobalInfo) => {
    // 如果查不到全局信息,就写入全局信息
    if (!globalResult.firstDate) {
      ...  // 插入健康任务信息、每日信息和全局信息
      callback(result, dayInfo);
    } else {
      // 如果查到全局信息,那么查询当日任务信息
      let newGlobalInfo = globalResult;
      let preDate = globalResult.lastDate;
      newGlobalInfo.lastDate = date;
      ...  // 更新全局信息

      // 查询当日任务信息
      GlobalInfoApi.updateData(newGlobalInfo, (isDone: number) => {
        if (isDone) {
          Logger.info('AppStart', 'update globalInfo success: ' + JSON.stringify(newGlobalInfo));
        }
      });
      self.queryPreInfo(self, date, preDate, result, callback);
    }
  });
}

11.1 日志类

日志类Logger旨在提供一个全局的日志打印、日志管理的地方,既可以规范整个应用的日志打印,也方便日后对日志工具类进行修改,而不需要去改动代码中每一个调用日志的地方,目前分info,debug,warn,error四个级别。

// Logger.ets
const LOGGER_PREFIX: string = 'Healthy_life';

class Logger {
  private domain: number;
  private prefix: string;

  // format Indicates the log format string.
  private format: string = '%{public}s, %{public}s';

  constructor(prefix: string = '', domain: number = 0xFF00) {
    this.prefix = prefix;
    this.domain = domain;
  }

  debug(...args: string[]): void {
    hilog.debug(this.domain, this.prefix, this.format, args);
  }

  info(...args: string[]): void {
    hilog.info(this.domain, this.prefix, this.format, args);
  }

  warn(...args: string[]): void {
    hilog.warn(this.domain, this.prefix, this.format, args);
  }

  error(...args: string[]): void {
    hilog.error(this.domain, this.prefix, this.format, args);
  }
}

export default new Logger(LOGGER_PREFIX, 0xFF02);

11.2 时间工具

为全局提供时间工具,避免重复定义。常用时间相关常量,时间函数示例(由时间常量衍生出星期一到星期日和数字 1-7 的字典映射)。

// Utils.ets
const CHINESE_OF_WEEK: string[] = ['一', '二', '三', '四', '五', '六', '日'];
const YEAR: string = '年';
const MONTH: string = '月';
const DAY: string = '日';
const WEEK: string = '星期';
const DAYS_OF_WEEK: number = 7;
const SUNDAY_FIRST_SHIFT: number = 6;

// 时间函数示例
export const oneWeekDictFunc = () => {
  const oneWeekDict: Array<string> = [];
  for (let index = 0; index < CHINESE_OF_WEEK.length; index++) {
      oneWeekDict[index] = `${WEEK}${CHINESE_OF_WEEK[index]}`;
  }
  return oneWeekDict;
}

11.3 单位转换工具

把比例等分浮点数转换为百分比字符串,例如成就页面,每一行平均分布三个徽章,可以先定义一个浮点数代表等分比例,再转换为百分比字符串。

// Utils.ets
export function ratio2percent(ratio: number): string {
  return `${ ratio * 100 }%`;
}

// import Utils工具方法:
import { ratio2percent } from '../common/utils/Utils';
  
// 引用工具方法( 例如成就页面,每个徽章占据屏幕宽度的三分之一 ) :
Column({ space: Const.DEFAULT_18 }) { 
  ...  // 省略徽章卡片的 UI 布局细节
}
.width(ratio2percent(achieveConst.ACHIEVE_SPLIT_RATIO))

11.4 事件分发类

事件分发类提供应用全局的事件注册,分发,接受,可以实现组件之间的解耦。全局共享一个实例, 将事件处理统一管理(HealthDataSrcMgr是单例)。注册事件、取消事件注册、发送事件代码示例如右侧所示。

// HomeComponent.ets
@Provide broadCast: BroadCast = HealthDataSrcMgr.getInstance().getBroadCast();

// HealthDataSrcMgr.ets
public getBroadCast(): BroadCast {
  return this.broadCast;
}

// 事件注册
// CustomDialogView.ets
aboutToAppear() {
  ...
  this.broadCast.on(BroadCastType.SHOW_ACHIEVEMENT_DIALOG, (achievementLevel: number) => { 
    ... // 省略回调细节
  })
  ...
}
  
// BroadCast.ets
public on(event: string, callback: Function) {
  switch (event) {
    case BroadCastType.SHOW_ACHIEVEMENT_DIALOG:
      this.callBackArray.showAchievementDialog = callback;
      break;
    case BroadCastType.SHOW_TASK_DETAIL_DIALOG:
      this.callBackArray.showTaskDetailDialog = callback;
      break;
    case BroadCastType.SHOW_TARGET_SETTING_DIALOG:
      this.callBackArray.showTargetSettingDialog = callback;
      break;
    case BroadCastType.SHOW_REMIND_TIME_DIALOG:
      this.callBackArray.showRemindTimeDialog = callback;
      break;
    case BroadCastType.SHOW_FREQUENCY_DIALOG:
      this.callBackArray.showFrequencyDialog = callback;
      break;
    default:
      break;
  }
}


// BroadCast.ets
public off(event: string, callback: Function) {
  if (event === null) {
    Logger.info(FILE_TAG, 'cancel all broadcast');
    this.callBackArray = callBackArrayTemp;
  }
  Logger.info(FILE_TAG, 'cancel broadcast with type '+ event);
  const cbs = this.callBackArray;
  if (!cbs) {
    return;
  }
  if (callback === null) {
    switch (event) {
      case BroadCastType.SHOW_ACHIEVEMENT_DIALOG:
        this.callBackArray.showAchievementDialog = () => {};
        break;
      case BroadCastType.SHOW_TASK_DETAIL_DIALOG:
        this.callBackArray.showTaskDetailDialog = () => {};
        break;
      case BroadCastType.SHOW_TARGET_SETTING_DIALOG:
        this.callBackArray.showTargetSettingDialog = () => {};
        break;
      case BroadCastType.SHOW_REMIND_TIME_DIALOG:
        this.callBackArray.showRemindTimeDialog = () => {};
        break;
      case BroadCastType.SHOW_FREQUENCY_DIALOG:
        this.callBackArray.showFrequencyDialog = () => {};
        break;
      default:
        break;
    }
  }
}

// 发送事件
// HomeComponent.ets
taskItemAction(item: TaskInfo, isClick: boolean): void {
  ...
  if (isClick) {
    // 点击任务打卡 
    ...
    this.broadCast.emit(BroadCastType.SHOW_TASK_DETAIL_DIALOG, [item, callback]);
  } else {
    ...
  }
}

// BroadCast.ets
public emit(event: string, args?: (number | number[] | (TaskInfo | CustomDialogCallback)[])) {
  if (!this.callBackArray) {
    Logger.info(FILE_TAG, 'emit broadcast failed for no callback');
    return;
  }
  Logger.info(FILE_TAG, 'emit broadcast with type '+ event);
  let cbs: Array<Function> = [];
  switch (event) {
    case BroadCastType.SHOW_ACHIEVEMENT_DIALOG:
      cbs = [this.callBackArray.showAchievementDialog];
      break;
    case BroadCastType.SHOW_TASK_DETAIL_DIALOG:
      cbs = [this.callBackArray.showTaskDetailDialog];
      break;
    case BroadCastType.SHOW_TARGET_SETTING_DIALOG:
      cbs = [this.callBackArray.showTargetSettingDialog];
      break;
    case BroadCastType.SHOW_REMIND_TIME_DIALOG:
      cbs = [this.callBackArray.showRemindTimeDialog];
      break;
    case BroadCastType.SHOW_FREQUENCY_DIALOG:
      cbs = [this.callBackArray.showFrequencyDialog];
      break;
    default:
      break;
  }
  if (cbs) {
    let len = cbs.length;
    for (let i = 0; i < len; i++) {
      try {
        if (args instanceof Array) {
          cbs[i](args[0], args[1]);
        } else {
          cbs[i](args);
        }
      } catch (error) {
        new Error(error);
      }
    }
  }
}

总结

您已经完成了本次Codelab的学习,并了解到以下知识点:

  1. ArkUI基础组件、容器组件的使用。
  2. 使用页面路由跳转到指定页面并传递所需参数。
  3. 基于基础组件封装自定义组件,如日历、弹窗等。
  4. 数据驱动UI组件刷新。
  5. 使用首选项接口实现应用权限管理。
  6. 使用关系型数据库读写关系型数据。
  7. 使用ArkTS卡片能力实现2x2和2x4规格的卡片。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值