HarmonyOS APP开发---龟寿录宠物龟记录App

2 阅读10分钟

如果你想做一个宠物龟记录App,需要用到Preferences和倒计时

欢迎大家去鸿蒙应用市场搜索下载「龟寿录」,体验一下这款宠物龟记录工具。你可以在里面记录背甲测量数据、设置冬眠倒计时、管理喂食频率日志,把你养龟的每一个细节都记下来。


写在前面

养龟是一件"慢"事。龟的生长速度很慢,一年可能只长几厘米。但正因为慢,所以更需要长期记录。如果你能坚持每隔几个月测量一次背甲长度,积累一两年之后,就能画出一条完整的生长曲线,清楚地看到龟的生长趋势。

冬眠是养龟过程中最让人操心的环节。不同品种的龟,冬眠的温度和时间都不一样。在冬眠期间,你需要定期检查龟的状态,确保温度不会太低(会冻死)也不会太高(会提前醒来)。如果能有一个倒计时,告诉你距离冬眠结束还有多少天,你心里就有底了。

喂食频率也很重要。龟在活跃期需要定期喂食,但在冬眠前需要停食排空肠胃,冬眠期间完全停食。如果有一个喂食日志,你就能清楚地知道上次喂食是什么时候、喂了多少、龟的反应如何。

这就是「龟寿录」这个App要做的三件事:背甲测量、冬眠倒计时、喂食频率日志。

这篇文章聊什么

这篇文章主要聊三件事:

  1. 测量数据的时序存储 -- 怎么按时间顺序存储背甲长度、体重等测量数据,并计算生长速率。
  2. 冬眠倒计时的实现 -- 怎么根据冬眠开始日期和预计结束日期,计算剩余天数,并用动画效果展示倒计时。
  3. 喂食频率日志的管理 -- 怎么记录每次喂食的详细信息,并计算喂食间隔。

第一步:背甲测量数据记录

背甲长度(Carapace Length,简称CL)是衡量龟体型大小的标准指标。定期测量并记录,可以追踪龟的生长情况。

React版本:背甲测量

const defaultMeasurements = [
  { id: '1', date: '2024-01-15', carapaceLength: 12.5, weight: 350, note: '状态良好' },
  { id: '2', date: '2024-04-15', carapaceLength: 13.1, weight: 380, note: '食欲旺盛' },
  { id: '3', date: '2024-07-15', carapaceLength: 13.8, weight: 420, note: '进入活跃期' },
];

function MeasurementTracker() {
  const [measurements, setMeasurements] = useState(() => {
    const saved = localStorage.getItem('turtle_measurements');
    return saved ? JSON.parse(saved) : defaultMeasurements;
  });

  const addMeasurement = (m) => {
    const updated = [{ ...m, id: Date.now().toString() }, ...measurements];
    setMeasurements(updated);
    localStorage.setItem('turtle_measurements', JSON.stringify(updated));
  };

  // 计算生长速率(每月增长多少厘米)
  const getGrowthRate = () => {
    if (measurements.length < 2) return null;
    const latest = measurements[0];
    const oldest = measurements[measurements.length - 1];
    const monthsDiff = (new Date(latest.date) - new Date(oldest.date)) / (1000 * 60 * 60 * 24 * 30);
    const lengthDiff = latest.carapaceLength - oldest.carapaceLength;
    return (lengthDiff / monthsDiff).toFixed(2);
  };

  return (
    <div className="measurement-tracker">
      <h3>背甲测量</h3>
      <p>当前背甲长:{measurements[0]?.carapaceLength} cm</p>
      <p>月均增长:{getGrowthRate()} cm/月</p>
      {/* 测量列表 */}
    </div>
  );
}

生长速率的计算逻辑是:用最新一次的背甲长度减去最早一次的背甲长度,再除以两次测量之间的月数。这样得到的就是"每月平均增长多少厘米"。如果只有一次测量数据,就无法计算速率,所以返回null。

ArkTS版本:背甲测量

