Flutter三方库适配OpenHarmony【age_calculator】年龄计算器项目完整实战

Flutter三方库适配OpenHarmony【age_calculator】年龄计算器项目完整实战

前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

age_calculator 是一个基于 Flutter 实现的 Age Calculator 年龄计算器 。项目核心源码位于 lib/main.dart,同时保留了 ohos OpenHarmony 平台工程目录,适合用来学习 Flutter 日期选择器DateTime 差值计算年月日借位修正统计卡片封装OpenHarmony 日期弹窗适配

年龄计算器看起来是一个简单工具,但它比普通数值计算器更容易出错。原因在于"年龄"不是单纯两个日期相减的天数,还要处理月份、天数不足时的借位逻辑。当前项目通过 _calculateAge() 同时计算年、月、日、总天数、总周数和按 365.25 估算的年龄,并通过 _getNextBirthday() 计算下一次生日倒计时。

本文重点回答三个问题:

  1. age_calculator 如何使用 showDatePicker 选择生日。
  2. Flutter 如何通过 DateTime、年月日借位和 difference 完成年龄计算。
  3. 这个项目适配 OpenHarmony 时,如何验证日期弹窗、结果卡片和生日倒计时。

图示说明:本文聚焦 Flutter 年龄计算器的日期选择、年龄算法和 OpenHarmony 工程承载,核心源码位于 lib/main.dart


一、背景与目标

1.1 项目功能概览

age_calculator 是一个轻量年龄计算工具。用户点击生日按钮后打开日期选择器,选择出生日期后,页面会自动计算年龄的年、月、日拆分,并展示总天数、总周数、估算年龄和下一次生日倒计时。

当前项目真实支持的功能包括:

  • 默认生日为 2000-01-01
  • 点击按钮打开日期选择器。
  • 日期选择范围从 1900 年到当前日期。
  • 选择生日后自动计算年龄。
  • 展示年龄的年、月、日拆分。
  • 展示总天数。
  • 展示总周数。
  • 展示按 365.25 估算的年龄。
  • 展示下一次生日还有多少天。
  • 使用 _buildAgeBox 封装年/月/日卡片。
  • 使用 _buildStatBox 封装统计数字。
  • 使用青绿色主题构建结果区域。

1.2 技术关键词

关键词 在项目中的作用
DateTime 保存生日和当前日期
showDatePicker 打开 Material 日期选择器
DateTime.difference 计算总天数
padLeft(2, '0') 格式化生日按钮文本
days < 0 处理天数借位
months < 0 处理月份借位
totalDays ~/ 7 计算总周数
totalDays / 365.25 估算年龄
_buildAgeBox 封装年龄数字卡片
_buildStatBox 封装统计卡片

1.3 项目目标

这个项目的目标是把日期选择和年龄计算的核心链路做完整:

  1. 用户选择生日。
  2. 应用保存 _birthDate
  3. 应用计算年、月、日差值。
  4. 应用修正天数和月份借位。
  5. 应用计算总天数和总周数。
  6. 应用计算下一次生日倒计时。
  7. 页面展示结果卡片。

关键点:年龄计算器不能只做 now.year - birth.year。真实结果需要根据月份和日期修正,否则会在生日未到时多算一年。


二、环境准备

2.1 项目目录结构

当前项目采用 Flutter 标准目录,同时包含 OpenHarmony 平台工程。

bash 复制代码
age_calculator/
├── lib/
│   └── main.dart
├── ohos/
│   ├── AppScope/
│   └── entry/
├── test/
│   └── widget_test.dart
├── pubspec.yaml
├── analysis_options.yaml
└── README.md

2.2 核心文件说明

文件或目录 作用 本文重点
lib/main.dart 应用入口、日期选择、年龄计算和 UI 核心源码
pubspec.yaml 项目名称、版本、依赖和 Flutter 配置 环境依赖
analysis_options.yaml Dart 静态分析规则 代码规范
test/ Flutter 测试目录 日期算法验证
ohos/ OpenHarmony 平台工程 平台承载

2.3 pubspec 基础配置

项目名称是 age_calculator,版本是 1.0.0+1,Dart SDK 约束是 ^3.9.2

