ScriptsApr 6, 2026·1 min read

TickMato

macOS

#!/bin/bash

PomodoroSync Daemon — 滴答番茄同步守护进程

监控 TickTick 番茄钟状态,自动驱动 TickMato 浮窗

用法:nohup bash ~/Scripts/pomodoro-sync-daemon.sh > /tmp/pomodoro-sync.log 2>&1 &

WAS_RUNNING=false

echo "🍅 PomodoroSync 已启动,正在后台监控 TickTick 番茄钟..."

while true; do STATE=$(osascript -e ' tell application "System Events" if not (exists process "TickTick") then return "no_ticktick" tell process "TickTick" set allTexts to every static text of window 1 set output to "" repeat with txt in allTexts try set output to output & (value of txt as text) & "|" end try end repeat return output end tell end tell' 2>&1)

if echo "$STATE" | grep -q "番茄专注"; then
    TIMER=$(echo "$STATE" | sed 's/.*番茄专注|//' | cut -d'|' -f1)

    if [ -n "$TIMER" ] && [ "$TIMER" != "25:00" ] && [ "$WAS_RUNNING" = "false" ]; then
        # TickTick 开始计时 → 启动 TickMato
        echo "$(date +%H:%M:%S) ⏰ 检测到番茄钟启动 ($TIMER)"
        WAS_RUNNING=true
        /Users/shiny/Scripts/TickMato --autostart &

    elif [ "$TIMER" = "25:00" ] && [ "$WAS_RUNNING" = "true" ]; then
        # TickTick 回到 25:00 → 用户手动结束了
        echo "$(date +%H:%M:%S) ⏹ 检测到番茄钟手动结束"
        WAS_RUNNING=false
        /Users/shiny/Scripts/TickMato --stop &
    fi
fi

sleep 2

done

// TickMato.swift — 滴答番茄 macOS 浮窗番茄钟 // Compile: swiftc -o TickMato TickMato.swift -framework AppKit // Usage: ./TickMato → IDLE, wait for Wind Up // ./TickMato --autostart → start 25min immediately // ./TickMato --break → start 5min break // ./TickMato --stop → reset to IDLE

import AppKit import Foundation

// ═══════════════════════════════════════════ // MARK: - Configuration // ═══════════════════════════════════════════

let kNotification = "com.tickmato.command" let kPidPath = (NSHomeDirectory() as NSString).appendingPathComponent(".tickmato.pid") let kWorkDuration = 1500 // 25 min let kBreakDuration = 300 // 5 min let kWechatBlockDuration = 600 // 10 min reminder block

struct Phase { let name: String let label: String let duration: Int }

let kPhases: [Phase] = [ Phase(name: "Initial Review", label: "r", duration: 120), Phase(name: "Work I", label: "w", duration: 630), Phase(name: "Work II", label: "w", duration: 630), Phase(name: "Final Review", label: "r", duration: 60), Phase(name: "Tracking", label: "t", duration: 60), ]

struct C { static let tomato = NSColor(srgbRed: 232/255, green: 57/255, blue: 42/255, alpha: 1) static let workBg = NSColor(srgbRed: 92/255, green: 15/255, blue: 8/255, alpha: 0.96) static let breakBg = NSColor(srgbRed: 27/255, green: 94/255, blue: 32/255, alpha: 0.96) static let idleBg = NSColor(srgbRed: 245/255, green: 245/255, blue: 245/255, alpha: 0.96) static let green = NSColor(srgbRed: 76/255, green: 175/255, blue: 80/255, alpha: 1) static let done = NSColor(srgbRed: 160/255, green: 160/255, blue: 160/255, alpha: 1) static let pending = NSColor(srgbRed: 224/255, green: 224/255, blue: 224/255, alpha: 1) }

enum AppState { case idle, work, brk }

struct PerfGrade: Equatable { let emoji: String let label: String let color: NSColor

static func == (lhs: PerfGrade, rhs: PerfGrade) -> Bool { lhs.emoji == rhs.emoji }

static let beyond = PerfGrade(emoji: "🟪", label: "突破上限!绝啦!!", color: NSColor(srgbRed: 155/255, green: 89/255, blue: 182/255, alpha: 1))
static let great  = PerfGrade(emoji: "🟦", label: "超预期,太牛了!", color: NSColor(srgbRed: 52/255, green: 152/255, blue: 219/255, alpha: 1))
static let good   = PerfGrade(emoji: "🟩", label: "完成啦很不错!", color: NSColor(srgbRed: 46/255, green: 204/255, blue: 113/255, alpha: 1))
static let almost = PerfGrade(emoji: "🟧", label: "差一点加油喔!", color: NSColor(srgbRed: 230/255, green: 126/255, blue: 34/255, alpha: 1))
static let low    = PerfGrade(emoji: "🟥", label: "状态不佳休息吧~", color: NSColor(srgbRed: 231/255, green: 76/255, blue: 60/255, alpha: 1))
static let none   = PerfGrade(emoji: "⬜", label: "", color: NSColor.tertiaryLabelColor)

/// Grade by per-working-day average: 🟥<5  🟧5-6  🟩7  🟦8-10  🟪≥11
static func grade(_ count: Int, workDays: Int = 1) -> PerfGrade {
    if count == 0 { return .none }
    let avg = Double(count) / Double(max(workDays, 1))
    if avg >= 11 { return .beyond }
    if avg >= 8  { return .great }
    if avg >= 7  { return .good }
    if avg >= 5  { return .almost }
    return .low
}

}

// ═══════════════════════════════════════════ // MARK: - Sound Preferences // ═══════════════════════════════════════════

struct SoundEvent { let key: String let label: String let defaultSound: String let defaultRepeat: Int }

let kSoundEvents: [SoundEvent] = [ SoundEvent(key: "windUp", label: "上发条 Wind Up", defaultSound: "Tink", defaultRepeat: 10), SoundEvent(key: "phaseTransition", label: "阶段切换 Phase Transition", defaultSound: "Tink", defaultRepeat: 1), SoundEvent(key: "workComplete", label: "番茄完成 Work Complete", defaultSound: "Hero", defaultRepeat: 3), SoundEvent(key: "breakEnd", label: "休息结束 Break End", defaultSound: "Funk", defaultRepeat: 2), SoundEvent(key: "manualStop", label: "手动停止 Manual Stop", defaultSound: "Pop", defaultRepeat: 1), SoundEvent(key: "wechatRemind", label: "微信提醒 WeChat Remind", defaultSound: "Glass", defaultRepeat: 2), ]

class SoundPrefs: NSObject, NSSoundDelegate { static let shared = SoundPrefs() private var activeSounds: [NSSound] = [] private var playGeneration = 0

func sound(_ sound: NSSound, didFinishPlaying aBool: Bool) {
    activeSounds.removeAll { $0 === sound }
}

func stopAll() {
    playGeneration += 1
    for s in activeSounds { s.stop() }
    activeSounds.removeAll()
}

// Scan directories for sound files, return (displayName, fullPath) pairs
static let allSoundFiles: [(name: String, path: String)] = {
    let searchDirs: [(prefix: String, dir: String)] = [
        ("",            "/System/Library/Sounds"),
        ("🔔 ",         "/System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones"),
        ("🔊 ",         "/System/Library/PrivateFrameworks/ScreenReader.framework/Versions/A/Resources/Sounds"),
        ("🎵 ",         "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds"),
        ("✨ ",         "/System/Library/PrivateFrameworks/AXMediaUtilities.framework/Versions/A/Resources/sounds"),
        ("📱 ",         "/System/Library/CoreServices"),
        ("🎤 ",         "/System/Library/PrivateFrameworks/AssistantServices.framework/Versions/A/Resources"),
        ("🔍 ",         "/System/Library/PrivateFrameworks/FindMyDevice.framework/Versions/A/Resources"),
        ("📩 ",         "/System/Library/PrivateFrameworks/IMDaemonCore.framework/Versions/A/Resources"),
        ("🗺️ ",        "/System/Library/PrivateFrameworks/Navigation.framework/Versions/A/Resources"),
        ("📤 ",         "/System/Library/PrivateFrameworks/Sharing.framework/Versions/A/Resources"),
        ("🎮 ",         "/System/Library/UserNotifications/Bundles"),
    ]
    let exts = Set(["aiff", "caf", "wav"])
    let skipWords = Set(["keyboard", "key_press", "dictation", "dtmf", "telephony",
                         "SiriTTS", "VoicePreview", "gryphon", "ttsbundle",
                         "MobileAsset", "TTSAXResource", "SecureCharacter",
                         "welcomeMessage", "VOTraining", "Siri+", "SpeakerRecog",
                         "VoiceTrigger", "screenshot"])
    var results: [(String, String)] = []

    func scan(prefix: String, dir: String) {
        let fm = FileManager.default
        guard let enumerator = fm.enumerator(atPath: dir) else { return }
        while let file = enumerator.nextObject() as? String {
            let ext = (file as NSString).pathExtension.lowercased()
            guard exts.contains(ext) else { continue }
            let full = (dir as NSString).appendingPathComponent(file)
            // Skip unwanted files
            if skipWords.contains(where: { full.contains($0) }) { continue }
            // Generate display name
            let base = ((file as NSString).lastPathComponent as NSString).deletingPathExtension
            let display = prefix + base
                .replacingOccurrences(of: "-EncoreInfinitum", with: "")
                .replacingOccurrences(of: "Text-Message-Acknowledgement-", with: "Msg-")
            results.append((display, full))
        }
    }

    for sd in searchDirs { scan(prefix: sd.prefix, dir: sd.dir) }
    results.sort { $0.0.localizedCaseInsensitiveCompare($1.0) == .orderedAscending }
    return results
}()

lazy var availableSounds: [String] = {
    SoundPrefs.allSoundFiles.map { $0.name }
}()

func soundName(for key: String) -> String {
    UserDefaults.standard.string(forKey: "sound.\(key)")
        ?? kSoundEvents.first { $0.key == key }?.defaultSound ?? "Tink"
}

func repeatCount(for key: String) -> Int {
    let val = UserDefaults.standard.integer(forKey: "repeat.\(key)")
    return val > 0 ? val : kSoundEvents.first { $0.key == key }?.defaultRepeat ?? 1
}

func set(sound: String, for key: String) {
    UserDefaults.standard.set(sound, forKey: "sound.\(key)")
}

func set(repeatCount: Int, for key: String) {
    UserDefaults.standard.set(repeatCount, forKey: "repeat.\(key)")
}

static func resolvePath(_ name: String) -> String? {
    if name == "None" { return nil }
    if let entry = allSoundFiles.first(where: { $0.name == name }) {
        return entry.path
    }
    // Fallback: try as system sound
    let path = "/System/Library/Sounds/\(name).aiff"
    return FileManager.default.fileExists(atPath: path) ? path : nil
}

private func playFile(_ path: String) {
    guard let s = NSSound(contentsOfFile: path, byReference: false) else { return }
    activeSounds.append(s)
    s.delegate = SoundPrefs.shared
    s.play()
}

func play(_ key: String) {
    let name = soundName(for: key)
    guard let path = SoundPrefs.resolvePath(name) else { return }
    let n = repeatCount(for: key)
    let gen = playGeneration

    if key == "windUp" {
        let interval = 0.18
        for i in 0..<n {
            DispatchQueue.main.asyncAfter(deadline: .now() + interval * Double(i)) { [self] in
                guard self.playGeneration == gen else { return }
                self.playFile(path)
            }
        }
    } else {
        playFile(path)
        if n > 1 {
            for i in 1..<n {
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.5 * Double(i)) { [self] in
                    guard self.playGeneration == gen else { return }
                    self.playFile(path)
                }
            }
        }
    }
}

