AtomGit Flutter 鸿蒙客户端:情绪日记的时间线实现

时间线是情绪日记最核心的浏览方式。本文将深入剖析如何用纯 Dart 实现按月分组的列表视图,覆盖数据层到渲染层的完整链路。

一、整体架构概览

E-Brufen 的情绪日记模块采用了清晰的分层架构:

复制代码
lib/
├── models/mood_entry.dart    # 数据模型:MoodType 枚举 + MoodEntry 类
├── data/mood_storage.dart    # 持久层:Hive 键值存储(鸿蒙兼容,无需 SQLite)
├── widgets/mood_picker.dart  # 输入组件:5 表情选择器
├── widgets/mood_chart.dart   # 统计组件:本周心情折线图
└── pages/diary/diary_page.dart  # 页面编排:TabBarView 三页签

DiaryPage 内部使用 TabBarView 承载三个页签------记录、时间线、统计。其中时间线 是本文的主角,对应 _buildTimelineTab() 方法(diary_page.dart 第 202-251 行)。

状态管理上,页面通过 MoodStorage(继承 ChangeNotifier)获取数据,在其 addListener 回调中触发 setState 重建 UI。每次数据变更(增删改)都会自动刷新时间线,无需手动管理局部状态。

二、数据模型:MoodType 与 MoodEntry

时间线的每条记录来源于 MoodEntry,它承载了渲染所需的所有字段:

dart 复制代码
// mood_entry.dart
enum MoodType {
  angry(1, '😡', '生气'),
  sad(2, '😢', '难过'),
  tired(3, '😴', '疲惫'),
  calm(4, '😐', '平静'),
  happy(5, '😊', '开心');

  final int value;    // 数字编码,用于统计图表 Y 轴映射
  final String emoji; // 列表渲染的视觉锚点(32px)
  final String label; // 中文标签,ListTile 的 title
  const MoodType(this.value, this.emoji, this.label);
}

class MoodEntry {
  final int? id;
  final MoodType moodType;
  final String? note;          // 可选备注,最多 500 字
  final DateTime createdAt;
  final DateTime updatedAt;
  // ... copyWith / toJson / fromJson 省略
}

设计要点:

  • MoodTypevalue 字段赋予每种情绪一个 1-5 的数值,为图表统计(本周心情曲线)提供了数值映射基础
  • MoodEntry 实现 copyWith 模式,保证编辑操作不可变更新,避免直接修改原始对象导致状态不可追踪
  • note 为可空字段------用户可以不写备注,此时 ListTile 的 subtitle 自动隐藏

三、存储层:Hive 键值存储

dart 复制代码
// mood_storage.dart
class MoodStorage extends ChangeNotifier {
  static const _boxName = 'moods';
  Box? _box;
  int _nextId = 1;

  Future<void> init() async {
    _box = await Hive.openBox(_boxName);
    if (_box!.isNotEmpty) {
      _nextId = _box!.keys.fold<int>(0,
        (max, k) => k is int ? (k > max ? k : max) : max) + 1;
    }
  }

  List<MoodEntry> getAll() {
    if (_box == null) return [];
    final entries = <MoodEntry>[];
    for (final key in _box!.keys) {
      if (key is int) {
        final raw = _box!.get(key);
        if (raw is String) entries.add(_parse(key, raw));
      }
    }
    entries.sort((a, b) => b.createdAt.compareTo(a.createdAt));
    return entries;
  }
}

为什么选 Hive 而非 sqflite? E-Brufen 同时面向鸿蒙平台,而 sqflite 依赖原生 SQLite,在 HarmonyOS 上兼容性存疑。Hive 是纯 Dart 实现,零原生依赖,天然跨平台兼容。每条记录以 JSON 字符串存入 Box,键为自增整数 ID。

getAll() 每次从 Box 全量读取并排序------这不是最优方案,但在日记场景(用户通常记录数在数百到数千条)下完全可行。如果未来需要分页,可以在存储层增加 getPage(offset, limit) 方法,配合时间线中的上拉加载更多。

⚠️ 重要前提getAll() 返回的列表已经按 createdAt 倒序排列(最新的在前)。这个顺序是整个分组算法的基石------Map 的插入顺序决定了月分组在 UI 中的展示顺序。

四、核心算法:O(n) Map-based 月分组

分组逻辑位于 _buildTimelineTab() 内部,仅 5 行代码:

dart 复制代码
// diary_page.dart 第 216-220 行
final grouped = <String, List<MoodEntry>>{};
for (final m in _allMoods) {
  final key = '${m.createdAt.year}年${m.createdAt.month}月';
  grouped.putIfAbsent(key, () => []).add(m);
}

