核心体验的精髓:把"记录情绪"从需要打开日记页的多步操作,降到首页上的一次点击。

一、设计动机

大多数情绪记录应用的门槛太高:打开 App → 进入日记 → 选择情绪 → 填备注 → 保存。E-Brufen 的"今日速记"将这个流程缩短到一次点击

这个功能诞生于一个简单的观察:用户打开 App 最频繁的时刻,往往只是想知道"我现在感觉怎么样"并快速记下来,而不是写一篇完整的日记。如果每次都要进入日记页面、选择日期、挑选情绪、填写备注,那记录行为本身就成了一种负担。

"今日速记"不要求用户填写任何文本,不要求选择日期(自动取当前时间戳),甚至不需要按"保存"按钮。点击 emoji,完成。这就是它的全部。

二、数据模型:MoodType 枚举

在这里插入图片描述

整个速记功能的数据基础是一个精心设计的枚举。MoodType 将五种核心情绪映射为数值、emoji 和中文字符串,数值同时服务于统计图表的 Y 轴映射:

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

  final int value;
  final String emoji;
  final String label;
  const MoodType(this.value, this.emoji, this.label);

  static MoodType fromValue(int v) => MoodType.values.firstWhere(
        (m) => m.value == v,
        orElse: () => MoodType.calm,
      );
}

value 从 1 到 5 递增,使得统计图表可以直接用数值绘制折线或柱状图。fromValue 工厂方法带有 orElse 回退,保证了反序列化时的健壮性——即使存储中出现异常数值也不会崩溃,而是静默回退到 calm

每条记录对应一个 MoodEntry 实例:
在这里插入图片描述

class MoodEntry {
  final int? id;
  final MoodType moodType;
  final String? note;       // 可选备注,速记时为 null
  final DateTime createdAt;
  final DateTime updatedAt;
  // ... copyWith, toJson, fromJson, toString
}

注意 note 字段是可选的——速记模式下它就是 null,而日记页面则允许用户填入最多 500 字的备注。同一个数据模型同时服务于两种场景,避免了类型分裂。

三、存储层:纯 Hive 实现,零原生依赖

在这里插入图片描述

// lib/data/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;
    }
  }

  Future<int> insert(MoodEntry entry) async {
    final id = _nextId++;
    final data = entry.toJson();
    data['id'] = id;
    await _box?.put(id, jsonEncode(data));
    notifyListeners();   // 触发 UI 刷新
    return id;
  }
}

几个值得关注的架构决策:

  1. 纯 Hive,无 SQLite:使用 hive_ce(Community Edition)而非 sqflitesqflite 依赖原生 SQLite,在鸿蒙平台上有兼容性问题;Hive 是纯 Dart 实现的键值存储,跨平台零摩擦。
  2. 手动 ID 自增_nextIdinit() 时从已有 key 中恢复最大值,避免了依赖数据库自增主键。这是键值存储上实现自增 ID 的经典模式。
  3. ChangeNotifier 模式MoodStorage 继承 ChangeNotifierinsert/update/delete 都调用 notifyListeners()。这意味着任何监听该存储的 Widget(如首页的时间感知问候语、日记页的统计图表)都会在数据变化时自动重建。

四、核心实现:_quickCheckIn 方法

在这里插入图片描述

// lib/pages/home_page.dart
Future<void> _quickCheckIn(BuildContext context, MoodType mood) async {
  final messenger = ScaffoldMessenger.of(context);
  try {
    final now = DateTime.now();
    moodStorage.insert(MoodEntry(
      moodType: mood,
      createdAt: now,
      updatedAt: now,
    ));
    messenger.showSnackBar(
      const SnackBar(
        content: Text('已记录 ✅'),
        duration: Duration(seconds: 1),
        behavior: SnackBarBehavior.floating,
      ),
    );
  } catch (e) {
    messenger.showSnackBar(
      SnackBar(
        content: Text('保存失败: $e'),
        duration: const Duration(seconds: 2),
        behavior: SnackBarBehavior.floating,
        backgroundColor: Colors.red.shade400,
      ),
    );
  }
}

这段不到 30 行的方法包含了几个精心设计的细节:

4.1 时间戳策略

final now = DateTime.now() 在方法入口处捕获一次,然后同时赋值给 createdAtupdatedAt。这样做的好处是确保两个时间戳完全一致——如果分别调用两次 DateTime.now(),中间可能存在微秒级差异,虽然对业务无影响,但在数据一致性上留下了隐患。一次捕获,处处使用,是最干净的做法。

