6af09e4324
- finishTraining 添加 _saving 防重入,避免1.5秒延迟期间连点 - _init 补充 _last30Signaled/_last10Signaled 重置,修复 多次训练时语音提示丢失的bug Co-Authored-By: Claude <noreply@anthropic.com>
219 lines
6.5 KiB
JavaScript
219 lines
6.5 KiB
JavaScript
const Timer = require('../../utils/timer')
|
|
const storage = require('../../utils/storage')
|
|
const planMod = require('../../utils/plan')
|
|
const themeMod = require('../../utils/theme')
|
|
const iconsMod = require('../../utils/icons')
|
|
const voice = require('../../utils/voice')
|
|
|
|
Page({
|
|
data: {
|
|
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
|
|
themeStyle: themeMod.BASE_VARS,
|
|
duration: 60,
|
|
remaining: 60,
|
|
status: 'idle',
|
|
isRunning: false,
|
|
isPaused: false,
|
|
isCompleted: false,
|
|
overtime: 0,
|
|
todayTarget: 0,
|
|
planDay: 1,
|
|
isFreeMode: false,
|
|
icons: iconsMod.build()
|
|
},
|
|
|
|
onLoad(options) {
|
|
themeMod.applyThemeToPage(this)
|
|
this._init(options)
|
|
},
|
|
|
|
onShow() {
|
|
themeMod.applyThemeToPage(this)
|
|
const theme = themeMod.getCurrentTheme()
|
|
this.setData({ theme, icons: iconsMod.build(theme) })
|
|
},
|
|
|
|
_init(options) {
|
|
let target
|
|
let planDay = 1
|
|
let isFreeMode = false
|
|
|
|
if (options && options.free) {
|
|
const parsed = parseInt(options.free)
|
|
target = parsed > 0 ? parsed : 60
|
|
isFreeMode = true
|
|
} else {
|
|
const settings = storage.getSettings()
|
|
const allRecords = Object.values(storage.getRecords()).flat()
|
|
const customPlans = storage.getCustomPlans()
|
|
const plan = planMod.getPlan(settings.planId, customPlans)
|
|
planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate)
|
|
target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays))
|
|
}
|
|
|
|
this.setData({
|
|
duration: target,
|
|
remaining: target,
|
|
todayTarget: target,
|
|
planDay: isFreeMode ? 0 : planDay,
|
|
isFreeMode
|
|
})
|
|
|
|
if (this._timer) {
|
|
this._timer.stop()
|
|
this._timer = null
|
|
}
|
|
|
|
// Reset reminder state
|
|
this._halfwaySignaled = false
|
|
this._last30Signaled = false
|
|
this._last10Signaled = false
|
|
this._lastMinuteAt = 0
|
|
this._lastCountdown = 0
|
|
this._saving = false
|
|
|
|
this._timer = new Timer({
|
|
onTick: (tick) => {
|
|
this.setData({
|
|
remaining: tick.remaining > 0 ? tick.remaining : 0,
|
|
overtime: tick.remaining <= 0 ? tick.elapsed - this.data.duration : 0
|
|
})
|
|
this._remind(tick)
|
|
},
|
|
onComplete: () => {
|
|
this.setData({ status: 'completed', isCompleted: true })
|
|
const s = storage.getSettings()
|
|
if (s.vibrate) {
|
|
try { wx.vibrateLong() } catch (e) {}
|
|
}
|
|
// 4th voice prompt: completion
|
|
if (s.voiceGuide !== false) {
|
|
voice.play('complete')
|
|
}
|
|
wx.showToast({ title: '目标完成! 太棒了!', icon: 'success', duration: 2000 })
|
|
}
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Per-tick reminders: vibration + voice prompts at 4 fixed points.
|
|
* - halfway: once when elapsed reaches half of duration
|
|
* - 30s: once when remaining hits 30
|
|
* - 10s: once when remaining hits 10
|
|
* - complete: fired in onComplete above
|
|
*
|
|
* Vibration and voice are independently gated by `vibrate` / `voiceGuide`
|
|
* settings — user can mute haptics but keep voice (e.g. in office) or vice versa.
|
|
*/
|
|
_remind(tick) {
|
|
const s = storage.getSettings()
|
|
const { remaining, elapsed, duration } = tick
|
|
const vibrate = (n, ms) => {
|
|
if (!s.vibrate) return
|
|
try {
|
|
for (let i = 0; i < n; i++) {
|
|
setTimeout(() => wx.vibrateShort(), i * (ms + 30))
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Each prompt is gated on a minimum duration so short free-mode
|
|
// trainings don't trigger them at nonsensical times (e.g. firing
|
|
// "最后30秒" on the first tick of a 30s session because remaining
|
|
// happens to equal 30 at elapsed=0).
|
|
//
|
|
// Thresholds chosen so prompts fire in the right ORDER for any
|
|
// duration: halfway first, then last30, then last10.
|
|
|
|
// 1. Halfway: needs >=10s total so the midpoint is at least 5s in
|
|
if (duration >= 10 && !this._halfwaySignaled && elapsed >= Math.floor(duration / 2)) {
|
|
this._halfwaySignaled = true
|
|
if (s.voiceGuide !== false) voice.play('halfway')
|
|
vibrate(2, 150)
|
|
}
|
|
|
|
// 2. Last 30 seconds: only if duration > 60s so halfway has already passed
|
|
if (duration > 60 && remaining === 30 && !this._last30Signaled) {
|
|
this._last30Signaled = true
|
|
if (s.voiceGuide !== false) voice.play('last30')
|
|
}
|
|
|
|
// 3. Last 10 seconds: only if duration > 20s so we have at least 10s of training
|
|
if (duration > 20 && remaining === 10 && !this._last10Signaled) {
|
|
this._last10Signaled = true
|
|
if (s.voiceGuide !== false) voice.play('last10')
|
|
}
|
|
|
|
// 4. Countdown buzzes at 5..1: only if duration >= 6s (so we can reach them)
|
|
if (duration >= 6 && remaining <= 5 && remaining > 0 && remaining !== this._lastCountdown) {
|
|
this._lastCountdown = remaining
|
|
vibrate(1, 100)
|
|
}
|
|
},
|
|
|
|
onUnload() {
|
|
voice.stop()
|
|
voice.destroy()
|
|
if (this._timer) {
|
|
this._timer.stop()
|
|
this._timer = null
|
|
}
|
|
},
|
|
|
|
onStart() {
|
|
if (this.data.isPaused) {
|
|
this._timer.resume()
|
|
this.setData({ isRunning: true, isPaused: false, status: 'running' })
|
|
return
|
|
}
|
|
if (this.data.isRunning) return
|
|
this._timer.start(this.data.duration)
|
|
this.setData({ isRunning: true, status: 'running' })
|
|
const s = storage.getSettings()
|
|
if (s.voiceGuide !== false) voice.play('start')
|
|
},
|
|
|
|
onPause() {
|
|
if (!this.data.isRunning || this.data.isPaused) return
|
|
this._timer.pause()
|
|
voice.stop()
|
|
this.setData({ isPaused: true, status: 'paused' })
|
|
},
|
|
|
|
onStop() {
|
|
wx.showModal({
|
|
title: '结束训练',
|
|
content: '确定要结束本次训练吗?',
|
|
success: (res) => {
|
|
if (res.confirm) this.finishTraining()
|
|
}
|
|
})
|
|
},
|
|
|
|
finishTraining() {
|
|
// Guard against double-tap: stop button is still visible during the
|
|
// 1.5s navigateBack delay, so a second tap would create a duplicate.
|
|
if (this._saving) return
|
|
this._saving = true
|
|
|
|
const elapsed = this._timer.stop()
|
|
if (elapsed < 3) {
|
|
this._saving = false
|
|
wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 })
|
|
setTimeout(() => { wx.navigateBack() }, 1500)
|
|
return
|
|
}
|
|
const today = storage.getToday()
|
|
storage.saveRecord({
|
|
date: today,
|
|
duration: elapsed,
|
|
planId: storage.getSettings().planId,
|
|
day: this.data.planDay
|
|
})
|
|
storage.updateStreak(today)
|
|
|
|
wx.showToast({ title: `已记录 ${elapsed}秒`, icon: 'none', duration: 1500 })
|
|
setTimeout(() => { wx.navigateBack() }, 1500)
|
|
}
|
|
})
|