feat: 训练完成全屏弹窗 (大数字+countUp+超时+3按钮)

替换原来不显眼的 wx.showToast:
- 全屏遮罩 + 大圆角卡片,scale-in 弹性入场
- 大数字 180rpx countUp 从 0 滚到实际秒数(1.2s)
- 超时标签 +Xs 秒超时(OvertimePop 弹性出现,延迟 1.1s 等数字到末尾)
- 连续打卡显示 🔥
- 3 按钮:再来一次(留在当前页重置) / 查看记录(跳记录页) / 回到首页
- 装饰 emoji 圆形 1.6s 循环脉冲
- onComplete 触发时自动保存,不需要再手动点结束
This commit is contained in:
2026-07-08 14:19:40 +08:00
parent f052327276
commit 503f01f6e9
3 changed files with 323 additions and 17 deletions
+100 -17
View File
@@ -23,6 +23,14 @@ Page({
celebrationLevel: 'normal', // normal / first / milestone / record
celebrationMessage: '',
confettiPieces: [],
// Completion modal (full-screen summary after training ends)
showCompletion: false,
completionElapsed: 0,
completionTarget: 0,
completionOvertime: 0,
completionStreak: 0,
completionLevel: 'normal',
completionMessage: '',
icons: iconsMod.build()
},
@@ -96,24 +104,15 @@ Page({
},
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 })
// Auto-save + show big completion modal
this._saveAndShowCompletion(elapsed)
}
})
},
@@ -271,8 +270,9 @@ Page({
},
finishTraining() {
// Guard against double-tap: stop button is still visible during the
// 1.5s navigateBack delay, so a second tap would create a duplicate.
// 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
this._saving = true
@@ -283,6 +283,20 @@ Page({
setTimeout(() => { wx.navigateBack() }, 1500)
return
}
this._saveAndShowCompletion(elapsed)
},
/**
* Persist the just-finished training and pop the big completion modal.
* Called from both onComplete (natural finish) and finishTraining
* (user ended early). Detection of "first / record / milestone" uses
* pre-save stats so the level is decided before the data changes.
*/
_saveAndShowCompletion(elapsed) {
const { level, message } = this._detectCelebrationLevel(elapsed)
const confettiPieces = this._buildConfettiPieces(level)
// Save record + streak
const today = storage.getToday()
storage.saveRecord({
date: today,
@@ -292,11 +306,80 @@ Page({
})
storage.updateStreak(today)
// Flag the just-completed training so the index page can briefly
// highlight the "今日已完成" badge when the user lands back there.
// Flag for the index page's "训练完成" highlight
try { wx.setStorageSync('_last_train_at', Date.now()) } catch (e) {}
wx.showToast({ title: `已记录 ${elapsed}`, icon: 'none', duration: 1500 })
setTimeout(() => { wx.navigateBack() }, 1500)
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,
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
}
this.setData({
showCompletion: false,
isCompleted: false,
status: 'idle',
isRunning: false,
isPaused: false,
remaining: this.data.duration,
overtime: 0,
// Re-init countUp so the big number rolls again on next start
_countUpTask: null
})
}
})