Files
wx_pbzc/pages/timer/timer.js
T
lc 05c4763e49 feat(timer): 循环训练组进度改为圆环分段弧 + 信息气泡三行合一
- progress-ring 新增 sets/currentSet/resting/successColor 属性,
  圆环外圈绘制分段弧(已完成=主色/当前=呼吸光点/休息下一组=绿),单组模式零渲染
- timer 删除顶部 circuit-bar 进度面板,布局骨架三模式统一
- 呼吸环淡化(描边 0.55→0.20,柔光渐变强度减半)
- 目标卡与提示气泡合并为 info-bubble,状态文案(撑住/休息中/已暂停)并入气泡顶部
2026-08-12 09:39:41 +08:00

923 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const Timer = require('../../utils/timer')
const CircuitTimer = require('../../utils/circuitTimer')
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,
// --- Circuit (循环训练) mode fields ---
isCircuitMode: false,
circuitSets: 4,
circuitHold: 30,
circuitRest: 15,
circuitSessions: 3,
currentSet: 0,
totalSets: 0,
phase: 'idle', // idle | working | resting | done
phaseRemaining: 0,
phaseElapsed: 0,
workTotal: 0, // effective held seconds (excludes rest)
isResting: false,
phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒")
// 组进度改由 progress-ring 外圈分段弧呈现,仅需传 sets/currentSet/resting,
// 页面不再维护点阵渲染数据(circuitDots/dotsDone 等已随旧面板一并删除)。
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: '',
// 完成弹窗科学提示(config.scienceTips 按本次时长选段)
completionTipValue: '',
completionTipRisk: '',
// --- 计时页改版派生字段(状态文案 / 目标信息卡 / 环刻度 / 环内副标题) ---
ringTicks: 0,
ringSubText: '',
statusText: '准备开始',
goalLine: '',
runningTip: '',
tipType: 'posture',
tipIcon: '',
icons: iconsMod.build()
},
onLoad(options) {
themeMod.applyThemeToPage(this)
this._init(options)
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)。
// 按模式取词条:循环模式要 restStart/nextSet/lastSet,单组模式要 goal,
// 各自不载对方的,省掉无谓的云函数调用与下载。
const s = storage.getSettings()
if (s.voiceGuide !== false) {
voice.preload(this.data.isCircuitMode ? voice.CIRCUIT_KEYS : voice.HOLD_KEYS)
}
// 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) })
// Coming back from the background (WeChat chat, phone call, home screen)
// clears the app-wide keep-awake flag, so re-arm it if a session is still
// in flight — otherwise the second half of the training silently loses
// every voice/haptic cue again.
if (this.data.isRunning) this._keepScreenOn(true)
},
_init(options) {
this._initOptions = options || {}
// Circuit (循环训练) mode: build the work/rest FSM and bail out of the
// single-hold init below. All controls / completion modal / save path are shared.
if (this._initOptions.circuit) {
this._initCircuit(this._initOptions)
return
}
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)
}
const durText = this._durationText(target)
this.setData({
duration: target,
remaining: target,
todayTarget: target,
planDay: isFreeMode ? 0 : planDay,
isFreeMode,
goalReached: false,
elapsed: 0,
goalLine: isFreeMode ? `自由训练 · 目标 ${durText}` : `今日目标 ${durText} · 第 ${planDay} 天`,
ringTicks: Math.min(target, 60),
ringSubText: `目标 ${durText}`,
statusText: '准备开始',
runningTip: '',
tipType: 'posture',
tipIcon: this.data.icons.peopleFill
})
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,
runningTip: this.data.goalReached ? '目标达成,继续挑战!' : this.data.hintTip,
tipType: this.data.goalReached ? 'done' : 'posture',
tipIcon: this.data.goalReached ? this.data.icons.successFill : this.data.icons.peopleFill
})
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, runningTip: '目标达成,继续挑战!', tipType: 'done', tipIcon: this.data.icons.successFill })
}
})
},
/**
* Initialize a circuit (循环) training session. Reads the persisted config
* (utils/storage.getCircuitConfig) but lets URL params override so the home
* page can jump straight in with the last-saved settings.
*/
_initCircuit(options) {
if (this._timer) {
this._timer.stop()
this._timer = null
}
// Reset per-session state (mirrors the single-hold path)
this._halfwaySignaled = false
this._last30Signaled = false
this._last10Signaled = false
this._lastMinuteAt = 0
this._lastCountdown = 0
this._saving = false
this._lastTipIndex = -1
this._voiceBusyUntil = 0
this._clearVibrateTimers()
const cfg = storage.getCircuitConfig()
const hold = this._int(options.hold, cfg.holdPerSet, 5, 600)
const sets = this._int(options.sets, cfg.sets, 1, 50)
const rest = this._int(options.rest, cfg.restPerSet, 0, 600)
this._circuitCfg = {
holdPerSet: hold,
sets,
restPerSet: rest,
sessionsPerWeek: cfg.sessionsPerWeek
}
this._timer = new CircuitTimer({
holdPerSet: hold,
sets,
restPerSet: rest,
onTick: (t) => {
// 组进度语义与旧点阵一致:休息时 setIndex 仍指向"刚做完的那一组",
// 组件按 resting 自行推导已完成/进行中/下一组三态(外圈分段弧)。
const resting = t.phase === 'resting'
this.setData({
currentSet: t.setIndex,
totalSets: t.sets,
phase: t.phase,
phaseRemaining: t.phaseRemaining,
phaseElapsed: t.phaseElapsed,
workTotal: t.workTotal,
isResting: resting,
// Reuse the single-hold display fields so progress-ring / completion
// number keep working: the ring shows the CURRENT set's countdown.
remaining: t.phaseRemaining,
elapsed: t.workTotal,
duration: t.phase === 'working' ? hold : rest,
statusText: resting ? '休息中' : '撑住',
runningTip: resting ? '' : this.data.phaseTip,
tipType: 'phase',
tipIcon: this.data.icons.peopleFill,
// 环内副标题:宏观进度(第几组)交给外圈分段弧,这里只报当前组位次。
ringSubText: `第 ${t.setIndex}/${t.sets} 组`
})
this._circuitCue(t)
},
onPhaseChange: (p) => this._circuitPhase(p),
onComplete: () => this._onCircuitComplete()
})
// 组数上限 50(见 _int 的 clamp),环形外圈 360° 空间充裕,即使 50 段也
// 不至于挤成线(旧点阵的 DOTS_MAX=15 是受横向卡片宽度限制,环形无此问题),
// 因此分段弧不做数量降级,页面也无须维护点阵渲染数据。
const durText = this._durationText(hold)
this.setData({
isCircuitMode: true,
isFreeMode: false,
planDay: 0,
circuitSets: sets,
circuitHold: hold,
circuitRest: rest,
circuitSessions: this._circuitCfg.sessionsPerWeek,
currentSet: 0,
totalSets: sets,
phase: 'idle',
phaseRemaining: hold,
phaseElapsed: 0,
workTotal: 0,
isResting: false,
phaseTip: '',
duration: hold,
remaining: hold,
todayTarget: hold,
goalReached: false,
elapsed: 0,
status: 'idle',
isRunning: false,
isPaused: false,
isCompleted: false,
showCompletion: false,
goalLine: `每组 ${durText} · 共 ${sets} 组`,
ringTicks: Math.min(hold, 60),
// idle 时环内副标题承载配置摘要(组数 + 休息时长),运行时由 onTick
// 换成"第 X/Y 组"。休息时长为 0 时不展示"休息"段。
ringSubText: `共 ${sets}${rest > 0 ? ' · 休息 ' + rest + ' 秒' : ''}`,
statusText: '准备开始',
runningTip: '',
tipType: 'posture',
tipIcon: this.data.icons.peopleFill
})
},
_int(v, fallback, min, max) {
const n = parseInt(v)
if (!Number.isFinite(n)) return fallback
return Math.max(min, Math.min(max, n))
},
/**
* 把秒数转成口语化时长文本:45 -> "45秒", 90 -> "1分30", 120 -> "2分钟"。
* 用于目标信息卡与环内副标题,比 MM:SS 更贴近自然语言。
*/
_durationText(sec) {
const s = Math.floor(sec || 0)
if (s < 60) return s + '秒'
const m = Math.floor(s / 60)
const rem = s % 60
return rem === 0 ? m + '分钟' : m + '分' + rem
},
/**
* Fire a haptic buzz, tracked so pause/stop/unload can cancel pending ones.
* Mirrors the vibrate helper inside _remind().
*/
_vibrateOnce(n, ms) {
const s = storage.getSettings()
if (s.vibrate === false) return
const intensity = s.vibrateIntensity || 'heavy'
const doVibrate = () => {
const fail = (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++) {
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) {}
},
/**
* Per-tick circuit cues. Mirrors _remind() for the single-hold mode, but the
* yardstick is the WHOLE session's effective held time (sets × holdPerSet),
* NOT the current set — otherwise a 4-set circuit would announce "已完成一半啦"
* four times (once per set), which is both noisy and semantically wrong.
*
* Only evaluated while `working`: workTotal is frozen during rest, so the
* thresholds can never re-fire mid-break.
*/
_circuitCue(t) {
if (t.phase !== 'working') return
const total = this._circuitCfg.sets * this._circuitCfg.holdPerSet
const done = t.workTotal
const left = total - done
const s = storage.getSettings()
const voiceOn = s.voiceGuide !== false
// Same duration gates as _remind(), so short circuits (e.g. 3 sets × 5s)
// don't fire "最后30秒" on the very first tick. `<=` rather than `===`
// survives a skipped second when the app returns from background.
if (total >= 10 && !this._halfwaySignaled && done >= Math.floor(total / 2)) {
this._halfwaySignaled = true
if (voiceOn) this._playProgressVoice('halfway')
this._vibrateOnce(2, 150)
}
if (total > 60 && left <= 30 && !this._last30Signaled) {
this._last30Signaled = true
if (voiceOn) this._playProgressVoice('last30')
}
if (total > 20 && left <= 10 && !this._last10Signaled) {
this._last10Signaled = true
if (voiceOn) this._playProgressVoice('last10')
}
// Countdown buzz over the last 5s of the FINAL set (the real finish line).
if (t.setIndex >= this._circuitCfg.sets && t.phaseRemaining <= 5 && t.phaseRemaining > 0 && t.phaseRemaining !== this._lastCountdown) {
this._lastCountdown = t.phaseRemaining
this._vibrateOnce(1, 100)
}
},
/**
* Play a whole-session progress prompt (halfway / last30 / last10) and claim
* a short priority window afterwards.
*
* Why: the halfway mark is `sets × hold / 2`, which for an EVEN set count
* lands exactly on the final second of set sets/2 — i.e. the same tick as the
* rest transition. voice.play() drops any in-flight clip when a new one
* starts, so without this the phase line would cut "已完成一半啦" off after
* one syllable. Progress prompts win; the phase line is the expendable one.
*/
_playProgressVoice(key) {
this._voiceBusyUntil = Date.now() + 2600 // ~ one spoken line
voice.play(key)
},
/**
* Fired once on each work/rest transition: haptic + on-screen phaseTip +
* a voice line (restStart / nextSet / lastSet — number-free, so 3 fixed
* clips cover any set count).
*
* `chatty` gate: a spoken line takes ~2s, so on short sets (<10s each) the
* prompts would overlap into a stream of chatter and step on the training
* rhythm. Below that we keep only the "last set" cue (the one moment worth
* interrupting for) and let haptics + text carry the rest.
*/
_circuitPhase(p) {
const s = storage.getSettings()
const chatty = this._circuitCfg.holdPerSet >= 10
// Yield the mic if a progress prompt is still speaking (see _playProgressVoice).
const voiceOn = s.voiceGuide !== false && Date.now() >= (this._voiceBusyUntil || 0)
if (p.phase === 'working') {
if (p.setIndex === 1) {
// No voice here: onStart already plays 'start' at the same instant.
this.setData({ phaseTip: '开始第一组' })
} else if (p.isLastSet) {
this.setData({ phaseTip: '最后一组,冲刺!' })
if (voiceOn) voice.play('lastSet')
this._vibrateOnce(2, 150)
} else {
this.setData({ phaseTip: '第 ' + p.setIndex + ' 组' })
if (voiceOn && chatty) voice.play('nextSet')
this._vibrateOnce(1, 150)
}
} else if (p.phase === 'resting') {
this.setData({ phaseTip: '休息 ' + this._circuitCfg.restPerSet + ' 秒' })
// Skip on very short breaks — the clip would still be talking when the
// next set starts, colliding with nextSet/lastSet.
if (voiceOn && chatty && this._circuitCfg.restPerSet >= 5) voice.play('restStart')
this._vibrateOnce(1, 100)
}
},
/**
* All sets completed naturally. workTotal already equals sets*holdPerSet.
* Reuse the shared completion/save path so celebration + streak + record
* all behave identically to a normal session.
*/
_onCircuitComplete() {
this._clearVibrateTimers()
const s = storage.getSettings()
if (s.voiceGuide !== false) voice.play('complete')
this._saveAndShowCompletion(this.data.workTotal)
},
/**
* 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) => {}
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 = []
},
/**
* Keep the screen awake while training.
*
* Why this matters here: during a plank the user's hands are on the floor —
* nobody taps the screen, so the system's idle timer (usually 60s) fires
* mid-session. Once the screen locks, the mini program is backgrounded and
* JS timers get throttled/suspended: the elapsed count still recovers
* (Timer/CircuitTimer reconcile against Date.now on resume), but every
* real-time cue in between — halfway / last30 / last10 voice, set-change
* and rest prompts, haptics — is simply lost.
*
* Scope caveat: wx.setKeepScreenOn is APP-wide, not page-scoped, and only
* resets when the user exits the mini program. So onUnload MUST turn it
* back off, otherwise the screen stays lit while they browse records or
* the leaderboard afterwards.
*
* fail is swallowed on purpose: some Android ROMs ignore this under battery
* saver, and a "failed to keep screen on" toast would only confuse the user
* — the training itself is unaffected. Supported since base library 1.4.0.
*/
_keepScreenOn(on) {
if (typeof wx.setKeepScreenOn !== 'function') return
wx.setKeepScreenOn({ keepScreenOn: !!on, fail() {} })
},
/**
* 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()
// 本函数运行在保存记录之前(pre-save state)。updateStreak 对「同一天再次训练」
// 会直接 return、不递增连胜数;所以若 streak.lastDate 已是今天,nextStreak 应取
// 当前 count 而非 count+1,否则庆祝文案会比真实连胜多 1 天。
const todayOnly = storage.getToday().substring(0, 10)
const lastIsToday = !!(streak.lastDate && streak.lastDate.substring(0, 10) === todayOnly)
const nextStreak = lastIsToday
? (streak.count || 1)
: ((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()
// Mandatory: the flag is app-wide, so leaving it on would keep the screen
// lit across every other page until the user quits the mini program.
this._keepScreenOn(false)
this._clearVibrateTimers()
if (this._timer) {
this._timer.stop()
this._timer = null
}
if (this._countUpTask) {
this._countUpTask.cancel()
this._countUpTask = null
}
if (this._completionCountUp) {
this._completionCountUp.cancel()
this._completionCountUp = null
}
},
onStart() {
if (this.data.isPaused) {
this._timer.resume()
// Re-assert: if the user backgrounded the app while paused, WeChat has
// already cleared the flag for us.
this._keepScreenOn(true)
this.setData({ isRunning: true, isPaused: false, status: 'running', statusText: '撑住', runningTip: '' })
return
}
if (this.data.isRunning) return
// Screen stays awake from here until onUnload. Deliberately NOT turned
// off on pause (users pause to adjust posture — a screen blackout mid-set
// is worse than a few seconds of extra backlight) nor on the completion
// modal (they're reading their result).
this._keepScreenOn(true)
// 取消可能还在滚的 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', statusText: '撑住', runningTip: '' })
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', statusText: '已暂停', runningTip: '' })
},
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', statusText: '撑住', runningTip: '' })
}
},
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)
},
/**
* 按本次实际训练时长从 config.scienceTips 选一段提示。
* 命中第一条 maxSeconds 大于 elapsed 的配置;缺失配置时返回空(不显示)。
*/
_pickScienceTip(elapsed) {
const tips = config.scienceTips || []
const sec = Math.max(0, Number(elapsed) || 0)
for (const t of tips) {
const max = t && t.maxSeconds == null ? Infinity : (t && t.maxSeconds)
if (sec < max) {
return { value: (t && t.value) || '', risk: (t && t.risk) || '' }
}
}
return { value: '', risk: '' }
},
/**
* 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()
const record = {
date: today,
duration: elapsed,
// Circuit records use a special planId so getPlanDay() never counts
// them toward the daily-plan progress; leaderboard/stats aggregate by
// `duration`, so a circuit's effective held seconds count normally.
planId: this.data.isCircuitMode ? 'circuit' : storage.getSettings().planId,
day: this.data.planDay
}
if (this.data.isCircuitMode && this._circuitCfg) {
record.mode = 'circuit'
record.sets = this._circuitCfg.sets
record.holdPerSet = this._circuitCfg.holdPerSet
record.restPerSet = this._circuitCfg.restPerSet
record.sessionsPerWeek = this._circuitCfg.sessionsPerWeek
}
storage.saveRecord(record)
storage.updateStreak(today)
// Flag for the index page's "训练完成" highlight
try { wx.setStorageSync('_last_train_at', Date.now()) } catch (e) {}
// 标记排行榜强刷:下次打开排行榜跳过云函数缓存,让刚训练的数据立即可见
try { wx.setStorageSync('_lb_force_refresh', true) } catch (e) {}
const finalStreak = storage.getStreak().count
const scienceTip = this._pickScienceTip(elapsed)
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,
// Circuit mode has no "overtime" concept (each set is a fixed target)
// and `duration` here is just the last set's length, so hide it.
completionOvertime: this.data.isCircuitMode ? 0 : Math.max(0, elapsed - this.data.duration),
completionStreak: finalStreak,
completionLevel: level,
completionMessage: message,
// 科学提示(按时长分段;无配置时为空字符串,wx:if 不渲染)
completionTipValue: scienceTip.value,
completionTipRisk: scienceTip.risk
})
// 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
}
// Circuit mode: rebuild the FSM (start() only resets from idle, and a
// finished/paused timer is no longer idle, so a fresh init is the clean
// reset; _initCircuit also closes the completion modal).
if (this.data.isCircuitMode) {
this._initCircuit(this._initOptions)
return
}
// 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,
statusText: '准备开始',
runningTip: '',
tipType: 'posture',
tipIcon: this.data.icons.peopleFill
})
},
// 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)
}
}
})