{
  "format" : 1,
  "manifest" : {
    "capabilities" : [

    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Pomodoro",
    "id" : "io.navbytes.stackbar.plugins.pomodoro",
    "minimumStackbar" : "0.12.0",
    "name" : "Pomodoro",
    "summary" : "A focus timer with presets, a progress ring, tags and today's sessions.",
    "surfaces" : [
      "menuBar",
      "panel",
      "settings",
      "widget"
    ],
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 1
        }
      },
      {
        "wake" : {

        }
      }
    ],
    "version" : "1.0.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "OkQ7UFViwJcV1O/vn8pg0tYe1eVZj12+y2lx+r1qA99E0pT1gAW7GIWCKWopvL3/JZb0nW0+VLdyjTbe3Ou8Ag==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// A focus timer with its own warm look: Focus and Break modes, presets, a\n/// big countdown inside a progress ring, a tag for what you are working on,\n/// and today's sessions. Stackbar counts the timer and animates the ring, so\n/// the plugin only renders when something changes. Needs nothing.\n@main\nfinal class Pomodoro: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.pomodoro\",\n        name: \"Pomodoro\",\n        version: \"1.0.0\",\n        summary: \"A focus timer with presets, a progress ring, tags and today's sessions.\",\n        surfaces: [.menuBar, .panel, .settings, .widget],\n        triggers: [.launch, .every(.seconds(1)), .wake],\n        minimumStackbar: \"0.12.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/Pomodoro\"\n    )\n\n    enum Mode: String, Codable, CaseIterable {\n        case focus = \"Focus\", rest = \"Break\"\n\n        var symbol: String { self == .focus ? \"flame.fill\" : \"cup.and.saucer.fill\" }\n    }\n\n    /// A running session, or a paused one with time left.\n    struct Session: Codable, Equatable {\n        var mode: Mode\n        var minutes: Int\n        var tag: String\n        var started: Date?\n        var remaining: TimeInterval\n\n        var end: Date? { started.map { $0.addingTimeInterval(remaining) } }\n\n        /// The whole session as if it had never paused: the same on every\n        /// render, so Stackbar counts it and nothing is resent each second.\n        var range: TimeRange? {\n            guard let started, let end else { return nil }\n            return TimeRange(start: started.addingTimeInterval(remaining - Double(minutes * 60)), end: end)\n        }\n    }\n\n    struct Day: Codable, Equatable {\n        var sessions = 0\n        var minutes = 0\n    }\n\n    // The look. Every color has a dark variant.\n    let cream = Paint.hex(\"#F7E7D9\", dark: \"#2B221C\")\n    let card = Paint.hex(\"#FFF6EE\", dark: \"#3A2E26\")\n    let orange = Paint.hex(\"#F28C38\", dark: \"#F59A4E\")\n    let ink = Paint.hex(\"#1C1A19\", dark: \"#F7EDE4\")\n    /// Text and symbols on orange stay dark in both modes, for contrast.\n    let onOrange = Paint.hex(\"#1C1A19\")\n    let muted = Paint.hex(\"#6E625A\", dark: \"#C9B9AC\")\n    let track = Paint.hex(\"#1C1A1914\", dark: \"#F7EDE41F\")\n\n    @Stored(\"mode\") var mode = Mode.focus\n    @Stored(\"focusMinutes\") var focusMinutes = 25\n    @Stored(\"breakMinutes\") var breakMinutes = 5\n    @Stored(\"session\") var session: Session? = nil\n    @Stored(\"tags\") var tags = [\"Deep Work\", \"Email\", \"Meetings\", \"Learning\"]\n    @Stored(\"tag\") var tag = \"Deep Work\"\n    @Stored(\"autoStart\") var autoStart = false\n    @Stored(\"sound\") var sound = true\n    @Stored(\"chime\") var chime = SystemSound.glass.rawValue\n    @Stored(\"days\") var days: [String: Day] = [:]\n\n    var now = Date()\n\n    func refresh() async throws {\n        now = Date()\n        if let session, let end = session.end, now >= end { finish(session) }\n    }\n\n    // MARK: - Surfaces\n\n    var menuBar: ViewContent {\n        if let session, let range = session.range {\n            HStack(spacing: 4) {\n                Image(systemName: session.mode.symbol)\n                TimerText(range)\n            }\n        } else {\n            Label(session == nil ? \"\" : clock(session?.remaining ?? 0), symbol: \"timer\")\n        }\n    }\n\n    var panel: ViewContent {\n        VStack(alignment: .center, spacing: 14) {\n            modeToggle\n            presets\n            Text(mode.rawValue).font(size: 13, weight: .semibold).foreground(muted)\n            countdown\n            ring\n            tagChip\n            stats\n            controls\n        }\n        .padding(18)\n        .frame(fillWidth: true)\n        .background(cream, in: .roundedRectangle(cornerRadius: 18))\n    }\n\n    var widget: ViewContent {\n        HStack(spacing: 16) {\n            ZStack {\n                progress(lineWidth: 7)\n                Image(systemName: mode.symbol).font(size: 24).foreground(orange)\n            }\n            .frame(width: 84, height: 84)\n            VStack(alignment: .start, spacing: 2) {\n                Text(\"\\(mode.rawValue) · \\(currentTag)\").font(size: 13, weight: .semibold).foreground(muted)\n                timeText.font(size: 44, weight: .heavy, design: .rounded, monospacedDigit: true).foreground(ink)\n            }\n        }\n        .frame(fillWidth: true, fillHeight: true)\n        .background(cream, in: .roundedRectangle(cornerRadius: 18))\n    }\n\n    var settings: ViewContent {\n        Section(\"Durations\") {\n            Slider(\"Focus minutes\", value: Double(focusMinutes), in: 5...120, step: 5) { self.focusMinutes = Int($0) }\n            Slider(\"Break minutes\", value: Double(breakMinutes), in: 1...30, step: 1) { self.breakMinutes = Int($0) }\n            Toggle(\"Start the next session on its own\", isOn: autoStart) { self.autoStart = $0 }\n        }\n        Section(\"Sound\") {\n            Toggle(\"Play a sound when a session ends\", isOn: sound) { self.sound = $0 }\n            Picker(\"Sound\", options: SystemSound.allCases.map(\\.rawValue), selection: chime) { self.chime = $0 }\n        }\n        Section(\"Tags\") {\n            ForEach(tags) { name in\n                Row(name, symbol: \"tag\", key: \"tag-\\(name)\").action(\"Remove\") { self.tags.removeAll { $0 == name } }\n            }\n            TextField(\"Add a tag\", text: \"\") { submitted in\n                let name = submitted.trimmingCharacters(in: .whitespaces)\n                if !name.isEmpty, !self.tags.contains(name) { self.tags.append(name) }\n            }\n        }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"status\", description: \"The timer's mode, time left and today's focus\") { _ in\n            let today = self.today\n            let state = self.session.map { \"\\($0.mode.rawValue) on \\($0.tag), \\(self.clock(self.left($0))) left\\($0.started == nil ? \" (paused)\" : \"\")\" } ?? \"Idle\"\n            return \"\\(state). Today: \\(today.sessions) sessions, \\(today.minutes) minutes focused.\"\n        }\n    }\n\n    // MARK: - Panel parts\n\n    @ViewBuilder private var modeToggle: ViewContent {\n        HStack(spacing: 4) {\n            ForEach(Mode.allCases) { option in\n                Button(key: \"mode-\\(option.rawValue)\") { self.select(option) } label: {\n                    HStack(spacing: 6) {\n                        Image(systemName: option.symbol)\n                        Text(option.rawValue)\n                    }\n                    .font(size: 14, weight: .semibold)\n                    .foreground(option == mode ? onOrange : muted)\n                    .frame(fillWidth: true)\n                    .padding(vertical: 9)\n                    .background(option == mode ? orange : .hex(\"#00000000\"), in: .capsule)\n                }\n            }\n        }\n        .padding(4)\n        .background(card, in: .capsule)\n        .border(ink, width: 2, in: .capsule)\n    }\n\n    @ViewBuilder private var presets: ViewContent {\n        HStack(spacing: 10) {\n            ForEach(presetMinutes) { minutes in\n                Button(key: \"preset-\\(minutes)\") { self.setMinutes(minutes) } label: {\n                    Text(\"\\(minutes)\")\n                        .font(size: 15, weight: .bold, monospacedDigit: true)\n                        .foreground(minutes == currentMinutes ? onOrange : ink)\n                        .frame(width: 52, height: 34)\n                        .background(minutes == currentMinutes ? orange : card, in: .roundedRectangle(cornerRadius: 10))\n                        .border(ink, width: 2, in: .roundedRectangle(cornerRadius: 10))\n                }\n            }\n        }\n    }\n\n    @ViewBuilder private var countdown: ViewContent {\n        timeText.font(size: 64, weight: .heavy, design: .rounded, monospacedDigit: true).foreground(ink)\n    }\n\n    @ViewBuilder private var ring: ViewContent {\n        ZStack {\n            progress(lineWidth: 6)\n            Button(key: \"start\") { self.toggle() } label: {\n                Image(systemName: isRunning ? \"pause.fill\" : \"play.fill\")\n                    .font(size: 26, weight: .bold)\n                    .foreground(onOrange)\n                    .frame(width: 76, height: 76)\n                    .background(orange, in: .circle)\n                    .border(ink, width: 2, in: .circle)\n                    .shadow(radius: 0, x: 2, y: 3, color: HexColor(\"#1C1A19\", dark: \"#000000\"))\n            }\n            .accessibilityLabel(isRunning ? \"Pause\" : \"Start\")\n        }\n        .frame(width: 128, height: 128)\n    }\n\n    @ViewBuilder private var tagChip: ViewContent {\n        Button(key: \"tag\") { self.nextTag() } label: {\n            HStack(spacing: 6) {\n                Circle().fill(orange).frame(width: 8, height: 8)\n                Text(currentTag)\n            }\n            .font(size: 13, weight: .semibold)\n            .foreground(ink)\n            .padding(horizontal: 12, vertical: 5)\n            .background(card, in: .capsule)\n            .border(ink, width: 1.5, in: .capsule)\n        }\n    }\n\n    @ViewBuilder private var stats: ViewContent {\n        HStack(spacing: 0) {\n            stat(\"\\(today.sessions)\", \"sessions\", symbol: \"checkmark.circle.fill\")\n            Rectangle().fill(track).frame(width: 1, height: 30)\n            stat(minutesText(today.minutes), \"focused\", symbol: \"clock.fill\")\n        }\n        .padding(vertical: 10)\n        .frame(width: 240)\n        .background(card, in: .roundedRectangle(cornerRadius: 14))\n    }\n\n    @ViewBuilder private func stat(_ value: String, _ caption: String, symbol: String) -> ViewContent {\n        HStack(spacing: 8) {\n            Image(systemName: symbol).font(size: 15).foreground(muted)\n            VStack(spacing: 0) {\n                Text(value).font(size: 16, weight: .bold).foreground(ink)\n                Text(caption).font(size: 11).foreground(muted)\n            }\n        }\n        .frame(fillWidth: true)\n    }\n\n    @ViewBuilder private var controls: ViewContent {\n        HStack(spacing: 14) {\n            iconButton(\"forward.fill\", label: \"Skip\", key: \"skip\") { self.skip() }\n            iconButton(\"repeat\", label: autoStart ? \"Auto-start on\" : \"Auto-start off\", key: \"auto\", filled: autoStart) { self.autoStart.toggle() }\n            iconButton(sound ? \"music.note\" : \"speaker.slash.fill\", label: sound ? \"Sound on\" : \"Sound off\", key: \"sound\", filled: sound) { self.sound.toggle() }\n            iconButton(\"arrow.counterclockwise\", label: \"Reset\", key: \"reset\") { self.reset() }\n        }\n    }\n\n    @ViewBuilder private func iconButton(_ symbol: String, label: String, key: String, filled: Bool = false, action: @escaping @MainActor () async throws -> Void) -> ViewContent {\n        Button(key: key, action: action) {\n            Image(systemName: symbol)\n                .font(size: 15, weight: .bold)\n                .foreground(filled ? onOrange : ink)\n                .frame(width: 40, height: 40)\n                .background(filled ? orange : card, in: .circle)\n                .border(ink, width: 2, in: .circle)\n        }\n        .accessibilityLabel(label)\n    }\n\n    @ViewBuilder private var timeText: ViewContent {\n        if let range = session?.range {\n            TimerText(range)\n        } else {\n            Text(clock(session?.remaining ?? Double(currentMinutes * 60)))\n        }\n    }\n\n    @ViewBuilder private func progress(lineWidth: Double) -> ViewContent {\n        if let range = session?.range {\n            ProgressBar(timing: range, style: .ring(lineWidth: lineWidth)).foreground(orange)\n        } else {\n            let total = Double(currentMinutes * 60)\n            ProgressBar(value: total - (session?.remaining ?? total), total: total, style: .ring(lineWidth: lineWidth)).foreground(orange)\n        }\n    }\n\n    // MARK: - State\n\n    private var isRunning: Bool { session?.started != nil }\n    private var currentMinutes: Int { session?.minutes ?? (mode == .focus ? focusMinutes : breakMinutes) }\n    private var currentTag: String { session?.tag ?? tag }\n    private var presetMinutes: [Int] { mode == .focus ? [25, 50, 90] : [5, 10, 15] }\n\n    private var dayKey: String {\n        let parts = Calendar.current.dateComponents([.year, .month, .day], from: now)\n        return String(format: \"%04d-%02d-%02d\", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)\n    }\n\n    private var today: Day { days[dayKey] ?? Day() }\n\n    private func left(_ session: Session) -> TimeInterval {\n        session.end.map { max(0, $0.timeIntervalSince(now)) } ?? session.remaining\n    }\n\n    private func toggle() {\n        now = Date()\n        if var session {\n            if session.started != nil {\n                session.remaining = left(session)\n                session.started = nil\n            } else {\n                session.started = now\n            }\n            self.session = session\n        } else {\n            session = Session(mode: mode, minutes: currentMinutes, tag: tag, started: now, remaining: Double(currentMinutes * 60))\n        }\n    }\n\n    private func select(_ option: Mode) {\n        guard option != mode else { return }\n        mode = option\n        session = nil\n    }\n\n    private func setMinutes(_ minutes: Int) {\n        if mode == .focus { focusMinutes = minutes } else { breakMinutes = minutes }\n        session = nil\n    }\n\n    private func nextTag() {\n        guard !tags.isEmpty else { return }\n        let index = tags.firstIndex(of: currentTag).map { ($0 + 1) % tags.count } ?? 0\n        tag = tags[index]\n        session?.tag = tag\n    }\n\n    private func skip() {\n        if let session { finish(session, counted: false) } else { select(mode == .focus ? .rest : .focus) }\n    }\n\n    private func reset() {\n        session = nil\n    }\n\n    /// Ends a session: counts a finished focus session, chimes, and moves to\n    /// the other mode, starting it when auto-start is on.\n    private func finish(_ ended: Session, counted: Bool = true) {\n        if counted && ended.mode == .focus {\n            var day = today\n            day.sessions += 1\n            day.minutes += ended.minutes\n            days[dayKey] = day\n            for old in days.keys.sorted().dropLast(60) { days[old] = nil }\n        }\n        if counted && sound, let chime = SystemSound(rawValue: chime) { Stackbar.play(chime) }\n        if counted { Stackbar.notify(ended.mode == .focus ? \"Focus done\" : \"Break over\", body: ended.mode == .focus ? \"\\(ended.minutes) minutes on \\(ended.tag). Time for a break.\" : \"Back to it.\") }\n        mode = ended.mode == .focus ? .rest : .focus\n        session = nil\n        if autoStart { toggle() }\n    }\n\n    private func clock(_ seconds: TimeInterval) -> String {\n        let total = Int(seconds.rounded(.up))\n        return String(format: \"%d:%02d\", total / 60, total % 60)\n    }\n\n    private func minutesText(_ minutes: Int) -> String {\n        minutes < 60 ? \"\\(minutes)m\" : \"\\(minutes / 60)h \\(minutes % 60)m\"\n    }\n}\n",
  "sourceDigest" : "8cfef3bff6358e315b810ead0a88b496845195bc40bfdca4484f9e56766a51ce"
}