Home: two floors of flat app pills (Claude, ChatGPT, two empty slots); hide VS Code and service pills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
// MARK: - BroV home: two floors of flat app pills
|
||||
//
|
||||
// Top floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch).
|
||||
// Bottom floor: two empty slots, to be filled later. The bot keeps its spot on the left.
|
||||
|
||||
struct DesktopAppSlot: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let bundleId: String
|
||||
let color: String
|
||||
let downloadURL: String
|
||||
}
|
||||
|
||||
enum DesktopApps {
|
||||
static let claude = DesktopAppSlot(id: "app_claude", name: "Claude",
|
||||
bundleId: "com.anthropic.claudefordesktop",
|
||||
color: "#D97757", downloadURL: "https://claude.ai/download")
|
||||
static let chatGPT = DesktopAppSlot(id: "app_chatgpt", name: "ChatGPT",
|
||||
bundleId: "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) {
|
||||
AppPill(app: DesktopApps.claude)
|
||||
AppPill(app: DesktopApps.chatGPT)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
EmptyPill()
|
||||
EmptyPill()
|
||||
}
|
||||
}
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: app.bundleId)
|
||||
isRunning = NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == app.bundleId }
|
||||
}
|
||||
|
||||
private func open() {
|
||||
SoundEngine.shared.play("blip")
|
||||
if let running = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == app.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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user