yaml 复制代码
name: age_calculator
description: "A new Flutter project."
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.9.2

publish_to: 'none' 表示这是应用工程,不会被误发布到 pub.dev

2.4 项目依赖

当前项目只使用 Flutter SDK 和基础图标库。

yaml 复制代码
dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^5.0.0

日期选择器来自 Flutter Material 组件,不需要额外依赖。

2.5 常用命令

bash 复制代码
flutter pub get
flutter analyze
flutter test
flutter run
命令 作用
flutter pub get 获取项目依赖
flutter analyze 执行静态分析
flutter test 运行测试
flutter run 启动调试运行
flutter build hap 构建 OpenHarmony HAP 包

三、应用入口与主题配置

3.1 main 函数

Flutter 应用从 main() 启动。

dart 复制代码
import 'package:flutter/material.dart';

void main() {
  runApp(const AgeCalculatorApp());
}

runApp(const AgeCalculatorApp())AgeCalculatorApp 放入 Flutter 渲染树根节点。

3.2 AgeCalculatorApp

dart 复制代码
class AgeCalculatorApp extends StatelessWidget {
  const AgeCalculatorApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Age Calculator',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const AgeCalculatorHomePage(title: 'Age Calculator'),
    );
  }
}

这里有三个关键信息:

  1. 应用标题是 Age Calculator
  2. 主题种子色使用 Colors.teal
  3. 首页是 AgeCalculatorHomePage,日期和年龄状态从首页 State 中维护。

3.3 Material 3 主题

dart 复制代码
theme: ThemeData(
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
  useMaterial3: true,
),

useMaterial3: true 会影响 AppBarElevatedButtonCard、日期选择器等组件的默认风格。当前项目通过青绿色卡片和按钮强化工具类页面的统一视觉。


四、页面状态设计

4.1 AgeCalculatorHomePage

dart 复制代码
class AgeCalculatorHomePage extends StatefulWidget {
  const AgeCalculatorHomePage({super.key, required this.title});
  final String title;

  @override
  State<AgeCalculatorHomePage> createState() => _AgeCalculatorHomePageState();
}

生日选择后,年龄结果会变化,因此页面使用 StatefulWidget

4.2 核心状态字段

dart 复制代码
class _AgeCalculatorHomePageState extends State<AgeCalculatorHomePage> {
  DateTime _birthDate = DateTime(2000, 1, 1);
  int _years = 0;
  int _months = 0;
  int _days = 0;
  int _totalDays = 0;
  int _totalWeeks = 0;
}
字段 类型 作用
_birthDate DateTime 保存出生日期
_years int 年龄中的年
_months int 年龄中的月
_days int 年龄中的日
_totalDays int 从生日到当前日期的总天数
_totalWeeks int 总周数

4.3 状态到 UI 的关系