4.2 浮动 SnackBar 的双通道反馈

成功路径:SnackBar 使用默认绿色背景(Material 3 的 success 语义色),显示 emoji ✅,duration: 1 秒后自动消失。1 秒的时长是经过体验打磨的——太短(如 500ms)用户来不及看清文字,太长(如 3 秒)则会阻碍后续操作。1 秒恰好完成"闪电确认"的感觉。

失败路径:backgroundColor: Colors.red.shade400 明确区分于成功态,duration: 2 秒给用户充足时间阅读错误信息(包含异常详情 $e)。

两处都使用了 behavior: SnackBarBehavior.floating,让 SnackBar 悬浮在内容上方而非固定在屏幕底部。浮动样式在视觉上更轻量,不会推动页面布局,也不会遮挡底部导航栏。

4.3 异常处理的范围

try-catch 包裹了 insert 和成功的 showSnackBar,但失败路径的 showSnackBarcatch 块内。这意味着即使 ScaffoldMessenger.of(context) 本身抛异常(极端情况,如 context 已销毁),成功路径的 SnackBar 发送失败也会被捕获,而失败路径的 SnackBar 发送若也失败则属于不可恢复的错误——这是合理的异常边界设计。

五、UI 层:Card + Tooltip + GestureDetector

在这里插入图片描述

// lib/pages/home_page.dart  build() 方法中的"今日速记"卡片
Padding(
  padding: const EdgeInsets.symmetric(horizontal: 24),
  child: Card(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('今日速记', style: TextStyle(
            fontSize: 14, fontWeight: FontWeight.w600,
            color: Color(0xFF5D4037),
          )),
          const SizedBox(height: 8),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: MoodType.values.map((mood) => Tooltip(
              message: mood.label,
              child: GestureDetector(
                onTap: () => _quickCheckIn(context, mood),
                child: Text(mood.emoji,
                  style: const TextStyle(fontSize: 32)),
              ),
            )).toList(),
          ),
        ],
      ),
    ),
  ),
),

这段 UI 代码有几个值得拆解的设计点:

5.1 动态生成 vs 硬编码

五个 emoji 不是硬编码的五个 GestureDetector,而是通过 MoodType.values.map(...) 动态生成。这意味着如果未来要在 MoodType 枚举中添加第六种情绪(例如"焦虑"),UI 会自动适配,无需修改任何 Widget 代码。这是数据驱动 UI 的典型实践。

5.2 Tooltip 的无障碍设计

