AtomGit Flutter 鸿蒙客户端:一键记录情绪的极致体验

📅 2026/7/9 18:27:25
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.valuev,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_boxNamemoods;Box?_box;int _nextId1;Futurevoidinit()async{_boxawaitHive.openBox(_boxName);if(_box!.isNotEmpty){_nextId_box!.keys.foldint(0,(max,k)kisint?(kmax?k:max):max)1;}}Futureintinsert(MoodEntryentry)async{finalid_nextId;finaldataentry.toJson();data[id]id;await_box?.put(id,jsonEncode(data));notifyListeners();// 触发 UI 刷新returnid;}}几个值得关注的架构决策纯 Hive无 SQLite使用hive_ceCommunity Edition而非sqflite。sqflite依赖原生 SQLite在鸿蒙平台上有兼容性问题Hive 是纯 Dart 实现的键值存储跨平台零摩擦。手动 ID 自增_nextId在init()时从已有 key 中恢复最大值避免了依赖数据库自增主键。这是键值存储上实现自增 ID 的经典模式。ChangeNotifier 模式MoodStorage继承ChangeNotifierinsert/update/delete都调用notifyListeners()。这意味着任何监听该存储的 Widget如首页的时间感知问候语、日记页的统计图表都会在数据变化时自动重建。四、核心实现_quickCheckIn 方法// lib/pages/home_page.dartFuturevoid_quickCheckIn(BuildContextcontext,MoodTypemood)async{finalmessengerScaffoldMessenger.of(context);try{finalnowDateTime.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 内联样式。原因很简单它不是导航入口而是一个即时操作热区——点击后不跳转页面原地完成记录。六、与日记页面的架构对比“今日速记和情绪日记共享同一套数据层MoodStorageMoodEntry但在交互深度上截然不同。理解这种差异有助于把握何时应该做减法”。维度今日速记情绪日记 (DiaryPage)入口首页内联卡片无跳转首页HomeCard→Navigator.push步骤1 次点击选择情绪 日期 备注 点击保存记录内容MoodType 时间戳MoodType 日期 备注(≤500字)Widget 复杂度1 个 Card 5 个 GestureDetector3 个 Tab记录/时间线/统计、MoodPicker、TextField、日期选择器、编辑/删除确认对话框编辑历史不支持支持_editMood回填、_deleteMood确认删除数据可视化无MoodChart 柱状图 每周摘要文本状态管理无本地状态_selectedMood、_noteController、_selectedDate、_editingId等 5 个状态变量适用场景我现在感觉不太好→ 点一下今天发生了很多事→ 详细记录并回顾架构上的关键洞察两者不是替代关系而是漏斗关系。速记降低了入口门槛——用户在首页点一下就能完成记录持续使用后积累的数据在日记页的时间线和统计Tab 中产生回顾价值。速记负责输入日记页负责消费。这种入口分离的模式在很多优秀应用中都能看到——Day One 的快速记录按钮、Things 的收件箱快速添加都是同一设计哲学。七、实战建议与改进方向7.1 防重复点击去抖当前实现没有防重复点击机制——用户快速连点三次会写入三条记录。对于速记场景这其实是一个合法的需求问题用户是想记录三次还是误触了三次比较稳妥的做法是加入冷却时间DateTime?_lastQuickCheckIn;Futurevoid_quickCheckIn(BuildContextcontext,MoodTypemood)async{finalnowDateTime.now();if(_lastQuickCheckIn!nullnow.difference(_lastQuickCheckIn!)constDuration(seconds:2)){return;// 2 秒内忽略重复点击}_lastQuickCheckInnow;// ... 原有逻辑}但要注意HomePage当前是StatelessWidget加入状态需要改为StatefulWidget。权衡之下如果日志写入足够快Hive 的同步写入通常在毫秒级重复点击的影响其实很小——三条相同时间戳的记录在统计时也只会轻微影响计数。7.2 触觉反馈目前点击没有任何触觉响应。Flutter 的HapticFeedback提供平台无关的震动 APIonTap:(){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 个字段的情绪应用一次点击的体验优势是决定性的。从技术角度看它展示了几个值得复用的模式数据驱动 UIMoodType.values.map()让 UI 自动适配枚举变更ChangeNotifier 作为轻量状态管理MoodStorage extends ChangeNotifier让多个页面响应同一数据源纯 Dart 存储Hive 替代 SQLite消除跨平台兼容性隐患异常边界的精准划分try-catch 包裹写操作SnackBar 分成功/失败双通道反馈Tooltip 的多重价值既服务于新用户的引导也服务于无障碍场景在鸿蒙平台上这些模式同样适用——Flutter 的跨平台能力保证了代码零修改运行而纯 Dart 的存储选型避免了原生依赖带来的构建问题。作者简介E-Brufen DevFlutter 鸿蒙开发者专注于跨平台移动应用开发与心理健康数字化项目地址AtomGit - E-Brufen。