Cette page est affichée en anglais. Une traduction française est en cours.
ScriptsApr 6, 2026·1 min de lecture

TickMato

macOS

Prêt pour agents

Staging sûr pour cet actif

Cet actif est d'abord staged. Le prompt copié demande à l'agent d'inspecter les fichiers staged avant d'activer scripts, config MCP ou config globale.

Stage only · 17/100Policy : staging
Surface agent
Tout agent MCP/CLI
Type
Script
Installation
Stage only
Confiance
Confiance : Established
Point d'entrée
pomodoro-sync-daemon.sh
Commande de staging sûr
npx -y tokrepo@latest install aee7e527-411c-4731-b070-6175e6bea194 --target codex

Stage les fichiers d'abord; l'activation exige la revue du README et du plan staged.

#!/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()

Fil de discussion

Connectez-vous pour rejoindre la discussion.
Aucun commentaire pour l'instant. Soyez le premier à partager votre avis.