专注记录做得越详细,越容易产生一个看似简单的产品争论:用户设定“每周完成 8 次专注”,统计页到底应该显示 8 次、8 天,还是 8 × 25 分钟?这三项都能画成漂亮的进度条,但表达的是不同承诺。FocusGarden(专注花园)上一期已经按日归组 SessionEntry,这次不再复制一份统计结果,而是给用户增加一个可以保存和修改的每周完成次数目标,让统计值与目标值独立变化。

沿用 focusgarden_timer_v2 Preferences 文件和会话键 session_bundle_v3。原记录有 id、startedAtMs、endedAtMs、plannedSec、label,仅对已完成会话计数;plannedSec 是计划时长,不是传感器测量出的真实投入时长。新版本只增加 weekly_goal_v1 一个键,保存目标次数、版本与修改时间;不反写历史记录,不改动计时器里的 deadlineMs,也不新建第二套“已完成次数总表”。目标用于自我管理,不构成医学或心理评估。

周目标配置与专注统计分离的技术封面

一、别把“目标变化”伪装成“历史变化”

假设过去七个自然日里用户完成 5 次专注,目标设成 8 次,进度条可显示 5/8。当用户把目标改为 6 次,显示立刻变成 5/6,但历史会话没有增加;如果此时把两组数字一起保存成一个“进度对象”,很容易把进度当成事实缓存,并在跨日或新增记录后变旧。我们的数据关系应固定为:sessions → completedCount,goal → weeklyTarget,两者只在展示层汇合。

这里需要提前限定“周”的含义:本篇用最近七个本地自然日(含今天)作为滚动时间窗,而不是周一至周日;它与上一期七日柱状统计一致。采用会话 endedAtMs 的本地日期作为归属,因为这里展示的是完成时刻,而非开始时刻。跨零点完成的会话只计入结束日;今天跨时区或手动调整时区后,日期边界可能改变,统计必须重新计算,不能缓存原来的本地日期字符串。

// entry/src/main/ets/common/WeeklyGoal.ets
export interface WeeklyGoal {
  schema: number;
  weeklyTarget: number;
  updatedAtMs: number;
}

export function validateGoal(target: number): number {
  if (!Number.isInteger(target) || target < 1 || target > 30) {
    throw new Error('周目标须为 1 至 30 次的整数');
  }
  return target;
}

export function decodeGoal(raw: string): WeeklyGoal {
  const parsed: WeeklyGoal = JSON.parse(raw) as WeeklyGoal;
  if (parsed.schema !== 1 ||
      !Number.isFinite(parsed.updatedAtMs)) {
    throw new Error('目标版本不受支持');
  }
  validateGoal(parsed.weeklyTarget);
  return parsed;
}

这里也要避免把“次数目标”写成带时长的名称,例如把 8 次换算成 200 分钟。只有每次会话计划时长完全相同、从未中断,才能在数学上做这样的换算;但我们的历史数据并不承诺这两个条件。产品希望展示分钟数时,应先定义“计划分钟”与“实际完成分钟”的口径,并让用户能看到差异来源。不同指标可以并列,却不能互相冒充。

上限 30 次是这个自律工具的交互约束,而不是 ArkData API 的容量限制。不同产品可以重新定义它,但应该把验证写在业务层,避免滑块和输入框走两套规则。旧设备首次打开没有 weekly_goal_v1 时,使用产品默认值 8,不意味着写入了一次真实用户设置;不合法的旧记录应提示用户重新选择,而不是悄悄把损坏值重置并覆盖。

二、用一个独立键持久化目标,避免重写历史

Preferences 存小型设置很合适。与其每次修改目标都重写几十条 SessionEntry,不如将目标放在独立键中。由于应用已经通过 @kit.ArkData 获取 Preferences 实例,本篇创建一个 WeeklyGoalStore 包装读写;put() 只修改偏好对象,flush() 返回后才更新页面上的“已保存”提示。连续拖动滑块的中间值不必每次立即写盘,应该只在用户点击“保存目标”时执行一次提交。