static func preview(_ name: String) {
    guard let path = resolvePath(name) else { return }
    shared.playFile(path)
}

}

// ═══════════════════════════════════════════ // MARK: - Sound Settings Window // ═══════════════════════════════════════════

class SoundSettingsController: NSObject, NSWindowDelegate { var window: NSWindow? var onClose: (() -> Void)? var popups: [String: NSPopUpButton] = [:] var steppers: [String: NSStepper] = [:] var stepperLabels: [String: NSTextField] = [:]

func show() {
    if let w = window { w.makeKeyAndOrderFront(nil); return }

    let W: CGFloat = 360
    let rowH: CGFloat = 56
    let H: CGFloat = CGFloat(kSoundEvents.count) * rowH + 116

    NSApp.setActivationPolicy(.regular)

    let w = NSWindow(
        contentRect: NSRect(x: 200, y: 200, width: W, height: H),
        styleMask: [.titled, .closable],
        backing: .buffered, defer: false
    )
    w.title = "⚙ Settings  设置"
    w.level = .floating
    w.delegate = self

    let content = w.contentView!

    // ── WeChat pause toggle ──
    let toggleY = H - 36
    let wechatCheck = NSButton(checkboxWithTitle: "切微信时暂停计时  Pause timer on WeChat",
                                target: self, action: #selector(wechatPauseToggled(_:)))
    wechatCheck.frame = NSRect(x: 16, y: toggleY, width: W - 32, height: 20)
    wechatCheck.state = UserDefaults.standard.object(forKey: "pauseOnWechat") as? Bool ?? true ? .on : .off
    wechatCheck.font = NSFont.systemFont(ofSize: 12, weight: .medium)
    content.addSubview(wechatCheck)

    let sep = NSBox(frame: NSRect(x: 16, y: toggleY - 12, width: W - 32, height: 1))
    sep.boxType = .separator
    content.addSubview(sep)

    let sounds = ["None"] + SoundPrefs.shared.availableSounds

    for (i, ev) in kSoundEvents.enumerated() {
        let y = H - CGFloat(i + 1) * rowH - 56

        // Label
        let lbl = NSTextField(labelWithString: ev.label)
        lbl.font = NSFont.systemFont(ofSize: 11, weight: .medium)
        lbl.textColor = .secondaryLabelColor
        lbl.frame = NSRect(x: 16, y: y + 24, width: W - 32, height: 16)
        content.addSubview(lbl)

        // Sound picker
        let popup = NSPopUpButton(frame: NSRect(x: 16, y: y, width: 180, height: 24))
        popup.addItems(withTitles: sounds)
        popup.selectItem(withTitle: SoundPrefs.shared.soundName(for: ev.key))
        popup.tag = i
        popup.target = self
        popup.action = #selector(soundChanged(_:))
        content.addSubview(popup)
        popups[ev.key] = popup

        // Repeat count label
        let rLbl = NSTextField(labelWithString: \(SoundPrefs.shared.repeatCount(for: ev.key))")
        rLbl.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
        rLbl.alignment = .center
        rLbl.frame = NSRect(x: 210, y: y + 2, width: 30, height: 20)
        content.addSubview(rLbl)
        stepperLabels[ev.key] = rLbl

        // Stepper for repeat count
        let stepper = NSStepper(frame: NSRect(x: 242, y: y, width: 20, height: 24))
        stepper.minValue = 1
        stepper.maxValue = 15
        stepper.integerValue = SoundPrefs.shared.repeatCount(for: ev.key)
        stepper.tag = i
        stepper.target = self
        stepper.action = #selector(repeatChanged(_:))
        content.addSubview(stepper)
        steppers[ev.key] = stepper

        // Preview button
        let preview = NSButton(frame: NSRect(x: 275, y: y, width: 70, height: 24))
        preview.title = "▶ 试听"
        preview.bezelStyle = .rounded
        preview.tag = i
        preview.target = self
        preview.action = #selector(previewSound(_:))
        content.addSubview(preview)
    }

    // Done button
    let doneBtn = NSButton(frame: NSRect(x: W / 2 - 50, y: 12, width: 100, height: 30))
    doneBtn.title = "Done  完成"
    doneBtn.bezelStyle = .rounded
    doneBtn.target = self
    doneBtn.action = #selector(close)
    content.addSubview(doneBtn)

    w.center()
    NSApp.activate(ignoringOtherApps: true)
    w.makeKeyAndOrderFront(nil)
    window = w
}

@objc func wechatPauseToggled(_ sender: NSButton) {
    UserDefaults.standard.set(sender.state == .on, forKey: "pauseOnWechat")
}

@objc func soundChanged(_ sender: NSPopUpButton) {
    let ev = kSoundEvents[sender.tag]
    let name = sender.titleOfSelectedItem ?? "Tink"
    SoundPrefs.shared.set(sound: name, for: ev.key)
    SoundPrefs.preview(name)
}

@objc func repeatChanged(_ sender: NSStepper) {
    let ev = kSoundEvents[sender.tag]
    SoundPrefs.shared.set(repeatCount: sender.integerValue, for: ev.key)
    stepperLabels[ev.key]?.stringValue = \(sender.integerValue)"
}

@objc func previewSound(_ sender: NSButton) {
    let ev = kSoundEvents[sender.tag]
    SoundPrefs.shared.play(ev.key)
}

@objc func close() {
    window?.close()
}

func windowWillClose(_ notification: Notification) {
    window = nil
    NSApp.setActivationPolicy(.accessory)
    onClose?()
}

}

// ═══════════════════════════════════════════ // MARK: - Stats Store // ═══════════════════════════════════════════

struct DayStats: Codable { var pomodoros: Int = 0 var abandoned: Int = 0 var focusSecs: Int = 0 var breakSecs: Int = 0 var wechatSecs: Int = 0 }

class StatsStore { static let shared = StatsStore() private let path = (NSHomeDirectory() as NSString).appendingPathComponent(".tickmato-stats.json") private var data: [String: DayStats] = [:]

private init() { load() }

private static let dayFmt: DateFormatter = {
    let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
}()

static func dayKey(_ date: Date = Date()) -> String { dayFmt.string(from: date) }

private func load() {
    guard let d = FileManager.default.contents(atPath: path),
          let obj = try? JSONDecoder().decode([String: DayStats].self, from: d)
    else { return }
    data = obj
}

func save() {
    guard let d = try? JSONEncoder().encode(data) else { return }
    try? d.write(to: URL(fileURLWithPath: path))
}

func today() -> DayStats { data[StatsStore.dayKey()] ?? DayStats() }
func stats(for key: String) -> DayStats { data[key] ?? DayStats() }

func addPomodoro() {
    var s = data[StatsStore.dayKey()] ?? DayStats(); s.pomodoros += 1
    data[StatsStore.dayKey()] = s; save()
}

func addAbandoned() {
    var s = data[StatsStore.dayKey()] ?? DayStats(); s.abandoned += 1
    data[StatsStore.dayKey()] = s; save()
}

func addSeconds(_ type: String, _ secs: Int) {
    guard secs > 0 else { return }
    var s = data[StatsStore.dayKey()] ?? DayStats()
    switch type {
    case "focus": s.focusSecs += secs
    case "break": s.breakSecs += secs
    case "wechat": s.wechatSecs += secs
    default: break
    }
    data[StatsStore.dayKey()] = s; save()
}

private func aggregate(_ keys: [String]) -> DayStats {
    var r = DayStats()
    for k in keys {
        let s = data[k] ?? DayStats()
        r.pomodoros += s.pomodoros; r.abandoned += s.abandoned
        r.focusSecs += s.focusSecs; r.breakSecs += s.breakSecs
        r.wechatSecs += s.wechatSecs
    }
    return r
}

private func dayKeys(from start: Date, count: Int) -> [String] {
    (0..<count).compactMap { Calendar.current.date(byAdding: .day, value: -$0, to: start) }
        .map { StatsStore.dayKey($0) }
}

func yesterday() -> DayStats {
    guard let d = Calendar.current.date(byAdding: .day, value: -1, to: Date()) else { return DayStats() }
    return stats(for: StatsStore.dayKey(d))
}

func thisWeek() -> DayStats {
    let wd = Calendar.current.component(.weekday, from: Date())
    return aggregate(dayKeys(from: Date(), count: (wd + 5) % 7 + 1))
}

func lastWeek() -> DayStats {
    let wd = Calendar.current.component(.weekday, from: Date())
    let off = (wd + 5) % 7 + 1
    guard let end = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return DayStats() }
    return aggregate(dayKeys(from: end, count: 7))
}

func thisMonth() -> DayStats {
    aggregate(dayKeys(from: Date(), count: Calendar.current.component(.day, from: Date())))
}

func currentStreak() -> Int {
    let cal = Calendar.current
    var streak = 0; var date = Date()
    if stats(for: StatsStore.dayKey(date)).pomodoros == 0 {
        guard let y = cal.date(byAdding: .day, value: -1, to: date) else { return 0 }
        date = y
    }
    while true {
        if stats(for: StatsStore.dayKey(date)).pomodoros == 0 { break }
        streak += 1
        guard let prev = cal.date(byAdding: .day, value: -1, to: date) else { break }
        date = prev
    }
    let best = UserDefaults.standard.integer(forKey: "bestStreak")
    if streak > best { UserDefaults.standard.set(streak, forKey: "bestStreak") }
    return streak
}

var bestStreak: Int { max(currentStreak(), UserDefaults.standard.integer(forKey: "bestStreak")) }

func lastMonth() -> DayStats {
    let day = Calendar.current.component(.day, from: Date())
    guard let end = Calendar.current.date(byAdding: .day, value: -day, to: Date()) else { return DayStats() }
    let n = Calendar.current.range(of: .day, in: .month, for: end)?.count ?? 30
    return aggregate(dayKeys(from: end, count: n))
}

}

// ═══════════════════════════════════════════ // MARK: - Stats Window // ═══════════════════════════════════════════

class StatsWindowController: NSObject, NSWindowDelegate { var window: NSWindow? var onClose: (() -> Void)? var textView: NSTextView? var tabControl: NSSegmentedControl? var navLabel: NSTextField? var pageOffset = 0 var currentTab = 0

static let wdShort = ["日","一","二","三","四","五","六"]

func show() {
    if let w = window { refresh(); w.makeKeyAndOrderFront(nil); return }
    NSApp.setActivationPolicy(.regular)
    let W: CGFloat = 420, H: CGFloat = 640
    let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: W, height: H),
                     styleMask: [.titled, .closable], backing: .buffered, defer: false)
    w.title = "📊 TickMato Stats"; w.level = .floating; w.delegate = self
    let cv = w.contentView!

