const storage = require('../../utils/storage') const planMod = require('../../utils/plan') const util = require('../../utils/util') const themeMod = require('../../utils/theme') const iconsMod = require('../../utils/icons') const config = require('../../config') const MAX_CUSTOM_DURATION = 3600 // 1 hour max Page({ data: { theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' }, themeStyle: themeMod.BASE_VARS, todayTarget: 0, todayDone: false, todayDuration: 0, todayTargetText: '', // 顶部问候区的用户资料(头像 + 昵称),来自 storage.getProfile()。 // 空则回退占位(人形图标 + "运动达人"),settings 改完回首页即刷新。 nickName: '', avatarUrl: '', // Ring number font size in rpx. WeChat auto-scales text by the user's // font-size setting (fontSizeScaleFactor) but does NOT scale rpx box // dimensions — so on a real device with enlarged system font the 84rpx // number grows past the fixed-size circular ring and gets clipped. We // cancel that scale here so the number renders at a constant visual size // (matching the devtools/simulator) on every device. See _readFontScale(). targetTimeFontSize: 96, streakCount: 0, planName: '', planDay: 1, planTotal: 7, // hero / 进度地图 / dial 相关展示字段(由 refresh 生成) greetText: '你好', streakText: '今天开启坚持之旅', questNodes: [], questTip: '', estKcal: 0, showPicker: false, pickerInputFocus: false, presets: [30, 60, 90, 120, 180, 300], customDuration: 60, customInput: '', // --- Circuit (循环训练) config panel (persisted via storage.saveCircuitConfig) --- showCircuitPicker: false, cHold: 30, cSets: 4, cRest: 15, cSessions: 3, icons: iconsMod.build() }, onLoad() { themeMod.applyThemeToPage(this) this._readFontScale() this._firstShow = true }, /** * Read the device's WeChat font-size scale and compensate the ring number * so it never overflows the fixed circular clip on real devices. * * WeChat auto-applies `fontSizeScaleFactor` to ALL text (including rpx font * sizes) but leaves rpx box dimensions untouched. A user who bumps up the * system font therefore sees enlarged text inside a non-enlarged ring. * * CRITICAL asymmetry: the DevTools simulator reports the SAME * `fontSizeScaleFactor` as a real device (the user's actual WeChat setting) * but does NOT actually scale rendered text. So if we compensate there, the * number ends up too small in the simulator. We therefore SKIP the * compensation under DevTools (host.env === 'devtools') and keep the design * size — matching what the simulator renders literally. * * On real devices, dividing our design size by the factor cancels the * auto-scale, keeping the number a constant 96rpx-equivalent everywhere. * Bounded so a bad reading can only ever make the number smaller (safe), * never larger (overflow). */ _readFontScale() { let factor = 1 try { const info = wx.getAppBaseInfo ? wx.getAppBaseInfo() : wx.getSystemInfoSync() const isDevtools = info.host && info.host.env === 'devtools' if (!isDevtools) { if (typeof info.fontSizeScaleFactor === 'number' && info.fontSizeScaleFactor > 0) { factor = info.fontSizeScaleFactor } else if (typeof info.fontSizeSetting === 'number' && info.fontSizeSetting > 0) { // fontSizeScaleFactor == currentFontSize / standardFontSize(17px) factor = info.fontSizeSetting / 17 } } } catch (e) {} const DESIGN = 96 let size = Math.round(DESIGN / factor) size = Math.max(48, Math.min(96, size)) this.setData({ targetTimeFontSize: size }) }, onShow() { themeMod.applyThemeToPage(this) try { const tb = this.getTabBar() if (tb) tb.setData({ selected: 0 }) } catch (e) {} // Skip the first onShow to avoid double-refresh; subsequent tab switches still refresh. if (this._firstShow) { this._firstShow = false this.refresh() this._checkFreshTrain() return } this.refresh() this._checkFreshTrain() }, /** * If user just returned from completing a training, briefly highlight * the "今日已完成" badge so the success feels tangible. The flag is * timestamped; older than 10s is treated as stale (e.g. user reopened * the app later). */ _checkFreshTrain() { try { const at = wx.getStorageSync('_last_train_at') if (!at) return const age = Date.now() - at if (age < 0 || age > 10 * 1000) { wx.removeStorageSync('_last_train_at') return } wx.removeStorageSync('_last_train_at') this.setData({ justCompleted: true }) if (this._justCompletedTimer) clearTimeout(this._justCompletedTimer) this._justCompletedTimer = setTimeout(() => { this.setData({ justCompleted: false }) }, 3000) } catch (e) {} }, onPullDownRefresh() { this.refresh() wx.stopPullDownRefresh() }, refresh() { const settings = storage.getSettings() const streak = storage.validateStreak() const todayRecord = storage.getTodayRecord() const allRecords = Object.values(storage.getRecords()).flat() const customPlans = storage.getCustomPlans() const plan = planMod.getPlan(settings.planId, customPlans) const planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate, customPlans) const clampedDay = Math.min(planDay, plan.totalDays) const target = planMod.getTodayTarget(settings.planId, clampedDay, customPlans) const theme = themeMod.getCurrentTheme() const profile = storage.getProfile() const greetText = this._greetByHour() const streakText = streak.count > 0 ? `已连续 ${streak.count} 天打卡` : '今天开启坚持之旅' const questNodes = this._buildQuestMap(plan.totalDays, clampedDay) const questTip = clampedDay >= plan.totalDays ? '🎉 本期计划已完成,去记录页看看战绩' : `⚡ 再坚持 ${plan.totalDays - clampedDay} 天,完成本期计划` // 千卡粗算:按约 4 千卡/分钟估算(1分30秒≈6千卡)。仅作激励性参考,非精确值。 const estKcal = Math.max(1, Math.round((target / 60) * 4)) this.setData({ theme, icons: iconsMod.build(theme), todayTarget: target, todayDone: todayRecord && todayRecord.duration >= target, todayDuration: todayRecord ? todayRecord.duration : 0, todayTargetText: util.formatTime(target), streakCount: streak.count, planName: plan.name, planDay: clampedDay, planTotal: plan.totalDays, greetText, streakText, questNodes, questTip, estKcal, nickName: profile.nickname || '', avatarUrl: profile.avatarUrl || '' }) }, _greetByHour() { const h = new Date().getHours() if (h < 6) return '夜深了' if (h < 12) return '早上好' if (h < 14) return '中午好' if (h < 18) return '下午好' if (h < 22) return '晚上好' return '夜深了' }, /** * 生成闯关进度地图节点。计划天数 <= 8 时画每天节点;> 8 时退化为 * 5 个里程碑节点(0/25/50/75/100%),避免节点过密。state: done|now|todo。 */ _buildQuestMap(total, day) { const nodes = [] if (total > 0 && total <= 8) { for (let i = 1; i <= total; i++) { nodes.push({ label: i === total ? '🏆' : String(i), state: i < day ? 'done' : (i === day ? 'now' : 'todo'), final: i === total }) } return nodes } const marks = [0, 0.25, 0.5, 0.75, 1] let nowAssigned = false marks.forEach((m, idx) => { const dayAt = Math.round(m * total) const isFinal = idx === marks.length - 1 let state if (dayAt <= day) state = 'done' else if (!nowAssigned) { state = 'now'; nowAssigned = true } else state = 'todo' nodes.push({ label: isFinal ? '🏆' : (m === 0 ? '起' : String(dayAt)), state, final: isFinal }) }) return nodes }, onStartTrain() { wx.navigateTo({ url: '/pages/timer/timer' }) }, onFreeTrain() { this.setData({ showPicker: true, customInput: '', customDuration: 60 }) }, onClosePicker() { this.setData({ showPicker: false }) }, onSelectPreset(e) { const val = e.currentTarget.dataset.value // 收起键盘(失焦 input),避免清空 customInput 时触发 adjust-position 让页面向上跳 this.setData({ pickerInputFocus: false, customDuration: val, customInput: '' }) }, onPickerInputFocus() { this.setData({ pickerInputFocus: true }) }, onCustomInput(e) { let val = parseInt(e.detail.value) || 0 if (val > MAX_CUSTOM_DURATION) val = MAX_CUSTOM_DURATION this.setData({ customInput: e.detail.value, customDuration: val }) }, onConfirmFree() { const dur = this.data.customDuration if (!dur || dur <= 0) { wx.showToast({ title: '请输入有效时长', icon: 'none' }) return } if (dur > MAX_CUSTOM_DURATION) { wx.showToast({ title: '最大时长为1小时', icon: 'none' }) return } this.setData({ showPicker: false }) wx.navigateTo({ url: `/pages/timer/timer?free=${dur}` }) }, /** * 循环训练入口:打开配置弹窗,预填上次保存的设置(getCircuitConfig 含默认值 * + 校验),用户改完确认后持久化并跳计时器。设置因此被保存下来——下次打开直接 * 沿用,无需重填。 */ onCircuitTrain() { const cfg = storage.getCircuitConfig() this.setData({ showCircuitPicker: true, cHold: cfg.holdPerSet, cSets: cfg.sets, cRest: cfg.restPerSet, cSessions: cfg.sessionsPerWeek }) }, onCloseCircuitPicker() { this.setData({ showCircuitPicker: false }) }, onCircuitStep(e) { const field = e.currentTarget.dataset.field const delta = Number(e.currentTarget.dataset.delta) || 0 const keyMap = { hold: 'cHold', sets: 'cSets', rest: 'cRest', sessions: 'cSessions' } const key = keyMap[field] if (!key) return const bounds = { hold: [5, 600], sets: [1, 50], rest: [0, 600], sessions: [1, 14] } let v = this.data[key] + delta const min = bounds[field][0] const max = bounds[field][1] if (v < min) v = min if (v > max) v = max this.setData({ [key]: v }) }, onConfirmCircuit() { const cfg = { holdPerSet: this.data.cHold, sets: this.data.cSets, restPerSet: this.data.cRest, sessionsPerWeek: this.data.cSessions } storage.saveCircuitConfig(cfg) this.setData({ showCircuitPicker: false }) wx.navigateTo({ url: `/pages/timer/timer?circuit=1&hold=${cfg.holdPerSet}&sets=${cfg.sets}&rest=${cfg.restPerSet}&sessions=${cfg.sessionsPerWeek}` }) }, /** * 分享给朋友。微信在用户点击右上角"···"菜单里的"转发给朋友"、 * 或者页面内任何 `