#mermaid-svg-HVzOeBckUfPaJPdm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HVzOeBckUfPaJPdm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HVzOeBckUfPaJPdm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HVzOeBckUfPaJPdm .error-icon{fill:#552222;}#mermaid-svg-HVzOeBckUfPaJPdm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HVzOeBckUfPaJPdm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HVzOeBckUfPaJPdm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HVzOeBckUfPaJPdm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HVzOeBckUfPaJPdm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HVzOeBckUfPaJPdm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HVzOeBckUfPaJPdm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HVzOeBckUfPaJPdm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HVzOeBckUfPaJPdm .marker.cross{stroke:#333333;}#mermaid-svg-HVzOeBckUfPaJPdm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HVzOeBckUfPaJPdm p{margin:0;}#mermaid-svg-HVzOeBckUfPaJPdm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HVzOeBckUfPaJPdm .cluster-label text{fill:#333;}#mermaid-svg-HVzOeBckUfPaJPdm .cluster-label span{color:#333;}#mermaid-svg-HVzOeBckUfPaJPdm .cluster-label span p{background-color:transparent;}#mermaid-svg-HVzOeBckUfPaJPdm .label text,#mermaid-svg-HVzOeBckUfPaJPdm span{fill:#333;color:#333;}#mermaid-svg-HVzOeBckUfPaJPdm .node rect,#mermaid-svg-HVzOeBckUfPaJPdm .node circle,#mermaid-svg-HVzOeBckUfPaJPdm .node ellipse,#mermaid-svg-HVzOeBckUfPaJPdm .node polygon,#mermaid-svg-HVzOeBckUfPaJPdm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HVzOeBckUfPaJPdm .rough-node .label text,#mermaid-svg-HVzOeBckUfPaJPdm .node .label text,#mermaid-svg-HVzOeBckUfPaJPdm .image-shape .label,#mermaid-svg-HVzOeBckUfPaJPdm .icon-shape .label{text-anchor:middle;}#mermaid-svg-HVzOeBckUfPaJPdm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HVzOeBckUfPaJPdm .rough-node .label,#mermaid-svg-HVzOeBckUfPaJPdm .node .label,#mermaid-svg-HVzOeBckUfPaJPdm .image-shape .label,#mermaid-svg-HVzOeBckUfPaJPdm .icon-shape .label{text-align:center;}#mermaid-svg-HVzOeBckUfPaJPdm .node.clickable{cursor:pointer;}#mermaid-svg-HVzOeBckUfPaJPdm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HVzOeBckUfPaJPdm .arrowheadPath{fill:#333333;}#mermaid-svg-HVzOeBckUfPaJPdm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HVzOeBckUfPaJPdm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HVzOeBckUfPaJPdm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HVzOeBckUfPaJPdm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HVzOeBckUfPaJPdm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HVzOeBckUfPaJPdm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HVzOeBckUfPaJPdm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HVzOeBckUfPaJPdm .cluster text{fill:#333;}#mermaid-svg-HVzOeBckUfPaJPdm .cluster span{color:#333;}#mermaid-svg-HVzOeBckUfPaJPdm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HVzOeBckUfPaJPdm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HVzOeBckUfPaJPdm rect.text{fill:none;stroke-width:0;}#mermaid-svg-HVzOeBckUfPaJPdm .icon-shape,#mermaid-svg-HVzOeBckUfPaJPdm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HVzOeBckUfPaJPdm .icon-shape p,#mermaid-svg-HVzOeBckUfPaJPdm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HVzOeBckUfPaJPdm .icon-shape .label rect,#mermaid-svg-HVzOeBckUfPaJPdm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HVzOeBckUfPaJPdm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HVzOeBckUfPaJPdm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HVzOeBckUfPaJPdm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} _birthDate
生日按钮
_calculateAge
_years/_months/_days
_totalDays
_totalWeeks
_getNextBirthday
AgeBox
StatBox: Total Days
StatBox: Total Weeks
Next Birthday 卡片

生日是唯一输入,后续所有结果都由 _birthDate 派生。


五、初始化与默认生日

5.1 默认生日

dart 复制代码
DateTime _birthDate = DateTime(2000, 1, 1);

项目默认生日是 2000-01-01。首次打开页面时,会基于这个日期计算年龄。

5.2 initState

dart 复制代码
@override
void initState() {
  super.initState();
  _calculateAge();
}

页面初始化后立即调用 _calculateAge(),确保初始 UI 有完整结果,而不是空白状态。

5.3 初始化链路