    let tabs = NSSegmentedControl(frame: NSRect(x: 16, y: H - 36, width: W - 32, height: 24))
    tabs.segmentCount = 2
    tabs.setLabel("🍅 番茄专注", forSegment: 0)
    tabs.setLabel("📱 微信 / 摸鱼", forSegment: 1)
    tabs.selectedSegment = 0; tabs.target = self; tabs.action = #selector(tabChanged(_:))
    cv.addSubview(tabs); tabControl = tabs

    let prev = NSButton(frame: NSRect(x: 16, y: H - 62, width: 28, height: 22))
    prev.title = "◀"; prev.bezelStyle = .rounded; prev.target = self; prev.action = #selector(goPrev)
    cv.addSubview(prev)
    let nl = NSTextField(labelWithString: "")
    nl.frame = NSRect(x: 48, y: H - 60, width: W - 96, height: 16)
    nl.alignment = .center; nl.font = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .medium)
    nl.textColor = .secondaryLabelColor; cv.addSubview(nl); navLabel = nl
    let next = NSButton(frame: NSRect(x: W - 44, y: H - 62, width: 28, height: 22))
    next.title = "▶"; next.bezelStyle = .rounded; next.target = self; next.action = #selector(goNext)
    cv.addSubview(next)

    let scroll = NSScrollView(frame: NSRect(x: 0, y: 40, width: W, height: H - 108))
    scroll.hasVerticalScroller = true; scroll.autohidesScrollers = true
    let tv = NSTextView(frame: NSRect(x: 0, y: 0, width: W, height: 3000))
    tv.isEditable = false; tv.isSelectable = true
    tv.textContainerInset = NSSize(width: 14, height: 8)
    tv.backgroundColor = .windowBackgroundColor
    tv.isVerticallyResizable = true; tv.autoresizingMask = [.width]
    tv.textContainer?.widthTracksTextView = true
    scroll.documentView = tv; cv.addSubview(scroll); textView = tv

    let done = NSButton(frame: NSRect(x: W / 2 - 40, y: 8, width: 80, height: 24))
    done.title = "完成"; done.bezelStyle = .rounded; done.target = self; done.action = #selector(close)
    cv.addSubview(done)

    refresh(); w.center(); NSApp.activate(ignoringOtherApps: true); w.makeKeyAndOrderFront(nil)
    window = w
}

@objc func tabChanged(_ s: NSSegmentedControl) { currentTab = s.selectedSegment; pageOffset = 0; refresh() }
@objc func goPrev() { pageOffset -= 1; refresh() }
@objc func goNext() { if pageOffset < 0 { pageOffset += 1; refresh() } }
@objc func close() { window?.close() }

func windowWillClose(_ notification: Notification) {
    window = nil
    NSApp.setActivationPolicy(.accessory)
    onClose?()
}

func refresh() {
    let end = endDate(); let start = Calendar.current.date(byAdding: .day, value: -6, to: end)!
    let fmt = DateFormatter(); fmt.dateFormat = "M/d"
    navLabel?.stringValue = "\(fmt.string(from: start)) – \(fmt.string(from: end))"
    textView?.textStorage?.setAttributedString(currentTab == 0 ? buildPomodoro() : buildWechat())
    // scroll to top
    textView?.scrollToBeginningOfDocument(nil)
}

// ── Shared Helpers ──

private func endDate() -> Date { Calendar.current.date(byAdding: .day, value: pageOffset * 7, to: Date())! }

private func fmtT(_ s: Int) -> String {
    if s < 60 { return s > 0 ? "<1m" : "0m" }
    let h = s/3600, m = (s%3600)/60; return h > 0 ? "\(h)h\(m)m" : "\(m)m"
}

private func sparkline(_ values: [Int]) -> String {
    let bars: [Character] = ["▁","▂","▃","▄","▅","▆","▇","█"]
    let mx = max(values.max() ?? 1, 1)
    return String(values.map { v in
        if v == 0 { return Character(" ") }
        let i = min(Int(Double(v) / Double(mx) * 7), 7)
        return bars[i]
    })
}

private func progressBar(_ cur: Int, _ target: Int, w: Int = 20) -> String {
    let pct = min(Double(cur) / Double(max(target, 1)), 1.0)
    let filled = Int(pct * Double(w))
    return String(repeating: "█", count: filled) + String(repeating: "░", count: w - filled)
}

private func weekAgg(offset: Int) -> (wn: Int, ds: DayStats) {
    let cal = Calendar.current; let now = Date()
    let wd = cal.component(.weekday, from: now)
    let toMon = (wd + 5) % 7
    guard let mon = cal.date(byAdding: .day, value: -toMon, to: now),
          let ws = cal.date(byAdding: .day, value: -offset * 7, to: mon) else { return (0, DayStats()) }
    var r = DayStats()
    for d in 0..<7 {
        guard let dt = cal.date(byAdding: .day, value: d, to: ws), dt <= now else { continue }
        let s = StatsStore.shared.stats(for: StatsStore.dayKey(dt))
        r.pomodoros += s.pomodoros; r.focusSecs += s.focusSecs
        r.breakSecs += s.breakSecs; r.wechatSecs += s.wechatSecs
    }
    return (cal.component(.weekOfYear, from: ws), r)
}

private func monthAgg(offset: Int) -> (label: String, ds: DayStats, wd: Int) {
    let cal = Calendar.current
    guard let t = cal.date(byAdding: .month, value: -offset, to: Date()) else { return ("", DayStats(), 1) }
    let y = cal.component(.year, from: t), m = cal.component(.month, from: t)
    let first = cal.date(from: DateComponents(year: y, month: m, day: 1))!
    let nd = cal.range(of: .day, in: .month, for: first)!.count
    let upto = offset == 0 ? cal.component(.day, from: Date()) : nd
    var r = DayStats()
    for d in 1...upto {
        guard let dt = cal.date(from: DateComponents(year: y, month: m, day: d)) else { continue }
        let s = StatsStore.shared.stats(for: StatsStore.dayKey(dt))
        r.pomodoros += s.pomodoros; r.focusSecs += s.focusSecs
        r.breakSecs += s.breakSecs; r.wechatSecs += s.wechatSecs
    }
    return ("\(m)月", r, max(upto * 5 / 7, 1))
}

// ════════════════════════════════
// Pomodoro Page
// ════════════════════════════════