每个 emoji 外包裹了 Tooltip(message: mood.label)。用户长按 emoji 时会弹出中文标签(“开心”“难过"等),这对于新用户理解 emoji 含义至关重要——😐 到底是"平静"还是"面无表情”?Tooltip 消除了歧义。

Tooltip 还有一个常被忽略的副产品:它对屏幕阅读器(如 TalkBack)暴露了 Semantics 信息,视障用户可以通过辅助功能获取每个按钮的语义标签。

5.3 卡片在页面中的位置

"今日速记"卡片位于首页 SingleChildScrollView 的末尾,排在三张功能卡片(白噪音、呼吸练习、情绪日记)之后。这个布局顺序遵循了"先发现、后记录"的信息架构——用户首先了解 App 提供的完整功能,最后在底部被引导到最轻量的操作。

卡片没有使用 HomeCard 组件(带渐变背景和导航箭头的那个),而是直接用 Card + 内联样式。原因很简单:它不是导航入口,而是一个即时操作热区——点击后不跳转页面,原地完成记录。

六、与日记页面的架构对比

“今日速记"和"情绪日记"共享同一套数据层(MoodStorage + MoodEntry),但在交互深度上截然不同。理解这种差异有助于把握何时应该"做减法”。

维度 今日速记 情绪日记 (DiaryPage)
入口 首页内联卡片,无跳转 首页 HomeCardNavigator.push
步骤 1 次点击 选择情绪 + 日期 + 备注 + 点击保存
记录内容 MoodType + 时间戳 MoodType + 日期 + 备注(≤500字)
Widget 复杂度 1 个 Card + 5 个 GestureDetector 3 个 Tab(记录/时间线/统计)、MoodPicker、TextField、日期选择器、编辑/删除确认对话框
编辑历史 不支持 支持(_editMood 回填、_deleteMood 确认删除)
数据可视化 MoodChart 柱状图 + 每周摘要文本
状态管理 无本地状态 _selectedMood_noteController_selectedDate_editingId 等 5 个状态变量
适用场景 "我现在感觉不太好"→ 点一下 "今天发生了很多事"→ 详细记录并回顾

架构上的关键洞察:两者不是替代关系,而是漏斗关系。速记降低了入口门槛——用户在首页点一下就能完成记录,持续使用后积累的数据在日记页的"时间线"和"统计"Tab 中产生回顾价值。速记负责输入,日记页负责消费。这种"入口分离"的模式在很多优秀应用中都能看到——Day One 的快速记录按钮、Things 的收件箱快速添加,都是同一设计哲学。

七、实战建议与改进方向

7.1 防重复点击(去抖)

当前实现没有防重复点击机制——用户快速连点三次 😊 会写入三条记录。对于速记场景,这其实是一个合法的需求问题:用户是想记录三次,还是误触了三次?比较稳妥的做法是加入冷却时间:

DateTime? _lastQuickCheckIn;

Future<void> _quickCheckIn(BuildContext context, MoodType mood) async {
  final now = DateTime.now();
  if (_lastQuickCheckIn != null &&
      now.difference(_lastQuickCheckIn!) < const Duration(seconds: 2)) {
    return; // 2 秒内忽略重复点击
  }
  _lastQuickCheckIn = now;
  // ... 原有逻辑
}

但要注意 HomePage 当前是 StatelessWidget,加入状态需要改为 StatefulWidget。权衡之下,如果日志写入足够快(Hive 的同步写入通常在毫秒级),重复点击的影响其实很小——三条相同时间戳的记录在统计时也只会轻微影响计数。

7.2 触觉反馈

目前点击没有任何触觉响应。Flutter 的 HapticFeedback 提供平台无关的震动 API:

onTap: () {
  HapticFeedback.lightImpact();
  _quickCheckIn(context, mood);
},

lightImpact 是最轻的震动档位(约 10ms),适合"轻点确认"的场景。比 mediumImpactheavyImpact 更不容易打扰用户,但又能提供物理层面的确认感。注意需要在 main.dart 中先调用 HapticFeedback 初始化(或在任何地方调用一次),它在鸿蒙上的行为与 Android 一致。

7.3 动画增强

点击后 emoji 可以播放一个"弹跳"动画作为视觉反馈,替代或补充 SnackBar:

// 使用 AnimatedScale 或自定义 AnimationController
// emoji 放大 1.3 倍 → 回弹到 1.0 倍,持续 300ms

但要注意:如果已经配置了 SnackBar 反馈,再加动画可能造成"过度反馈"。建议二选一,或者在动画完成后不再显示 SnackBar。

7.4 HomePage 从 StatelessWidget 到 StatefulWidget

当前 HomePageStatelessWidget,持有的 moodStoragesettings 通过构造函数注入。这是合理的——_quickCheckIn 是一个纯副作用方法,不改变 Widget 自身的渲染状态。MoodStorageChangeNotifier,数据变更通过 notifyListeners() 通知监听者(如 DiaryPage),而不是通知 HomePage

但如果未来需要加入冷却时间、动画控制器、或"今天已记录 X 次"的计数显示,就需要重构为 StatefulWidget。当前的设计预留了这种重构空间——构造函数参数清晰,方法独立,迁移成本很低。

总结

"今日速记"是 E-Brufen 设计哲学的缩影:降低用户的操作成本就是最好的用户体验。相比那些需要填写 5 个字段的情绪应用,一次点击的体验优势是决定性的。

从技术角度看,它展示了几个值得复用的模式:

  • 数据驱动 UIMoodType.values.map() 让 UI 自动适配枚举变更
  • ChangeNotifier 作为轻量状态管理MoodStorage extends ChangeNotifier 让多个页面响应同一数据源
  • 纯 Dart 存储:Hive 替代 SQLite,消除跨平台兼容性隐患
  • 异常边界的精准划分:try-catch 包裹写操作,SnackBar 分成功/失败双通道反馈
  • Tooltip 的多重价值:既服务于新用户的引导,也服务于无障碍场景

在鸿蒙平台上,这些模式同样适用——Flutter 的跨平台能力保证了代码零修改运行,而纯 Dart 的存储选型避免了原生依赖带来的构建问题。


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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