const themeMod = require('./utils/theme') const cloud = require('./utils/cloud') const storage = require('./utils/storage') const darkMod = require('./utils/darkMode') const voice = require('./utils/voice') App({ async onLaunch() { // 尽早设导航栏色 + 窗口背景:原生导航栏在 JS 执行前用 app.json 写死的 // 橙色,这里在 onLaunch 第一时间切到用户主题色,缩小冷启动跳变窗口。 themeMod.applyChrome() // Init dark mode listener (refreshes all open pages when system theme changes) darkMod.watchDarkMode((isDark) => { const pages = getCurrentPages() pages.forEach(p => { if (p && typeof p.applyThemeToPage === 'function') { p.applyThemeToPage() } else if (p && p.route) { // index/records/leaderboard 等页面没定义 applyThemeToPage 方法, // 但都在 onLoad/onShow 里套用过主题,这里直接用 themeMod 兜底刷新, // 否则系统深/浅色切换时这些页不会实时变色。 try { themeMod.applyThemeToPage(p) } catch (e) {} } // 自定义 tabBar 不在 pages 列表里,单独通知它切换暗黑/明亮 try { const tb = p.getTabBar && p.getTabBar() if (tb && typeof tb.updateTheme === 'function') tb.updateTheme() } catch (e) {} }) }) // Init cloud storage (no-op if not configured) cloud.init() const settings = wx.getStorageSync('user_settings') if (!settings) { wx.setStorageSync('user_settings', { planId: 'beginner', voiceGuide: true, vibrate: true, planStartDate: storage.getToday() }) } else if (!settings.planStartDate) { // Migrate: add planStartDate for existing users settings.planStartDate = storage.getToday() wx.setStorageSync('user_settings', settings) } // 预载语音到本地缓存:app 启动即后台下载,训练时即时播放(语音引导开启时) const _vs = wx.getStorageSync('user_settings') || {} if (_vs.voiceGuide !== false) voice.preload() const streak = wx.getStorageSync('current_streak') if (!streak) { wx.setStorageSync('current_streak', { count: 0, lastDate: '' }) } this.globalData.theme = themeMod.getCurrentTheme() // Sync with cloud: always locate our doc first (pullAll caches // _openid / _docId), then either restore or push. The previous flow // only pulled when local was empty, so on a cold start with non-empty // local it pushed without locating — and because the module-level // _openid / _docId caches had been reset, pushAll fell through to // add() and created a duplicate cloud doc every cold start. The // leaderboard then summed duration across N docs for the same user. const records = wx.getStorageSync('training_records') if (cloud.enabled) { try { const cloudData = await cloud.pullAll() const hasLocal = records && Object.keys(records).length > 0 const hasCloud = cloudData && cloudData.records && Object.keys(cloudData.records).length > 0 && Object.values(cloudData.records).some(arr => Array.isArray(arr) && arr.length > 0) // Parse cloud updatedAt into a comparable timestamp let cloudTs = 0 if (cloudData && cloudData.updatedAt) { const d = cloudData.updatedAt instanceof Date ? cloudData.updatedAt : new Date(cloudData.updatedAt) cloudTs = d.getTime() || 0 } const localSyncAt = wx.getStorageSync('_cloud_sync_at') || 0 if (!hasLocal && hasCloud) { // Case 1: Fresh install on a new device — restore from cloud this._restoreFromCloud(cloudData, cloudTs) wx.setStorageSync('_cloud_sync_at', cloudTs) } else if (hasLocal && hasCloud) { // Case 2: Both sides have data — compare timestamps if (cloudTs > localSyncAt) { // Cloud is newer (another device synced after our last push). // Merge cloud into local, then push the merged result back so // both sides converge (records union + local's unsynced edits). this._restoreFromCloud(cloudData, cloudTs) wx.setStorageSync('_cloud_sync_at', cloudTs) cloud.pushAll() } else { // Local is at least as current — push cloud.pushAll() } } else if (hasLocal && !hasCloud) { // Case 3: Only local data (first-ever sync) — push cloud.pushAll() } // else: no data anywhere — nothing to sync } catch (e) { console.error('Cloud sync on launch failed:', e) } } }, _restoreFromCloud(cloudData, cloudTs) { if (!cloudData) return try { // Records: MERGE instead of overwrite. A training saved during the // async pullAll (or edits from another device) would otherwise be // wiped by the cloud's older snapshot. if (cloudData.records) { const merged = storage.mergeRecords(cloudData.records) const hasAny = Object.values(merged).some( arr => Array.isArray(arr) && arr.length > 0 ) if (hasAny) { wx.setStorageSync('training_records', merged) } } // Single-value fields: only adopt the cloud version if the local // side hasn't changed since the cloud snapshot. Otherwise we'd // clobber unsynced local edits (e.g. a setting flipped mid-sync). const localChangedAt = wx.getStorageSync('_local_changed_at') || 0 const keepLocal = cloudTs > 0 && localChangedAt > cloudTs if (keepLocal) { console.log('[cloud] restore: keeping local single-value fields (local changed after cloud snapshot)') } else { if (cloudData.settings) { wx.setStorageSync('user_settings', cloudData.settings) } if (cloudData.customPlans && typeof cloudData.customPlans === 'object') { wx.setStorageSync('custom_plans', cloudData.customPlans) } if (cloudData.profile && typeof cloudData.profile === 'object') { wx.setStorageSync('user_profile', cloudData.profile) } if (cloudData.themeId) { themeMod.setTheme(cloudData.themeId) } } // Streak: never trust the cloud value - recompute from the merged // records so it reflects actual history on this device. storage.recomputeStreak() } catch (e) { console.error('Cloud restore failed:', e) } }, globalData: { planId: 'beginner', theme: null } })