import SwiftUI struct ContentView: View { @EnvironmentObject var vm: UsageViewModel @EnvironmentObject var updater: UpdateManager @State private var showSettings = false @State private var showLogin = false @State private var showUpdateSheet = false var claudeLimits: [AgentLimit] { vm.limits } var body: some View { ZStack { Color(nsColor: .windowBackgroundColor) .opacity(0.97) VStack(spacing: 0) { // Header PanelHeader(showSettings: $showSettings) .environmentObject(vm) Divider().opacity(0.4) // Update success banner — shown after auto-relaunch if updater.justUpdated && !showSettings { UpdateSuccessBanner(version: updater.currentVersion) { updater.justUpdated = false } } // Update available banner if updater.updateAvailable && !showSettings { UpdateBanner(showSheet: $showUpdateSheet) .environmentObject(updater) } if showSettings { SettingsView(showSettings: $showSettings, showUpdateSheet: $showUpdateSheet) .environmentObject(vm) .environmentObject(updater) .transition(.move(edge: .trailing).combined(with: .opacity)) } else { VStack(spacing: 0) { // Cards grid if vm.limits.isEmpty { EmptyStateView() .padding(.vertical, 40) } else { VStack(spacing: 8) { ForEach(vm.limits) { limit in VStack(spacing: 0) { HStack { Text(limit.window == .fiveHour ? "5 Hour Limit" : "7 Day Limit") .font(.system(size: 11, weight: .semibold)) .foregroundColor(.secondary) .textCase(.uppercase) Spacer() } .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 4) LimitCard(limit: limit) .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(8) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.08), lineWidth: 0.5)) .padding(.horizontal, 8) } } } .padding(.bottom, 4) // Extra credits row if let extra = vm.extraUsage, extra.isEnabled { ExtraUsageRow(extra: extra, prepaid: vm.prepaidCredits, overage: vm.overageSpendLimit) .padding(.horizontal, 16) .padding(.top, 12) } // Session Diary DiarySection(limits: vm.limits) .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 8) } if let err = vm.errorMessage { if vm.isNotAuthenticated { SignInBanner { showLogin = true } .padding(.horizontal, 16) .padding(.bottom, 8) } else { ErrorBanner(message: err) .padding(.horizontal, 16) .padding(.bottom, 8) } } } .transition(.opacity) Divider().opacity(0.3) // Footer FooterBar() .environmentObject(vm) } } } .frame(width: 480) .animation(.easeInOut(duration: 0.2), value: showSettings) .onReceive(NotificationCenter.default.publisher(for: .popoverWillOpen)) { _ in showSettings = false } .onReceive(NotificationCenter.default.publisher(for: .openUpdateSheet)) { _ in showSettings = false showUpdateSheet = true } .sheet(isPresented: $showLogin) { LoginSheetView(isPresented: $showLogin) { Task { await vm.reloadAPIWebViewAndRefresh() } } } .sheet(isPresented: $showUpdateSheet) { UpdateSheet(isPresented: $showUpdateSheet) .environmentObject(updater) } .onChange(of: vm.triggerLogin) { triggered in if triggered { showLogin = true vm.triggerLogin = false } } .task { await updater.checkForUpdates() } } } // MARK: - Panel Header struct PanelHeader: View { @EnvironmentObject var vm: UsageViewModel @Binding var showSettings: Bool var body: some View { HStack(spacing: 10) { // App icon Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) .resizable() .frame(width: 24, height: 24) .cornerRadius(6) Text("ClaudeChecker") .font(.system(size: 14, weight: .semibold)) Spacer() HeaderButton(icon: "gearshape") { showSettings.toggle() } .background(showSettings ? Color.primary.opacity(0.08) : Color.clear) .cornerRadius(6) HeaderButton(icon: "xmark") { NotificationCenter.default.post(name: .closePopover, object: nil) } } .padding(.horizontal, 16) .padding(.vertical, 11) } } struct HeaderButton: View { let icon: String let action: () -> Void var body: some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 11.5, weight: .regular)) .foregroundColor(.secondary) .frame(width: 26, height: 26) .background(Color.primary.opacity(0.001)) .contentShape(Rectangle()) } .buttonStyle(.plain) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.primary.opacity(0.1), lineWidth: 0.5) ) } } // MARK: - Limit Card struct LimitCard: View { let limit: AgentLimit @EnvironmentObject var vm: UsageViewModel var accentColor: Color { .orange } var body: some View { HStack(alignment: .center, spacing: 16) { // Gauge GaugeView(percent: limit.usedPercent, color: accentColor) .frame(width: 76, height: 76) // Centre info block VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { AgentIconView(agent: .claude, size: 14) Text("Claude") .font(.system(size: 13, weight: .semibold)) Text("· \(vm.planLabel)") .font(.system(size: 11)) .foregroundColor(.secondary) Spacer() if limit.isLive { LiveBadge() } Text("Usg: \(limit.usageLabel)") .font(.system(size: 10, weight: .medium)) .foregroundColor(.secondary) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.primary.opacity(0.06)) .cornerRadius(4) .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.primary.opacity(0.1), lineWidth: 0.5)) } ProgressBar(value: limit.usedPercent / 100, color: accentColor) .frame(height: 4) HStack(spacing: 16) { HStack(spacing: 4) { Image(systemName: "clock") .font(.system(size: 10)) .foregroundColor(.secondary) Text(limit.timeRemaining) .font(.system(size: 12, weight: .medium).monospacedDigit()) } Text("Resets \(limit.resetDate.formatted(date: .abbreviated, time: .shortened))") .font(.system(size: 11)) .foregroundColor(.secondary) Spacer() HStack(spacing: 3) { Image(systemName: "chart.line.uptrend.xyaxis") .font(.system(size: 9)) .foregroundColor(.secondary) Text(limit.projectedHit.display) .font(.system(size: 11, weight: .medium)) .foregroundColor(.secondary) } } } } .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color(nsColor: .controlBackgroundColor)) } } // MARK: - Gauge View struct GaugeView: View { let percent: Double let color: Color var body: some View { ZStack { // Track Circle() .trim(from: 0.125, to: 0.875) .stroke(Color.primary.opacity(0.07), style: StrokeStyle(lineWidth: 6, lineCap: .round)) .rotationEffect(.degrees(90)) // Fill Circle() .trim(from: 0.125, to: 0.125 + 0.75 * (percent / 100)) .stroke(color, style: StrokeStyle(lineWidth: 6, lineCap: .round)) .rotationEffect(.degrees(90)) .animation(.easeInOut(duration: 0.8), value: percent) // Label VStack(spacing: 1) { Text("\(Int(percent.rounded()))%") .font(.system(size: 17, weight: .bold, design: .monospaced)) .minimumScaleFactor(0.7) Text("Updated\nnow") .font(.system(size: 7.5)) .foregroundColor(.secondary) .multilineTextAlignment(.center) .lineLimit(2) } } } } // MARK: - Progress Bar struct ProgressBar: View { let value: Double // 0.0–1.0 let color: Color var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 2) .fill(Color.primary.opacity(0.07)) RoundedRectangle(cornerRadius: 2) .fill(color) .frame(width: geo.size.width * CGFloat(min(1, max(0, value)))) .animation(.easeInOut(duration: 0.6), value: value) } } } } // MARK: - Live Badge struct LiveBadge: View { @State private var pulse = false var body: some View { HStack(spacing: 4) { Circle() .fill(Color.green) .frame(width: 5, height: 5) .opacity(pulse ? 0.4 : 1.0) .animation(.easeInOut(duration: 1.2).repeatForever(), value: pulse) .onAppear { pulse = true } Text("Live") .font(.system(size: 10, weight: .medium)) .foregroundColor(.green) } .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.green.opacity(0.1)) .cornerRadius(4) .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.green.opacity(0.2), lineWidth: 0.5)) } } // MARK: - Agent Icon struct AgentIconView: View { let agent: AgentType let size: CGFloat var body: some View { ZStack { Circle() .fill(Color.orange.opacity(0.15)) .frame(width: size * 1.4, height: size * 1.4) Text("✦") .font(.system(size: size * 0.7)) .foregroundColor(.orange) } } } // MARK: - Session Diary struct DiarySection: View { let limits: [AgentLimit] var sparkData: [Double] { limits.first(where: { $0.window == .fiveHour })?.burnHistory ?? [] } var burnRate: Double { limits.first(where: { $0.window == .fiveHour })?.burnRate ?? 0 } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text("Session Diary") .font(.system(size: 12, weight: .semibold)) .foregroundColor(.secondary) Text("quota samples · 5h avg burn rate") .font(.system(size: 10.5)) .foregroundColor(Color.secondary.opacity(0.6)) } DiaryRow( totalSaved: sparkData.count, windowSaved: sparkData.count, burnRate: burnRate, sparkData: sparkData, color: .orange ) } } } struct DiaryRow: View { let totalSaved: Int let windowSaved: Int let burnRate: Double let sparkData: [Double] let color: Color var body: some View { VStack(alignment: .leading, spacing: 5) { HStack { Text("\(totalSaved) samples") .font(.system(size: 10.5)) .foregroundColor(.secondary) Spacer() Text("Avg burn rate: \(String(format: "%.1f", burnRate))%/h") .font(.system(size: 10.5)) .foregroundColor(.secondary) } SparklineView(data: sparkData, color: color) .frame(height: 28) .cornerRadius(3) } } } // MARK: - Sparkline struct SparklineView: View { let data: [Double] let color: Color private func pt(i: Int, w: CGFloat, h: CGFloat, minV: Double, range: Double) -> CGPoint { let x = CGFloat(i) / CGFloat(data.count - 1) * w let y = h - CGFloat((data[i] - minV) / range) * (h - 6) - 3 return CGPoint(x: x, y: y) } var body: some View { GeometryReader { geo in let plotData = data.count == 1 ? [data[0], data[0]] : data if plotData.count > 1 { let data = plotData let w = geo.size.width let h = geo.size.height let minV = data.min() ?? 0 let maxV = data.max() ?? 1 let range = max(maxV - minV, 1) ZStack { Path { path in path.move(to: CGPoint(x: 0, y: h)) for i in 0.. 0 else { return 0 } return min(100, max(0, (s / l) * 100)) } var color: Color { pct > 80 ? .red : pct > 50 ? .orange : .green } func fmt(_ cents: Double?) -> String { guard let cents else { return "—" } return String(format: "%.2f", cents / 100) } var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "creditcard") .font(.system(size: 11)) .foregroundColor(.secondary) Text("Extra Usage Credits") .font(.system(size: 12, weight: .semibold)) .foregroundColor(.secondary) } VStack(spacing: 4) { ExtraUsageLine(label: "Spent", value: "\(cur) \(fmt(spentCents))", color: color) ExtraUsageLine(label: "Limit", value: limitCents != nil ? "\(cur) \(fmt(limitCents))" : "Unlimited", color: .secondary) ExtraUsageLine(label: "Balance", value: "\(cur) \(fmt(balanceCents))", color: .primary) } if limitCents != nil { ProgressBar(value: pct / 100, color: color) .frame(height: 4) } } .padding(12) .background(Color.primary.opacity(0.04)) .cornerRadius(8) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.08), lineWidth: 0.5)) } } struct ExtraUsageLine: View { let label: String let value: String let color: Color var body: some View { HStack { Text(label) .font(.system(size: 11)) .foregroundColor(.secondary) Spacer() Text(value) .font(.system(size: 11.5, weight: .medium).monospacedDigit()) .foregroundColor(color) } } } // MARK: - Settings View struct SettingsView: View { @EnvironmentObject var vm: UsageViewModel @EnvironmentObject var updater: UpdateManager @Binding var showSettings: Bool @Binding var showUpdateSheet: Bool @State private var checkingForUpdates = false let refreshOptions: [(label: String, seconds: TimeInterval)] = [ ("1 min", 60), ("2 min", 120), ("3 min", 180), ("4 min", 240), ("5 min", 300), ("10 min", 600), ] var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Button(action: { showSettings = false }) { HStack(spacing: 4) { Image(systemName: "chevron.left") Text("Back") } .font(.system(size: 12)) .foregroundColor(.accentColor) } .buttonStyle(.plain) Spacer() } Text("Settings") .font(.system(size: 16, weight: .semibold)) // Auth section VStack(alignment: .leading, spacing: 8) { Label("Authentication", systemImage: "person.crop.circle") .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) if vm.isSignedIn { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Circle() .fill(Color.green) .frame(width: 7, height: 7) Text("Signed in to claude.ai") .font(.system(size: 13, weight: .semibold)) } if !vm.userEmail.isEmpty { Text(vm.userEmail) .font(.system(size: 11.5)) .foregroundColor(.secondary) .padding(.leading, 13) } } } else { HStack(spacing: 6) { Circle() .fill(Color.orange) .frame(width: 7, height: 7) Text("Not signed in") .font(.system(size: 13, weight: .semibold)) .foregroundColor(.orange) } } Text("Your session is stored locally and persists across restarts.") .font(.system(size: 11.5)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { Button(action: { showSettings = false vm.triggerLogin = true }) { Label(vm.isSignedIn ? "Re-authenticate" : "Sign in", systemImage: "arrow.right.circle") .font(.system(size: 12)) } .buttonStyle(.bordered) .controlSize(.small) if vm.isSignedIn { Button(action: { Task { await vm.signOut() } }) { Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") .font(.system(size: 12)) } .buttonStyle(.bordered) .controlSize(.small) .tint(.red) } } } Divider() // Beta channel VStack(alignment: .leading, spacing: 8) { Label("Update channel", systemImage: "dot.radiowaves.left.and.right") .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) Toggle(isOn: $updater.betaChannel) { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text("Beta updates") .font(.system(size: 12)) Text("BETA") .font(.system(size: 9, weight: .bold)) .foregroundColor(.white) .padding(.horizontal, 5) .padding(.vertical, 2) .background(Color.orange) .cornerRadius(4) if updater.betaAvailable && !updater.betaChannel { Text("v\(updater.latestBetaVersion) available") .font(.system(size: 10, weight: .medium)) .foregroundColor(.orange) } } Text("Receive early access builds — may be less stable") .font(.system(size: 11)) .foregroundColor(.secondary) } } .toggleStyle(.switch) .onChange(of: updater.betaChannel) { _ in Task { await updater.checkForUpdates() } } } Divider() // Menubar display VStack(alignment: .leading, spacing: 8) { Label("Menubar display", systemImage: "menubar.rectangle") .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) Toggle(isOn: $vm.showInMenuBar) { VStack(alignment: .leading, spacing: 2) { Text("Show percentages in menubar") .font(.system(size: 12)) Text("Displays 5h and 7d usage inline next to the icon") .font(.system(size: 11)) .foregroundColor(.secondary) } } .toggleStyle(.switch) } Divider() // Refresh interval VStack(alignment: .leading, spacing: 8) { Label("Auto-refresh interval", systemImage: "clock.arrow.circlepath") .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) Picker("", selection: $vm.refreshInterval) { ForEach(refreshOptions, id: \.seconds) { option in Text(option.label).tag(option.seconds) } } .pickerStyle(.segmented) .labelsHidden() } Divider() // Endpoints info VStack(alignment: .leading, spacing: 4) { Label("Data source", systemImage: "network") .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) VStack(alignment: .leading, spacing: 2) { Text("• /api/bootstrap") Text("• /api/organizations/{id}/usage") Text("• /api/organizations/{id}/overage_spend_limit") Text("• /api/organizations/{id}/prepaid/credits") } .font(.system(size: 11, design: .monospaced)) .foregroundColor(Color.secondary.opacity(0.7)) } Spacer() Divider() // About HStack(spacing: 12) { Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) .resizable() .frame(width: 36, height: 36) .cornerRadius(8) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text("ClaudeChecker") .font(.system(size: 12, weight: .semibold)) Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")") .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(.secondary) .padding(.horizontal, 5) .padding(.vertical, 1) .background(Color.primary.opacity(0.06)) .cornerRadius(4) } if updater.updateAvailable { Text("v\(updater.latestVersion) available") .font(.system(size: 10, weight: .medium)) .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.blue) .cornerRadius(4) } else if updater.betaAvailable { Text("v\(updater.latestBetaVersion) available") .font(.system(size: 10, weight: .medium)) .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.orange) .cornerRadius(4) } Text("Built by superdooper86 & claude") .font(.system(size: 11)) .foregroundColor(.secondary) } Spacer() Button(action: { if updater.updateAvailable { showSettings = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showUpdateSheet = true } } else if updater.betaAvailable && !updater.betaChannel { // Tease: explain they need to enable beta } else { checkingForUpdates = true Task { await updater.checkForUpdates() checkingForUpdates = false } } }) { if checkingForUpdates { ProgressView().scaleEffect(0.6).frame(width: 16, height: 16) } else if updater.updateAvailable { Text("Install") .font(.system(size: 11)) } else if updater.betaAvailable && !updater.betaChannel { Text("Enable beta to install") .font(.system(size: 10)) .foregroundColor(.orange) } else { Text("Check") .font(.system(size: 11)) } } .buttonStyle(.bordered) .controlSize(.small) .disabled(updater.betaAvailable && !updater.updateAvailable && !updater.betaChannel) } .padding(.top, 4) } .padding(16) } } // MARK: - Sign In Banner struct SignInBanner: View { let onSignIn: () -> Void var body: some View { HStack(spacing: 10) { Image(systemName: "person.crop.circle.badge.exclamationmark") .foregroundColor(.orange) .font(.system(size: 14)) VStack(alignment: .leading, spacing: 2) { Text("Not signed in") .font(.system(size: 12, weight: .medium)) Text("Sign in to claude.ai to see your usage.") .font(.system(size: 11)) .foregroundColor(.secondary) } Spacer() Button("Sign In") { onSignIn() } .buttonStyle(.borderedProminent) .controlSize(.small) } .padding(.horizontal, 12) .padding(.vertical, 10) .background(Color.orange.opacity(0.08)) .cornerRadius(8) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.orange.opacity(0.2), lineWidth: 0.5)) } } // MARK: - Error Banner struct ErrorBanner: View { let message: String var body: some View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) .font(.system(size: 12)) Text(message) .font(.system(size: 11.5)) .foregroundColor(.secondary) Spacer() } .padding(.horizontal, 12) .padding(.vertical, 8) .background(Color.orange.opacity(0.08)) .cornerRadius(6) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.orange.opacity(0.2), lineWidth: 0.5)) } } // MARK: - Empty State struct EmptyStateView: View { var body: some View { VStack(spacing: 10) { Image(systemName: "person.crop.circle.badge.questionmark") .font(.system(size: 28)) .foregroundColor(.secondary) Text("Not signed in") .font(.system(size: 13, weight: .medium)) Text("Sign in to claude.ai to see your usage.") .font(.system(size: 11.5)) .foregroundColor(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) } } // MARK: - Color Extension extension Color { init(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 Scanner(string: hex).scanHexInt64(&int) let r = Double((int >> 16) & 0xFF) / 255 let g = Double((int >> 8) & 0xFF) / 255 let b = Double(int & 0xFF) / 255 self.init(red: r, green: g, blue: b) } } // MARK: - Update Success Banner struct UpdateSuccessBanner: View { let version: String let onDismiss: () -> Void @State private var timeRemaining = 10 @State private var timer: Timer? = nil var body: some View { HStack(spacing: 10) { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) .font(.system(size: 16)) VStack(alignment: .leading, spacing: 1) { Text("Updated to v\(version) successfully!") .font(.system(size: 12, weight: .semibold)) Text("ClaudeChecker is now up to date.") .font(.system(size: 11)) .foregroundColor(.secondary) } Spacer() Text("\(timeRemaining)s") .font(.system(size: 10, design: .monospaced)) .foregroundColor(Color.secondary.opacity(0.5)) Button(action: { timer?.invalidate() onDismiss() }) { Image(systemName: "xmark") .font(.system(size: 10, weight: .medium)) .foregroundColor(.secondary) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 10) .background(Color.green.opacity(0.08)) .overlay(Divider(), alignment: .bottom) .onAppear { timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in if timeRemaining > 1 { timeRemaining -= 1 } else { timer?.invalidate() onDismiss() } } } .onDisappear { timer?.invalidate() } } } // MARK: - Markdown notes renderer struct ReleaseNotesText: View { let text: String var font: Font = .system(size: 12) var body: some View { let processed = text .split(separator: "\n", omittingEmptySubsequences: false) .map { line -> String in let s = String(line) if s.hasPrefix("### ") { return "**\(s.dropFirst(4))**" } if s.hasPrefix("## ") { return "**\(s.dropFirst(3))**" } if s.hasPrefix("# ") { return "**\(s.dropFirst(2))**" } if s.hasPrefix("- ") { return "• \(s.dropFirst(2))" } return s } .joined(separator: "\n") if let attr = try? AttributedString(markdown: processed, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { Text(attr).font(font).foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } else { Text(text).font(font).foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } } } // MARK: - Update Banner struct UpdateBanner: View { @EnvironmentObject var updater: UpdateManager @Binding var showSheet: Bool var body: some View { HStack(spacing: 10) { Image(systemName: "arrow.down.circle.fill") .foregroundColor(.blue) .font(.system(size: 16)) VStack(alignment: .leading, spacing: 1) { Text("Update available — v\(updater.latestVersion)") .font(.system(size: 12, weight: .semibold)) Text("Tap Update to view what's new & install.") .font(.system(size: 11)) .foregroundColor(.secondary) } Spacer() Button("Update") { showSheet = true } .buttonStyle(.borderedProminent) .controlSize(.small) } .padding(.horizontal, 16) .padding(.vertical, 10) .background(Color.blue.opacity(0.08)) .overlay(Divider(), alignment: .bottom) .onTapGesture { showSheet = true } } } // MARK: - Update Sheet struct UpdateSheet: View { @EnvironmentObject var updater: UpdateManager @Binding var isPresented: Bool var body: some View { VStack(spacing: 0) { // Header HStack { Text("Update ClaudeChecker") .font(.system(size: 14, weight: .semibold)) Spacer() if !updater.isDownloading && !updater.updateComplete { Button("Not Now") { isPresented = false } .buttonStyle(.bordered) .controlSize(.small) } } .padding(.horizontal, 16) .padding(.vertical, 12) Divider() VStack(spacing: 20) { // Icon + version info HStack(spacing: 14) { Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) .resizable() .frame(width: 48, height: 48) .cornerRadius(11) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text("v\(updater.currentVersion)") .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) Image(systemName: "arrow.right") .font(.system(size: 10)) .foregroundColor(.secondary) Text("v\(updater.latestVersion)") .font(.system(size: 13, weight: .semibold, design: .monospaced)) .foregroundColor(.blue) } if !updater.releaseNotes.isEmpty { ReleaseNotesText(text: updater.releaseNotes) } } Spacer() } if updater.updateComplete { // Done state VStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 32)) .foregroundColor(.green) Text(updater.statusMessage) .font(.system(size: 13, weight: .medium)) } } else if updater.isDownloading { // Progress state VStack(alignment: .leading, spacing: 8) { HStack { Text(updater.statusMessage) .font(.system(size: 12)) .foregroundColor(.secondary) Spacer() Text("\(Int(updater.downloadProgress * 100))%") .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundColor(.secondary) } ProgressView(value: updater.downloadProgress) .progressViewStyle(.linear) .tint(.blue) } } else { // Ready to install VStack(spacing: 10) { if let err = updater.updateError { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text(err) .font(.system(size: 11.5)) .foregroundColor(.secondary) } } Text("ClaudeChecker will download the update, replace itself, and relaunch automatically.") .font(.system(size: 11.5)) .foregroundColor(.secondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) Button(action: { Task { await updater.downloadAndInstall() } }) { HStack { Image(systemName: "arrow.down.circle.fill") Text(updater.updateError != nil ? "Try Again" : "Install Update") .fontWeight(.semibold) } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) } } } .padding(20) } .frame(width: 380) } }