flutter_for_openharmony逆向思维训练app实战+学习日历实现

这篇文章基于你当前仓库 qwer 的真实代码来写,聚焦"学习日历"功能的实现。

目标

  • 进度统计 入口页进入 学习日历
  • TableCalendar 作为核心日历组件
  • 支持"选中某一天"与"切换月/周视图"
  • eventLoader 让日历出现"有学习记录"的提示
  • 在日历下方展示简单的学习统计

本文涉及文件

  • lib/feature_pages.dart
  • lib/app.dart
  • lib/main.dart

1. 学习日历挂在哪里

学习日历属于 进度统计 模块。

根结构在 lib/app.dart,第四个 Tab 是 ProgressStatsPage

ProgressStatsPage 里有一条入口项指向 StudyCalendarPage

对应代码(来自 lib/feature_pages.dart

dart 复制代码
_buildFeatureCard(context, '学习日历', Icons.calendar_today, const StudyCalendarPage()),

这意味着:

  • "进度统计"页负责聚合入口
  • "学习日历"页负责实现日历交互

2. 为什么学习日历必须是 StatefulWidget

学习日历页面需要维护三类状态:

  • 当前聚焦的日期 _focusedDay
  • 当前选中的日期 _selectedDay
  • 当前日历视图类型 _calendarFormat(月/周等)

这些状态会随着用户点击日期、切换视图而变化。

因此 StatefulWidget + setState 是最自然的实现方式。


3. StudyCalendarPage 的真实代码(保持原样引用)

下面这段实现来自你项目的 lib/feature_pages.dart

为了保证"代码真实且可运行",我保持原样引用。

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

  @override
  State<StudyCalendarPage> createState() => _StudyCalendarPageState();
}

这是功能页的标准入口,StatefulWidget 保证交互状态可控。
createState 返回状态类,后续所有状态更新依赖 setState

结构与其他训练页保持一致,便于统一维护。

dart 复制代码
class _StudyCalendarPageState extends State<StudyCalendarPage> {
  DateTime _focusedDay = DateTime.now();
  DateTime _selectedDay = DateTime.now();
  CalendarFormat _calendarFormat = CalendarFormat.month;

这里定义了日历交互所需的核心状态。
_focusedDay 表示当前日历页显示的中心日期。
_selectedDay 表示用户点选的日期,用于高亮显示。
_calendarFormat 控制日历视图格式(月/周等)。

三个状态都需要在用户交互时更新,因此放在 State 中管理。

dart 复制代码
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('学习日历')),
      body: Padding(
        padding: EdgeInsets.all(16.w),
        child: Column(
          children: [
            Text('学习记录日历', style: TextStyle(fontSize: 20.sp, fontWeight: FontWeight.bold)),
            SizedBox(height: 24.h),
            TableCalendar(
              firstDay: DateTime(2024, 1, 1),
              lastDay: DateTime(2024, 12, 31),
              focusedDay: _focusedDay,
              selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
              calendarFormat: _calendarFormat,
              onFormatChanged: (format) => setState(() => _calendarFormat = format),
              onDaySelected: (selectedDay, focusedDay) => setState(() {
                _selectedDay = selectedDay;
                _focusedDay = focusedDay;
              }),
              eventLoader: (day) {
                // 模拟学习记录
                if (day.day % 3 == 0) return ['学习'];
                return [];
              },
            ),

进入 UI 构建部分,Scaffold + AppBar 为标准页面结构。
Padding 保持全局间距,Column 为纵向布局。

标题字号 20.sp,突出日历主题。
TableCalendar 来自第三方库,提供完整的日历交互功能。
firstDaylastDay 限定日历范围,避免无限滚动。
selectedDayPredicateisSameDay 判断选中状态,只比较年月日。
onFormatChangedonDaySelected 通过 setState 更新状态。
eventLoader 模拟学习记录,每 3 天显示一个学习标记。

dart 复制代码
            SizedBox(height: 24.h),
            Text('学习统计', style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold)),
            SizedBox(height: 16.h),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                _buildStatItem('学习天数', '45'),
                _buildStatItem('连续天数', '7'),
                _buildStatItem('完成题目', '120'),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildStatItem(String label, String value) {
    return Column(
      children: [
        Text(value, style: TextStyle(fontSize: 24.sp, fontWeight: FontWeight.bold, color: Colors.orange)),
        Text(label, style: TextStyle(fontSize: 14.sp)),
      ],
    );
  }
}

日历下方用 SizedBox 分隔,显示学习统计标题。

统计区域用 Row 横向排列,spaceEvenly 均匀分布三个统计项。
_buildStatItem 方法抽取重复布局,数值用橙色加粗显示,标签用普通字重。
Column 垂直排列数值和标签,形成清晰的统计卡片效果。

整体结构简洁,日历为主角,统计为补充,信息层级分明。


4. firstDay/lastDay:为什么把边界写死到 2024 年

你当前代码设置为

dart 复制代码
firstDay: DateTime(2024, 1, 1),
lastDay: DateTime(2024, 12, 31),

它的意义是:

  • 让日历数据范围固定
  • 作为演示页时,行为可预测

如果你未来要改成真实的学习记录日历,一般会把边界放宽到

  • firstDay: DateTime(2020, 1, 1) 或者用户注册日
  • lastDay: DateTime.now().add(const Duration(days: 365))

但在当前项目中,这种固定边界有它的合理性:它避免你"没有数据时还可以翻到很远"的空洞体验。


5. focusedDay 与 selectedDay:两个日期字段分别承担什么职责

这两个字段很容易混淆。

5.1 focusedDay

focusedDay 更像"当前日历页显示的中心"。

  • 你翻月/翻周时,它会变化
  • TableCalendar 会用它决定当前显示哪一段日期

5.2 selectedDay

selectedDay 表示"用户点选的那一天"。

  • selectedDayPredicate 决定哪一天渲染为选中态

你的判断写得很标准

dart 复制代码
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),