private func buildPomodoro() -> NSAttributedString {
    let r = NSMutableAttributedString()
    let cal = Calendar.current; let now = Date()
    let store = StatsStore.shared

    // Fonts
    let big    = NSFont.monospacedDigitSystemFont(ofSize: 28, weight: .bold)
    let med    = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
    let body   = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
    let small  = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular)
    let tiny   = NSFont.systemFont(ofSize: 9)
    let dim = NSColor.secondaryLabelColor; let lbl = NSColor.labelColor
    let sep = NSColor.separatorColor

    func add(_ s: String, f: NSFont = body, c: NSColor = lbl) {
        r.append(NSAttributedString(string: s, attributes: [.font: f, .foregroundColor: c]))
    }

    // ── Hero Cards ──
    let td = store.today(); let tdG = PerfGrade.grade(td.pomodoros)
    let tw = store.thisWeek(); let twG = PerfGrade.grade(tw.pomodoros, workDays: 5)

    add("\n")
    // Today
    add("  TODAY 今日\n", f: tiny, c: dim)
    add("  \(td.pomodoros)", f: big, c: tdG == .none ? dim : tdG.color)
    add(" 🍅  ", f: med)
    add(fmtT(td.focusSecs), f: med, c: dim)
    add("\n")
    add("  \(progressBar(td.pomodoros, 7, w: 15))", f: small, c: tdG == .none ? dim : tdG.color)
    add("  \(td.pomodoros)/7", f: small, c: dim)
    add("\n")
    if td.pomodoros > 0 {
        add("  \(tdG.emoji) \(tdG.label)\n", f: tiny, c: tdG.color)
    }

    add("\n")
    // This Week
    add("  WEEK 本周\n", f: tiny, c: dim)
    add("  \(tw.pomodoros)", f: big, c: twG == .none ? dim : twG.color)
    add(" 🍅  ", f: med)
    add(fmtT(tw.focusSecs), f: med, c: dim)
    add("\n")
    add("  \(progressBar(tw.pomodoros, 35, w: 15))", f: small, c: twG == .none ? dim : twG.color)
    add("  \(tw.pomodoros)/35", f: small, c: dim)
    add("\n")

    // ── Streak ──
    let streak = store.currentStreak()
    let best = store.bestStreak
    let tm = store.thisMonth()
    add("\n")
    add("  🔥 连续 \(streak)天", f: med)
    if best > streak { add("  ·  最长 \(best)天", f: small, c: dim) }
    add("  ·  本月 \(tm.pomodoros)🍅", f: small, c: dim)
    add("\n")

    // ── Separator ──
    add("  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)

    // ── 7-Day Detail ──
    add("\n")
    let end = endDate()
    for i in (0..<7).reversed() {
        guard let date = cal.date(byAdding: .day, value: -i, to: end) else { continue }
        let ds = store.stats(for: StatsStore.dayKey(date))
        let g = PerfGrade.grade(ds.pomodoros)
        let dn = cal.component(.day, from: date)
        let wd = cal.component(.weekday, from: date)
        let isToday = cal.isDateInToday(date)
        let isFuture = date > now

        add("  ", f: small)
        add(String(format: "%2d", dn), f: small, c: isToday ? lbl : dim)
        add(" \(Self.wdShort[wd - 1]) ", f: small, c: isToday ? lbl : dim)

        if isFuture {
            add("  ·\n", f: small, c: NSColor.tertiaryLabelColor)
            continue
        }

        let count = min(ds.pomodoros, 15)
        if count > 0 {
            // Each ■ = 1 pomodoro, colored by grade
            let block = String(repeating: "■ ", count: count).trimmingCharacters(in: .whitespaces)
            add(block, f: small, c: g.color)
            add(String(format: "  %d", ds.pomodoros), f: small, c: dim)
        } else {
            add("  ·", f: small, c: NSColor.tertiaryLabelColor)
        }
        if isToday { add("  ◂", f: tiny, c: dim) }
        add("\n")
    }

    // Week summary
    var wkTotal = 0; var wkFocus = 0
    for i in 0..<7 {
        guard let d = cal.date(byAdding: .day, value: -i, to: end), d <= now else { continue }
        let ds = store.stats(for: StatsStore.dayKey(d))
        wkTotal += ds.pomodoros; wkFocus += ds.focusSecs
    }
    let wkG = PerfGrade.grade(wkTotal, workDays: 5)
    add("\n  合计 \(wkTotal)🍅 \(wkG.emoji)  ⏱\(fmtT(wkFocus))\n", f: small, c: dim)

    // ── Separator ──
    add("  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)

    // ── 30-Day Sparkline ──
    var last30: [Int] = []
    for i in (0..<30).reversed() {
        guard let d = cal.date(byAdding: .day, value: -i, to: now) else { continue }
        last30.append(store.stats(for: StatsStore.dayKey(d)).pomodoros)
    }
    add("\n  近30天 ", f: small, c: dim)
    // Color each spark char by its value's grade
    let bars: [Character] = ["▁","▂","▃","▄","▅","▆","▇","█"]
    let mx = max(last30.max() ?? 1, 1)
    for v in last30 {
        if v == 0 {
            add(" ", f: small, c: NSColor.tertiaryLabelColor)
        } else {
            let idx = min(Int(Double(v) / Double(mx) * 7), 7)
            let g = PerfGrade.grade(v)
            add(String(bars[idx]), f: small, c: g.color)
        }
    }
    add("\n", f: small)

    // ── Separator ──
    add("  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)

    // ── Weekly Bars (8 weeks) ──
    add("\n  近8周\n", f: med)
    for i in (0..<8).reversed() {
        let (wn, ws) = weekAgg(offset: i)
        let g = PerfGrade.grade(ws.pomodoros, workDays: 5)
        let isCur = i == 0
        add("  ", f: small)
        add(String(format: "W%02d ", wn), f: small, c: isCur ? lbl : dim)
        let bLen = max(min(ws.pomodoros * 2 / 5, 20), 0) // scale: 50 pomodoros = 20 chars
        if bLen > 0 { add(String(repeating: "█", count: bLen), f: small, c: g.color) }
        add(String(repeating: " ", count: max(20 - bLen, 0)), f: small)
        add(String(format: " %3d ", ws.pomodoros), f: small)
        add(g.emoji, f: tiny)
        if isCur { add(" ◂", f: tiny, c: dim) }
        add("\n")
    }

    // ── Monthly Heatmap ──
    add("\n", f: small)
    add("  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)
    let year = cal.component(.year, from: now)
    let month = cal.component(.month, from: now)
    let today = cal.component(.day, from: now)
    let firstOfMonth = cal.date(from: DateComponents(year: year, month: month, day: 1))!
    let firstWd = cal.component(.weekday, from: firstOfMonth)
    let numDays = cal.range(of: .day, in: .month, for: firstOfMonth)!.count
    let startOff = (firstWd + 5) % 7

    add("\n  \(month)月热力图\n", f: med)
    // Build week columns
    let totalCells = startOff + numDays
    let numWeeks = (totalCells + 6) / 7

    // Header: week numbers
    add("       ", f: small, c: dim)
    for w in 0..<numWeeks {
        add(String(format: "W%d ", w + 1), f: small, c: dim)
    }
    add("\n")

    // Rows: Mon(0) through Sun(6)
    for row in 0..<7 {
        add("   \(Self.wdShort[(row + 1) % 7]) ", f: small, c: dim)
        for col in 0..<numWeeks {
            let dayIdx = col * 7 + row - startOff + 1
            if dayIdx >= 1 && dayIdx <= numDays {
                if dayIdx <= today {
                    let dt = cal.date(from: DateComponents(year: year, month: month, day: dayIdx))!
                    let ds = store.stats(for: StatsStore.dayKey(dt))
                    let g = PerfGrade.grade(ds.pomodoros)
                    add(" ■ ", f: small, c: g == .none ? NSColor.quaternaryLabelColor : g.color)
                } else {
                    add(" · ", f: small, c: NSColor.tertiaryLabelColor)
                }
            } else {
                add("   ", f: small)
            }
        }
        add("\n")
    }

    // ── Monthly Bars (6 months) ──
    add("\n  近6月\n", f: med)
    for i in (0..<6).reversed() {
        let (label, ms, wd) = monthAgg(offset: i)
        let g = PerfGrade.grade(ms.pomodoros, workDays: wd)
        let isCur = i == 0
        add("  ", f: small)
        add(String(format: "%3@ ", label), f: small, c: isCur ? lbl : dim)
        let bLen = max(min(ms.pomodoros / 5, 20), 0)
        if bLen > 0 { add(String(repeating: "█", count: bLen), f: small, c: g.color) }
        add(String(repeating: " ", count: max(20 - bLen, 0)), f: small)
        add(String(format: "%4d ", ms.pomodoros), f: small)
        add(g.emoji, f: tiny)
        if isCur { add(" ◂", f: tiny, c: dim) }
        add("\n")
    }

    add("\n")
    return r
}

// ════════════════════════════════
// WeChat / 摸鱼 Page
// ════════════════════════════════

private func buildWechat() -> NSAttributedString {
    let r = NSMutableAttributedString()
    let cal = Calendar.current; let now = Date()
    let store = StatsStore.shared

    let big   = NSFont.monospacedDigitSystemFont(ofSize: 28, weight: .bold)
    let med   = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
    let body  = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
    let small = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular)
    let tiny  = NSFont.systemFont(ofSize: 9)
    let dim = NSColor.secondaryLabelColor; let lbl = NSColor.labelColor
    let sep = NSColor.separatorColor
    let bar1 = NSColor(srgbRed: 76/255, green: 175/255, blue: 80/255, alpha: 1)  // green
    let bar2 = NSColor(srgbRed: 230/255, green: 126/255, blue: 34/255, alpha: 1) // orange for high

    func add(_ s: String, f: NSFont = body, c: NSColor = lbl) {
        r.append(NSAttributedString(string: s, attributes: [.font: f, .foregroundColor: c]))
    }
    func barColor(_ secs: Int) -> NSColor { secs > 3600 ? bar2 : bar1 }

    // ── Hero ──
    let td = store.today(); let yd = store.yesterday()
    let tw = store.thisWeek(); let lw = store.lastWeek()

    add("\n")
    add("  TODAY 今日\n", f: tiny, c: dim)
    add("  \(fmtT(td.wechatSecs))", f: big, c: barColor(td.wechatSecs))
    add("  📱\n", f: med)
    let ydDiff = td.wechatSecs - yd.wechatSecs
    if yd.wechatSecs > 0 {
        let arrow = ydDiff >= 0 ? "▲" : "▼"
        let ac: NSColor = ydDiff >= 0 ? bar2 : bar1
        add("  \(arrow) \(ydDiff >= 0 ? "+" : "")\(fmtT(abs(ydDiff))) vs 昨日\n", f: small, c: ac)
    }

    add("\n")
    add("  WEEK 本周\n", f: tiny, c: dim)
    add("  \(fmtT(tw.wechatSecs))", f: big, c: barColor(tw.wechatSecs))
    add("  📱\n", f: med)
    let lwDiff = tw.wechatSecs - lw.wechatSecs
    if lw.wechatSecs > 0 {
        let arrow = lwDiff >= 0 ? "▲" : "▼"
        let ac: NSColor = lwDiff >= 0 ? bar2 : bar1
        add("  \(arrow) \(lwDiff >= 0 ? "+" : "")\(fmtT(abs(lwDiff))) vs 上周\n", f: small, c: ac)
    }

    add("  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)

    // ── 7-Day Detail ──
    let end = endDate()
    add("\n")
    var daySecs: [(Date, Int)] = []
    for i in (0..<7).reversed() {
        guard let d = cal.date(byAdding: .day, value: -i, to: end) else { continue }
        daySecs.append((d, store.stats(for: StatsStore.dayKey(d)).wechatSecs))
    }
    let maxS = max(daySecs.map({ $0.1 }).max() ?? 1, 300)

    for (date, secs) in daySecs {
        let dn = cal.component(.day, from: date)
        let wd = cal.component(.weekday, from: date)
        let isToday = cal.isDateInToday(date)
        let isFuture = date > now

        add("  ", f: small)
        add(String(format: "%2d", dn), f: small, c: isToday ? lbl : dim)
        add(" \(Self.wdShort[wd - 1]) ", f: small, c: isToday ? lbl : dim)

        if isFuture { add(\n", f: small, c: NSColor.tertiaryLabelColor); continue }

        let bLen = max(Int(Double(secs) / Double(maxS) * 20), secs > 0 ? 1 : 0)
        if bLen > 0 { add(String(repeating: "█", count: bLen), f: small, c: barColor(secs)) }
        add(String(repeating: " ", count: max(20 - bLen, 0)), f: small)
        add("  \(fmtT(secs))", f: small, c: dim)
        if isToday { add(" ◂", f: tiny, c: dim) }
        add("\n")
    }

    let wkTotal = daySecs.filter { $0.0 <= now }.reduce(0) { $0 + $1.1 }
    add("\n  合计 \(fmtT(wkTotal))\n", f: small, c: dim)

    add("  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)

    // ── 30-Day Sparkline ──

var last30: [Int] = [] for i in (0..<30).reversed() { guard let d = cal.date(byAdding: .day, value: -i, to: now) else { continue } last30.append(store.stats(for: StatsStore.dayKey(d)).wechatSecs) } add("\n 近30天 ", f: small, c: dim) let bars: [Character] = ["▁","▂","▃","▄","▅","▆","▇","█"] let mx = max(last30.max() ?? 1, 1) for v in last30 { if v == 0 { add(" ", f: small, c: NSColor.tertiaryLabelColor) } else { let idx = min(Int(Double(v) / Double(mx) * 7), 7) add(String(bars[idx]), f: small, c: barColor(v)) } } add("\n")

    add("  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", f: small, c: sep)

    // ── Weekly Bars ──
    add("\n  近8周\n", f: med)
    var weekSecs: [(Int, Int)] = []
    for i in (0..<8).reversed() { let (wn, ws) = weekAgg(offset: i); weekSecs.append((wn, ws.wechatSecs)) }
    let maxWS = max(weekSecs.map({ $0.1 }).max() ?? 1, 1800)

    for (idx, (wn, secs)) in weekSecs.enumerated() {
        let isCur = idx == weekSecs.count - 1
        add("  ", f: small)
        add(String(format: "W%02d ", wn), f: small, c: isCur ? lbl : dim)
        let bLen = Int(Double(secs) / Double(maxWS) * 20)
        if bLen > 0 { add(String(repeating: "█", count: bLen), f: small, c: barColor(secs)) }
        add(String(repeating: " ", count: max(20 - bLen, 0)), f: small)
        add("  \(fmtT(secs))", f: small, c: dim)
        if isCur { add(" ◂", f: tiny, c: dim) }
        add("\n")
    }

    // ── Monthly Bars ──
    add("\n  近6月\n", f: med)
    var monthS: [(String, Int)] = []
    for i in (0..<6).reversed() { let (l, ms, _) = monthAgg(offset: i); monthS.append((l, ms.wechatSecs)) }
    let maxMS = max(monthS.map({ $0.1 }).max() ?? 1, 3600)

    for (idx, (label, secs)) in monthS.enumerated() {
        let isCur = idx == monthS.count - 1
        add("  ", f: small)
        add(String(format: "%3@ ", label), f: small, c: isCur ? lbl : dim)
        let bLen = Int(Double(secs) / Double(maxMS) * 20)
        if bLen > 0 { add(String(repeating: "█", count: bLen), f: small, c: barColor(secs)) }
        add(String(repeating: " ", count: max(20 - bLen, 0)), f: small)
        add("  \(fmtT(secs))", f: small, c: dim)
        if isCur { add(" ◂", f: tiny, c: dim) }
        add("\n")
    }

    add("\n")
    return r
}

}

// MARK: - Clickable Panel // ═══════════════════════════════════════════

class FloatingPanel: NSPanel { override var canBecomeKey: Bool { true } }

// ═══════════════════════════════════════════ // MARK: - Segment Progress View // ═══════════════════════════════════════════

class SegmentBar: NSView { var elapsed = 0 var appState: AppState = .idle var wechatBlockProgress: CGFloat = 0 var onSeek: ((Int) -> Void)? private var isDragging = false

override func resetCursorRects() {
    if appState != .idle {
        addCursorRect(bounds, cursor: .resizeLeftRight)
    }
}

private func elapsedFromMouse(_ event: NSEvent) -> Int {
    let loc = convert(event.locationInWindow, from: nil)
    let pct = max(0, min(1, loc.x / bounds.width))
    let total = appState == .brk ? kBreakDuration : kWorkDuration
    return Int(pct * CGFloat(total))
}

override func mouseDown(with event: NSEvent) {
    guard appState != .idle else { super.mouseDown(with: event); return }
    isDragging = true
    onSeek?(elapsedFromMouse(event))
}

override func mouseDragged(with event: NSEvent) {
    guard isDragging else { return }
    onSeek?(elapsedFromMouse(event))
}

override func mouseUp(with event: NSEvent) {
    isDragging = false
}

override func draw(_ dirtyRect: NSRect) {
    let w = bounds.width, h = bounds.height

    if appState == .idle {
        C.pending.setFill()
        NSBezierPath(roundedRect: bounds, xRadius: 3, yRadius: 3).fill()
        if wechatBlockProgress > 0 {
            C.green.setFill()
            NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: w * min(wechatBlockProgress, 1), height: h),
                         xRadius: 3, yRadius: 3).fill()
        }
        return
    }

    if appState == .brk {
        C.green.withAlphaComponent(0.25).setFill()
        NSBezierPath(roundedRect: bounds, xRadius: 3, yRadius: 3).fill()
        let pct = CGFloat(elapsed) / CGFloat(kBreakDuration)
        C.green.setFill()
        NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: w * min(pct, 1), height: h),
                     xRadius: 3, yRadius: 3).fill()
        return
    }

    var x: CGFloat = 0
    var accum = 0
    for (i, ph) in kPhases.enumerated() {
        let segW = w * CGFloat(ph.duration) / CGFloat(kWorkDuration)
        C.pending.setFill()
        NSBezierPath(rect: NSRect(x: x, y: 0, width: segW, height: h)).fill()

        if appState == .work {
            let segEnd = accum + ph.duration
            let fill: CGFloat
            if elapsed >= segEnd { fill = 1 }
            else if elapsed > accum { fill = CGFloat(elapsed - accum) / CGFloat(ph.duration) }
            else { fill = 0 }
            if fill > 0 {
                (elapsed >= segEnd ? C.done : C.tomato).setFill()
                NSBezierPath(rect: NSRect(x: x, y: 0, width: segW * fill, height: h)).fill()
            }
        }

        let textColor: NSColor = appState == .work && elapsed > accum
            ? .white.withAlphaComponent(0.9)
            : (appState == .idle ? .darkGray.withAlphaComponent(0.5) : .white.withAlphaComponent(0.35))
        let s = NSAttributedString(string: ph.label, attributes: [
            .font: NSFont.systemFont(ofSize: 7, weight: .semibold),
            .foregroundColor: textColor,
        ])
        let sz = s.size()
        if segW > sz.width + 2 {
            s.draw(at: NSPoint(x: x + (segW - sz.width) / 2, y: (h - sz.height) / 2))
        }

        if i < kPhases.count - 1 {
            (appState == .idle ? NSColor.gray.withAlphaComponent(0.2) : .white.withAlphaComponent(0.15)).setFill()
            NSBezierPath(rect: NSRect(x: x + segW - 0.5, y: 0, width: 1, height: h)).fill()
        }
        accum += ph.duration
        x += segW
    }
}

}

// ═══════════════════════════════════════════ // MARK: - App Delegate // ═══════════════════════════════════════════

class TickMatoDelegate: NSObject, NSApplicationDelegate { var panel: FloatingPanel! var bg: NSView! var statusLabel: NSTextField! var timerLabel: NSTextField! var segmentBar: SegmentBar! var statsLabel: NSTextField! var actionBtn: NSButton! var statsBtn: NSButton! var gearBtn: NSButton!

let soundSettings = SoundSettingsController()
let statsWindow = StatsWindowController()

var timer: Timer?
var idleTimer: Timer?
var alarmTimer: Timer?
var alarmElapsed = 0
var workDoneRinging = false
var workStatsAlreadyFlushed = false
var state: AppState = .idle
var elapsed = 0
var curPhase = -1
var initialCmd: String?
var isWechatFront = false
var wechatIdleSeconds = 0
var wechatBlockSeconds = 0
var wechatSessionSeconds = 0
var stateEnteredAt = Date()
var wechatActiveAt: Date?
var lastStatsFlush = 0
var pausedElapsed: Int? = nil   // non-nil = paused by WeChat, stores saved elapsed seconds
var pausedPhase: Int = -1
var dimWindow: NSWindow?

let W: CGFloat = 280
let H: CGFloat = 178

func applicationDidFinishLaunching(_ n: Notification) {
    NSApp.setActivationPolicy(.accessory)
    writePID()
    buildUI()
    listenIPC()
    observeAppSwitch()
    applyState(animate: false)

    let reshow = { [weak self] in
        DispatchQueue.main.async { self?.panel.orderFront(nil) }
    }
    soundSettings.onClose = reshow
    statsWindow.onClose = reshow

    if let c = initialCmd { handleCmd(c) }
    else { startIdleTick() }
}

func applicationWillTerminate(_ n: Notification) {
    flushStateTime(); flushWechatTime(); removePID()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }

// ── Build UI ──

func buildUI() {
    let screen = NSScreen.main!.frame
    let x = screen.maxX - W - 16
    let y = screen.maxY - H - 36

    panel = FloatingPanel(
        contentRect: NSRect(x: x, y: y, width: W, height: H),
        styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel],
        backing: .buffered, defer: false
    )
    panel.level = .floating
    panel.isFloatingPanel = true
    panel.hidesOnDeactivate = false
    panel.titlebarAppearsTransparent = true
    panel.titleVisibility = .hidden
    panel.backgroundColor = .clear
    panel.isOpaque = false
    panel.hasShadow = true
    panel.isMovableByWindowBackground = true

    bg = panel.contentView!
    bg.wantsLayer = true
    bg.layer?.cornerRadius = 12
    bg.layer?.masksToBounds = true

    statusLabel = makeLabel("", size: 11, weight: .medium)
    statusLabel.frame = NSRect(x: 10, y: H - 40, width: W - 20, height: 16)
    statusLabel.alignment = .center
    statusLabel.lineBreakMode = .byTruncatingTail
    bg.addSubview(statusLabel)

    timerLabel = makeLabel("25:00", size: 42, weight: .bold, mono: true)
    timerLabel.frame = NSRect(x: 0, y: H - 90, width: W, height: 48)
    timerLabel.alignment = .center
    bg.addSubview(timerLabel)

    segmentBar = SegmentBar()
    segmentBar.frame = NSRect(x: 12, y: H - 102, width: W - 24, height: 8)
    segmentBar.wantsLayer = true
    segmentBar.layer?.cornerRadius = 4
    segmentBar.layer?.masksToBounds = true
    bg.addSubview(segmentBar)

    statsLabel = makeLabel("", size: 10, weight: .medium)
    statsLabel.frame = NSRect(x: 12, y: H - 118, width: W - 24, height: 14)
    statsLabel.alignment = .center
    statsLabel.lineBreakMode = .byTruncatingTail
    bg.addSubview(statsLabel)

    // Action button
    actionBtn = NSButton(frame: NSRect(x: 12, y: 10, width: W - 100, height: 34))
    actionBtn.wantsLayer = true
    actionBtn.isBordered = false
    actionBtn.layer?.cornerRadius = 8
    actionBtn.target = self
    actionBtn.action = #selector(onAction)
    bg.addSubview(actionBtn)

    // Stats button
    statsBtn = NSButton(frame: NSRect(x: W - 82, y: 10, width: 34, height: 34))
    statsBtn.wantsLayer = true
    statsBtn.isBordered = false
    statsBtn.layer?.cornerRadius = 8
    statsBtn.target = self
    statsBtn.action = #selector(openStats)
    bg.addSubview(statsBtn)

    // Gear button
    gearBtn = NSButton(frame: NSRect(x: W - 44, y: 10, width: 34, height: 34))
    gearBtn.wantsLayer = true
    gearBtn.isBordered = false
    gearBtn.layer?.cornerRadius = 8
    gearBtn.target = self
    gearBtn.action = #selector(openSettings)
    let gearAttr = NSAttributedString(string: "⚙", attributes: [
        .font: NSFont.systemFont(ofSize: 18),
        .foregroundColor: NSColor.white.withAlphaComponent(0.8),
    ])
    gearBtn.attributedTitle = gearAttr
    gearBtn.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.1).cgColor
    bg.addSubview(gearBtn)

    segmentBar.onSeek = { [weak self] newElapsed in
        self?.seekTo(newElapsed)
    }

    panel.orderFront(nil)
}

