mirror of
https://github.com/carey314/mio-plugin-worker.git
synced 2026-08-10 07:04:32 +00:00
feat(clockout): celebration on boundary cross
Prior: clockoutRemainingSec rolled forward to tomorrow the moment target <= now, so the panel jumped 1s → 86399s with no observable "zero" — the most expected moment of the day was silent. Now: - clockoutRemainingSec returns 0 when past today's clockout (sits at 0 until midnight rolls the calendar component) - WorkerStore detects the >0 → 0 edge in tickFire, fires per-day- deduped celebration (notification + Glass × 3 + panel banner ~5s) - SoundPlayer.playClockoutCelebration: Glass.aiff × 3 with 0.6s gap (joyful 1.8s shot, not the sit Morse's 15s nag) - ClockoutView overlays a lime→tomato gradient banner driven by store.clockoutCelebrationActive - tipText rewrite — old text said "不会响铃" which contradicts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c1747dc1ce
commit
d4fea96d17
@ -50,6 +50,25 @@ final class SoundPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clockout celebration tone — Glass (1.0s sustained "✨" tone)
|
||||
/// played 3 times with 0.5s gap. Distinct from sit Morse so the
|
||||
/// user instantly knows "下班" not "起来动一下".
|
||||
func playClockoutCelebration() {
|
||||
stop()
|
||||
let glassURL = URL(fileURLWithPath: "/System/Library/Sounds/Glass.aiff")
|
||||
// 3 chimes at 0s, 0.6s, 1.2s — shorter cadence than sit (full
|
||||
// shot of joy, not a nag). Schedule with DispatchQueue async
|
||||
// since this is a fixed-shot pattern, no need for a Timer loop.
|
||||
for delay in [0.0, 0.6, 1.2] {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let _ = self else { return }
|
||||
if let s = NSSound(contentsOf: glassURL, byReference: true) {
|
||||
s.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel any in-flight alert. Called by WorkerStore.sitStop() so
|
||||
/// "user stopped sit monitoring" silences the nag immediately.
|
||||
func stop() {
|
||||
|
||||
@ -43,6 +43,7 @@ private enum K {
|
||||
static let waterHistory = "water.history" // [String: Int] (date → cups)
|
||||
static let waterGoal = "water.goal" // Int, default 8
|
||||
static let clockoutHHmm = "clockout.hhmm" // String "18:00"
|
||||
static let lastClockoutCelebDate = "clockout.lastCelebDate" // String yyyy-MM-dd, dedupe per-day
|
||||
}
|
||||
|
||||
private let suiteName = "com.mioisland.plugin.worker"
|
||||
@ -139,6 +140,15 @@ final class WorkerStore: ObservableObject {
|
||||
/// Clockout
|
||||
@Published var clockoutHour: Int = 18
|
||||
@Published var clockoutMinute: Int = 0
|
||||
/// Brief celebratory flag — true for ~5s after crossing the clockout
|
||||
/// boundary. ClockoutView reads this to overlay a banner. Reset by
|
||||
/// the tick after the fade window.
|
||||
@Published var clockoutCelebrationActive: Bool = false
|
||||
/// Wallclock at which celebration should clear. Tick monitors this.
|
||||
private var clockoutCelebrationEndsAt: Date? = nil
|
||||
/// Previous tick's clockout remaining seconds. Used to detect the
|
||||
/// >0 → 0 crossing edge.
|
||||
private var prevClockoutRemSec: Int? = nil
|
||||
|
||||
/// Weekend (derived)
|
||||
@Published var weekendDays: Int = 0
|
||||
@ -285,10 +295,43 @@ final class WorkerStore: ObservableObject {
|
||||
// 3. Weekend countdown — recompute every tick (cheap).
|
||||
recomputeWeekend()
|
||||
|
||||
// 4. Detect day rollover for pomodoro / water / sit counters.
|
||||
// 4. Clockout — fire celebration on the boundary crossing edge.
|
||||
let curClockoutRem = clockoutRemainingSec
|
||||
if let prev = prevClockoutRemSec, prev > 0 && curClockoutRem == 0 {
|
||||
triggerClockoutCelebrationIfNeeded()
|
||||
}
|
||||
prevClockoutRemSec = curClockoutRem
|
||||
// Clear the celebration flag once the fade window elapses.
|
||||
if let endsAt = clockoutCelebrationEndsAt, Date() >= endsAt {
|
||||
clockoutCelebrationActive = false
|
||||
clockoutCelebrationEndsAt = nil
|
||||
}
|
||||
|
||||
// 5. Detect day rollover for pomodoro / water / sit counters.
|
||||
rolloverIfNeeded()
|
||||
}
|
||||
|
||||
/// Per-day-deduped celebration trigger. Fires UN notification +
|
||||
/// Glass tone × 3 + sets the in-panel banner flag for ~5s.
|
||||
private func triggerClockoutCelebrationIfNeeded() {
|
||||
let today = Self.dateKey(Date(), calendar: calendar)
|
||||
let lastCeleb = defaults.string(forKey: K.lastClockoutCelebDate) ?? ""
|
||||
guard lastCeleb != today else {
|
||||
WorkerDebugLog.write("clockout boundary @ \(today) but already celebrated, skip")
|
||||
return
|
||||
}
|
||||
defaults.set(today, forKey: K.lastClockoutCelebDate)
|
||||
|
||||
WorkerDebugLog.write("clockout celebration fired for \(today)")
|
||||
WorkerNotificationCenter.shared.notify(
|
||||
title: "今日打卡下班 🎉",
|
||||
body: "辛苦了,到点了,关 IDE 下班!"
|
||||
)
|
||||
SoundPlayer.shared.playClockoutCelebration()
|
||||
clockoutCelebrationActive = true
|
||||
clockoutCelebrationEndsAt = Date().addingTimeInterval(5.0)
|
||||
}
|
||||
|
||||
// MARK: - Pomodoro
|
||||
|
||||
/// Total duration (seconds) of the active phase. Used as denominator
|
||||
@ -523,18 +566,24 @@ final class WorkerStore: ObservableObject {
|
||||
defaults.set(String(format: "%02d:%02d", h, m), forKey: K.clockoutHHmm)
|
||||
}
|
||||
|
||||
/// Seconds remaining until today's clockout. If it's already past,
|
||||
/// returns the seconds until tomorrow's clockout (rolls over).
|
||||
/// Seconds remaining until **today's** clockout. Returns 0 when
|
||||
/// already past — the next day rolls automatically when calendar's
|
||||
/// dateComponents(.year,.month,.day, from: now) picks up the new
|
||||
/// date after midnight.
|
||||
///
|
||||
/// Prior version rolled forward to tomorrow on `target <= now`, so
|
||||
/// the moment clockout was hit the counter jumped 1s → 86399s with
|
||||
/// no observable "zero" frame — celebrating the "下班" moment was
|
||||
/// impossible. Now: hit zero, hold zero until midnight, restart at
|
||||
/// new day's wall-time countdown.
|
||||
var clockoutRemainingSec: Int {
|
||||
let now = Date()
|
||||
var comps = calendar.dateComponents([.year, .month, .day], from: now)
|
||||
comps.hour = clockoutHour
|
||||
comps.minute = clockoutMinute
|
||||
comps.second = 0
|
||||
var target = calendar.date(from: comps) ?? now
|
||||
if target <= now {
|
||||
target = calendar.date(byAdding: .day, value: 1, to: target) ?? target
|
||||
}
|
||||
guard let target = calendar.date(from: comps) else { return 0 }
|
||||
if target <= now { return 0 }
|
||||
return max(0, Int(target.timeIntervalSince(now)))
|
||||
}
|
||||
|
||||
|
||||
@ -14,26 +14,66 @@ struct ClockoutView: View {
|
||||
@State private var editMinute: Int = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
statusBadge
|
||||
ZStack(alignment: .top) {
|
||||
VStack(spacing: 18) {
|
||||
statusBadge
|
||||
|
||||
countdownDisplay
|
||||
countdownDisplay
|
||||
|
||||
progressBar
|
||||
progressBar
|
||||
|
||||
controls
|
||||
controls
|
||||
|
||||
Divider()
|
||||
.background(WorkerTheme.overlay08)
|
||||
.padding(.horizontal, 24)
|
||||
Divider()
|
||||
.background(WorkerTheme.overlay08)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
timeRow
|
||||
timeRow
|
||||
|
||||
tipText
|
||||
tipText
|
||||
|
||||
Spacer(minLength: 0)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
// Celebration banner — overlays the panel for ~5s after the
|
||||
// user crosses today's clockout boundary. Subtle gold→lime
|
||||
// shimmer so it pops without being kindergarten-style confetti.
|
||||
if store.clockoutCelebrationActive {
|
||||
celebrationBanner
|
||||
.transition(
|
||||
.asymmetric(
|
||||
insertion: .move(edge: .top).combined(with: .opacity),
|
||||
removal: .opacity
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.78),
|
||||
value: store.clockoutCelebrationActive)
|
||||
}
|
||||
|
||||
private var celebrationBanner: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text("🎉 今日打卡下班 🎉")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
Text("辛苦了,到点了,关 IDE 下班!")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(.black.opacity(0.7))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [WorkerTheme.lime, WorkerTheme.tomato.opacity(0.95)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: WorkerTheme.lime.opacity(0.45), radius: 16, x: 0, y: 4)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// MARK: - Status
|
||||
@ -236,7 +276,7 @@ struct ClockoutView: View {
|
||||
}
|
||||
|
||||
private var tipText: some View {
|
||||
Text("下班时间到时不会响铃 —\n本插件只负责让你看着时间倒计时偷着乐。")
|
||||
Text("到点会有通知 + 一段 Glass 音效庆祝下班\n每日只触发一次,重启插件不会重复响铃。")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(WorkerTheme.fg45)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user