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') 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, overtime: 0, todayTarget: 0, planDay: 1, isFreeMode: false, celebrationLevel: 'normal', // normal / first / milestone / record celebrationMessage: '', confettiPieces: [], icons: iconsMod.build() }, onLoad(options) { themeMod.applyThemeToPage(this) this._init(options) // 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) target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays)) } this.setData({ duration: target, remaining: target, todayTarget: target, planDay: isFreeMode ? 0 : planDay, isFreeMode }) if (this._timer) { this._timer.stop() this._timer = null } // Reset reminder state this._halfwaySignaled = false this._last30Signaled = false this._last10Signaled = false this._lastMinuteAt = 0 this._lastCountdown = 0 this._saving = false this._timer = new Timer({ onTick: (tick) => { this.setData({ remaining: tick.remaining > 0 ? tick.remaining : 0, overtime: tick.remaining <= 0 ? tick.elapsed - this.data.duration : 0 }) this._remind(tick) }, onComplete: () => { const elapsed = this._timer.elapsed const { level, message } = this._detectCelebrationLevel(elapsed) const confettiPieces = this._buildConfettiPieces(level) this.setData({ status: 'completed', isCompleted: true, celebrationLevel: level, celebrationMessage: message, confettiPieces }) const s = storage.getSettings() if (s.vibrate) { try { wx.vibrateLong() } catch (e) {} } // 4th voice prompt: completion if (s.voiceGuide !== false) { voice.play('complete') } wx.showToast({ title: '目标完成! 太棒了!', icon: 'success', duration: 2000 }) } }) }, /** * Per-tick reminders: vibration + voice prompts at 4 fixed points. * - halfway: once when elapsed reaches half of duration * - 30s: once when remaining hits 30 * - 10s: once when remaining hits 10 * - complete: fired in onComplete above * * Vibration and voice are independently gated by `vibrate` / `voiceGuide` * settings — user can mute haptics but keep voice (e.g. in office) or vice versa. */ _remind(tick) { const s = storage.getSettings() const { remaining, elapsed, duration } = tick const vibrate = (n, ms) => { if (!s.vibrate) return try { for (let i = 0; i < n; i++) { setTimeout(() => wx.vibrateShort(), i * (ms + 30)) } } catch (e) {} } // Each prompt is gated on a minimum duration so short free-mode // trainings don't trigger them at nonsensical times (e.g. firing // "最后30秒" on the first tick of a 30s session because remaining // happens to equal 30 at elapsed=0). // // Thresholds chosen so prompts fire in the right ORDER for any // duration: halfway first, then last30, then last10. // 1. Halfway: needs >=10s total so the midpoint is at least 5s in if (duration >= 10 && !this._halfwaySignaled && elapsed >= Math.floor(duration / 2)) { this._halfwaySignaled = true if (s.voiceGuide !== false) voice.play('halfway') vibrate(2, 150) } // 2. Last 30 seconds: only if duration > 60s so halfway has already passed if (duration > 60 && remaining === 30 && !this._last30Signaled) { this._last30Signaled = true if (s.voiceGuide !== false) voice.play('last30') } // 3. Last 10 seconds: only if duration > 20s so we have at least 10s of training if (duration > 20 && remaining === 10 && !this._last10Signaled) { this._last10Signaled = true if (s.voiceGuide !== false) voice.play('last10') } // 4. Countdown buzzes at 5..1: only if duration >= 6s (so we can reach them) if (duration >= 6 && remaining <= 5 && remaining > 0 && remaining !== this._lastCountdown) { this._lastCountdown = remaining vibrate(1, 100) } }, /** * Detect which celebration level to show. Called at onComplete (BEFORE * saveRecord fires in finishTraining), 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() 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.setData({ isPaused: true, status: 'paused' }) }, onStop() { wx.showModal({ title: '结束训练', content: '确定要结束本次训练吗?', success: (res) => { if (res.confirm) this.finishTraining() } }) }, finishTraining() { // Guard against double-tap: stop button is still visible during the // 1.5s navigateBack delay, so a second tap would create a duplicate. if (this._saving) return this._saving = true const elapsed = this._timer.stop() if (elapsed < 3) { this._saving = false wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 }) setTimeout(() => { wx.navigateBack() }, 1500) return } const today = storage.getToday() storage.saveRecord({ date: today, duration: elapsed, planId: storage.getSettings().planId, day: this.data.planDay }) storage.updateStreak(today) wx.showToast({ title: `已记录 ${elapsed}秒`, icon: 'none', duration: 1500 }) setTimeout(() => { wx.navigateBack() }, 1500) } })