Flutter 实战:date_calculator 日期计算器的日期选择、间隔拆解与 鸿蒙适配解析

前言

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

日期计算器是一个非常适合练习 Flutter 交互状态的项目:它既有日期选择器,又有时间差计算;既要展示天数、周数、月数、年份差,又要支持基于起始日期增加天数得到结果日期。date_calculator 用一个单页应用覆盖了这些能力,代码结构清晰,适合用来分析 Flutter 小工具应用 的完整实现路径。

这篇文章会围绕 date_calculator 的真实实现展开,重点讲清楚:

  • DateTime 状态如何初始化和更新。
  • showDatePicker 如何接入页面交互。
  • 天、周、月、年四类间隔如何计算。
  • Add Days 模式为什么使用 StatefulBuilder
  • 这类纯 Flutter 工具应用迁移到 OpenHarmony 时要关注哪些点。

日期工具看似只是“两个日期相减”,但真正落到应用里,还要处理日期选择、结果表达、月份修正、局部输入、跨端弹窗和布局稳定性。

在这里插入图片描述

图示说明:上图展示 Flutter 页面在移动端的布局组织方式。date_calculator 的页面由模式切换区、日期选择卡片、结果卡片和间隔拆解区域组成。

一、项目定位与功能边界

1.1 应用定位

date_calculator 是一个日期计算工具,主要解决两类问题:

  1. 两个日期之间相差多少天。
  2. 从某个起始日期增加指定天数后是哪一天。

这类工具常见于排期、学习计划、项目周期、账期、活动周期和倒计时计算等场景。

1.2 功能模块

功能模块 页面表现 源码实现
日期间隔模式 Days Between 标签 _selectedMode == 0
加天数模式 Add Days 标签 _selectedMode == 1
起始日期 Start Date 选择器 _startDate
结束日期 End Date 选择器 _endDate
日期选择弹窗 系统样式日期选择器 showDatePicker
间隔拆解 Days、Weeks、Months、Years 多个 getter
结果日期 输入天数后显示新日期 _startDate.add(Duration(days: n))

1.3 技术栈

技术 使用位置 价值
Flutter 页面、卡片、输入、日期选择 快速搭建跨端 UI
Dart 日期运算与状态逻辑 DateTimeDuration、getter 简洁表达业务
Material 3 主题与组件风格 useMaterial3: true
StatefulWidget 页面状态管理 日期、模式、输入变化都需要刷新
StatefulBuilder Add Days 局部状态 在局部区域内刷新输入结果

二、工程结构与运行环境

2.1 目录结构

date_calculator 保持了标准 Flutter 工程结构,主逻辑集中在 lib/main.dart

文件或目录 作用
lib/main.dart 应用入口、首页状态、日期算法和 UI 构建
pubspec.yaml 依赖声明、Flutter 资源配置
test/widget_test.dart Widget 测试入口
ohos/ OpenHarmony 平台工程
analysis_options.yaml Dart 静态分析规则

2.2 基础运行命令

flutter doctor
flutter pub get
flutter run

这三个命令分别用于检查 Flutter 环境、恢复依赖和启动应用。当前项目依赖较轻,主要依赖 Flutter SDK 自带能力。

2.3 依赖声明

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

这个项目没有使用复杂原生插件,日期计算也不依赖网络服务,因此它的跨端验证重点主要集中在 UI、日期选择器、输入行为和窗口尺寸上。

三、应用入口与主题配置

3.1 main 函数

Flutter 应用从 main() 进入,随后加载根组件。

import 'package:flutter/material.dart';

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

入口函数没有业务逻辑,只负责启动应用。这样可以让根组件承担应用配置,让首页承担交互逻辑。

3.2 根组件

class DateCalculatorApp extends StatelessWidget {
  const DateCalculatorApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Date Calculator',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.brown),
        useMaterial3: true,
      ),
      home: const DateCalculatorHomePage(title: 'Date Calculator'),
    );
  }
}

根组件使用 StatelessWidget,因为它不保存日期、模式或输入状态。它的职责是配置应用标题、主题和首页。

3.3 主题选择

date_calculator 使用棕色作为种子色:

colorScheme: ColorScheme.fromSeed(seedColor: Colors.brown)

棕色主题会影响 AppBar、模式选中态、结果卡片和统计项颜色,使页面视觉保持一致。

四、StatefulWidget 与核心状态

4.1 首页组件

class DateCalculatorHomePage extends StatefulWidget {
  const DateCalculatorHomePage({super.key, required this.title});
  final String title;

  
  State<DateCalculatorHomePage> createState() =>
      _DateCalculatorHomePageState();
}