func makeLabel(_ text: String, size: CGFloat, weight: NSFont.Weight, mono: Bool = false) -> NSTextField {
    let l = NSTextField(labelWithString: text)
    l.font = mono ? NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
                  : NSFont.systemFont(ofSize: size, weight: weight)
    return l
}

func setButtonTitle(_ title: String) {
    actionBtn.attributedTitle = NSAttributedString(string: title, attributes: [
        .font: NSFont.systemFont(ofSize: 13, weight: .semibold),
        .foregroundColor: NSColor.white,
    ])
}

// ── Break Dim Overlay ──

func showDimOverlay() {
    guard dimWindow == nil else { return }
    let frame = NSScreen.screens.reduce(NSRect.zero) { $0.union($1.frame) }
    let w = NSWindow(contentRect: frame, styleMask: .borderless, backing: .buffered, defer: false)
    w.level = NSWindow.Level(rawValue: Int(NSWindow.Level.floating.rawValue) - 1)
    w.backgroundColor = NSColor.black.withAlphaComponent(0.65)
    w.isOpaque = false
    w.hasShadow = false
    w.ignoresMouseEvents = true
    w.collectionBehavior = [.canJoinAllSpaces, .stationary]

    let label = NSTextField(labelWithString: "🌱 休息一下")
    label.font = NSFont.systemFont(ofSize: 48, weight: .light)
    label.textColor = .white.withAlphaComponent(0.5)
    label.alignment = .center
    label.sizeToFit()
    if let mainScreen = NSScreen.main {
        label.frame.origin = NSPoint(
            x: mainScreen.frame.midX - label.frame.width / 2,
            y: mainScreen.frame.midY - label.frame.height / 2
        )
    }
    w.contentView?.addSubview(label)

    w.orderFront(nil)
    panel.orderFront(nil)  // keep TickMato above overlay
    dimWindow = w
}

