Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion ClaudeMeter/Models/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,20 @@ struct AppSettings: Codable, Equatable, Sendable {
/// Menu bar icon display style
var iconStyle: IconStyle

/// Tint the menu bar icon with the green/orange/red status colours. When false (default),
/// the icon is rendered as a template image so macOS tints it to match the system menu bar
/// (white in dark mode, black in light mode), aligning with the system tray's aesthetic.
var useColoredIcon: Bool

static let `default` = AppSettings(
refreshInterval: 60,
hasNotificationsEnabled: true,
notificationThresholds: .default,
isFirstLaunch: true,
cachedOrganizationId: nil,
isSonnetUsageShown: false,
iconStyle: .battery
iconStyle: .battery,
useColoredIcon: false
)

enum CodingKeys: String, CodingKey {
Expand All @@ -48,6 +54,41 @@ struct AppSettings: Codable, Equatable, Sendable {
case cachedOrganizationId = "cached_organization_id"
case isSonnetUsageShown = "show_sonnet_usage"
case iconStyle = "icon_style"
case useColoredIcon = "use_colored_icon"
}

init(
refreshInterval: TimeInterval,
hasNotificationsEnabled: Bool,
notificationThresholds: NotificationThresholds,
isFirstLaunch: Bool,
cachedOrganizationId: UUID?,
isSonnetUsageShown: Bool,
iconStyle: IconStyle,
useColoredIcon: Bool
) {
self.refreshInterval = refreshInterval
self.hasNotificationsEnabled = hasNotificationsEnabled
self.notificationThresholds = notificationThresholds
self.isFirstLaunch = isFirstLaunch
self.cachedOrganizationId = cachedOrganizationId
self.isSonnetUsageShown = isSonnetUsageShown
self.iconStyle = iconStyle
self.useColoredIcon = useColoredIcon
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let defaults = AppSettings.default
refreshInterval = try container.decodeIfPresent(TimeInterval.self, forKey: .refreshInterval) ?? defaults.refreshInterval
hasNotificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .hasNotificationsEnabled) ?? defaults.hasNotificationsEnabled
notificationThresholds = try container.decodeIfPresent(NotificationThresholds.self, forKey: .notificationThresholds) ?? defaults.notificationThresholds
isFirstLaunch = try container.decodeIfPresent(Bool.self, forKey: .isFirstLaunch) ?? defaults.isFirstLaunch
cachedOrganizationId = try container.decodeIfPresent(UUID.self, forKey: .cachedOrganizationId)
isSonnetUsageShown = try container.decodeIfPresent(Bool.self, forKey: .isSonnetUsageShown) ?? defaults.isSonnetUsageShown
iconStyle = try container.decodeIfPresent(IconStyle.self, forKey: .iconStyle) ?? defaults.iconStyle
// Backwards-compat: this key didn't exist before, so fall back to the default (off).
useColoredIcon = try container.decodeIfPresent(Bool.self, forKey: .useColoredIcon) ?? defaults.useColoredIcon
}
}

Expand Down
17 changes: 11 additions & 6 deletions ClaudeMeter/Views/MenuBar/IconCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,17 @@ final class IconCache {
isLoading: Bool,
isStale: Bool,
iconStyle: IconStyle,
weeklyPercentage: Double
weeklyPercentage: Double,
useColor: Bool
) -> NSImage? {
cache.object(forKey: cacheKey(
percentage: percentage,
status: status,
isLoading: isLoading,
isStale: isStale,
iconStyle: iconStyle,
weeklyPercentage: weeklyPercentage
weeklyPercentage: weeklyPercentage,
useColor: useColor
))
}