import { preferences } from '@kit.ArkData';

// 测量记录
interface Measurement {
  id: string;
  date: string;
  carapaceLength: number;  // 背甲长度,cm
  weight: number;           // 体重,g
  note: string;
}

let dataStore: preferences.Preferences | null = null;

@Entry
@Component
struct MeasurementPage {
  @State measurements: Measurement[] = [];
  @State inputLength: number = 15.0;
  @State inputWeight: number = 400;
  @State inputNote: string = '';

  async aboutToAppear() {
    try {
      dataStore = await preferences.getPreferences(getContext(this), 'guishoulu_store');
      const json = await dataStore.get('turtle_measurements', '[]') as string;
      this.measurements = JSON.parse(json) as Measurement[];
    } catch (err) {
      console.error('加载测量数据失败', JSON.stringify(err));
    }
  }

  // 计算生长速率
  getGrowthRate(): string {
    if (this.measurements.length < 2) return '--';
    const latest = this.measurements[0];
    const oldest = this.measurements[this.measurements.length - 1];
    const monthsDiff = (new Date(latest.date).getTime() - new Date(oldest.date).getTime()) / (1000 * 60 * 60 * 24 * 30);
    if (monthsDiff === 0) return '--';
    const lengthDiff = latest.carapaceLength - oldest.carapaceLength;
    return (lengthDiff / monthsDiff).toFixed(2);
  }

  // 计算体重增长速率
  getWeightGrowthRate(): string {
    if (this.measurements.length < 2) return '--';
    const latest = this.measurements[0];
    const oldest = this.measurements[this.measurements.length - 1];
    const monthsDiff = (new Date(latest.date).getTime() - new Date(oldest.date).getTime()) / (1000 * 60 * 60 * 24 * 30);
    if (monthsDiff === 0) return '--';
    const weightDiff = latest.weight - oldest.weight;
    return (weightDiff / monthsDiff).toFixed(0);
  }

  async addMeasurement() {
    const m: Measurement = {
      id: Date.now().toString(),
      date: new Date().toISOString().split('T')[0],
      carapaceLength: this.inputLength,
      weight: this.inputWeight,
      note: this.inputNote,
    };

    this.measurements = [m, ...this.measurements];

    if (dataStore) {
      await dataStore.put('turtle_measurements', JSON.stringify(this.measurements));
      await dataStore.flush();
    }

    this.inputNote = '';
  }

