倒计时不只是数字递减——配合触觉反馈,可以为放松体验增加一层"物理感",让用户即使闭着眼睛也能感知时间的流动。


一、设计理念:让用户不必看屏幕

白噪音模块的核心使用场景是:用户闭上眼睛,沉浸在放松状态中。在这种场景下,屏幕上的视觉信息对用户是"不可见"的。

传统的解决方式是音频提示("叮"的一声),但 E-Brufen 当前没有音频播放能力。于是我们转向了触觉通道

视觉通道(眼睛闭着)
  ↓ 不可用
听觉通道(无音频播放)
  ↓ 不可用
触觉通道 ← E-Brufen 的主反馈通道!
  ↓
用户感知到震动 → 知道时间在流逝 → 保持放松状态

二、定时器选项设计

在这里插入图片描述

2.1 预设时长

final List<int> _durations = [15, 30, 60, 90];

Wrap(spacing: 8, children: [
  ..._durations.map((d) => ChoiceChip(
    label: Text('$d分'),
    selected: _remainingSec > 0 && _remainingSec ~/ 60 == d,
    onSelected: (_) => setState(() {
      _remainingSec = d * 60;
      widget.settings.timerDuration = d;  // 持久化偏好
    }),
  )),
  ChoiceChip(
    label: const Text('不限'),
    selected: _remainingSec == 0,
    onSelected: (_) => setState(() => _remainingSec = 0),
  ),
])

设计考量

时长 适用场景 触觉反馈次数
15 分钟 快速放松、课间休息 14 次轻触 + 1 次重触
30 分钟 午休小憩 29 次轻触 + 1 次重触
60 分钟 深度放松 59 次轻触 + 1 次重触
90 分钟 完整睡眠周期 89 次轻触 + 1 次重触
不限 背景白噪音(办公等) 无(不启动 Timer)

⚠️ "不限"选项不启动 Timer,_remainingSec == 0 作为特殊标记。此时 _startCountdown() 直接 return,不会消耗系统资源。


三、Timer.periodic 倒计时实现

3.1 核心代码

void _startCountdown() {
  _countdown?.cancel();           // 取消旧的 Timer(防重复)
  if (_remainingSec <= 0) return;  // "不限"模式直接返回

  _countdown = Timer.periodic(const Duration(seconds: 1), (timer) {
    if (!_isPlaying) {            // 手动停止 → 取消计时
      timer.cancel();
      return;
    }

    setState(() {
      _remainingSec--;

      if (_remainingSec <= 0) {   // 时间到
        _onTimerDone();
        timer.cancel();
        return;
      }

      // ⏰ 每分钟一次轻触觉——"无声的报时"
      if (_remainingSec % 60 == 0) {
        HapticFeedback.lightImpact();
      }
    });
  });
}

3.2 状态流程图

用户点击播放
  ↓
_startCountdown() → Timer.periodic(每秒)
  ↓
每秒回调:
  ├─ !_isPlaying? → timer.cancel() (用户手动停止)
  ├─ _remainingSec == 0? → _onTimerDone() (自然结束)
  ├─ _remainingSec % 60 == 0? → lightImpact() (分钟标记)
  └─ _remainingSec-- (继续倒计时)

★ Insight ─────────────────────────────────────
_remainingSec % 60 == 0 是"无声提醒"的核心。用户闭眼放松时,每分钟一次轻触觉让他知道"又过了一分钟",而不需要睁眼看屏幕。这是 E-Brufen 最精妙的设计细节之一——它把"必须看"变成了"可以感受"。
─────────────────────────────────────────────────


四、完成时的双重反馈

void _onTimerDone() {
  setState(() {
    _isPlaying = false;
  });
  _pulse.stop();
  _pulse.reset();

  HapticFeedback.heavyImpact();  // 强烈的"唤醒"震动

  if (mounted) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text('时间到,放松结束 🌙'),
        duration: Duration(seconds: 2),
      ),
    );
  }
}

双重信号确保用户一定感知到结束:

  1. heavyImpact() — 强烈的触觉(即使在口袋中也能感知)
  2. SnackBar — 视觉确认(如果屏幕可见)

⚠️ if (mounted) 检查是必须的——Timer 回调可能在 Widget 已销毁后触发(如用户快速返回首页),不检查会导致 setState() called after dispose() 崩溃。


五、HapticFeedback 类型详解

Flutter 提供四种触觉反馈强度:

类型 强度 持续时间 E-Brufen 中的使用
lightImpact() ~10ms ✅ 每分钟标记
mediumImpact() ~15ms 可用于场景切换确认
heavyImpact() ~20ms ✅ 定时结束唤醒
selectionClick() 极轻 ~5ms 可用于 emoji 选择反馈

选择 lightImpact 而非 selectionClick 的原因selectionClick 太轻,在口袋或桌面上可能感知不到。lightImpact 提供了足够的感知强度而不至于"惊醒"放松中的用户。


六、播放/停止的对称控制

void _togglePlay() {
  setState(() {
    _isPlaying = !_isPlaying;
    if (_isPlaying) {
      _pulse.repeat(reverse: true);  // 启动脉冲动画
      _startCountdown();             // 启动倒计时
    } else {
      _pulse.stop();                 // 停止动画
      _pulse.reset();
      _countdown?.cancel();          // 取消倒计时
    }
  });
}

一对对称操作:播放时同时启动动画和 Timer,停止时同时取消两者。这确保了动画和计时永远同步。


七、资源释放


void dispose() {
  _pulse.dispose();         // 释放 AnimationController
  _countdown?.cancel();     // 取消 Timer(防止内存泄漏)
  super.dispose();
}

释放顺序:先 dispose 动画(可能有回调引用 Timer),再 cancel Timer。两个异步资源都要清理——漏掉任何一个都会导致内存泄漏。


八、时间格式化

String _formatTime(int s) {
  final m = s ~/ 60;
  final sec = s % 60;
  return '${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}';
}
// 03:45, 00:30, 01:00

上一篇白噪音场景的视觉设计
下一篇ChangeNotifier 模式:轻量级状态管理


作者简介:E-Brufen Dev,Flutter & 鸿蒙开发者。触觉反馈设计基于 HapticFeedback API 在鸿蒙 6.0 真机上的实测体验,项目地址:AtomGit - E-Brufen

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