#mermaid-svg-xNsSr0eq2m345qGV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xNsSr0eq2m345qGV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xNsSr0eq2m345qGV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xNsSr0eq2m345qGV .error-icon{fill:#552222;}#mermaid-svg-xNsSr0eq2m345qGV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xNsSr0eq2m345qGV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xNsSr0eq2m345qGV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xNsSr0eq2m345qGV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xNsSr0eq2m345qGV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xNsSr0eq2m345qGV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xNsSr0eq2m345qGV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xNsSr0eq2m345qGV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xNsSr0eq2m345qGV .marker.cross{stroke:#333333;}#mermaid-svg-xNsSr0eq2m345qGV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xNsSr0eq2m345qGV p{margin:0;}#mermaid-svg-xNsSr0eq2m345qGV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xNsSr0eq2m345qGV .cluster-label text{fill:#333;}#mermaid-svg-xNsSr0eq2m345qGV .cluster-label span{color:#333;}#mermaid-svg-xNsSr0eq2m345qGV .cluster-label span p{background-color:transparent;}#mermaid-svg-xNsSr0eq2m345qGV .label text,#mermaid-svg-xNsSr0eq2m345qGV span{fill:#333;color:#333;}#mermaid-svg-xNsSr0eq2m345qGV .node rect,#mermaid-svg-xNsSr0eq2m345qGV .node circle,#mermaid-svg-xNsSr0eq2m345qGV .node ellipse,#mermaid-svg-xNsSr0eq2m345qGV .node polygon,#mermaid-svg-xNsSr0eq2m345qGV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xNsSr0eq2m345qGV .rough-node .label text,#mermaid-svg-xNsSr0eq2m345qGV .node .label text,#mermaid-svg-xNsSr0eq2m345qGV .image-shape .label,#mermaid-svg-xNsSr0eq2m345qGV .icon-shape .label{text-anchor:middle;}#mermaid-svg-xNsSr0eq2m345qGV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xNsSr0eq2m345qGV .rough-node .label,#mermaid-svg-xNsSr0eq2m345qGV .node .label,#mermaid-svg-xNsSr0eq2m345qGV .image-shape .label,#mermaid-svg-xNsSr0eq2m345qGV .icon-shape .label{text-align:center;}#mermaid-svg-xNsSr0eq2m345qGV .node.clickable{cursor:pointer;}#mermaid-svg-xNsSr0eq2m345qGV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xNsSr0eq2m345qGV .arrowheadPath{fill:#333333;}#mermaid-svg-xNsSr0eq2m345qGV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xNsSr0eq2m345qGV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xNsSr0eq2m345qGV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xNsSr0eq2m345qGV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xNsSr0eq2m345qGV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xNsSr0eq2m345qGV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xNsSr0eq2m345qGV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xNsSr0eq2m345qGV .cluster text{fill:#333;}#mermaid-svg-xNsSr0eq2m345qGV .cluster span{color:#333;}#mermaid-svg-xNsSr0eq2m345qGV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xNsSr0eq2m345qGV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xNsSr0eq2m345qGV rect.text{fill:none;stroke-width:0;}#mermaid-svg-xNsSr0eq2m345qGV .icon-shape,#mermaid-svg-xNsSr0eq2m345qGV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xNsSr0eq2m345qGV .icon-shape p,#mermaid-svg-xNsSr0eq2m345qGV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xNsSr0eq2m345qGV .icon-shape .label rect,#mermaid-svg-xNsSr0eq2m345qGV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xNsSr0eq2m345qGV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xNsSr0eq2m345qGV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xNsSr0eq2m345qGV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 创建 State
_birthDate = 2000-01-01
initState
_calculateAge
计算年/月/日
计算总天数/总周数
setState 更新 UI


六、日期选择器实现

6.1 _selectDate 方法

dart 复制代码
Future<void> _selectDate() async {
  final DateTime? picked = await showDatePicker(
    context: context,
    initialDate: _birthDate,
    firstDate: DateTime(1900),
    lastDate: DateTime.now(),
  );

  if (picked != null && picked != _birthDate) {
    setState(() {
      _birthDate = picked;
    });
    _calculateAge();
  }
}

showDatePicker 会弹出 Material 日期选择器。用户选择日期后,方法会更新 _birthDate 并重新计算年龄。

6.2 日期选择范围

参数 当前值 作用
initialDate _birthDate 打开时默认选中当前生日
firstDate DateTime(1900) 最早可选 1900 年
lastDate DateTime.now() 最晚只能选当前日期

6.3 选择后更新

dart 复制代码
if (picked != null && picked != _birthDate) {
  setState(() {
    _birthDate = picked;
  });
  _calculateAge();
}

如果用户取消选择,pickednull,页面不会更新。如果用户选择了新的日期,页面会保存生日并重新计算。

6.4 生日按钮

dart 复制代码
ElevatedButton.icon(
  onPressed: _selectDate,
  icon: const Icon(Icons.calendar_today),
  label: Text(
    '${_birthDate.year}-${_birthDate.month.toString().padLeft(2, '0')}-${_birthDate.day.toString().padLeft(2, '0')}',
    style: const TextStyle(fontSize: 20),
  ),
)

按钮显示当前生日,并使用日历图标提示可以点击选择日期。


七、年龄计算算法

7.1 _calculateAge 方法