// entry/src/main/ets/common/WeeklyGoalStore.ets
import { preferences } from '@kit.ArkData';
import { WeeklyGoal, decodeGoal, validateGoal } from './WeeklyGoal';

export class WeeklyGoalStore {
  constructor(private store: preferences.Preferences) {}

  async load(): Promise<WeeklyGoal> {
    const value = await this.store.get('weekly_goal_v1', '');
    if (typeof value !== 'string') { throw new Error('周目标类型错误'); }
    if (value === '') {
      return { schema: 1, weeklyTarget: 8, updatedAtMs: 0 };
    }
    return decodeGoal(value);
  }

  async save(target: number): Promise<WeeklyGoal> {
    const next: WeeklyGoal = { schema: 1,
      weeklyTarget: validateGoal(target), updatedAtMs: Date.now() };
    await this.store.put('weekly_goal_v1', JSON.stringify(next));
    await this.store.flush();
    return next;
  }
}

这段类不负责创建 Preferences 或处理页面生命周期。入口页面沿用已有的 store,初始化时传入 new WeeklyGoalStore(store) 即可。尤其不要为每个列表项创建一个 Preferences 对象,也不要同时用多进程修改相同文件;官方 Preferences 参考 明确说明线程与进程边界及持久化方式。单次保存失败应让 UI 继续显示当前草稿值,并把状态改成“保存失败,可重试”;不能根据滑块的位置推断磁盘里已有新的目标。

三、统计只用已完成记录,进度允许超过目标

为了让周目标可以被验证,先把计算移到纯函数里。这个函数只接受已完成会话列表、现在的本地时刻和目标次数;页面不需要在每次滑动目标时改写源数据。对于完成次数超出目标的情况,数字照实显示 10/8,而进度条视觉上封顶为 100%。这是视觉控件的边界,不代表丢掉两条真实会话。

// entry/src/main/ets/common/WeeklyProgress.ets
import { validateGoal } from './WeeklyGoal';
export interface CompletedSession {
  id: string;
  endedAtMs: number;
}
export interface WeeklyProgress {
  completed: number;
  target: number;
  ratio: number;
}

export function countRollingDays(items: CompletedSession[],
  nowMs: number, target: number): WeeklyProgress {
  const today: Date = new Date(nowMs);
  const start: Date = new Date(today.getFullYear(),
    today.getMonth(), today.getDate() - 6);
  const tomorrow: Date = new Date(today.getFullYear(),
    today.getMonth(), today.getDate() + 1);
  const unique: Set<string> = new Set<string>();
  for (const item of items) {
    if (Number.isFinite(item.endedAtMs) &&
        item.endedAtMs >= start.getTime() &&
        item.endedAtMs < tomorrow.getTime()) {
      unique.add(item.id);
    }
  }
  const count: number = unique.size;
  return { completed: count, target,
    ratio: Math.min(100, (count / validateGoal(target)) * 100) };
}

函数用本地日期构造午夜边界,不靠 7 * 24 * 3600 * 1000 毫秒回退,因为某些地区夏令时调整后,自然日未必总长 24 小时。Set 用原始会话 id 去重:同一会话即使被重复读取,也只应算一次。它并不负责判断任意会话是否“真实完成”,这一前提仍来自上一篇保存完成记录的业务逻辑。需要注意从旧 SessionEntry 映射到 CompletedSession 时,不应把“中止”“暂停”记录误传进来。

FocusGarden 周目标配置在 DevEco Studio 中的代码与预览设计

四、目标页面的视觉反馈不是一张新报表

在现有“七日专注统计”页面下方,新增一张“本周目标”卡片,顶部显示目标和完成次数,中间用 Progress({ value: ratio, total: 100, type: ProgressType.Linear }) 呈现比例,底部通过 Slider({ value: draftTarget, min: 1, max: 30, step: 1 }) 调整草稿值,再由按钮调用 save()。Slider 发生 onChange 时只更新 @State draftTarget,不能直接把动画过程的每个中间值都写入 Preferences。保存前后分别显示“尚未保存”和“目标已保存”,让用户知道哪一个数字可以在重启后恢复。