Expand All @@ -40,7 +42,8 @@ final class IconCache {
isLoading: Bool,
isStale: Bool,
iconStyle: IconStyle,
weeklyPercentage: Double
weeklyPercentage: Double,
useColor: Bool
) {
cache.setObject(
image,
Expand All @@ -50,7 +53,8 @@ final class IconCache {
isLoading: isLoading,
isStale: isStale,
iconStyle: iconStyle,
weeklyPercentage: weeklyPercentage
weeklyPercentage: weeklyPercentage,
useColor: useColor
)
)
}
Expand All @@ -61,10 +65,11 @@ final class IconCache {
isLoading: Bool,
isStale: Bool,
iconStyle: IconStyle,
weeklyPercentage: Double
weeklyPercentage: Double,
useColor: Bool
) -> NSString {
let percent = String(format: "%.2f", percentage)
let weekly = String(format: "%.2f", weeklyPercentage)
return "\(percent)|\(weekly)|\(status.rawValue)|\(isLoading)|\(isStale)|\(iconStyle.rawValue)" as NSString
return "\(percent)|\(weekly)|\(status.rawValue)|\(isLoading)|\(isStale)|\(iconStyle.rawValue)|\(useColor)" as NSString
}
}
33 changes: 7 additions & 26 deletions ClaudeMeter/Views/MenuBar/IconStyles/BatteryIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct BatteryIcon: View {
let status: UsageStatus
let isLoading: Bool
let isStale: Bool
var useColor: Bool = true

private let capsuleWidth: CGFloat = 28
private let capsuleHeight: CGFloat = 10
Expand All @@ -22,24 +23,24 @@ struct BatteryIcon: View {
if isLoading {
Image(systemName: "arrow.clockwise")
.font(.system(size: 10, weight: .medium))
.foregroundColor(statusColor)
.foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale))
} else {
// Capsule with gradient fill using mask for proper rounded ends
Capsule()
.fill(Color.gray.opacity(0.3))
.fill(MenuBarIconColors.track(useColor: useColor))
.overlay(alignment: .leading) {
GeometryReader { geo in
fillGradient
MenuBarIconColors.gradient(useColor: useColor, isStale: isStale)
.frame(width: geo.size.width * min(percentage / 100, 1.0))
}
.clipShape(Capsule())
}
.frame(width: capsuleWidth, height: capsuleHeight)

// Percentage text
Text("\(Int(percentage))%")
Text("\(Int(percentage))%")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(statusColor)
.foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale))
}

if isStale && !isLoading {
Expand All @@ -53,27 +54,6 @@ struct BatteryIcon: View {
.accessibilityLabel("Usage: \(Int(percentage)) percent")
.accessibilityValue(status.accessibilityDescription)
}

private var fillGradient: LinearGradient {
if isStale {
return LinearGradient(
colors: [.gray, .gray],
startPoint: .leading,
endPoint: .trailing
)
}

// Gradient shows current status position
return LinearGradient(
colors: [.green, .yellow, .orange, .red],
startPoint: .leading,
endPoint: .trailing
)
}

private var statusColor: Color {
isStale ? .gray : status.color
}
}

#Preview {
Expand All @@ -84,6 +64,7 @@ struct BatteryIcon: View {
BatteryIcon(percentage: 95, status: .critical, isLoading: false, isStale: false)
BatteryIcon(percentage: 45, status: .safe, isLoading: true, isStale: false)
BatteryIcon(percentage: 45, status: .safe, isLoading: false, isStale: true)
BatteryIcon(percentage: 75, status: .warning, isLoading: false, isStale: false, useColor: false)
}
.padding()
}
13 changes: 5 additions & 8 deletions ClaudeMeter/Views/MenuBar/IconStyles/CircularGaugeIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct CircularGaugeIcon: View {
let status: UsageStatus
let isLoading: Bool
let isStale: Bool
var useColor: Bool = true

private let lineWidth: CGFloat = 3
private let size: CGFloat = 18
Expand All @@ -21,23 +22,23 @@ struct CircularGaugeIcon: View {
ZStack {
// Background circle
Circle()
.stroke(Color.gray.opacity(0.3), lineWidth: lineWidth)
.stroke(MenuBarIconColors.track(useColor: useColor), lineWidth: lineWidth)

// Progress arc
Circle()
.trim(from: 0, to: min(percentage / 100, 1.0))
.stroke(statusColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.stroke(MenuBarIconColors.fill(useColor: useColor, status: status, isStale: isStale), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))

// Center percentage or loading indicator
if isLoading {
Image(systemName: "arrow.clockwise")
.font(.system(size: 7, weight: .medium))
.foregroundColor(statusColor)
.foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale))
} else {
Text("\(Int(percentage))")
.font(.system(size: 7, weight: .bold, design: .rounded))
.foregroundColor(statusColor)
.foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale))
}
}
.frame(width: size, height: size)
Expand All @@ -54,10 +55,6 @@ struct CircularGaugeIcon: View {
.accessibilityLabel("Usage: \(Int(percentage)) percent")
.accessibilityValue(status.accessibilityDescription)
}

private var statusColor: Color {
isStale ? .gray : status.color
}
}