func hideDimOverlay() {
    dimWindow?.close()
    dimWindow = nil
}

// ── State Machine ──

func transitionTo(_ newState: AppState) {
    let old = state
    let fromRinging = workStatsAlreadyFlushed
    workDoneRinging = false          // Bug fix: always clear ringing flag on any transition
    timer?.invalidate(); timer = nil
    idleTimer?.invalidate(); idleTimer = nil
    stopAlarm()

    // Flush stats for the state we're leaving
    if fromRinging {
        workStatsAlreadyFlushed = false
    } else {
        flushStateTime()
        if old == .work {
            UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastFocusEnd")
            if newState == .brk { StatsStore.shared.addPomodoro() }
            else if pausedElapsed == nil { StatsStore.shared.addAbandoned() }
            // If pausedElapsed != nil, it's a WeChat pause — not an abandonment
        }
    }
    flushWechatTime()
    // Clear pause state when entering work or break (fresh start)
    if newState != .idle { pausedElapsed = nil; pausedPhase = -1 }

    elapsed = 0; curPhase = -1
    isWechatFront = false; wechatIdleSeconds = 0; wechatBlockSeconds = 0; wechatSessionSeconds = 0
    stateEnteredAt = Date(); lastStatsFlush = 0

    state = newState
    segmentBar.appState = newState
    segmentBar.elapsed = 0
    segmentBar.wechatBlockProgress = 0

    switch newState {
    case .idle:
        hideDimOverlay()
        if old == .brk { SoundPrefs.shared.play("breakEnd") }
        checkCurrentFrontApp()
        startIdleTick()
    case .work:
        hideDimOverlay()
        SoundPrefs.shared.play("windUp")
        startTick()
    case .brk:
        if old == .work && !fromRinging { startAlarm("workComplete") }
        showDimOverlay()
        startTick()
    }

    applyState(animate: old != newState)
}

func applyState(animate: Bool) {
    let bgColor: CGColor
    switch state {
    case .idle:
        bgColor = C.idleBg.cgColor
        if let saved = pausedElapsed {
            let rem = kWorkDuration - saved
            statusLabel.stringValue = "⏸ 已暂停  Paused"
            timerLabel.stringValue = String(format: "%02d:%02d", rem / 60, rem % 60)
            setButtonTitle("Continue  继续")
        } else {
            let ts = UserDefaults.standard.double(forKey: "lastFocusEnd")
            if ts > 0 {
                statusLabel.stringValue = "距上次专注  Since Last Focus"
                timerLabel.stringValue = formatIdleElapsed(since: ts)
            } else {
                statusLabel.stringValue = "Ready to Pomodoro!  准备好开始番茄工作法了!"
                timerLabel.stringValue = "25:00"
            }
            setButtonTitle("Focus  去专注")
        }
        statusLabel.textColor = .darkGray
        timerLabel.textColor = NSColor.darkGray.withAlphaComponent(0.6)
        actionBtn.layer?.backgroundColor = C.tomato.cgColor
        updateStatsLabel()
        let idleBtnColor = NSColor.darkGray.withAlphaComponent(0.1).cgColor
        gearBtn.layer?.backgroundColor = idleBtnColor
        statsBtn.layer?.backgroundColor = idleBtnColor
        let idleIconColor = NSColor.darkGray.withAlphaComponent(0.6)
        gearBtn.attributedTitle = NSAttributedString(string: "⚙", attributes: [
            .font: NSFont.systemFont(ofSize: 18), .foregroundColor: idleIconColor])
        statsBtn.attributedTitle = NSAttributedString(string: "📊", attributes: [
            .font: NSFont.systemFont(ofSize: 16), .foregroundColor: idleIconColor])
    case .work:
        bgColor = C.workBg.cgColor
        statusLabel.stringValue = "Initial Review  专注中"
        statusLabel.textColor = .white.withAlphaComponent(0.7)
        timerLabel.stringValue = "25:00"
        timerLabel.textColor = .white
        setButtonTitle("Squash  停止")
        updateStatsLabel()
        let workBtnColor = NSColor.white.withAlphaComponent(0.1).cgColor
        actionBtn.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.15).cgColor
        gearBtn.layer?.backgroundColor = workBtnColor
        statsBtn.layer?.backgroundColor = workBtnColor
        let workIconColor = NSColor.white.withAlphaComponent(0.5)
        gearBtn.attributedTitle = NSAttributedString(string: "⚙", attributes: [
            .font: NSFont.systemFont(ofSize: 18), .foregroundColor: workIconColor])
        statsBtn.attributedTitle = NSAttributedString(string: "📊", attributes: [
            .font: NSFont.systemFont(ofSize: 16), .foregroundColor: workIconColor])
    case .brk:
        bgColor = C.breakBg.cgColor
        statusLabel.stringValue = "Take a break!  休息一下 🌱"
        statusLabel.textColor = .white.withAlphaComponent(0.8)
        timerLabel.stringValue = "05:00"
        timerLabel.textColor = .white
        setButtonTitle("Skip Break  跳过")
        updateStatsLabel()
        let brkBtnColor = NSColor.white.withAlphaComponent(0.1).cgColor
        actionBtn.layer?.backgroundColor = C.green.withAlphaComponent(0.4).cgColor
        gearBtn.layer?.backgroundColor = brkBtnColor
        statsBtn.layer?.backgroundColor = brkBtnColor
        let brkIconColor = NSColor.white.withAlphaComponent(0.5)
        gearBtn.attributedTitle = NSAttributedString(string: "⚙", attributes: [
            .font: NSFont.systemFont(ofSize: 18), .foregroundColor: brkIconColor])
        statsBtn.attributedTitle = NSAttributedString(string: "📊", attributes: [
            .font: NSFont.systemFont(ofSize: 16), .foregroundColor: brkIconColor])
    }

    segmentBar.needsDisplay = true

    if animate {
        let anim = CABasicAnimation(keyPath: "backgroundColor")
        anim.fromValue = bg.layer?.backgroundColor
        anim.toValue = bgColor
        anim.duration = 0.4
        anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        bg.layer?.add(anim, forKey: "bg")
    }
    bg.layer?.backgroundColor = bgColor
}