  build() {
    Scroll() {
      Column() {
        Text('背甲测量记录')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 16 })

        // 当前数据卡片
        if (this.measurements.length > 0) {
          Row({ space: 12 }) {
            Column() {
              Text(`${this.measurements[0].carapaceLength}`)
                .fontSize(32)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4CAF50')
              Text('背甲长度(cm)')
                .fontSize(12)
                .fontColor('#999999')
            }
            .layoutWeight(1)
            .padding(16)
            .backgroundColor('#E8F5E9')
            .borderRadius(12)

            Column() {
              Text(`${this.measurements[0].weight}`)
                .fontSize(32)
                .fontWeight(FontWeight.Bold)
                .fontColor('#2196F3')
              Text('体重(g)')
                .fontSize(12)
                .fontColor('#999999')
            }
            .layoutWeight(1)
            .padding(16)
            .backgroundColor('#E3F2FD')
            .borderRadius(12)
          }
          .width('90%')
          .margin({ bottom: 12 })

          // 生长速率
          Row({ space: 12 }) {
            Column() {
              Text(`${this.getGrowthRate()}`)
                .fontSize(24)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FF9800')
              Text('月均增长(cm/月)')
                .fontSize(12)
                .fontColor('#999999')
            }
            .layoutWeight(1)
            .padding(12)
            .backgroundColor('#FFF3E0')
            .borderRadius(12)

            Column() {
              Text(`${this.getWeightGrowthRate()}`)
                .fontSize(24)
                .fontWeight(FontWeight.Bold)
                .fontColor('#9C27B0')
              Text('月均增重(g/月)')
                .fontSize(12)
                .fontColor('#999999')
            }
            .layoutWeight(1)
            .padding(12)
            .backgroundColor('#F3E5F5')
            .borderRadius(12)
          }
          .width('90%')
          .margin({ bottom: 20 })
        }

        // 添加测量
        Column() {
          Text('添加测量')
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .margin({ bottom: 12 })

          Row() {
            Text('背甲长度')
              .fontSize(14)
              .width(80)
            Text(`${this.inputLength} cm`)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .margin({ left: 8, right: 8 })
            Slider({ min: 1, max: 50, step: 0.1, value: this.inputLength, style: SliderStyle.OutSet })
              .layoutWeight(1)
              .onChange((value: number) => { this.inputLength = value; })
          }
          .width('100%')
          .margin({ bottom: 8 })

          Row() {
            Text('体重')
              .fontSize(14)
              .width(80)
            Text(`${this.inputWeight} g`)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .margin({ left: 8, right: 8 })
            Slider({ min: 10, max: 5000, step: 10, value: this.inputWeight, style: SliderStyle.OutSet })
              .layoutWeight(1)
              .onChange((value: number) => { this.inputWeight = value; })
          }
          .width('100%')
          .margin({ bottom: 8 })

          TextArea({ placeholder: '备注...', text: this.inputNote })
            .width('100%')
            .height(50)
            .onChange((value: string) => { this.inputNote = value; })
            .margin({ bottom: 12 })

          Button('记录测量')
            .width('100%')
            .backgroundColor('#4CAF50')
            .fontColor('#FFFFFF')
            .onClick(() => { this.addMeasurement(); })
        }
        .width('90%')
        .padding(16)
        .backgroundColor('#F5F5F5')
        .borderRadius(12)
        .margin({ bottom: 20 })

        // 测量历史
        Text('测量历史')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .width('90%')
          .margin({ bottom: 12 })

        ForEach(this.measurements, (m: Measurement) => {
          Row() {
            Text(m.date)
              .fontSize(13)
              .fontColor('#999999')
              .width(90)
            Text(`${m.carapaceLength}cm`)
              .fontSize(13)
              .fontColor('#4CAF50')
              .fontWeight(FontWeight.Bold)
              .width(60)
            Text(`${m.weight}g`)
              .fontSize(13)
              .fontColor('#2196F3')
              .width(60)
            if (m.note) {
              Text(m.note)
                .fontSize(12)
                .fontColor('#666666')
                .layoutWeight(1)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
            }
          }
          .width('90%')
          .padding(10)
          .backgroundColor('#FAFAFA')
          .borderRadius(6)
          .margin({ bottom: 4 })
        }, (m: Measurement) => m.id)
      }
      .width('100%')
      .padding(20)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
  }
}

测量历史列表里,每条记录显示日期、背甲长度、体重和备注。背甲长度用绿色显示,体重用蓝色显示,这样视觉上更容易区分。备注如果太长,用maxLines(1)textOverflow来截断,避免占用太多空间。


第二步:冬眠倒计时

冬眠倒计时是这个App最有特色的功能。用户设置冬眠的开始日期和预计结束日期,App会自动计算剩余天数,并用醒目的方式展示。

React版本:冬眠倒计时