首页之所以使用 StatefulWidget,是因为日期选择、模式切换和加天数输入都会改变页面显示。

4.2 状态字段

class _DateCalculatorHomePageState extends State<DateCalculatorHomePage> {
  DateTime _startDate = DateTime.now().subtract(const Duration(days: 30));
  DateTime _endDate = DateTime.now();
  int _selectedMode = 0;
}

这三个字段决定了页面的主要状态。

字段 类型 作用
_startDate DateTime 起始日期,默认当前日期前 30 天
_endDate DateTime 结束日期,默认当前日期
_selectedMode int 当前模式,0 表示间隔计算,1 表示加天数

4.3 默认日期的意义

默认把起始日期设置为当前日期前 30 天,结束日期设置为当前日期,应用一打开就能展示一个明确结果,而不是空白页面。

DateTime _startDate = DateTime.now().subtract(const Duration(days: 30));
DateTime _endDate = DateTime.now();

这种默认值设计对工具类应用很友好:用户进入页面后可以先理解结果,再根据需要调整日期。

五、日期间隔计算逻辑

5.1 天数差

int get _daysBetween => _endDate.difference(_startDate).inDays.abs();

difference 返回两个日期之间的 DurationinDays 取天数,abs() 保证结果为正数。这样无论用户先选较早日期还是较晚日期,页面都展示正向间隔。

5.2 周数差

int get _weeksBetween => (_daysBetween / 7).floor();

周数差由天数差除以 7 后向下取整,表示完整周数。

天数差 完整周数
6 0
7 1
15 2
30 4

5.3 年份差

int get _yearsBetween => (_endDate.year - _startDate.year);

年份差直接取两个日期的年份字段相减。这里要注意,当前源码没有对月份和日期做周年修正,因此它表达的是年份字段差,而不是严格意义上的完整周年数。

日期计算要区分“字段差”和“完整周期差”。前者实现简单,后者通常需要更多边界规则。

六、月份差修正算法

6.1 月份差计算方法

int _getMonthsDifference() {
  int months = (_endDate.year - _startDate.year) * 12;
  months += _endDate.month - _startDate.month;
  if (_endDate.day < _startDate.day) {
    months--;
  }
  return months.abs();
}

月份差比天数差复杂,因为不同月份天数不同,不能简单用天数除以 30。

6.2 算法步骤

  1. 先用年份差乘以 12,得到跨年的基础月份。
  2. 再加上结束月份与起始月份的差。
  3. 如果结束日小于起始日,说明最后一个月还没有走满,扣减一个月。
  4. 最后使用 abs() 保证展示正数。

6.3 示例表

起始日期 结束日期 计算说明 月份差
Jan 10, 2026 Feb 10, 2026 正好满一个月 1
Jan 15, 2026 Feb 10, 2026 结束日小于起始日 0
Jan 01, 2026 Mar 01, 2026 满两个月 2
Nov 20, 2025 Feb 19, 2026 最后一个月未满 2

这个算法适合轻量展示“整月差”,可读性强,计算成本低。

七、日期选择器接入

7.1 showDatePicker 调用

项目通过 _selectDate 打开日期选择器。

Future<void> _selectDate(bool isStart) async {
  final DateTime? picked = await showDatePicker(
    context: context,
    initialDate: isStart ? _startDate : _endDate,
    firstDate: DateTime(1900),
    lastDate: DateTime(2100),
  );

  if (picked != null) {
    setState(() {
      if (isStart) {
        _startDate = picked;
      } else {
        _endDate = picked;
      }
    });
  }
}

showDatePicker 是 Flutter Material 提供的日期选择弹窗,返回值可能为空。用户取消选择时,pickednull

7.2 参数设计

参数 当前取值 含义
context 当前页面上下文 用于展示弹窗
initialDate 起始或结束日期 打开弹窗时默认选中
firstDate DateTime(1900) 可选最早日期
lastDate DateTime(2100) 可选最晚日期

7.3 写入状态

isStart 决定更新哪个日期。

if (isStart) {
  _startDate = picked;
} else {
  _endDate = picked;
}

这让起始日期选择器和结束日期选择器复用同一个方法,减少重复逻辑。

八、日期格式化方法

8.1 月份缩写数组

源码使用英文月份缩写数组格式化日期。

String _formatDate(DateTime date) {
  final months = [
    'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
    'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
  ];
  return '${months[date.month - 1]} ${date.day}, ${date.year}';
}

格式化后的结果类似 Jun 10, 2026

8.2 数组索引

DateTime.month 的取值范围是 1 到 12,而数组索引从 0 开始,因此需要 date.month - 1

months[date.month - 1]

