diff --git a/Sources/engine/SoundPlayer.swift b/Sources/engine/SoundPlayer.swift index 2cdf18c..f81e352 100644 --- a/Sources/engine/SoundPlayer.swift +++ b/Sources/engine/SoundPlayer.swift @@ -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() { diff --git a/Sources/engine/WorkerStore.swift b/Sources/engine/WorkerStore.swift index 398e408..172fe8d 100644 --- a/Sources/engine/WorkerStore.swift +++ b/Sources/engine/WorkerStore.swift @@ -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))) } diff --git a/Sources/ui/ClockoutView.swift b/Sources/ui/ClockoutView.swift index 3ca1d0a..f81a365 100644 --- a/Sources/ui/ClockoutView.swift +++ b/Sources/ui/ClockoutView.swift @@ -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)