function HibernationCountdown() {
  const [config, setConfig] = useState(() => {
    const saved = localStorage.getItem('hibernation_config');
    return saved ? JSON.parse(saved) : {
      startDate: '2024-11-15',
      endDate: '2025-03-15',
      isHibernating: false,
    };
  });

  const [now, setNow] = useState(new Date());

  useEffect(() => {
    const timer = setInterval(() => setNow(new Date()), 60000); // 每分钟更新
    return () => clearInterval(timer);
  }, []);

  const getDaysRemaining = () => {
    const end = new Date(config.endDate);
    const diff = end - now;
    return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
  };

  const getDaysElapsed = () => {
    const start = new Date(config.startDate);
    const diff = now - start;
    return Math.max(0, Math.floor(diff / (1000 * 60 * 60 * 24)));
  };

  const startHibernation = () => {
    const updated = { ...config, isHibernating: true, startDate: new Date().toISOString().split('T')[0] };
    setConfig(updated);
    localStorage.setItem('hibernation_config', JSON.stringify(updated));
  };

  const endHibernation = () => {
    const updated = { ...config, isHibernating: false };
    setConfig(updated);
    localStorage.setItem('hibernation_config', JSON.stringify(updated));
  };

  return (
    <div className="hibernation-countdown">
      <h3>冬眠倒计时</h3>
      {config.isHibernating ? (
        <div className="countdown-display">
          <p>已冬眠 {getDaysElapsed()} 天</p>
          <p>距离结束还有 {getDaysRemaining()} 天</p>
          <button onClick={endHibernation}>结束冬眠</button>
        </div>
      ) : (
        <div>
          <p>当前不在冬眠期</p>
          <button onClick={startHibernation}>开始冬眠</button>
        </div>
      )}
    </div>
  );
}

倒计时的核心逻辑是计算当前时间与结束日期之间的天数差。用setInterval每分钟更新一次当前时间,这样倒计时就能实时更新。不过在实际的App中,你可以用更精确的定时器(比如每秒更新),但每分钟更新已经足够了,毕竟天数级别的倒计时不需要秒级精度。

ArkTS版本:冬眠倒计时

interface HibernationConfig {
  startDate: string;
  endDate: string;
  isHibernating: boolean;
  targetTemp: number;  // 冬眠目标温度
}

@Entry
@Component
struct HibernationPage {
  @State config: HibernationConfig = {
    startDate: '',
    endDate: '',
    isHibernating: false,
    targetTemp: 5,
  };
  @State daysRemaining: number = 0;
  @State daysElapsed: number = 0;
  @State editEndDate: string = '';
  @State editTargetTemp: number = 5;

  private timerId: number = -1;

  async aboutToAppear() {
    if (!dataStore) return;
    try {
      const json = await dataStore.get('hibernation_config', '') as string;
      if (json !== '') {
        this.config = JSON.parse(json) as HibernationConfig;
      }
    } catch (err) {
      console.error('加载冬眠配置失败', JSON.stringify(err));
    }

    this.updateCountdown();
    // 每分钟更新一次倒计时
    this.timerId = setInterval(() => {
      this.updateCountdown();
    }, 60000);
  }

  aboutToDisappear() {
    if (this.timerId !== -1) {
      clearInterval(this.timerId);
    }
  }