dart 复制代码
void _calculateAge() {
  final now = DateTime.now();
  int years = now.year - _birthDate.year;
  int months = now.month - _birthDate.month;
  int days = now.day - _birthDate.day;

  if (days < 0) {
    months--;
    days += DateTime(now.year, now.month, 0).day;
  }

  if (months < 0) {
    years--;
    months += 12;
  }

  final totalDays = now.difference(_birthDate).inDays;

  setState(() {
    _years = years;
    _months = months;
    _days = days;
    _totalDays = totalDays;
    _totalWeeks = totalDays ~/ 7;
  });
}

这段代码分为四步:先计算直接差值,再处理天数借位,再处理月份借位,最后计算总天数和总周数。

7.2 初始差值

dart 复制代码
int years = now.year - _birthDate.year;
int months = now.month - _birthDate.month;
int days = now.day - _birthDate.day;

直接相减能得到基础差值,但如果当前日期的"日"小于生日的"日",或者当前月份小于生日月份,就需要修正。

7.3 天数借位

dart 复制代码
if (days < 0) {
  months--;
  days += DateTime(now.year, now.month, 0).day;
}

days < 0 时,说明当前月的日期还没到生日那一天,需要从月份借 1。DateTime(now.year, now.month, 0).day 可以得到上个月的天数。

7.4 月份借位

dart 复制代码
if (months < 0) {
  years--;
  months += 12;
}

months < 0 时,说明今年生日月份还没到,需要从年份借 1,并把月份加 12。

7.5 统计总天数和总周数

dart 复制代码
final totalDays = now.difference(_birthDate).inDays;

_totalDays = totalDays;
_totalWeeks = totalDays ~/ 7;

DateTime.difference 返回两个时间点之间的 DurationinDays 得到总天数。总周数使用整数除法 ~/ 7


八、生日倒计时

8.1 _getNextBirthday 方法

dart 复制代码
String _getNextBirthday() {
  final now = DateTime.now();
  var nextBirthday = DateTime(now.year, _birthDate.month, _birthDate.day);
  if (nextBirthday.isBefore(now) || nextBirthday.isAtSameMomentAs(now)) {
    nextBirthday = DateTime(now.year + 1, _birthDate.month, _birthDate.day);
  }
  final daysUntil = nextBirthday.difference(now).inDays;
  return 'In $daysUntil days (${nextBirthday.month}/${nextBirthday.day})';
}

这个方法先构造今年的生日。如果今年生日已经过去或正好是当前日期,就把下一次生日设置为明年。

8.2 判断逻辑

条件 处理
今年生日在当前时间之前 使用明年生日
今年生日等于当前时间 使用明年生日
今年生日在当前时间之后 使用今年生日

8.3 返回文案

text 复制代码
In 120 days (10/1)

返回文案包含剩余天数和下一次生日的月/日。

8.4 闰日边界

如果生日是 2 月 29 日,非闰年构造 DateTime(year, 2, 29) 时 Dart 会进行日期规范化。正式项目中可以单独处理闰日生日策略,例如按 2 月 28 日或 3 月 1 日计算。


九、页面布局与结果卡片

9.1 Scaffold 页面骨架

dart 复制代码
return Scaffold(
  appBar: AppBar(
    title: Text(widget.title),
    backgroundColor: Theme.of(context).colorScheme.inversePrimary,
  ),
  body: Padding(
    padding: const EdgeInsets.all(24.0),
    child: Column(...),
  ),
);

页面使用 Scaffold 提供应用栏,主体区域使用 24 像素内边距。

9.2 主结果卡片

dart 复制代码
Card(
  elevation: 8,
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
  child: Container(
    padding: const EdgeInsets.all(24),
    child: Column(...),
  ),
)

主结果卡片承载年龄拆分、总天数、总周数和估算年龄。

9.3 年月日展示

dart 复制代码
Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    _buildAgeBox(_years.toString(), 'Years'),
    _buildAgeBox(_months.toString(), 'Months'),
    _buildAgeBox(_days.toString(), 'Days'),
  ],
)

三个 AgeBox 结构一致,通过方法复用避免重复代码。

9.4 统计展示