算法分析:

  • 时间复杂度 :O(n),每条记录仅遍历一次。putIfAbsent 是 O(1) 均摊,add 是 O(1)。
  • 空间复杂度:O(n),额外存储一份分组后的引用(MoodEntry 对象本身不复制,仅复制引用)。
  • 保持顺序 :Dart 的 LinkedHashMap{} 的默认实现)保持插入顺序。因为 _allMoods 已按时间倒序,所以首个遇到的条目决定了该月 key 的插入顺序------即月份从最新到最旧。

分组后的数据结构示意:

复制代码
{
  "2026年7月": [MoodEntry(happy, 07-06), MoodEntry(calm, 07-05), ...],
  "2026年6月": [MoodEntry(sad, 06-28), ...],
  "2025年12月": [MoodEntry(tired, 12-31), ...]
}

为什么不用第三方 SectionList? Flutter 生态中虽然有 sticky_headersgrouped_list 等包,但引入依赖会增加维护成本(版本冲突、鸿蒙兼容性未知、API 变更风险)。5 行手写分组代码零依赖、完全可控,符合 "够用就好" 的原则。

五、ListView.builder 渲染策略

dart 复制代码
// diary_page.dart 第 222-251 行
return ListView.builder(
  padding: const EdgeInsets.all(16),
  itemCount: grouped.length,          // 以月份数作为 itemCount
  itemBuilder: (context, index) {
    final month = grouped.keys.elementAt(index);
    final moods = grouped[month]!;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // ── 月份标题 ──
        Padding(
          padding: const EdgeInsets.symmetric(vertical: 12),
          child: Text(month, style: const TextStyle(
            fontSize: 16, fontWeight: FontWeight.bold, color: Colors.grey,
          )),
        ),
        // ── 本月所有条目(展开映射为 Card 列表)──
        ...moods.map((mood) => Card(
          margin: const EdgeInsets.only(bottom: 8),
          child: ListTile(
            leading: Text(mood.moodType.emoji,
              style: const TextStyle(fontSize: 32)),
            title: Text(mood.moodType.label),
            subtitle: mood.note != null && mood.note!.isNotEmpty
              ? Text(mood.note!,
                  maxLines: 1, overflow: TextOverflow.ellipsis)
              : null,
            trailing: Text(
              _formatDate(mood.createdAt),
              style: const TextStyle(
                fontSize: 12, color: Colors.grey)),
            onTap: () => _showMoodDetail(mood),
          ),
        )),
      ],
    );
  },
);

渲染结构可视化:

复制代码
┌─ "2026年7月" ───────────────────┐
│ ┌────────────────────────────┐  │
│ │ 😊  开心         07-06    │  │  ← Card / ListTile
│ └────────────────────────────┘  │
│ ┌────────────────────────────┐  │
│ │ 😐  平静         07-05    │  │
│ └────────────────────────────┘  │
├─ "2026年6月" ───────────────────┤
│ ┌────────────────────────────┐  │
│ │ 😢  难过  今天下雨... 06-28│  │  ← 备注超出截断
│ └────────────────────────────┘  │
│ ┌────────────────────────────┐  │
│ │ 😴  疲惫         06-25    │  │  ← 无备注时 subtitle 为 null
│ └────────────────────────────┘  │
└─────────────────────────────────┘

ListTile 字段分工

字段 内容 样式 作用
leading moodType.emoji fontSize: 32 视觉锚点,一眼识别情绪
title moodType.label 默认 中文情绪名称
subtitle note (首行截断) maxLines: 1 备注预览,超长用 TextOverflow.ellipsis
trailing 格式化日期 fontSize: 12, grey 右上角辅助信息

Column 展开 vs 嵌套 ListView

这是关键架构决策:外层用 ListView.builder,每个 item 内部用 Column + 展开的 children

dart 复制代码
// ❌ 错误做法:嵌套 ListView → 滚动冲突、性能差
// ListView.builder(
//   itemBuilder: (_, i) => Column(children: [
//     MonthHeader(...),
//     ListView.builder(...)  // 嵌套滚动!
//   ]),
// )

// ✅ 正确做法:展开为 Widget 列表
// ListView.builder(
//   itemBuilder: (_, i) => Column(children: [
//     MonthHeader(...),
//     ...moods.map((m) => Card(...)),  // 展开到同一 Column
//   ]),
// )

展开 ...moods.map(...) 意味着每月的条目在 build 时一次性构造。对于每月通常不超过几十条的记录,这完全可接受。如果单月有上千条记录,可以改用二级 ListView.builder 并将 itemCount 设为总条目数,在 items 中穿插月份标题。但 E-Brufen 的日记场景不需要这种过度优化------简单方案的可维护性远胜于为假想的极端情况做防御性设计。

六、空状态设计

dart 复制代码
// diary_page.dart 第 203-213 行
if (_allMoods.isEmpty) {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Text('📝', style: TextStyle(fontSize: 64)),
        const SizedBox(height: 16),
        Text('还没有记录,今天心情怎么样?',
          style: AppTheme.greetingStyle),
      ],
    ),
  );
}