这是一处很小但很重要的细节。日期格式化一旦索引错误,就会出现月份偏移。

8.3 展示格式

日期对象 展示结果
DateTime(2026, 1, 1) Jan 1, 2026
DateTime(2026, 6, 10) Jun 10, 2026
DateTime(2026, 12, 31) Dec 31, 2026

当前实现适合英文界面。如果面向中文用户,可以结合本地化方案展示为 2026年6月10日

九、页面布局结构

9.1 Scaffold 页面骨架

return Scaffold(
  appBar: AppBar(
    title: Text(widget.title),
    backgroundColor: Theme.of(context).colorScheme.inversePrimary,
  ),
  body: SingleChildScrollView(
    padding: const EdgeInsets.all(24),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        // 模式切换、日期选择、结果卡片、拆解卡片
      ],
    ),
  ),
);

页面主体使用 SingleChildScrollView,可以避免小屏幕或横屏情况下内容溢出。

9.2 模块顺序

页面从上到下依次是:

  1. 模式切换区。
  2. 起始日期与结束日期选择卡片。
  3. 结果卡片。
  4. 间隔拆解卡片。

这种顺序符合工具类应用的使用路径:先选择模式,再输入条件,最后查看结果。

9.3 布局组件表

组件 用途
Scaffold 页面基础结构
AppBar 顶部标题
SingleChildScrollView 允许内容滚动
Column 纵向排列模块
Card 承载日期和结果区域
Row 横向排列日期选择器
Expanded 平分横向空间

十、模式切换实现

10.1 模式容器

Container(
  decoration: BoxDecoration(
    color: Colors.brown.shade50,
    borderRadius: BorderRadius.circular(12),
  ),
  child: Row(
    children: [
      _buildModeTab(0, 'Days Between', Icons.calendar_view_day),
      _buildModeTab(1, 'Add Days', Icons.add_circle),
    ],
  ),
)

两个模式放在同一个容器中,视觉上形成分段控制器。

10.2 单个模式标签

Widget _buildModeTab(int mode, String label, IconData icon) {
  final isSelected = _selectedMode == mode;
  return Expanded(
    child: GestureDetector(
      onTap: () => setState(() => _selectedMode = mode),
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 12),
        decoration: BoxDecoration(
          color: isSelected ? Colors.brown : Colors.transparent,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(icon, size: 18),
            const SizedBox(width: 4),
            Text(label),
          ],
        ),
      ),
    ),
  );
}

GestureDetector 负责点击切换,isSelected 负责控制颜色和字重。

10.3 模式对 UI 的影响

模式 结果标题 主结果区域 额外区域
Days Between Time Difference 天数差 Breakdown 卡片
Add Days Result Date 输入天数和结果日期 不展示 Breakdown

这种条件渲染让一个页面复用两套功能,同时保持结构简单。

十一、日期选择卡片

11.1 日期选择区域

Card(
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: Row(
      children: [
        Expanded(
          child: _buildDateSelector(
            'Start Date',
            _startDate,
            () => _selectDate(true),
          ),
        ),
        const SizedBox(width: 16),
        Expanded(
          child: _buildDateSelector(
            'End Date',
            _endDate,
            () => _selectDate(false),
          ),
        ),
      ],
    ),
  ),
)

两个日期选择器放在同一行,结构对称,便于比较。

11.2 选择器组件

Widget _buildDateSelector(
  String label,
  DateTime date,
  VoidCallback onTap,
) {
  return GestureDetector(
    onTap: onTap,
    child: Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey.shade300),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label),
          const SizedBox(height: 4),
          Text(_formatDate(date)),
        ],
      ),
    ),
  );
}

这个组件把标签、日期文本和点击行为封装到一起,减少了 build() 方法里的重复代码。

11.3 交互路径

点击 Start Date
打开日期选择器
用户选择日期
picked 不为空
setState 更新 _startDate
getter 重新计算间隔
页面刷新

结束日期选择器的流程完全一致,只是写入 _endDate

十二、结果卡片与间隔拆解

12.1 结果卡片

Card(
  elevation: 8,
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(20),
  ),
  child: Container(
    padding: const EdgeInsets.all(24),
    decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(20),
      gradient: LinearGradient(
        colors: [Colors.brown.shade100, Colors.brown.shade50],
      ),
    ),
    child: Column(
      children: [
        Text(_selectedMode == 0 ? 'Time Difference' : 'Result Date'),
      ],
    ),
  ),
)

结果卡片使用大圆角、阴影和渐变背景,让关键结果在页面中更醒目。

12.2 天数结果