  // 更新倒计时
  updateCountdown() {
    if (!this.config.isHibernating) {
      this.daysRemaining = 0;
      this.daysElapsed = 0;
      return;
    }
    const now = new Date();
    const end = new Date(this.config.endDate);
    const start = new Date(this.config.startDate);
    this.daysRemaining = Math.max(0, Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)));
    this.daysElapsed = Math.max(0, Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
  }

  // 开始冬眠
  async startHibernation() {
    const today = new Date().toISOString().split('T')[0];
    this.config = {
      startDate: today,
      endDate: this.editEndDate,
      isHibernating: true,
      targetTemp: this.editTargetTemp,
    };
    this.updateCountdown();

    if (dataStore) {
      await dataStore.put('hibernation_config', JSON.stringify(this.config));
      await dataStore.flush();
    }
  }

  // 结束冬眠
  async endHibernation() {
    this.config.isHibernating = false;
    this.daysRemaining = 0;
    this.daysElapsed = 0;

    if (dataStore) {
      await dataStore.put('hibernation_config', JSON.stringify(this.config));
      await dataStore.flush();
    }
  }

  // 获取进度百分比
  getProgressPercent(): number {
    if (!this.config.isHibernating) return 0;
    const start = new Date(this.config.startDate).getTime();
    const end = new Date(this.config.endDate).getTime();
    const now = Date.now();
    if (now <= start) return 0;
    if (now >= end) return 100;
    return Math.round(((now - start) / (end - start)) * 100);
  }

  build() {
    Scroll() {
      Column() {
        Text('冬眠倒计时')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 16 })

        if (this.config.isHibernating) {
          // 冬眠中的显示
          Column() {
            Text('冬眠中')
              .fontSize(18)
              .fontColor('#2196F3')
              .margin({ bottom: 16 })

            // 倒计时大数字
            Text(`${this.daysRemaining}`)
              .fontSize(72)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FF5722')
            Text('天结束')
              .fontSize(16)
              .fontColor('#999999')
              .margin({ bottom: 16 })

            // 进度条
            Progress({ value: this.getProgressPercent(), total: 100 })
              .width('100%')
              .color('#4CAF50')
              .margin({ bottom: 8 })

            Text(`已冬眠 ${this.daysElapsed} 天 | 进度 ${this.getProgressPercent()}%`)
              .fontSize(13)
              .fontColor('#999999')
              .margin({ bottom: 16 })

            // 冬眠信息
            Row() {
              Text(`开始:${this.config.startDate}`)
                .fontSize(13)
                .fontColor('#666666')
                .layoutWeight(1)
              Text(`结束:${this.config.endDate}`)
                .fontSize(13)
                .fontColor('#666666')
                .layoutWeight(1)
            }
            .width('100%')
            .margin({ bottom: 8 })

            Text(`目标温度:${this.config.targetTemp}度`)
              .fontSize(13)
              .fontColor('#666666')
              .margin({ bottom: 16 })

            Button('结束冬眠')
              .width('100%')
              .backgroundColor('#F44336')
              .fontColor('#FFFFFF')
              .onClick(() => { this.endHibernation(); })
          }
          .width('90%')
          .padding(24)
          .backgroundColor('#E3F2FD')
          .borderRadius(16)
          .margin({ bottom: 20 })
        } else {
          // 非冬眠期 - 设置界面
          Column() {
            Text('设置冬眠计划')
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .margin({ bottom: 12 })

            Text('当前不在冬眠期')
              .fontSize(14)
              .fontColor('#999999')
              .margin({ bottom: 16 })

            TextInput({ placeholder: '预计结束日期 YYYY-MM-DD', text: this.editEndDate })
              .width('100%')
              .onChange((value: string) => { this.editEndDate = value; })
              .margin({ bottom: 12 })

            Row() {
              Text('目标温度')
                .fontSize(14)
                .width(80)
              Text(`${this.editTargetTemp} 度`)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .margin({ left: 8, right: 8 })
              Slider({ min: 0, max: 15, step: 1, value: this.editTargetTemp, style: SliderStyle.OutSet })
                .layoutWeight(1)
                .onChange((value: number) => { this.editTargetTemp = value; })
            }
            .width('100%')
            .margin({ bottom: 16 })

            Button('开始冬眠')
              .width('100%')
              .backgroundColor('#2196F3')
              .fontColor('#FFFFFF')
              .onClick(() => { this.startHibernation(); })
          }
          .width('90%')
          .padding(16)
          .backgroundColor('#F5F5F5')
          .borderRadius(12)
          .margin({ bottom: 20 })
        }
      }
      .width('100%')
      .padding(20)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
  }
}

这里有一个很重要的细节:aboutToDisappear。这个生命周期在组件即将销毁的时候调用,我们在这里清除定时器。如果不清除,定时器会一直运行,即使组件已经不存在了,这会造成内存泄漏。这跟React的useEffect返回清理函数是一个道理。

进度百分比的计算:用当前时间在开始和结束之间的位置来计算。如果还没开始,进度是0%;如果已经结束,进度是100%;否则按比例计算。


第三步:喂食频率日志

React版本