// WeeklyGoalPage.ets:现有统计页面中的目标卡片核心区域
@State private weeklyTarget: number = 8;
@State private draftTarget: number = 8;
@State private completedCount: number = 5;
@State private saving: boolean = false;

@Builder GoalCard() {
  Column({ space: 12 }) {
    Text(`本周目标  ${this.completedCount} / ${this.weeklyTarget} 次`)
      .fontSize(22).fontWeight(FontWeight.Bold)
    Progress({ value: Math.min(100,
      this.completedCount / this.weeklyTarget * 100),
      total: 100, type: ProgressType.Linear })
      .color('#298C74').width('100%')
    Text(`调整为 ${this.draftTarget} 次`)
    Slider({ value: this.draftTarget, min: 1, max: 30, step: 1 })
      .onChange((value: number) => { this.draftTarget = Math.round(value); })
    Button(this.saving ? '保存中' : '保存目标')
      .enabled(!this.saving)
      .onClick(() => { this.persistDraftGoal(); })
  }.padding(20).backgroundColor('#FFFFFF').borderRadius(18)
}

persistDraftGoal() 是页面中调用 WeeklyGoalStore.save(this.draftTarget) 的异步方法,关键约束是保存中的按钮不可重入。可以将它写在同一页面组件中:

private goalStore: WeeklyGoalStore | null = null;
@State private saveTip: string = '目标尚未保存';

private async persistDraftGoal(): Promise<void> {
  if (this.saving || this.goalStore === null) { return; }
  this.saving = true;
  try {
    const next: WeeklyGoal = await this.goalStore.save(this.draftTarget);
    this.weeklyTarget = next.weeklyTarget;
    this.saveTip = '目标已保存';
  } catch (_) {
    this.saveTip = '保存失败,请重试';
  } finally {
    this.saving = false;
  }
}

这里的 goalStore 必须在现有 Preferences 初始化完成后被赋值,首次 load() 也需要 try/catch:一旦旧数据损坏,允许继续查看历史会话,但暂时禁用目标保存直到用户确认重置。不要在 aboutToAppear() 中一边异步读旧设置一边允许保存,因为读取可能晚于点击,反向覆盖用户刚选择的数值。成功时才更新 weeklyTarget 并显示“已保存”,异常时保持旧值并提示存储状态需要核对;如果 put() 已更新缓存但 flush() 失败,不能把旧屏幕数值当成磁盘一定回滚的证据,需要重新读取设置或执行恢复策略。这一行为与“预览滑块修改”的体验恰好不同。示例将业务层和控件层分开讲,是为了不把大量生命周期逻辑混进一张页面截图。Progress 和 Slider 的参数可对照华为 Progress 与 Slider API 文档。

完成次数、目标次数和视觉进度三者的数据关系图

测试时先使用固定的五条已完成会话检查 5/8;改成 6 后应显示 5/6,原会话 JSON 不变。再加入三条不同 id 的已完成记录,应显示 8/6,进度条封顶而次数不截断;插入重复 id 不应增加分子。第三步把设备日期切到跨周边界附近,核查“最近七个自然日”的滚动范围。在测试环境中还应故意将会话结束时刻构造在凌晨零点前后,确保“周目标”的分子与七日列表一致。同一条记录不能一处归到昨天、一处归到今天;为此统计入口应复用上一期的日期归属函数,而不是让两个页面各自写一段格式化字符串的逻辑。最后人为令 flush() 失败,界面草稿仍可修改,但“已保存目标”必须保持旧值。这样验证的是计算边界与存储协议,不是凭空报告设备的性能数据。

三个目标与完成次数的边界对比

五、可编辑的目标应比漂亮的进度条更可靠

这次设计的核心不是新增一条绿色进度线,而是把“我想完成多少次”和“我实际完成过多少次”拆成两条数据路径。目标可以频繁修改,历史却不能被它反向污染;时间窗可以重新计算,记录身份不能跟着 UI 排序变化。下一期将围绕设置迁移、记录清理和数据恢复收尾,让 FocusGarden 从计时器、历史统计到目标配置形成一条能升级、能排错的本地数据路径。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