#Preview {
Expand Down
37 changes: 11 additions & 26 deletions ClaudeMeter/Views/MenuBar/IconStyles/DualBarIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct DualBarIcon: View {
let status: UsageStatus
let isLoading: Bool
let isStale: Bool
var useColor: Bool = true

private let barWidth: CGFloat = 32
private let barHeight: CGFloat = 5
Expand All @@ -24,31 +25,31 @@ struct DualBarIcon: View {
if isLoading {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .medium))
.foregroundColor(statusColor)
.foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale))
} else {
// Two stacked progress bars
VStack(spacing: barSpacing) {
// Session bar (top) - blue/cyan
// Session bar (top): status colour in colour mode, black in monochrome
ProgressBar(
percentage: percentage,
color: sessionBarColor,
isStale: isStale
color: MenuBarIconColors.fill(useColor: useColor, status: status, isStale: isStale),
useColor: useColor
)
.frame(width: barWidth, height: barHeight)

// Weekly bar (bottom) - purple
// Weekly bar (bottom): purple in colour mode, black in monochrome
ProgressBar(
percentage: weeklyPercentage,
color: weeklyBarColor,
isStale: isStale
color: MenuBarIconColors.secondary(useColor: useColor, isStale: isStale),
useColor: useColor
)
.frame(width: barWidth, height: barHeight)
}

// Show session percentage (primary metric)
Text("\(Int(percentage))%")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(statusColor)
.foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale))
}

if isStale && !isLoading {
Expand All @@ -62,36 +63,20 @@ struct DualBarIcon: View {
.accessibilityLabel("Session: \(Int(percentage)) percent, Weekly: \(Int(weeklyPercentage)) percent")
.accessibilityValue(status.accessibilityDescription)
}

private var statusColor: Color {
isStale ? .gray : status.color
}

private var sessionBarColor: Color {
if isStale { return .gray }
// Use status color for session bar
return status.color
}

private var weeklyBarColor: Color {
if isStale { return .gray }
// Purple/violet for weekly to distinguish from session
return .purple
}
}

/// Individual progress bar component
private struct ProgressBar: View {
let percentage: Double
let color: Color
let isStale: Bool
let useColor: Bool

var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
// Background
RoundedRectangle(cornerRadius: 1.5)
.fill(Color.gray.opacity(0.3))
.fill(MenuBarIconColors.track(useColor: useColor))

// Fill
RoundedRectangle(cornerRadius: 1.5)
Expand Down
11 changes: 4 additions & 7 deletions ClaudeMeter/Views/MenuBar/IconStyles/GaugeIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,19 @@ struct GaugeIcon: View {
let status: UsageStatus
let isLoading: Bool
let isStale: Bool
var useColor: Bool = true

var body: some View {
HStack(spacing: 4) {
if isLoading {
Image(systemName: "arrow.clockwise")
.font(.system(size: 14, weight: .medium))
.foregroundColor(statusColor)
.foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale))
} else {
Image(systemName: symbolName)
.font(.system(size: 14, weight: .medium))
.foregroundColor(statusColor)
.symbolRenderingMode(.hierarchical)
.foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale))
.symbolRenderingMode(useColor ? .hierarchical : .monochrome)
}

if isStale && !isLoading {
Expand Down Expand Up @@ -59,10 +60,6 @@ struct GaugeIcon: View {
return "gauge.with.dots.needle.100percent"
}
}

private var statusColor: Color {
isStale ? .gray : status.color
}
}

#Preview {
Expand Down
9 changes: 3 additions & 6 deletions ClaudeMeter/Views/MenuBar/IconStyles/MinimalIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,18 @@ struct MinimalIcon: View {
let status: UsageStatus
let isLoading: Bool
let isStale: Bool
var useColor: Bool = true

var body: some View {
HStack(spacing: 2) {
if isLoading {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .medium))
.foregroundColor(statusColor)
.foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale))
} else {
Text("\(Int(percentage))%")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(statusColor)
.foregroundColor(MenuBarIconColors.text(useColor: useColor, status: status, isStale: isStale))
}

if isStale && !isLoading {
Expand All @@ -37,10 +38,6 @@ struct MinimalIcon: View {
.accessibilityLabel("Usage: \(Int(percentage)) percent")
.accessibilityValue(status.accessibilityDescription)
}

private var statusColor: Color {
isStale ? .gray : status.color
}
}

#Preview {
Expand Down
Loading