diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..9137441
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,46 @@
+name: Build and Release
+
+on:
+ push:
+ branches: [main]
+
+jobs:
+ build:
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build Release
+ run: |
+ xcodebuild -scheme Barik -configuration Release \
+ -derivedDataPath build \
+ CODE_SIGNING_ALLOWED=NO
+
+ - name: Ad-hoc sign app
+ run: |
+ codesign --force --deep --sign - \
+ --entitlements Barik/Barik.entitlements \
+ --options runtime \
+ build/Build/Products/Release/Barik.app
+
+ - name: Zip app
+ run: ditto -c -k --keepParent build/Build/Products/Release/Barik.app Barik.zip
+
+ - name: Get version
+ id: version
+ run: |
+ VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" build/Build/Products/Release/Barik.app/Contents/Info.plist)
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ - name: Update latest release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release delete latest --yes --cleanup-tag 2>/dev/null || true
+ gh release create latest Barik.zip \
+ --title "Latest Build (v${{ steps.version.outputs.version }})" \
+ --notes "Automated build from \`main\` branch ($(date -u +%Y-%m-%d)).
+
+ Built from commit ${{ github.sha }}." \
+ --target ${{ github.sha }} \
+ --prerelease
diff --git a/Barik/AppDelegate.swift b/Barik/AppDelegate.swift
index c11da02..94a1f37 100644
--- a/Barik/AppDelegate.swift
+++ b/Barik/AppDelegate.swift
@@ -3,6 +3,8 @@ import SwiftUI
final class AppDelegate: NSObject, NSApplicationDelegate {
private var backgroundPanel: NSPanel?
private var menuBarPanel: NSPanel?
+ private let contextMenu = MenuBarContextMenu()
+ private var eventMonitor: Any?
func applicationDidFinishLaunching(_ notification: Notification) {
if let error = ConfigManager.shared.initError {
@@ -21,6 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
MenuBarPopup.setup()
setupPanels()
+ setupContextMenuMonitor()
NotificationCenter.default.addObserver(
self,
@@ -72,6 +75,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
panel = newPanel
}
+ private func setupContextMenuMonitor() {
+ eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) {
+ [weak self] event in
+ guard let self,
+ let panel = self.menuBarPanel,
+ event.window === panel,
+ let contentView = panel.contentView
+ else {
+ return event
+ }
+ let locationInView = contentView.convert(event.locationInWindow, from: nil)
+ self.contextMenu.popUp(
+ positioning: nil, at: locationInView, in: contentView)
+ return nil
+ }
+ }
+
private func showFatalConfigError(message: String) {
let alert = NSAlert()
alert.messageText = "Configuration Error"
diff --git a/Barik/Barik.entitlements b/Barik/Barik.entitlements
index e45d024..769aec2 100644
--- a/Barik/Barik.entitlements
+++ b/Barik/Barik.entitlements
@@ -8,5 +8,7 @@
com.apple.security.personal-information.location
+ com.apple.security.network.client
+
diff --git a/Barik/Config/ConfigManager.swift b/Barik/Config/ConfigManager.swift
index 70af4d6..d7648f6 100644
--- a/Barik/Config/ConfigManager.swift
+++ b/Barik/Config/ConfigManager.swift
@@ -11,6 +11,7 @@ final class ConfigManager: ObservableObject {
private var fileWatchSource: DispatchSourceFileSystemObject?
private var fileDescriptor: CInt = -1
private var configFilePath: String?
+ private var suppressNextReload = false
private init() {
loadOrCreateConfigIfNeeded()
@@ -72,8 +73,11 @@ final class ConfigManager: ObservableObject {
displayed = [ # widgets on menu bar
"default.spaces",
"spacer",
+ "default.claude-usage",
+ "default.nowplaying",
"default.network",
"default.battery",
+ "default.countdown",
"divider",
# { "default.time" = { time-zone = "America/Los_Angeles", format = "E d, hh:mm" } },
"default.time"
@@ -84,6 +88,11 @@ final class ConfigManager: ObservableObject {
window.show-title = true
window.title.max-length = 50
+ [widgets.default.claude-usage]
+ plan = "pro"
+ five-hour-limit = 80
+ weekly-limit = 500
+
[widgets.default.battery]
show-percentage = true
warning-level = 30
@@ -116,6 +125,10 @@ final class ConfigManager: ObservableObject {
guard let self = self, let path = self.configFilePath else {
return
}
+ if self.suppressNextReload {
+ self.suppressNextReload = false
+ return
+ }
self.parseConfigFile(at: path)
}
fileWatchSource?.setCancelHandler { [weak self] in
@@ -134,7 +147,26 @@ final class ConfigManager: ObservableObject {
do {
let currentText = try String(contentsOfFile: path, encoding: .utf8)
let updatedText = updatedTOMLString(
- original: currentText, key: key, newValue: newValue)
+ original: currentText, key: key, newValue: newValue, quoteValue: true)
+ try updatedText.write(
+ toFile: path, atomically: false, encoding: .utf8)
+ DispatchQueue.main.async {
+ self.parseConfigFile(at: path)
+ }
+ } catch {
+ print("Error updating config:", error)
+ }
+ }
+
+ func updateConfigValueRaw(key: String, newValue: String) {
+ guard let path = configFilePath else {
+ print("Config file path is not set")
+ return
+ }
+ do {
+ let currentText = try String(contentsOfFile: path, encoding: .utf8)
+ let updatedText = updatedTOMLString(
+ original: currentText, key: key, newValue: newValue, quoteValue: false)
try updatedText.write(
toFile: path, atomically: false, encoding: .utf8)
DispatchQueue.main.async {
@@ -146,8 +178,9 @@ final class ConfigManager: ObservableObject {
}
private func updatedTOMLString(
- original: String, key: String, newValue: String
+ original: String, key: String, newValue: String, quoteValue: Bool = true
) -> String {
+ let formattedValue = quoteValue ? "\"\(newValue)\"" : newValue
if key.contains(".") {
let components = key.split(separator: ".").map(String.init)
guard components.count >= 2 else {
@@ -168,7 +201,7 @@ final class ConfigManager: ObservableObject {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") {
if insideTargetTable && !updatedKey {
- newLines.append("\(actualKey) = \"\(newValue)\"")
+ newLines.append("\(actualKey) = \(formattedValue)")
updatedKey = true
}
if trimmed == tableHeader {
@@ -185,7 +218,7 @@ final class ConfigManager: ObservableObject {
if line.range(of: pattern, options: .regularExpression)
!= nil
{
- newLines.append("\(actualKey) = \"\(newValue)\"")
+ newLines.append("\(actualKey) = \(formattedValue)")
updatedKey = true
continue
}
@@ -195,13 +228,13 @@ final class ConfigManager: ObservableObject {
}
if foundTable && insideTargetTable && !updatedKey {
- newLines.append("\(actualKey) = \"\(newValue)\"")
+ newLines.append("\(actualKey) = \(formattedValue)")
}
if !foundTable {
newLines.append("")
newLines.append("[\(tablePath)]")
- newLines.append("\(actualKey) = \"\(newValue)\"")
+ newLines.append("\(actualKey) = \(formattedValue)")
}
return newLines.joined(separator: "\n")
} else {
@@ -217,7 +250,7 @@ final class ConfigManager: ObservableObject {
if line.range(of: pattern, options: .regularExpression)
!= nil
{
- newLines.append("\(key) = \"\(newValue)\"")
+ newLines.append("\(key) = \(formattedValue)")
updatedAtLeastOnce = true
continue
}
@@ -225,12 +258,100 @@ final class ConfigManager: ObservableObject {
newLines.append(line)
}
if !updatedAtLeastOnce {
- newLines.append("\(key) = \"\(newValue)\"")
+ newLines.append("\(key) = \(formattedValue)")
}
return newLines.joined(separator: "\n")
}
}
+ func updateDisplayedWidgets(_ items: [TomlWidgetItem]) {
+ guard let path = configFilePath else {
+ print("Config file path is not set")
+ return
+ }
+ do {
+ let currentText = try String(contentsOfFile: path, encoding: .utf8)
+ let updatedText = replaceDisplayedArray(in: currentText, with: items)
+ suppressNextReload = true
+ try updatedText.write(toFile: path, atomically: true, encoding: .utf8)
+ DispatchQueue.main.async {
+ self.parseConfigFile(at: path)
+ }
+ } catch {
+ suppressNextReload = false
+ print("Error updating displayed widgets:", error)
+ }
+ }
+
+ private func replaceDisplayedArray(in original: String, with items: [TomlWidgetItem]) -> String {
+ let lines = original.components(separatedBy: "\n")
+ var inWidgetsSection = false
+ var arrayStartLine: Int?
+ var arrayEndLine: Int?
+ var bracketDepth = 0
+ var foundStart = false
+
+ for (lineIndex, line) in lines.enumerated() {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+
+ if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") {
+ inWidgetsSection = (trimmed == "[widgets]")
+ if foundStart && !inWidgetsSection {
+ break
+ }
+ continue
+ }
+
+ if inWidgetsSection && !foundStart {
+ if trimmed.hasPrefix("displayed") && trimmed.contains("=") {
+ arrayStartLine = lineIndex
+ foundStart = true
+ for char in trimmed {
+ if char == Character("[") { bracketDepth += 1 }
+ if char == Character("]") { bracketDepth -= 1 }
+ }
+ if bracketDepth == 0 {
+ arrayEndLine = lineIndex
+ break
+ }
+ }
+ } else if foundStart && arrayEndLine == nil {
+ for char in trimmed {
+ if char == Character("[") { bracketDepth += 1 }
+ if char == Character("]") { bracketDepth -= 1 }
+ }
+ if bracketDepth == 0 {
+ arrayEndLine = lineIndex
+ break
+ }
+ }
+ }
+
+ guard let start = arrayStartLine, let end = arrayEndLine else {
+ return original
+ }
+
+ let newArrayLines = "displayed = " + items.toTomlDisplayedArray()
+
+ var newLines = Array(lines[0.. ConfigData {
config.rootToml.widgets.config(for: widgetId) ?? [:]
}
diff --git a/Barik/Config/ConfigModels.swift b/Barik/Config/ConfigModels.swift
index d5f16a1..e66ea38 100644
--- a/Barik/Config/ConfigModels.swift
+++ b/Barik/Config/ConfigModels.swift
@@ -5,6 +5,7 @@ struct RootToml: Decodable {
var theme: String?
var yabai: YabaiConfig?
var aerospace: AerospaceConfig?
+ var keybinds: KeybindsConfig?
var experimental: ExperimentalConfig?
var widgets: WidgetsSection
@@ -12,6 +13,7 @@ struct RootToml: Decodable {
self.theme = nil
self.yabai = nil
self.aerospace = nil
+ self.keybinds = nil
self.widgets = WidgetsSection(displayed: [], others: [:])
}
}
@@ -35,6 +37,10 @@ struct Config {
rootToml.aerospace ?? AerospaceConfig()
}
+ var keybinds: KeybindsConfig {
+ rootToml.keybinds ?? KeybindsConfig()
+ }
+
var experimental: ExperimentalConfig {
rootToml.experimental ?? ExperimentalConfig()
}
@@ -113,16 +119,19 @@ struct WidgetsSection: Decodable {
}
}
-struct TomlWidgetItem: Decodable {
+struct TomlWidgetItem: Decodable, Equatable, Hashable {
+ let instanceID: UUID
let id: String
let inlineParams: ConfigData
init(id: String, inlineParams: ConfigData) {
+ self.instanceID = UUID()
self.id = id
self.inlineParams = inlineParams
}
init(from decoder: Decoder) throws {
+ self.instanceID = UUID()
let container = try decoder.singleValueContainer()
if let strValue = try? container.decode(String.self) {
@@ -148,9 +157,34 @@ struct TomlWidgetItem: Decodable {
self.id = widgetId
self.inlineParams = params
}
+
+ static func == (lhs: TomlWidgetItem, rhs: TomlWidgetItem) -> Bool {
+ lhs.instanceID == rhs.instanceID
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(instanceID)
+ }
+
+ func toTomlString() -> String {
+ if inlineParams.isEmpty {
+ return "\"\(id)\""
+ }
+ let paramsString = inlineParams.map { key, value in
+ "\(key) = \(value.toTomlValueString())"
+ }.joined(separator: ", ")
+ return "{ \"\(id)\" = { \(paramsString) } }"
+ }
+}
+
+extension Array where Element == TomlWidgetItem {
+ func toTomlDisplayedArray() -> String {
+ let items = self.map { " \($0.toTomlString())" }.joined(separator: ",\n")
+ return "[\n\(items)\n]"
+ }
}
-enum TOMLValue: Decodable {
+enum TOMLValue: Decodable, Equatable, Hashable {
case string(String)
case bool(Bool)
case int(Int)
@@ -216,6 +250,22 @@ extension TOMLValue {
if case let .dictionary(dict) = self { return dict }
return nil
}
+
+ func toTomlValueString() -> String {
+ switch self {
+ case .string(let s): return "\"\(s)\""
+ case .bool(let b): return b ? "true" : "false"
+ case .int(let i): return "\(i)"
+ case .double(let d): return "\(d)"
+ case .array(let arr):
+ let inner = arr.map { $0.toTomlValueString() }.joined(separator: ", ")
+ return "[\(inner)]"
+ case .dictionary(let dict):
+ let inner = dict.map { "\($0.key) = \($0.value.toTomlValueString())" }.joined(separator: ", ")
+ return "{ \(inner) }"
+ case .null: return "\"\""
+ }
+ }
}
struct YabaiConfig: Decodable {
@@ -246,6 +296,62 @@ struct AerospaceConfig: Decodable {
}
}
+
+struct KeybindsConfig: Decodable {
+ let notifications: NotificationConfig
+
+ enum CodingKeys: String, CodingKey {
+ case notifications
+ }
+
+ init() {
+ self.notifications = NotificationConfig()
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ notifications = try container.decodeIfPresent(NotificationConfig.self, forKey: .notifications) ?? NotificationConfig()
+ }
+}
+
+struct NotificationConfig: Decodable {
+ let keyCode: CGKeyCode
+ var flags: CGEventFlags
+
+ init() {
+ keyCode = 45 // 'n' key
+ flags = [.maskCommand, .maskAlternate] // Cmd and Opt
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ keyCode = try container.decodeIfPresent(UInt16.self, forKey: .keyCode) ?? 45
+ flags = []
+
+ let flagStrings = try container.decodeIfPresent(Array.self, forKey: .flags) ?? ["ctrl", "opt"]
+ for flag in flagStrings {
+
+ switch flag {
+ case "cmd":
+ flags.insert(.maskCommand)
+ case "opt":
+ flags.insert(.maskAlternate)
+ case "ctrl":
+ flags.insert(.maskControl)
+ case "shift":
+ flags.insert(.maskShift)
+ default:
+ print("No flag match for: \(flag)")
+ }
+ }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case keyCode, flags
+ }
+}
+
struct ExperimentalConfig: Decodable {
let foreground: ForegroundConfig
let background: BackgroundConfig
diff --git a/Barik/Helpers/SystemUIHelper.swift b/Barik/Helpers/SystemUIHelper.swift
new file mode 100644
index 0000000..acd5e01
--- /dev/null
+++ b/Barik/Helpers/SystemUIHelper.swift
@@ -0,0 +1,77 @@
+import AppKit
+import Foundation
+
+/// Helper for triggering macOS system UI elements
+final class SystemUIHelper {
+ /// Opens the macOS Notification Center by simulating the configured keypress
+ static func openNotificationCenter() {
+ // Check for accessibility permissions first
+ guard checkAccessibilityPermissions() else {
+ print("Accessibility permissions not granted")
+ return
+ }
+
+ let keyCode: CGKeyCode = ConfigManager.shared.config.keybinds.notifications.keyCode
+ let flags: CGEventFlags = ConfigManager.shared.config.keybinds.notifications.flags
+
+ // Create and post key down event
+ if let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true) {
+ keyDown.flags = flags
+ keyDown.post(tap: .cghidEventTap)
+ }
+
+ // Create and post key up event
+ if let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) {
+ keyUp.flags = flags
+ keyUp.post(tap: .cghidEventTap)
+ }
+ }
+
+ /// Opens the macOS Weather menu bar dropdown
+ static func openWeatherDropdown() {
+ let script = """
+ tell application "System Events"
+ tell process "ControlCenter"
+ try
+ click menu bar item "Weather" of menu bar 1
+ on error
+ -- Weather might not be in menu bar, try to open Weather app instead
+ tell application "Weather" to activate
+ end try
+ end tell
+ end tell
+ """
+ runAppleScript(script)
+ }
+
+ /// Opens the Weather app
+ static func openWeatherApp() {
+ NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Weather")!)
+ // Fallback to opening Weather app directly
+ if let weatherURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.weather") {
+ NSWorkspace.shared.open(weatherURL)
+ }
+ }
+
+ /// Runs an AppleScript
+ @discardableResult
+ private static func runAppleScript(_ script: String) -> String? {
+ guard let appleScript = NSAppleScript(source: script) else {
+ return nil
+ }
+ var error: NSDictionary?
+ let result = appleScript.executeAndReturnError(&error)
+ if let error = error {
+ print("AppleScript Error: \(error)")
+ return nil
+ }
+ return result.stringValue
+ }
+
+ /// Quick way to check for accessibility permissions
+ static func checkAccessibilityPermissions() -> Bool {
+ let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
+ let accessEnabled = AXIsProcessTrustedWithOptions(options)
+ return accessEnabled
+ }
+}
diff --git a/Barik/Info.plist b/Barik/Info.plist
index 0c67376..08763ad 100644
--- a/Barik/Info.plist
+++ b/Barik/Info.plist
@@ -1,5 +1,10 @@
-
+
+ NSLocationUsageDescription
+ Barik needs your location to show local weather conditions.
+ NSLocationWhenInUseUsageDescription
+ Barik needs your location to show local weather conditions.
+
diff --git a/Barik/MenuBarPopup/MenuBarPopup.swift b/Barik/MenuBarPopup/MenuBarPopup.swift
index ef7a111..6a8d7c3 100644
--- a/Barik/MenuBarPopup/MenuBarPopup.swift
+++ b/Barik/MenuBarPopup/MenuBarPopup.swift
@@ -3,7 +3,7 @@ import SwiftUI
private var panel: NSPanel?
class HidingPanel: NSPanel, NSWindowDelegate {
- var hideTimer: Timer?
+ var hideWorkItem: DispatchWorkItem?
override var canBecomeKey: Bool {
return true
@@ -23,13 +23,12 @@ class HidingPanel: NSPanel, NSWindowDelegate {
func windowDidResignKey(_ notification: Notification) {
NotificationCenter.default.post(name: .willHideWindow, object: nil)
- hideTimer = Timer.scheduledTimer(
- withTimeInterval: TimeInterval(
- Constants.menuBarPopupAnimationDurationInMilliseconds) / 1000.0,
- repeats: false
- ) { [weak self] _ in
+ let workItem = DispatchWorkItem { [weak self] in
self?.orderOut(nil)
}
+ hideWorkItem = workItem
+ let duration = Double(Constants.menuBarPopupAnimationDurationInMilliseconds) / 1000.0
+ DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem)
}
}
@@ -59,8 +58,8 @@ class MenuBarPopup {
lastContentIdentifier = id
if let hidingPanel = panel as? HidingPanel {
- hidingPanel.hideTimer?.invalidate()
- hidingPanel.hideTimer = nil
+ hidingPanel.hideWorkItem?.cancel()
+ hidingPanel.hideWorkItem = nil
}
if panel.isKeyWindow {
@@ -73,13 +72,10 @@ class MenuBarPopup {
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
panel.contentView = NSHostingView(
rootView:
- ZStack {
- MenuBarPopupView {
- content()
- }
- .position(x: rect.midX)
+ MenuBarPopupView(widgetRect: rect) {
+ content()
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.id(UUID())
)
panel.makeKeyAndOrderFront(nil)
@@ -91,13 +87,10 @@ class MenuBarPopup {
} else {
panel.contentView = NSHostingView(
rootView:
- ZStack {
- MenuBarPopupView {
- content()
- }
- .position(x: rect.midX)
+ MenuBarPopupView(widgetRect: rect) {
+ content()
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
)
panel.makeKeyAndOrderFront(nil)
DispatchQueue.main.async {
@@ -108,13 +101,11 @@ class MenuBarPopup {
}
static func setup() {
- guard let screen = NSScreen.main?.visibleFrame else { return }
- let panelFrame = NSRect(
- x: 0,
- y: 0,
- width: screen.size.width,
- height: screen.size.height
- )
+ guard let screen = NSScreen.main else { return }
+
+ // Use full screen frame so the panel covers the entire screen including menu bar area
+ // This ensures consistent positioning regardless of dock position or menu bar configuration
+ let panelFrame = screen.frame
let newPanel = HidingPanel(
contentRect: panelFrame,
diff --git a/Barik/MenuBarPopup/MenuBarPopupVariantView.swift b/Barik/MenuBarPopup/MenuBarPopupVariantView.swift
index 88e0c15..fa22316 100644
--- a/Barik/MenuBarPopup/MenuBarPopupVariantView.swift
+++ b/Barik/MenuBarPopup/MenuBarPopupVariantView.swift
@@ -1,13 +1,14 @@
import SwiftUI
enum MenuBarPopupVariant: String, Equatable {
- case box, vertical, horizontal, settings
+ case box, vertical, horizontal, dayView, settings
}
struct MenuBarPopupVariantView: View {
private let box: AnyView?
private let vertical: AnyView?
private let horizontal: AnyView?
+ private let dayView: AnyView?
private let settings: AnyView?
var selectedVariant: MenuBarPopupVariant
@@ -22,6 +23,7 @@ struct MenuBarPopupVariantView: View {
@ViewBuilder box: () -> some View = { EmptyView() },
@ViewBuilder vertical: () -> some View = { EmptyView() },
@ViewBuilder horizontal: () -> some View = { EmptyView() },
+ @ViewBuilder dayView: () -> some View = { EmptyView() },
@ViewBuilder settings: () -> some View = { EmptyView() }
) {
self.selectedVariant = selectedVariant
@@ -30,6 +32,7 @@ struct MenuBarPopupVariantView: View {
let boxView = box()
let verticalView = vertical()
let horizontalView = horizontal()
+ let dayViewView = dayView()
let settingsView = settings()
self.box = (boxView is EmptyView) ? nil : AnyView(boxView)
@@ -37,6 +40,8 @@ struct MenuBarPopupVariantView: View {
(verticalView is EmptyView) ? nil : AnyView(verticalView)
self.horizontal =
(horizontalView is EmptyView) ? nil : AnyView(horizontalView)
+ self.dayView =
+ (dayViewView is EmptyView) ? nil : AnyView(dayViewView)
self.settings =
(settingsView is EmptyView) ? nil : AnyView(settingsView)
}
@@ -63,6 +68,11 @@ struct MenuBarPopupVariantView: View {
variant: .horizontal,
systemImageName: "rectangle.inset.filled")
}
+ if dayView != nil {
+ variantButton(
+ variant: .dayView,
+ systemImageName: "calendar.day.timeline.left")
+ }
if settings != nil {
variantButton(
variant: .settings, systemImageName: "gearshape.fill")
@@ -89,6 +99,8 @@ struct MenuBarPopupVariantView: View {
if let view = vertical { view }
case .horizontal:
if let view = horizontal { view }
+ case .dayView:
+ if let view = dayView { view }
case .settings:
if let view = settings { view }
}
diff --git a/Barik/MenuBarPopup/MenuBarPopupView.swift b/Barik/MenuBarPopup/MenuBarPopupView.swift
index 8cd889b..81c8f76 100644
--- a/Barik/MenuBarPopup/MenuBarPopupView.swift
+++ b/Barik/MenuBarPopup/MenuBarPopupView.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct MenuBarPopupView: View {
let content: Content
let isPreview: Bool
+ let widgetRect: CGRect
@ObservedObject var configManager = ConfigManager.shared
var foregroundHeight: CGFloat { configManager.config.experimental.foreground.resolveHeight() }
@@ -21,7 +22,8 @@ struct MenuBarPopupView: View {
private let willChangeContent = NotificationCenter.default.publisher(
for: .willChangeContent)
- init(isPreview: Bool = false, @ViewBuilder content: () -> Content) {
+ init(widgetRect: CGRect = .zero, isPreview: Bool = false, @ViewBuilder content: () -> Content) {
+ self.widgetRect = widgetRect
self.content = content()
self.isPreview = isPreview
if isPreview {
@@ -29,16 +31,21 @@ struct MenuBarPopupView: View {
}
}
+ // Position popup directly below the Barik menu bar
+ // foregroundHeight is the exact height of the Barik bar, which overlays the system menu bar
+ var popupTopPosition: CGFloat {
+ return foregroundHeight
+ }
+
var body: some View {
ZStack(alignment: .topTrailing) {
content
.background(Color.black)
.cornerRadius(((1.0 - animationValue) * 1) + 40)
- .padding(.top, foregroundHeight + 5)
- .offset(x: computedOffset, y: computedYOffset)
.shadow(radius: 30)
.blur(radius: (1.0 - (0.1 + 0.9 * animationValue)) * 20)
- .scaleEffect(x: 0.2 + 0.8 * animationValue, y: animationValue)
+ .scaleEffect(x: 0.2 + 0.8 * animationValue, y: animationValue, anchor: .top)
+ .offset(x: computedOffset, y: popupTopPosition)
.opacity(animationValue)
.transaction { transaction in
if isHideAnimation {
@@ -135,23 +142,29 @@ struct MenuBarPopupView: View {
.preferredColorScheme(.dark)
}
+ // Calculate X offset to center popup under widget, with edge constraints
var computedOffset: CGFloat {
let screenWidth = NSScreen.main?.frame.width ?? 0
- let W = viewFrame.width
- let M = viewFrame.midX
- let newLeft = (M - W / 2) - 20
- let newRight = (M + W / 2) + 20
+ let contentWidth = viewFrame.width > 0 ? viewFrame.width : 200 // Fallback width
+
+ // Start by centering under the widget
+ var xOffset = widgetRect.midX - contentWidth / 2
+
+ // Constrain to screen edges with 20pt margin
+ let rightEdge = xOffset + contentWidth + 20
+ let leftEdge = xOffset - 20
- if newRight > screenWidth {
- return screenWidth - newRight
- } else if newLeft < 0 {
- return -newLeft
+ if rightEdge > screenWidth {
+ xOffset -= (rightEdge - screenWidth)
+ } else if leftEdge < 0 {
+ xOffset -= leftEdge
}
- return 0
+
+ return xOffset
}
var computedYOffset: CGFloat {
- return viewFrame.height / 2
+ return 0
}
}
diff --git a/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/Contents.json b/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/Contents.json
new file mode 100644
index 0000000..6d46d5e
--- /dev/null
+++ b/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "claude-icon.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/claude-icon.svg b/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/claude-icon.svg
new file mode 100644
index 0000000..5714e77
--- /dev/null
+++ b/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/claude-icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/Barik/Resources/Assets.xcassets/TomatoIcon.imageset/Contents.json b/Barik/Resources/Assets.xcassets/TomatoIcon.imageset/Contents.json
new file mode 100644
index 0000000..e4d165a
--- /dev/null
+++ b/Barik/Resources/Assets.xcassets/TomatoIcon.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "tomato.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Barik/Resources/Assets.xcassets/TomatoIcon.imageset/tomato.svg b/Barik/Resources/Assets.xcassets/TomatoIcon.imageset/tomato.svg
new file mode 100644
index 0000000..1cf2227
--- /dev/null
+++ b/Barik/Resources/Assets.xcassets/TomatoIcon.imageset/tomato.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/Barik/Resources/Assets.xcassets/TomatoIconFilled.imageset/Contents.json b/Barik/Resources/Assets.xcassets/TomatoIconFilled.imageset/Contents.json
new file mode 100644
index 0000000..d2d7971
--- /dev/null
+++ b/Barik/Resources/Assets.xcassets/TomatoIconFilled.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "tomato-filled.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Barik/Resources/Assets.xcassets/TomatoIconFilled.imageset/tomato-filled.svg b/Barik/Resources/Assets.xcassets/TomatoIconFilled.imageset/tomato-filled.svg
new file mode 100644
index 0000000..2e7d0ce
--- /dev/null
+++ b/Barik/Resources/Assets.xcassets/TomatoIconFilled.imageset/tomato-filled.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/Barik/Resources/Localizable.xcstrings b/Barik/Resources/Localizable.xcstrings
index ca19551..ec604ea 100644
--- a/Barik/Resources/Localizable.xcstrings
+++ b/Barik/Resources/Localizable.xcstrings
@@ -1,11 +1,33 @@
{
"sourceLanguage" : "en",
"strings" : {
+ "" : {
+
+ },
"?%@?" : {
"shouldTranslate" : false
},
"%lld" : {
"shouldTranslate" : false
+ },
+ "%lld all-day" : {
+
+ },
+ "%lld all-day event%@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$lld all-day event%2$@"
+ }
+ }
+ }
+ },
+ "%lld%%" : {
+
+ },
+ "%lld%% chance of rain" : {
+
},
"ALL_DAY" : {
"extractionState" : "manual",
@@ -131,8 +153,15 @@
}
}
}
+ },
+ "Back" : {
+
+ },
+ "CALENDARS" : {
+
},
"Channel: %@" : {
+ "extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -382,6 +411,7 @@
}
},
"Ethernet: %@" : {
+ "extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -504,8 +534,25 @@
}
}
}
+ },
+ "H:%@ L:%@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "H:%1$@ L:%2$@"
+ }
+ }
+ }
+ },
+ "Known Network" : {
+
+ },
+ "Loading weather..." : {
+
},
"Noise: %lld" : {
+ "extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -628,8 +675,18 @@
}
}
}
+ },
+ "Open in Calendar" : {
+
+ },
+ "Open Weather" : {
+
+ },
+ "Other Networks" : {
+
},
"RSSI: %lld" : {
+ "extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -754,6 +811,7 @@
}
},
"Signal strength: %@" : {
+ "extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -1622,6 +1680,12 @@
}
}
}
+ },
+ "Wi-Fi" : {
+
+ },
+ "Wi-Fi Settings..." : {
+
}
},
"version" : "1.0"
diff --git a/Barik/Views/MenuBarContextMenu.swift b/Barik/Views/MenuBarContextMenu.swift
new file mode 100644
index 0000000..62155e9
--- /dev/null
+++ b/Barik/Views/MenuBarContextMenu.swift
@@ -0,0 +1,86 @@
+import AppKit
+
+final class MenuBarContextMenu: NSMenu, NSMenuDelegate {
+ private static let widgetEntries: [(id: String, name: String)] = [
+ ("default.spaces", "Spaces"),
+ ("default.network", "Network"),
+ ("default.battery", "Battery"),
+ ("default.time", "Time & Calendar"),
+ ("default.nowplaying", "Now Playing"),
+ ("default.weather", "Weather"),
+ ("default.claude-usage", "Claude Usage"),
+ ("default.pomodoro", "Pomodoro"),
+ ("default.countdown", "Countdown"),
+ ]
+
+ override init(title: String = "") {
+ super.init(title: title)
+ self.delegate = self
+ self.autoenablesItems = false
+ }
+
+ required init(coder: NSCoder) {
+ super.init(coder: coder)
+ self.delegate = self
+ self.autoenablesItems = false
+ }
+
+ func menuNeedsUpdate(_ menu: NSMenu) {
+ menu.removeAllItems()
+
+ let displayedIds = ConfigManager.shared.config.rootToml.widgets.displayed.map(\.id)
+
+ for entry in Self.widgetEntries {
+ let item = NSMenuItem(
+ title: entry.name,
+ action: #selector(toggleWidget(_:)),
+ keyEquivalent: "")
+ item.target = self
+ item.representedObject = entry.id
+ item.state = displayedIds.contains(entry.id) ? .on : .off
+ menu.addItem(item)
+ }
+
+ menu.addItem(.separator())
+
+ let editItem = NSMenuItem(
+ title: "Edit Config...",
+ action: #selector(openConfig),
+ keyEquivalent: "")
+ editItem.target = self
+ menu.addItem(editItem)
+
+ let quitItem = NSMenuItem(
+ title: "Quit Barik",
+ action: #selector(quitApp),
+ keyEquivalent: "")
+ quitItem.target = self
+ menu.addItem(quitItem)
+ }
+
+ @objc private func toggleWidget(_ sender: NSMenuItem) {
+ guard let widgetId = sender.representedObject as? String else { return }
+ ConfigManager.shared.toggleWidget(widgetId)
+ }
+
+ @objc private func openConfig() {
+ let homePath = FileManager.default.homeDirectoryForCurrentUser.path
+ let path1 = "\(homePath)/.barik-config.toml"
+ let path2 = "\(homePath)/.config/barik/config.toml"
+
+ let path: String
+ if FileManager.default.fileExists(atPath: path1) {
+ path = path1
+ } else if FileManager.default.fileExists(atPath: path2) {
+ path = path2
+ } else {
+ return
+ }
+
+ NSWorkspace.shared.open(URL(fileURLWithPath: path))
+ }
+
+ @objc private func quitApp() {
+ NSApplication.shared.terminate(nil)
+ }
+}
diff --git a/Barik/Views/MenuBarView.swift b/Barik/Views/MenuBarView.swift
index 31081ab..98b05e4 100644
--- a/Barik/Views/MenuBarView.swift
+++ b/Barik/Views/MenuBarView.swift
@@ -1,7 +1,16 @@
import SwiftUI
+import UniformTypeIdentifiers
struct MenuBarView: View {
@ObservedObject var configManager = ConfigManager.shared
+ @State private var widgetItems: [TomlWidgetItem] = []
+ @State private var draggingItem: TomlWidgetItem?
+
+ private var displayedFingerprint: String {
+ configManager.config.rootToml.widgets.displayed
+ .map { $0.id }
+ .joined(separator: "|")
+ }
var body: some View {
let theme: ColorScheme? =
@@ -14,17 +23,40 @@ struct MenuBarView: View {
.none
}
- let items = configManager.config.rootToml.widgets.displayed
-
HStack(spacing: 0) {
HStack(spacing: configManager.config.experimental.foreground.spacing) {
- ForEach(0.. Void
+
+ func performDrop(info: DropInfo) -> Bool {
+ draggingItem = nil
+ onReorderComplete()
+ return true
+ }
+
+ func dropEntered(info: DropInfo) {
+ guard let dragging = draggingItem,
+ let fromIndex = items.firstIndex(where: { $0.instanceID == dragging.instanceID }),
+ fromIndex != targetIndex
+ else { return }
+
+ withAnimation(.smooth(duration: 0.2)) {
+ items.move(
+ fromOffsets: IndexSet(integer: fromIndex),
+ toOffset: targetIndex
+ )
+ }
+ }
+
+ func dropUpdated(info: DropInfo) -> DropProposal? {
+ DropProposal(operation: .move)
+ }
+
+ func validateDrop(info: DropInfo) -> Bool {
+ draggingItem != nil
+ }
+}
+
+struct WidgetDropDelegate: DropDelegate {
+ let item: TomlWidgetItem
+ @Binding var items: [TomlWidgetItem]
+ @Binding var draggingItem: TomlWidgetItem?
+ let onReorderComplete: () -> Void
+
+ func performDrop(info: DropInfo) -> Bool {
+ draggingItem = nil
+ onReorderComplete()
+ return true
+ }
+
+ func dropEntered(info: DropInfo) {
+ guard let dragging = draggingItem,
+ dragging.instanceID != item.instanceID,
+ let fromIndex = items.firstIndex(where: { $0.instanceID == dragging.instanceID }),
+ let toIndex = items.firstIndex(where: { $0.instanceID == item.instanceID })
+ else { return }
+
+ withAnimation(.smooth(duration: 0.2)) {
+ items.move(
+ fromOffsets: IndexSet(integer: fromIndex),
+ toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex
+ )
+ }
+ }
+
+ func dropUpdated(info: DropInfo) -> DropProposal? {
+ DropProposal(operation: .move)
+ }
+
+ func validateDrop(info: DropInfo) -> Bool {
+ draggingItem != nil
+ }
+}
diff --git a/Barik/Widgets/Battery/BatteryManager.swift b/Barik/Widgets/Battery/BatteryManager.swift
index 7d4e700..7a1e5f3 100644
--- a/Barik/Widgets/Battery/BatteryManager.swift
+++ b/Barik/Widgets/Battery/BatteryManager.swift
@@ -7,7 +7,7 @@ class BatteryManager: ObservableObject {
@Published var batteryLevel: Int = 0
@Published var isCharging: Bool = false
@Published var isPluggedIn: Bool = false
- private var timer: Timer?
+ private var runLoopSource: CFRunLoopSource?
init() {
startMonitoring()
@@ -18,17 +18,33 @@ class BatteryManager: ObservableObject {
}
private func startMonitoring() {
- // Update every 1 second.
- timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) {
- [weak self] _ in
- self?.updateBatteryStatus()
+ let context = UnsafeMutableRawPointer(
+ Unmanaged.passUnretained(self).toOpaque())
+
+ runLoopSource = IOPSNotificationCreateRunLoopSource(
+ { context in
+ guard let context = context else { return }
+ let manager = Unmanaged.fromOpaque(context)
+ .takeUnretainedValue()
+ DispatchQueue.main.async {
+ manager.updateBatteryStatus()
+ }
+ },
+ context
+ )?.takeRetainedValue()
+
+ if let source = runLoopSource {
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .defaultMode)
}
+
updateBatteryStatus()
}
private func stopMonitoring() {
- timer?.invalidate()
- timer = nil
+ if let source = runLoopSource {
+ CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .defaultMode)
+ runLoopSource = nil
+ }
}
/// This method updates the battery level and charging state.
diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift
new file mode 100644
index 0000000..ddd8f5d
--- /dev/null
+++ b/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift
@@ -0,0 +1,192 @@
+import Foundation
+import Security
+import SwiftUI
+
+// MARK: - Data Models
+
+struct ClaudeUsageData {
+ var fiveHourPercentage: Double = 0
+ var fiveHourResetDate: Date?
+
+ var weeklyPercentage: Double = 0
+ var weeklyResetDate: Date?
+
+ var plan: String = "Pro"
+ var lastUpdated: Date = Date()
+ var isAvailable: Bool = false
+}
+
+private struct UsageResponse: Codable {
+ let fiveHour: UsageBucket?
+ let sevenDay: UsageBucket?
+ let sevenDaySonnet: UsageBucket?
+
+ enum CodingKeys: String, CodingKey {
+ case fiveHour = "five_hour"
+ case sevenDay = "seven_day"
+ case sevenDaySonnet = "seven_day_sonnet"
+ }
+
+ struct UsageBucket: Codable {
+ let utilization: Double
+ let resetsAt: String
+
+ enum CodingKeys: String, CodingKey {
+ case utilization
+ case resetsAt = "resets_at"
+ }
+ }
+}
+
+// MARK: - Manager
+
+@MainActor
+final class ClaudeUsageManager: ObservableObject {
+ static let shared = ClaudeUsageManager()
+
+ @Published private(set) var usageData = ClaudeUsageData()
+ @Published private(set) var isConnected: Bool = false
+
+ private var refreshTimer: Timer?
+ private var cachedCredentials: (accessToken: String, plan: String)?
+ private var currentConfig: ConfigData = [:]
+
+ private static let connectedKey = "claude-usage-connected"
+
+ private init() {
+ NotificationCenter.default.addObserver(
+ forName: NSWorkspace.didWakeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in
+ self?.handleWake()
+ }
+ }
+ }
+
+ func startUpdating(config: ConfigData) {
+ currentConfig = config
+ }
+
+ /// Called when the popup appears. Reconnects silently if the user previously granted access,
+ /// deferring keychain access until the user actually interacts with the widget.
+ func reconnectIfNeeded() {
+ if !isConnected && UserDefaults.standard.bool(forKey: Self.connectedKey) {
+ connectAndFetch()
+ }
+ }
+
+ func stopUpdating() {
+ refreshTimer?.invalidate()
+ refreshTimer = nil
+ }
+
+ func refresh() {
+ fetchData()
+ }
+
+ /// Called when user explicitly clicks "Allow Access" in the popup.
+ /// Triggers the macOS Keychain permission dialog.
+ func requestAccess() {
+ connectAndFetch()
+ }
+
+ private func handleWake() {
+ guard isConnected, cachedCredentials != nil else { return }
+ // Restart the timer since sleep may have disrupted it
+ refreshTimer?.invalidate()
+ refreshTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ self?.fetchData()
+ }
+ }
+ fetchData()
+ }
+
+ private func connectAndFetch() {
+ guard let creds = readKeychainCredentials() else {
+ isConnected = false
+ cachedCredentials = nil
+ UserDefaults.standard.set(false, forKey: Self.connectedKey)
+ return
+ }
+
+ cachedCredentials = creds
+ isConnected = true
+ UserDefaults.standard.set(true, forKey: Self.connectedKey)
+ fetchData()
+
+ refreshTimer?.invalidate()
+ refreshTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ self?.fetchData()
+ }
+ }
+ }
+
+ // MARK: - Data Fetching
+
+ private func fetchData() {
+ guard let creds = cachedCredentials else { return }
+
+ let plan = currentConfig["plan"]?.stringValue ?? creds.plan
+
+ Task {
+ guard let response = await fetchUsageFromAPI(token: creds.accessToken) else { return }
+
+ let isoFormatter = ISO8601DateFormatter()
+ isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+
+ var data = ClaudeUsageData()
+ data.fiveHourPercentage = (response.fiveHour?.utilization ?? 0) / 100
+ data.fiveHourResetDate = response.fiveHour.flatMap { isoFormatter.date(from: $0.resetsAt) }
+ data.weeklyPercentage = (response.sevenDay?.utilization ?? 0) / 100
+ data.weeklyResetDate = response.sevenDay.flatMap { isoFormatter.date(from: $0.resetsAt) }
+ data.plan = plan.capitalized
+ data.lastUpdated = Date()
+ data.isAvailable = true
+
+ self.usageData = data
+ }
+ }
+
+ // MARK: - API
+
+ private func fetchUsageFromAPI(token: String) async -> UsageResponse? {
+ guard let url = URL(string: "https://api.anthropic.com/api/oauth/usage") else { return nil }
+
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta")
+ request.timeoutInterval = 5
+
+ guard let (data, response) = try? await URLSession.shared.data(for: request),
+ let http = response as? HTTPURLResponse,
+ http.statusCode == 200 else { return nil }
+
+ return try? JSONDecoder().decode(UsageResponse.self, from: data)
+ }
+
+ // MARK: - Keychain
+
+ private func readKeychainCredentials() -> (accessToken: String, plan: String)? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "Claude Code-credentials",
+ kSecReturnData as String: true,
+ ]
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ guard status == errSecSuccess,
+ let data = result as? Data,
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let oauth = json["claudeAiOauth"] as? [String: Any],
+ let token = oauth["accessToken"] as? String else {
+ return nil
+ }
+ let plan = oauth["subscriptionType"] as? String ?? "pro"
+ return (token, plan)
+ }
+}
diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift
new file mode 100644
index 0000000..1a7742a
--- /dev/null
+++ b/Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift
@@ -0,0 +1,244 @@
+import SwiftUI
+
+struct ClaudeUsagePopup: View {
+ @EnvironmentObject var configProvider: ConfigProvider
+ @ObservedObject private var usageManager = ClaudeUsageManager.shared
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ if !usageManager.isConnected {
+ connectView
+ } else if usageManager.usageData.isAvailable {
+ titleBar
+ Divider().background(Color.white.opacity(0.2))
+ rateLimitSection(
+ icon: "clock",
+ title: "5-Hour Window",
+ percentage: usageManager.usageData.fiveHourPercentage,
+ resetDate: usageManager.usageData.fiveHourResetDate,
+ resetPrefix: "Resets in"
+ )
+ Divider().background(Color.white.opacity(0.2))
+ rateLimitSection(
+ icon: "calendar",
+ title: "Weekly",
+ percentage: usageManager.usageData.weeklyPercentage,
+ resetDate: usageManager.usageData.weeklyResetDate,
+ resetPrefix: "Resets"
+ )
+ Divider().background(Color.white.opacity(0.2))
+ footerSection
+ } else {
+ loadingView
+ }
+ }
+ .frame(width: 280)
+ .background(Color.black)
+ .onAppear {
+ usageManager.reconnectIfNeeded()
+ }
+ }
+
+ // MARK: - Title Bar
+
+ private var titleBar: some View {
+ HStack(spacing: 8) {
+ Image("ClaudeIcon")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 18, height: 18)
+ Text("Claude Usage")
+ .font(.system(size: 14, weight: .semibold))
+ Spacer()
+ Text(usageManager.usageData.plan)
+ .font(.system(size: 11, weight: .medium))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(planBadgeColor.opacity(0.3))
+ .foregroundColor(planBadgeColor)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 14)
+ }
+
+ private var planBadgeColor: Color {
+ switch usageManager.usageData.plan.lowercased() {
+ case "pro": return .orange
+ case "max": return .purple
+ case "team": return .blue
+ case "free": return .gray
+ default: return .orange
+ }
+ }
+
+ // MARK: - Rate Limit Section
+
+ private func rateLimitSection(
+ icon: String,
+ title: String,
+ percentage: Double,
+ resetDate: Date?,
+ resetPrefix: String
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Image(systemName: icon)
+ .font(.system(size: 12))
+ .opacity(0.6)
+ Text(title)
+ .font(.system(size: 13, weight: .medium))
+ Spacer()
+ Text("\(Int(min(percentage, 1.0) * 100))%")
+ .font(.system(size: 24, weight: .semibold))
+ }
+
+ GeometryReader { geometry in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 3)
+ .fill(Color.gray.opacity(0.3))
+ .frame(height: 6)
+ RoundedRectangle(cornerRadius: 3)
+ .fill(progressColor(for: percentage))
+ .frame(
+ width: geometry.size.width * min(percentage, 1.0),
+ height: 6
+ )
+ .animation(.easeOut(duration: 0.3), value: percentage)
+ }
+ }
+ .frame(height: 6)
+
+ if let resetDate = resetDate {
+ Text("\(resetPrefix) \(resetTimeString(resetDate))")
+ .font(.system(size: 11))
+ .opacity(0.5)
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 14)
+ }
+
+ private func progressColor(for percentage: Double) -> Color {
+ if percentage >= 0.8 { return .red }
+ if percentage >= 0.6 { return .orange }
+ return .white
+ }
+
+ private func resetTimeString(_ date: Date) -> String {
+ let interval = date.timeIntervalSince(Date())
+ if interval <= 0 { return "soon" }
+
+ let hours = Int(interval) / 3600
+ let minutes = (Int(interval) % 3600) / 60
+
+ if hours > 24 {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "E h:mm a"
+ return formatter.string(from: date)
+ } else if hours > 0 {
+ return "\(hours)h \(minutes)m"
+ } else {
+ return "\(minutes)m"
+ }
+ }
+
+ // MARK: - Footer
+
+ private var footerSection: some View {
+ HStack {
+ Text("Updated \(timeAgoString(usageManager.usageData.lastUpdated))")
+ .font(.system(size: 11))
+ .opacity(0.4)
+
+ Spacer()
+
+ Button(action: {
+ usageManager.refresh()
+ }) {
+ Image(systemName: "arrow.clockwise")
+ .font(.system(size: 12))
+ .opacity(0.6)
+ }
+ .buttonStyle(.plain)
+ .onHover { hovering in
+ if hovering {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ }
+
+ private func timeAgoString(_ date: Date) -> String {
+ let seconds = Int(Date().timeIntervalSince(date))
+ if seconds < 60 { return "\(seconds) sec ago" }
+ let minutes = seconds / 60
+ if minutes < 60 { return "\(minutes) min ago" }
+ return "\(minutes / 60)h ago"
+ }
+
+ // MARK: - Connect
+
+ private var connectView: some View {
+ VStack(spacing: 14) {
+ Image("ClaudeIcon")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 28, height: 28)
+
+ Text("Claude Usage")
+ .font(.system(size: 14, weight: .semibold))
+
+ Text("View your Claude rate limit usage directly in the menu bar.")
+ .font(.system(size: 11))
+ .opacity(0.5)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Button(action: {
+ usageManager.requestAccess()
+ }) {
+ Text("Allow Access")
+ .font(.system(size: 12, weight: .medium))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 6)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(Color(red: 0.89, green: 0.45, blue: 0.29))
+ .onHover { hovering in
+ if hovering {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+
+ Text("Reads credentials from your Claude Code keychain entry.")
+ .font(.system(size: 10))
+ .opacity(0.3)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 30)
+ .padding(.vertical, 30)
+ }
+
+ // MARK: - Loading
+
+ private var loadingView: some View {
+ VStack(spacing: 12) {
+ ProgressView()
+ .scaleEffect(0.8)
+ Text("Loading usage data...")
+ .font(.system(size: 11))
+ .opacity(0.5)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(40)
+ }
+}
diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift
new file mode 100644
index 0000000..6cc7b9a
--- /dev/null
+++ b/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift
@@ -0,0 +1,61 @@
+import SwiftUI
+
+struct ClaudeUsageWidget: View {
+ @EnvironmentObject var configProvider: ConfigProvider
+ @ObservedObject private var usageManager = ClaudeUsageManager.shared
+
+ @State private var widgetFrame: CGRect = .zero
+
+ private var percentage: Double {
+ usageManager.usageData.fiveHourPercentage
+ }
+
+ private var ringColor: Color {
+ if percentage >= 0.8 { return .red }
+ if percentage >= 0.6 { return .orange }
+ return .white
+ }
+
+ var body: some View {
+ ZStack {
+ if usageManager.usageData.isAvailable {
+ Circle()
+ .trim(from: 0.5 - min(percentage, 1.0) / 2, to: 0.5 + min(percentage, 1.0) / 2)
+ .stroke(ringColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
+ .rotationEffect(.degrees(90))
+ .animation(.easeOut(duration: 0.3), value: percentage)
+ }
+
+ Image("ClaudeIcon")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ .frame(width: 28, height: 28)
+ .foregroundStyle(.foregroundOutside)
+ .shadow(color: .foregroundShadowOutside, radius: 3)
+ .experimentalConfiguration(cornerRadius: 15)
+ .frame(maxHeight: .infinity)
+ .background(.black.opacity(0.001))
+ .background(
+ GeometryReader { geometry in
+ Color.clear
+ .onAppear {
+ widgetFrame = geometry.frame(in: .global)
+ }
+ .onChange(of: geometry.frame(in: .global)) { _, newFrame in
+ widgetFrame = newFrame
+ }
+ }
+ )
+ .onTapGesture {
+ MenuBarPopup.show(rect: widgetFrame, id: "claude-usage") {
+ ClaudeUsagePopup()
+ .environmentObject(configProvider)
+ }
+ }
+ .onAppear {
+ usageManager.startUpdating(config: configProvider.config)
+ }
+ }
+}
diff --git a/Barik/Widgets/Countdown/CountdownManager.swift b/Barik/Widgets/Countdown/CountdownManager.swift
new file mode 100644
index 0000000..e405181
--- /dev/null
+++ b/Barik/Widgets/Countdown/CountdownManager.swift
@@ -0,0 +1,46 @@
+import Foundation
+import SwiftUI
+
+final class CountdownManager: ObservableObject {
+ static let shared = CountdownManager()
+
+ private static let labelKey = "countdown-label"
+ private static let yearKey = "countdown-target-year"
+ private static let monthKey = "countdown-target-month"
+ private static let dayKey = "countdown-target-day"
+
+ @Published var label: String {
+ didSet { UserDefaults.standard.set(label, forKey: Self.labelKey) }
+ }
+ @Published var targetYear: Int {
+ didSet { UserDefaults.standard.set(targetYear, forKey: Self.yearKey) }
+ }
+ @Published var targetMonth: Int {
+ didSet { UserDefaults.standard.set(targetMonth, forKey: Self.monthKey) }
+ }
+ @Published var targetDay: Int {
+ didSet { UserDefaults.standard.set(targetDay, forKey: Self.dayKey) }
+ }
+
+ private init() {
+ let defaults = UserDefaults.standard
+ self.label = defaults.string(forKey: Self.labelKey) ?? "Christmas"
+ self.targetYear = defaults.object(forKey: Self.yearKey) as? Int ?? 2026
+ self.targetMonth = defaults.object(forKey: Self.monthKey) as? Int ?? 12
+ self.targetDay = defaults.object(forKey: Self.dayKey) as? Int ?? 25
+ }
+
+ var targetDate: Date {
+ var components = DateComponents()
+ components.year = targetYear
+ components.month = targetMonth
+ components.day = targetDay
+ return Calendar.current.startOfDay(for: Calendar.current.date(from: components)!)
+ }
+
+ var daysRemaining: Int {
+ let today = Calendar.current.startOfDay(for: Date())
+ let components = Calendar.current.dateComponents([.day], from: today, to: targetDate)
+ return max(components.day ?? 0, 0)
+ }
+}
diff --git a/Barik/Widgets/Countdown/CountdownPopup.swift b/Barik/Widgets/Countdown/CountdownPopup.swift
new file mode 100644
index 0000000..b1b6748
--- /dev/null
+++ b/Barik/Widgets/Countdown/CountdownPopup.swift
@@ -0,0 +1,152 @@
+import SwiftUI
+
+struct CountdownPopup: View {
+ @ObservedObject private var manager = CountdownManager.shared
+ @State private var showSettings = false
+
+ var body: some View {
+ VStack(spacing: 16) {
+ ZStack {
+ Text("Countdown")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.white)
+ HStack {
+ Spacer()
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ showSettings.toggle()
+ }
+ } label: {
+ Image(systemName: showSettings ? "xmark" : "gearshape")
+ .font(.system(size: 11))
+ .foregroundStyle(.gray)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
+ if showSettings {
+ settingsView
+ } else {
+ countdownView
+ }
+ }
+ .frame(width: 260)
+ .padding(24)
+ }
+
+ @ViewBuilder
+ private var countdownView: some View {
+ VStack(spacing: 8) {
+ Text("\(manager.daysRemaining)")
+ .font(.system(size: 40, weight: .bold, design: .rounded))
+ .foregroundStyle(.white)
+
+ Text(manager.daysRemaining == 1
+ ? "day until \(manager.label)!"
+ : "days until \(manager.label)!")
+ .font(.system(size: 13))
+ .foregroundStyle(.gray)
+ .multilineTextAlignment(.center)
+
+ Text(formattedTargetDate)
+ .font(.system(size: 11))
+ .foregroundStyle(.gray.opacity(0.6))
+ .padding(.top, 4)
+ }
+ }
+
+ @ViewBuilder
+ private var settingsView: some View {
+ VStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Label")
+ .font(.system(size: 11))
+ .foregroundStyle(.gray)
+ TextField("Event name", text: $manager.label)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 12))
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Target Date")
+ .font(.system(size: 11))
+ .foregroundStyle(.gray)
+ HStack(spacing: 6) {
+ Stepper("", value: $manager.targetMonth, in: 1...12)
+ .labelsHidden()
+ Text("\(monthName)/\(String(format: "%02d", manager.targetDay))/\(String(manager.targetYear))")
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundStyle(.white)
+ }
+
+ HStack(spacing: 8) {
+ stepperRow("Month", value: $manager.targetMonth, range: 1...12)
+ stepperRow("Day", value: $manager.targetDay, range: 1...31)
+ stepperRow("Year", value: $manager.targetYear, range: 2025...2100)
+ }
+ }
+ }
+ }
+
+ private var monthName: String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "MMM"
+ var components = DateComponents()
+ components.month = manager.targetMonth
+ if let date = Calendar.current.date(from: components) {
+ return formatter.string(from: date)
+ }
+ return ""
+ }
+
+ private var formattedTargetDate: String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .long
+ return formatter.string(from: manager.targetDate)
+ }
+
+ private func stepperRow(_ label: String, value: Binding, range: ClosedRange) -> some View {
+ VStack(spacing: 2) {
+ Text(label)
+ .font(.system(size: 9))
+ .foregroundStyle(.gray)
+ HStack(spacing: 4) {
+ Button {
+ if value.wrappedValue > range.lowerBound {
+ value.wrappedValue -= 1
+ }
+ } label: {
+ Image(systemName: "minus")
+ .font(.system(size: 8, weight: .bold))
+ .foregroundStyle(.white)
+ .frame(width: 18, height: 18)
+ .background(Color.gray.opacity(0.3))
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+
+ Text("\(value.wrappedValue)")
+ .font(.system(size: 11, weight: .semibold, design: .monospaced))
+ .foregroundStyle(.white)
+ .lineLimit(1)
+ .fixedSize()
+ .frame(minWidth: 20)
+
+ Button {
+ if value.wrappedValue < range.upperBound {
+ value.wrappedValue += 1
+ }
+ } label: {
+ Image(systemName: "plus")
+ .font(.system(size: 8, weight: .bold))
+ .foregroundStyle(.white)
+ .frame(width: 18, height: 18)
+ .background(Color.gray.opacity(0.3))
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+}
diff --git a/Barik/Widgets/Countdown/CountdownWidget.swift b/Barik/Widgets/Countdown/CountdownWidget.swift
new file mode 100644
index 0000000..65e9aaf
--- /dev/null
+++ b/Barik/Widgets/Countdown/CountdownWidget.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+
+struct CountdownWidget: View {
+ @EnvironmentObject var configProvider: ConfigProvider
+ var config: ConfigData { configProvider.config }
+
+ @ObservedObject private var manager = CountdownManager.shared
+ @State private var rect: CGRect = .zero
+
+ private let timer = Timer.publish(every: 3600, on: .main, in: .common).autoconnect()
+
+ var body: some View {
+ CalendarIcon(days: manager.daysRemaining)
+ .shadow(color: .foregroundShadowOutside, radius: 3)
+ .background(
+ GeometryReader { geometry in
+ Color.clear
+ .onAppear {
+ rect = geometry.frame(in: .global)
+ }
+ .onChange(of: geometry.frame(in: .global)) { _, newState in
+ rect = newState
+ }
+ }
+ )
+ .experimentalConfiguration(cornerRadius: 15)
+ .frame(maxHeight: .infinity)
+ .background(.black.opacity(0.001))
+ .onTapGesture {
+ MenuBarPopup.show(rect: rect, id: "countdown") {
+ CountdownPopup()
+ }
+ }
+ .onReceive(timer) { _ in
+ manager.objectWillChange.send()
+ }
+ }
+}
+
+private struct CalendarIcon: View {
+ let days: Int
+
+ private var isWide: Bool { days >= 100 }
+ private var iconWidth: CGFloat { isWide ? 24 : 18 }
+ private var frameWidth: CGFloat { isWide ? 28 : 22 }
+
+ var body: some View {
+ ZStack {
+ // Calendar outline
+ RoundedRectangle(cornerRadius: 3)
+ .stroke(Color.foregroundOutside, lineWidth: 1.5)
+ .frame(width: iconWidth, height: 20)
+
+ // Top bar
+ Rectangle()
+ .fill(Color.foregroundOutside)
+ .frame(width: iconWidth, height: 5)
+ .clipShape(
+ .rect(topLeadingRadius: 3, topTrailingRadius: 3)
+ )
+ .offset(y: -7.5)
+
+ // Binding rings
+ HStack(spacing: 8) {
+ RoundedRectangle(cornerRadius: 1)
+ .fill(Color.foregroundOutside)
+ .frame(width: 2, height: 5)
+ RoundedRectangle(cornerRadius: 1)
+ .fill(Color.foregroundOutside)
+ .frame(width: 2, height: 5)
+ }
+ .offset(y: -10)
+
+ // Day number
+ Text("\(days)")
+ .font(.system(size: isWide ? 9 : 10, weight: .bold))
+ .foregroundStyle(.foregroundOutside)
+ .offset(y: 2)
+ }
+ .frame(width: frameWidth, height: 24)
+ }
+}
diff --git a/Barik/Widgets/Network/NetworkPopup.swift b/Barik/Widgets/Network/NetworkPopup.swift
index 8d5465d..623f427 100644
--- a/Barik/Widgets/Network/NetworkPopup.swift
+++ b/Barik/Widgets/Network/NetworkPopup.swift
@@ -1,129 +1,221 @@
import SwiftUI
-/// Window displaying detailed network status information.
+/// Window displaying detailed network status information with WiFi controls.
struct NetworkPopup: View {
@StateObject private var viewModel = NetworkStatusViewModel()
+ @State private var showOtherNetworks = false
var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- if viewModel.wifiState != .notSupported {
- HStack(spacing: 8) {
- wifiIcon
- Text(viewModel.ssid)
+ VStack(alignment: .leading, spacing: 0) {
+ // WiFi Toggle Header
+ wifiToggleHeader
+
+ Divider()
+ .background(Color.gray.opacity(0.3))
+ .padding(.vertical, 8)
+
+ if viewModel.isWiFiEnabled {
+ // Known Network (currently connected)
+ if viewModel.ssid != "Not connected" && viewModel.ssid != "No interface" {
+ knownNetworkSection
+
+ Divider()
+ .background(Color.gray.opacity(0.3))
+ .padding(.vertical, 8)
+ }
+
+ // Other Networks
+ otherNetworksSection
+
+ Divider()
+ .background(Color.gray.opacity(0.3))
+ .padding(.vertical, 8)
+ }
+
+ // WiFi Settings Button
+ wifiSettingsButton
+ }
+ .padding(16)
+ .frame(width: 280)
+ .background(Color.black)
+ .onAppear {
+ if viewModel.isWiFiEnabled {
+ viewModel.scanForNetworks()
+ }
+ }
+ }
+
+ // MARK: - WiFi Toggle Header
+
+ private var wifiToggleHeader: some View {
+ HStack {
+ Text("Wi-Fi")
+ .font(.headline)
+ .foregroundColor(.white)
+
+ Spacer()
+
+ Toggle("", isOn: Binding(
+ get: { viewModel.isWiFiEnabled },
+ set: { _ in viewModel.toggleWiFi() }
+ ))
+ .toggleStyle(SwitchToggleStyle(tint: .blue))
+ .labelsHidden()
+ }
+ .padding(.bottom, 4)
+ }
+
+ // MARK: - Known Network Section
+
+ private var knownNetworkSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Known Network")
+ .font(.subheadline)
+ .foregroundColor(.gray)
+
+ HStack(spacing: 12) {
+ // WiFi icon with signal strength
+ ZStack {
+ Circle()
+ .fill(Color.blue)
+ .frame(width: 32, height: 32)
+
+ Image(systemName: wifiIconName(for: viewModel.rssi))
+ .font(.system(size: 14))
.foregroundColor(.white)
- .font(.headline)
}
- if viewModel.ssid != "Not connected"
- && viewModel.ssid != "No interface"
- {
- VStack(alignment: .leading, spacing: 4) {
- Text(
- "Signal strength: \(viewModel.wifiSignalStrength.rawValue)"
- )
- Text("RSSI: \(viewModel.rssi)")
- Text("Noise: \(viewModel.noise)")
- Text("Channel: \(viewModel.channel)")
+ Text(viewModel.ssid)
+ .font(.body)
+ .foregroundColor(.white)
+
+ Spacer()
+
+ Image(systemName: "lock.fill")
+ .font(.system(size: 12))
+ .foregroundColor(.gray)
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ // MARK: - Other Networks Section
+
+ private var otherNetworksSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ // Header with expand/collapse
+ Button(action: {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ showOtherNetworks.toggle()
+ }
+ if showOtherNetworks && viewModel.availableNetworks.isEmpty {
+ viewModel.scanForNetworks()
+ }
+ }) {
+ HStack {
+ Text("Other Networks")
+ .font(.subheadline)
+ .foregroundColor(.gray)
+
+ Spacer()
+
+ if viewModel.isScanning {
+ ProgressView()
+ .scaleEffect(0.7)
+ .frame(width: 16, height: 16)
+ } else {
+ Image(systemName: showOtherNetworks ? "chevron.down" : "chevron.right")
+ .font(.system(size: 12))
+ .foregroundColor(.gray)
}
- .font(.subheadline)
}
}
+ .buttonStyle(PlainButtonStyle())
+ .contentShape(Rectangle())
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(showOtherNetworks ? Color.blue.opacity(0.3) : Color.clear)
+ .padding(.horizontal, -8)
+ .padding(.vertical, -4)
+ )
- // Ethernet section
- if viewModel.ethernetState != .notSupported {
- HStack(spacing: 8) {
- ethernetIcon
- Text("Ethernet: \(viewModel.ethernetState.rawValue)")
- .foregroundColor(.white)
- .font(.headline)
+ // Network list
+ if showOtherNetworks {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 2) {
+ ForEach(otherNetworks) { network in
+ networkRow(network)
+ }
+ }
}
+ .frame(maxHeight: 300)
}
}
- .padding(25)
- .background(Color.black)
}
- /// Chooses the Wi‑Fi icon based on the status and connection availability.
- private var wifiIcon: some View {
- if viewModel.ssid == "Not connected" {
- return Image(systemName: "wifi.slash")
- .padding(8)
- .background(Color.red.opacity(0.8))
- .clipShape(Circle())
- .foregroundStyle(.white)
+ private var otherNetworks: [WiFiNetwork] {
+ viewModel.availableNetworks.filter { !$0.isConnected }
+ }
+
+ private func networkRow(_ network: WiFiNetwork) -> some View {
+ Button(action: {
+ viewModel.connectToNetwork(network)
+ }) {
+ HStack(spacing: 12) {
+ Image(systemName: wifiIconName(for: network.rssi))
+ .font(.system(size: 14))
+ .foregroundColor(.gray)
+ .frame(width: 20)
+
+ Text(network.ssid)
+ .font(.body)
+ .foregroundColor(.white)
+ .lineLimit(1)
+
+ Spacer()
+
+ if network.isSecure {
+ Image(systemName: "lock.fill")
+ .font(.system(size: 12))
+ .foregroundColor(.gray)
+ }
+ }
+ .padding(.vertical, 6)
+ .padding(.horizontal, 8)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(PlainButtonStyle())
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color.white.opacity(0.001))
+ )
+ .onHover { hovering in
+ // Could add hover effect here
}
- switch viewModel.wifiState {
- case .connected:
- return Image(systemName: "wifi")
- .padding(8)
- .background(Color.blue.opacity(0.8))
- .clipShape(Circle())
- .foregroundStyle(.white)
- case .connecting:
- return Image(systemName: "wifi")
- .padding(8)
- .background(Color.yellow.opacity(0.8))
- .clipShape(Circle())
- .foregroundStyle(.white)
- case .connectedWithoutInternet:
- return Image(systemName: "wifi.exclamationmark")
- .padding(8)
- .background(Color.yellow.opacity(0.8))
- .clipShape(Circle())
- .foregroundStyle(.white)
- case .disconnected:
- return Image(systemName: "wifi.slash")
- .padding(8)
- .background(Color.gray.opacity(0.8))
- .clipShape(Circle())
- .foregroundStyle(.white)
- case .disabled:
- return Image(systemName: "wifi.slash")
- .padding(8)
- .background(Color.red.opacity(0.8))
- .clipShape(Circle())
- .foregroundStyle(.white)
- case .notSupported:
- return Image(systemName: "wifi.exclamationmark")
- .padding(8)
- .background(Color.gray.opacity(0.8))
- .clipShape(Circle())
- .foregroundStyle(.white)
+ }
+
+ // MARK: - WiFi Settings Button
+
+ private var wifiSettingsButton: some View {
+ Button(action: {
+ viewModel.openWiFiSettings()
+ }) {
+ Text("Wi-Fi Settings...")
+ .font(.body)
+ .foregroundColor(.white)
}
+ .buttonStyle(PlainButtonStyle())
}
- private var ethernetIcon: some View {
- switch viewModel.ethernetState {
- case .connected:
- return Image(systemName: "network")
- .padding(8)
- .background(Color.blue.opacity(0.8))
- .clipShape(Circle())
- case .connectedWithoutInternet:
- return Image(systemName: "network")
- .padding(8)
- .background(Color.yellow.opacity(0.8))
- .clipShape(Circle())
- case .connecting:
- return Image(systemName: "network.slash")
- .padding(8)
- .background(Color.yellow.opacity(0.8))
- .clipShape(Circle())
- case .disconnected:
- return Image(systemName: "network.slash")
- .padding(8)
- .background(Color.gray.opacity(0.8))
- .clipShape(Circle())
- case .disabled:
- return Image(systemName: "network.slash")
- .padding(8)
- .background(Color.red.opacity(0.8))
- .clipShape(Circle())
- case .notSupported:
- return Image(systemName: "questionmark.circle")
- .padding(8)
- .background(Color.gray.opacity(0.8))
- .clipShape(Circle())
+ // MARK: - Helpers
+
+ private func wifiIconName(for rssi: Int) -> String {
+ if rssi >= -50 {
+ return "wifi"
+ } else if rssi >= -70 {
+ return "wifi"
+ } else {
+ return "wifi"
}
}
}
diff --git a/Barik/Widgets/Network/NetworkViewModel.swift b/Barik/Widgets/Network/NetworkViewModel.swift
index 2a0ba3e..6ab5ac7 100644
--- a/Barik/Widgets/Network/NetworkViewModel.swift
+++ b/Barik/Widgets/Network/NetworkViewModel.swift
@@ -19,9 +19,31 @@ enum WifiSignalStrength: String {
case unknown = "Unknown"
}
+struct WiFiNetwork: Identifiable, Hashable {
+ let id = UUID()
+ let ssid: String
+ let rssi: Int
+ let isSecure: Bool
+ let isConnected: Bool
+
+ var signalBars: Int {
+ if rssi >= -50 { return 3 }
+ else if rssi >= -70 { return 2 }
+ else { return 1 }
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(ssid)
+ }
+
+ static func == (lhs: WiFiNetwork, rhs: WiFiNetwork) -> Bool {
+ lhs.ssid == rhs.ssid
+ }
+}
+
/// Unified view model for monitoring network and Wi‑Fi status.
final class NetworkStatusViewModel: NSObject, ObservableObject,
- CLLocationManagerDelegate
+ CLLocationManagerDelegate, CWEventDelegate
{
// States for Wi‑Fi and Ethernet obtained via NWPathMonitor.
@@ -34,6 +56,11 @@ final class NetworkStatusViewModel: NSObject, ObservableObject,
@Published var noise: Int = 0
@Published var channel: String = "N/A"
+ // WiFi control and scanning
+ @Published var isWiFiEnabled: Bool = true
+ @Published var availableNetworks: [WiFiNetwork] = []
+ @Published var isScanning: Bool = false
+
/// Computed property for signal strength.
var wifiSignalStrength: WifiSignalStrength {
// If Wi‑Fi is not connected or the interface is missing – return unknown.
@@ -54,11 +81,13 @@ final class NetworkStatusViewModel: NSObject, ObservableObject,
private var timer: Timer?
private let locationManager = CLLocationManager()
+ private var wifiClient: CWWiFiClient?
override init() {
super.init()
locationManager.delegate = self
locationManager.requestWhenInUseAuthorization()
+ checkWiFiPowerState()
startNetworkMonitoring()
startWiFiMonitoring()
}
@@ -86,8 +115,16 @@ final class NetworkStatusViewModel: NSObject, ObservableObject,
default:
self.wifiState = .connectedWithoutInternet
}
+ } else if let interface = CWWiFiClient.shared().interface(),
+ interface.powerOn(),
+ interface.rssiValue() < 0 {
+ // WiFi is physically connected (has signal) but traffic
+ // is routed through a VPN tunnel, so NWPathMonitor
+ // doesn't report WiFi as the active transport.
+ // rssiValue() returns a negative dBm when associated,
+ // 0 when not — and unlike ssid(), needs no location auth.
+ self.wifiState = path.status == .satisfied ? .connected : .connectedWithoutInternet
} else {
- // If the Wi‑Fi interface is available but not in use – consider it enabled but not connected.
self.wifiState = .disconnected
}
} else {
@@ -125,16 +162,61 @@ final class NetworkStatusViewModel: NSObject, ObservableObject,
// MARK: — Updating Wi‑Fi information via CoreWLAN.
private func startWiFiMonitoring() {
- timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) {
- [weak self] _ in
- self?.updateWiFiInfo()
+ // Set up CWWiFiClient delegate for event-based SSID and link changes
+ wifiClient = CWWiFiClient.shared()
+ wifiClient?.delegate = self
+ do {
+ try wifiClient?.startMonitoringEvent(with: .ssidDidChange)
+ try wifiClient?.startMonitoringEvent(with: .linkDidChange)
+ } catch {
+ print("Failed to start WiFi event monitoring: \(error)")
}
+
+ // Initial update
updateWiFiInfo()
+
+ // Reduced polling (30 seconds) for signal strength updates only when connected
+ // RSSI has no event API, so we need polling for signal strength
+ startSignalStrengthPolling()
}
private func stopWiFiMonitoring() {
timer?.invalidate()
timer = nil
+
+ // Stop monitoring WiFi events
+ do {
+ try wifiClient?.stopMonitoringEvent(with: .ssidDidChange)
+ try wifiClient?.stopMonitoringEvent(with: .linkDidChange)
+ } catch {
+ print("Failed to stop WiFi event monitoring: \(error)")
+ }
+ wifiClient?.delegate = nil
+ wifiClient = nil
+ }
+
+ /// Start reduced polling for signal strength (RSSI) updates.
+ /// Only polls when WiFi is connected since RSSI has no event API.
+ private func startSignalStrengthPolling() {
+ timer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
+ [weak self] _ in
+ guard let self = self else { return }
+ // Only poll for signal strength when WiFi is connected
+ if self.wifiState == .connected || self.wifiState == .connectedWithoutInternet {
+ self.updateSignalStrength()
+ }
+ }
+ }
+
+ /// Update only signal strength (RSSI and noise) - used for polling
+ private func updateSignalStrength() {
+ let client = CWWiFiClient.shared()
+ if let interface = client.interface(), interface.ssid() != nil {
+ DispatchQueue.main.async {
+ self.rssi = interface.rssiValue()
+ self.noise = interface.noiseMeasurement()
+ }
+ }
}
private func updateWiFiInfo() {
@@ -170,6 +252,23 @@ final class NetworkStatusViewModel: NSObject, ObservableObject,
}
}
+ // MARK: — CWEventDelegate
+
+ /// Called when SSID changes (connecting to different network)
+ func ssidDidChangeForWiFiInterface(withName interfaceName: String) {
+ DispatchQueue.main.async {
+ self.updateWiFiInfo()
+ }
+ }
+
+ /// Called when link state changes (connected/disconnected)
+ func linkDidChangeForWiFiInterface(withName interfaceName: String) {
+ DispatchQueue.main.async {
+ self.updateWiFiInfo()
+ self.checkWiFiPowerState()
+ }
+ }
+
// MARK: — CLLocationManagerDelegate.
func locationManager(
@@ -178,4 +277,128 @@ final class NetworkStatusViewModel: NSObject, ObservableObject,
) {
updateWiFiInfo()
}
+
+ // MARK: — WiFi Control Methods
+
+ /// Toggle WiFi on/off
+ func toggleWiFi() {
+ let client = CWWiFiClient.shared()
+ guard let interface = client.interface() else { return }
+
+ do {
+ let newState = !isWiFiEnabled
+ try interface.setPower(newState)
+ DispatchQueue.main.async {
+ self.isWiFiEnabled = newState
+ if !newState {
+ self.ssid = "Not connected"
+ self.availableNetworks = []
+ } else {
+ self.updateWiFiInfo()
+ self.scanForNetworks()
+ }
+ }
+ } catch {
+ print("Failed to toggle WiFi: \(error)")
+ }
+ }
+
+ /// Scan for available WiFi networks
+ func scanForNetworks() {
+ guard isWiFiEnabled else { return }
+
+ isScanning = true
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self = self else { return }
+
+ let client = CWWiFiClient.shared()
+ guard let interface = client.interface() else {
+ DispatchQueue.main.async {
+ self.isScanning = false
+ }
+ return
+ }
+
+ do {
+ let networks = try interface.scanForNetworks(withSSID: nil)
+ let currentSSID = interface.ssid()
+
+ var networkList: [WiFiNetwork] = []
+ var seenSSIDs = Set()
+
+ for network in networks {
+ guard let ssid = network.ssid, !ssid.isEmpty, !seenSSIDs.contains(ssid) else {
+ continue
+ }
+ seenSSIDs.insert(ssid)
+
+ let wifiNetwork = WiFiNetwork(
+ ssid: ssid,
+ rssi: network.rssiValue,
+ isSecure: network.supportsSecurity(.wpaPersonal) ||
+ network.supportsSecurity(.wpa2Personal) ||
+ network.supportsSecurity(.wpa3Personal) ||
+ network.supportsSecurity(.dynamicWEP),
+ isConnected: ssid == currentSSID
+ )
+ networkList.append(wifiNetwork)
+ }
+
+ // Sort: connected first, then by signal strength
+ networkList.sort { lhs, rhs in
+ if lhs.isConnected != rhs.isConnected {
+ return lhs.isConnected
+ }
+ return lhs.rssi > rhs.rssi
+ }
+
+ DispatchQueue.main.async {
+ self.availableNetworks = networkList
+ self.isScanning = false
+ }
+ } catch {
+ print("Failed to scan for networks: \(error)")
+ DispatchQueue.main.async {
+ self.isScanning = false
+ }
+ }
+ }
+ }
+
+ /// Connect to a WiFi network
+ func connectToNetwork(_ network: WiFiNetwork, password: String? = nil) {
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ let client = CWWiFiClient.shared()
+ guard let interface = client.interface() else { return }
+
+ do {
+ let networks = try interface.scanForNetworks(withSSID: network.ssid.data(using: .utf8))
+ guard let targetNetwork = networks.first else { return }
+
+ try interface.associate(to: targetNetwork, password: password)
+
+ DispatchQueue.main.async {
+ self?.updateWiFiInfo()
+ self?.scanForNetworks()
+ }
+ } catch {
+ print("Failed to connect to network: \(error)")
+ }
+ }
+ }
+
+ /// Open WiFi settings in System Preferences
+ func openWiFiSettings() {
+ if let url = URL(string: "x-apple.systempreferences:com.apple.Network-Settings.extension") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+
+ /// Check WiFi power state
+ private func checkWiFiPowerState() {
+ let client = CWWiFiClient.shared()
+ if let interface = client.interface() {
+ isWiFiEnabled = interface.powerOn()
+ }
+ }
}
diff --git a/Barik/Widgets/NowPlaying/NowPlayingManager.swift b/Barik/Widgets/NowPlaying/NowPlayingManager.swift
index 61e1c61..7cbdb87 100644
--- a/Barik/Widgets/NowPlaying/NowPlayingManager.swift
+++ b/Barik/Widgets/NowPlaying/NowPlayingManager.swift
@@ -65,6 +65,16 @@ enum MusicApp: String, CaseIterable {
case spotify = "Spotify"
case music = "Music"
+ /// The notification name for playback state changes.
+ var notificationName: Notification.Name {
+ switch self {
+ case .spotify:
+ return Notification.Name("com.spotify.client.PlaybackStateChanged")
+ case .music:
+ return Notification.Name("com.apple.Music.playerInfo")
+ }
+ }
+
/// AppleScript to fetch the now playing song.
var nowPlayingScript: String {
if self == .music {
@@ -143,7 +153,7 @@ final class NowPlayingProvider {
}
/// Returns the now playing song for a specific music application.
- private static func fetchNowPlaying(from app: MusicApp) -> NowPlayingSong? {
+ static func fetchNowPlaying(from app: MusicApp) -> NowPlayingSong? {
guard let output = runAppleScript(app.nowPlayingScript),
output != "stopped"
else {
@@ -189,26 +199,71 @@ final class NowPlayingProvider {
// MARK: - Now Playing Manager
-/// An observable manager that periodically updates the now playing song.
+/// An observable manager that uses event-driven notifications to update the now playing song.
final class NowPlayingManager: ObservableObject {
static let shared = NowPlayingManager()
@Published private(set) var nowPlaying: NowPlayingSong?
- private var cancellable: AnyCancellable?
+ private var notificationTasks: [Task] = []
+ private var positionUpdateCancellable: AnyCancellable?
private init() {
- cancellable = Timer.publish(every: 0.3, on: .main, in: .common)
+ setupNotificationObservers()
+ // Initial fetch to populate current state
+ updateNowPlaying()
+ // Timer for position updates only (less frequent, only when playing)
+ setupPositionUpdates()
+ }
+
+ deinit {
+ notificationTasks.forEach { $0.cancel() }
+ }
+
+ /// Sets up observers for music app notifications using DistributedNotificationCenter.
+ private func setupNotificationObservers() {
+ for app in MusicApp.allCases {
+ let task = Task { @MainActor [weak self] in
+ let notifications = DistributedNotificationCenter.default().notifications(
+ named: app.notificationName
+ )
+ for await _ in notifications {
+ self?.handleNotification(from: app)
+ }
+ }
+ notificationTasks.append(task)
+ }
+ }
+
+ /// Handles a notification from a music application.
+ @MainActor
+ private func handleNotification(from app: MusicApp) {
+ // Fetch on background thread to avoid blocking
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ let song = NowPlayingProvider.fetchNowPlaying(from: app)
+ DispatchQueue.main.async {
+ self?.nowPlaying = song
+ }
+ }
+ }
+
+ /// Sets up a timer for position updates (only needed for progress bar).
+ private func setupPositionUpdates() {
+ // Update position every 1 second (only when playing)
+ positionUpdateCancellable = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
- self?.updateNowPlaying()
+ guard let self = self,
+ let current = self.nowPlaying,
+ current.state == .playing else { return }
+ self.updateNowPlaying()
}
}
/// Updates the now playing song asynchronously.
private func updateNowPlaying() {
- DispatchQueue.global(qos: .background).async {
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let song = NowPlayingProvider.fetchNowPlaying()
- DispatchQueue.main.async { [weak self] in
+ DispatchQueue.main.async {
self?.nowPlaying = song
}
}
@@ -228,4 +283,15 @@ final class NowPlayingManager: ObservableObject {
func nextTrack() {
NowPlayingProvider.executeCommand { $0.nextTrackCommand }
}
+
+ /// Opens and activates the music application that is currently playing.
+ func openMusicApp() {
+ guard let song = nowPlaying else { return }
+ if let app = NSWorkspace.shared.runningApplications.first(where: {
+ $0.localizedName == song.appName
+ }),
+ let bundleURL = app.bundleURL {
+ NSWorkspace.shared.open(bundleURL)
+ }
+ }
}
diff --git a/Barik/Widgets/NowPlaying/NowPlayingPopup.swift b/Barik/Widgets/NowPlaying/NowPlayingPopup.swift
index 071cafb..39775d3 100644
--- a/Barik/Widgets/NowPlaying/NowPlayingPopup.swift
+++ b/Barik/Widgets/NowPlaying/NowPlayingPopup.swift
@@ -66,6 +66,7 @@ private struct NowPlayingVerticalPopup: View {
: nil
)
.animation(.smooth(duration: 0.5, extraBounce: 0.4), value: song.state == .paused)
+ .onTapGesture { playingManager.openMusicApp() }
VStack(alignment: .center) {
Text(song.title)
@@ -77,6 +78,7 @@ private struct NowPlayingVerticalPopup: View {
.font(.system(size: 15))
.fontWeight(.light)
}
+ .onTapGesture { playingManager.openMusicApp() }
HStack {
Text(timeString(from: position))
@@ -135,6 +137,7 @@ struct NowPlayingHorizontalPopup: View {
: nil
)
.animation(.smooth(duration: 0.5, extraBounce: 0.4), value: song.state == .paused)
+ .onTapGesture { playingManager.openMusicApp() }
VStack(alignment: .leading, spacing: 0) {
Text(song.title)
@@ -147,6 +150,7 @@ struct NowPlayingHorizontalPopup: View {
}
.padding(.trailing, 8)
.frame(maxWidth: .infinity, alignment: .leading)
+ .onTapGesture { playingManager.openMusicApp() }
}
HStack {
diff --git a/Barik/Widgets/NowPlaying/NowPlayingWidget.swift b/Barik/Widgets/NowPlaying/NowPlayingWidget.swift
index 81040d4..4fc2299 100644
--- a/Barik/Widgets/NowPlaying/NowPlayingWidget.swift
+++ b/Barik/Widgets/NowPlaying/NowPlayingWidget.swift
@@ -26,6 +26,7 @@ struct NowPlayingWidget: View {
// Visible content with fixed animated width.
VisibleNowPlayingContent(song: song, width: animatedWidth)
+ .contentShape(Rectangle())
.onTapGesture {
MenuBarPopup.show(rect: widgetFrame, id: "nowplaying") {
NowPlayingPopup(configProvider: configProvider)
diff --git a/Barik/Widgets/Pomodoro/PomodoroManager.swift b/Barik/Widgets/Pomodoro/PomodoroManager.swift
new file mode 100644
index 0000000..e9e34e1
--- /dev/null
+++ b/Barik/Widgets/Pomodoro/PomodoroManager.swift
@@ -0,0 +1,168 @@
+import Combine
+import Foundation
+import UserNotifications
+
+enum PomodoroPhase: String {
+ case idle
+ case working = "Working"
+ case onBreak = "Break"
+ case onLongBreak = "Long Break"
+}
+
+private class PomodoroNotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ completionHandler([.banner, .sound])
+ }
+}
+
+class PomodoroManager: ObservableObject {
+ static let shared = PomodoroManager()
+
+ private let notificationDelegate = PomodoroNotificationDelegate()
+
+ @Published var phase: PomodoroPhase = .idle
+ @Published var timeRemaining: Int = 0
+ @Published var completedSessions: Int = 0
+ @Published var isPaused: Bool = false
+
+ @Published var showTimerText: Bool = false
+
+ @Published var workDuration: Int = 25
+ @Published var breakDuration: Int = 5
+ @Published var longBreakDuration: Int = 15
+ @Published var sessionsBeforeLongBreak: Int = 4
+
+ var isActive: Bool { phase != .idle }
+
+ var totalDuration: Int {
+ switch phase {
+ case .idle: return workDuration * 60
+ case .working: return workDuration * 60
+ case .onBreak: return breakDuration * 60
+ case .onLongBreak: return longBreakDuration * 60
+ }
+ }
+
+ var progress: Double {
+ guard totalDuration > 0 else { return 0 }
+ return Double(totalDuration - timeRemaining) / Double(totalDuration)
+ }
+
+ private var timerCancellable: AnyCancellable?
+
+ init() {
+ UNUserNotificationCenter.current().delegate = notificationDelegate
+ requestNotificationPermission()
+ }
+
+ func start() {
+ phase = .working
+ timeRemaining = workDuration * 60
+ isPaused = false
+ startTimer()
+ }
+
+ func pause() {
+ isPaused = true
+ timerCancellable?.cancel()
+ timerCancellable = nil
+ }
+
+ func resume() {
+ isPaused = false
+ startTimer()
+ }
+
+ func reset() {
+ timerCancellable?.cancel()
+ timerCancellable = nil
+ phase = .idle
+ timeRemaining = 0
+ completedSessions = 0
+ isPaused = false
+ }
+
+ func skip() {
+ timerCancellable?.cancel()
+ timerCancellable = nil
+ transitionToNextPhase()
+ }
+
+ private func startTimer() {
+ timerCancellable = Timer.publish(every: 1, on: .main, in: .common)
+ .autoconnect()
+ .sink { [weak self] _ in
+ self?.tick()
+ }
+ }
+
+ private func tick() {
+ guard timeRemaining > 0 else { return }
+ timeRemaining -= 1
+ if timeRemaining == 0 {
+ timerCancellable?.cancel()
+ timerCancellable = nil
+ sendNotification()
+ transitionToNextPhase()
+ }
+ }
+
+ private func transitionToNextPhase() {
+ switch phase {
+ case .idle:
+ break
+ case .working:
+ completedSessions += 1
+ if completedSessions >= sessionsBeforeLongBreak {
+ phase = .onLongBreak
+ timeRemaining = longBreakDuration * 60
+ completedSessions = 0
+ } else {
+ phase = .onBreak
+ timeRemaining = breakDuration * 60
+ }
+ isPaused = false
+ startTimer()
+ case .onBreak, .onLongBreak:
+ phase = .working
+ timeRemaining = workDuration * 60
+ isPaused = false
+ startTimer()
+ }
+ }
+
+ private func requestNotificationPermission() {
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
+ }
+
+ private func sendNotification() {
+ let content = UNMutableNotificationContent()
+ switch phase {
+ case .working:
+ content.title = "Pomodoro Complete"
+ content.body = "Time for a break!"
+ case .onBreak, .onLongBreak:
+ content.title = "Break Over"
+ content.body = "Ready to focus?"
+ case .idle:
+ return
+ }
+ content.sound = .default
+ let request = UNNotificationRequest(
+ identifier: UUID().uuidString,
+ content: content,
+ trigger: nil
+ )
+ UNUserNotificationCenter.current().add(request)
+ }
+
+ var formattedTime: String {
+ let minutes = timeRemaining / 60
+ let seconds = timeRemaining % 60
+ return String(format: "%d:%02d", minutes, seconds)
+ }
+}
diff --git a/Barik/Widgets/Pomodoro/PomodoroPopup.swift b/Barik/Widgets/Pomodoro/PomodoroPopup.swift
new file mode 100644
index 0000000..daae51f
--- /dev/null
+++ b/Barik/Widgets/Pomodoro/PomodoroPopup.swift
@@ -0,0 +1,232 @@
+import SwiftUI
+import UserNotifications
+
+struct PomodoroPopup: View {
+ @ObservedObject private var manager = PomodoroManager.shared
+ @State private var showSettings = false
+ @State private var notificationStatus: UNAuthorizationStatus = .notDetermined
+
+ var body: some View {
+ VStack(spacing: 16) {
+ // Header with settings toggle
+ ZStack {
+ Text(manager.isActive ? manager.phase.rawValue : "Pomodoro")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(phaseColor)
+ HStack {
+ Spacer()
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ showSettings.toggle()
+ }
+ } label: {
+ Image(systemName: showSettings ? "xmark" : "gearshape")
+ .font(.system(size: 11))
+ .foregroundStyle(.gray)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
+ if showSettings {
+ settingsView
+ } else {
+ timerView
+ }
+ }
+ .frame(width: 180)
+ .padding(24)
+ }
+
+ @ViewBuilder
+ private var timerView: some View {
+ // Circular progress ring
+ ZStack {
+ Circle()
+ .stroke(Color.gray.opacity(0.3), lineWidth: 6)
+ Circle()
+ .trim(from: 0, to: manager.isActive ? manager.progress : 0)
+ .stroke(
+ phaseColor,
+ style: StrokeStyle(lineWidth: 6, lineCap: .round)
+ )
+ .rotationEffect(.degrees(-90))
+ .animation(.linear(duration: 1), value: manager.progress)
+
+ VStack(spacing: 2) {
+ Text(manager.isActive ? manager.formattedTime : "0:00")
+ .font(.system(size: 20, weight: .bold, design: .monospaced))
+ .foregroundStyle(.white)
+ .contentTransition(.numericText())
+ .animation(.default, value: manager.timeRemaining)
+
+ if manager.isPaused {
+ Text("Paused")
+ .font(.system(size: 10))
+ .foregroundStyle(.gray)
+ }
+ }
+ }
+ .frame(width: 80, height: 80)
+
+ // Session dots
+ if manager.isActive || manager.completedSessions > 0 {
+ HStack(spacing: 6) {
+ ForEach(0.. Void) -> some View {
+ Button(action: action) {
+ Image(systemName: systemName)
+ .font(.system(size: 14))
+ .foregroundStyle(color)
+ .frame(width: 32, height: 32)
+ .background(color.opacity(0.15))
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+
+ private func durationStepper(_ label: String, value: Binding, range: ClosedRange) -> some View {
+ HStack {
+ Text(label)
+ .font(.system(size: 12))
+ .foregroundStyle(.gray)
+ Spacer()
+ HStack(spacing: 8) {
+ Button {
+ if value.wrappedValue > range.lowerBound {
+ value.wrappedValue -= 1
+ }
+ } label: {
+ Image(systemName: "minus")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundStyle(.white)
+ .frame(width: 22, height: 22)
+ .background(Color.gray.opacity(0.3))
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+
+ Text("\(value.wrappedValue)")
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundStyle(.white)
+ .frame(width: 28)
+
+ Button {
+ if value.wrappedValue < range.upperBound {
+ value.wrappedValue += 1
+ }
+ } label: {
+ Image(systemName: "plus")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundStyle(.white)
+ .frame(width: 22, height: 22)
+ .background(Color.gray.opacity(0.3))
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+}
diff --git a/Barik/Widgets/Pomodoro/PomodoroWidget.swift b/Barik/Widgets/Pomodoro/PomodoroWidget.swift
new file mode 100644
index 0000000..d1987e8
--- /dev/null
+++ b/Barik/Widgets/Pomodoro/PomodoroWidget.swift
@@ -0,0 +1,133 @@
+import SwiftUI
+
+struct PieShape: Shape {
+ var fillAmount: Double
+
+ var animatableData: Double {
+ get { fillAmount }
+ set { fillAmount = newValue }
+ }
+
+ func path(in rect: CGRect) -> Path {
+ let center = CGPoint(x: rect.midX, y: rect.midY)
+ let radius = min(rect.width, rect.height) / 2
+ let startAngle = Angle(degrees: -90)
+ let endAngle = Angle(degrees: -90 + 360 * fillAmount)
+
+ var path = Path()
+ if fillAmount >= 1.0 {
+ path.addEllipse(in: rect)
+ } else if fillAmount > 0 {
+ path.move(to: center)
+ path.addArc(
+ center: center, radius: radius,
+ startAngle: startAngle, endAngle: endAngle,
+ clockwise: false)
+ path.closeSubpath()
+ }
+ return path
+ }
+}
+
+struct PomodoroWidget: View {
+ @EnvironmentObject var configProvider: ConfigProvider
+ var config: ConfigData { configProvider.config }
+
+ @ObservedObject private var manager = PomodoroManager.shared
+
+ @State private var rect: CGRect = .zero
+
+ var body: some View {
+ HStack(spacing: 4) {
+ if manager.showTimerText {
+ Image("TomatoIcon")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ .foregroundStyle(phaseColor)
+ } else {
+ progressIcon
+ }
+
+ if manager.isActive && manager.showTimerText {
+ Text(manager.formattedTime)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundStyle(.foregroundOutside)
+ .contentTransition(.numericText())
+ .animation(.default, value: manager.timeRemaining)
+ }
+ }
+ .shadow(color: .foregroundShadowOutside, radius: 3)
+ .background(
+ GeometryReader { geometry in
+ Color.clear
+ .onAppear {
+ rect = geometry.frame(in: .global)
+ }
+ .onChange(of: geometry.frame(in: .global)) { _, newState in
+ rect = newState
+ }
+ }
+ )
+ .experimentalConfiguration(cornerRadius: 15)
+ .frame(maxHeight: .infinity)
+ .background(.black.opacity(0.001))
+ .onTapGesture {
+ MenuBarPopup.show(rect: rect, id: "pomodoro") {
+ PomodoroPopup()
+ }
+ }
+ .onAppear {
+ applyConfig()
+ }
+ }
+
+ private var progressIcon: some View {
+ ZStack {
+ if manager.isActive {
+ // Dim filled body — always visible as the "empty" state
+ Image("TomatoIconFilled")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ .foregroundStyle(phaseColor.opacity(0.2))
+
+ // Filled body masked by pie — shows remaining time as fill level
+ Image("TomatoIconFilled")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ .foregroundStyle(phaseColor)
+ .mask(
+ PieShape(fillAmount: 1.0 - manager.progress)
+ .frame(width: 20, height: 20)
+ )
+ .animation(.linear(duration: 1), value: manager.progress)
+ }
+
+ // Outline always visible on top
+ Image("TomatoIcon")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ .foregroundStyle(phaseColor)
+ }
+ }
+
+ private var phaseColor: Color {
+ switch manager.phase {
+ case .idle: return .foregroundOutside
+ case .working: return .red
+ case .onBreak: return .green
+ case .onLongBreak: return .blue
+ }
+ }
+
+ private func applyConfig() {
+ manager.workDuration = config["work-duration"]?.intValue ?? 25
+ manager.breakDuration = config["break-duration"]?.intValue ?? 5
+ manager.longBreakDuration = config["long-break-duration"]?.intValue ?? 15
+ manager.sessionsBeforeLongBreak = config["sessions-before-long-break"]?.intValue ?? 4
+ manager.showTimerText = config["icon-style"]?.stringValue == "timer"
+ }
+}
diff --git a/Barik/Widgets/Spaces/SpacesModels.swift b/Barik/Widgets/Spaces/SpacesModels.swift
index 1d8848d..ce31362 100644
--- a/Barik/Widgets/Spaces/SpacesModels.swift
+++ b/Barik/Widgets/Spaces/SpacesModels.swift
@@ -1,4 +1,5 @@
import AppKit
+import Combine
protocol SpaceModel: Identifiable, Equatable, Codable {
associatedtype WindowType: WindowModel
@@ -19,6 +20,23 @@ protocol SpacesProvider {
func getSpacesWithWindows() -> [SpaceType]?
}
+// MARK: - Event-Based Provider Support
+
+enum SpaceEvent {
+ case initialState([AnySpace])
+ case focusChanged(String)
+ case windowsUpdated(String, [AnyWindow])
+ case spaceCreated(String)
+ case spaceDestroyed(String)
+}
+
+protocol EventBasedSpacesProvider {
+ var spacesPublisher: AnyPublisher { get }
+
+ func startObserving()
+ func stopObserving()
+}
+
protocol SwitchableSpacesProvider: SpacesProvider {
func focusSpace(spaceId: String, needWindowFocus: Bool)
func focusWindow(windowId: String)
@@ -62,6 +80,12 @@ struct AnySpace: Identifiable, Equatable {
self.windows = space.windows.map { AnyWindow($0) }
}
+ init(id: String, isFocused: Bool, windows: [AnyWindow]) {
+ self.id = id
+ self.isFocused = isFocused
+ self.windows = windows
+ }
+
static func == (lhs: AnySpace, rhs: AnySpace) -> Bool {
return lhs.id == rhs.id && lhs.isFocused == rhs.isFocused
&& lhs.windows == rhs.windows
@@ -73,10 +97,19 @@ class AnySpacesProvider {
private let _focusSpace: ((String, Bool) -> Void)?
private let _focusWindow: ((String) -> Void)?
+ private let _isEventBased: Bool
+ private let _startObserving: (() -> Void)?
+ private let _stopObserving: (() -> Void)?
+ private let _spacesPublisher: AnyPublisher?
+
+ var isEventBased: Bool { _isEventBased }
+ var spacesPublisher: AnyPublisher? { _spacesPublisher }
+
init(_ provider: P) {
_getSpacesWithWindows = {
provider.getSpacesWithWindows()?.map { AnySpace($0) }
}
+
if let switchable = provider as? any SwitchableSpacesProvider {
_focusSpace = { spaceId, needWindowFocus in
switchable.focusSpace(
@@ -89,6 +122,18 @@ class AnySpacesProvider {
_focusSpace = nil
_focusWindow = nil
}
+
+ if let eventBased = provider as? any EventBasedSpacesProvider {
+ _isEventBased = true
+ _startObserving = eventBased.startObserving
+ _stopObserving = eventBased.stopObserving
+ _spacesPublisher = eventBased.spacesPublisher
+ } else {
+ _isEventBased = false
+ _startObserving = nil
+ _stopObserving = nil
+ _spacesPublisher = nil
+ }
}
func getSpacesWithWindows() -> [AnySpace]? {
@@ -102,4 +147,12 @@ class AnySpacesProvider {
func focusWindow(windowId: String) {
_focusWindow?(windowId)
}
+
+ func startObserving() {
+ _startObserving?()
+ }
+
+ func stopObserving() {
+ _stopObserving?()
+ }
}
diff --git a/Barik/Widgets/Spaces/SpacesViewModel.swift b/Barik/Widgets/Spaces/SpacesViewModel.swift
index 858e59b..6efba17 100644
--- a/Barik/Widgets/Spaces/SpacesViewModel.swift
+++ b/Barik/Widgets/Spaces/SpacesViewModel.swift
@@ -4,8 +4,10 @@ import Foundation
class SpacesViewModel: ObservableObject {
@Published var spaces: [AnySpace] = []
- private var timer: Timer?
private var provider: AnySpacesProvider?
+ private var cancellables: Set = []
+ private var spacesById: [String: AnySpace] = [:]
+ private var workspaceObservers: [NSObjectProtocol] = []
init() {
let runningApps = NSWorkspace.shared.runningApplications.compactMap {
@@ -26,16 +28,120 @@ class SpacesViewModel: ObservableObject {
}
private func startMonitoring() {
- timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) {
- [weak self] _ in
+ if let provider = provider {
+ if provider.isEventBased {
+ startMonitoringEventBasedProvider()
+ } else {
+ startMonitoringWithWorkspaceNotifications()
+ }
+ }
+ }
+
+ private func stopMonitoring() {
+ if let provider = provider {
+ if provider.isEventBased {
+ stopMonitoringEventBasedProvider()
+ } else {
+ stopMonitoringWorkspaceNotifications()
+ }
+ }
+ }
+
+ private func startMonitoringWithWorkspaceNotifications() {
+ let notificationCenter = NSWorkspace.shared.notificationCenter
+
+ // Observe space changes
+ let spaceObserver = notificationCenter.addObserver(
+ forName: NSWorkspace.activeSpaceDidChangeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.loadSpaces()
+ }
+ workspaceObservers.append(spaceObserver)
+
+ // Observe application activation (may indicate space/window changes)
+ let activateObserver = notificationCenter.addObserver(
+ forName: NSWorkspace.didActivateApplicationNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
self?.loadSpaces()
}
+ workspaceObservers.append(activateObserver)
+
+ // Observe application deactivation
+ let deactivateObserver = notificationCenter.addObserver(
+ forName: NSWorkspace.didDeactivateApplicationNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.loadSpaces()
+ }
+ workspaceObservers.append(deactivateObserver)
+
+ // Load initial state
loadSpaces()
}
- private func stopMonitoring() {
- timer?.invalidate()
- timer = nil
+ private func stopMonitoringWorkspaceNotifications() {
+ let notificationCenter = NSWorkspace.shared.notificationCenter
+ for observer in workspaceObservers {
+ notificationCenter.removeObserver(observer)
+ }
+ workspaceObservers.removeAll()
+ }
+
+ private func startMonitoringEventBasedProvider() {
+ guard let provider = provider else { return }
+ provider.spacesPublisher?
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] event in
+ self?.handleSpaceEvent(event)
+ }
+ .store(in: &cancellables)
+ provider.startObserving()
+ }
+
+ private func stopMonitoringEventBasedProvider() {
+ provider?.stopObserving()
+ cancellables.removeAll()
+ }
+
+ private func handleSpaceEvent(_ event: SpaceEvent) {
+ switch event {
+ case .initialState(let spaces):
+ spacesById = Dictionary(uniqueKeysWithValues: spaces.map { ($0.id, $0) })
+ updatePublishedSpaces()
+ case .focusChanged(let spaceId):
+ for (id, space) in spacesById {
+ let newFocused = id == spaceId
+ if space.isFocused != newFocused {
+ spacesById[id] = AnySpace(
+ id: space.id, isFocused: newFocused, windows: space.windows)
+ }
+ }
+ updatePublishedSpaces()
+ case .windowsUpdated(let spaceId, let windows):
+ if let space = spacesById[spaceId] {
+ spacesById[spaceId] = AnySpace(
+ id: space.id, isFocused: space.isFocused, windows: windows)
+ }
+ updatePublishedSpaces()
+ case .spaceCreated(let spaceId):
+ spacesById[spaceId] = AnySpace(id: spaceId, isFocused: false, windows: [])
+ updatePublishedSpaces()
+ case .spaceDestroyed(let spaceId):
+ spacesById.removeValue(forKey: spaceId)
+ updatePublishedSpaces()
+ }
+ }
+
+ private func updatePublishedSpaces() {
+ let sortedSpaces = spacesById.values.sorted { $0.id < $1.id }
+ if sortedSpaces != spaces {
+ spaces = sortedSpaces
+ }
}
private func loadSpaces() {
diff --git a/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift b/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift
index 91d3e0e..2ef7bdf 100644
--- a/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift
+++ b/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift
@@ -1,9 +1,171 @@
+import Combine
import Foundation
-class YabaiSpacesProvider: SpacesProvider, SwitchableSpacesProvider {
+class YabaiSpacesProvider: SpacesProvider, SwitchableSpacesProvider, EventBasedSpacesProvider {
typealias SpaceType = YabaiSpace
let executablePath = ConfigManager.shared.config.yabai.path
+ // MARK: - Event-Based Provider Support
+
+ private let spacesSubject = PassthroughSubject()
+ var spacesPublisher: AnyPublisher {
+ spacesSubject.eraseToAnyPublisher()
+ }
+
+ private var socketFileDescriptor: Int32 = -1
+ private var socketPath = "/tmp/barik-yabai.sock"
+ private var isObserving = false
+ private var socketQueue = DispatchQueue(label: "com.barik.yabai.socket", qos: .userInitiated)
+
+ func startObserving() {
+ guard !isObserving else { return }
+ isObserving = true
+
+ // Send initial state asynchronously to avoid blocking main thread
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self = self else { return }
+ if let spaces = self.getSpacesWithWindows() {
+ let anySpaces = spaces.map { AnySpace($0) }
+ DispatchQueue.main.async {
+ self.spacesSubject.send(.initialState(anySpaces))
+ }
+ }
+ }
+
+ // Start socket listener
+ socketQueue.async { [weak self] in
+ self?.startSocketListener()
+ }
+ }
+
+ func stopObserving() {
+ isObserving = false
+ if socketFileDescriptor >= 0 {
+ close(socketFileDescriptor)
+ socketFileDescriptor = -1
+ }
+ unlink(socketPath)
+ }
+
+ private func startSocketListener() {
+ // Remove existing socket file
+ unlink(socketPath)
+
+ // Create Unix domain socket (SOCK_STREAM for nc -U compatibility)
+ socketFileDescriptor = socket(AF_UNIX, SOCK_STREAM, 0)
+ guard socketFileDescriptor >= 0 else {
+ print("Failed to create socket")
+ return
+ }
+
+ var addr = sockaddr_un()
+ addr.sun_family = sa_family_t(AF_UNIX)
+ let pathSize = MemoryLayout.size(ofValue: addr.sun_path)
+ socketPath.withCString { ptr in
+ withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
+ let pathBytes = UnsafeMutableRawPointer(pathPtr)
+ .assumingMemoryBound(to: CChar.self)
+ strncpy(pathBytes, ptr, pathSize - 1)
+ }
+ }
+
+ let bindResult = withUnsafePointer(to: &addr) { addrPtr in
+ addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
+ bind(socketFileDescriptor, sockaddrPtr, socklen_t(MemoryLayout.size))
+ }
+ }
+
+ guard bindResult >= 0 else {
+ print("Failed to bind socket: \(String(cString: strerror(errno)))")
+ close(socketFileDescriptor)
+ socketFileDescriptor = -1
+ return
+ }
+
+ // Listen for incoming connections
+ guard listen(socketFileDescriptor, 5) >= 0 else {
+ print("Failed to listen on socket: \(String(cString: strerror(errno)))")
+ close(socketFileDescriptor)
+ socketFileDescriptor = -1
+ return
+ }
+
+ // Accept connections and read messages
+ var buffer = [CChar](repeating: 0, count: 1024)
+ while isObserving && socketFileDescriptor >= 0 {
+ let clientFd = accept(socketFileDescriptor, nil, nil)
+ if clientFd >= 0 {
+ let bytesRead = recv(clientFd, &buffer, buffer.count - 1, 0)
+ if bytesRead > 0 {
+ buffer[bytesRead] = 0
+ let message = String(cString: buffer)
+ handleSocketMessage(message.trimmingCharacters(in: .whitespacesAndNewlines))
+ }
+ close(clientFd)
+ }
+ }
+ }
+
+ private func handleSocketMessage(_ message: String) {
+ // Parse message format: "event_type:data" or just "event_type"
+ let parts = message.split(separator: ":", maxSplits: 1)
+ let eventType = String(parts[0])
+ let data = parts.count > 1 ? String(parts[1]) : nil
+
+ switch eventType {
+ case "space_changed":
+ if let spaceId = data {
+ spacesSubject.send(.focusChanged(spaceId))
+ } else {
+ // Fallback: query current focused space
+ refreshSpaces()
+ }
+
+ case "window_focused", "window_created", "window_destroyed", "window_moved":
+ // For window events, refresh the windows for affected space
+ if let spaceIdStr = data, let spaces = getSpacesWithWindows() {
+ if let space = spaces.first(where: { String($0.id) == spaceIdStr }) {
+ let windows = space.windows.map { AnyWindow($0) }
+ spacesSubject.send(.windowsUpdated(spaceIdStr, windows))
+ }
+ } else {
+ refreshSpaces()
+ }
+
+ case "space_created":
+ if let spaceId = data {
+ spacesSubject.send(.spaceCreated(spaceId))
+ } else {
+ refreshSpaces()
+ }
+
+ case "space_destroyed":
+ if let spaceId = data {
+ spacesSubject.send(.spaceDestroyed(spaceId))
+ } else {
+ refreshSpaces()
+ }
+
+ default:
+ // Unknown event, refresh all
+ refreshSpaces()
+ }
+ }
+
+ private func refreshSpaces() {
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self = self else { return }
+ if let spaces = self.getSpacesWithWindows() {
+ let anySpaces = spaces.map { AnySpace($0) }
+ DispatchQueue.main.async {
+ self.spacesSubject.send(.initialState(anySpaces))
+ }
+ }
+ }
+ }
+
+ // MARK: - Original Provider Methods
+
private func runYabaiCommand(arguments: [String]) -> Data? {
let process = Process()
process.executableURL = URL(fileURLWithPath: executablePath)
diff --git a/Barik/Widgets/Time+Calendar/CalendarManager.swift b/Barik/Widgets/Time+Calendar/CalendarManager.swift
index e7ea454..0d6b85b 100644
--- a/Barik/Widgets/Time+Calendar/CalendarManager.swift
+++ b/Barik/Widgets/Time+Calendar/CalendarManager.swift
@@ -4,56 +4,98 @@ import Foundation
class CalendarManager: ObservableObject {
let configProvider: ConfigProvider
- var config: ConfigData? {
- configProvider.config["calendar"]?.dictionaryValue
+
+ // Read config directly from ConfigManager.shared to get latest values
+ private var calendarConfig: ConfigData? {
+ let widgetConfig = ConfigManager.shared.globalWidgetConfig(for: "default.time")
+ return widgetConfig["calendar"]?.dictionaryValue
}
var allowList: [String] {
Array(
- (config?["allow-list"]?.arrayValue?.map { $0.stringValue ?? "" }
+ (calendarConfig?["allow-list"]?.arrayValue?.map { $0.stringValue ?? "" }
.drop(while: { $0 == "" })) ?? [])
}
var denyList: [String] {
Array(
- (config?["deny-list"]?.arrayValue?.map { $0.stringValue ?? "" }
+ (calendarConfig?["deny-list"]?.arrayValue?.map { $0.stringValue ?? "" }
.drop(while: { $0 == "" })) ?? [])
}
@Published var nextEvent: EKEvent?
@Published var todaysEvents: [EKEvent] = []
@Published var tomorrowsEvents: [EKEvent] = []
- private let eventStore = EKEventStore()
- private var timer: Timer?
+ @Published var allCalendars: [EKCalendar] = []
+ let eventStore = EKEventStore()
+ private var debounceTimer: Timer?
+ private var configCancellable: AnyCancellable?
init(configProvider: ConfigProvider) {
self.configProvider = configProvider
requestAccess()
startMonitoring()
+
+ // Subscribe to ConfigManager.shared config changes to re-fetch events when deny-list changes
+ configCancellable = ConfigManager.shared.$config
+ .dropFirst() // Skip initial value
+ .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
+ .sink { [weak self] _ in
+ self?.fetchTodaysEvents()
+ self?.fetchTomorrowsEvents()
+ self?.fetchNextEvent()
+ }
}
deinit {
stopMonitoring()
+ configCancellable?.cancel()
}
private func startMonitoring() {
- timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) {
- [weak self] _ in
- self?.fetchTodaysEvents()
- self?.fetchTomorrowsEvents()
- self?.fetchNextEvent()
- }
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(handleCalendarStoreChanged),
+ name: .EKEventStoreChanged,
+ object: eventStore
+ )
+ fetchAllCalendars()
fetchTodaysEvents()
fetchTomorrowsEvents()
fetchNextEvent()
}
+ func fetchAllCalendars() {
+ let calendars = eventStore.calendars(for: .event)
+ .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
+ DispatchQueue.main.async {
+ self.allCalendars = calendars
+ }
+ }
+
private func stopMonitoring() {
- timer?.invalidate()
- timer = nil
+ debounceTimer?.invalidate()
+ debounceTimer = nil
+ NotificationCenter.default.removeObserver(
+ self,
+ name: .EKEventStoreChanged,
+ object: eventStore
+ )
+ }
+
+ @objc private func handleCalendarStoreChanged() {
+ debounceTimer?.invalidate()
+ debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) {
+ [weak self] _ in
+ self?.fetchAllCalendars()
+ self?.fetchTodaysEvents()
+ self?.fetchTomorrowsEvents()
+ self?.fetchNextEvent()
+ }
}
private func requestAccess() {
eventStore.requestFullAccessToEvents { [weak self] granted, error in
if granted && error == nil {
+ self?.fetchAllCalendars()
self?.fetchTodaysEvents()
self?.fetchTomorrowsEvents()
self?.fetchNextEvent()
@@ -67,10 +109,10 @@ class CalendarManager: ObservableObject {
private func filterEvents(_ events: [EKEvent]) -> [EKEvent] {
var filtered = events
if !allowList.isEmpty {
- filtered = filtered.filter { allowList.contains($0.calendar.title) }
+ filtered = filtered.filter { allowList.contains($0.calendar.calendarIdentifier) }
}
if !denyList.isEmpty {
- filtered = filtered.filter { !denyList.contains($0.calendar.title) }
+ filtered = filtered.filter { !denyList.contains($0.calendar.calendarIdentifier) }
}
return filtered
}
diff --git a/Barik/Widgets/Time+Calendar/CalendarPopup.swift b/Barik/Widgets/Time+Calendar/CalendarPopup.swift
index 7918dbb..a7378a7 100644
--- a/Barik/Widgets/Time+Calendar/CalendarPopup.swift
+++ b/Barik/Widgets/Time+Calendar/CalendarPopup.swift
@@ -1,6 +1,64 @@
+import AppKit
import EventKit
import SwiftUI
+// MARK: - Calendar App Configuration
+
+private enum KnownCalendarApp: String, CaseIterable {
+ case apple = "com.apple.iCal"
+ case notion = "com.cron.electron"
+ case fantastical = "com.flexibits.fantastical2.mac"
+ case busycal = "com.busymac.busycal3"
+
+ var displayName: String {
+ switch self {
+ case .apple: return "Apple Calendar"
+ case .notion: return "Notion Calendar"
+ case .fantastical: return "Fantastical"
+ case .busycal: return "BusyCal"
+ }
+ }
+
+ var isInstalled: Bool {
+ NSWorkspace.shared.urlForApplication(withBundleIdentifier: rawValue) != nil
+ }
+
+ static var installedApps: [KnownCalendarApp] {
+ allCases.filter { $0.isInstalled }
+ }
+}
+
+private func configuredCalendarAppBundleId() -> String {
+ let widgetConfig = ConfigManager.shared.globalWidgetConfig(for: "default.time")
+ return widgetConfig["calendar"]?.dictionaryValue?["default-app"]?.stringValue
+ ?? KnownCalendarApp.apple.rawValue
+}
+
+private func configuredCalendarAppName() -> String {
+ let bundleId = configuredCalendarAppBundleId()
+ if let known = KnownCalendarApp(rawValue: bundleId) {
+ return known.displayName
+ }
+ if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) {
+ return appURL.deletingPathExtension().lastPathComponent
+ }
+ return "Calendar"
+}
+
+private func openEventInCalendarApp(event: EKEvent) {
+ let bundleId = configuredCalendarAppBundleId()
+
+ if bundleId == KnownCalendarApp.apple.rawValue {
+ // calshow: opens Apple Calendar focused on the event's date
+ let timestamp = event.startDate.timeIntervalSinceReferenceDate
+ if let url = URL(string: "calshow:\(timestamp)") {
+ NSWorkspace.shared.open(url)
+ }
+ } else if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) {
+ NSWorkspace.shared.open(appURL)
+ }
+}
+
struct CalendarPopup: View {
let calendarManager: CalendarManager
@@ -11,15 +69,27 @@ struct CalendarPopup: View {
MenuBarPopupVariantView(
selectedVariant: selectedVariant,
onVariantSelected: { variant in
- selectedVariant = variant
+ // If clicking settings while already in settings, go back to dayView
+ let newVariant = (variant == .settings && selectedVariant == .settings) ? .dayView : variant
+ selectedVariant = newVariant
ConfigManager.shared.updateConfigValue(
key: "widgets.default.time.popup.view-variant",
- newValue: variant.rawValue
+ newValue: newVariant.rawValue
)
},
box: { CalendarBoxPopup() },
vertical: { CalendarVerticalPopup(calendarManager) },
- horizontal: { CalendarHorizontalPopup(calendarManager) }
+ horizontal: { CalendarHorizontalPopup(calendarManager) },
+ dayView: { CalendarDayViewPopup(calendarManager) },
+ settings: {
+ CalendarSettingsView(calendarManager, onBack: {
+ selectedVariant = .dayView
+ ConfigManager.shared.updateConfigValue(
+ key: "widgets.default.time.popup.view-variant",
+ newValue: "dayView"
+ )
+ })
+ }
)
.onAppear {
if let variantString = configProvider.config["popup"]?
@@ -315,6 +385,7 @@ private struct EventListView: View {
private struct EventRow: View {
let event: EKEvent
+ @State private var showDetail = false
var body: some View {
let eventTime = getEventTime(event)
@@ -340,6 +411,15 @@ private struct EventRow: View {
.background(Color(event.calendar.cgColor).opacity(0.2))
.cornerRadius(6)
.frame(maxWidth: .infinity)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ showDetail.toggle()
+ }
+ .popover(isPresented: $showDetail, arrowEdge: .leading) {
+ EventDetailView(event: event) {
+ showDetail = false
+ }
+ }
}
func getEventTime(_ event: EKEvent) -> String {
@@ -357,6 +437,1031 @@ private struct EventRow: View {
}
}
+// MARK: - Event Detail View
+
+private struct EventDetailView: View {
+ let event: EKEvent
+ let onDismiss: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ // Header with close button
+ HStack {
+ Circle()
+ .fill(Color(event.calendar.cgColor))
+ .frame(width: 10, height: 10)
+ Text(event.calendar.title)
+ .font(.system(size: 11))
+ .foregroundColor(.gray)
+ Spacer()
+ Button {
+ onDismiss()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 16))
+ .foregroundColor(.gray)
+ }
+ .buttonStyle(.plain)
+ }
+
+ // Event title
+ Text(event.title ?? "Untitled Event")
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundColor(.white)
+ .fixedSize(horizontal: false, vertical: true)
+
+ // Time
+ HStack(spacing: 6) {
+ Image(systemName: "clock")
+ .font(.system(size: 12))
+ .foregroundColor(.gray)
+ Text(formatEventTime())
+ .font(.system(size: 12))
+ .foregroundColor(.white.opacity(0.8))
+ }
+
+ // Location (if available)
+ if let location = event.location, !location.isEmpty {
+ HStack(spacing: 6) {
+ Image(systemName: "location")
+ .font(.system(size: 12))
+ .foregroundColor(.gray)
+ Text(location)
+ .font(.system(size: 12))
+ .foregroundColor(.white.opacity(0.8))
+ .lineLimit(2)
+ }
+ }
+
+ // Notes (if available)
+ if let notes = event.notes, !notes.isEmpty {
+ HStack(alignment: .top, spacing: 6) {
+ Image(systemName: "note.text")
+ .font(.system(size: 12))
+ .foregroundColor(.gray)
+ Text(notes)
+ .font(.system(size: 11))
+ .foregroundColor(.white.opacity(0.7))
+ .lineLimit(3)
+ }
+ }
+
+ // Open in Calendar button
+ Button {
+ openEventInCalendarApp(event: event)
+ } label: {
+ HStack {
+ Image(systemName: "calendar")
+ .font(.system(size: 12))
+ Text("Open in \(configuredCalendarAppName())")
+ .font(.system(size: 12, weight: .medium))
+ }
+ .foregroundColor(.white)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Color(event.calendar.cgColor).opacity(0.5))
+ .cornerRadius(6)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(14)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(Color.black.opacity(0.9))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Color(event.calendar.cgColor).opacity(0.4), lineWidth: 1)
+ )
+ )
+ .frame(width: 220)
+ }
+
+ private func formatEventTime() -> String {
+ if event.isAllDay {
+ return NSLocalizedString("ALL_DAY", comment: "")
+ }
+ let formatter = DateFormatter()
+ formatter.setLocalizedDateFormatFromTemplate("j:mm")
+ let start = formatter.string(from: event.startDate)
+ let end = formatter.string(from: event.endDate)
+ return "\(start) — \(end)"
+ }
+}
+
+// MARK: - Day View Popup (Mac Sidebar Style)
+
+struct CalendarDayViewPopup: View {
+ let calendarManager: CalendarManager
+ @State private var selectedEvent: EKEvent?
+
+ init(_ calendarManager: CalendarManager) {
+ self.calendarManager = calendarManager
+ }
+
+ private let hourHeight: CGFloat = 24
+ private let startHour: Int = 9
+ private let endHour: Int = 24 // Extend to midnight
+ private let visibleHours: Int = 8 // Show 8 hours at a time (same as original 9-17)
+
+ private var scrollableHeight: CGFloat {
+ CGFloat(visibleHours) * hourHeight
+ }
+
+ var body: some View {
+ Group {
+ if let event = selectedEvent {
+ // Show expanded event detail view
+ ExpandedEventView(event: event) {
+ withAnimation(.smooth(duration: 0.2)) {
+ selectedEvent = nil
+ }
+ }
+ } else {
+ // Show normal day view
+ HStack(alignment: .top, spacing: 0) {
+ // Left side: Today
+ TodayColumnView(
+ startHour: startHour,
+ endHour: endHour,
+ hourHeight: hourHeight,
+ scrollableHeight: scrollableHeight,
+ events: calendarManager.todaysEvents,
+ onEventSelected: { event in
+ withAnimation(.smooth(duration: 0.2)) {
+ selectedEvent = event
+ }
+ }
+ )
+
+ // Divider
+ Rectangle()
+ .fill(Color.white.opacity(0.1))
+ .frame(width: 1)
+
+ // Right side: Tomorrow
+ TomorrowColumnView(
+ startHour: startHour,
+ endHour: endHour,
+ hourHeight: hourHeight,
+ scrollableHeight: scrollableHeight,
+ events: calendarManager.tomorrowsEvents,
+ onEventSelected: { event in
+ withAnimation(.smooth(duration: 0.2)) {
+ selectedEvent = event
+ }
+ }
+ )
+ }
+ }
+ }
+ .padding(20)
+ .fontWeight(.semibold)
+ .foregroundStyle(.white)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+}
+
+// MARK: - Expanded Event View (fills popup space)
+
+private struct ExpandedEventView: View {
+ let event: EKEvent
+ let onBack: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ // Header with back button
+ HStack {
+ Button {
+ onBack()
+ } label: {
+ HStack(spacing: 4) {
+ Image(systemName: "chevron.left")
+ .font(.system(size: 14, weight: .semibold))
+ Text("Back")
+ .font(.system(size: 13))
+ }
+ .foregroundColor(.gray)
+ }
+ .buttonStyle(.plain)
+
+ Spacer()
+
+ // Calendar indicator
+ HStack(spacing: 6) {
+ Circle()
+ .fill(Color(event.calendar.cgColor))
+ .frame(width: 10, height: 10)
+ Text(event.calendar.title)
+ .font(.system(size: 12))
+ .foregroundColor(.gray)
+ }
+ }
+
+ // Event title
+ Text(event.title ?? "Untitled Event")
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundColor(.white)
+ .fixedSize(horizontal: false, vertical: true)
+
+ // Time
+ HStack(spacing: 8) {
+ Image(systemName: "clock")
+ .font(.system(size: 14))
+ .foregroundColor(Color(event.calendar.cgColor))
+ VStack(alignment: .leading, spacing: 2) {
+ Text(formatEventDate())
+ .font(.system(size: 13))
+ .foregroundColor(.white.opacity(0.9))
+ Text(formatEventTime())
+ .font(.system(size: 13))
+ .foregroundColor(.white.opacity(0.7))
+ }
+ }
+
+ // Location (if available)
+ if let location = event.location, !location.isEmpty {
+ HStack(alignment: .top, spacing: 8) {
+ Image(systemName: "location")
+ .font(.system(size: 14))
+ .foregroundColor(Color(event.calendar.cgColor))
+ Text(location)
+ .font(.system(size: 13))
+ .foregroundColor(.white.opacity(0.9))
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ // Notes (if available)
+ if let notes = event.notes, !notes.isEmpty {
+ HStack(alignment: .top, spacing: 8) {
+ Image(systemName: "note.text")
+ .font(.system(size: 14))
+ .foregroundColor(Color(event.calendar.cgColor))
+ ScrollView {
+ Text(notes)
+ .font(.system(size: 12))
+ .foregroundColor(.white.opacity(0.8))
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .frame(maxHeight: 100)
+ }
+ }
+
+ Spacer()
+
+ // Open in Calendar button
+ Button {
+ openEventInCalendarApp(event: event)
+ } label: {
+ HStack {
+ Image(systemName: "calendar")
+ .font(.system(size: 13))
+ Text("Open in \(configuredCalendarAppName())")
+ .font(.system(size: 13, weight: .medium))
+ }
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .background(Color(event.calendar.cgColor).opacity(0.5))
+ .cornerRadius(8)
+ }
+ .buttonStyle(.plain)
+ }
+ .frame(width: 380) // Match the width of the day view (180 + 1 + 220 - some padding)
+ }
+
+ private func formatEventDate() -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "EEEE, MMMM d, yyyy"
+ return formatter.string(from: event.startDate)
+ }
+
+ private func formatEventTime() -> String {
+ if event.isAllDay {
+ return NSLocalizedString("ALL_DAY", comment: "")
+ }
+ let formatter = DateFormatter()
+ formatter.setLocalizedDateFormatFromTemplate("j:mm")
+ let start = formatter.string(from: event.startDate)
+ let end = formatter.string(from: event.endDate)
+ return "\(start) — \(end)"
+ }
+}
+
+private struct TodayColumnView: View {
+ let startHour: Int
+ let endHour: Int
+ let hourHeight: CGFloat
+ let scrollableHeight: CGFloat
+ let events: [EKEvent]
+ let onEventSelected: (EKEvent) -> Void
+
+ @State private var currentTime = Date()
+ @State private var showAllDayEvents = false
+ private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
+
+ private var allDayEvents: [EKEvent] {
+ events.filter { $0.isAllDay }
+ }
+
+ private var timedEvents: [EKEvent] {
+ events.filter { !$0.isAllDay }
+ }
+
+ private var dayOfWeek: String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "EEEE"
+ return formatter.string(from: Date()).uppercased()
+ }
+
+ private var dayNumber: String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "d"
+ return formatter.string(from: Date())
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ // Header: Day of week + date
+ VStack(alignment: .leading, spacing: 6) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(dayOfWeek)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(Color(red: 1.0, green: 0.4, blue: 0.4))
+ Text(dayNumber)
+ .font(.system(size: 34, weight: .light))
+ }
+
+ // All-day events badge (clickable)
+ if !allDayEvents.isEmpty {
+ allDayEventsBadge
+ .onTapGesture {
+ withAnimation(.smooth(duration: 0.2)) {
+ showAllDayEvents.toggle()
+ }
+ }
+
+ // Expanded all-day events list
+ if showAllDayEvents {
+ allDayEventsList
+ }
+ }
+ }
+ .padding(.bottom, 15)
+
+ // Time slots with current time indicator (scrollable)
+ ScrollView {
+ ZStack(alignment: .topLeading) {
+ // Hour labels and lines
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(startHour..= startHour && hour < endHour {
+ HStack(spacing: 0) {
+ Circle()
+ .fill(Color.red)
+ .frame(width: 8, height: 8)
+ Rectangle()
+ .fill(Color.red)
+ .frame(height: 1)
+ }
+ .offset(x: 28, y: yPosition - 4)
+ }
+ }
+ }
+
+ private var eventsOverlay: some View {
+ let calendar = Calendar.current
+ let visibleEvents = timedEvents.filter { event in
+ let hour = calendar.component(.hour, from: event.startDate)
+ return hour >= startHour && hour < endHour
+ }
+
+ // Group overlapping events
+ let groupedEvents = groupOverlappingEvents(visibleEvents)
+
+ return ZStack(alignment: .topLeading) {
+ ForEach(groupedEvents, id: \.0.eventIdentifier) { event, column, totalColumns in
+ eventBlock(event: event, column: column, totalColumns: totalColumns)
+ }
+ }
+ }
+
+ private func eventBlock(event: EKEvent, column: Int, totalColumns: Int) -> some View {
+ let calendar = Calendar.current
+ let hour = calendar.component(.hour, from: event.startDate)
+ let minute = calendar.component(.minute, from: event.startDate)
+
+ let hourOffset = hour - startHour
+ let minuteOffset = CGFloat(minute) / 60.0
+ let yPosition = CGFloat(hourOffset) * hourHeight + minuteOffset * hourHeight
+
+ // Calculate duration for height
+ let duration = event.endDate.timeIntervalSince(event.startDate) / 3600.0
+ let height = max(CGFloat(duration) * hourHeight - 2, 20)
+
+ // Calculate width based on overlapping events
+ let availableWidth: CGFloat = 120 // Slightly smaller for today column
+ let eventWidth = (availableWidth / CGFloat(totalColumns)) - 2
+ let xOffset: CGFloat = 36 + CGFloat(column) * (eventWidth + 2)
+
+ return Button {
+ onEventSelected(event)
+ } label: {
+ HStack(spacing: 0) {
+ Rectangle()
+ .fill(Color(event.calendar.cgColor))
+ .frame(width: 3)
+
+ Text(event.title ?? "")
+ .font(.system(size: 11))
+ .lineLimit(height > 30 ? 2 : 1)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ }
+ .frame(width: eventWidth, height: height, alignment: .leading)
+ .background(Color(event.calendar.cgColor).opacity(0.25))
+ .cornerRadius(4)
+ }
+ .buttonStyle(.plain)
+ .offset(x: xOffset, y: yPosition)
+ }
+
+ private func groupOverlappingEvents(_ events: [EKEvent]) -> [(EKEvent, Int, Int)] {
+ guard !events.isEmpty else { return [] }
+
+ var result: [(EKEvent, Int, Int)] = []
+ var groups: [[EKEvent]] = []
+
+ let sortedEvents = events.sorted { $0.startDate < $1.startDate }
+
+ for event in sortedEvents {
+ var placed = false
+ for i in groups.indices {
+ let groupEnd = groups[i].map { $0.endDate }.max() ?? Date.distantPast
+ if event.startDate >= groupEnd {
+ groups[i].append(event)
+ placed = true
+ break
+ }
+ }
+ if !placed {
+ groups.append([event])
+ }
+ }
+
+ // Flatten with column info
+ for event in sortedEvents {
+ var column = 0
+ var overlappingCount = 1
+
+ for (idx, group) in groups.enumerated() {
+ if group.contains(where: { $0.eventIdentifier == event.eventIdentifier }) {
+ column = idx
+ // Count how many groups overlap with this event
+ overlappingCount = groups.filter { group in
+ group.contains { otherEvent in
+ !(event.endDate <= otherEvent.startDate || event.startDate >= otherEvent.endDate)
+ }
+ }.count
+ break
+ }
+ }
+
+ result.append((event, column, overlappingCount))
+ }
+
+ return result
+ }
+
+ private func formatHour(_ hour: Int) -> String {
+ let h = hour > 12 ? hour - 12 : hour
+ return "\(h)"
+ }
+
+ private var allDayEventsBadge: some View {
+ HStack(spacing: 4) {
+ // Show colored dots for first 3 calendars
+ HStack(spacing: -4) {
+ ForEach(Array(allDayEvents.prefix(3).enumerated()), id: \.offset) { _, event in
+ Circle()
+ .fill(Color(event.calendar.cgColor))
+ .frame(width: 14, height: 14)
+ .overlay(
+ Circle()
+ .stroke(Color.black, lineWidth: 2)
+ )
+ }
+ }
+
+ Text("\(allDayEvents.count) all-day")
+ .font(.system(size: 12))
+ .foregroundColor(.white)
+
+ Image(systemName: showAllDayEvents ? "chevron.up" : "chevron.down")
+ .font(.system(size: 10))
+ .foregroundColor(.gray)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color.white.opacity(0.1))
+ .cornerRadius(6)
+ .contentShape(Rectangle())
+ }
+
+ private var allDayEventsList: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(allDayEvents, id: \.eventIdentifier) { event in
+ HStack(spacing: 6) {
+ Rectangle()
+ .fill(Color(event.calendar.cgColor))
+ .frame(width: 3, height: 16)
+ .cornerRadius(1.5)
+
+ Text(event.title ?? "Untitled")
+ .font(.system(size: 12))
+ .foregroundColor(.white)
+ .lineLimit(1)
+ }
+ .padding(.vertical, 2)
+ }
+ }
+ .padding(.top, 6)
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ }
+}
+
+private struct TomorrowColumnView: View {
+ let startHour: Int
+ let endHour: Int
+ let hourHeight: CGFloat
+ let scrollableHeight: CGFloat
+ let events: [EKEvent]
+ let onEventSelected: (EKEvent) -> Void
+
+ @State private var showAllDayEvents = false
+
+ private var allDayEvents: [EKEvent] {
+ events.filter { $0.isAllDay }
+ }
+
+ private var timedEvents: [EKEvent] {
+ events.filter { !$0.isAllDay }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ // Header: TOMORROW
+ VStack(alignment: .leading, spacing: 6) {
+ Text("TOMORROW")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.gray)
+
+ // All-day events badge (clickable)
+ if !allDayEvents.isEmpty {
+ allDayEventsBadge
+ .onTapGesture {
+ withAnimation(.smooth(duration: 0.2)) {
+ showAllDayEvents.toggle()
+ }
+ }
+
+ // Expanded all-day events list
+ if showAllDayEvents {
+ allDayEventsList
+ }
+ }
+ }
+ .padding(.bottom, 15)
+
+ // Time slots with events (scrollable)
+ ScrollView {
+ ZStack(alignment: .topLeading) {
+ // Hour labels
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(startHour..= startHour && hour < endHour
+ }
+
+ // Group overlapping events
+ let groupedEvents = groupOverlappingEvents(visibleEvents)
+
+ return ZStack(alignment: .topLeading) {
+ ForEach(groupedEvents, id: \.0.eventIdentifier) { event, column, totalColumns in
+ eventBlock(event: event, column: column, totalColumns: totalColumns)
+ }
+ }
+ }
+
+ private func eventBlock(event: EKEvent, column: Int, totalColumns: Int) -> some View {
+ let calendar = Calendar.current
+ let hour = calendar.component(.hour, from: event.startDate)
+ let minute = calendar.component(.minute, from: event.startDate)
+
+ let hourOffset = hour - startHour
+ let minuteOffset = CGFloat(minute) / 60.0
+ let yPosition = CGFloat(hourOffset) * hourHeight + minuteOffset * hourHeight
+
+ // Calculate duration for height
+ let duration = event.endDate.timeIntervalSince(event.startDate) / 3600.0
+ let height = max(CGFloat(duration) * hourHeight - 2, 20)
+
+ // Calculate width based on overlapping events
+ let availableWidth: CGFloat = 160
+ let eventWidth = (availableWidth / CGFloat(totalColumns)) - 2
+ let xOffset: CGFloat = 36 + CGFloat(column) * (eventWidth + 2)
+
+ return Button {
+ onEventSelected(event)
+ } label: {
+ HStack(spacing: 0) {
+ Rectangle()
+ .fill(Color(event.calendar.cgColor))
+ .frame(width: 3)
+
+ Text(event.title ?? "")
+ .font(.system(size: 11))
+ .lineLimit(height > 30 ? 2 : 1)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ }
+ .frame(width: eventWidth, height: height, alignment: .leading)
+ .background(Color(event.calendar.cgColor).opacity(0.25))
+ .cornerRadius(4)
+ }
+ .buttonStyle(.plain)
+ .offset(x: xOffset, y: yPosition)
+ }
+
+ private func groupOverlappingEvents(_ events: [EKEvent]) -> [(EKEvent, Int, Int)] {
+ guard !events.isEmpty else { return [] }
+
+ var result: [(EKEvent, Int, Int)] = []
+ var groups: [[EKEvent]] = []
+
+ let sortedEvents = events.sorted { $0.startDate < $1.startDate }
+
+ for event in sortedEvents {
+ var placed = false
+ for i in groups.indices {
+ let groupEnd = groups[i].map { $0.endDate }.max() ?? Date.distantPast
+ if event.startDate >= groupEnd {
+ groups[i].append(event)
+ placed = true
+ break
+ }
+ }
+ if !placed {
+ groups.append([event])
+ }
+ }
+
+ // Flatten with column info
+ for event in sortedEvents {
+ var column = 0
+ var overlappingCount = 1
+
+ for (idx, group) in groups.enumerated() {
+ if group.contains(where: { $0.eventIdentifier == event.eventIdentifier }) {
+ column = idx
+ // Count how many groups overlap with this event
+ overlappingCount = groups.filter { group in
+ group.contains { otherEvent in
+ !(event.endDate <= otherEvent.startDate || event.startDate >= otherEvent.endDate)
+ }
+ }.count
+ break
+ }
+ }
+
+ result.append((event, column, overlappingCount))
+ }
+
+ return result
+ }
+
+ private func formatHour(_ hour: Int) -> String {
+ let h = hour > 12 ? hour - 12 : hour
+ return "\(h)"
+ }
+}
+
+// MARK: - Calendar Settings View
+
+struct CalendarSettingsView: View {
+ @ObservedObject var calendarManager: CalendarManager
+ @State private var denyListState: Set = []
+ @State private var selectedAppBundleId: String = KnownCalendarApp.apple.rawValue
+ var onBack: (() -> Void)?
+
+ init(_ calendarManager: CalendarManager, onBack: (() -> Void)? = nil) {
+ self.calendarManager = calendarManager
+ self.onBack = onBack
+ }
+
+ private var maxHeight: CGFloat {
+ (NSScreen.main?.frame.height ?? 800) / 2
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ // Header with back button and title
+ HStack {
+ Button {
+ onBack?()
+ } label: {
+ Image(systemName: "chevron.left")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.gray)
+ }
+ .buttonStyle(.plain)
+
+ Spacer()
+
+ Text("SETTINGS")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.gray)
+ }
+ .padding(.bottom, 15)
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ // Default calendar app section
+ VStack(alignment: .leading, spacing: 8) {
+ Text("OPEN EVENTS WITH")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundColor(.gray)
+
+ ForEach(KnownCalendarApp.installedApps, id: \.rawValue) { app in
+ CalendarAppRow(
+ app: app,
+ isSelected: selectedAppBundleId == app.rawValue,
+ onSelect: {
+ selectedAppBundleId = app.rawValue
+ saveDefaultApp(app.rawValue)
+ }
+ )
+ }
+ }
+
+ // Calendar visibility section
+ VStack(alignment: .leading, spacing: 8) {
+ Text("SHOW / HIDE")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundColor(.gray)
+
+ ForEach(calendarManager.allCalendars, id: \.calendarIdentifier) { calendar in
+ CalendarToggleRow(
+ calendar: calendar,
+ isEnabled: !denyListState.contains(calendar.calendarIdentifier),
+ onToggle: { enabled in
+ toggleCalendar(calendar, enabled: enabled)
+ }
+ )
+ }
+ }
+ }
+ }
+ .frame(maxHeight: maxHeight - 80) // Account for header and padding
+ }
+ .frame(width: 250)
+ .padding(20)
+ .fontWeight(.semibold)
+ .foregroundStyle(.white)
+ .onAppear {
+ loadDenyListFromConfig()
+ loadDefaultAppFromConfig()
+ }
+ }
+
+ private func loadDenyListFromConfig() {
+ // Read deny-list directly from ConfigManager to ensure we have the latest persisted values
+ let widgetConfig = ConfigManager.shared.globalWidgetConfig(for: "default.time")
+ if let calendarConfig = widgetConfig["calendar"]?.dictionaryValue,
+ let denyListArray = calendarConfig["deny-list"]?.arrayValue {
+ denyListState = Set(denyListArray.compactMap { $0.stringValue }.filter { !$0.isEmpty })
+ } else {
+ denyListState = []
+ }
+ }
+
+ private func loadDefaultAppFromConfig() {
+ let widgetConfig = ConfigManager.shared.globalWidgetConfig(for: "default.time")
+ if let calendarConfig = widgetConfig["calendar"]?.dictionaryValue,
+ let appId = calendarConfig["default-app"]?.stringValue, !appId.isEmpty {
+ selectedAppBundleId = appId
+ } else {
+ selectedAppBundleId = KnownCalendarApp.apple.rawValue
+ }
+ }
+
+ private func saveDefaultApp(_ bundleId: String) {
+ ConfigManager.shared.updateConfigValue(
+ key: "widgets.default.time.calendar.default-app",
+ newValue: bundleId
+ )
+ }
+
+ private func toggleCalendar(_ calendar: EKCalendar, enabled: Bool) {
+ // Update local state immediately for responsive UI
+ if enabled {
+ denyListState.remove(calendar.calendarIdentifier)
+ } else {
+ denyListState.insert(calendar.calendarIdentifier)
+ }
+
+ // Format as TOML array string and save
+ let tomlArray = "[" + denyListState.sorted().map { "\"\($0)\"" }.joined(separator: ", ") + "]"
+ ConfigManager.shared.updateConfigValueRaw(
+ key: "widgets.default.time.calendar.deny-list",
+ newValue: tomlArray
+ )
+ }
+}
+
+private struct CalendarAppRow: View {
+ let app: KnownCalendarApp
+ let isSelected: Bool
+ let onSelect: () -> Void
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Text(app.displayName)
+ .font(.system(size: 13))
+ .foregroundColor(.white)
+ .lineLimit(1)
+
+ Spacer()
+
+ Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
+ .font(.system(size: 18))
+ .foregroundColor(isSelected ? .blue : .gray.opacity(0.5))
+ }
+ .padding(.vertical, 6)
+ .padding(.horizontal, 10)
+ .background(Color.white.opacity(0.05))
+ .cornerRadius(8)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ onSelect()
+ }
+ }
+}
+
+private struct CalendarToggleRow: View {
+ let calendar: EKCalendar
+ let isEnabled: Bool
+ let onToggle: (Bool) -> Void
+
+ var body: some View {
+ HStack(spacing: 10) {
+ // Calendar color indicator
+ Circle()
+ .fill(Color(calendar.cgColor))
+ .frame(width: 12, height: 12)
+
+ // Calendar name
+ Text(calendar.title)
+ .font(.system(size: 13))
+ .foregroundColor(.white)
+ .lineLimit(1)
+
+ Spacer()
+
+ // Toggle checkbox
+ Image(systemName: isEnabled ? "checkmark.circle.fill" : "circle")
+ .font(.system(size: 18))
+ .foregroundColor(isEnabled ? Color(calendar.cgColor) : .gray.opacity(0.5))
+ }
+ .padding(.vertical, 6)
+ .padding(.horizontal, 10)
+ .background(Color.white.opacity(0.05))
+ .cornerRadius(8)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ onToggle(!isEnabled)
+ }
+ }
+}
+
struct CalendarPopup_Previews: PreviewProvider {
var configProvider: ConfigProvider = ConfigProvider(config: ConfigData())
var calendarManager: CalendarManager
@@ -381,5 +1486,9 @@ struct CalendarPopup_Previews: PreviewProvider {
.background(Color.black)
.previewLayout(.sizeThatFits)
.previewDisplayName("Horizontal")
+ CalendarDayViewPopup(calendarManager)
+ .background(Color.black)
+ .previewLayout(.sizeThatFits)
+ .previewDisplayName("Day View")
}
}
diff --git a/Barik/Widgets/Time+Calendar/TimeWidget.swift b/Barik/Widgets/Time+Calendar/TimeWidget.swift
index bb8ac60..e94e5d3 100644
--- a/Barik/Widgets/Time+Calendar/TimeWidget.swift
+++ b/Barik/Widgets/Time+Calendar/TimeWidget.swift
@@ -8,6 +8,7 @@ struct TimeWidget: View {
var format: String { config["format"]?.stringValue ?? "E d, J:mm" }
var timeZone: String? { config["time-zone"]?.stringValue }
+ var clickAction: String { config["click-action"]?.stringValue ?? "calendar" }
var calendarFormat: String {
calendarConfig?["format"]?.stringValue ?? "J:mm"
@@ -17,7 +18,11 @@ struct TimeWidget: View {
}
@State private var currentTime = Date()
- let calendarManager: CalendarManager
+ @StateObject private var calendarManager: CalendarManager
+
+ init(configProvider: ConfigProvider) {
+ _calendarManager = StateObject(wrappedValue: CalendarManager(configProvider: configProvider))
+ }
@State private var rect = CGRect()
@@ -54,13 +59,18 @@ struct TimeWidget: View {
)
.experimentalConfiguration(cornerRadius: 15)
.frame(maxHeight: .infinity)
- .background(.black.opacity(0.001))
+ .contentShape(Rectangle())
.monospacedDigit()
.onTapGesture {
- MenuBarPopup.show(rect: rect, id: "calendar") {
- CalendarPopup(
- calendarManager: calendarManager,
- configProvider: configProvider)
+ switch clickAction {
+ case "notification-center":
+ SystemUIHelper.openNotificationCenter()
+ default:
+ MenuBarPopup.show(rect: rect, id: "calendar") {
+ CalendarPopup(
+ calendarManager: calendarManager,
+ configProvider: configProvider)
+ }
}
}
}
@@ -97,10 +107,9 @@ struct TimeWidget: View {
struct TimeWidget_Previews: PreviewProvider {
static var previews: some View {
let provider = ConfigProvider(config: ConfigData())
- let manager = CalendarManager(configProvider: provider)
ZStack {
- TimeWidget(calendarManager: manager)
+ TimeWidget(configProvider: provider)
.environmentObject(provider)
}.frame(width: 500, height: 100)
}
diff --git a/Barik/Widgets/Weather/WeatherPopup.swift b/Barik/Widgets/Weather/WeatherPopup.swift
new file mode 100644
index 0000000..9cb2ea1
--- /dev/null
+++ b/Barik/Widgets/Weather/WeatherPopup.swift
@@ -0,0 +1,140 @@
+import SwiftUI
+
+struct WeatherPopup: View {
+ @ObservedObject private var weatherManager = WeatherManager.shared
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ if let weather = weatherManager.currentWeather {
+ // Header: Location + Current Weather
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 4) {
+ Text(weatherManager.locationName ?? "Current Location")
+ .font(.system(size: 14, weight: .medium))
+ Image(systemName: "location.fill")
+ .font(.system(size: 8))
+ .opacity(0.6)
+ }
+ Text(weather.temperature)
+ .font(.system(size: 48, weight: .regular))
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 2) {
+ Image(systemName: weather.symbolName)
+ .symbolRenderingMode(.multicolor)
+ .font(.system(size: 28))
+ Text(weather.condition)
+ .font(.system(size: 13))
+ .opacity(0.8)
+ if let high = weatherManager.highTemp, let low = weatherManager.lowTemp {
+ Text("H:\(high) L:\(low)")
+ .font(.system(size: 12))
+ .opacity(0.6)
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 20)
+ .padding(.bottom, 15)
+
+ Divider()
+ .background(Color.white.opacity(0.2))
+
+ // Precipitation indicator (if raining)
+ if let precipitation = weatherManager.precipitation, precipitation > 0 {
+ HStack(spacing: 8) {
+ Image(systemName: "umbrella.fill")
+ .font(.system(size: 14))
+ Text("\(Int(precipitation * 100))% chance of rain")
+ .font(.system(size: 13))
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+
+ Divider()
+ .background(Color.white.opacity(0.2))
+ }
+
+ // Hourly Forecast
+ if !weatherManager.hourlyForecast.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 20) {
+ ForEach(weatherManager.hourlyForecast.prefix(6), id: \.time) { hour in
+ VStack(spacing: 8) {
+ Text(hour.timeLabel)
+ .font(.system(size: 12, weight: .medium))
+ .opacity(0.8)
+ Image(systemName: hour.symbolName)
+ .symbolRenderingMode(.multicolor)
+ .font(.system(size: 20))
+ if let precip = hour.precipitationProbability, precip > 0 {
+ Text("\(precip)%")
+ .font(.system(size: 10))
+ .foregroundColor(.cyan)
+ }
+ Text(hour.temperature)
+ .font(.system(size: 14, weight: .medium))
+ }
+ .frame(width: 50)
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 15)
+ }
+
+ Divider()
+ .background(Color.white.opacity(0.2))
+ }
+
+ // Open Weather button
+ Button(action: {
+ SystemUIHelper.openWeatherApp()
+ }) {
+ HStack {
+ Text("Open Weather")
+ .font(.system(size: 13))
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.system(size: 12))
+ .opacity(0.5)
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .background(Color.white.opacity(0.001))
+ .onHover { hovering in
+ if hovering {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+
+ } else {
+ // Loading state
+ VStack(spacing: 12) {
+ ProgressView()
+ Text("Loading weather...")
+ .font(.system(size: 13))
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(40)
+ }
+ }
+ .frame(width: 280)
+ .background(Color.black)
+ }
+}
+
+struct WeatherPopup_Previews: PreviewProvider {
+ static var previews: some View {
+ WeatherPopup()
+ .background(Color.black)
+ }
+}
diff --git a/Barik/Widgets/Weather/WeatherWidget.swift b/Barik/Widgets/Weather/WeatherWidget.swift
new file mode 100644
index 0000000..f9dfcca
--- /dev/null
+++ b/Barik/Widgets/Weather/WeatherWidget.swift
@@ -0,0 +1,373 @@
+import SwiftUI
+import CoreLocation
+
+/// Weather widget that displays current weather using Open-Meteo API
+struct WeatherWidget: View {
+ @EnvironmentObject var configProvider: ConfigProvider
+ @ObservedObject private var weatherManager = WeatherManager.shared
+
+ @State private var widgetFrame: CGRect = .zero
+
+ var body: some View {
+ HStack(spacing: 4) {
+ if let weather = weatherManager.currentWeather {
+ Image(systemName: weather.symbolName)
+ .symbolRenderingMode(.multicolor)
+ Text(weather.temperature)
+ .fontWeight(.semibold)
+ } else {
+ Image(systemName: "cloud.sun")
+ .symbolRenderingMode(.multicolor)
+ if weatherManager.isLoading {
+ ProgressView()
+ .scaleEffect(0.5)
+ }
+ }
+ }
+ .font(.headline)
+ .foregroundStyle(.foregroundOutside)
+ .shadow(color: .foregroundShadowOutside, radius: 3)
+ .experimentalConfiguration(cornerRadius: 15)
+ .frame(maxHeight: .infinity)
+ .background(.black.opacity(0.001))
+ .background(
+ GeometryReader { geometry in
+ Color.clear
+ .onAppear {
+ widgetFrame = geometry.frame(in: .global)
+ }
+ .onChange(of: geometry.frame(in: .global)) { _, newFrame in
+ widgetFrame = newFrame
+ }
+ }
+ )
+ .onTapGesture {
+ MenuBarPopup.show(rect: widgetFrame, id: "weather") {
+ WeatherPopup()
+ }
+ }
+ .onAppear {
+ weatherManager.startUpdating()
+ }
+ }
+}
+
+// MARK: - Weather Data Models
+
+struct CurrentWeather {
+ let temperature: String
+ let symbolName: String
+ let condition: String
+}
+
+struct HourlyForecast {
+ let time: Date
+ let timeLabel: String
+ let temperature: String
+ let symbolName: String
+ let precipitationProbability: Int?
+}
+
+// MARK: - Open-Meteo API Response
+
+struct OpenMeteoResponse: Codable {
+ let currentWeather: OpenMeteoCurrentWeather
+ let hourly: OpenMeteoHourly?
+ let daily: OpenMeteoDaily?
+
+ enum CodingKeys: String, CodingKey {
+ case currentWeather = "current_weather"
+ case hourly
+ case daily
+ }
+}
+
+struct OpenMeteoCurrentWeather: Codable {
+ let temperature: Double
+ let weathercode: Int
+}
+
+struct OpenMeteoHourly: Codable {
+ let time: [String]
+ let temperature2m: [Double]
+ let weathercode: [Int]
+ let precipitationProbability: [Int]?
+
+ enum CodingKeys: String, CodingKey {
+ case time
+ case temperature2m = "temperature_2m"
+ case weathercode
+ case precipitationProbability = "precipitation_probability"
+ }
+}
+
+struct OpenMeteoDaily: Codable {
+ let temperature2mMax: [Double]
+ let temperature2mMin: [Double]
+
+ enum CodingKeys: String, CodingKey {
+ case temperature2mMax = "temperature_2m_max"
+ case temperature2mMin = "temperature_2m_min"
+ }
+}
+
+// MARK: - Weather Manager
+
+@MainActor
+final class WeatherManager: NSObject, ObservableObject {
+ static let shared = WeatherManager()
+
+ @Published private(set) var currentWeather: CurrentWeather?
+ @Published private(set) var hourlyForecast: [HourlyForecast] = []
+ @Published private(set) var locationName: String?
+ @Published private(set) var highTemp: String?
+ @Published private(set) var lowTemp: String?
+ @Published private(set) var precipitation: Double?
+ @Published private(set) var isLoading = false
+
+ private let locationManager = CLLocationManager()
+ private let geocoder = CLGeocoder()
+ private var lastLocation: CLLocation?
+ private var updateTimer: Timer?
+
+ override private init() {
+ super.init()
+ locationManager.delegate = self
+ locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
+ }
+
+ func startUpdating() {
+ if locationManager.authorizationStatus == .notDetermined {
+ locationManager.requestWhenInUseAuthorization()
+ }
+ locationManager.startUpdatingLocation()
+
+ // Update every 15 minutes
+ updateTimer?.invalidate()
+ updateTimer = Timer.scheduledTimer(withTimeInterval: 900, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ self?.fetchWeather()
+ }
+ }
+ }
+
+ func stopUpdating() {
+ locationManager.stopUpdatingLocation()
+ updateTimer?.invalidate()
+ updateTimer = nil
+ }
+
+ private func fetchWeather() {
+ guard let location = lastLocation else { return }
+
+ isLoading = true
+
+ // Reverse geocode for location name
+ geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
+ if let placemark = placemarks?.first {
+ Task { @MainActor in
+ self?.locationName = placemark.locality ?? placemark.administrativeArea ?? "Unknown"
+ }
+ }
+ }
+
+ Task {
+ do {
+ let lat = location.coordinate.latitude
+ let lon = location.coordinate.longitude
+ let urlString = "https://api.open-meteo.com/v1/forecast?latitude=\(lat)&longitude=\(lon)¤t_weather=true&hourly=temperature_2m,weathercode,precipitation_probability&daily=temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&timezone=auto&forecast_days=1"
+
+ guard let url = URL(string: urlString) else {
+ isLoading = false
+ return
+ }
+
+ let (data, _) = try await URLSession.shared.data(from: url)
+ let response = try JSONDecoder().decode(OpenMeteoResponse.self, from: data)
+
+ // Current weather
+ let temp = Int(response.currentWeather.temperature.rounded())
+ let symbol = symbolName(for: response.currentWeather.weathercode)
+ let condition = conditionName(for: response.currentWeather.weathercode)
+
+ self.currentWeather = CurrentWeather(
+ temperature: "\(temp)°F",
+ symbolName: symbol,
+ condition: condition
+ )
+
+ // Daily high/low
+ if let daily = response.daily {
+ if let high = daily.temperature2mMax.first {
+ self.highTemp = "\(Int(high.rounded()))°"
+ }
+ if let low = daily.temperature2mMin.first {
+ self.lowTemp = "\(Int(low.rounded()))°"
+ }
+ }
+
+ // Hourly forecast
+ if let hourly = response.hourly {
+ let dateFormatter = ISO8601DateFormatter()
+ dateFormatter.formatOptions = [.withFullDate, .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime]
+
+ let timeFormatter = DateFormatter()
+ timeFormatter.dateFormat = "ha"
+
+ let now = Date()
+ var forecasts: [HourlyForecast] = []
+
+ for i in 0.. now {
+ let tempF = Int(hourly.temperature2m[i].rounded())
+ let sym = symbolName(for: hourly.weathercode[i])
+ let precip = hourly.precipitationProbability?[safe: i]
+
+ let label = forecasts.isEmpty ? "Now" : timeFormatter.string(from: date)
+
+ forecasts.append(HourlyForecast(
+ time: date,
+ timeLabel: label,
+ temperature: "\(tempF)°",
+ symbolName: sym,
+ precipitationProbability: precip
+ ))
+
+ if forecasts.count >= 6 { break }
+ }
+ }
+
+ // Set precipitation from first hour
+ if let firstPrecip = hourly.precipitationProbability?.first(where: { $0 > 0 }) {
+ self.precipitation = Double(firstPrecip) / 100.0
+ } else {
+ self.precipitation = nil
+ }
+
+ self.hourlyForecast = forecasts
+ }
+ } catch {
+ print("Weather fetch error: \(error)")
+ }
+ isLoading = false
+ }
+ }
+
+ /// Maps Open-Meteo weather codes to SF Symbols
+ func symbolName(for code: Int) -> String {
+ switch code {
+ case 0:
+ return "sun.max.fill"
+ case 1, 2:
+ return "cloud.sun.fill"
+ case 3:
+ return "cloud.fill"
+ case 45, 48:
+ return "cloud.fog.fill"
+ case 51, 53, 55, 56, 57:
+ return "cloud.drizzle.fill"
+ case 61, 63, 65, 66, 67:
+ return "cloud.rain.fill"
+ case 71, 73, 75, 77:
+ return "cloud.snow.fill"
+ case 80, 81, 82:
+ return "cloud.heavyrain.fill"
+ case 85, 86:
+ return "cloud.snow.fill"
+ case 95, 96, 99:
+ return "cloud.bolt.rain.fill"
+ default:
+ return "cloud.fill"
+ }
+ }
+
+ /// Maps Open-Meteo weather codes to condition names
+ func conditionName(for code: Int) -> String {
+ switch code {
+ case 0:
+ return "Clear"
+ case 1:
+ return "Mainly Clear"
+ case 2:
+ return "Partly Cloudy"
+ case 3:
+ return "Overcast"
+ case 45, 48:
+ return "Foggy"
+ case 51, 53, 55:
+ return "Drizzle"
+ case 56, 57:
+ return "Freezing Drizzle"
+ case 61, 63, 65:
+ return "Rain"
+ case 66, 67:
+ return "Freezing Rain"
+ case 71, 73, 75:
+ return "Snow"
+ case 77:
+ return "Snow Grains"
+ case 80, 81, 82:
+ return "Rain Showers"
+ case 85, 86:
+ return "Snow Showers"
+ case 95:
+ return "Thunderstorm"
+ case 96, 99:
+ return "Thunderstorm with Hail"
+ default:
+ return "Unknown"
+ }
+ }
+}
+
+// MARK: - CLLocationManagerDelegate
+
+extension WeatherManager: CLLocationManagerDelegate {
+ nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
+ guard let location = locations.last else { return }
+
+ Task { @MainActor in
+ // Only update if location changed significantly (1km)
+ if lastLocation == nil || lastLocation!.distance(from: location) > 1000 {
+ lastLocation = location
+ fetchWeather()
+ }
+ }
+ }
+
+ nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
+ print("Location error: \(error)")
+ }
+
+ nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
+ if manager.authorizationStatus == .authorized ||
+ manager.authorizationStatus == .authorizedAlways {
+ manager.startUpdatingLocation()
+ }
+ }
+}
+
+// MARK: - Array Safe Subscript
+
+extension Array {
+ subscript(safe index: Int) -> Element? {
+ return indices.contains(index) ? self[index] : nil
+ }
+}
+
+struct WeatherWidget_Previews: PreviewProvider {
+ static var previews: some View {
+ ZStack {
+ WeatherWidget()
+ }.frame(width: 100, height: 50)
+ }
+}
diff --git a/README.md b/README.md
index 491a885..ec5643e 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,25 @@
-**NOTICE**: Unfortunately, I don’t have much free time to actively maintain this project. If you like the project but are not satisfied with its current state, you can explore the many forks or create your own. Even if you’re unfamiliar with **Swift**, tools like **Claude Code** and **Codex** can effectively help implement projects like this. This is a great opportunity to tailor **barik** to your needs and make it exactly the way you’d like.
-
-----
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+# barik-but-better
+
+A fork of [barik](https://github.com/mocki-toki/barik) with active improvements and new features.
+
+## Improvements over barik
+
+- **Drag-and-drop widget reordering** - Drag widgets directly in the menu bar to rearrange them, order persists to config
+- **Claude Code usage widget** - Track API usage in real-time with a donut ring indicator that changes color at thresholds, plus a popup with rolling window and weekly stats
+- **Pomodoro timer widget** - Built-in pomodoro timer with work/break/long break phases, circular progress ring, session tracking, adjustable durations, and macOS notifications
+- **Drastically reduced CPU usage** - Replaced polling with event-driven notifications for music playback and space changes
+- **Enhanced calendar popup** - Day view with event selection and detailed event information
+- **Click to open music player** - Click on album art, song title, or artist in the now playing popup to open Spotify or Apple Music
+- **Improved WiFi popup** - macOS-style controls and better network information
+- **New weather popup** - Hourly forecast using Open-Meteo API
+- **Fixed popup positioning** - Consistent popup positioning across all widgets
+
+---
+
**barik** is a lightweight macOS menu bar replacement. If you use [**yabai**](https://github.com/koekeishiya/yabai) or [**AeroSpace**](https://github.com/nikitabobko/AeroSpace) for tiling WM, you can display the current space in a sleek macOS-style panel with smooth animations. This makes it easy to see which number to press to switch spaces.
@@ -45,23 +45,53 @@ https://github.com/user-attachments/assets/d3799e24-c077-4c6a-a7da-a1f2eee1a07f
## Quick Start
-1. Install **barik** via [Homebrew](https://brew.sh/)
+### Install via Homebrew
+
+```sh
+brew install --cask bettercoderthanyou/formulae/barik-but-better
+```
+
+### Or build from source
+
+1. Clone the repo and build with Xcode:
```sh
-brew install --cask mocki-toki/formulae/barik
+git clone https://github.com/bettercoderthanyou/barik-but-better.git
+cd barik-but-better
+xcodebuild -scheme Barik -configuration Release build
```
-Or you can download from [Releases](https://github.com/mocki-toki/barik/releases), unzip it, and move it to your Applications folder.
+2. Copy the built app to Applications:
+
+```sh
+cp -R ~/Library/Developer/Xcode/DerivedData/Barik-*/Build/Products/Release/Barik.app /Applications/
+```
+
+> **Note:** Building from source requires Xcode (not just Command Line Tools).
+
+### Troubleshooting: "damaged and should be moved to Trash"
+
+If macOS says the app is damaged, run this command to remove the quarantine attribute:
+
+```sh
+xattr -cr /Applications/Barik.app
+```
+
+Alternatively, you can right-click the app in Finder and select **Open** to bypass the Gatekeeper warning.
+
+This happens because the app is not notarized with Apple. It is safe to use — you can verify by [building from source](#or-build-from-source).
+
+### Set up your desktop
-2. _(Optional)_ To display open applications and spaces, install [**yabai**](https://github.com/koekeishiya/yabai) or [**AeroSpace**](https://github.com/nikitabobko/AeroSpace) and set up hotkeys. For **yabai**, you'll need **skhd** or **Raycast scripts**. Don't forget to configure **top padding** — [here's an example for **yabai**](https://github.com/mocki-toki/barik/blob/main/example/.yabairc).
+1. _(Optional)_ To display open applications and spaces, install [**yabai**](https://github.com/koekeishiya/yabai) or [**AeroSpace**](https://github.com/nikitabobko/AeroSpace) and set up hotkeys. For **yabai**, you'll need **skhd** or **Raycast scripts**. Don't forget to configure **top padding** — [here's an example for **yabai**](https://github.com/mocki-toki/barik/blob/main/example/.yabairc).
-3. Hide the system menu bar in **System Settings** and uncheck **Desktop & Dock → Show items → On Desktop**.
+2. Hide the system menu bar in **System Settings** and uncheck **Desktop & Dock → Show items → On Desktop**.
-4. Launch **barik** from the Applications folder.
+3. Launch **barik** from the Applications folder.
-5. Add **barik** to your login items for automatic startup.
+4. Add **barik** to your login items for automatic startup.
-**That's it!** Try switching spaces and see the panel in action.
+**That's it!** Try switching spaces and see the panel in action. You can drag widgets to reorder them directly in the menu bar.
## Configuration
@@ -80,6 +110,7 @@ theme = "system" # system, light, dark
displayed = [ # widgets on menu bar
"default.spaces",
"spacer",
+ "default.pomodoro",
"default.nowplaying",
"default.network",
"default.battery",
@@ -149,7 +180,7 @@ Unfortunately, macOS does not support access to its API that allows music contro
1. Spotify (requires the desktop application)
2. Apple Music (requires the desktop application)
-Create an issue so we can add your favorite music service: https://github.com/mocki-toki/barik/issues/new
+Create an issue so we can add your favorite music service: https://github.com/bettercoderthanyou/barik-but-better/issues/new
## Where Are the Menu Items?
@@ -175,4 +206,4 @@ Apple and macOS are trademarks of Apple Inc. This project is not connected to Ap
## Stars
-[](https://starchart.cc/mocki-toki/barik)
+[](https://starchart.cc/bettercoderthanyou/barik-but-better)