1c140bd503
- timer: 完成弹窗存实际时长 completionActualElapsed,分享改用它,避免用动画值导致秒数错误 - theme: setTheme 补 _markLocalChanged,避免云恢复覆盖未同步的主题改动 - cloud: _doPush 重定位查询补 orderBy(updatedAt desc),与 pullAll 一致,避免命中最旧文档 - storage: deleteRecord 改用 recomputeStreak,修复删连续链中间某天 streak 虚高 Co-Authored-By: Claude <noreply@anthropic.com>
516 lines
17 KiB
JavaScript
516 lines
17 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')
|
|
const { countUp } = require('../../utils/countUp')
|
|
const config = require('../../config')
|
|
Page({
|
|
data: {
|
|
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
|
|
themeStyle: themeMod.BASE_VARS,
|
|
duration: 60,
|
|
remaining: 0,
|
|
status: 'idle',
|
|
isRunning: false,
|
|
isPaused: false,
|
|
isCompleted: false,
|
|
elapsed: 0,
|
|
goalReached: false,
|
|
hintTip: (config.postureTips && config.postureTips[0]) || '',
|
|
todayTarget: 0,
|
|
planDay: 1,
|
|
isFreeMode: false,
|
|
celebrationLevel: 'normal', // normal / first / milestone / record
|
|
celebrationMessage: '',
|
|
confettiPieces: [],
|
|
// Completion modal (full-screen summary after training ends)
|
|
showCompletion: false,
|
|
showStopConfirm: false, // 自定义"结束训练"确认弹窗,替代 wx.showModal
|
|
completionElapsed: 0,
|
|
completionTarget: 0,
|
|
completionOvertime: 0,
|
|
completionStreak: 0,
|
|
completionLevel: 'normal',
|
|
completionMessage: '',
|
|
icons: iconsMod.build()
|
|
},
|
|
|
|
onLoad(options) {
|
|
themeMod.applyThemeToPage(this)
|
|
this._init(options)
|
|
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)
|
|
const s = storage.getSettings()
|
|
if (s.voiceGuide !== false) voice.preload()
|
|
// countUp: 数字从 0 滚到目标秒数
|
|
const target = this.data.duration
|
|
if (this._countUpTask) this._countUpTask.cancel()
|
|
this._countUpTask = countUp({
|
|
from: 0,
|
|
to: target,
|
|
duration: 600,
|
|
onUpdate: (v) => this.setData({ remaining: v }),
|
|
onComplete: () => { this._countUpTask = null }
|
|
})
|
|
},
|
|
|
|
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, customPlans)
|
|
target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays), customPlans)
|
|
}
|
|
|
|
this.setData({
|
|
duration: target,
|
|
remaining: target,
|
|
todayTarget: target,
|
|
planDay: isFreeMode ? 0 : planDay,
|
|
isFreeMode,
|
|
goalReached: false,
|
|
elapsed: 0
|
|
})
|
|
|
|
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._lastTipIndex = -1
|
|
this._clearVibrateTimers()
|
|
|
|
this._timer = new Timer({
|
|
onTick: (tick) => {
|
|
this.setData({
|
|
remaining: tick.remaining > 0 ? tick.remaining : 0,
|
|
elapsed: tick.elapsed
|
|
})
|
|
this._updateHintTip(tick)
|
|
this._remind(tick)
|
|
},
|
|
onGoalReached: () => {
|
|
// Target hit - don't end. Voice prompt only (no vibration, per
|
|
// design); keep counting so the user can train to failure.
|
|
const s = storage.getSettings()
|
|
if (s.voiceGuide !== false) {
|
|
voice.play('goal')
|
|
}
|
|
this.setData({ goalReached: true })
|
|
}
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Rotate the posture cue in the hint area every config.tipInterval seconds.
|
|
* Only runs while still under the target (after goalReached the hint
|
|
* switches to the "goal reached" line). Skips setData when the index
|
|
* hasn't changed to avoid per-second re-renders.
|
|
*/
|
|
_updateHintTip(tick) {
|
|
if (tick.remaining <= 0) return
|
|
const tips = config.postureTips || []
|
|
if (tips.length === 0) return
|
|
const interval = config.tipInterval || 12
|
|
const idx = Math.floor(tick.elapsed / interval) % tips.length
|
|
if (idx !== this._lastTipIndex) {
|
|
this._lastTipIndex = idx
|
|
this.setData({ hintTip: tips[idx] })
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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 finishTraining when the user ends the session
|
|
*
|
|
* 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) => {
|
|
// `=== false` (not `!s.vibrate`) so an undefined field (legacy users
|
|
// whose settings predate the toggle) is treated as ON, matching the
|
|
// voiceGuide check below. `!s.vibrate` used to see undefined as falsy
|
|
// and silently skip vibration until the user toggled the switch.
|
|
if (s.vibrate === false) return
|
|
const intensity = s.vibrateIntensity || 'heavy'
|
|
// light/medium = vibrateShort(type); heavy = vibrateLong(400ms).
|
|
// vibrateLong is the most reliable on iOS real devices; the short
|
|
// types are nicer but may be silent on some devices - user picks.
|
|
const doVibrate = () => {
|
|
const fail = (err) => console.warn('[vibrate] failed:', err.errMsg || err)
|
|
if (intensity === 'light') wx.vibrateShort({ type: 'light', fail })
|
|
else if (intensity === 'medium') wx.vibrateShort({ type: 'medium', fail })
|
|
else wx.vibrateLong({ fail })
|
|
}
|
|
try {
|
|
for (let i = 0; i < n; i++) {
|
|
// Track timers so pause/stop/unload can cancel pending bursts
|
|
// instead of firing vibrations on a page that's already torn down.
|
|
const id = setTimeout(() => {
|
|
doVibrate()
|
|
const idx = this._vibrateTimers.indexOf(id)
|
|
if (idx > -1) this._vibrateTimers.splice(idx, 1)
|
|
}, i * (ms + 30))
|
|
this._vibrateTimers.push(id)
|
|
}
|
|
} 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)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Cancel any pending vibration timers. Tracked so pause/stop/unload can
|
|
* abort scheduled bursts instead of firing on a torn-down page.
|
|
*/
|
|
_clearVibrateTimers() {
|
|
if (!this._vibrateTimers) {
|
|
this._vibrateTimers = []
|
|
return
|
|
}
|
|
this._vibrateTimers.forEach((id) => clearTimeout(id))
|
|
this._vibrateTimers = []
|
|
},
|
|
|
|
/**
|
|
* Detect which celebration level to show. Called from _saveAndShowCompletion
|
|
* (runs in finishTraining, BEFORE saveRecord), so stats reflect pre-save state.
|
|
* - first: user's very first training ever
|
|
* - milestone: streak will hit a memorable number after this save
|
|
* - record: this duration beats the user's previous max
|
|
* - normal: everything else
|
|
*/
|
|
_detectCelebrationLevel(elapsed) {
|
|
const stats = storage.getTotalStats()
|
|
const streak = storage.getStreak()
|
|
const nextStreak = (streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1
|
|
|
|
if (stats.totalSessions === 0) {
|
|
return { level: 'first', message: '第一次训练!坚持就是胜利' }
|
|
}
|
|
if ([7, 14, 30, 50, 100, 365].includes(nextStreak)) {
|
|
return { level: 'milestone', message: `连续打卡 ${nextStreak} 天!` }
|
|
}
|
|
if (elapsed > stats.maxDuration) {
|
|
return { level: 'record', message: `新纪录!${elapsed}秒` }
|
|
}
|
|
return { level: 'normal', message: '完成!' }
|
|
},
|
|
|
|
/**
|
|
* Build the confetti piece list. More pieces + more colors for higher
|
|
* celebration levels. Pieces are pre-positioned so the layout doesn't
|
|
* shift when wx:for renders them.
|
|
*/
|
|
_buildConfettiPieces(level) {
|
|
const counts = { normal: 12, record: 24, milestone: 32, first: 40 }
|
|
const count = counts[level] || counts.normal
|
|
const palette = ['#FF6B35', '#FFD93D', '#4CAF50', '#2979FF', '#EC407A', '#7E57C2', '#FF8C5A', '#5C9CFF', '#6EC072', '#A08AD6']
|
|
const pieces = []
|
|
for (let i = 0; i < count; i++) {
|
|
pieces.push({
|
|
left: Math.round((i / count) * 96 + 2),
|
|
color: palette[i % palette.length],
|
|
delay: Math.round(Math.random() * 300) / 1000,
|
|
rotate: Math.round((Math.random() * 90) - 45),
|
|
size: level === 'first' ? 40 : level === 'milestone' ? 36 : 32
|
|
})
|
|
}
|
|
return pieces
|
|
},
|
|
|
|
onUnload() {
|
|
voice.stop()
|
|
voice.destroy()
|
|
this._clearVibrateTimers()
|
|
if (this._timer) {
|
|
this._timer.stop()
|
|
this._timer = null
|
|
}
|
|
if (this._countUpTask) {
|
|
this._countUpTask.cancel()
|
|
this._countUpTask = null
|
|
}
|
|
},
|
|
|
|
onStart() {
|
|
if (this.data.isPaused) {
|
|
this._timer.resume()
|
|
this.setData({ isRunning: true, isPaused: false, status: 'running' })
|
|
return
|
|
}
|
|
if (this.data.isRunning) return
|
|
// 取消可能还在滚的 countUp,确保 remaining = duration
|
|
if (this._countUpTask) {
|
|
this._countUpTask.cancel()
|
|
this._countUpTask = null
|
|
this.setData({ remaining: this.data.duration })
|
|
}
|
|
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._clearVibrateTimers()
|
|
this.setData({ isPaused: true, status: 'paused' })
|
|
},
|
|
|
|
onStop() {
|
|
// 用自定义确认弹窗替代 wx.showModal:
|
|
// 系统原生 modal 在 WebView stacking context 下 hit-test 偶发不稳,
|
|
// 自定义 modal + catchtouchmove 与现有 completion-mask 同模式,行为更可靠。
|
|
// 弹窗停留期间暂停计时,避免犹豫时间被计入训练时长
|
|
this._stopWasPaused = this.data.isPaused
|
|
const patch = { showStopConfirm: true }
|
|
if (this.data.isRunning && !this.data.isPaused) {
|
|
this._timer.pause()
|
|
patch.isPaused = true
|
|
patch.status = 'paused'
|
|
}
|
|
this.setData(patch)
|
|
},
|
|
|
|
onStopCancel() {
|
|
this.setData({ showStopConfirm: false })
|
|
// 弹窗前若正在计时(被 onStop 暂停),取消后恢复
|
|
if (!this._stopWasPaused && this.data.isRunning) {
|
|
this._timer.resume()
|
|
this.setData({ isRunning: true, isPaused: false, status: 'running' })
|
|
}
|
|
},
|
|
|
|
onStopConfirm() {
|
|
this.setData({ showStopConfirm: false })
|
|
this.finishTraining()
|
|
},
|
|
|
|
finishTraining() {
|
|
// Guard against double-tap: completion modal shows immediately, so
|
|
// a second tap on the underlying 结束 button is harmless — but we
|
|
// still need to guard the save itself.
|
|
if (this._saving) return
|
|
|
|
const elapsed = this._timer.stop()
|
|
this._clearVibrateTimers()
|
|
if (elapsed < 3) {
|
|
wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 })
|
|
setTimeout(() => { wx.navigateBack() }, 1500)
|
|
return
|
|
}
|
|
// Completion voice (replaces the old auto-onComplete cue). No vibration.
|
|
const s = storage.getSettings()
|
|
if (s.voiceGuide !== false) {
|
|
voice.play('complete')
|
|
}
|
|
this._saveAndShowCompletion(elapsed)
|
|
},
|
|
|
|
/**
|
|
* Persist the just-finished training and pop the big completion modal.
|
|
* Called from finishTraining (the user manually ends; the timer keeps
|
|
* running past the goal until they stop). Detection of "first / record
|
|
* / milestone" uses pre-save stats so the level is decided before the
|
|
* data changes.
|
|
*/
|
|
_saveAndShowCompletion(elapsed) {
|
|
// Re-entry guard: protects the finishTraining path so the save never
|
|
// runs twice even if the user double-taps end.
|
|
if (this._saving) return
|
|
this._saving = true
|
|
|
|
const { level, message } = this._detectCelebrationLevel(elapsed)
|
|
const confettiPieces = this._buildConfettiPieces(level)
|
|
|
|
// Save record + streak
|
|
const today = storage.getToday()
|
|
storage.saveRecord({
|
|
date: today,
|
|
duration: elapsed,
|
|
planId: storage.getSettings().planId,
|
|
day: this.data.planDay
|
|
})
|
|
storage.updateStreak(today)
|
|
|
|
// Flag for the index page's "训练完成" highlight
|
|
try { wx.setStorageSync('_last_train_at', Date.now()) } catch (e) {}
|
|
|
|
const finalStreak = storage.getStreak().count
|
|
|
|
this.setData({
|
|
// Background effects keep playing behind the modal
|
|
status: 'completed',
|
|
isCompleted: true,
|
|
celebrationLevel: level,
|
|
celebrationMessage: message,
|
|
confettiPieces,
|
|
// Modal state
|
|
showCompletion: true,
|
|
completionElapsed: 0,
|
|
completionActualElapsed: elapsed,
|
|
completionTarget: this.data.duration,
|
|
completionOvertime: Math.max(0, elapsed - this.data.duration),
|
|
completionStreak: finalStreak,
|
|
completionLevel: level,
|
|
completionMessage: message
|
|
})
|
|
|
|
// Animate the big number from 0 to elapsed
|
|
if (this._completionCountUp) this._completionCountUp.cancel()
|
|
this._completionCountUp = countUp({
|
|
from: 0,
|
|
to: elapsed,
|
|
duration: 1200,
|
|
onUpdate: (v) => this.setData({ completionElapsed: v }),
|
|
onComplete: () => { this._completionCountUp = null }
|
|
})
|
|
},
|
|
|
|
onCloseCompletion() {
|
|
if (this._completionCountUp) {
|
|
this._completionCountUp.cancel()
|
|
this._completionCountUp = null
|
|
}
|
|
this.setData({ showCompletion: false })
|
|
setTimeout(() => { wx.navigateBack() }, 280)
|
|
},
|
|
|
|
onViewRecords() {
|
|
if (this._completionCountUp) {
|
|
this._completionCountUp.cancel()
|
|
this._completionCountUp = null
|
|
}
|
|
this.setData({ showCompletion: false })
|
|
setTimeout(() => {
|
|
wx.navigateBack({
|
|
success: () => {
|
|
setTimeout(() => wx.switchTab({ url: '/pages/records/records' }), 100)
|
|
}
|
|
})
|
|
}, 280)
|
|
},
|
|
|
|
onTrainAgain() {
|
|
// Reset the timer to idle so the user can start a new session right
|
|
// here (no need to navigate back and re-tap 开始).
|
|
if (this._completionCountUp) {
|
|
this._completionCountUp.cancel()
|
|
this._completionCountUp = null
|
|
}
|
|
// Reset per-session state so the next run fires reminders/saves
|
|
// correctly (previously these stayed set, muting cues on the 2nd run).
|
|
this._saving = false
|
|
this._halfwaySignaled = false
|
|
this._last30Signaled = false
|
|
this._last10Signaled = false
|
|
this._lastMinuteAt = 0
|
|
this._lastCountdown = 0
|
|
this._lastTipIndex = -1
|
|
this._clearVibrateTimers()
|
|
this._countUpTask = null
|
|
this.setData({
|
|
showCompletion: false,
|
|
isCompleted: false,
|
|
status: 'idle',
|
|
isRunning: false,
|
|
isPaused: false,
|
|
remaining: this.data.duration,
|
|
elapsed: 0,
|
|
goalReached: false
|
|
})
|
|
},
|
|
|
|
// No-op handler for catchtouchmove. Swallows move events that would
|
|
// otherwise bubble to underlying timer page controls (causing missed
|
|
// taps on the completion modal).
|
|
onNoop() { /* swallow */ },
|
|
|
|
/**
|
|
* 分享给朋友。训练完成弹窗里的分享按钮会走到这里,标题里拼上用户
|
|
* 刚才撑了多久,转化率更高;右上角"···"菜单也共用同一份文案。
|
|
*/
|
|
onShareAppMessage() {
|
|
const elapsed = this.data.completionActualElapsed || this.data.duration
|
|
const s = config.share.timer
|
|
return {
|
|
title: s.titleTemplate.replace('{elapsed}', elapsed),
|
|
path: s.path
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 分享到朋友圈,同样带上刚完成的时长晒成绩。
|
|
*/
|
|
onShareTimeline() {
|
|
const elapsed = this.data.completionActualElapsed || this.data.duration
|
|
return {
|
|
title: config.share.timer.timelineTitleTemplate.replace('{elapsed}', elapsed)
|
|
}
|
|
}
|
|
})
|