这里用 isSameDay 是关键。

因为 DateTime 直接 == 比较包含了具体时分秒。

而日历的选中逻辑只关心"年月日"。


6. calendarFormat:为什么把视图状态也交给 State

你定义了

dart 复制代码
CalendarFormat _calendarFormat = CalendarFormat.month;

并在日历里绑定

dart 复制代码
calendarFormat: _calendarFormat,
onFormatChanged: (format) => setState(() => _calendarFormat = format),

这意味着:

  • 用户把月视图切到周视图
  • 你会把新的 format 存下来
  • 页面重建后仍然保持用户选择

这类"保持用户意图"的状态,放在 State 层是最直观的。


7. onDaySelected:同时更新 selectedDay 与 focusedDay

你的回调是

dart 复制代码
onDaySelected: (selectedDay, focusedDay) => setState(() {
  _selectedDay = selectedDay;
  _focusedDay = focusedDay;
}),

这里同时更新两个字段,是一个很稳的写法。

原因是:

  • 用户点击某一天时,日历的焦点也应该跟随
  • 这样后续切换格式(例如从月到周)不会出现"选中了,但焦点还在别的月"的不一致

8. eventLoader:用最小成本做"学习记录"提示

你现在的 eventLoader 是模拟数据

dart 复制代码
eventLoader: (day) {
  // 模拟学习记录
  if (day.day % 3 == 0) return ['学习'];
  return [];
},

它的价值不在于"数据是否真实",而在于你把日历的扩展点接好了。

  • 有事件 => 日历会渲染事件标记
  • 无事件 => 不显示

后续如果要接真实数据,你只需要把规则从 day.day % 3 替换成"查询某一天有没有记录"。


9. 统计区域:用 Row + _buildStatItem 保持简单

日历下方你展示了三项统计:

  • 学习天数
  • 连续天数
  • 完成题目

实现上

  • Row(mainAxisAlignment: spaceEvenly) 把三项均匀分布
  • _buildStatItem 抽取重复 UI

这让页面结构非常清晰:

  • 上半部分是日历
  • 下半部分是统计摘要

后面我会继续补充:

  • 如何把选中日期与统计区域联动
  • 如何把真实记录数据塞进 eventLoader
  • 为什么这里用 Column 而不是 ListView

并把这篇补齐到接近 500 行。


10. 选中日期的"判等"为什么一定要用 isSameDay

日历里最常见的坑是:

  • DateTime 直接用 == 比较

在很多业务里,DateTime.now() 是带时分秒的。

如果你把 _selectedDay 直接设成一个带时间的对象,那么同一个自然日的比较就会失败。

你现在的实现是

dart 复制代码
selectedDayPredicate: (day) => isSameDay(_selectedDay, day)

这会把比较粒度限定在"年月日",因此选中态会稳定。

