Files
brov-macbook/NotchBuddy/Sources/App/AppGridView.swift
T
2026-10-06 22:08:29 +03:00

169 lines
6.4 KiB
Swift

import SwiftUI
import AppKit
// MARK: - BroV home: two floors of flat app pills
//
// Top floor: two empty slots, to be filled later.
// Bottom floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch). The bot keeps its spot on the left.
struct DesktopAppSlot: Identifiable {
let id: String
let name: String
/// Tried in order: the first installed one wins.
let bundleIds: [String]
let color: String
let downloadURL: String
}
enum DesktopApps {
static let claude = DesktopAppSlot(id: "app_claude", name: "Claude",
bundleIds: ["com.anthropic.claudefordesktop"],
color: "#D97757", downloadURL: "https://claude.ai/download")
static let chatGPT = DesktopAppSlot(id: "app_chatgpt", name: "ChatGPT",
// New ChatGPT app first (its id is com.openai.codex), Classic as fallback.
bundleIds: ["com.openai.codex", "com.openai.chat"],
color: "#10A37F", downloadURL: "https://chatgpt.com/download")
}
struct AppGridView: View {
@ObservedObject var state: AppState
var body: some View {
ZStack {
VStack(spacing: 8) {
HStack(spacing: 8) {
EmptyPill()
EmptyPill()
}
HStack(spacing: 8) {
AppPill(app: DesktopApps.claude)
AppPill(app: DesktopApps.chatGPT)
}
}
.padding(.leading, 108)
.padding(.trailing, 14)
.padding(.vertical, 6)
#if !APPSTORE
if state.showingPlanDetail {
CardBackground(wash: nil)
ClaudePlanCardView(usage: state.claudePlanUsage)
.transition(.opacity)
}
#endif
}
#if !APPSTORE
.onChange(of: state.mode) { _, m in
if m != .expanded { state.showingPlanDetail = false }
}
#endif
}
}
// MARK: - App pill
struct AppPill: View {
let app: DesktopAppSlot
@State private var appURL: URL?
@State private var isRunning = false
@State private var isHovered = false
private var status: String {
if appURL == nil { return "не установлено" }
return isRunning ? "запущено" : "не запущено"
}
var body: some View {
Button(action: open) {
HStack(spacing: 10) {
icon
.frame(width: 26, height: 26)
VStack(alignment: .leading, spacing: 1) {
Text(app.name)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
HStack(spacing: 5) {
Circle()
.fill(isRunning ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
.frame(width: 5, height: 5)
Text(status)
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#8E939C"))
}
}
Spacer(minLength: 4)
Image(systemName: "arrow.up.right")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(isHovered ? Color(hex: app.color) : Color(hex: "#5F646D"))
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(isHovered ? Color(hex: app.color).opacity(0.16) : Color(hex: "#0E0F11"))
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.scaleEffect(isHovered ? 1.02 : 1)
.onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } }
.onAppear(perform: refresh)
.onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification)) { _ in refresh() }
.onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { _ in refresh() }
}
@ViewBuilder private var icon: some View {
if let appURL {
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
.resizable()
.interpolation(.high)
} else {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color(hex: app.color))
.overlay(Text(String(app.name.prefix(1)))
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white))
}
}
/// The first installed bundle id of the slot.
private var bundleId: String? {
app.bundleIds.first { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) != nil }
}
private func refresh() {
appURL = bundleId.flatMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) }
isRunning = NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == bundleId }
}
private func open() {
SoundEngine.shared.play("blip")
if let running = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleId }) {
running.activate(options: .activateIgnoringOtherApps)
} else if let appURL {
NSWorkspace.shared.openApplication(at: appURL, configuration: .init(), completionHandler: nil)
} else if let url = URL(string: app.downloadURL) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Empty slot
struct EmptyPill: View {
var body: some View {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.overlay(
Text("пусто")
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#4A4E55"))
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}