dart 复制代码
Row(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  children: [
    _buildStatBox('$_totalDays', 'Total Days'),
    _buildStatBox('$_totalWeeks', 'Total Weeks'),
    _buildStatBox('${(_totalDays / 365.25).toStringAsFixed(1)}', 'Age'),
  ],
)

统计区域展示总天数、总周数和按 365.25 估算的年龄。


十、AgeBox 与 StatBox 封装

10.1 _buildAgeBox

dart 复制代码
Widget _buildAgeBox(String value, String label) {
  return Container(
    padding: const EdgeInsets.all(16),
    margin: const EdgeInsets.symmetric(horizontal: 8),
    decoration: BoxDecoration(
      color: Colors.teal.shade100,
      borderRadius: BorderRadius.circular(12),
    ),
    child: Column(
      children: [
        Text(
          value,
          style: const TextStyle(
            fontSize: 36,
            fontWeight: FontWeight.bold,
            color: Colors.teal,
          ),
        ),
        Text(label, style: TextStyle(color: Colors.teal.shade700)),
      ],
    ),
  );
}

_buildAgeBox 用于展示 Years、Months、Days 三个大号数字卡片。

10.2 _buildStatBox

dart 复制代码
Widget _buildStatBox(String value, String label) {
  return Column(
    children: [
      Text(
        value,
        style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
      ),
      Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
    ],
  );
}

_buildStatBox 用于展示 Total Days、Total Weeks、Age 三个统计信息。

10.3 组件复用表

方法 用途 视觉重点
_buildAgeBox 年/月/日大号卡片 青绿色背景、大号数字
_buildStatBox 统计数据 数值加粗、标签较小

十一、OpenHarmony 适配边界

11.1 Flutter 层职责

当前项目的业务逻辑全部在 Flutter 层完成。

text 复制代码
Flutter 层:
AgeCalculatorApp
AgeCalculatorHomePage
_AgeCalculatorHomePageState
showDatePicker
_calculateAge
_getNextBirthday
_buildAgeBox
_buildStatBox

OpenHarmony 层:
AppScope
entry
EntryAbility
Index
resources

Flutter 层负责日期选择、年龄计算和页面展示;OpenHarmony 层负责应用启动、模块配置和页面承载。

11.2 平台侧文件作用

层级 文件 作用
应用级 AppScope/app.json5 描述应用整体信息
模块级 entry/src/main/module.json5 描述模块入口和页面
Ability EntryAbility.ets 应用启动入口
页面 Index.ets 承载 Flutter 页面
资源 resources/base 图标、字符串和配置资源
插件 GeneratedPluginRegistrant.ets 插件注册入口

11.3 年龄计算器适配特点

当前项目不需要网络、定位、相机、传感器、数据库或文件读写权限。OpenHarmony 侧主要验证:

  1. 日期选择器是否正常弹出。
  2. 日期范围是否限制在 1900 年到当前日期。
  3. 选择日期后结果是否自动更新。
  4. 年/月/日卡片是否正常布局。
  5. 总天数、总周数和估算年龄是否展示稳定。
  6. 下一次生日倒计时卡片是否正常刷新。

十二、测试与验证

12.1 页面验证路径

年龄计算器的核心验证路径如下:

  1. 启动应用,标题显示 Age Calculator
  2. 默认生日按钮显示 2000-01-01
  3. 页面展示 Your Birthday 文案。
  4. 页面展示 Years、Months、Days 三个年龄卡片。
  5. 页面展示 Total Days、Total Weeks、Age 三个统计项。
  6. 页面展示 Next Birthday 卡片。
  7. 点击生日按钮,日期选择器弹出。
  8. 选择新日期后,年龄结果更新。
  9. 选择今天作为生日时,年龄结果接近 0。
  10. 选择较早日期时,总天数和总周数变大。

12.2 Widget 测试示例

下面的测试验证页面基础内容。

dart 复制代码
testWidgets('shows age calculator page content', (WidgetTester tester) async {
  await tester.pumpWidget(const AgeCalculatorApp());

  expect(find.text('Age Calculator'), findsOneWidget);
  expect(find.text('Your Birthday'), findsOneWidget);
  expect(find.text('Years'), findsOneWidget);
  expect(find.text('Months'), findsOneWidget);
  expect(find.text('Days'), findsOneWidget);
  expect(find.text('Next Birthday'), findsOneWidget);
});