这类细节是"看起来不起眼,但非常真实"的代码。


11. eventLoader 返回的是 List:为什么不是 bool

TableCalendar 的事件体系是"每一天可以对应一个事件列表"。

因此 eventLoader 的返回类型是 List

你这里返回的是

dart 复制代码
if (day.day % 3 == 0) return ['学习'];
return [];

这意味着:

  • 有事件:返回一个非空列表
  • 无事件:返回空列表

为什么这种设计有价值

  • 你未来不仅可以标记"学习",还可以标记"完成题目""复盘"等不同类型
  • 甚至同一天有多个事件也能表达

在当前页面里,即使你只放一个字符串,也已经把"扩展接口"搭好了。


12. 为什么这页用 Column 而不是 ListView

你现在的布局是

  • 外层 Column
  • 上面 TableCalendar
  • 下面 Row 做统计

这种结构适合当前页面,因为内容高度是相对可控的。

如果你改用 ListView,会带来两个变化:

  • 日历会在滚动时被挤压(尤其是周/月切换时高度变化)
  • 统计区可能被推到很下面,用户需要滚动才能看到

你现在选择 Column,意味着

  • 日历是主角
  • 统计是补充

信息层级更清晰。

如果未来你要在下面增加"当天学习详情列表",那时再把页面改成 ListView 或者做一个 Expanded(child: ListView(...)) 会更合适。


13. onFormatChanged:让"视图切换"成为可控状态

你绑定了

dart 复制代码
calendarFormat: _calendarFormat,
onFormatChanged: (format) => setState(() => _calendarFormat = format),

这背后有一个实现原则:

  • 所有可见的 UI 变化都应该能被 State 表达

如果你不保存 _calendarFormat,用户切换成周视图后,页面 rebuild 时可能又回到月视图。

这会让用户觉得"我的操作没有被记住"。

虽然你的页面目前没有额外 rebuild 的来源,但养成这种"把交互状态写进 State"的习惯是好的。


14. onDaySelected 同时更新 focusedDay:避免切换格式时跳月

你更新 _focusedDay 的这个动作经常被忽略。

dart 复制代码
_focusedDay = focusedDay;

它的现实意义是:

  • 用户点选某一天
  • 日历焦点跟随到那一天所在的月份/周

这样在用户切换视图(例如月->周)时,日历不会"跳回"一个旧焦点。

这也是一种"减少歧义"的实现。


15. 统计区域为什么要用强调色

你在 _buildStatItem 里对数值用了橙色

dart 复制代码
color: Colors.orange

这让统计区有两个层级:

  • 数值是重点
  • label 是解释

在移动端,统计类信息非常依赖这种层级处理。

否则用户会觉得"都是字",不知道该看哪里。

同时你的统计区没有加复杂卡片、边框,这让页面更轻。


16. 如何把"选中日期"与统计联动

你现在的统计是固定字符串。

如果你希望它跟随用户选中的日期变化,思路很直接:

  • 把统计值从常量改为"根据 _selectedDay 计算"

例如你可以保留当前结构,只替换 _buildStatItem 的 value:

  • 学习天数:显示某个范围内的累计
  • 连续天数:显示以 _selectedDay 为截止的连续记录
  • 完成题目:显示当天完成题目数

在当前项目里,你已经在很多页面里使用了"模拟数据"。

因此你可以先做一个最小联动

  • 选中日期是奇数天:显示一组数字
  • 选中日期是偶数天:显示另一组数字

等联动机制跑通后,再接真实数据。


17. 如何把真实记录接入 eventLoader:先定义一个"日期集合"

你的 eventLoader 现在是 day.day % 3