设计思路:

  • 大号 emoji(64px) 作为视觉焦点,降低空白页的冷感。选用 📝 而非 😊------强调"记录"行为而非默认情绪
  • 引导性文案 使用 AppTheme.greetingStyle(22px, weight 600, 棕色 #5D4037),与首页问候保持视觉一致性
  • 暗示行动:文案是"今天心情怎么样?"而非"暂无数据"------引导用户去记录页签到,而非停留在空白时间线
  • 空状态判断放在分组之前(if (_allMoods.isEmpty)),避免对空列表执行无意义的分组计算

七、日期格式化

dart 复制代码
// diary_page.dart 第 108-109 行
String _formatDate(DateTime d) =>
  '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
// 输出示例:2026-07-06

不过度依赖 intl 包(避免引入国际化依赖),直接用字符串拼接。padLeft(2, '0') 保证月和日始终为两位数,生成整齐的 ISO 风格日期。

八、详情弹窗与编辑删除

时间线中的每条记录可点击,触发 showModalBottomSheet

dart 复制代码
// diary_page.dart 第 253-299 行
void _showMoodDetail(MoodEntry mood) {
  showModalBottomSheet(
    context: context,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
    ),
    builder: (_) => Padding(
      padding: const EdgeInsets.all(20),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('${mood.moodType.emoji}  ${mood.moodType.label}',
              style: const TextStyle(fontSize: 24)),
          const SizedBox(height: 8),
          Text(_formatDate(mood.createdAt),
              style: const TextStyle(color: Colors.grey)),
          if (mood.note != null && mood.note!.isNotEmpty) ...[
            const SizedBox(height: 12),
            Text(mood.note!),
          ],
          const SizedBox(height: 16),
          Row(
            mainAxisAlignment: MainAxisAlignment.end,
            children: [
              TextButton.icon(
                onPressed: () {
                  Navigator.pop(context);
                  _editMood(mood);     // 切换到记录 Tab,预填数据
                },
                icon: const Icon(Icons.edit),
                label: const Text('编辑'),
              ),
              TextButton.icon(
                onPressed: () {
                  Navigator.pop(context);
                  if (mood.id != null) _deleteMood(mood.id!);
                },
                icon: const Icon(Icons.delete, color: Colors.red),
                label: const Text('删除', style: TextStyle(color: Colors.red)),
              ),
            ],
          ),
        ],
      ),
    ),
  );
}

编辑操作会关闭弹窗后自动切换到"记录"页签(_tabController.animateTo(0)),并预填选中日期的数据进行修改,提供流畅的编辑体验。

九、性能总结与优化方向

维度 当前方案 适用场景 优化方向(如需)
分组算法 O(n) Map < 10,000 条 无需优化
渲染方式 Column 展开 每月 < 100 条 二级 ListView.builder
数据读取 全量 getAll() < 5,000 条 分页查询 + 游标
状态更新 ChangeNotifier 单页签 更细粒度的通知

当前方案的核心理念:为一个清晰的边界场景(日记记录通常几百到几千条)选择最简单的实现,而非为所有可能的极端情况做架构。这并非懒惰------恰恰是有经验的工程判断。

十、完整代码清单

时间线的完整实现仅 49 行(_buildTimelineTab 方法 202-251 行),加上空状态判断和详情弹窗,总计约 100 行 Dart 代码。代码集中在一个文件(diary_page.dart),阅读和维护成本极低。

分组算法 5 行 + 渲染逻辑 30 行 + 空状态 10 行 + 详情弹窗 46 行 = 一个完整可用的情绪时间线。


作者简介 :E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化。本项目开源在 AtomGit,使用 MIT 协议,项目地址:AtomGit - E-Brufen

相关推荐
m0_749690231 小时前
HarmonyOS 本地备份与系统备份:BackupExtensionAbility、快照导出和恢复
harmonyos
澹然疏离1 小时前
HarmonyOS应用《民族图鉴》开发第33篇:StorageService——本地存储服务封装
harmonyos
Nturmoils2 小时前
Agent 不只是聊天框:用 ArkUI 做一个鸿蒙任务工作台
harmonyos
GitCode官方2 小时前
开源鸿蒙跨平台直播| RN 三方库开源鸿蒙标准化适配实战分享
华为·开源·harmonyos·atomgit
西西学代码2 小时前
Flutter---Container
flutter
西西学代码2 小时前
Flutter---底部导航栏(2)
开发语言·javascript·flutter
恣逍信点3 小时前
《凌微经》助读:本体论根基——“无之自悖”与“形性一体”
人工智能·科技·学习·程序人生·生活·交友·哲学
浮江雾4 小时前
Flutter第二节----Dart中的常量与变量
开发语言·前端·javascript·flutter·入门
chh5634 小时前
C++--string
java·开发语言·网络·c++·学习