12.3 年龄算法测试示例

可以将年龄算法抽成纯函数后测试。

dart 复制代码
({int years, int months, int days}) calculateAgeParts(
  DateTime birthDate,
  DateTime now,
) {
  int years = now.year - birthDate.year;
  int months = now.month - birthDate.month;
  int days = now.day - birthDate.day;

  if (days < 0) {
    months--;
    days += DateTime(now.year, now.month, 0).day;
  }

  if (months < 0) {
    years--;
    months += 12;
  }

  return (years: years, months: months, days: days);
}

测试用例:

dart 复制代码
test('calculates age parts with borrow', () {
  final result = calculateAgeParts(
    DateTime(2000, 10, 20),
    DateTime(2026, 6, 2),
  );

  expect(result.years, 25);
  expect(result.months, 7);
});

12.4 下一次生日测试示例

dart 复制代码
DateTime nextBirthday(DateTime birthDate, DateTime now) {
  var next = DateTime(now.year, birthDate.month, birthDate.day);
  if (next.isBefore(now) || next.isAtSameMomentAs(now)) {
    next = DateTime(now.year + 1, birthDate.month, birthDate.day);
  }
  return next;
}

测试用例:

dart 复制代码
test('finds next birthday', () {
  final next = nextBirthday(
    DateTime(2000, 10, 1),
    DateTime(2026, 6, 2),
  );

  expect(next.year, 2026);
  expect(next.month, 10);
  expect(next.day, 1);
});

十三、常见问题与优化建议

13.1 为什么不能只用年份相减

如果只计算 now.year - birth.year,会在今年生日还没到时多算一年。当前项目通过 months < 0days < 0 修正这个问题。

13.2 为什么需要天数借位

当当前日期的日小于生日的日时,直接相减会得到负数。此时需要从月份借 1,并加上上个月天数。

13.3 为什么 totalWeeks 使用整数除法

dart 复制代码
_totalWeeks = totalDays ~/ 7;

~/ 表示整数除法。总周数通常展示完整周数,因此小数部分会被舍弃。

13.4 为什么估算年龄使用 365.25

dart 复制代码
(_totalDays / 365.25).toStringAsFixed(1)

365.25 用于粗略考虑闰年影响。它适合做估算展示,但精确年龄仍以年/月/日拆分为准。

13.5 日期选择器为什么限制 lastDate

lastDate: DateTime.now() 可以防止选择未来日期作为生日。年龄计算器通常只处理已经发生的出生日期。

13.6 闰年生日如何进一步处理

当前代码依赖 Dart DateTime 的日期规范化。如果正式应用需要处理 2 月 29 日生日,可以明确约定非闰年按 2 月 28 日或 3 月 1 日计算。

13.7 UI 层可以如何继续优化

  • 将年龄算法抽成纯函数,便于测试。
  • 将日期格式化抽成独立方法。
  • 将 AgeBox 和 StatBox 拆分为独立 Widget。
  • 增加中文日期和中文标签。
  • 增加生日提醒或倒计时进度条。
  • 增加小屏幕滚动支持,避免内容溢出。

十四、完整业务链路复盘

14.1 日期选择链路

  1. 用户点击生日按钮。
  2. _selectDate() 调用 showDatePicker
  3. 用户选择日期。
  4. picked 返回新日期。
  5. _birthDate 更新。
  6. _calculateAge() 重新计算。
  7. 页面展示新结果。

14.2 年龄计算链路

  1. 获取 DateTime.now()
  2. 年、月、日分别直接相减。
  3. 如果 days < 0,月份借位。
  4. 如果 months < 0,年份借位。
  5. 使用 difference 计算总天数。
  6. 使用 ~/ 7 计算总周数。
  7. 使用 setState 更新页面。

14.3 生日倒计时链路

  1. 使用当前年份和生日月日构造今年生日。
  2. 判断今年生日是否已经过去。
  3. 如果已过去或正好是当前时间,则构造明年生日。
  4. 计算下一次生日与当前时间的天数差。
  5. 返回 In X days (M/D) 文案。

14.4 数据到 UI 的映射

