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() 计算下一次生日倒计时。
本文重点回答三个问题:
age_calculator如何使用showDatePicker选择生日。- Flutter 如何通过
DateTime、年月日借位和difference完成年龄计算。 - 这个项目适配 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 项目目标
这个项目的目标是把日期选择和年龄计算的核心链路做完整:
- 用户选择生日。
- 应用保存
_birthDate。 - 应用计算年、月、日差值。
- 应用修正天数和月份借位。
- 应用计算总天数和总周数。
- 应用计算下一次生日倒计时。
- 页面展示结果卡片。
关键点:年龄计算器不能只做
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'),
);
}
}
这里有三个关键信息:
- 应用标题是
Age Calculator。 - 主题种子色使用
Colors.teal。 - 首页是
AgeCalculatorHomePage,日期和年龄状态从首页 State 中维护。
3.3 Material 3 主题
dart
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
useMaterial3: true 会影响 AppBar、ElevatedButton、Card、日期选择器等组件的默认风格。当前项目通过青绿色卡片和按钮强化工具类页面的统一视觉。
四、页面状态设计
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();
}
如果用户取消选择,picked 为 null,页面不会更新。如果用户选择了新的日期,页面会保存生日并重新计算。
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 返回两个时间点之间的 Duration,inDays 得到总天数。总周数使用整数除法 ~/ 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 侧主要验证:
- 日期选择器是否正常弹出。
- 日期范围是否限制在 1900 年到当前日期。
- 选择日期后结果是否自动更新。
- 年/月/日卡片是否正常布局。
- 总天数、总周数和估算年龄是否展示稳定。
- 下一次生日倒计时卡片是否正常刷新。
十二、测试与验证
12.1 页面验证路径
年龄计算器的核心验证路径如下:
- 启动应用,标题显示
Age Calculator。 - 默认生日按钮显示
2000-01-01。 - 页面展示
Your Birthday文案。 - 页面展示 Years、Months、Days 三个年龄卡片。
- 页面展示 Total Days、Total Weeks、Age 三个统计项。
- 页面展示 Next Birthday 卡片。
- 点击生日按钮,日期选择器弹出。
- 选择新日期后,年龄结果更新。
- 选择今天作为生日时,年龄结果接近 0。
- 选择较早日期时,总天数和总周数变大。
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 < 0 和 days < 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 日期选择链路
- 用户点击生日按钮。
_selectDate()调用showDatePicker。- 用户选择日期。
picked返回新日期。_birthDate更新。_calculateAge()重新计算。- 页面展示新结果。
14.2 年龄计算链路
- 获取
DateTime.now()。 - 年、月、日分别直接相减。
- 如果
days < 0,月份借位。 - 如果
months < 0,年份借位。 - 使用
difference计算总天数。 - 使用
~/ 7计算总周数。 - 使用
setState更新页面。
14.3 生日倒计时链路
- 使用当前年份和生日月日构造今年生日。
- 判断今年生日是否已经过去。
- 如果已过去或正好是当前时间,则构造明年生日。
- 计算下一次生日与当前时间的天数差。
- 返回
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 < 0 和 months < 0 处理借位修正;第三条是结果展示,年龄拆分、总天数、总周数、估算年龄和下一次生日都通过卡片呈现。
从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。它不需要额外权限,但需要重点验证日期选择器弹窗、日期范围、结果卡片布局和下一次生日文案在 OpenHarmony 设备上的展示效果。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
-
Flutter 官方文档:https://docs.flutter.dev/
-
Dart 官方文档:https://dart.dev/guides
-
OpenHarmony 官网:https://www.openharmony.cn/