if (_selectedMode == 0) ...[
  Text(
    '$_daysBetween',
    style: const TextStyle(
      fontSize: 64,
      fontWeight: FontWeight.bold,
      color: Colors.brown,
    ),
  ),
  const Text('days'),
]

日期间隔模式下,页面突出展示天数,因为天数是最直观、最常用的结果。

12.3 Breakdown 卡片

if (_selectedMode == 0)
  Card(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('Breakdown'),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildBreakdownItem('Days', _daysBetween.toString()),
              _buildBreakdownItem('Weeks', _weeksBetween.toString()),
              _buildBreakdownItem('Months', _monthsBetween.toString()),
              _buildBreakdownItem('Years', _yearsBetween.toString()),
            ],
          ),
        ],
      ),
    ),
  )

拆解卡片让用户同时看到天、周、月、年四种视角。

十三、Add Days 模式解析

13.1 局部输入实现

Add Days 模式由 _buildAddDaysResult() 负责。

Widget _buildAddDaysResult() {
  final addController = TextEditingController();
  return StatefulBuilder(
    builder: (context, setState) {
      int daysToAdd = int.tryParse(addController.text) ?? 0;
      DateTime resultDate = _startDate.add(Duration(days: daysToAdd));

      return Column(
        children: [
          TextField(
            controller: addController,
            keyboardType: TextInputType.number,
            onChanged: (value) {
              setState(() {
                daysToAdd = int.tryParse(value) ?? 0;
                resultDate = _startDate.add(Duration(days: daysToAdd));
              });
            },
          ),
          Text(_formatDate(resultDate)),
        ],
      );
    },
  );
}

这段代码使用局部 StatefulBuilder 处理 Add Days 输入,而不是把输入控制器放到页面 State 顶层。

13.2 计算公式

DateTime resultDate = _startDate.add(Duration(days: daysToAdd));

公式非常直观:以起始日期为基准,增加输入的天数。

Start Date Days to add Result Date
Jun 10, 2026 1 Jun 11, 2026
Jun 10, 2026 7 Jun 17, 2026
Jun 10, 2026 30 Jul 10, 2026

13.3 局部状态的特点

StatefulBuilder 可以让局部区域重新构建,适合这种范围很小的临时输入。当前实现能正常表达 Add Days 的交互,但由于控制器在方法内部创建,复杂场景下可以进一步放到 State 字段中统一管理生命周期。

十四、边界输入与日期规则

14.1 日期选择范围

firstDate: DateTime(1900),
lastDate: DateTime(2100),

这个范围覆盖了大多数日常使用场景,也避免用户选择过于极端的日期。

14.2 输入天数解析

int daysToAdd = int.tryParse(addController.text) ?? 0;

输入为空或无法解析时回退为 0,因此结果日期会保持为起始日期。

14.3 日期顺序

天数差和月份差都使用了绝对值:

_endDate.difference(_startDate).inDays.abs();

这意味着即使用户把结束日期选在起始日期之前,页面也会展示正数间隔。

14.4 边界场景表

场景 当前表现 说明
起始日期早于结束日期 正常展示间隔 标准场景
起始日期晚于结束日期 展示正数间隔 使用 abs()
取消日期选择 不更新日期 picked == null
Add Days 输入为空 增加 0 天 回退为 0
Add Days 输入非数字 增加 0 天 tryParse 失败

十五、Widget 测试设计

15.1 基础渲染测试

import 'package:flutter_test/flutter_test.dart';
import '../lib/main.dart';

void main() {
  testWidgets('date calculator renders home page', (tester) async {
    await tester.pumpWidget(const DateCalculatorApp());

    expect(find.text('Date Calculator'), findsWidgets);
    expect(find.text('Days Between'), findsOneWidget);
    expect(find.text('Add Days'), findsOneWidget);
  });
}

这类测试能验证根组件、标题和模式切换区是否正常渲染。

15.2 日期选择器入口测试

testWidgets('date selectors are visible', (tester) async {
  await tester.pumpWidget(const DateCalculatorApp());

  expect(find.text('Start Date'), findsOneWidget);
  expect(find.text('End Date'), findsOneWidget);
});

由于日期选择器属于弹窗交互,测试时可以先覆盖入口控件是否存在。

15.3 Add Days 模式测试

testWidgets('add days mode shows input field', (tester) async {
  await tester.pumpWidget(const DateCalculatorApp());

  await tester.tap(find.text('Add Days'));
  await tester.pump();

  expect(find.text('Days to add'), findsOneWidget);
  expect(find.text('Result Date'), findsOneWidget);
});

这个测试覆盖模式切换后的条件渲染。

15.4 测试命令

flutter test