function FeedingLog() {
  const [logs, setLogs] = useState(() => {
    const saved = localStorage.getItem('feeding_logs');
    return saved ? JSON.parse(saved) : [];
  });

  const addLog = (log) => {
    const updated = [{ ...log, id: Date.now().toString(), date: new Date().toISOString().split('T')[0] }, ...logs];
    setLogs(updated);
    localStorage.setItem('feeding_logs', JSON.stringify(updated));
  };

  // 计算距上次喂食的天数
  const getDaysSinceLastFeed = () => {
    if (logs.length === 0) return '--';
    const lastDate = new Date(logs[0].date);
    return Math.floor((Date.now() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
  };

  return (
    <div className="feeding-log">
      <h3>喂食日志</h3>
      <p>距上次喂食:{getDaysSinceLastFeed()} 天</p>
      {/* 表单和列表 */}
    </div>
  );
}

ArkTS版本:喂食频率日志

interface FeedingLog {
  id: string;
  date: string;
  food: string;        // 食物类型
  amount: string;      // 投喂量
  response: string;    // 龟的反应
  note: string;
}

@Entry
@Component
struct FeedingLogPage {
  @State logs: FeedingLog[] = [];
  @State inputFood: string = '';
  @State inputAmount: string = '';
  @State inputResponse: number = 0;
  @State inputNote: string = '';

  private responseOptions: string[] = ['积极', '一般', '不进食'];

  async aboutToAppear() {
    if (!dataStore) return;
    try {
      const json = await dataStore.get('feeding_logs', '[]') as string;
      this.logs = JSON.parse(json) as FeedingLog[];
    } catch (err) {
      console.error('加载喂食日志失败', JSON.stringify(err));
    }
  }

  // 距上次喂食天数
  getDaysSinceLastFeed(): string {
    if (this.logs.length === 0) return '--';
    const lastDate = new Date(this.logs[0].date);
    const days = Math.floor((Date.now() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
    return `${days}`;
  }

  async addLog() {
    const log: FeedingLog = {
      id: Date.now().toString(),
      date: new Date().toISOString().split('T')[0],
      food: this.inputFood,
      amount: this.inputAmount,
      response: this.responseOptions[this.inputResponse],
      note: this.inputNote,
    };

    this.logs = [log, ...this.logs];

    if (dataStore) {
      await dataStore.put('feeding_logs', JSON.stringify(this.logs));
      await dataStore.flush();
    }

    this.inputFood = '';
    this.inputAmount = '';
    this.inputNote = '';
  }

  build() {
    Scroll() {
      Column() {
        Text('喂食频率日志')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 16 })

        // 上次喂食提示
        Row() {
          Column() {
            Text(`${this.getDaysSinceLastFeed()}`)
              .fontSize(36)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.logs.length === 0 ? '#999999' :
                parseInt(this.getDaysSinceLastFeed()) > 7 ? '#F44336' : '#4CAF50')
            Text('天前喂食')
              .fontSize(13)
              .fontColor('#999999')
          }
          .layoutWeight(1)
          .padding(16)
          .backgroundColor('#FFF3E0')
          .borderRadius(12)

          Column() {
            Text(`${this.logs.length}`)
              .fontSize(36)
              .fontWeight(FontWeight.Bold)
              .fontColor('#2196F3')
            Text('总喂食次数')
              .fontSize(13)
              .fontColor('#999999')
          }
          .layoutWeight(1)
          .padding(16)
          .backgroundColor('#E3F2FD')
          .borderRadius(12)
        }
        .width('90%')
        .space(12)
        .margin({ bottom: 20 })

        // 添加喂食记录
        Column() {
          Text('记录喂食')
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .margin({ bottom: 12 })

          TextInput({ placeholder: '食物类型(如:龟粮、小鱼)', text: this.inputFood })
            .width('100%')
            .onChange((value: string) => { this.inputFood = value; })
            .margin({ bottom: 8 })

          TextInput({ placeholder: '投喂量(如:5粒、2条)', text: this.inputAmount })
            .width('100%')
            .onChange((value: string) => { this.inputAmount = value; })
            .margin({ bottom: 8 })

          Row() {
            Text('反应')
              .fontSize(14)
              .width(60)
            ForEach(this.responseOptions, (option: string, index: number) => {
              Text(option)
                .fontSize(14)
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor(this.inputResponse === index ? '#4CAF50' : '#E0E0E0')
                .fontColor(this.inputResponse === index ? '#FFFFFF' : '#333333')
                .borderRadius(16)
                .onClick(() => { this.inputResponse = index; })
            }, (_option: string, index: number) => `${index}`)
          }
          .width('100%')
          .margin({ bottom: 8 })

          TextArea({ placeholder: '备注...', text: this.inputNote })
            .width('100%')
            .height(50)
            .onChange((value: string) => { this.inputNote = value; })
            .margin({ bottom: 12 })

          Button('记录喂食')
            .width('100%')
            .backgroundColor('#4CAF50')
            .fontColor('#FFFFFF')
            .onClick(() => { this.addLog(); })
        }
        .width('90%')
        .padding(16)
        .backgroundColor('#F5F5F5')
        .borderRadius(12)
        .margin({ bottom: 20 })

        // 喂食历史
        if (this.logs.length > 0) {
          Text('喂食历史')
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .width('90%')
            .margin({ bottom: 12 })

          ForEach(this.logs, (log: FeedingLog) => {
            Row() {
              Text(log.date)
                .fontSize(13)
                .fontColor('#999999')
                .width(90)
              Text(log.food)
                .fontSize(13)
                .fontColor('#333333')
                .width(60)
              Text(log.amount)
                .fontSize(13)
                .fontColor('#666666')
                .width(60)
              Text(log.response)
                .fontSize(13)
                .fontColor(log.response === '积极' ? '#4CAF50' : log.response === '一般' ? '#FF9800' : '#F44336')
                .layoutWeight(1)
            }
            .width('90%')
            .padding(10)
            .backgroundColor('#FAFAFA')
            .borderRadius(6)
            .margin({ bottom: 4 })
          }, (log: FeedingLog) => log.id)
        }
      }
      .width('100%')
      .padding(20)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
  }
}

"距上次喂食"的天数用颜色来区分紧急程度:绿色表示最近喂过(7天以内),红色表示超过7天没喂了。这个阈值可以根据龟的品种和季节来调整,这里先用7天作为默认值。


流程图

flowchart TD
    A[用户打开App] --> B[加载测量数据]
    B --> C[显示当前背甲长度和体重]
    C --> D[显示生长速率]
    D --> E[用户添加新测量]
    E --> F[保存到Preferences]
    F --> G[更新统计数据]

    A --> H[加载冬眠配置]
    H --> I{是否在冬眠中?}
    I -->|是| J[显示倒计时和进度]
    I -->|否| K[显示冬眠设置界面]
    J --> L[每分钟更新倒计时]
    K --> M[用户设置结束日期和温度]
    M --> N[开始冬眠]
    N --> J

    A --> O[加载喂食日志]
    O --> P[显示距上次喂食天数]
    P --> Q[用户记录喂食]
    Q --> R[保存到Preferences]
    R --> S[更新喂食间隔]

React vs ArkTS 对比表

功能点React (Web)ArkTS (鸿蒙)
定时器setInterval + useEffect清理setInterval + aboutToDisappear清理
条件渲染三元表达式if/else语句
进度条div + CSSProgress组件
天数计算Date差值 / 毫秒常量Date差值 / 毫秒常量(一致)
颜色预警条件className条件fontColor

总结

这篇文章我们用「龟寿录」这个宠物龟记录App,学习了三个功能的实现:

  1. 背甲测量记录:用时序数组存储测量数据,用reduce计算生长速率,用卡片式布局展示当前数据和速率。

  2. 冬眠倒计时:用setInterval实现定时更新,用aboutToDisappear清理定时器,用Progress组件显示进度条,根据天数差值计算倒计时。

  3. 喂食频率日志:记录每次喂食的详细信息,计算距上次喂食的天数并用颜色预警,历史记录按时间倒序显示。

如果你对养龟感兴趣,欢迎去鸿蒙应用市场搜索「龟寿录」下载体验。