// ── Timer ──

func startTick() {
    timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
        self?.tick()
    }
}

func tick() {
    elapsed += 1
    let total = state == .work ? kWorkDuration : kBreakDuration
    let rem = total - elapsed

    if rem <= 0 {
        timer?.invalidate(); timer = nil
        if state == .work {
            // Don't auto-transition — ring and wait for user to click
            workDoneRinging = true
            workStatsAlreadyFlushed = true
            timerLabel.stringValue = "00:00"
            statusLabel.stringValue = "🍅 番茄完成!点击进入休息"
            setButtonTitle("Break  休息")
            startAlarm("workComplete")
            showDimOverlay()
            // Flush stats now
            flushStateTime()
            StatsStore.shared.addPomodoro()
            UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastFocusEnd")
            return
        }
        else { transitionTo(.idle) }
        return
    }

    timerLabel.stringValue = String(format: "%02d:%02d", rem / 60, rem % 60)
    segmentBar.elapsed = elapsed
    segmentBar.needsDisplay = true

    // Periodic stats update (every 30s)
    if elapsed % 30 == 0 { updateStatsLabel() }

    if state == .work {
        var acc = 0
        for (i, ph) in kPhases.enumerated() {
            if elapsed <= acc + ph.duration {
                if i != curPhase {
                    curPhase = i
                    statusLabel.stringValue = "\(ph.name)  专注中"
                    if i > 0 { SoundPrefs.shared.play("phaseTransition") }
                }
                break
            }
            acc += ph.duration
        }
    }
}

// ── Idle Timer ──

func startIdleTick() {
    idleTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
        self?.idleTick()
    }
}

func idleTick() {
    if isWechatFront {
        wechatIdleSeconds += 1
        wechatSessionSeconds += 1
        wechatBlockSeconds += 1
        if wechatBlockSeconds >= kWechatBlockDuration {
            SoundPrefs.shared.play("wechatRemind")
            wechatBlockSeconds = 0
        }
    }

    // Big timer: show paused remaining or "距上次专注"
    if let saved = pausedElapsed, !isWechatFront {
        let rem = kWorkDuration - saved
        timerLabel.stringValue = String(format: "%02d:%02d", rem / 60, rem % 60)
    } else {
        let ts = UserDefaults.standard.double(forKey: "lastFocusEnd")
        if ts > 0 {
            timerLabel.stringValue = formatIdleElapsed(since: ts)
        }
    }

    // Status label: context-aware
    updateIdleAppearance()

    // Periodic stats flush & label update (every 30s)
    lastStatsFlush += 1
    if lastStatsFlush >= 30 {
        lastStatsFlush = 0
        if let wa = wechatActiveAt {
            let secs = Int(Date().timeIntervalSince(wa))
            StatsStore.shared.addSeconds("wechat", secs)
            wechatActiveAt = Date()
        }
        updateStatsLabel()
    }
}

func formatSeconds(_ secs: Int) -> String {
    if secs < 0 { return "00:00" }
    let h = secs / 3600, m = (secs % 3600) / 60, s = secs % 60
    return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%02d:%02d", m, s)
}

func formatIdleElapsed(since ts: Double) -> String {
    formatSeconds(Int(Date().timeIntervalSince1970 - ts))
}

func updateIdleAppearance() {
    guard state == .idle else { return }
    let wechatMinutes = CGFloat(wechatIdleSeconds) / 60.0
    let darkText = wechatMinutes < 25
    let baseColor: NSColor = darkText ? .darkGray : .white.withAlphaComponent(0.8)
    let monoFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .bold)
    let textFont = NSFont.systemFont(ofSize: 11, weight: .medium)

    let text = NSMutableAttributedString()

    if isWechatFront {
        // "📱 刷微信中  本次 03:25 · 今日共 45m"
        text.append(NSAttributedString(string: "📱 刷微信中  本次 ", attributes: [
            .foregroundColor: baseColor, .font: textFont]))
        text.append(NSAttributedString(string: formatSeconds(wechatSessionSeconds), attributes: [
            .foregroundColor: C.green, .font: monoFont]))

        let todayTotal = StatsStore.shared.today().wechatSecs + (wechatActiveAt != nil
            ? Int(Date().timeIntervalSince(wechatActiveAt!)) : 0)
        if todayTotal > 60 {
            let h = todayTotal / 3600, m = (todayTotal % 3600) / 60
            let totalStr = h > 0 ? "\(h)h\(m)m" : "\(m)m"
            text.append(NSAttributedString(string: " · 今日共 \(totalStr)", attributes: [
                .foregroundColor: baseColor.withAlphaComponent(0.6), .font: textFont]))
        }
    } else if pausedElapsed != nil {
        // Paused by WeChat — show paused status
        text.append(NSAttributedString(string: "⏸ 已暂停  Paused", attributes: [
            .foregroundColor: baseColor, .font: textFont]))
    } else if wechatIdleSeconds > 0 {
        // "距上次专注 · 📱今日 45m"
        text.append(NSAttributedString(string: "距上次专注", attributes: [
            .foregroundColor: baseColor, .font: textFont]))

        let todayTotal = StatsStore.shared.today().wechatSecs
        if todayTotal > 60 {
            let h = todayTotal / 3600, m = (todayTotal % 3600) / 60
            let totalStr = h > 0 ? "\(h)h\(m)m" : "\(m)m"
            text.append(NSAttributedString(string: " · 📱今日 \(totalStr)", attributes: [
                .foregroundColor: C.green, .font: textFont]))
        }
    } else {
        text.append(NSAttributedString(string: "距上次专注", attributes: [
            .foregroundColor: baseColor, .font: textFont]))
    }

    statusLabel.attributedStringValue = text
    timerLabel.textColor = darkText ? NSColor.darkGray.withAlphaComponent(0.6) : .white

    segmentBar.wechatBlockProgress = CGFloat(wechatBlockSeconds) / CGFloat(kWechatBlockDuration)
    segmentBar.needsDisplay = true

    let bgColor = wechatBgColor(totalSeconds: wechatIdleSeconds)
    bg.layer?.backgroundColor = bgColor.cgColor

    if !darkText {
        let wBtnColor = NSColor.white.withAlphaComponent(0.1).cgColor
        let wIconColor = NSColor.white.withAlphaComponent(0.5)
        gearBtn.layer?.backgroundColor = wBtnColor
        statsBtn.layer?.backgroundColor = wBtnColor
        gearBtn.attributedTitle = NSAttributedString(string: "⚙", attributes: [
            .font: NSFont.systemFont(ofSize: 18), .foregroundColor: wIconColor])
        statsBtn.attributedTitle = NSAttributedString(string: "📊", attributes: [
            .font: NSFont.systemFont(ofSize: 16), .foregroundColor: wIconColor])
        statsLabel.textColor = .white.withAlphaComponent(0.4)
    }
}

func wechatBgColor(totalSeconds: Int) -> NSColor {
    let minutes = CGFloat(totalSeconds) / 60.0
    let stops: [(m: CGFloat, r: CGFloat, g: CGFloat, b: CGFloat)] = [
        (0,  245, 245, 245),
        (10, 255, 243, 224),
        (20, 255, 204, 128),
        (30, 245, 140, 60),
        (40, 232, 57,  42),
    ]
    var i = 0
    while i < stops.count - 2 && minutes > stops[i + 1].m { i += 1 }
    let lo = stops[i], hi = stops[i + 1]
    let range = hi.m - lo.m
    let t: CGFloat = range > 0 ? min((minutes - lo.m) / range, 1) : 1
    return NSColor(
        srgbRed: (lo.r + t * (hi.r - lo.r)) / 255,
        green:   (lo.g + t * (hi.g - lo.g)) / 255,
        blue:    (lo.b + t * (hi.b - lo.b)) / 255,
        alpha: 0.96
    )
}

func checkCurrentFrontApp() {
    if let app = NSWorkspace.shared.frontmostApplication,
       app.bundleIdentifier == "com.tencent.xinWeChat" {
        isWechatFront = true
        if wechatActiveAt == nil { wechatActiveAt = Date() }
    }
}