数据 处理方式 UI 输出
_birthDate padLeft 日期格式化 生日按钮
_years _buildAgeBox Years
_months _buildAgeBox Months
_days _buildAgeBox Days
_totalDays _buildStatBox Total Days
_totalWeeks _buildStatBox Total Weeks
_totalDays / 365.25 toStringAsFixed(1) Age
_getNextBirthday() 字符串拼接 Next Birthday

十五、核心源码总览

下面集中展示当前项目最关键的年龄计算代码。

dart 复制代码
void _calculateAge() {
  final now = DateTime.now();
  int years = now.year - _birthDate.year;
  int months = now.month - _birthDate.month;
  int days = now.day - _birthDate.day;

  if (days < 0) {
    months--;
    days += DateTime(now.year, now.month, 0).day;
  }

  if (months < 0) {
    years--;
    months += 12;
  }

  final totalDays = now.difference(_birthDate).inDays;

  setState(() {
    _years = years;
    _months = months;
    _days = days;
    _totalDays = totalDays;
    _totalWeeks = totalDays ~/ 7;
  });
}

日期选择器代码如下:

dart 复制代码
Future<void> _selectDate() async {
  final DateTime? picked = await showDatePicker(
    context: context,
    initialDate: _birthDate,
    firstDate: DateTime(1900),
    lastDate: DateTime.now(),
  );

  if (picked != null && picked != _birthDate) {
    setState(() {
      _birthDate = picked;
    });
    _calculateAge();
  }
}

下一次生日计算代码如下:

dart 复制代码
String _getNextBirthday() {
  final now = DateTime.now();
  var nextBirthday = DateTime(now.year, _birthDate.month, _birthDate.day);
  if (nextBirthday.isBefore(now) || nextBirthday.isAtSameMomentAs(now)) {
    nextBirthday = DateTime(now.year + 1, _birthDate.month, _birthDate.day);
  }
  final daysUntil = nextBirthday.difference(now).inDays;
  return 'In $daysUntil days (${nextBirthday.month}/${nextBirthday.day})';
}

这些代码体现了项目主干:选择生日、计算年龄拆分、统计总天数/周数,并计算下一次生日。


总结

age_calculator 是一个很适合学习 Flutter 日期处理和工具类 UI 封装的项目。它通过 showDatePicker 获取生日,通过 _calculateAge() 完成年、月、日借位修正,通过 DateTime.difference 计算总天数,再通过 _buildAgeBox_buildStatBox 展示结果。

从源码结构看,项目的技术重点集中在三条线:第一条是日期选择,showDatePicker 控制可选范围并返回生日;第二条是日期算法,days < 0months < 0 处理借位修正;第三条是结果展示,年龄拆分、总天数、总周数、估算年龄和下一次生日都通过卡片呈现。

从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。它不需要额外权限,但需要重点验证日期选择器弹窗、日期范围、结果卡片布局和下一次生日文案在 OpenHarmony 设备上的展示效果。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

相关推荐
慧海灵舟1 小时前
阿里 AGenUI 开源库前后端实战教程 —— Day 6:鸿蒙端 Playground 官方示例项目解析
华为·开源·harmonyos·写文章,赢小鸿ai
慧海灵舟1 小时前
鸿蒙零基础实战教程Day1:HarmonyOS ArkUI 入门实战
华为·harmonyos
痕忆丶1 小时前
openharmony北向开发基础之访问公共文件目录
harmonyos
特立独行的猫a2 小时前
OHOS (OpenHarmony) 鸿蒙的Rust 交叉编译环境搭建指南
华为·rust·harmonyos·鸿蒙pc
Swift社区2 小时前
HarmonyOS鸿蒙PC平台三方库移植使用vcpkg 移植 Crashpad 过程实战总结
华为·harmonyos
再见6582 小时前
鸿蒙原生开发实战:从零打造一款涂鸦板应用
华为·harmonyos
大雷神2 小时前
第42篇|拍摄预览浮层:让用户确认刚拍的成果
harmonyos
Bowen_J2 小时前
Flutter 屏幕旋转适配
android·flutter·ios
再见6589 小时前
【HarmonyOS】 Todo 应用开发实战
harmonyos