b52db87ddf
- 领奖台数据不足 3 名时显示虚拟空位(虚线轮廓+虚位以待) - 领奖台勋章与昵称对齐改为等高盒结构性修复(移除 margin 盲推) - 全 app 成就勋章图标尺寸统一为 30rpx(前三名/列表/设置页/记录页一致) - 勋章 SVG 按包围盒精确居中(utils/icons.js) - 设置页/记录页勋章展示配套调整
890 lines
32 KiB
JavaScript
890 lines
32 KiB
JavaScript
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 秒")
|
||
// 顶部循环进度面板:组进度胶囊点阵
|
||
circuitDots: [], // [1..sets],仅用于 wx:for 渲染,组数过多时不渲染
|
||
circuitShowDots: true, // 组数 > DOTS_MAX 时退化为线性进度条
|
||
circuitTotalWork: 0, // sets × holdPerSet,面板里作为"目标秒数"展示
|
||
dotsDone: 0, // 已完成组数
|
||
dotsActive: 0, // 正在进行的组序号(休息时为 0)
|
||
dotsNext: 0, // 休息时即将开始的组序号(工作时为 0)
|
||
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: '',
|
||
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)
|
||
}
|
||
|
||
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 })
|
||
}
|
||
})
|
||
},
|
||
|
||
/**
|
||
* 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) => {
|
||
// 组进度点阵三态。休息阶段 CircuitTimer 的 setIndex 仍指向"刚做完
|
||
// 的那一组",所以 resting 时 setIndex 组算已完成、下一组标为 next。
|
||
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,
|
||
dotsDone: resting ? t.setIndex : Math.max(0, t.setIndex - 1),
|
||
dotsActive: resting ? 0 : t.setIndex,
|
||
dotsNext: resting ? t.setIndex + 1 : 0,
|
||
// 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
|
||
})
|
||
this._circuitCue(t)
|
||
},
|
||
onPhaseChange: (p) => this._circuitPhase(p),
|
||
onComplete: () => this._onCircuitComplete()
|
||
})
|
||
|
||
// 点阵超过 DOTS_MAX 个就挤成头发丝了,退化成一条线性进度条。
|
||
const DOTS_MAX = 15
|
||
const dots = []
|
||
if (sets <= DOTS_MAX) {
|
||
for (let i = 1; i <= sets; i++) dots.push(i)
|
||
}
|
||
|
||
this.setData({
|
||
isCircuitMode: true,
|
||
isFreeMode: false,
|
||
planDay: 0,
|
||
circuitSets: sets,
|
||
circuitHold: hold,
|
||
circuitRest: rest,
|
||
circuitSessions: this._circuitCfg.sessionsPerWeek,
|
||
circuitDots: dots,
|
||
circuitShowDots: sets <= DOTS_MAX,
|
||
circuitTotalWork: sets * hold,
|
||
dotsDone: 0,
|
||
dotsActive: 0,
|
||
dotsNext: 0,
|
||
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
|
||
})
|
||
},
|
||
|
||
_int(v, fallback, min, max) {
|
||
const n = parseInt(v)
|
||
if (!Number.isFinite(n)) return fallback
|
||
return Math.max(min, Math.min(max, n))
|
||
},
|
||
|
||
/**
|
||
* 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) => 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++) {
|
||
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) => 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 = []
|
||
},
|
||
|
||
/**
|
||
* 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' })
|
||
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' })
|
||
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)
|
||
},
|
||
|
||
/**
|
||
* 按本次实际训练时长从 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
|
||
})
|
||
},
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
})
|