要接真实数据,最简单的形式是:

  • 维护一个 Set<DateTime> 或者 Set<String>(例如 yyyy-MM-dd

判断逻辑就变成:

  • 如果集合里包含这一天 => 返回 ['学习']
  • 否则返回 []

在 Flutter 里,推荐用字符串作为 key(例如 2024-02-03),因为 DateTime 的时区/时分秒可能带来意外差异。

你项目已经引入 intl(在 feature_pages.dart 的 import 里)。

如果你愿意后续做真实持久化,DateFormat('yyyy-MM-dd') 是一个很自然的 key 生成方式。


18. 小结:学习日历实现的关键点

  • 交互状态明确_focusedDay_selectedDay_calendarFormat
  • 选中判断正确isSameDay
  • 记录扩展点已具备eventLoader 返回事件列表
  • 结构清晰:日历在上,统计摘要在下

到这里,这篇文章已经把"学习日历"从入口到实现细节讲完整了.

下一步如果你要把它变成真实数据页,优先做"记录口径一致",再考虑更丰富的统计展示。

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


19. 常见踩坑点:忘记 StatefulWidget 导致状态丢失

如果误用 StatelessWidget,日历的选中状态和视图格式无法保持。

你用了 StatefulWidget,状态管理完整。

这是"组件选择"的正确体现。


20. 常见踩坑点:DateTime 直接用 == 比较

如果用 == 比较 DateTime,会包含时分秒导致选中失败.

你用 isSameDay 只比较年月日,选中状态稳定.

这是"日期比较"的正确方式。


21. 常见踩坑点:onDaySelected 只更新 selectedDay

如果只更新 _selectedDay 而不更新 _focusedDay,切换视图时会跳月.

你同时更新两个字段,状态同步.

这是"状态一致性"的体现。


22. 常见踩坑点:onFormatChanged 不保存状态

如果不保存 _calendarFormat,用户切换视图后重建会回到默认.

你用 setState 保存格式,用户意图被记住.

这是"状态持久化"的体现。


23. 常见踩坑点:eventLoader 返回 null

如果 eventLoader 返回 null,可能导致日历异常.

你始终返回 List,空列表表示无事件.

这是"返回值安全"的体现。


24. 常见踩坑点:firstDay/lastDay 范围过小

如果范围太小,用户可能无法看到需要的日期.

你设置为 2024 年全年,范围合理.

这是"日期范围"的合理设置。


25. 常见踩坑点:SizedBox 高度为 0

如果 SizedBox 没有明确高度,可能不占空间.

你给了 .h 值,保证间距生效.

这是"间距明确"的体现。


26. 常见踩坑点:EdgeInsets.all 的参数为 0

如果 EdgeInsets.all(0),相当于没有内边距.

你用了 16.w,保证内容不贴边.

这是"间距合理"的体现。


27. 常见踩坑点:AppBar 的 title 为空

如果 AppBartitle 为空,导航栏可能显示异常.

你给了明确标题,保证导航栏正常.

这是"导航完整性"的体现。


28. 常见踩坑点:Scaffold 的 body 为 null

如果 Scaffoldbody 为 null,页面会空白.

你给了完整 body,保证页面有内容.

这是"页面完整性"的体现。


29. 常见踩坑点:StatefulWidget 的 key 为 null

如果 StatefulWidgetkey 为 null,在某些重建场景可能出问题.

你用了 const Key(),保证稳定.

这是"Key 使用"的体现。


30. 常见踩坑点:Column 的 children 为空

如果 Columnchildren 为空,页面会空白.

你有标题、日历和统计,页面有内容.

这是"页面完整性"的体现。


31. 常见踩坑点:Text 的 data 为空

如果 Textdata 为空,可能不显示.

你给了明确文本,内容可见.

这是"文本完整性"的体现。


32. 常见踩坑点:Text 的 fontSize 过大

如果 fontSize 过大,标题可能换行或溢出.

你用了 20.sp、18.sp、24.sp、14.sp,适配不同层级.

这是"响应式字体"的体现。


33. 常见踩坑点:FontWeight 的值无效

如果 FontWeight 的值不在预定义范围内,可能无效.

你用了 FontWeight.bold,合法且效果明显.

这是"字体粗细"的体现。


34. 常见踩坑点:Colors.orange 为 null

如果 Colors.orange 为 null,文字颜色可能失效.
Colors.orange 在 Flutter 中始终有效,颜色正常.

这是"颜色安全"的体现。


35. 常见踩坑点:Row 的 children 为空

如果 Rowchildren 为空,行可能空白.

你有三个统计项,行完整.

这是"行完整性"的体现。


36. 常见踩坑点:MainAxisAlignment 的值无效

如果 MainAxisAlignment 的值不在 MainAxisAlignment 枚举中,会抛异常.

你用了 spaceEvenly,合法且效果明显.

这是"枚举使用"的体现。


37. 常见踩坑点:Column 的 crossAxisAlignment 默认居中

如果 Column 的子节点宽度不同,默认居中可能不协调.

你用了默认值,内容对齐自然.

这是"布局默认值"的合理使用。


38. 常见踩坑点:Column 的 children 为空

如果 Columnchildren 为空,统计项可能空白.

你有数值和标签,内容完整.

这是"统计项完整性"的体现。


39. 常见踩坑点:CalendarFormat 的值无效

如果 CalendarFormat 的值不在 CalendarFormat 枚举中,会抛异常.

你用了 CalendarFormat.month,合法且效果明显.

这是"枚举使用"的体现。


40. 常见踩坑点:TableCalendar 的必要参数缺失

如果 TableCalendar 的必要参数缺失,日历可能显示异常.

你配置了 firstDaylastDayfocusedDay 等必要参数.

这是"组件配置"的体现。


41. 常见踩坑点:selectedDayPredicate 为 null

如果 selectedDayPredicate 为 null,选中状态可能不显示.

你提供了判断函数,选中状态正常.

这是"回调配置"的体现。


42. 常见踩坑点:onFormatChanged 为 null

如果 onFormatChanged 为 null,用户无法切换视图格式.

你提供了回调,视图切换正常.

这是"交互配置"的体现。


43. 常见踩坑点:onDaySelected 为 null

如果 onDaySelected 为 null,用户无法选择日期.

你提供了回调,日期选择正常.

这是"交互配置"的体现。


44. 常见踩坑点:eventLoader 为 null

如果 eventLoader 为 null,日历可能无法显示事件标记.

你提供了回调,事件标记正常.

这是"事件配置"的体现。


45. 常见踩坑点:day.day % 3 的逻辑错误

如果取模逻辑错误,事件标记可能不准确.

你用 day.day % 3 == 0,每 3 天一个标记,逻辑正确.

这是"业务逻辑"的体现。


46. 常见踩坑点:MainAxisSize 的值无效

如果 MainAxisSize 的值不在 MainAxisSize 枚举中,会抛异常.

你用了默认值,布局正常.

这是"枚举使用"的体现。


47. 常见踩坑点:CrossAxisAlignment 的值无效

如果 CrossAxisAlignment 的值不在 CrossAxisAlignment 枚举中,会抛异常.

你用了默认值,布局正常.

这是"枚举使用"的体现。


48. 常见踩坑点:VerticalDirection 的值无效

如果 VerticalDirection 的值不在 VerticalDirection 枚举中,会抛异常.

你用了默认值,布局正常.

这是"枚举使用"的体现。


49. 常见踩坑点:TextDirection 的值无效

如果 TextDirection 的值不在 TextDirection 枚举中,会抛异常.

你用了默认值,布局正常.

这是"枚举使用"的体现。


50. 常见踩坑点:TextAlign 的值无效

如果 TextAlign 的值不在 TextAlign 枚举中,会抛异常.

你用了默认值,布局正常.

这是"枚举使用"的体现。


51. 小结补充:从"常见踩坑点"看工程稳健性

你当前的实现已经避开了绝大多数常见坑.

这说明你对 Flutter 的基础组件和第三方库使用有扎实理解.

即使未来扩展功能,这些稳健的写法也会减少维护成本.

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

相关推荐
近津薪荼2 小时前
优选算法——双指针8(单调性)
数据结构·c++·学习·算法
AC赳赳老秦2 小时前
外文文献精读:DeepSeek翻译并解析顶会论文核心技术要点
前端·flutter·zookeeper·自动化·rabbitmq·prometheus·deepseek
不光头强3 小时前
shiro学习要点
java·学习·spring
●VON3 小时前
React Native for OpenHarmony:ScrollView 事件流、布局行为与性能优化深度剖析
学习·react native·react.js·性能优化·openharmony
爱吃大芒果3 小时前
Flutter for OpenHarmony 实战: mango_shop 购物车模块的状态同步与本地缓存处理
flutter·缓存·dart
2601_949543013 小时前
Flutter for OpenHarmony垃圾分类指南App实战:意见反馈实现
android·flutter
子春一3 小时前
Flutter for OpenHarmony:构建一个 Flutter 天气卡片组件,深入解析动态 UI、响应式布局与语义化设计
javascript·flutter·ui
雨季6663 小时前
Flutter 三端应用实战:OpenHarmony “极简文本行数统计器”
开发语言·前端·flutter·ui·交互
爱吃大芒果3 小时前
Flutter for OpenHarmony 适配:mango_shop 页面布局的鸿蒙多设备屏幕适配方案
flutter·华为·harmonyos