feat: v1.5 — plan editor, voice prompts, UI polish
Major features: - Training plan editor: edit preset days/duration per plan (in-place override via custom_plans storage; preset ids preserved so existing records stay valid) - Voice prompts at 4 fixed points during training (halfway / 30s / 10s / done) via Tencent Cloud TTS (cloudfunctions/tts + utils/voice.js) - Free-training button: switched from outline (transparent) to ghost variant (theme-tinted) for visual weight Bug fixes: - 4 functional pages: SVG data URIs failed to render in WeChat because '\#' in colors was parsed as a data-URI fragment delimiter. encode the whole SVG via encodeURIComponent in utils/icons.js build(). - Old records (saved before id field was added) could not be deleted (data-id was empty, triggered defensive guard). Backfill stable legacy-<month>-<index>-<duration> ids in getRecords() and persist. - settings.js plan-row editor button event was bubbling up to the row's bindtap (which also fired onSelectPlan). Wrapped in catch:tap. UI: - Settings page: 3 hardcoded plans replaced with dynamic buildPlansList that overlays custom_plans on top of presets - Plan editor: bottom-sheet modal in settings page (regular view, not ui-modal — WeChat custom component root element drops position:fixed in this runtime) - Free-training button: rounded pill style, full-width, 24rpx gap from primary action; bottom sheet uses max-height: 88vh + internal scroll - Version bumped to v1.5, last updated 2026-06-10 Removed: - Daily reminder section (dailyReminder / reminderTime) — replaced by the 4 voice prompts which cover the same user need without requiring long-term scheduling that WeChat mini-programs can't actually do Misc: - utils/plan.js refactored to formula-driven: presets declare totalDays/startTarget/increment/cycleDays, days[] is generated. Same formula applies to custom plans. - Timer _remind() guards each prompt on minimum duration so short free-mode sessions don't fire 'last30' at the start - Cloud storage cloud_plans field added to data push payload; restored on first install via _restoreFromCloud - .gitignore added for local AI tool caches (.reasonix/, reasonix.toml, .codegraph/daemon.pid)
This commit is contained in:
+96
-8
@@ -2,6 +2,8 @@ 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')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -16,7 +18,8 @@ Page({
|
||||
overtime: 0,
|
||||
todayTarget: 0,
|
||||
planDay: 1,
|
||||
isFreeMode: false
|
||||
isFreeMode: false,
|
||||
icons: iconsMod.build()
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
@@ -26,22 +29,25 @@ Page({
|
||||
|
||||
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) {
|
||||
target = parseInt(options.free) || 60
|
||||
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 plan = planMod.getPlan(settings.planId)
|
||||
planDay = planMod.getPlanDay(settings.planId, allRecords)
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -53,25 +59,101 @@ Page({
|
||||
isFreeMode
|
||||
})
|
||||
|
||||
if (this._timer) {
|
||||
this._timer.stop()
|
||||
this._timer = null
|
||||
}
|
||||
|
||||
// Reset reminder state
|
||||
this._halfwaySignaled = false
|
||||
this._lastMinuteAt = 0
|
||||
this._lastCountdown = 0
|
||||
|
||||
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: () => {
|
||||
this.setData({ status: 'completed', isCompleted: true })
|
||||
if (storage.getSettings().vibrate) {
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
if (this._timer) this._timer.stop()
|
||||
voice.stop()
|
||||
if (this._timer) {
|
||||
this._timer.stop()
|
||||
this._timer = null
|
||||
}
|
||||
},
|
||||
|
||||
onStart() {
|
||||
@@ -88,6 +170,7 @@ Page({
|
||||
onPause() {
|
||||
if (!this.data.isRunning || this.data.isPaused) return
|
||||
this._timer.pause()
|
||||
voice.stop()
|
||||
this.setData({ isPaused: true, status: 'paused' })
|
||||
},
|
||||
|
||||
@@ -103,6 +186,11 @@ Page({
|
||||
|
||||
finishTraining() {
|
||||
const elapsed = this._timer.stop()
|
||||
if (elapsed < 3) {
|
||||
wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 })
|
||||
setTimeout(() => { wx.navigateBack() }, 1500)
|
||||
return
|
||||
}
|
||||
const today = storage.getToday()
|
||||
storage.saveRecord({
|
||||
date: today,
|
||||
@@ -113,6 +201,6 @@ Page({
|
||||
storage.updateStreak(today)
|
||||
|
||||
wx.showToast({ title: `已记录 ${elapsed}秒`, icon: 'none', duration: 1500 })
|
||||
setTimeout(() => { wx.navigateBack() }, 3000)
|
||||
setTimeout(() => { wx.navigateBack() }, 1500)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user