AtomGit Flutter 鸿蒙客户端:一键记录情绪的极致体验
核心体验的精髓:把"记录情绪"从需要打开日记页的多步操作,降到首页上的一次点击。
一、设计动机
大多数情绪记录应用的门槛太高:打开 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;
}
}
几个值得关注的架构决策:
- 纯 Hive,无 SQLite:使用
hive_ce(Community Edition)而非sqflite。sqflite依赖原生 SQLite,在鸿蒙平台上有兼容性问题;Hive 是纯 Dart 实现的键值存储,跨平台零摩擦。 - 手动 ID 自增:
_nextId在init()时从已有 key 中恢复最大值,避免了依赖数据库自增主键。这是键值存储上实现自增 ID 的经典模式。 - ChangeNotifier 模式:
MoodStorage继承ChangeNotifier,insert/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() 在方法入口处捕获一次,然后同时赋值给 createdAt 和 updatedAt。这样做的好处是确保两个时间戳完全一致——如果分别调用两次 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,但失败路径的 showSnackBar 在 catch 块内。这意味着即使 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) |
|---|---|---|
| 入口 | 首页内联卡片,无跳转 | 首页 HomeCard → Navigator.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),适合"轻点确认"的场景。比 mediumImpact 和 heavyImpact 更不容易打扰用户,但又能提供物理层面的确认感。注意需要在 main.dart 中先调用 HapticFeedback 初始化(或在任何地方调用一次),它在鸿蒙上的行为与 Android 一致。
7.3 动画增强
点击后 emoji 可以播放一个"弹跳"动画作为视觉反馈,替代或补充 SnackBar:
// 使用 AnimatedScale 或自定义 AnimationController
// emoji 放大 1.3 倍 → 回弹到 1.0 倍,持续 300ms
但要注意:如果已经配置了 SnackBar 反馈,再加动画可能造成"过度反馈"。建议二选一,或者在动画完成后不再显示 SnackBar。
7.4 HomePage 从 StatelessWidget 到 StatefulWidget
当前 HomePage 是 StatelessWidget,持有的 moodStorage 和 settings 通过构造函数注入。这是合理的——_quickCheckIn 是一个纯副作用方法,不改变 Widget 自身的渲染状态。MoodStorage 是 ChangeNotifier,数据变更通过 notifyListeners() 通知监听者(如 DiaryPage),而不是通知 HomePage。
但如果未来需要加入冷却时间、动画控制器、或"今天已记录 X 次"的计数显示,就需要重构为 StatefulWidget。当前的设计预留了这种重构空间——构造函数参数清晰,方法独立,迁移成本很低。
总结
"今日速记"是 E-Brufen 设计哲学的缩影:降低用户的操作成本就是最好的用户体验。相比那些需要填写 5 个字段的情绪应用,一次点击的体验优势是决定性的。
从技术角度看,它展示了几个值得复用的模式:
- 数据驱动 UI:
MoodType.values.map()让 UI 自动适配枚举变更 - ChangeNotifier 作为轻量状态管理:
MoodStorage extends ChangeNotifier让多个页面响应同一数据源 - 纯 Dart 存储:Hive 替代 SQLite,消除跨平台兼容性隐患
- 异常边界的精准划分:try-catch 包裹写操作,SnackBar 分成功/失败双通道反馈
- Tooltip 的多重价值:既服务于新用户的引导,也服务于无障碍场景
在鸿蒙平台上,这些模式同样适用——Flutter 的跨平台能力保证了代码零修改运行,而纯 Dart 的存储选型避免了原生依赖带来的构建问题。
作者简介:E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化,项目地址:AtomGit - E-Brufen。。
更多推荐

所有评论(0)