// ── Stats Helpers ──

func flushStateTime() {
    let secs = Int(Date().timeIntervalSince(stateEnteredAt))
    guard secs > 0 else { return }
    switch state {
    case .work: StatsStore.shared.addSeconds("focus", secs)
    case .brk:  StatsStore.shared.addSeconds("break", secs)
    case .idle: break
    }
}

func flushWechatTime() {
    guard let start = wechatActiveAt else { return }
    let secs = Int(Date().timeIntervalSince(start))
    StatsStore.shared.addSeconds("wechat", secs)
    wechatActiveAt = nil
}

func updateStatsLabel() {
    guard let label = statsLabel else { return }
    let s = StatsStore.shared.today()
    // Add live unflushed time
    var focusLive = s.focusSecs
    var wechatLive = s.wechatSecs
    if state == .work { focusLive += Int(Date().timeIntervalSince(stateEnteredAt)) }
    if let wa = wechatActiveAt { wechatLive += Int(Date().timeIntervalSince(wa)) }

    let fmtT = { (secs: Int) -> String in
        if secs < 60 { return "<1m" }
        let h = secs / 3600, m = (secs % 3600) / 60
        return h > 0 ? "\(h)h\(m)m" : "\(m)m"
    }

    let g = PerfGrade.grade(s.pomodoros)
    let text = "今日 \(g.emoji)\(s.pomodoros)  ⏱\(fmtT(focusLive))  📱\(fmtT(wechatLive))"
    let color: NSColor = state == .idle ? .darkGray.withAlphaComponent(0.5) : .white.withAlphaComponent(0.4)
    label.stringValue = text
    label.textColor = color
}

@objc func openStats() {
    // Flush current data so stats window shows latest
    if state == .work || state == .brk {
        let secs = Int(Date().timeIntervalSince(stateEnteredAt))
        if state == .work { StatsStore.shared.addSeconds("focus", secs) }
        else { StatsStore.shared.addSeconds("break", secs) }
        stateEnteredAt = Date()
    }
    if let wa = wechatActiveAt {
        let secs = Int(Date().timeIntervalSince(wa))
        StatsStore.shared.addSeconds("wechat", secs)
        wechatActiveAt = Date()
    }
    statsWindow.show()
}

// ── Seek ──

func seekTo(_ newElapsed: Int) {
    guard (state == .work || state == .brk) && !workDoneRinging else { return }
    let total = state == .work ? kWorkDuration : kBreakDuration
    elapsed = max(0, min(newElapsed, total - 1))
    let rem = total - elapsed
    timerLabel.stringValue = String(format: "%02d:%02d", rem / 60, rem % 60)
    segmentBar.elapsed = elapsed
    segmentBar.needsDisplay = true

    if state == .work {
        var acc = 0
        for (i, ph) in kPhases.enumerated() {
            if elapsed <= acc + ph.duration {
                if i != curPhase {
                    curPhase = i
                    statusLabel.stringValue = "\(ph.name)  专注中"
                }
                break
            }
            acc += ph.duration
        }
    }
}

// ── Alarm (persistent ringing) ──

func startAlarm(_ key: String) {
    stopAlarm()
    alarmElapsed = 0
    SoundPrefs.shared.play(key)
    alarmTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
        guard let self = self else { return }
        self.alarmElapsed += 5
        if self.alarmElapsed >= 60 {
            self.stopAlarm()
            if self.workDoneRinging { self.workDoneRinging = false; self.transitionTo(.brk) }
            return
        }
        SoundPrefs.shared.play(key)
    }
}

func stopAlarm() {
    alarmTimer?.invalidate(); alarmTimer = nil; alarmElapsed = 0
    SoundPrefs.shared.stopAll()
}

// ── Actions ──

@objc func onAction() {
    stopAlarm()
    if workDoneRinging {
        workDoneRinging = false
        transitionTo(.brk)
        return
    }
    switch state {
    case .idle:
        if let saved = pausedElapsed {
            resumeWork(from: saved)
        } else {
            transitionTo(.work)
        }
    case .work:  SoundPrefs.shared.play("manualStop"); transitionTo(.idle)
    case .brk:   transitionTo(.idle)
    }
}

func resumeWork(from savedElapsed: Int) {
    pausedElapsed = nil; pausedPhase = -1

    idleTimer?.invalidate(); idleTimer = nil
    isWechatFront = false
    wechatIdleSeconds = 0; wechatBlockSeconds = 0; wechatSessionSeconds = 0
    flushWechatTime()

    state = .work
    elapsed = savedElapsed
    stateEnteredAt = Date()
    lastStatsFlush = 0

    segmentBar.appState = .work
    segmentBar.elapsed = savedElapsed
    segmentBar.wechatBlockProgress = 0

    SoundPrefs.shared.play("windUp")
    startTick()
    applyState(animate: true)

    // Restore correct remaining time & phase
    let rem = kWorkDuration - elapsed
    timerLabel.stringValue = String(format: "%02d:%02d", rem / 60, rem % 60)
    var acc = 0
    for (i, ph) in kPhases.enumerated() {
        if elapsed <= acc + ph.duration {
            curPhase = i
            statusLabel.stringValue = "\(ph.name)  专注中"
            break
        }
        acc += ph.duration
    }
}

@objc func openSettings() {
    soundSettings.show()
}

// ── App Switch Detection ──

func observeAppSwitch() {
    NSWorkspace.shared.notificationCenter.addObserver(
        self, selector: #selector(appDidActivate(_:)),
        name: NSWorkspace.didActivateApplicationNotification,
        object: nil
    )
}

@objc func appDidActivate(_ note: Notification) {
    guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
    let isWeChat = app.bundleIdentifier == "com.tencent.xinWeChat"

    // Global WeChat time tracking
    if isWeChat && wechatActiveAt == nil {
        wechatActiveAt = Date()
    } else if !isWeChat && wechatActiveAt != nil {
        flushWechatTime()
    }

    let pauseOnWechat = UserDefaults.standard.object(forKey: "pauseOnWechat") as? Bool ?? true
    if isWeChat && state == .work && pauseOnWechat {
        // Pause: save progress so user can resume later
        pausedElapsed = elapsed
        pausedPhase = curPhase
        stopTickTickTimer()
        transitionTo(.idle)
        isWechatFront = true
        wechatActiveAt = Date()  // re-set after transition cleared it
        updateIdleAppearance()
        return
    }

    if state == .idle {
        if isWeChat && !isWechatFront { wechatSessionSeconds = 0 }
        isWechatFront = isWeChat
        updateIdleAppearance()
    }
}

func stopTickTickTimer() {
    DispatchQueue.global(qos: .userInitiated).async {
        let script = """
        tell application "System Events"
            if not (exists process "TickTick") then return "no_ticktick"
            tell process "TickTick"
                set hasPomo to false
                set txts to every static text of window 1
                repeat with t in txts
                    try
                        if (value of t as text) contains "番茄专注" then
                            set hasPomo to true
                            exit repeat
                        end if
                    end try
                end repeat
                if not hasPomo then return "no_pomo"
                set btns to every button of window 1
                set maxArea to 0
                set targetBtn to missing value
                repeat with b in btns
                    try
                        set r to role description of b
                        if r is "关闭按钮" or r is "全屏幕按钮" or r is "最小化按钮" then
                        else
                            set s to size of b
                            set area to (item 1 of s) * (item 2 of s)
                            if area > maxArea then
                                set maxArea to area
                                set targetBtn to b
                            end if
                        end if
                    end try
                end repeat
                if targetBtn is not missing value and maxArea > 3000 then
                    perform action "AXPress" of targetBtn
                    return "stopped"
                end if
                return "no_button"
            end tell
        end tell
        """
        let task = Process()
        task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
        task.arguments = ["-e", script]
        try? task.run()
        task.waitUntilExit()
    }
}

// ── IPC ──

func listenIPC() {
    DistributedNotificationCenter.default().addObserver(
        self, selector: #selector(onIPC(_:)),
        name: Notification.Name(kNotification),
        object: nil, suspensionBehavior: .deliverImmediately
    )
}

@objc func onIPC(_ note: Notification) {
    if let cmd = note.object as? String {
        DispatchQueue.main.async { self.handleCmd(cmd) }
    }
}

func handleCmd(_ raw: String) {
    let cmd = raw.replacingOccurrences(of: "--", with: "")
    panel?.orderFront(nil) // always show window on any command
    switch cmd {
    case "autostart": pausedElapsed = nil; transitionTo(.work)
    case "break":     pausedElapsed = nil; transitionTo(.brk)
    case "stop":      pausedElapsed = nil; SoundPrefs.shared.play("manualStop"); transitionTo(.idle)
    case "settings":  openSettings()
    case "show":      break   // panel?.orderFront already called above
    default: break
    }
}

// ── PID ──

func writePID() {
    try? "\(ProcessInfo.processInfo.processIdentifier)".write(
        toFile: kPidPath, atomically: true, encoding: .utf8)
}
func removePID() {
    try? FileManager.default.removeItem(atPath: kPidPath)
}

}

// ═══════════════════════════════════════════ // MARK: - Entry Point // ═══════════════════════════════════════════

let args = CommandLine.arguments let cmd = args.count > 1 ? args[1] : nil

if let raw = try? String(contentsOfFile: kPidPath, encoding: .utf8), let pid = Int32(raw.trimmingCharacters(in: .whitespacesAndNewlines)), kill(pid, 0) == 0 { let ipcCmd = cmd?.replacingOccurrences(of: "--", with: "") ?? "show" DistributedNotificationCenter.default().postNotificationName( Notification.Name(kNotification), object: ipcCmd, userInfo: nil, deliverImmediately: true ) exit(0) }

let app = NSApplication.shared let delegate = TickMatoDelegate() delegate.initialCmd = cmd app.delegate = delegate app.run()

Discussion

Sign in to join the discussion.
No comments yet. Be the first to share your thoughts.

Related Assets