如果测试入口引用的根组件与当前源码不一致,Flutter 会在编译阶段直接暴露问题。保持测试代码和真实组件名称一致,是小项目持续维护的基础。

十六、OpenHarmony 适配观察

16.1 适配优势

date_calculator 的业务逻辑主要由 Dart 和 Flutter Widget 实现,没有复杂平台通道和原生能力依赖。

维度 当前项目情况 OpenHarmony 关注点
日期算法 Dart DateTime 多端计算结果一致
日期选择器 showDatePicker 弹窗样式和交互验证
输入框 TextField 数字键盘和焦点行为
图标 Material Icons 字体资源显示
布局 标准 Widget 小屏、横屏、窗口尺寸

16.2 构建命令参考

flutter clean
flutter pub get
flutter build hap

具体构建命令与所使用的 OpenHarmony Flutter 适配环境有关。对这个项目而言,业务层逻辑较独立,主要验证点集中在渲染和交互。

16.3 运行验证要点

  1. 应用能正常启动到首页。
  2. Days BetweenAdd Days 可以切换。
  3. 起始日期和结束日期都能打开日期选择器。
  4. 日期选择后天数差、周数、月份差同步刷新。
  5. Add Days 输入后结果日期能即时变化。
  6. 小屏下日期选择卡片不出现明显溢出。

OpenHarmony 适配中,纯 Dart 计算通常不是主要风险,真正需要细看的往往是弹窗、输入法、字体和窗口尺寸。

十七、可维护性与演进方向

17.1 当前实现的优点

维度 表现
状态集中 起始日期、结束日期、模式都在 State 中
方法清晰 日期选择、格式化、月份计算分别封装
视觉直观 结果卡片和拆解卡片层级清楚
依赖简单 没有额外日期库或平台插件

17.2 日期算法抽离

如果项目继续扩展,可以把日期计算逻辑抽离为纯函数。

int daysBetween(DateTime start, DateTime end) {
  return end.difference(start).inDays.abs();
}

int weeksBetween(DateTime start, DateTime end) {
  return (daysBetween(start, end) / 7).floor();
}

DateTime addDays(DateTime start, int days) {
  return start.add(Duration(days: days));
}

抽离后的函数可以单独做单元测试,页面只负责收集输入和展示结果。

17.3 模式枚举

当前 _selectedMode 使用 01 表示模式。随着功能增加,可以使用枚举提升可读性。

enum DateCalculatorMode {
  daysBetween,
  addDays,
}

枚举能减少魔法数字,让代码语义更稳定。

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

18.1 为什么天数差使用绝对值

用户选择日期时不一定保证起始日期早于结束日期。使用 abs() 后,页面始终展示正数间隔,适合多数日常计算场景。

18.2 月份差为什么不能用天数除以 30

因为每个月天数不同,二月、闰年、大小月都会影响结果。当前源码按年份和月份字段计算,再根据日期字段修正,更接近“完整月数”的语义。

18.3 年份差是否等于完整周年数

当前 _yearsBetween 直接使用年份字段相减,没有判断月份和日期,因此它是年份字段差,不是严格完整周年数。文章分析时要按源码真实表现理解。

18.4 Add Days 为什么使用局部 StatefulBuilder

Add Days 的输入只影响结果卡片内部,不影响页面其他区域。StatefulBuilder 可以让局部输入和结果刷新保持在较小范围内。

18.5 为什么日期选择范围是 1900 到 2100

这个范围覆盖常见历史和未来日期需求,同时避免选择过于极端的日期。对日常排期、账期、纪念日和项目周期来说已经足够。

18.6 为什么不引入日期处理库

当前需求只涉及基础日期差、月份差和加天数,Dart 内置 DateTimeDuration 已经可以完成。引入额外库会增加依赖和跨端验证成本。

总结

date_calculator 展示了 Flutter 日期工具应用的一条清晰实现路径:使用 DateTime 保存起始日期和结束日期,使用 showDatePicker 完成日期选择,使用 getter 计算天数、周数、月份差和年份字段差,再通过结果卡片和 Breakdown 区域展示给用户。

从工程角度看,它的优势是依赖简单、状态集中、交互路径短,非常适合用于学习 Flutter 表单类、工具类页面的开发方式。对于 OpenHarmony 适配来说,这类项目没有复杂原生插件,关键是验证日期选择器、输入法、字体图标、滚动布局和不同屏幕尺寸下的展示效果。

如果继续扩展,可以把日期算法抽离为纯函数,为间隔计算和 Add Days 增加单元测试,并将模式编号升级为枚举。这样既保留单页工具的轻量感,也能让代码更容易长期维护。

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


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