AtomGit Flutter 鸿蒙客户端:一键记录情绪的极致体验
核心体验的精髓:把"记录情绪"从需要打开日记页的多步操作,降到首页上的一次点击。
一、设计动机
大多数情绪记录应用的门槛太高:打开 App → 进入日记 → 选择情绪 → 填备注 → 保存。E-Brufen 的"今日速记"将这个流程缩短到一次点击。
这个功能诞生于一个简单的观察:用户打开 App 最频繁的时刻,往往只是想知道"我现在感觉怎么样"并快速记下来,而不是写一篇完整的日记。如果每次都要进入日记页面、选择日期、挑选情绪、填写备注,那记录行为本身就成了一种负担。
"今日速记"不要求用户填写任何文本,不要求选择日期(自动取当前时间戳),甚至不需要按"保存"按钮。点击 emoji,完成。这就是它的全部。
二、数据模型:MoodType 枚举
整个速记功能的数据基础是一个精心设计的枚举。MoodType将五种核心情绪映射为数值、emoji 和中文字符串,数值同时服务于统计图表的 Y 轴映射:
// lib/models/mood_entry.dartenumMoodType{angry(1,'😡','生气'),sad(2,'😢','难过'),tired(3,'😴','疲惫'),calm(4,'😐','平静'),happy(5,'😊','开心');finalint value;finalStringemoji;finalStringlabel;constMoodType(this.value,this.emoji,this.label);staticMoodTypefromValue(int v)=>MoodType.values.firstWhere((m)=>m.value==v,orElse:()=>MoodType.calm,);}value从 1 到 5 递增,使得统计图表可以直接用数值绘制折线或柱状图。fromValue工厂方法带有orElse回退,保证了反序列化时的健壮性——即使存储中出现异常数值也不会崩溃,而是静默回退到calm。
每条记录对应一个MoodEntry实例:
classMoodEntry{finalint?id;finalMoodTypemoodType;finalString?note;// 可选备注,速记时为 nullfinalDateTimecreatedAt;finalDateTimeupdatedAt;// ... copyWith, toJson, fromJson, toString}注意note字段是可选的——速记模式下它就是null,而日记页面则允许用户填入最多 500 字的备注。同一个数据模型同时服务于两种场景,避免了类型分裂。
三、存储层:纯 Hive 实现,零原生依赖
// lib/data/mood_storage.dartclassMoodStorageextendsChangeNotifier{staticconst_boxName='moods';Box?_box;int _nextId=1;Future<void>init()async{_box=awaitHive.openBox(_boxName);if(_box!.isNotEmpty){_nextId=_box!.keys.fold<int>(0,(max,k)=>kisint?(k>max?k:max):max)+1;}}Future<int>insert(MoodEntryentry)async{finalid=_nextId++;finaldata=entry.toJson();data['id']=id;await_box?.put(id,jsonEncode(data));notifyListeners();// 触发 UI 刷新returnid;}}几个值得关注的架构决策:
- 纯 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.dartFuture<void>_quickCheckIn(BuildContextcontext,MoodTypemood)async{finalmessenger=ScaffoldMessenger.of(context);try{finalnow=DateTime.now();moodStorage.insert(MoodEntry(moodType:mood,createdAt:now,updatedAt:now,));messenger.showSnackBar(constSnackBar(content:Text('已记录 ✅'),duration:Duration(seconds:1),behavior:SnackBarBehavior.floating,),);}catch(e){messenger.showSnackBar(SnackBar(content:Text('保存失败:$e'),duration:constDuration(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:constEdgeInsets.symmetric(horizontal:24),child:Card(child:Padding(padding:constEdgeInsets.all(16),child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[constText('今日速记',style:TextStyle(fontSize:14,fontWeight:FontWeight.w600,color:Color(0xFF5D4037),)),constSizedBox(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:constTextStyle(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(BuildContextcontext,MoodTypemood)async{finalnow=DateTime.now();if(_lastQuickCheckIn!=null&&now.difference(_lastQuickCheckIn!)<constDuration(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。
