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 } if (this._completionCountUp) { this._completionCountUp.cancel() this._completionCountUp = 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) } } })