3c8a9a1c12
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4730 lines
194 KiB
Swift
4730 lines
194 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - Dispatch view content by IslandView
|
||
|
||
struct IslandViewContent: View {
|
||
let view: IslandView
|
||
@ObservedObject var state: AppState
|
||
|
||
var body: some View {
|
||
switch view {
|
||
// BroV home: two floors of flat app pills (Claude, ChatGPT, two free slots).
|
||
case .overview: AppGridView(state: state)
|
||
case .empty: AppGridView(state: state)
|
||
case .approval: ApprovalView(state: state)
|
||
case .question: QuestionView(state: state)
|
||
case .error: ErrorView(state: state)
|
||
case .finished: FinishedView(state: state)
|
||
case .confused: ConfusedView()
|
||
case .upload: UploadView(state: state)
|
||
case .uploading: UploadingView(state: state)
|
||
case .choose: ChooseView(state: state)
|
||
case .mail: MailView(state: state)
|
||
case .prompt: PromptView(state: state)
|
||
case .searching: SearchingView(state: state)
|
||
case .result: ResultView(state: state)
|
||
case .note: NoteView(state: state)
|
||
case .settings: SettingsIslandView(state: state)
|
||
case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView
|
||
case .wardrobe: WardrobeView(state: state)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Overview
|
||
|
||
struct OverviewView: View {
|
||
@ObservedObject var state: AppState
|
||
@State private var showingN8nDetail = false
|
||
@State private var activeDiffId: Int? = nil
|
||
|
||
var agent: AgentTask? { state.focusTask }
|
||
|
||
var body: some View {
|
||
HStack(spacing: 10) {
|
||
// Left card: title row + ticker below + ↗ button overlay
|
||
ZStack(alignment: .topLeading) {
|
||
CardBackground(wash: nil)
|
||
|
||
// Title row + ticker stacked (or integration card)
|
||
if let agent = agent {
|
||
if agent.isIntegration {
|
||
IntegrationCardView(task: agent, showingDetail: $showingN8nDetail, onDiffTap: { diffIdx in
|
||
withAnimation(.easeIn(duration: 0.16)) { activeDiffId = diffIdx }
|
||
})
|
||
} else {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(hex: agent.color))
|
||
.frame(width: 7, height: 7)
|
||
Text(agent.name)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.layoutPriority(1)
|
||
Text({ () -> String in
|
||
switch agent.source {
|
||
case .claudeCode: return "Claude Code"
|
||
case .agent: return "Агент"
|
||
case .n8n: return "n8n"
|
||
}
|
||
}())
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
Spacer(minLength: 2)
|
||
if agent.steps.count > 1 {
|
||
Text("\(min(agent.stepIndex + 1, agent.steps.count))/\(agent.steps.count)")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
.fixedSize()
|
||
}
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 36)
|
||
|
||
TickerView(task: agent, onDiffTap: { diffIdx in
|
||
withAnimation(.easeIn(duration: 0.16)) { activeDiffId = diffIdx }
|
||
})
|
||
.frame(height: 44)
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
}
|
||
}
|
||
|
||
// Plan detail overlays on top of normal content (GitHub build, home view only)
|
||
#if !APPSTORE
|
||
if state.showingPlanDetail {
|
||
CardBackground(wash: nil)
|
||
ClaudePlanCardView(usage: state.claudePlanUsage)
|
||
.transition(.opacity)
|
||
}
|
||
#endif
|
||
|
||
// Diff overlay — replaces ticker when a diff step is tapped
|
||
if let diffId = activeDiffId,
|
||
let task = agent,
|
||
let diff = state.sessionDiffs[task.id]?.first(where: { $0.id == diffId }) {
|
||
CardBackground(wash: nil)
|
||
DiffCardView(diff: diff, onDismiss: { activeDiffId = nil })
|
||
.transition(.opacity)
|
||
}
|
||
|
||
// ↗ jump button — last in ZStack so it renders on top; hidden while any detail is open
|
||
#if !APPSTORE
|
||
let hideJumpButton = showingN8nDetail || state.showingPlanDetail || activeDiffId != nil
|
||
#else
|
||
let hideJumpButton = showingN8nDetail || activeDiffId != nil
|
||
#endif
|
||
if !hideJumpButton {
|
||
Button(action: { openAgentTarget(agent) }) {
|
||
Image(systemName: "arrow.up.right")
|
||
.font(.system(size: 8, weight: .medium))
|
||
.foregroundColor(Color(hex: "#5F646D"))
|
||
.frame(width: 16, height: 16)
|
||
.background(Color.white.opacity(0.07))
|
||
.clipShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.top, 8)
|
||
.padding(.trailing, 10)
|
||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||
}
|
||
}
|
||
.frame(width: 322)
|
||
|
||
// Right card: agent pills
|
||
CardBackground(wash: nil) {
|
||
AgentPillsView(state: state)
|
||
}
|
||
}
|
||
.onChange(of: state.focusId) { _, new in
|
||
showingN8nDetail = false
|
||
activeDiffId = nil
|
||
#if !APPSTORE
|
||
withAnimation(.easeIn(duration: 0.16)) { state.showingPlanDetail = false }
|
||
#endif
|
||
if new == "integration_github" { GithubPoller.shared.refreshIfStale() }
|
||
}
|
||
#if !APPSTORE
|
||
.onChange(of: state.view) { _, v in
|
||
if v != .overview { state.showingPlanDetail = false; activeDiffId = nil }
|
||
}
|
||
#endif
|
||
.onChange(of: state.mode) { _, m in
|
||
#if !APPSTORE
|
||
if m != .expanded { state.showingPlanDetail = false; activeDiffId = nil }
|
||
#endif
|
||
if m == .expanded && state.focusId == "integration_github" {
|
||
GithubPoller.shared.refreshIfStale()
|
||
}
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .islandToggleDiff)) { _ in
|
||
if let id = activeDiffId {
|
||
withAnimation(.easeIn(duration: 0.16)) { activeDiffId = nil }
|
||
_ = id
|
||
} else if let task = state.focusTask,
|
||
let last = state.sessionDiffs[task.id]?.last {
|
||
withAnimation(.easeIn(duration: 0.16)) { activeDiffId = last.id }
|
||
}
|
||
}
|
||
}
|
||
|
||
private func openAgentTarget(_ task: AgentTask?) {
|
||
guard let task else { return }
|
||
switch task.id {
|
||
case "integration_claude":
|
||
let vscodeBundleId = "com.microsoft.VSCode"
|
||
if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == vscodeBundleId }) {
|
||
app.activate(options: .activateIgnoringOtherApps)
|
||
} else {
|
||
NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications/Visual Studio Code.app"))
|
||
}
|
||
case "integration_resend":
|
||
NSWorkspace.shared.open(URL(string: "https://resend.com/emails")!)
|
||
case "integration_vercel":
|
||
NSWorkspace.shared.open(URL(string: "https://vercel.com/dashboard")!)
|
||
case "integration_github":
|
||
NSWorkspace.shared.open(URL(string: "https://github.com/pulls")!)
|
||
case "integration_n8n":
|
||
if let urlStr = KeychainStore.shared.get("n8n-url"), let url = URL(string: urlStr) {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
case "integration_stripe":
|
||
NSWorkspace.shared.open(URL(string: "https://dashboard.stripe.com/payments")!)
|
||
case "integration_notion":
|
||
NSWorkspace.shared.open(URL(string: "https://notion.so")!)
|
||
case "integration_calcom":
|
||
NSWorkspace.shared.open(URL(string: "https://app.cal.com/bookings")!)
|
||
case "agent_cursor":
|
||
#if !APPSTORE
|
||
if let url = NSWorkspace.shared.urlForApplication(
|
||
withBundleIdentifier: "com.todesktop.230313mzl4w4u92") {
|
||
NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil)
|
||
}
|
||
#endif
|
||
case "agent_codex":
|
||
#if !APPSTORE
|
||
if let url = NSWorkspace.shared.urlForApplication(
|
||
withBundleIdentifier: "com.openai.codex") {
|
||
NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil)
|
||
}
|
||
#endif
|
||
case "agent_gemini", "agent_antigravity":
|
||
#if !APPSTORE
|
||
let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2",
|
||
"net.kovidgoyal.kitty", "com.mitchellh.ghostty"]
|
||
if let hit = terminalBundleIds.compactMap({ id in
|
||
NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id }
|
||
}).first {
|
||
hit.activate(options: .activateIgnoringOtherApps)
|
||
}
|
||
#endif
|
||
case "ai_anthropic":
|
||
switchChatProvider(.anthropic)
|
||
case "ai_google":
|
||
switchChatProvider(.google)
|
||
case "ai_openai":
|
||
switchChatProvider(.openai)
|
||
case "ai_ollama":
|
||
switchChatProvider(.ollama)
|
||
case "ai_lmstudio":
|
||
switchChatProvider(.lmstudio)
|
||
case "integration_music":
|
||
#if !APPSTORE
|
||
MusicController.shared.openMusic()
|
||
#endif
|
||
default:
|
||
// Non-integration real tasks
|
||
if task.source == .n8n {
|
||
if let urlStr = KeychainStore.shared.get("n8n-url"), let url = URL(string: urlStr) {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
} else {
|
||
#if !APPSTORE
|
||
let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2",
|
||
"net.kovidgoyal.kitty", "com.mitchellh.ghostty"]
|
||
if let hit = terminalBundleIds.compactMap({ id in
|
||
NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id }
|
||
}).first {
|
||
hit.activate(options: .activateIgnoringOtherApps)
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Empty
|
||
|
||
struct EmptyStateView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
CardBackground(wash: nil)
|
||
HStack(spacing: 16) {
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
Text("Сейчас ничего не запущено.")
|
||
.font(.system(size: 15, weight: .semibold))
|
||
Text("Перетащи файл или окно — или спроси что угодно.")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(Color(hex: "#9398A1"))
|
||
}
|
||
Spacer()
|
||
PrimaryButton("Спросить Claude") {
|
||
state.view = .prompt
|
||
}
|
||
}
|
||
.padding(.leading, 118)
|
||
.padding(.trailing, 18)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Approval
|
||
|
||
struct ApprovalView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
var approval: ApprovalInfo? { state.pendingApproval }
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
CardBackground(wash: .amber)
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
AgentWho(task: state.focusTask, label: "просит разрешение")
|
||
CodeBlock(text: approval?.command ?? approval?.tool ?? "…")
|
||
HStack(spacing: 8) {
|
||
SecondaryButton("Запретить") {
|
||
HookServer.shared.sendApprovalDecision("deny")
|
||
}
|
||
PrimaryButton("Разрешить") {
|
||
HookServer.shared.sendApprovalDecision("allow")
|
||
}
|
||
// Codex rejects updatedPermissions, so "Always" is not offered
|
||
if approval?.pillId != "agent_codex" {
|
||
SecondaryButton("Всегда") {
|
||
HookServer.shared.sendApprovalDecision("always")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.leading, 116)
|
||
.padding(.trailing, 16)
|
||
.padding(.vertical, 4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Question
|
||
|
||
struct QuestionView: View {
|
||
@ObservedObject var state: AppState
|
||
@State private var questionIndex = 0
|
||
// Per-question selected labels (empty = none chosen yet)
|
||
@State private var selections: [[String]] = []
|
||
// Per-question custom "Other…" text
|
||
@State private var otherTexts: [String] = []
|
||
// Per-question "Other…" mode active
|
||
@State private var showOther: [Bool] = []
|
||
@FocusState private var otherFieldFocused: Bool
|
||
|
||
var question: AskQuestion? { state.pendingQuestion }
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
CardBackground(wash: .cyan)
|
||
if let q = question, !q.questions.isEmpty {
|
||
let qi = min(questionIndex, q.questions.count - 1)
|
||
let item = q.questions[qi]
|
||
let isLast = qi == q.questions.count - 1
|
||
let isMulti = item.multiSelect
|
||
let curSel = qi < selections.count ? selections[qi] : []
|
||
let curOther = qi < showOther.count ? showOther[qi] : false
|
||
let curOtherText = qi < otherTexts.count ? otherTexts[qi] : ""
|
||
let canProceed = !curSel.isEmpty || (curOther && !curOtherText.isEmpty)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
// Header row: agent name + question counter + "Reply in terminal" link
|
||
HStack(spacing: 4) {
|
||
AgentWho(task: nil, label: "Claude Code спрашивает")
|
||
Spacer(minLength: 4)
|
||
if q.questions.count > 1 {
|
||
Text("\(qi + 1)/\(q.questions.count)")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
Button("Ответить в терминале") { HookServer.shared.sendQuestionAsk() }
|
||
.buttonStyle(.plain)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
.underline()
|
||
}
|
||
// Optional short header label above question text
|
||
if !item.header.isEmpty {
|
||
Text(item.header)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
Text(item.question)
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
.lineLimit(2)
|
||
// Options (wrapping) or "Other…" compact inline row
|
||
if curOther {
|
||
HStack(spacing: 6) {
|
||
TextField("Твой ответ…", text: Binding(
|
||
get: { qi < otherTexts.count ? otherTexts[qi] : "" },
|
||
set: { v in if qi < otherTexts.count { otherTexts[qi] = v } }
|
||
))
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
.focused($otherFieldFocused)
|
||
.onAppear { otherFieldFocused = true }
|
||
.onSubmit { commitOtherAndProceed(q: q, qi: qi, isLast: isLast) }
|
||
.onExitCommand { if qi < showOther.count { showOther[qi] = false } }
|
||
.padding(.horizontal, 8).padding(.vertical, 5)
|
||
.background(Color.white.opacity(0.07))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
Button(isLast ? "Отправить" : "Далее") {
|
||
commitOtherAndProceed(q: q, qi: qi, isLast: isLast)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(curOtherText.isEmpty ? Color(hex: "#6B7079") : Color(hex: "#F5F6F8"))
|
||
.padding(.horizontal, 8).padding(.vertical, 5)
|
||
.background(Color.white.opacity(curOtherText.isEmpty ? 0.05 : 0.15))
|
||
.clipShape(Capsule())
|
||
.disabled(curOtherText.isEmpty)
|
||
Button { if qi < showOther.count { showOther[qi] = false } } label: {
|
||
Text("✕").font(.system(size: 9))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
} else {
|
||
ChipFlowLayout(spacing: 6) {
|
||
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
|
||
let isSelected = curSel.contains(opt.label)
|
||
if isMulti {
|
||
Button {
|
||
toggleSelection(qi: qi, label: opt.label)
|
||
} label: {
|
||
Text(opt.label)
|
||
.font(.system(size: 12, weight: .medium))
|
||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||
.background(isSelected ? Color(hex: "#22D3EE").opacity(0.22) : Color.white.opacity(0.07))
|
||
.foregroundColor(isSelected ? Color(hex: "#67E8F9") : Color(hex: "#C5C8CD"))
|
||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||
.overlay(RoundedRectangle(cornerRadius: 7).stroke(isSelected ? Color(hex: "#22D3EE").opacity(0.55) : Color.white.opacity(0.1), lineWidth: 1))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: [])
|
||
} else {
|
||
SecondaryButton(opt.label) {
|
||
selectAndProceed(q: q, qi: qi, label: opt.label, isLast: isLast)
|
||
}
|
||
.keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: [])
|
||
}
|
||
}
|
||
// "Other…" implicit free-text option
|
||
SecondaryButton("Другое…") {
|
||
if qi < showOther.count { showOther[qi] = true }
|
||
}
|
||
}
|
||
}
|
||
// Send/Next — only for multi-select (and not while "Other…" field is open)
|
||
if isMulti && !curOther {
|
||
PrimaryButton(isLast ? "Отправить" : "Далее") {
|
||
proceedFromQuestion(q: q, qi: qi, isLast: isLast)
|
||
}
|
||
.disabled(!canProceed)
|
||
.opacity(canProceed ? 1 : 0.4)
|
||
}
|
||
}
|
||
.padding(.leading, 116)
|
||
.padding(.trailing, 16)
|
||
.padding(.vertical, 4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
.onAppear { resetQuestionState() }
|
||
.onChange(of: state.pendingQuestion) { _, _ in resetQuestionState() }
|
||
.onDisappear { HookServer.shared.releaseQuestionFD() }
|
||
}
|
||
|
||
private func resetQuestionState() {
|
||
questionIndex = 0
|
||
let count = state.pendingQuestion?.questions.count ?? 0
|
||
selections = Array(repeating: [], count: count)
|
||
otherTexts = Array(repeating: "", count: count)
|
||
showOther = Array(repeating: false, count: count)
|
||
}
|
||
|
||
private func toggleSelection(qi: Int, label: String) {
|
||
guard qi < selections.count else { return }
|
||
if let i = selections[qi].firstIndex(of: label) {
|
||
selections[qi].remove(at: i)
|
||
} else {
|
||
selections[qi].append(label)
|
||
}
|
||
}
|
||
|
||
// Single-select: pick a label and immediately advance/send
|
||
private func selectAndProceed(q: AskQuestion, qi: Int, label: String, isLast: Bool) {
|
||
guard qi < selections.count else { return }
|
||
selections[qi] = [label]
|
||
if isLast { sendAnswers(q: q) } else { withAnimation { questionIndex = qi + 1 } }
|
||
}
|
||
|
||
// Multi-select Send/Next button
|
||
private func proceedFromQuestion(q: AskQuestion, qi: Int, isLast: Bool) {
|
||
if isLast { sendAnswers(q: q) } else { withAnimation { questionIndex = qi + 1 } }
|
||
}
|
||
|
||
// "Other…" confirm
|
||
private func commitOtherAndProceed(q: AskQuestion, qi: Int, isLast: Bool) {
|
||
let text = qi < otherTexts.count ? otherTexts[qi] : ""
|
||
guard !text.isEmpty else { return }
|
||
if qi < selections.count { selections[qi] = [text] }
|
||
if isLast {
|
||
sendAnswers(q: q)
|
||
} else {
|
||
if qi < showOther.count { showOther[qi] = false }
|
||
withAnimation { questionIndex = qi + 1 }
|
||
}
|
||
}
|
||
|
||
private func sendAnswers(q: AskQuestion) {
|
||
let answers = AskQuestion.buildAnswers(questions: q.questions, selections: selections)
|
||
HookServer.shared.sendQuestionAnswers(answers)
|
||
}
|
||
}
|
||
|
||
// MARK: - Error
|
||
|
||
struct ErrorView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
CardBackground(wash: .red)
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
AgentWho(task: state.focusTask, label: "n8n")
|
||
Text("Сценарий остановлен.")
|
||
.font(.system(size: 15, weight: .semibold))
|
||
Text("Узел Gmail не ответил за 30 с. Повтори или открой n8n.")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(Color(hex: "#FF8D97"))
|
||
HStack(spacing: 8) {
|
||
PrimaryButton("Повторить") { /* retry */ }
|
||
SecondaryButton("Открыть в n8n") { /* open */ }
|
||
}
|
||
}
|
||
.padding(.leading, 116)
|
||
.padding(.trailing, 16)
|
||
.padding(.vertical, 4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Finished
|
||
|
||
struct FinishedView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
CardBackground(wash: .green)
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
AgentWho(task: state.focusTask, label: "Claude Code закончил")
|
||
Text({
|
||
if let fl = state.focusTask?.finalLine { return fl }
|
||
if let s = state.focusTask?.steps.last(where: { !$0.isDiffStep }) { return s }
|
||
return "Сессия завершена"
|
||
}())
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
HStack(spacing: 8) {
|
||
#if !APPSTORE
|
||
PrimaryButton("Открыть терминал") {
|
||
let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2", "net.kovidgoyal.kitty", "com.mitchellh.ghostty"]
|
||
let activated = terminalBundleIds.compactMap { id in
|
||
NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id }
|
||
}.first.map { $0.activate(options: .activateIgnoringOtherApps) }
|
||
if activated == nil {
|
||
NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"))
|
||
}
|
||
NotificationCenter.default.post(name: .islandCollapse, object: nil)
|
||
}
|
||
#endif
|
||
SecondaryButton("ОК") {
|
||
NotificationCenter.default.post(name: .islandCollapse, object: nil)
|
||
}
|
||
}
|
||
}
|
||
.padding(.leading, 116)
|
||
.padding(.trailing, 16)
|
||
.padding(.vertical, 4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Diff Card
|
||
|
||
struct DiffCardView: View {
|
||
let diff: FileDiff
|
||
let onDismiss: () -> Void
|
||
|
||
private var allLines: [DiffLine] { diff.hunks.flatMap { $0.lines } }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 6) {
|
||
Button(action: onDismiss) {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 8, weight: .medium))
|
||
Text(diff.name)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.lineLimit(1)
|
||
}
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
}
|
||
.buttonStyle(.plain)
|
||
Spacer(minLength: 2)
|
||
if diff.added > 0 {
|
||
Text("+\(diff.added)")
|
||
.font(.system(size: 10, weight: .medium).monospaced())
|
||
.foregroundColor(Color(hex: "#22C55E"))
|
||
}
|
||
if diff.removed > 0 {
|
||
Text("−\(diff.removed)")
|
||
.font(.system(size: 10, weight: .medium).monospaced())
|
||
.foregroundColor(Color(hex: "#F4505E"))
|
||
}
|
||
Button(action: { openInEditor(diff) }) {
|
||
Image(systemName: "arrow.up.right")
|
||
.font(.system(size: 8, weight: .medium))
|
||
.foregroundColor(Color(hex: "#5F646D"))
|
||
.frame(width: 14, height: 14)
|
||
.background(Color.white.opacity(0.07))
|
||
.clipShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.bottom, 3)
|
||
|
||
// Content
|
||
if diff.tooLarge {
|
||
Text("Слишком большой diff")
|
||
.font(.system(size: 10.5))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
} else if allLines.isEmpty {
|
||
Text("Нет изменений")
|
||
.font(.system(size: 10.5))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
} else {
|
||
ScrollView(.vertical, showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
ForEach(Array(allLines.enumerated()), id: \.offset) { _, line in
|
||
DiffLineRowView(line: line)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 10)
|
||
.padding(.vertical, 4)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.onExitCommand { onDismiss() }
|
||
}
|
||
|
||
private func openInEditor(_ diff: FileDiff) {
|
||
let path = diff.path
|
||
#if !APPSTORE
|
||
let codePaths = ["/opt/homebrew/bin/code", "/usr/local/bin/code", "/usr/bin/code",
|
||
"\(NSHomeDirectory())/.nvm/current/bin/code"]
|
||
if let codePath = codePaths.first(where: { FileManager.default.fileExists(atPath: $0) }) {
|
||
let p = Process()
|
||
p.executableURL = URL(fileURLWithPath: codePath)
|
||
// Write: open at line 1; Edit/MultiEdit: open file without line number
|
||
p.arguments = diff.isNewFile ? ["-g", "\(path):1"] : [path]
|
||
try? p.run()
|
||
return
|
||
}
|
||
#endif
|
||
NSWorkspace.shared.open(URL(fileURLWithPath: path))
|
||
}
|
||
}
|
||
|
||
struct DiffLineRowView: View {
|
||
let line: DiffLine
|
||
|
||
private var bgColor: Color {
|
||
switch line.kind {
|
||
case .added: return Color(hex: "#22C55E").opacity(0.12)
|
||
case .removed: return Color(hex: "#F4505E").opacity(0.12)
|
||
case .context: return Color.clear
|
||
}
|
||
}
|
||
private var fgColor: Color {
|
||
switch line.kind {
|
||
case .added: return Color(hex: "#86EFAC")
|
||
case .removed: return Color(hex: "#FCA5A5")
|
||
case .context: return Color(hex: "#6B7079")
|
||
}
|
||
}
|
||
private var symbol: String {
|
||
switch line.kind {
|
||
case .added: return "+"
|
||
case .removed: return "−"
|
||
case .context: return " "
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 0) {
|
||
Text(symbol)
|
||
.font(.system(size: 10.5).monospaced())
|
||
.foregroundColor(line.kind == .added ? Color(hex: "#22C55E") :
|
||
line.kind == .removed ? Color(hex: "#F4505E") :
|
||
Color(hex: "#454850"))
|
||
.frame(width: 12, alignment: .leading)
|
||
Text(line.text)
|
||
.font(.system(size: 10.5).monospaced())
|
||
.foregroundColor(fgColor)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.background(bgColor)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
|
||
// MARK: - Confused
|
||
|
||
struct ConfusedView: View {
|
||
var body: some View {
|
||
ZStack {
|
||
CardBackground(wash: .pink)
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
Text("Слишком много тычков разом.").font(.system(size: 15, weight: .semibold))
|
||
Text("Дай секунду — вернусь к работе через три секунды.")
|
||
.font(.system(size: 13)).foregroundColor(Color(hex: "#9398A1"))
|
||
}
|
||
.padding(.leading, 128)
|
||
.padding(.trailing, 18)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Upload (drop zone)
|
||
|
||
struct UploadView: View {
|
||
@ObservedObject var state: AppState
|
||
@State private var dashPhase: CGFloat = 0
|
||
@State private var breathAngle: Double = 0
|
||
// Timer only runs while this is the active tab — killed on deactivation
|
||
@State private var animTimer: Timer? = nil
|
||
|
||
private var borderOpacity: Double {
|
||
let breathe = 0.11 + 0.04 * (sin(breathAngle) * 0.5 + 0.5)
|
||
return state.fileDragOver ? 0.65 : breathe
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.fill(Color(hex: "#0E0F11"))
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.stroke(
|
||
state.fileDragOver
|
||
? Color(hex: "#22C55E").opacity(borderOpacity)
|
||
: Color.white.opacity(borderOpacity),
|
||
style: StrokeStyle(lineWidth: 1.5, dash: [6, 5], dashPhase: dashPhase)
|
||
)
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.fill(RadialGradient(
|
||
colors: [Color(hex: "#22C55E").opacity(state.fileDragOver ? 0.13 : 0), Color.clear],
|
||
center: .bottom, startRadius: 0, endRadius: 200
|
||
))
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("Перетащи файлы сюда")
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundColor(state.fileDragOver ? Color(hex: "#34D399") : Color(hex: "#D5D7DB"))
|
||
HStack(spacing: 6) {
|
||
ForEach(["PDF", "Фото", "Код", "Документы"], id: \.self) { label in
|
||
Text(label)
|
||
.font(.system(size: 11))
|
||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||
.background(Color.white.opacity(0.07))
|
||
.foregroundColor(Color(hex: "#B9BDC4"))
|
||
.clipShape(Capsule())
|
||
}
|
||
}
|
||
}
|
||
.padding(.leading, 196)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.onChange(of: state.view) { _, newView in
|
||
newView == .upload ? startTimer() : stopTimer()
|
||
}
|
||
.onAppear {
|
||
if state.view == .upload { startTimer() }
|
||
}
|
||
.onDisappear { stopTimer() }
|
||
}
|
||
|
||
private func startTimer() {
|
||
guard animTimer == nil else { return }
|
||
// 20 fps — smooth enough for slow dash, 3× lighter than 60fps
|
||
animTimer = Timer.scheduledTimer(withTimeInterval: 1.0/20.0, repeats: true) { _ in
|
||
dashPhase += 1.0 // 20 pt/s march
|
||
breathAngle += 0.9 / 20.0 // advance sin phase at 0.9 rad/s
|
||
}
|
||
}
|
||
|
||
private func stopTimer() {
|
||
animTimer?.invalidate()
|
||
animTimer = nil
|
||
}
|
||
}
|
||
|
||
// MARK: - Uploading
|
||
|
||
struct UploadingView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
// Bar geometry in content coords (content has 10pt H padding each side).
|
||
// Island bar: left=36, right=562 (640-78), width=526.
|
||
// Content bar: left=26, width=526.
|
||
// barTop=58 → island y = content_start(42)+58 = 100; bot cy=103 (center = barTop+3).
|
||
private let barLeft: CGFloat = 26
|
||
private let barWidth: CGFloat = 526
|
||
private let barTop: CGFloat = 58
|
||
|
||
var body: some View {
|
||
// TimelineView fires at display refresh rate — progress derived from elapsed wall time,
|
||
// not from @Published uploadProgress (which only flips to 1.0 at completion).
|
||
TimelineView(.animation) { tl in
|
||
let elapsed: Double = {
|
||
guard let start = state.uploadStartTime else { return 0 }
|
||
return tl.date.timeIntervalSince(start)
|
||
}()
|
||
let t = min(1.0, max(0, elapsed / state.uploadDuration))
|
||
let progress = CGFloat(t * (2 - t)) // ease-out quad
|
||
let fillWidth = max(0, barWidth * progress)
|
||
let isDone = state.uploadProgress >= 0.999 // only true after handle() sets it
|
||
|
||
ZStack(alignment: .topLeading) {
|
||
// Background: dark base
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.fill(Color(hex: "#141518"))
|
||
|
||
// Permanent green radial wash — brighter at completion
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.fill(RadialGradient(
|
||
colors: [Color(hex: "#34D399").opacity(isDone ? 0.28 : 0.14), Color.clear],
|
||
center: UnitPoint(x: 0.5, y: 1.4),
|
||
startRadius: 0,
|
||
endRadius: 260
|
||
))
|
||
|
||
// Bar track
|
||
RoundedRectangle(cornerRadius: 3)
|
||
.fill(Color.white.opacity(0.09))
|
||
.frame(width: barWidth, height: 6)
|
||
.offset(x: barLeft, y: barTop)
|
||
|
||
// Bar fill
|
||
RoundedRectangle(cornerRadius: 3)
|
||
.fill(LinearGradient(
|
||
colors: [Color(hex: "#1FA87A"), Color(hex: "#34D399")],
|
||
startPoint: .leading, endPoint: .trailing
|
||
))
|
||
.frame(width: fillWidth, height: 6)
|
||
.offset(x: barLeft, y: barTop)
|
||
|
||
// Glow trail behind dot leading edge
|
||
if progress > 0.01 {
|
||
Ellipse()
|
||
.fill(Color(hex: "#6EE7B7").opacity(0.45))
|
||
.frame(width: 28, height: 12)
|
||
.blur(radius: 5)
|
||
.offset(x: barLeft + fillWidth - 14, y: barTop - 3)
|
||
}
|
||
|
||
// Text row — filename + % (above bar)
|
||
HStack(spacing: 0) {
|
||
if isDone {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(Color(hex: "#34D399"))
|
||
Text(" \(state.droppedFile?.name ?? "Файл")")
|
||
.font(.system(size: 12.5, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#34D399"))
|
||
.lineLimit(1).truncationMode(.middle)
|
||
} else {
|
||
Text("Загружаю \(state.droppedFile?.name ?? "файл")")
|
||
.font(.system(size: 12.5))
|
||
.foregroundColor(Color(hex: "#A9ADB5"))
|
||
.lineLimit(1).truncationMode(.middle)
|
||
Spacer(minLength: 8)
|
||
Text("\(Int(progress * 100)) %")
|
||
.font(.system(size: 12.5, weight: .medium))
|
||
.foregroundColor(Color(hex: "#A9ADB5"))
|
||
.monospacedDigit()
|
||
}
|
||
}
|
||
.frame(width: barWidth)
|
||
.offset(x: barLeft, y: barTop - 22)
|
||
|
||
// Subtle top border (same as CardBackground)
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.stroke(Color.white.opacity(0.035), lineWidth: 1)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Choose
|
||
|
||
struct ChooseView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .leading) {
|
||
CardBackground(wash: nil)
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
let fileName = state.droppedFile?.name ?? "Файл"
|
||
(Text(fileName).font(.system(size: 14, weight: .semibold)) + Text(" готов.").font(.system(size: 14, weight: .semibold)))
|
||
Text("Что с ним сделать?").font(.system(size: 12.5)).foregroundColor(Color(hex: "#9398A1"))
|
||
HStack(spacing: 8) {
|
||
PrimaryButton("Задать вопрос") { state.view = .prompt }
|
||
SecondaryButton("Отправить почтой") { state.view = .mail }
|
||
}
|
||
}
|
||
.padding(.leading, 98)
|
||
.padding(.trailing, 18)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Mail
|
||
|
||
struct MailView: View {
|
||
@ObservedObject var state: AppState
|
||
@State private var to: String = ""
|
||
@State private var subject: String = ""
|
||
@State private var bodyText: String = ""
|
||
@State private var statusMsg: String = ""
|
||
@State private var isSending = false
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .leading) {
|
||
CardBackground(wash: nil)
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 6) {
|
||
Text("Новое письмо").font(.system(size: 12, weight: .semibold))
|
||
if let name = state.droppedFile?.name {
|
||
Text("with").font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C"))
|
||
Text(name).font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C"))
|
||
.lineLimit(1).truncationMode(.middle)
|
||
}
|
||
}
|
||
|
||
MailField(label: "Кому", placeholder: "address@example.com", text: $to)
|
||
MailField(label: "Тема", placeholder: state.droppedFile?.name ?? "Тема", text: $subject)
|
||
|
||
// Body — TextEditor scrolls internally when text overflows
|
||
TextEditor(text: $bodyText)
|
||
.scrollContentBackground(.hidden)
|
||
.font(.system(size: 12.5))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
.frame(height: 44)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(Color.white.opacity(0.06))
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
|
||
if !statusMsg.isEmpty {
|
||
Text(statusMsg).font(.system(size: 11)).foregroundColor(Color(hex: "#FF8D97"))
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
PrimaryButton(isSending ? "Отправка…" : "Отправить") {
|
||
guard !isSending else { return }
|
||
sendMail()
|
||
}
|
||
SecondaryButton("Отмена") { state.view = .choose }
|
||
}
|
||
}
|
||
.padding(.leading, 92)
|
||
.padding(.trailing, 18)
|
||
.padding(.vertical, 8)
|
||
}
|
||
.onAppear { subject = state.droppedFile?.name ?? "" }
|
||
}
|
||
|
||
private func sendMail() {
|
||
guard !to.isEmpty else { statusMsg = "Не указан получатель."; return }
|
||
let subj = subject.isEmpty ? (state.droppedFile?.name ?? "Файл") : subject
|
||
|
||
// Prefer Resend if API key + sender address are configured
|
||
let apiKey = KeychainStore.shared.get("resend-api-key")
|
||
let fromAddr = KeychainStore.shared.get("resend-from")
|
||
|
||
if let apiKey, let fromAddr {
|
||
isSending = true
|
||
statusMsg = ""
|
||
let recipient = to
|
||
let msgBody = bodyText
|
||
let fileURL = state.droppedFile?.url
|
||
Task {
|
||
let ok = await sendViaResend(apiKey: apiKey, from: fromAddr,
|
||
to: recipient, subject: subj,
|
||
body: msgBody, fileURL: fileURL)
|
||
await MainActor.run {
|
||
isSending = false
|
||
if ok { onSuccess(recipient: recipient) }
|
||
else { statusMsg = "Ошибка Resend — проверь API-ключ и отправителя." }
|
||
}
|
||
}
|
||
} else if apiKey != nil && fromAddr == nil {
|
||
// API key set but no sender — guide user instead of silent fallback
|
||
statusMsg = "Укажи адрес отправителя в настройках."
|
||
} else {
|
||
// No Resend — fallback to Mail
|
||
sendViaAppleMail(to: to, subject: subj)
|
||
}
|
||
}
|
||
|
||
private func sendViaResend(apiKey: String, from: String, to: String,
|
||
subject: String, body: String, fileURL: URL?) async -> Bool {
|
||
guard let url = URL(string: "https://api.resend.com/emails") else { return false }
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = "POST"
|
||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
|
||
var payload: [String: Any] = [
|
||
"from": from,
|
||
"to": [to],
|
||
"subject": subject,
|
||
"text": body.isEmpty ? " " : body
|
||
]
|
||
if let fileURL, let data = try? Data(contentsOf: fileURL) {
|
||
payload["attachments"] = [[
|
||
"filename": fileURL.lastPathComponent,
|
||
"content": data.base64EncodedString()
|
||
]]
|
||
}
|
||
guard let httpBody = try? JSONSerialization.data(withJSONObject: payload) else { return false }
|
||
request.httpBody = httpBody
|
||
guard let (_, response) = try? await URLSession.shared.data(for: request) else { return false }
|
||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||
if code == 200 || code == 201 { return true }
|
||
print("[Resend] HTTP \(code)")
|
||
return false
|
||
}
|
||
|
||
private func sendViaAppleMail(to: String, subject: String) {
|
||
#if APPSTORE
|
||
// App Store: no AppleScript — use NSSharingService to compose (user sends manually)
|
||
guard let service = NSSharingService(named: .composeEmail) else {
|
||
statusMsg = "Mail недоступен."
|
||
return
|
||
}
|
||
var items: [Any] = [bodyText.isEmpty ? " " : bodyText]
|
||
if let url = state.droppedFile?.url,
|
||
FileManager.default.fileExists(atPath: url.path) {
|
||
items.append(url)
|
||
}
|
||
service.recipients = [to]
|
||
service.subject = subject
|
||
service.perform(withItems: items)
|
||
onSuccess(recipient: to)
|
||
#else
|
||
func asEscape(_ s: String) -> String {
|
||
s.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||
}
|
||
|
||
let bodyLines = bodyText.isEmpty ? [""] : bodyText.components(separatedBy: "\n")
|
||
let bodyExpr = bodyLines.map { "\"\(asEscape($0))\"" }.joined(separator: " & linefeed & ")
|
||
+ " & return & return"
|
||
|
||
let attachBlock: String
|
||
if let url = state.droppedFile?.url,
|
||
FileManager.default.fileExists(atPath: url.path) {
|
||
let escapedPath = asEscape(url.path)
|
||
attachBlock = "make new attachment with properties {file name:(POSIX file \"\(escapedPath)\")} at after the last paragraph of content"
|
||
} else {
|
||
attachBlock = ""
|
||
}
|
||
|
||
let script = """
|
||
tell application "Mail"
|
||
set m to make new outgoing message with properties {subject:"\(asEscape(subject))", visible:false}
|
||
set content of m to \(bodyExpr)
|
||
tell m
|
||
make new to recipient at end of to recipients with properties {address:"\(asEscape(to))"}
|
||
\(attachBlock)
|
||
end tell
|
||
delay 1
|
||
send m
|
||
end tell
|
||
"""
|
||
var err: NSDictionary?
|
||
NSAppleScript(source: script)?.executeAndReturnError(&err)
|
||
if err == nil { onSuccess(recipient: to) }
|
||
else { statusMsg = "Ошибка Mail: \(err?["NSAppleScriptErrorMessage"] as? String ?? "неизвестно")" }
|
||
#endif
|
||
}
|
||
|
||
private func onSuccess(recipient: String) {
|
||
SoundEngine.shared.play("send")
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.wink)
|
||
state.noteMessage = "Письмо отправлено: \(recipient)."
|
||
state.view = .note
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||
NotificationCenter.default.post(name: .islandCollapse, object: nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Prompt (chat)
|
||
|
||
struct PromptView: View {
|
||
@ObservedObject var state: AppState
|
||
@State private var text: String = ""
|
||
@FocusState private var focused: Bool
|
||
@State private var showModelPicker = false
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .leading) {
|
||
CardBackground(wash: .indigo)
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
if let ctx = state.promptContext {
|
||
ContextChip(context: ctx).padding(.top, 4)
|
||
}
|
||
|
||
if !state.chatHistory.isEmpty {
|
||
ScrollViewReader { proxy in
|
||
ScrollView(.vertical, showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
ForEach(state.chatHistory) { msg in
|
||
ChatBubble(message: msg).id(msg.id)
|
||
}
|
||
if state.stateOverride != nil {
|
||
HStack { TypingDotsView(); Spacer(minLength: 32) }
|
||
.id("typing")
|
||
}
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
.onChange(of: state.chatHistory) { _, _ in
|
||
if let last = state.chatHistory.last(where: { !$0.content.isEmpty }) {
|
||
proxy.scrollTo(last.id, anchor: .bottom)
|
||
}
|
||
}
|
||
.onChange(of: state.stateOverride) { _, v in
|
||
if v != nil {
|
||
withAnimation { proxy.scrollTo("typing", anchor: .bottom) }
|
||
} else if let last = state.chatHistory.last(where: { !$0.content.isEmpty }) {
|
||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||
}
|
||
}
|
||
.onAppear {
|
||
if let last = state.chatHistory.last {
|
||
proxy.scrollTo(last.id, anchor: .bottom)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
} else {
|
||
Spacer()
|
||
}
|
||
|
||
HStack(spacing: 0) {
|
||
Spacer()
|
||
Button {
|
||
withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
|
||
showModelPicker.toggle()
|
||
}
|
||
} label: {
|
||
HStack(spacing: 5) {
|
||
Circle()
|
||
.fill(Color(hex: state.chatProvider.accentHex))
|
||
.frame(width: 6, height: 6)
|
||
Text(state.activeChatModel)
|
||
.font(.system(size: 10.5, weight: .medium))
|
||
.foregroundColor(Color(hex: "#7B8089"))
|
||
Image(systemName: "chevron.up.chevron.down")
|
||
.font(.system(size: 8))
|
||
.foregroundColor(Color(hex: "#5C6370"))
|
||
}
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.background(Color.white.opacity(0.06))
|
||
.clipShape(Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.popover(isPresented: $showModelPicker, arrowEdge: .bottom) {
|
||
ModelPickerView(state: state, isPresented: $showModelPicker)
|
||
.frame(width: 300)
|
||
}
|
||
}
|
||
.padding(.horizontal, 10)
|
||
|
||
HStack(spacing: 8) {
|
||
TextField(state.chatHistory.isEmpty ? "Спроси что угодно…" : "Продолжить…", text: $text)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 13))
|
||
.focused($focused)
|
||
.onSubmit { sendMessage() }
|
||
|
||
Button(action: sendMessage) {
|
||
Image(systemName: "arrow.up")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#0B0C0E"))
|
||
}
|
||
.buttonStyle(SendButtonStyle())
|
||
.disabled(text.isEmpty)
|
||
}
|
||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||
.background(Color.white.opacity(0.07))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
.simultaneousGesture(TapGesture().onEnded { focused = true })
|
||
}
|
||
.padding(.leading, 84)
|
||
.padding(.trailing, 16)
|
||
.padding(.top, 12)
|
||
.padding(.bottom, 14)
|
||
}
|
||
.padding(.bottom, 10)
|
||
.onAppear { focused = true }
|
||
.onChange(of: state.view) { _, view in
|
||
if view == .prompt {
|
||
state.fetchModelsIfNeeded(for: state.chatProvider)
|
||
}
|
||
}
|
||
.onChange(of: state.chatProvider) { _, provider in
|
||
if state.view == .prompt {
|
||
state.fetchModelsIfNeeded(for: provider)
|
||
}
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .islandSendMessage)) { _ in
|
||
guard state.view == .prompt else { return }
|
||
sendMessage()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .islandNewConversation)) { _ in
|
||
guard state.view == .prompt else { return }
|
||
text = ""
|
||
state.chatHistory = []
|
||
ClaudeService.shared.clearConversation()
|
||
ClaudeCodeCLI.shared.newConversation()
|
||
focused = true
|
||
}
|
||
}
|
||
|
||
private func sendMessage() {
|
||
let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !query.isEmpty else { return }
|
||
text = ""
|
||
focused = false
|
||
state.chatHistory.append(ChatMessage(role: .user, content: query))
|
||
state.stateOverride = .thinking
|
||
Task {
|
||
await ClaudeService.shared.chat(query: query, context: state.promptContext, state: state)
|
||
await MainActor.run { focused = true }
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: - Model / provider picker
|
||
|
||
/// Wrapping horizontal flow layout — used by ModelPickerView and QuestionView.
|
||
struct ChipFlowLayout: Layout {
|
||
var spacing: CGFloat = 6
|
||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
||
let r = rows(maxW: proposal.replacingUnspecifiedDimensions().width, subviews: subviews)
|
||
return r.size
|
||
}
|
||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
|
||
let r = rows(maxW: bounds.width, subviews: subviews)
|
||
for (idx, pt) in r.placements.enumerated() {
|
||
subviews[idx].place(at: CGPoint(x: bounds.minX + pt.x, y: bounds.minY + pt.y), proposal: .unspecified)
|
||
}
|
||
}
|
||
private func rows(maxW: CGFloat, subviews: Subviews) -> (size: CGSize, placements: [(x: CGFloat, y: CGFloat)]) {
|
||
var x: CGFloat = 0, y: CGFloat = 0, lineH: CGFloat = 0, maxX: CGFloat = 0
|
||
var pts: [(x: CGFloat, y: CGFloat)] = []
|
||
for sv in subviews {
|
||
let sz = sv.sizeThatFits(.unspecified)
|
||
if x + sz.width > maxW, x > 0 { x = 0; y += lineH + spacing; lineH = 0 }
|
||
pts.append((x: x, y: y))
|
||
x += sz.width + spacing
|
||
lineH = max(lineH, sz.height)
|
||
maxX = max(maxX, x - spacing)
|
||
}
|
||
return (CGSize(width: max(maxX, 0), height: y + lineH), pts)
|
||
}
|
||
}
|
||
|
||
struct ModelPickerView: View {
|
||
@ObservedObject var state: AppState
|
||
@Binding var isPresented: Bool
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
// Provider chips — wrap; hide local providers when not connected and not already active
|
||
let visibleProviders = ChatProvider.allCases.filter { p in
|
||
if p == .ollama { return !AppState.shared.ollamaServerURL.isEmpty || state.chatProvider == .ollama }
|
||
if p == .lmstudio { return !AppState.shared.lmstudioServerURL.isEmpty || state.chatProvider == .lmstudio }
|
||
return true
|
||
}
|
||
ChipFlowLayout(spacing: 6) {
|
||
ForEach(visibleProviders, id: \.self) { provider in
|
||
Button {
|
||
guard provider != state.chatProvider else { return }
|
||
withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) {
|
||
state.chatProvider = provider
|
||
}
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised)
|
||
SoundEngine.shared.play("pop")
|
||
} label: {
|
||
HStack(spacing: 5) {
|
||
Circle()
|
||
.fill(Color(hex: provider.accentHex))
|
||
.frame(width: 7, height: 7)
|
||
Text(provider.displayName)
|
||
.font(.system(size: 12, weight: state.chatProvider == provider ? .semibold : .regular))
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(state.chatProvider == provider
|
||
? Color(hex: provider.accentHex).opacity(0.18)
|
||
: Color.white.opacity(0.06))
|
||
.overlay(Capsule().stroke(
|
||
state.chatProvider == provider
|
||
? Color(hex: provider.accentHex).opacity(0.5)
|
||
: Color.white.opacity(0.1),
|
||
lineWidth: 1))
|
||
.clipShape(Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
Divider().opacity(0.2)
|
||
|
||
// Model list for current provider — fetched dynamically
|
||
modelListView
|
||
.frame(height: 260, alignment: .top)
|
||
}
|
||
.padding(14)
|
||
.background(Color(hex: "#16171B"))
|
||
.onAppear {
|
||
// Force-refresh local providers every time the picker opens
|
||
if state.chatProvider.isLocal {
|
||
state.fetchedProviderModels[state.chatProvider] = nil
|
||
state.providerModelFetchError[state.chatProvider] = nil
|
||
}
|
||
state.fetchModelsIfNeeded(for: state.chatProvider)
|
||
}
|
||
.onChange(of: state.chatProvider) { _, provider in
|
||
if provider.isLocal {
|
||
state.fetchedProviderModels[provider] = nil
|
||
state.providerModelFetchError[provider] = nil
|
||
}
|
||
state.fetchModelsIfNeeded(for: provider)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var modelListView: some View {
|
||
if state.loadingProviderModels.contains(state.chatProvider) {
|
||
HStack(spacing: 8) {
|
||
ProgressView().scaleEffect(0.7)
|
||
Text("Загрузка моделей…")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(Color(hex: "#8A8F98"))
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.vertical, 4)
|
||
} else if let error = state.providerModelFetchError[state.chatProvider] {
|
||
Text(error)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8A8F98"))
|
||
.padding(.vertical, 4)
|
||
} else if let models = state.fetchedProviderModels[state.chatProvider] {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
ForEach(models, id: \.id) { model in
|
||
Button {
|
||
switch state.chatProvider {
|
||
case .claudeCode: state.claudeCodeModel = model.id
|
||
case .anthropic: state.claudeModel = model.id
|
||
case .google: state.googleChatModel = model.id
|
||
case .openai: state.openAIChatModel = model.id
|
||
case .ollama: state.ollamaChatModel = model.id
|
||
case .lmstudio: state.lmstudioChatModel = model.id
|
||
}
|
||
isPresented = false
|
||
SoundEngine.shared.play("blip")
|
||
} label: {
|
||
HStack {
|
||
Text(model.label)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(state.activeChatModel == model.id
|
||
? Color(hex: state.chatProvider.accentHex)
|
||
: Color(hex: "#C8CDD4"))
|
||
Spacer()
|
||
if state.activeChatModel == model.id {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(Color(hex: state.chatProvider.accentHex))
|
||
}
|
||
}
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 6)
|
||
.background(state.activeChatModel == model.id
|
||
? Color(hex: state.chatProvider.accentHex).opacity(0.1)
|
||
: Color.clear)
|
||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ChatBubble: View {
|
||
let message: ChatMessage
|
||
|
||
var body: some View {
|
||
if !message.content.isEmpty {
|
||
HStack(alignment: .top) {
|
||
if message.role == .user {
|
||
Spacer(minLength: 32)
|
||
Text(message.content)
|
||
.font(.system(size: 12.5))
|
||
.foregroundColor(Color(hex: "#F1F2F4"))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.textSelection(.enabled)
|
||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||
.background(Color.white.opacity(0.13))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
} else {
|
||
ChatMarkdownView(markdown: message.content)
|
||
Spacer(minLength: 8)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct TypingDotsView: View {
|
||
@State private var phase = false
|
||
|
||
var body: some View {
|
||
HStack(spacing: 4) {
|
||
ForEach(0..<3, id: \.self) { i in
|
||
Circle()
|
||
.fill(Color(hex: "#6B7079"))
|
||
.frame(width: 5, height: 5)
|
||
.scaleEffect(phase ? 1.2 : 0.6)
|
||
.animation(
|
||
.easeInOut(duration: 0.45).repeatForever().delay(Double(i) * 0.14),
|
||
value: phase
|
||
)
|
||
}
|
||
}
|
||
.padding(.horizontal, 2).padding(.vertical, 4)
|
||
.onAppear { phase = true }
|
||
}
|
||
}
|
||
|
||
// MARK: - Searching
|
||
|
||
struct SearchingView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
var label: String {
|
||
switch state.promptContext {
|
||
case .window(_, let title, _): return "Claude читает \(title)…"
|
||
case .file(let name, _): return "Claude читает \(name)…"
|
||
case nil: return "Claude ищет…"
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .leading) {
|
||
CardBackground(wash: .indigo)
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
if let ctx = state.promptContext {
|
||
ContextChip(context: ctx)
|
||
}
|
||
ShimmeringText(label)
|
||
.font(.system(size: 13.5))
|
||
}
|
||
.padding(.leading, 84)
|
||
.padding(.trailing, 16)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Result
|
||
|
||
struct ResultView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .leading) {
|
||
CardBackground(wash: .green)
|
||
|
||
if let result = state.searchResult {
|
||
VStack(alignment: .leading, spacing: 7) {
|
||
Text(result.title)
|
||
.font(.system(size: 15, weight: .semibold))
|
||
|
||
VStack(spacing: 4) {
|
||
ForEach(result.items.prefix(3), id: \.label) { item in
|
||
HStack {
|
||
Text(item.label).font(.system(size: 12.5, weight: .semibold))
|
||
Spacer()
|
||
Text(item.detail).font(.system(size: 12.5)).foregroundColor(Color(hex: "#9398A1"))
|
||
}
|
||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||
.background(Color.white.opacity(0.05))
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
}
|
||
|
||
if let note = result.note {
|
||
Text(note).font(.system(size: 11)).foregroundColor(Color(hex: "#6E737C"))
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
// The URL comes from the model, which may have read attacker-controlled
|
||
// files or pages: only plain web links may leave the app.
|
||
let openURL = safeWebURL(result.items.first?.url)
|
||
PrimaryButton("Открыть") {
|
||
if let openURL { NSWorkspace.shared.open(openURL) }
|
||
}
|
||
.disabled(openURL == nil)
|
||
.help(openURL?.absoluteString ?? "")
|
||
SecondaryButton("Копировать") {
|
||
let text = result.items.map { "\($0.label): \($0.detail)" }.joined(separator: "\n")
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(text, forType: .string)
|
||
}
|
||
SecondaryButton("Закрыть") { state.view = state.tasks.isEmpty ? .empty : .overview }
|
||
}
|
||
}
|
||
.padding(.leading, 84)
|
||
.padding(.trailing, 16)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Note (short message, auto-closes)
|
||
|
||
struct NoteView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .leading) {
|
||
CardBackground(wash: nil)
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(state.noteMessage ?? "")
|
||
.font(.system(size: 15, weight: .semibold))
|
||
}
|
||
.padding(.leading, 98)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Integration card (overview left card when an integration pill is focused)
|
||
|
||
struct IntegrationCardView: View {
|
||
let task: AgentTask
|
||
@Binding var showingDetail: Bool
|
||
var onDiffTap: ((Int) -> Void)? = nil
|
||
@ObservedObject private var appState = AppState.shared
|
||
@State private var githubDetailSection: GitHubDetailSection = .myPRs
|
||
|
||
private var isConfigured: Bool {
|
||
switch task.id {
|
||
case "integration_claude":
|
||
#if APPSTORE
|
||
// Sandboxed: can't read ~/.claude directly — check install flag set by HookServer
|
||
return UserDefaults.standard.bool(forKey: "coucouHooksInstalled")
|
||
#else
|
||
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude/settings.json")
|
||
guard let data = try? Data(contentsOf: url),
|
||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
let hooks = json["hooks"] as? [String: Any],
|
||
let ss = hooks["SessionStart"] as? [[String: Any]] else { return false }
|
||
return ss.contains { ($0["hooks"] as? [[String: Any]])?.contains {
|
||
let cmd = $0["command"] as? String
|
||
return cmd?.contains("NotchBuddy") == true || cmd?.contains("coucou") == true
|
||
} ?? false }
|
||
#endif
|
||
case "agent_gemini":
|
||
#if !APPSTORE
|
||
return HookServer.geminiHooksInstalled()
|
||
#else
|
||
return false
|
||
#endif
|
||
case "agent_antigravity":
|
||
#if !APPSTORE
|
||
return HookServer.agyHooksInstalled()
|
||
#else
|
||
return false
|
||
#endif
|
||
case "agent_cursor", "agent_codex":
|
||
return false // coming soon
|
||
case "integration_music":
|
||
#if !APPSTORE
|
||
return true // Apple Music is always installed on macOS
|
||
#else
|
||
return false
|
||
#endif
|
||
case "ai_anthropic": return KeychainStore.shared.get("anthropic-api-key") != nil
|
||
case "ai_google": return KeychainStore.shared.get("google-api-key") != nil
|
||
case "ai_openai": return KeychainStore.shared.get("openai-api-key") != nil
|
||
case "ai_ollama": return !AppState.shared.ollamaServerURL.isEmpty
|
||
case "ai_lmstudio": return !AppState.shared.lmstudioServerURL.isEmpty
|
||
case "integration_resend": return KeychainStore.shared.get("resend-api-key") != nil
|
||
case "integration_n8n": return KeychainStore.shared.get("n8n-api-key") != nil
|
||
case "integration_vercel": return KeychainStore.shared.get("vercel-token") != nil
|
||
case "integration_github": return KeychainStore.shared.get("github-token") != nil
|
||
case "integration_stripe": return KeychainStore.shared.get("stripe-api-key") != nil
|
||
case "integration_notion": return KeychainStore.shared.get("notion-api-key") != nil
|
||
case "integration_calcom": return KeychainStore.shared.get("calcom-api-key") != nil
|
||
default: return false
|
||
}
|
||
}
|
||
|
||
private var openURL: URL? {
|
||
switch task.id {
|
||
case "integration_claude": return nil // uses terminal button below
|
||
case "integration_resend": return URL(string: "https://resend.com/emails")
|
||
case "integration_n8n":
|
||
if let s = KeychainStore.shared.get("n8n-url") { return URL(string: s) }
|
||
return nil
|
||
case "integration_vercel": return URL(string: "https://vercel.com/dashboard")
|
||
case "integration_github": return URL(string: "https://github.com")
|
||
case "integration_stripe": return URL(string: "https://dashboard.stripe.com/payments")
|
||
case "integration_notion": return URL(string: "https://notion.so")
|
||
case "integration_calcom": return URL(string: "https://app.cal.com/bookings")
|
||
default: return nil
|
||
}
|
||
}
|
||
|
||
// Workspace/agent pill with active session: show ticker layout
|
||
private var agentSessionActive: Bool {
|
||
guard let def = PillCatalog.definition(for: task.id) else { return false }
|
||
guard def.category == .workspace || def.category == .agent else { return false }
|
||
return task.state != .idle || !task.steps.isEmpty
|
||
}
|
||
|
||
// n8n with a finished execution: show result row instead of "Open n8n" button
|
||
private var n8nHasActivity: Bool {
|
||
task.id == "integration_n8n" && !task.steps.isEmpty &&
|
||
(task.state == .finished || task.state == .error)
|
||
}
|
||
|
||
// Vercel with recent deployments
|
||
private var vercelHasActivity: Bool {
|
||
task.id == "integration_vercel" && !appState.vercelDeployments.isEmpty
|
||
}
|
||
|
||
// Resend with recent emails
|
||
private var resendHasData: Bool {
|
||
task.id == "integration_resend" && !appState.resendEmails.isEmpty
|
||
}
|
||
|
||
// GitHub with stats or pulse loaded
|
||
private var githubHasData: Bool {
|
||
task.id == "integration_github" && (appState.githubPulse != nil || appState.githubStats != nil)
|
||
}
|
||
|
||
// GitHub with pulse loaded (richer card)
|
||
private var githubHasPulse: Bool {
|
||
task.id == "integration_github" && appState.githubPulse != nil
|
||
}
|
||
|
||
// Stripe: show card as soon as first poll completes (balance OR payments)
|
||
private var stripeHasData: Bool {
|
||
task.id == "integration_stripe" && appState.stripeLoaded
|
||
}
|
||
|
||
// Cal.com: show calendar as soon as first poll completes
|
||
private var calcomHasData: Bool {
|
||
task.id == "integration_calcom" && appState.calcomLoaded
|
||
}
|
||
|
||
// Notion: show pages as soon as first poll completes
|
||
private var notionHasData: Bool {
|
||
task.id == "integration_notion" && appState.notionLoaded
|
||
}
|
||
|
||
// Apple Music: show card when a track is loaded (playing or paused) or automation is denied
|
||
private var musicIsActive: Bool {
|
||
#if !APPSTORE
|
||
guard task.id == "integration_music" else { return false }
|
||
if appState.musicAutomationDenied { return true }
|
||
return MusicController.shared.trackTitle != nil
|
||
#else
|
||
return false
|
||
#endif
|
||
}
|
||
|
||
private var statusDot: Color {
|
||
#if !APPSTORE
|
||
if task.id == "integration_music" {
|
||
if appState.musicAutomationDenied { return Color(hex: "#F4505E") }
|
||
return appState.musicPlaying ? Color(hex: "#FA2D48") : Color(hex: "#22C55E")
|
||
}
|
||
#endif
|
||
if PillCatalog.definition(for: task.id)?.comingSoon == true { return Color(hex: "#6B7079") }
|
||
let svcErr = task.id == "integration_stripe" ? appState.stripeError
|
||
: task.id == "integration_calcom" ? appState.calcomError
|
||
: nil
|
||
if svcErr != nil { return Color(hex: "#F4505E") }
|
||
return isConfigured ? Color(hex: "#22C55E") : Color(hex: "#F4505E")
|
||
}
|
||
|
||
private var statusLabel: String {
|
||
#if !APPSTORE
|
||
if task.id == "integration_music" {
|
||
if appState.musicAutomationDenied { return "Автоматизация запрещена" }
|
||
if appState.musicPlaying { return "Играет · \(MusicController.shared.trackTitle ?? "Неизвестно")" }
|
||
return "Не играет"
|
||
}
|
||
#endif
|
||
if PillCatalog.definition(for: task.id)?.comingSoon == true { return "Скоро" }
|
||
let svcErr = task.id == "integration_stripe" ? appState.stripeError
|
||
: task.id == "integration_calcom" ? appState.calcomError
|
||
: nil
|
||
if let err = svcErr { return err }
|
||
let isHooks = task.id == "agent_gemini" || task.id == "agent_antigravity"
|
||
let isAI = ChatProvider(pillID: task.id) != nil
|
||
if isConfigured {
|
||
if isHooks { return "Хуки установлены" }
|
||
if isAI {
|
||
let provider = ChatProvider(pillID: task.id)!
|
||
if provider.isLocal {
|
||
let model = provider == .ollama ? appState.ollamaChatModel : appState.lmstudioChatModel
|
||
return "Подключено · \(model)"
|
||
}
|
||
let model: String
|
||
switch task.id {
|
||
case "ai_anthropic": model = appState.claudeModel
|
||
case "ai_google": model = appState.googleChatModel
|
||
case "ai_openai": model = appState.openAIChatModel
|
||
default: model = ""
|
||
}
|
||
return "Ключ задан · \(model)"
|
||
}
|
||
return "Подключено · загрузка…"
|
||
} else {
|
||
if isHooks { return "Хуки не установлены" }
|
||
if isAI {
|
||
let provider = ChatProvider(pillID: task.id)!
|
||
return provider.isLocal ? "Не подключено" : "Ключ не задан"
|
||
}
|
||
return "Ключ не задан"
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
if showingDetail && n8nHasActivity {
|
||
N8nDetailView(task: task) {
|
||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = false }
|
||
}
|
||
.transition(.opacity)
|
||
} else if showingDetail && vercelHasActivity {
|
||
VercelDetailView(deployment: appState.vercelDeployments[0]) {
|
||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = false }
|
||
}
|
||
.transition(.opacity)
|
||
} else if vercelHasActivity {
|
||
VercelDeploymentListView(deployments: appState.vercelDeployments, onOpenDetail: {
|
||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true }
|
||
})
|
||
.transition(.opacity)
|
||
} else if resendHasData {
|
||
ResendCardView(emails: appState.resendEmails, total: appState.resendTotal)
|
||
.transition(.opacity)
|
||
} else if showingDetail && githubHasPulse {
|
||
GitHubDetailView(
|
||
section: githubDetailSection,
|
||
pulse: appState.githubPulse!,
|
||
activity: appState.githubActivity,
|
||
stats: appState.githubStats,
|
||
onBack: {
|
||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = false }
|
||
}
|
||
)
|
||
.transition(.opacity)
|
||
} else if githubHasPulse {
|
||
GitHubPulseCardView(
|
||
pulse: appState.githubPulse!,
|
||
stats: appState.githubStats,
|
||
activity: appState.githubActivity,
|
||
onTapSection: { section in
|
||
githubDetailSection = section
|
||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true }
|
||
}
|
||
)
|
||
.transition(.opacity)
|
||
} else if githubHasData {
|
||
GitHubStatsCardView(stats: appState.githubStats!)
|
||
.transition(.opacity)
|
||
} else if stripeHasData {
|
||
StripeCardView()
|
||
.transition(.opacity)
|
||
} else if calcomHasData {
|
||
CalcomCardView()
|
||
.transition(.opacity)
|
||
} else if notionHasData {
|
||
NotionCardView()
|
||
.transition(.opacity)
|
||
} else if musicIsActive {
|
||
#if !APPSTORE
|
||
MusicCardView()
|
||
.transition(.opacity)
|
||
#endif
|
||
} else if agentSessionActive {
|
||
// Active session view — reuse overview layout
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(hex: task.color))
|
||
.frame(width: 7, height: 7)
|
||
Text(task.name)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
.layoutPriority(1)
|
||
Text(PillCatalog.definition(for: task.id)?.sessionSubtitle ?? "Агент")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
Spacer(minLength: 2)
|
||
if task.steps.count > 1 {
|
||
Text("\(min(task.stepIndex + 1, task.steps.count))/\(task.steps.count)")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
.fixedSize()
|
||
}
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 36)
|
||
|
||
TickerView(task: task, onDiffTap: onDiffTap)
|
||
.frame(height: 44)
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
} else {
|
||
// Idle / not connected view — slides in from left when returning from detail
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(hex: task.color))
|
||
.frame(width: 7, height: 7)
|
||
Text(PillCatalog.definition(for: task.id)?.name ?? task.name)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
Text(PillCatalog.definition(for: task.id)?.subtitle ?? "Интеграция")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
Spacer(minLength: 2)
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 36)
|
||
|
||
HStack(spacing: 5) {
|
||
Circle().fill(statusDot).frame(width: 5, height: 5)
|
||
Text(statusLabel)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
.padding(.leading, 108)
|
||
.padding(.top, 2)
|
||
|
||
HStack(spacing: 8) {
|
||
if task.id == "integration_claude" {
|
||
Button("Открыть Visual Studio Code") { openVSCode() }
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: task.color).opacity(0.7))
|
||
.buttonStyle(.plain)
|
||
} else if task.id == "agent_cursor" {
|
||
#if !APPSTORE
|
||
if let url = NSWorkspace.shared.urlForApplication(
|
||
withBundleIdentifier: "com.todesktop.230313mzl4w4u92") {
|
||
Button("Открыть Cursor") {
|
||
NSWorkspace.shared.openApplication(at: url, configuration: .init(),
|
||
completionHandler: nil)
|
||
}
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: task.color).opacity(0.85))
|
||
.buttonStyle(.plain)
|
||
}
|
||
#endif
|
||
} else if task.id == "agent_codex" {
|
||
#if !APPSTORE
|
||
if let url = NSWorkspace.shared.urlForApplication(
|
||
withBundleIdentifier: "com.openai.codex") {
|
||
Button("Открыть Codex") {
|
||
NSWorkspace.shared.openApplication(at: url, configuration: .init(),
|
||
completionHandler: nil)
|
||
}
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: task.color).opacity(0.85))
|
||
.buttonStyle(.plain)
|
||
}
|
||
#endif
|
||
} else if let provider = ChatProvider(pillID: task.id) {
|
||
if isConfigured {
|
||
Button("Чат с \(task.name)") {
|
||
switchChatProvider(provider)
|
||
}
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: task.color).opacity(0.85))
|
||
.buttonStyle(.plain)
|
||
}
|
||
} else if task.id == "integration_music" {
|
||
#if !APPSTORE
|
||
Button("Открыть Музыку") { MusicController.shared.openMusic() }
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: task.color).opacity(0.85))
|
||
.buttonStyle(.plain)
|
||
if appState.musicAutomationDenied {
|
||
Button("Открыть настройки…") { MusicController.shared.openAutomationSettings() }
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.buttonStyle(.plain)
|
||
}
|
||
#endif
|
||
} else if n8nHasActivity {
|
||
// Clickable pill — tap to open execution detail
|
||
let success = task.state == .finished
|
||
let accent = success ? Color(hex: "#22C55E") : Color(hex: "#F4505E")
|
||
Button(action: {
|
||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true }
|
||
}) {
|
||
HStack(spacing: 5) {
|
||
Circle().fill(accent).frame(width: 5, height: 5)
|
||
Text(task.steps.first ?? "Сценарий")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
Image(systemName: "ellipsis")
|
||
.font(.system(size: 8, weight: .medium))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||
.background(accent.opacity(0.1))
|
||
.clipShape(Capsule())
|
||
.overlay(Capsule().stroke(accent.opacity(0.22), lineWidth: 1))
|
||
}
|
||
.buttonStyle(.plain)
|
||
} else if let url = openURL {
|
||
Button("Открыть \(task.name)") { NSWorkspace.shared.open(url) }
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: task.color).opacity(0.85))
|
||
.buttonStyle(.plain)
|
||
}
|
||
if task.id == "integration_stripe" && isConfigured {
|
||
Button("Обновить") { Task { @MainActor in StripePoller.shared.pollNow() } }
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: "#0570DE").opacity(0.85))
|
||
.buttonStyle(.plain)
|
||
}
|
||
if task.id == "integration_calcom" && isConfigured {
|
||
Button("Обновить") { Task { @MainActor in CalcomPoller.shared.pollNow() } }
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: "#C9956A").opacity(0.85))
|
||
.buttonStyle(.plain)
|
||
}
|
||
// Settings button: shown when not configured, except cursor/codex and music
|
||
if !isConfigured
|
||
&& task.id != "agent_cursor"
|
||
&& task.id != "agent_codex"
|
||
&& task.id != "integration_music" {
|
||
Button("Настройки…") {
|
||
let section: String
|
||
switch PillCatalog.definition(for: task.id)?.category {
|
||
case .workspace, .agent: section = "agents"
|
||
case .ai: section = "chat"
|
||
default: section = "integrations"
|
||
}
|
||
NotificationCenter.default.post(name: .openFullSettings, object: section)
|
||
}
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.leading, 108)
|
||
.padding(.top, 2)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
|
||
private func openVSCode() {
|
||
let ids = ["com.microsoft.VSCode", "com.microsoft.VSCodeInsiders", "com.vscodium.codium"]
|
||
let appURL = ids.compactMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) }.first
|
||
|
||
// If we have a project folder, open it directly in VS Code
|
||
if let cwd = task.sessionCwd, !cwd.isEmpty, let appURL = appURL {
|
||
NSWorkspace.shared.open(
|
||
[URL(fileURLWithPath: cwd)],
|
||
withApplicationAt: appURL,
|
||
configuration: .init(),
|
||
completionHandler: nil
|
||
)
|
||
return
|
||
}
|
||
|
||
// No cwd: activate running instance or launch fresh
|
||
if let running = ids.compactMap({ id in
|
||
NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id }
|
||
}).first {
|
||
running.activate(options: .activateIgnoringOtherApps)
|
||
return
|
||
}
|
||
if let appURL = appURL {
|
||
NSWorkspace.shared.openApplication(at: appURL, configuration: .init(), completionHandler: nil)
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Vercel Deployment List View
|
||
|
||
struct VercelDeploymentListView: View {
|
||
let deployments: [VercelDeployment]
|
||
let onOpenDetail: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(hex: "#7C5CFF"))
|
||
.frame(width: 7, height: 7)
|
||
Text("Vercel")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
Text("Деплои")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 36)
|
||
|
||
// Deployment rows
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
// First deployment — highlighted, with detail button
|
||
if let first = deployments.first {
|
||
let accent = Color(hex: first.isSuccess ? "#22C55E" : "#F4505E")
|
||
HStack(spacing: 5) {
|
||
Circle().fill(accent).frame(width: 5, height: 5)
|
||
Text(first.projectName)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
.layoutPriority(1)
|
||
Text(first.timeAgo)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
Button(action: onOpenDetail) {
|
||
Image(systemName: "ellipsis")
|
||
.font(.system(size: 8, weight: .medium))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
.frame(width: 18, height: 18)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(accent.opacity(0.08))
|
||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||
}
|
||
|
||
// Remaining deployments — plain rows, identical structure → perfect alignment
|
||
ForEach(Array(deployments.dropFirst().prefix(2))) { dep in
|
||
let accent = Color(hex: dep.isSuccess ? "#22C55E" : "#F4505E")
|
||
HStack(spacing: 5) {
|
||
Circle().fill(accent).frame(width: 5, height: 5)
|
||
Text(dep.projectName)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: "#9398A1"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
.layoutPriority(1)
|
||
Text(dep.timeAgo)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
.padding(.top, 5)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
}
|
||
}
|
||
|
||
// MARK: - Vercel Deployment Detail View
|
||
|
||
struct VercelDetailView: View {
|
||
let deployment: VercelDeployment
|
||
let onClose: () -> Void
|
||
|
||
private var accent: Color { Color(hex: deployment.isSuccess ? "#22C55E" : "#F4505E") }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
// Header
|
||
HStack(spacing: 7) {
|
||
Button(action: onClose) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
.frame(width: 28, height: 28)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Circle().fill(accent).frame(width: 6, height: 6)
|
||
Text(deployment.projectName)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
.lineLimit(1).truncationMode(.middle)
|
||
.layoutPriority(1)
|
||
Spacer(minLength: 2)
|
||
Text(deployment.statusLabel)
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundColor(accent)
|
||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||
.background(accent.opacity(0.14))
|
||
.clipShape(Capsule())
|
||
}
|
||
|
||
// Details
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
if let commit = deployment.commitMessage {
|
||
Text(commit)
|
||
.font(.system(size: 10.5))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.lineLimit(2)
|
||
}
|
||
HStack(spacing: 8) {
|
||
if let branch = deployment.branch {
|
||
Label(branch, systemImage: "arrow.branch")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
Text(deployment.timeAgo == "только что" ? deployment.timeAgo : deployment.timeAgo + " назад")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
Button(action: {
|
||
if let url = URL(string: "https://\(deployment.url)") {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
}) {
|
||
Text(deployment.url)
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(Color(hex: "#7C5CFF").opacity(0.85))
|
||
.lineLimit(1).truncationMode(.middle)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.top, 8)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
|
||
// MARK: - Resend Card View
|
||
|
||
struct ResendPulseDot: View {
|
||
@State private var on = false
|
||
var body: some View {
|
||
Circle()
|
||
.fill(Color(hex: "#22C55E"))
|
||
.frame(width: 4, height: 4)
|
||
.opacity(on ? 1 : 0.2)
|
||
.onAppear {
|
||
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { on = true }
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ResendCardView: View {
|
||
let emails: [ResendEmail]
|
||
let total: Int?
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(hex: "#22C55E"))
|
||
.frame(width: 7, height: 7)
|
||
Text("Resend")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
Text("Письма")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
if let total {
|
||
ResendPulseDot()
|
||
Text("\(total)")
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.monospacedDigit()
|
||
}
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 36)
|
||
|
||
// Email rows — first is highlighted, rest plain (same structure as Vercel list)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
if let first = emails.first {
|
||
let accent = Color(hex: first.isDelivered ? "#22C55E" : "#F4505E")
|
||
HStack(spacing: 5) {
|
||
Circle().fill(accent).frame(width: 5, height: 5)
|
||
Text(first.recipientShort)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
.layoutPriority(1)
|
||
Text(first.timeAgo)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
if !first.subject.isEmpty {
|
||
Text(first.subject)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#4D5159"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
}
|
||
}
|
||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(accent.opacity(0.08))
|
||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||
}
|
||
|
||
ForEach(Array(emails.dropFirst().prefix(2))) { email in
|
||
let accent = Color(hex: email.isDelivered ? "#22C55E" : "#F4505E")
|
||
HStack(spacing: 5) {
|
||
Circle().fill(accent).frame(width: 5, height: 5)
|
||
Text(email.recipientShort)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: "#9398A1"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
.layoutPriority(1)
|
||
Text(email.timeAgo)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
.padding(.top, 5)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
}
|
||
}
|
||
|
||
// MARK: - GitHub Pulse Card View
|
||
|
||
private func ciWorstState(_ prs: [GitHubPR]) -> CIState {
|
||
if prs.contains(where: { $0.ci == .failure }) { return .failure }
|
||
if prs.contains(where: { $0.ci == .pending }) { return .pending }
|
||
if prs.contains(where: { $0.ci == .success }) { return .success }
|
||
return .unknown
|
||
}
|
||
|
||
private func ciColor(_ state: CIState) -> String {
|
||
switch state {
|
||
case .failure: return "#F4505E"
|
||
case .pending: return "#F5A524"
|
||
case .success: return "#22C55E"
|
||
case .unknown: return "#6B7079"
|
||
}
|
||
}
|
||
|
||
private func mainCIWorst(_ repos: [GitHubRepoCI]) -> CIState {
|
||
if repos.contains(where: { $0.ci == .failure }) { return .failure }
|
||
if repos.contains(where: { $0.ci == .pending }) { return .pending }
|
||
if repos.contains(where: { $0.ci == .success }) { return .success }
|
||
return .unknown
|
||
}
|
||
|
||
struct GitHubPulseCardView: View {
|
||
let pulse: GitHubPulse
|
||
let stats: GitHubStats?
|
||
let activity: GitHubActivity?
|
||
let onTapSection: (GitHubDetailSection) -> Void
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(hex: "#F4505E"))
|
||
.frame(width: 7, height: 7)
|
||
Text("GitHub")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
if let s = stats {
|
||
// Stars + 7-day mini-row → opens Activity detail
|
||
Button(action: { onTapSection(.activity) }) {
|
||
HStack(spacing: 4) {
|
||
Text("★ \(formatCount(s.totalStars))")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.lineLimit(1)
|
||
if let act = activity {
|
||
HStack(spacing: 2) {
|
||
ForEach(act.lastDays(7), id: \.date) { day in
|
||
RoundedRectangle(cornerRadius: 1.5)
|
||
.fill(contributionColor(day.level))
|
||
.frame(width: 7, height: 7)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
} else if let act = activity {
|
||
// No stats yet but activity loaded — show mini-row only
|
||
Button(action: { onTapSection(.activity) }) {
|
||
HStack(spacing: 2) {
|
||
ForEach(act.lastDays(7), id: \.date) { day in
|
||
RoundedRectangle(cornerRadius: 1.5)
|
||
.fill(contributionColor(day.level))
|
||
.frame(width: 7, height: 7)
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
} else {
|
||
Text("Обзор")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 36)
|
||
|
||
// Stat rows
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
// My PRs
|
||
let prWorst = ciWorstState(pulse.myPRs)
|
||
let prValue: String = {
|
||
let n = pulse.myPRs.count
|
||
if n == 0 { return "0" }
|
||
let failing = pulse.myPRs.filter { $0.ci == .failure }.count
|
||
let pending = pulse.myPRs.filter { $0.ci == .pending }.count
|
||
if failing > 0 { return "\(n) · \(failing) с ошибкой" }
|
||
if pending > 0 { return "\(n) · идёт" }
|
||
return "\(n)"
|
||
}()
|
||
GitHubStatRow(
|
||
icon: "arrow.triangle.pull", iconColor: ciColor(prWorst),
|
||
label: "Мои PR", value: prValue
|
||
) { onTapSection(.myPRs) }
|
||
|
||
// To review
|
||
let reviewCount = pulse.toReview.count
|
||
GitHubStatRow(
|
||
icon: "eye",
|
||
iconColor: reviewCount > 0 ? "#8AB4F8" : "#6B7079",
|
||
label: "На ревью",
|
||
value: "\(reviewCount)"
|
||
) { onTapSection(.toReview) }
|
||
|
||
// Default branch CI
|
||
let mainWorst = mainCIWorst(pulse.mainCI)
|
||
let (ciIcon, ciIconColor, ciValue): (String, String, String) = {
|
||
switch mainWorst {
|
||
case .failure:
|
||
let n = pulse.mainCI.filter { $0.ci == .failure }.count
|
||
return ("xmark.octagon.fill", "#F4505E", "\(n) с ошибкой")
|
||
case .pending:
|
||
return ("checkmark.seal.fill", "#F5A524", "идёт")
|
||
case .success:
|
||
return ("checkmark.seal.fill", "#22C55E", "всё зелёное")
|
||
case .unknown:
|
||
return ("checkmark.seal.fill", "#6B7079", pulse.mainCI.isEmpty ? "нет репо" : "неизвестно")
|
||
}
|
||
}()
|
||
GitHubStatRow(
|
||
icon: ciIcon, iconColor: ciIconColor,
|
||
label: "CI основной ветки", value: ciValue
|
||
) { onTapSection(.mainCI) }
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
.clipped()
|
||
}
|
||
|
||
private func formatCount(_ n: Int) -> String {
|
||
if n >= 1000 { return String(format: "%.1fk", Double(n) / 1000) }
|
||
return "\(n)"
|
||
}
|
||
}
|
||
|
||
private struct GitHubStatRow: View {
|
||
let icon: String
|
||
let iconColor: String
|
||
let label: String
|
||
let value: String
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 7) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: iconColor))
|
||
.frame(width: 14)
|
||
Text(label)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
Spacer()
|
||
Text(value)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.monospacedDigit()
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
// MARK: - GitHub Detail View
|
||
|
||
struct GitHubDetailView: View {
|
||
let section: GitHubDetailSection
|
||
let pulse: GitHubPulse
|
||
let activity: GitHubActivity?
|
||
let stats: GitHubStats?
|
||
let onBack: () -> Void
|
||
@ObservedObject private var appState = AppState.shared
|
||
|
||
private var title: String {
|
||
switch section {
|
||
case .myPRs: return "Мои PR"
|
||
case .toReview: return "На ревью"
|
||
case .mainCI: return "CI основной ветки"
|
||
case .activity: return "Активность"
|
||
}
|
||
}
|
||
|
||
private var items: [GitHubPR] {
|
||
switch section {
|
||
case .myPRs: return pulse.myPRs
|
||
case .toReview: return pulse.toReview
|
||
case .mainCI, .activity: return []
|
||
}
|
||
}
|
||
|
||
private var repoItems: [GitHubRepoCI] {
|
||
section == .mainCI ? pulse.mainCI : []
|
||
}
|
||
|
||
private var totalItems: Int { items.count + repoItems.count }
|
||
|
||
var body: some View {
|
||
if section == .activity {
|
||
GitHubActivityDetailContent(
|
||
activity: activity,
|
||
stats: stats,
|
||
login: pulse.login,
|
||
onBack: onBack
|
||
)
|
||
} else {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 6) {
|
||
Button(action: onBack) {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 8, weight: .medium))
|
||
Text(title)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
}
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
}
|
||
.buttonStyle(.plain)
|
||
Spacer(minLength: 2)
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
|
||
// List
|
||
if items.isEmpty && repoItems.isEmpty {
|
||
Text("Пусто")
|
||
.font(.system(size: 10.5))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
.padding(.top, 8)
|
||
.padding(.leading, 108)
|
||
} else {
|
||
ScrollView(.vertical, showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, pr in
|
||
GitHubPRRowView(pr: pr, showCI: section == .myPRs,
|
||
selected: appState.cardSelection == idx)
|
||
}
|
||
ForEach(Array(repoItems.enumerated()), id: \.element.repo) { idx, repo in
|
||
GitHubRepoCIRowView(repo: repo,
|
||
selected: appState.cardSelection == items.count + idx)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxHeight: 60) // 3 rows × 20 pt; rest scrolls
|
||
.mask(
|
||
Group {
|
||
if totalItems > 3 {
|
||
LinearGradient(
|
||
stops: [
|
||
.init(color: .black, location: 0),
|
||
.init(color: .black, location: 0.8),
|
||
.init(color: .clear, location: 1.0)
|
||
],
|
||
startPoint: .top, endPoint: .bottom
|
||
)
|
||
} else {
|
||
Color.black
|
||
}
|
||
}
|
||
)
|
||
.padding(.top, 4)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 8)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
.clipped()
|
||
.onAppear {
|
||
GithubPoller.shared.refreshIfStale()
|
||
appState.cardItemCount = totalItems
|
||
}
|
||
.onDisappear { appState.cardItemCount = 0 }
|
||
.onReceive(NotificationCenter.default.publisher(for: .islandActivateCardSelection)) { _ in
|
||
guard let sel = appState.cardSelection else { return }
|
||
if sel < items.count {
|
||
let pr = items[sel]
|
||
if let url = safeWebURL(pr.url), url.host == "github.com" {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
} else {
|
||
let repoIdx = sel - items.count
|
||
guard repoIdx < repoItems.count else { return }
|
||
let repo = repoItems[repoIdx]
|
||
let actionsURL = repo.url.hasSuffix("/") ? repo.url + "actions" : repo.url + "/actions"
|
||
if let url = safeWebURL(actionsURL), url.host == "github.com" {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
}
|
||
}
|
||
.onExitCommand { onBack() }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - GitHub Activity Detail
|
||
|
||
private struct GitHubActivityDetailContent: View {
|
||
let activity: GitHubActivity?
|
||
let stats: GitHubStats?
|
||
let login: String
|
||
let onBack: () -> Void
|
||
|
||
@State private var hoveredDay: ContributionDay? = nil
|
||
|
||
// Dynamic grid: s=7pt, spacing=1.5pt; numWeeks = floor((202 + 1.5) / (7 + 1.5)) = 23
|
||
private let squareSize: CGFloat = 7
|
||
private let spacing: CGFloat = 1.5
|
||
private var numWeeks: Int { Int((202 + spacing) / (squareSize + spacing)) }
|
||
|
||
private var headerRight: String {
|
||
if let day = hoveredDay {
|
||
let label: String
|
||
switch day.count {
|
||
case 0: label = "Нет вкладов"
|
||
default: label = "\(day.count) \(ruPlural(day.count, "вклад", "вклада", "вкладов"))"
|
||
}
|
||
return "\(activityDateLabel(day.date)) · \(label)"
|
||
}
|
||
guard let act = activity else { return "" }
|
||
let total = activityTotalLabel(act.total)
|
||
if let s = stats { return "\(total) за год · \(s.totalRepos) репо" }
|
||
return "\(total) за год"
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 6) {
|
||
Button(action: onBack) {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 8, weight: .medium))
|
||
Text("Активность")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
}
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
}
|
||
.buttonStyle(.plain)
|
||
Spacer(minLength: 2)
|
||
if activity != nil {
|
||
Button(action: {
|
||
let urlStr = "https://github.com/\(login)"
|
||
if let url = safeWebURL(urlStr), url.host == "github.com" {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
}) {
|
||
Text(headerRight)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.lineLimit(1)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
|
||
// Grid
|
||
if let act = activity {
|
||
let weeks = act.lastWeeks(numWeeks)
|
||
HStack(alignment: .top, spacing: spacing) {
|
||
ForEach(weeks.indices, id: \.self) { wi in
|
||
VStack(spacing: spacing) {
|
||
ForEach(0..<7, id: \.self) { dow in
|
||
if let day = weeks[wi].first(where: { $0.weekday == dow }) {
|
||
RoundedRectangle(cornerRadius: 1.5)
|
||
.fill(contributionColor(day.level))
|
||
.frame(width: squareSize, height: squareSize)
|
||
.onHover { hovering in hoveredDay = hovering ? day : nil }
|
||
.onTapGesture {
|
||
hoveredDay = (hoveredDay?.date == day.date) ? nil : day
|
||
}
|
||
} else {
|
||
Color.clear.frame(width: squareSize, height: squareSize)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.top, 5)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
} else {
|
||
Text("Загрузка…")
|
||
.font(.system(size: 10.5))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
.padding(.top, 8)
|
||
.padding(.leading, 108)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
.clipped()
|
||
.onAppear { GithubPoller.shared.refreshActivityIfStale() }
|
||
.onExitCommand { onBack() }
|
||
}
|
||
|
||
private func activityDateLabel(_ dateStr: String) -> String {
|
||
let parts = dateStr.split(separator: "-")
|
||
guard parts.count == 3,
|
||
let month = Int(parts[1]), month >= 1 && month <= 12,
|
||
let day = Int(parts[2]) else { return dateStr }
|
||
let months = ["янв","фев","мар","апр","мая","июн","июл","авг","сен","окт","ноя","дек"]
|
||
return "\(day) \(months[month - 1])"
|
||
}
|
||
|
||
private func activityTotalLabel(_ n: Int) -> String {
|
||
let nf = NumberFormatter()
|
||
nf.numberStyle = .decimal
|
||
nf.locale = Locale(identifier: "ru_RU")
|
||
return nf.string(from: NSNumber(value: n)) ?? "\(n)"
|
||
}
|
||
}
|
||
|
||
private func contributionColor(_ level: Int) -> Color {
|
||
switch level {
|
||
case 1: return Color(hex: "#0E4429")
|
||
case 2: return Color(hex: "#006D32")
|
||
case 3: return Color(hex: "#26A641")
|
||
case 4: return Color(hex: "#39D353")
|
||
default: return Color.white.opacity(0.06)
|
||
}
|
||
}
|
||
|
||
private func ghCIDot(_ ci: CIState) -> Color {
|
||
switch ci {
|
||
case .failure: return Color(hex: "#F4505E")
|
||
case .pending: return Color(hex: "#F5A524")
|
||
case .success: return Color(hex: "#22C55E")
|
||
case .unknown: return Color.clear
|
||
}
|
||
}
|
||
|
||
private struct GitHubPRRowView: View {
|
||
let pr: GitHubPR
|
||
let showCI: Bool
|
||
var selected: Bool = false
|
||
|
||
var body: some View {
|
||
Button(action: {
|
||
if let url = safeWebURL(pr.url), url.host == "github.com" {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
}) {
|
||
HStack(spacing: 5) {
|
||
if showCI {
|
||
Circle()
|
||
.fill(ghCIDot(pr.ci))
|
||
.frame(width: 5, height: 5)
|
||
.opacity(pr.ci == .unknown ? 0 : 1)
|
||
} else {
|
||
Spacer().frame(width: 5)
|
||
}
|
||
Text("\(pr.repo.components(separatedBy: "/").last ?? pr.repo)#\(pr.number)")
|
||
.font(.system(size: 10.5))
|
||
.foregroundColor(Color(hex: "#9398A1"))
|
||
.lineLimit(1)
|
||
Text(pr.title)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
if pr.isDraft {
|
||
Text("Черновик")
|
||
.font(.system(size: 9.5))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.frame(height: 20)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 4)
|
||
.fill(Color.white.opacity(0.12))
|
||
.opacity(selected ? 1 : 0)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
private struct GitHubRepoCIRowView: View {
|
||
let repo: GitHubRepoCI
|
||
var selected: Bool = false
|
||
|
||
private var ciStateWord: String? {
|
||
switch repo.ci {
|
||
case .failure: return "failing"
|
||
case .pending: return "running"
|
||
case .success: return "passing"
|
||
case .unknown: return nil
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
Button(action: {
|
||
let actionsURL = repo.url.hasSuffix("/") ? repo.url + "actions" : repo.url + "/actions"
|
||
if let url = safeWebURL(actionsURL), url.host == "github.com" {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
}) {
|
||
HStack(spacing: 5) {
|
||
Circle()
|
||
.fill(ghCIDot(repo.ci))
|
||
.frame(width: 5, height: 5)
|
||
.opacity(repo.ci == .unknown ? 0 : 1)
|
||
Text(repo.repo.components(separatedBy: "/").last ?? repo.repo)
|
||
.font(.system(size: 10.5))
|
||
.foregroundColor(Color(hex: "#9398A1"))
|
||
.lineLimit(1)
|
||
Text(repo.branch)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
Spacer(minLength: 4)
|
||
if let word = ciStateWord {
|
||
Text(word)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(ghCIDot(repo.ci))
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.frame(height: 20)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 4)
|
||
.fill(Color.white.opacity(0.12))
|
||
.opacity(selected ? 1 : 0)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
// MARK: - GitHub Stats Card View
|
||
|
||
struct GitHubStatsCardView: View {
|
||
let stats: GitHubStats
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(hex: "#F4505E"))
|
||
.frame(width: 7, height: 7)
|
||
Text("GitHub")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
Text("Обзор")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 36)
|
||
|
||
// Stats rows
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
StatRow(icon: "star.fill", color: "#F5A524",
|
||
label: "Всего звёзд", value: formatCount(stats.totalStars))
|
||
StatRow(icon: "square.stack.fill", color: "#6B7079",
|
||
label: "Репозитории", value: "\(stats.totalRepos)")
|
||
}
|
||
.padding(.top, 8)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
}
|
||
|
||
private func formatCount(_ n: Int) -> String {
|
||
if n >= 1000 { return String(format: "%.1fk", Double(n) / 1000) }
|
||
return "\(n)"
|
||
}
|
||
}
|
||
|
||
private struct StatRow: View {
|
||
let icon: String
|
||
let color: String
|
||
let label: String
|
||
let value: String
|
||
|
||
var body: some View {
|
||
HStack(spacing: 7) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: color))
|
||
.frame(width: 14)
|
||
Text(label)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
Spacer()
|
||
Text(value)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.monospacedDigit()
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
|
||
// MARK: - Stripe Card View
|
||
|
||
struct StripeCardView: View {
|
||
@ObservedObject private var appState = AppState.shared
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(hex: "#0570DE"))
|
||
.frame(width: 7, height: 7)
|
||
Text("Stripe")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
Text("Платежи")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 36)
|
||
|
||
// Balance
|
||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||
Text(balanceFormatted)
|
||
.font(.system(size: 20, weight: .bold, design: .monospaced))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
.contentTransition(.numericText(countsDown: false))
|
||
.animation(.easeOut(duration: 1.2), value: appState.stripeDisplayBalance)
|
||
Text(appState.stripeCurrency.uppercased())
|
||
.font(.system(size: 9, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
.padding(.bottom, 1)
|
||
}
|
||
.padding(.leading, 108)
|
||
.padding(.top, 4)
|
||
|
||
// Payment rows (animated list)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
ForEach(appState.stripePayments) { payment in
|
||
StripePaymentRow(payment: payment)
|
||
.transition(.asymmetric(
|
||
insertion: .move(edge: .top).combined(with: .opacity),
|
||
removal: .move(edge: .bottom).combined(with: .opacity)
|
||
))
|
||
}
|
||
}
|
||
.animation(.spring(response: 0.38, dampingFraction: 0.82),
|
||
value: appState.stripePayments.map(\.id))
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
.padding(.top, 4)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
}
|
||
|
||
private var balanceFormatted: String {
|
||
String(format: "%.2f", Double(appState.stripeDisplayBalance) / 100.0)
|
||
}
|
||
}
|
||
|
||
private struct StripePaymentRow: View {
|
||
let payment: StripePayment
|
||
|
||
var body: some View {
|
||
let accent = payment.isSuccess ? Color(hex: "#22C55E") : Color(hex: "#F4505E")
|
||
HStack(spacing: 5) {
|
||
Circle().fill(accent).frame(width: 5, height: 5)
|
||
Text(payment.description ?? "Платёж")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
.layoutPriority(1)
|
||
Spacer(minLength: 4)
|
||
Text("+\(payment.amountFormatted)")
|
||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||
.foregroundColor(Color(hex: "#22C55E"))
|
||
.fixedSize()
|
||
Text(payment.timeAgo)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
.fixedSize()
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
|
||
// MARK: - Cal.com Card View
|
||
|
||
struct CalcomCardView: View {
|
||
@ObservedObject private var appState = AppState.shared
|
||
@State private var selectedDate: Date? = nil
|
||
@State private var selectedBooking: CalcomBooking? = nil
|
||
@State private var displayMonth: Date = Date()
|
||
@State private var displayHalf: Int = 1 // 1 = first half, 2 = second half
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let booking = selectedBooking {
|
||
CalcomBookingDetailView(booking: booking) {
|
||
withAnimation(.easeOut(duration: 0.2)) { selectedBooking = nil }
|
||
}
|
||
} else if let date = selectedDate {
|
||
CalcomDayView(
|
||
date: date,
|
||
bookings: bookingsFor(date),
|
||
onSelect: { b in withAnimation(.easeOut(duration: 0.2)) { selectedBooking = b } },
|
||
onBack: { withAnimation(.easeOut(duration: 0.2)) { selectedDate = nil } }
|
||
)
|
||
} else {
|
||
CalcomCalendarView(
|
||
displayMonth: $displayMonth,
|
||
displayHalf: $displayHalf,
|
||
bookings: appState.calcomBookings,
|
||
onSelect: { d in withAnimation(.easeOut(duration: 0.2)) { selectedDate = d } }
|
||
)
|
||
}
|
||
}
|
||
.onChange(of: appState.focusId) { _, _ in
|
||
selectedDate = nil; selectedBooking = nil; displayHalf = 1
|
||
}
|
||
}
|
||
|
||
private func bookingsFor(_ date: Date) -> [CalcomBooking] {
|
||
let c = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
||
let key = "\(c.year!)-\(String(format: "%02d", c.month!))-\(String(format: "%02d", c.day!))"
|
||
return appState.calcomBookings.filter { $0.dayKey == key }
|
||
.sorted { $0.startTime < $1.startTime }
|
||
}
|
||
}
|
||
|
||
struct CalcomCalendarView: View {
|
||
@Binding var displayMonth: Date
|
||
@Binding var displayHalf: Int
|
||
let bookings: [CalcomBooking]
|
||
let onSelect: (Date) -> Void
|
||
|
||
private let cal = Calendar.current
|
||
|
||
private var navLabel: String {
|
||
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU"); f.dateFormat = "LLLL yyyy"
|
||
return "\(f.string(from: displayMonth)) Q\(displayHalf)"
|
||
}
|
||
|
||
// 7 consecutive days per row, day 1 always at far left — no weekday alignment
|
||
private var allWeeks: [[Date?]] {
|
||
let comps = cal.dateComponents([.year, .month], from: displayMonth)
|
||
let monthStart = cal.date(from: comps)!
|
||
let daysInMonth = cal.range(of: .day, in: .month, for: displayMonth)!.count
|
||
var result: [[Date?]] = []
|
||
var chunk: [Date?] = []
|
||
for i in 0..<daysInMonth {
|
||
chunk.append(cal.date(byAdding: .day, value: i, to: monthStart)!)
|
||
if chunk.count == 7 { result.append(chunk); chunk = [] }
|
||
}
|
||
if !chunk.isEmpty {
|
||
while chunk.count < 7 { chunk.append(nil) }
|
||
result.append(chunk)
|
||
}
|
||
return result
|
||
}
|
||
|
||
// Visible weeks for current half
|
||
private var visibleWeeks: [[Date?]] {
|
||
let all = allWeeks
|
||
let splitAt = 2 // always 2 weeks per Q
|
||
return displayHalf == 1 ? Array(all[0..<splitAt]) : Array(all[splitAt...])
|
||
}
|
||
|
||
private func hasBookings(_ d: Date) -> Bool {
|
||
let c = cal.dateComponents([.year, .month, .day], from: d)
|
||
let key = "\(c.year!)-\(String(format: "%02d", c.month!))-\(String(format: "%02d", c.day!))"
|
||
return bookings.contains { $0.dayKey == key }
|
||
}
|
||
|
||
private func goBack() {
|
||
if displayHalf == 1 {
|
||
displayMonth = cal.date(byAdding: .month, value: -1, to: displayMonth) ?? displayMonth
|
||
displayHalf = 2
|
||
} else {
|
||
displayHalf = 1
|
||
}
|
||
}
|
||
|
||
private func goForward() {
|
||
if displayHalf == 1 {
|
||
displayHalf = 2
|
||
} else {
|
||
displayMonth = cal.date(byAdding: .month, value: 1, to: displayMonth) ?? displayMonth
|
||
displayHalf = 1
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 6) {
|
||
Circle().fill(Color(hex: "#C9956A")).frame(width: 7, height: 7)
|
||
Text("Cal.com").font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8"))
|
||
Text("Расписание").font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
.padding(.top, 6).padding(.leading, 108).padding(.trailing, 36)
|
||
|
||
HStack(spacing: 0) {
|
||
Button { goBack() } label: {
|
||
Image(systemName: "chevron.left").font(.system(size: 8, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#6B7079")).frame(width: 18, height: 16)
|
||
}.buttonStyle(.plain)
|
||
Text(navLabel).font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#C5C8CD")).frame(maxWidth: .infinity)
|
||
Button { goForward() } label: {
|
||
Image(systemName: "chevron.right").font(.system(size: 8, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#6B7079")).frame(width: 18, height: 16)
|
||
}.buttonStyle(.plain)
|
||
}
|
||
.padding(.leading, 108).padding(.trailing, 12).padding(.top, 2)
|
||
|
||
VStack(spacing: 1) {
|
||
ForEach(visibleWeeks.indices, id: \.self) { i in
|
||
CalcomWeekRow(week: visibleWeeks[i], hasBookings: hasBookings,
|
||
isToday: cal.isDateInToday, onSelect: onSelect)
|
||
}
|
||
}
|
||
.padding(.leading, 108).padding(.trailing, 12).padding(.top, 2)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4)
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
|
||
private struct CalcomWeekRow: View {
|
||
let week: [Date?]
|
||
let hasBookings: (Date) -> Bool
|
||
let isToday: (Date) -> Bool
|
||
let onSelect: (Date) -> Void
|
||
|
||
private var weekLabel: String {
|
||
guard let first = week.compactMap({ $0 }).first else { return "" }
|
||
let f = DateFormatter(); f.dateFormat = "dd.MM"
|
||
return f.string(from: first)
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 0) {
|
||
Text(weekLabel).font(.system(size: 7)).foregroundColor(Color(hex: "#4B5563"))
|
||
.frame(width: 26, alignment: .leading)
|
||
ForEach(0..<7, id: \.self) { i in
|
||
if let day = week[i] {
|
||
CalcomDayCell(day: day, hasEvents: hasBookings(day), isToday: isToday(day))
|
||
.contentShape(Rectangle()).onTapGesture { onSelect(day) }.frame(maxWidth: .infinity)
|
||
} else {
|
||
Color.clear.frame(maxWidth: .infinity).frame(height: 18)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct CalcomDayCell: View {
|
||
let day: Date
|
||
let hasEvents: Bool
|
||
let isToday: Bool
|
||
var body: some View {
|
||
VStack(spacing: 1) {
|
||
Text("\(Calendar.current.component(.day, from: day))")
|
||
.font(.system(size: 9, weight: isToday ? .bold : .regular))
|
||
.foregroundColor(isToday ? .white : Color(hex: "#9398A1"))
|
||
.frame(width: 13, height: 13)
|
||
.background(isToday ? Color(hex: "#C9956A").opacity(0.55) : Color.clear)
|
||
.clipShape(Circle())
|
||
Circle().fill(hasEvents ? Color(hex: "#C9956A") : Color.clear).frame(width: 3, height: 3)
|
||
}
|
||
.frame(height: 18)
|
||
}
|
||
}
|
||
|
||
struct CalcomDayView: View {
|
||
let date: Date
|
||
let bookings: [CalcomBooking]
|
||
let onSelect: (CalcomBooking) -> Void
|
||
let onBack: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 4) {
|
||
Button(action: onBack) {
|
||
Image(systemName: "chevron.left").font(.system(size: 9, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#6B7079")).frame(width: 22, height: 22).contentShape(Rectangle())
|
||
}.buttonStyle(.plain).padding(.leading, 108)
|
||
Text(dayLabel).font(.system(size: 11, weight: .semibold)).foregroundColor(Color(hex: "#C5C8CD"))
|
||
Spacer()
|
||
}
|
||
.padding(.top, 6).padding(.trailing, 12)
|
||
|
||
if bookings.isEmpty {
|
||
Text("Звонков нет").font(.system(size: 11)).foregroundColor(Color(hex: "#6B7079"))
|
||
.padding(.leading, 116).padding(.top, 8)
|
||
} else {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
ForEach(bookings) { b in
|
||
Button { onSelect(b) } label: {
|
||
HStack(spacing: 6) {
|
||
Circle().fill(Color(hex: "#C9956A")).frame(width: 4, height: 4)
|
||
Text(b.timeLabel)
|
||
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||
.foregroundColor(Color(hex: "#C9956A")).fixedSize()
|
||
Text(b.title).font(.system(size: 11)).foregroundColor(Color(hex: "#C5C8CD"))
|
||
.lineLimit(1).truncationMode(.tail).layoutPriority(1)
|
||
Spacer(minLength: 2)
|
||
Image(systemName: "chevron.right").font(.system(size: 8))
|
||
.foregroundColor(Color(hex: "#4B5563"))
|
||
}
|
||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||
.background(Color(hex: "#C9956A").opacity(0.06))
|
||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||
}.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.leading, 108).padding(.trailing, 12).padding(.top, 5)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4)
|
||
.transition(.opacity)
|
||
}
|
||
private var dayLabel: String {
|
||
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU"); f.dateFormat = "EEEE, d MMMM"; return f.string(from: date)
|
||
}
|
||
}
|
||
|
||
struct CalcomBookingDetailView: View {
|
||
let booking: CalcomBooking
|
||
let onBack: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 4) {
|
||
Button(action: onBack) {
|
||
Image(systemName: "chevron.left").font(.system(size: 9, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#6B7079")).frame(width: 22, height: 22).contentShape(Rectangle())
|
||
}.buttonStyle(.plain).padding(.leading, 108)
|
||
Text(booking.timeLabel)
|
||
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||
.foregroundColor(Color(hex: "#C9956A"))
|
||
Spacer()
|
||
}
|
||
.padding(.top, 6).padding(.trailing, 12)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(booking.title).font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8")).lineLimit(1)
|
||
if let name = booking.attendeeName, !name.isEmpty {
|
||
CalcomDetailRow(icon: "person.fill", text: name, size: 11)
|
||
}
|
||
if let email = booking.attendeeEmail, !email.isEmpty {
|
||
CalcomDetailRow(icon: "envelope.fill", text: email, size: 10, truncate: true)
|
||
}
|
||
if let notes = booking.attendeeNotes, !notes.isEmpty {
|
||
CalcomDetailRow(icon: "note.text", text: notes, size: 10, lines: 2)
|
||
}
|
||
}
|
||
.padding(.leading, 114).padding(.trailing, 12).padding(.top, 5)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4)
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
|
||
private struct CalcomDetailRow: View {
|
||
let icon: String
|
||
let text: String
|
||
var size: CGFloat = 11
|
||
var truncate: Bool = false
|
||
var lines: Int = 1
|
||
var body: some View {
|
||
HStack(alignment: .top, spacing: 4) {
|
||
Image(systemName: icon).font(.system(size: 9)).foregroundColor(Color(hex: "#6B7079")).frame(width: 10)
|
||
Text(text).font(.system(size: size)).foregroundColor(Color(hex: "#9398A1"))
|
||
.lineLimit(lines).truncationMode(truncate ? .middle : .tail)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Notion Card View
|
||
|
||
struct NotionCardView: View {
|
||
@ObservedObject private var appState = AppState.shared
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 6) {
|
||
Circle().fill(Color(hex: "#E8E8E8")).frame(width: 7, height: 7)
|
||
Text("Notion").font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8"))
|
||
Text("Недавние").font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
.padding(.top, 6).padding(.leading, 108).padding(.trailing, 36)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
ForEach(appState.notionPages.prefix(3)) { page in
|
||
Button {
|
||
if let url = safeWebURL(page.url) { NSWorkspace.shared.open(url) }
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
if let emoji = page.emoji {
|
||
Text(emoji).font(.system(size: 10)).frame(width: 14)
|
||
} else {
|
||
Image(systemName: "doc.text").font(.system(size: 9))
|
||
.foregroundColor(Color(hex: "#6B7079")).frame(width: 14)
|
||
}
|
||
Text(page.title).font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
.lineLimit(1).truncationMode(.tail).layoutPriority(1)
|
||
Spacer(minLength: 4)
|
||
Text(page.timeAgo).font(.system(size: 9))
|
||
.foregroundColor(Color(hex: "#4B5563"))
|
||
}
|
||
.padding(.horizontal, 6).padding(.vertical, 4)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.leading, 102).padding(.trailing, 12).padding(.top, 5)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4)
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
|
||
// MARK: - n8n Execution Detail View
|
||
|
||
struct N8nDetailView: View {
|
||
let task: AgentTask
|
||
let onClose: () -> Void
|
||
|
||
private var success: Bool { task.state == .finished }
|
||
private var accent: Color { success ? Color(hex: "#22C55E") : Color(hex: "#F4505E") }
|
||
private var statusLabel: String { success ? "Успех" : "Ошибка" }
|
||
private var detail: String? { task.steps.dropFirst().first }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
|
||
// Header: back button + workflow name + status badge
|
||
HStack(spacing: 7) {
|
||
Button(action: onClose) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
.frame(width: 28, height: 28) // large hit area
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Circle().fill(accent).frame(width: 6, height: 6)
|
||
|
||
Text(task.steps.first ?? "Сценарий")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
.lineLimit(1).truncationMode(.middle)
|
||
.layoutPriority(1)
|
||
|
||
Spacer(minLength: 2)
|
||
|
||
Text(statusLabel)
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundColor(accent)
|
||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||
.background(accent.opacity(0.14))
|
||
.clipShape(Capsule())
|
||
}
|
||
|
||
// Detail body — monospaced, selectable
|
||
if let detail {
|
||
ScrollView(.vertical, showsIndicators: false) {
|
||
Text(detail)
|
||
.font(.system(size: 10.5, design: .monospaced))
|
||
.foregroundColor(Color(hex: "#9398A1"))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.lineSpacing(2)
|
||
.textSelection(.enabled)
|
||
}
|
||
.frame(maxHeight: 88)
|
||
} else {
|
||
Text(success ? "Успешно завершено." : "Нет подробностей об ошибке.")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#6B7079"))
|
||
}
|
||
}
|
||
.padding(.top, 8)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.contentShape(Rectangle()) // prevent taps falling through transparent areas
|
||
}
|
||
}
|
||
|
||
// MARK: - Ticker (overview scrolling task steps) V2
|
||
|
||
struct TickerView: View {
|
||
let task: AgentTask?
|
||
var onDiffTap: ((Int) -> Void)? = nil
|
||
|
||
@State private var rowA: String = "…" // completed (above, left-shifted)
|
||
@State private var rowB: String = "…" // current (below) → animates diagonally up-left
|
||
@State private var rowC: String = "" // incoming current — slides in from below
|
||
|
||
@State private var rowAOffset: CGFloat = 0
|
||
@State private var rowAOpacity: Double = 1
|
||
@State private var rowBOffset: CGFloat = 22
|
||
@State private var rowBPhase: Double = 0 // 0=current, 1=completed (drives X+scale)
|
||
@State private var rowCOffset: CGFloat = 44
|
||
@State private var rowCOpacity: Double = 0
|
||
|
||
@State private var displayIndex: Int = -1
|
||
@State private var isTransitioning = false
|
||
|
||
private let completedScale: CGFloat = 11.5 / 13 // 0.885 — matches completed font size
|
||
|
||
var steps: [String] {
|
||
let raw = task?.steps ?? []
|
||
return raw.isEmpty ? ["…"] : raw
|
||
}
|
||
|
||
var body: some View {
|
||
let isActive = task?.state == .thinking || task?.state == .working
|
||
let rowADiffTap: (() -> Void)? = rowA.parseDiffStep().map { dp in { onDiffTap?(dp.diffId) } }
|
||
let rowBDiffTap: (() -> Void)? = rowB.parseDiffStep().map { dp in { onDiffTap?(dp.diffId) } }
|
||
|
||
ZStack(alignment: .topLeading) {
|
||
Color.clear
|
||
|
||
// Row A: completed row — always rendered at phase=1 + completedScale
|
||
TickerRowView(text: rowA, phase: 1.0, isActive: isActive, onDiffTap: rowADiffTap)
|
||
.scaleEffect(completedScale, anchor: .leading)
|
||
.offset(x: -10, y: rowAOffset)
|
||
.opacity(rowAOpacity)
|
||
|
||
// Row B: current step → animates diagonally up-left, phase 0→1, scale 1→completedScale
|
||
TickerRowView(text: rowB, phase: rowBPhase, isActive: isActive, onDiffTap: rowBDiffTap)
|
||
.scaleEffect(1 - rowBPhase * (1 - completedScale), anchor: .leading)
|
||
.offset(x: -rowBPhase * 10, y: rowBOffset)
|
||
|
||
// Row C: incoming new step — slides in from below at phase=0
|
||
TickerRowView(text: rowC, phase: 0.0, isActive: isActive)
|
||
.offset(y: rowCOffset)
|
||
.opacity(rowCOpacity)
|
||
}
|
||
.frame(height: 44)
|
||
.clipped()
|
||
.mask(LinearGradient(
|
||
stops: [
|
||
.init(color: .clear, location: 0),
|
||
.init(color: .black, location: 0.12),
|
||
.init(color: .black, location: 0.85),
|
||
.init(color: .clear, location: 1)
|
||
],
|
||
startPoint: .top, endPoint: .bottom
|
||
))
|
||
.onAppear {
|
||
let idx = task?.stepIndex ?? -1
|
||
displayIndex = idx
|
||
if idx >= 0, !steps.isEmpty {
|
||
rowA = idx > 0 ? steps[max(0, idx - 1)] : "…"
|
||
rowB = steps[min(idx, steps.count - 1)]
|
||
}
|
||
}
|
||
.onChange(of: task?.steps.count) { _, _ in
|
||
guard let task, !task.steps.isEmpty, !isTransitioning else { return }
|
||
let newIdx = task.stepIndex
|
||
if displayIndex < 0 {
|
||
displayIndex = newIdx
|
||
rowA = newIdx > 0 ? steps[max(0, newIdx - 1)] : "…"
|
||
rowB = steps[min(newIdx, steps.count - 1)]
|
||
return
|
||
}
|
||
guard newIdx != displayIndex else { return }
|
||
tickerAnimate(to: newIdx)
|
||
}
|
||
}
|
||
|
||
private func tickerAnimate(to newIdx: Int) {
|
||
isTransitioning = true
|
||
rowC = steps[min(newIdx, steps.count - 1)]
|
||
rowCOffset = 44
|
||
rowCOpacity = 0
|
||
|
||
// Old completed (rowA): fades + slides further up
|
||
withAnimation(.easeOut(duration: 0.28)) {
|
||
rowAOffset = -22
|
||
rowAOpacity = 0
|
||
}
|
||
|
||
// Current (rowB): moves diagonally up-left + shrinks to completed size
|
||
withAnimation(.timingCurve(0.4, 0, 0.2, 1, duration: 0.38)) {
|
||
rowBOffset = 0
|
||
rowBPhase = 1
|
||
}
|
||
|
||
// New current (rowC): slides in from below
|
||
withAnimation(.timingCurve(0.4, 0, 0.2, 1, duration: 0.38)) {
|
||
rowCOffset = 22
|
||
rowCOpacity = 1
|
||
}
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.50) {
|
||
self.displayIndex = newIdx
|
||
self.rowA = self.rowB
|
||
self.rowAOffset = 0
|
||
self.rowAOpacity = 1
|
||
self.rowB = self.rowC
|
||
self.rowBOffset = 22
|
||
self.rowBPhase = 0
|
||
self.rowCOffset = 44
|
||
self.rowCOpacity = 0
|
||
self.isTransitioning = false
|
||
}
|
||
}
|
||
}
|
||
|
||
struct TickerRowView: View {
|
||
let text: String
|
||
let phase: Double // 0 = current (shimmer, large), 1 = completed (dim, scaled down by caller)
|
||
var isActive: Bool = true
|
||
var onDiffTap: (() -> Void)? = nil
|
||
|
||
var body: some View {
|
||
let chevronOpacity: Double = isActive ? max(0, 1 - phase * 2) : 0
|
||
let checkmarkOpacity: Double = isActive ? max(0, phase * 2 - 1) : 1
|
||
let shimmerOpacity: Double = isActive ? max(0, 1 - phase * 1.6) : 0
|
||
let staticOpacity: Double = isActive ? min(1, max(0, phase * 2 - 0.4)) : 1
|
||
let staticColor = (!isActive && phase < 0.5) ? Color(hex: "#C9CDD4") : Color(hex: "#6B7079")
|
||
|
||
if let dp = text.parseDiffStep() {
|
||
HStack(spacing: 6) {
|
||
ZStack {
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 9, weight: .medium))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.opacity(chevronOpacity)
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 8, weight: .regular))
|
||
.foregroundColor(Color(hex: "#454850"))
|
||
.opacity(checkmarkOpacity)
|
||
}
|
||
.frame(width: 12, alignment: .center)
|
||
// Filename + counts
|
||
HStack(spacing: 0) {
|
||
ZStack(alignment: .leading) {
|
||
TickerShimmerText(text: dp.filename)
|
||
.opacity(shimmerOpacity)
|
||
Text(dp.filename)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundColor(staticColor)
|
||
.lineLimit(1).truncationMode(.tail)
|
||
.opacity(staticOpacity)
|
||
}
|
||
if dp.added > 0 {
|
||
Text(" +\(dp.added)")
|
||
.font(.system(size: 10, weight: .medium).monospaced())
|
||
.foregroundColor(Color(hex: "#22C55E"))
|
||
.fixedSize()
|
||
}
|
||
if dp.removed > 0 {
|
||
Text(" −\(dp.removed)")
|
||
.font(.system(size: 10, weight: .medium).monospaced())
|
||
.foregroundColor(Color(hex: "#F4505E"))
|
||
.fixedSize()
|
||
}
|
||
}
|
||
}
|
||
.frame(height: 22, alignment: .leading)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { onDiffTap?() }
|
||
} else {
|
||
HStack(spacing: 6) {
|
||
// Icon: chevron fades out first half, checkmark fades in second half
|
||
ZStack {
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 9, weight: .medium))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.opacity(chevronOpacity)
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 8, weight: .regular))
|
||
.foregroundColor(Color(hex: "#454850"))
|
||
.opacity(checkmarkOpacity)
|
||
}
|
||
.frame(width: 12, alignment: .center)
|
||
|
||
// Text: shimmer fades out, dim completed text fades in (overlapping cross-fade)
|
||
ZStack(alignment: .leading) {
|
||
TickerShimmerText(text: text)
|
||
.opacity(shimmerOpacity)
|
||
Text(text)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundColor(staticColor)
|
||
.lineLimit(1).truncationMode(.tail)
|
||
.opacity(staticOpacity)
|
||
}
|
||
}
|
||
.frame(height: 22, alignment: .leading)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct TickerShimmerText: View {
|
||
let text: String
|
||
|
||
var body: some View {
|
||
TimelineView(.animation) { tl in
|
||
let t = tl.date.timeIntervalSinceReferenceDate
|
||
let p = CGFloat(t.truncatingRemainder(dividingBy: 2.2) / 2.2)
|
||
// phase sweeps -0.1 → 1.1 so white peak enters from left and exits right
|
||
let phase = p * 1.2 - 0.1
|
||
Text(text)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.foregroundStyle(LinearGradient(stops: [
|
||
.init(color: Color(hex: "#7c818a"), location: max(0, phase - 0.3)),
|
||
.init(color: Color(hex: "#F2F3F5"), location: max(0, min(1, phase))),
|
||
.init(color: Color(hex: "#7c818a"), location: min(1, phase + 0.3)),
|
||
], startPoint: .leading, endPoint: .trailing))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Agent pills (overview right card)
|
||
|
||
struct AgentPillsView: View {
|
||
@ObservedObject var state: AppState
|
||
@State private var swapping = false
|
||
|
||
private var others: [AgentTask] {
|
||
state.tasks.filter { $0.id != state.focusId }
|
||
}
|
||
|
||
private var displayTasks: [AgentTask] {
|
||
Array(others.prefix(4))
|
||
}
|
||
|
||
private let columns = [
|
||
GridItem(.flexible(), spacing: 4),
|
||
GridItem(.flexible(), spacing: 4)
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
Spacer(minLength: 0)
|
||
LazyVGrid(columns: columns, spacing: 4) {
|
||
ForEach(displayTasks) { task in
|
||
#if !APPSTORE
|
||
if task.id == "integration_music" {
|
||
MusicPill(task: task, state: state, swapping: $swapping) {
|
||
swapping = true
|
||
state.setFocus(task.id)
|
||
SoundEngine.shared.play("blip")
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false }
|
||
}
|
||
} else {
|
||
AgentPill(task: task, state: state, swapping: $swapping) {
|
||
swapping = true
|
||
state.setFocus(task.id)
|
||
SoundEngine.shared.play("blip")
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false }
|
||
}
|
||
}
|
||
#else
|
||
AgentPill(task: task, state: state, swapping: $swapping) {
|
||
swapping = true
|
||
state.setFocus(task.id)
|
||
SoundEngine.shared.play("blip")
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false }
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
.padding(.horizontal, 8)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
|
||
struct AgentPill: View {
|
||
let task: AgentTask
|
||
@ObservedObject var state: AppState
|
||
@Binding var swapping: Bool
|
||
let onTap: () -> Void
|
||
@State private var isHovered = false
|
||
|
||
private var effectiveColor: String { task.color }
|
||
|
||
// VS Code pill always shows "VS Code" label regardless of active project name
|
||
private var displayName: String {
|
||
task.id == "integration_claude" ? "VS Code" : task.name
|
||
}
|
||
|
||
var body: some View {
|
||
Button(action: { onTap() }) {
|
||
ZStack(alignment: .topTrailing) {
|
||
ZStack {
|
||
Capsule()
|
||
.fill(isHovered
|
||
? Color(hex: effectiveColor).opacity(0.18)
|
||
: Color(hex: "#0E0F11"))
|
||
Capsule()
|
||
.stroke(Color(hex: effectiveColor).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1)
|
||
HStack(spacing: 0) {
|
||
MiniBotCanvasView(task: task)
|
||
.frame(width: 22 / 0.6, height: 22 / 0.6)
|
||
.frame(width: 22, height: 22, alignment: .center)
|
||
.padding(.leading, 8)
|
||
Spacer()
|
||
}
|
||
Text(displayName)
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(isHovered
|
||
? Color(hex: effectiveColor).lighter(by: 0.3)
|
||
: Color(hex: "#6B7079"))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 28)
|
||
.shadow(color: Color(hex: effectiveColor).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2)
|
||
|
||
// Alert badge (approval / finished / error)
|
||
if let badge = task.pillBadge {
|
||
PillBadgeView(badge: badge, taskColor: effectiveColor)
|
||
.offset(x: 3, y: -3)
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.scaleEffect(isHovered ? 1.04 : 1.0)
|
||
.brightness(isHovered ? 0.06 : 0)
|
||
.onHover { newHover in
|
||
guard !swapping else { return }
|
||
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Music Pill (GitHub build only)
|
||
|
||
#if !APPSTORE
|
||
struct MusicPill: View {
|
||
let task: AgentTask
|
||
@ObservedObject var state: AppState
|
||
@Binding var swapping: Bool
|
||
let onTap: () -> Void
|
||
@State private var isHovered = false
|
||
|
||
private var isPlaying: Bool { AppState.shared.musicPlaying }
|
||
private var showControls: Bool { isHovered && MusicController.shared.trackTitle != nil }
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
// Selection target — full pill area, receives taps where controls don't
|
||
Capsule()
|
||
.fill(Color.clear)
|
||
.contentShape(Capsule())
|
||
.onTapGesture { onTap() }
|
||
|
||
// Visual fills
|
||
Capsule()
|
||
.fill(isHovered ? Color(hex: task.color).opacity(0.18) : Color(hex: "#0E0F11"))
|
||
.allowsHitTesting(false)
|
||
Capsule()
|
||
.stroke(Color(hex: task.color).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1)
|
||
.allowsHitTesting(false)
|
||
|
||
// Mini Mochi at leading edge
|
||
HStack(spacing: 0) {
|
||
MiniBotCanvasView(task: task, isDancing: isPlaying)
|
||
.frame(width: 22 / 0.6, height: 22 / 0.6)
|
||
.frame(width: 22, height: 22, alignment: .center)
|
||
.padding(.leading, 8)
|
||
Spacer()
|
||
}
|
||
.allowsHitTesting(false)
|
||
|
||
// Title — trailing padding grows on hover to make room for buttons
|
||
Text(task.name)
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(isHovered ? Color(hex: task.color).lighter(by: 0.3) : Color(hex: "#6B7079"))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.padding(.leading, 34)
|
||
.padding(.trailing, showControls ? 52 : 10)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: showControls)
|
||
.allowsHitTesting(false)
|
||
|
||
// Playback controls — appear on hover when a track is loaded
|
||
if showControls {
|
||
HStack(spacing: 0) {
|
||
Spacer()
|
||
HStack(spacing: 2) {
|
||
MusicControlButton(icon: isPlaying ? "pause.fill" : "play.fill", color: task.color) {
|
||
MusicController.shared.playPause()
|
||
}
|
||
MusicControlButton(icon: "forward.fill", color: task.color) {
|
||
MusicController.shared.nextTrack()
|
||
}
|
||
}
|
||
.padding(.trailing, 4)
|
||
}
|
||
.transition(.opacity.combined(with: .scale(scale: 0.85, anchor: .trailing)))
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 28)
|
||
.shadow(color: Color(hex: task.color).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2)
|
||
.scaleEffect(isHovered ? 1.04 : 1.0)
|
||
.brightness(isHovered ? 0.06 : 0)
|
||
.onHover { newHover in
|
||
guard !swapping else { return }
|
||
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover }
|
||
}
|
||
}
|
||
}
|
||
|
||
struct MusicControlButton: View {
|
||
let icon: String
|
||
let color: String
|
||
let action: () -> Void
|
||
@State private var isHovered = false
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(isHovered ? Color(hex: color).opacity(0.18) : Color(hex: "#0E0F11"))
|
||
Circle()
|
||
.stroke(Color(hex: color).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1)
|
||
Image(systemName: icon)
|
||
.font(.system(size: 8, weight: .semibold))
|
||
.foregroundColor(isHovered ? Color(hex: color).lighter(by: 0.3) : Color(hex: "#6B7079"))
|
||
}
|
||
.frame(width: 20, height: 20)
|
||
.shadow(color: Color(hex: color).opacity(isHovered ? 0.35 : 0), radius: 6)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.scaleEffect(isHovered ? 1.1 : 1.0)
|
||
.onHover { newHover in
|
||
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover }
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Music Card View (GitHub build only)
|
||
|
||
#if !APPSTORE
|
||
struct MusicCardView: View {
|
||
@ObservedObject private var controller = MusicController.shared
|
||
@ObservedObject private var appState = AppState.shared
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
if appState.musicAutomationDenied {
|
||
// Automation denied — prompt user to fix
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(hex: "#F4505E"))
|
||
.frame(width: 7, height: 7)
|
||
Text("Apple Music")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
Spacer(minLength: 2)
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 36)
|
||
|
||
Text("Разреши BroV управлять Музыкой")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
|
||
Button("Открыть настройки…") { MusicController.shared.openAutomationSettings() }
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(Color(hex: "#FA2D48").opacity(0.85))
|
||
.buttonStyle(.plain)
|
||
.padding(.leading, 108)
|
||
.padding(.top, 2)
|
||
} else {
|
||
// Line 1: dot + title
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(hex: "#FA2D48"))
|
||
.frame(width: 7, height: 7)
|
||
if let title = controller.trackTitle {
|
||
Text(title)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
.frame(maxWidth: 150, alignment: .leading)
|
||
}
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.leading, 108)
|
||
|
||
// Line 2: artist
|
||
if let artist = controller.artist {
|
||
Text(artist)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.lineLimit(1).truncationMode(.tail)
|
||
.frame(maxWidth: 150, alignment: .leading)
|
||
.padding(.leading, 108)
|
||
}
|
||
|
||
// Line 3: controls
|
||
HStack(spacing: 8) {
|
||
Button(action: { MusicController.shared.previousTrack() }) {
|
||
Image(systemName: "backward.fill")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
.buttonStyle(.plain)
|
||
Button(action: { MusicController.shared.playPause() }) {
|
||
Image(systemName: appState.musicPlaying ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#FA2D48"))
|
||
}
|
||
.buttonStyle(.plain)
|
||
Button(action: { MusicController.shared.nextTrack() }) {
|
||
Image(systemName: "forward.fill")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.leading, 108)
|
||
.padding(.top, 6)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||
.padding(.top, 4)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
struct PillBadgeView: View {
|
||
let badge: PillBadge
|
||
let taskColor: String
|
||
|
||
private var badgeColor: Color {
|
||
switch badge {
|
||
case .approval: return Color(hex: "#F5A524")
|
||
case .finished: return Color(hex: "#22C55E")
|
||
case .error: return Color(hex: "#F4505E")
|
||
}
|
||
}
|
||
|
||
private var icon: String {
|
||
switch badge {
|
||
case .approval: return "exclamationmark"
|
||
case .finished: return "checkmark"
|
||
case .error: return "xmark"
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color(hex: "#0B0C0E"))
|
||
.frame(width: 14, height: 14)
|
||
Circle()
|
||
.fill(badgeColor)
|
||
.frame(width: 12, height: 12)
|
||
Image(systemName: icon)
|
||
.font(.system(size: 6, weight: .bold))
|
||
.foregroundColor(.black)
|
||
}
|
||
.shadow(color: badgeColor.opacity(0.6), radius: 4, x: 0, y: 0)
|
||
}
|
||
}
|
||
|
||
// MARK: - Column agents (right side of non-overview views)
|
||
|
||
struct ColumnAgentsView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
// BroV: the VS Code container pill (integration_claude) stays internal, never shown.
|
||
var others: [AgentTask] {
|
||
state.tasks.filter { $0.id != state.focusId && $0.id != "integration_claude" }
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
ForEach(Array(others.prefix(4).enumerated()), id: \.1.id) { idx, task in
|
||
MiniBotCanvasView(task: task)
|
||
.frame(width: 16 / 0.6, height: 16 / 0.6)
|
||
.frame(width: 16, height: 16)
|
||
.position(x: 0, y: CGFloat(50 + idx * 24))
|
||
.animation(.spring(response: 0.5, dampingFraction: 0.72).delay(Double(idx) * 0.035), value: idx)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Wardrobe
|
||
|
||
struct WardrobeView: View {
|
||
@ObservedObject var state: AppState
|
||
@State private var hoveredOutfit: Outfit? = nil
|
||
|
||
private let columns = Array(repeating: GridItem(.fixed(30), spacing: 5), count: 14)
|
||
|
||
private var headerRight: String {
|
||
// Hover takes priority: show hovered outfit name
|
||
if let h = hoveredOutfit {
|
||
if h == .auto {
|
||
let seasonal = Outfit.seasonal(for: Date(), calendar: .current)
|
||
let name = seasonal == .none ? "Нет" : seasonal.displayName
|
||
return "Авто · по сезону (сейчас: \(name))"
|
||
}
|
||
return h.displayName
|
||
}
|
||
// Fall back to current selection
|
||
let sel = state.mochiOutfitSelection
|
||
if sel == .auto {
|
||
let seasonal = Outfit.seasonal(for: Date(), calendar: .current)
|
||
let name = seasonal == .none ? "Нет" : seasonal.displayName
|
||
return "Авто · \(name)"
|
||
}
|
||
return sel.displayName
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 6) {
|
||
Text("Гардероб")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
Spacer(minLength: 4)
|
||
Text(headerRight)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.lineLimit(1)
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.horizontal, 10)
|
||
|
||
// Grid
|
||
let allOutfits = Outfit.allCases.filter { $0 != .auto }
|
||
let withAuto = [Outfit.auto] + allOutfits
|
||
ScrollView(.vertical, showsIndicators: false) {
|
||
LazyVGrid(columns: columns, spacing: 5) {
|
||
ForEach(withAuto, id: \.rawValue) { outfit in
|
||
OutfitPillView(
|
||
outfit: outfit,
|
||
isSelected: state.mochiOutfitSelection == outfit,
|
||
isHovered: hoveredOutfit == outfit,
|
||
onHover: { h in
|
||
hoveredOutfit = h ? outfit : nil
|
||
if h {
|
||
// Preview on main Mochi
|
||
let preview: Outfit = outfit == .auto
|
||
? Outfit.seasonal(for: Date(), calendar: .current)
|
||
: outfit
|
||
state.wardrobePreviewOutfit = preview
|
||
} else if hoveredOutfit == nil {
|
||
state.wardrobePreviewOutfit = nil
|
||
}
|
||
},
|
||
onTap: {
|
||
guard state.mochiOutfitSelection != outfit else { return }
|
||
state.mochiOutfitSelection = outfit
|
||
SoundEngine.shared.play("pop")
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.proud)
|
||
},
|
||
seasonalOutfit: outfit == .auto ? state.resolvedOutfit : .none
|
||
)
|
||
}
|
||
}
|
||
.padding(.vertical, 6)
|
||
.padding(.horizontal, 8)
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
.mask(
|
||
LinearGradient(
|
||
stops: [
|
||
.init(color: .white, location: 0),
|
||
.init(color: .white, location: 0.85),
|
||
.init(color: .clear, location: 1)
|
||
],
|
||
startPoint: .top, endPoint: .bottom
|
||
)
|
||
)
|
||
}
|
||
.padding(.leading, 108)
|
||
.padding(.trailing, 12)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.onDisappear {
|
||
state.wardrobePreviewOutfit = nil
|
||
hoveredOutfit = nil
|
||
}
|
||
.onExitCommand {
|
||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||
state.view = .overview
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct OutfitPillView: View {
|
||
let outfit: Outfit
|
||
let isSelected: Bool
|
||
let isHovered: Bool
|
||
let onHover: (Bool) -> Void
|
||
let onTap: () -> Void
|
||
var seasonalOutfit: Outfit = .none
|
||
|
||
var body: some View {
|
||
Canvas { context, size in
|
||
drawOutfitIcon(context: context, size: size, outfit: outfit, seasonal: seasonalOutfit)
|
||
}
|
||
.frame(width: 30, height: 30)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 7)
|
||
.fill(isHovered ? Color.white.opacity(0.10) : Color.white.opacity(0.06))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 7)
|
||
.stroke(isSelected ? Color.white.opacity(0.40) : Color.white.opacity(0.08), lineWidth: 1)
|
||
)
|
||
.onHover { onHover($0) }
|
||
.onTapGesture { onTap() }
|
||
}
|
||
}
|
||
|
||
private func drawOutfitIcon(context: GraphicsContext, size: CGSize, outfit: Outfit, seasonal: Outfit = .none) {
|
||
let W = size.width, H = size.height
|
||
let cx = W / 2, cy = H / 2
|
||
let R: CGFloat = 6.5 // small scale for icon
|
||
|
||
switch outfit {
|
||
case .auto:
|
||
let iconR: CGFloat = 10.0
|
||
let rx = iconR * 1.14, ry = iconR * 0.88
|
||
let mH = MochiH(R: iconR, yaw: 0, pitch: 0)
|
||
let bodyPath = mochiOutfitPath(rx, ry)
|
||
let iconCY = cy + iconR * 0.62
|
||
|
||
// Draw the seasonal outfit behind body
|
||
if seasonal != .none && seasonal != .auto {
|
||
drawOutfitBehindStatic(context: context, outfit: seasonal, H: mH,
|
||
cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1,
|
||
roll: 0, morph: 0, isMini: false)
|
||
}
|
||
// Body
|
||
var ctx = context
|
||
ctx.translateBy(x: cx, y: iconCY)
|
||
ctx.fill(bodyPath, with: .linearGradient(
|
||
Gradient(colors: [Color(red: 0.929, green: 0.929, blue: 0.937),
|
||
Color(red: 0.769, green: 0.773, blue: 0.792)]),
|
||
startPoint: CGPoint(x: rx*0.7, y: -ry*0.85),
|
||
endPoint: CGPoint(x: -rx*0.8, y: ry*0.9)
|
||
))
|
||
ctx.fill(bodyPath, with: .radialGradient(
|
||
Gradient(stops: [.init(color: .clear, location: 0.6),
|
||
.init(color: Color.black.opacity(0.2), location: 1)]),
|
||
center: .zero, startRadius: iconR*0.15, endRadius: iconR*1.25
|
||
))
|
||
// Eyes
|
||
var eyeCtx = ctx; eyeCtx.clip(to: bodyPath)
|
||
let ink = Color(red: 0.102, green: 0.082, blue: 0.071)
|
||
for f in mEyeFrames(mH) {
|
||
guard f.visible else { continue }
|
||
var ec = eyeCtx; ec.translateBy(x: f.x, y: f.y); ec.scaleBy(x: f.fx, y: f.fy)
|
||
let hh = max(f.h, f.w*0.3)
|
||
var pill = Path()
|
||
pill.addRoundedRect(in: CGRect(x: -f.w/2, y: -hh/2, width: f.w, height: hh),
|
||
cornerSize: CGSize(width: min(f.w/2,hh/2), height: min(f.w/2,hh/2)))
|
||
ec.fill(pill, with: .color(ink))
|
||
}
|
||
// Front outfit
|
||
if seasonal != .none && seasonal != .auto {
|
||
drawOutfitFrontStatic(context: context, outfit: seasonal, H: mH,
|
||
cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1,
|
||
roll: 0, morph: 0, isMini: false)
|
||
}
|
||
// AUTO badge at bottom
|
||
var badgeCtx = context
|
||
let badgeCY = iconCY + ry * 0.72
|
||
badgeCtx.translateBy(x: cx, y: badgeCY)
|
||
let bw: CGFloat = 14, bh: CGFloat = 6.5
|
||
var badge = Path()
|
||
badge.addRoundedRect(in: CGRect(x: -bw/2, y: -bh/2, width: bw, height: bh),
|
||
cornerSize: CGSize(width: bh/2, height: bh/2))
|
||
badgeCtx.fill(badge, with: .color(Color.black.opacity(0.60)))
|
||
badgeCtx.draw(Text("АВТО").font(.system(size: 4.2, weight: .semibold)).foregroundColor(.white),
|
||
at: .zero)
|
||
|
||
case .none:
|
||
var ctx = context
|
||
ctx.translateBy(x: cx, y: cy)
|
||
// Circle with diagonal slash (⊘)
|
||
var circle = Path()
|
||
circle.addEllipse(in: CGRect(x: -R * 0.82, y: -R * 0.82, width: R * 1.64, height: R * 1.64))
|
||
ctx.stroke(circle, with: .color(Color(hex: "#454850")), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
|
||
var slash = Path()
|
||
slash.move(to: CGPoint(x: -R * 0.56, y: R * 0.56))
|
||
slash.addLine(to: CGPoint(x: R * 0.56, y: -R * 0.56))
|
||
ctx.stroke(slash, with: .color(Color(hex: "#454850")), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
|
||
|
||
default:
|
||
// Small Mochi wearing the outfit
|
||
let iconR: CGFloat = 10.0
|
||
let rx = iconR * 1.14, ry = iconR * 0.88
|
||
let mH = MochiH(R: iconR, yaw: 0, pitch: 0)
|
||
let bodyPath = mochiOutfitPath(rx, ry)
|
||
let iconCY = cy + iconR * 0.62
|
||
|
||
// Draw outfit behind
|
||
drawOutfitBehindStatic(context: context, outfit: outfit, H: mH,
|
||
cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1,
|
||
roll: 0, morph: 0, isMini: false)
|
||
|
||
// Draw body
|
||
var ctx = context
|
||
ctx.translateBy(x: cx, y: iconCY)
|
||
let pumpkin = outfit == .pumpkin
|
||
let top = pumpkin ? Color(hex: "#FFA94D") : Color(red: 0.929, green: 0.929, blue: 0.937)
|
||
let bot = pumpkin ? Color(hex: "#E8590C") : Color(red: 0.769, green: 0.773, blue: 0.792)
|
||
ctx.fill(bodyPath, with: .linearGradient(
|
||
Gradient(colors: [top, bot]),
|
||
startPoint: CGPoint(x: rx * 0.7, y: -ry * 0.85),
|
||
endPoint: CGPoint(x: -rx * 0.8, y: ry * 0.9)
|
||
))
|
||
ctx.fill(bodyPath, with: .radialGradient(
|
||
Gradient(stops: [
|
||
.init(color: .clear, location: 0),
|
||
.init(color: .clear, location: 0.6),
|
||
.init(color: Color.black.opacity(0.2), location: 1)
|
||
]),
|
||
center: .zero, startRadius: iconR * 0.15, endRadius: iconR * 1.25
|
||
))
|
||
ctx.fill(bodyPath, with: .radialGradient(
|
||
Gradient(stops: [
|
||
.init(color: Color.white.opacity(0.55), location: 0),
|
||
.init(color: .clear, location: 1)
|
||
]),
|
||
center: CGPoint(x: rx * 0.34, y: -ry * 0.46),
|
||
startRadius: 0, endRadius: iconR * 0.42
|
||
))
|
||
|
||
// Draw eyes
|
||
var eyeCtx = ctx
|
||
eyeCtx.clip(to: bodyPath)
|
||
let ink = Color(red: 0.102, green: 0.082, blue: 0.071)
|
||
for f in mEyeFrames(mH) {
|
||
guard f.visible else { continue }
|
||
var ec = eyeCtx
|
||
ec.translateBy(x: f.x, y: f.y)
|
||
ec.scaleBy(x: f.fx, y: f.fy)
|
||
let hh = max(f.h, f.w * 0.3)
|
||
var pill = Path()
|
||
pill.addRoundedRect(
|
||
in: CGRect(x: -f.w / 2, y: -hh / 2, width: f.w, height: hh),
|
||
cornerSize: CGSize(width: min(f.w / 2, hh / 2), height: min(f.w / 2, hh / 2))
|
||
)
|
||
ec.fill(pill, with: .color(ink))
|
||
}
|
||
|
||
// Draw outfit front
|
||
drawOutfitFrontStatic(context: context, outfit: outfit, H: mH,
|
||
cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1,
|
||
roll: 0, morph: 0, isMini: false)
|
||
}
|
||
}
|
||
// MARK: - Card background
|
||
|
||
struct CardBackground<Content: View>: View {
|
||
enum Wash { case red, green, pink, amber, cyan, indigo, soft }
|
||
|
||
let wash: Wash?
|
||
let content: (() -> Content)?
|
||
|
||
init(wash: Wash?, @ViewBuilder content: @escaping () -> Content) {
|
||
self.wash = wash
|
||
self.content = content
|
||
}
|
||
|
||
var washColor: Color {
|
||
switch wash {
|
||
case .red: return Color(hex: "#F4505E").opacity(0.55)
|
||
case .green: return Color(hex: "#34D399").opacity(0.5)
|
||
case .pink: return Color(hex: "#F472B6").opacity(0.55)
|
||
case .amber: return Color(hex: "#F5A524").opacity(0.42)
|
||
case .cyan: return Color(hex: "#22D3EE").opacity(0.38)
|
||
case .indigo: return Color(hex: "#6366F1").opacity(0.5)
|
||
case .soft: return Color.white.opacity(0.08)
|
||
case nil: return Color.clear
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.fill(Color(hex: "#141518"))
|
||
.overlay(
|
||
RadialGradient(
|
||
gradient: Gradient(stops: [
|
||
.init(color: washColor, location: 0),
|
||
.init(color: .clear, location: 0.7)
|
||
]),
|
||
center: UnitPoint(x: 0.5, y: 1.3),
|
||
startRadius: 0,
|
||
endRadius: 280
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.stroke(Color.white.opacity(0.035), lineWidth: 1)
|
||
)
|
||
|
||
if let content = content {
|
||
content()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
extension CardBackground where Content == EmptyView {
|
||
init(wash: Wash?) {
|
||
self.wash = wash
|
||
self.content = nil
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.fill(Color(hex: "#141518"))
|
||
.overlay(
|
||
RadialGradient(
|
||
gradient: Gradient(stops: [
|
||
.init(color: washColor, location: 0),
|
||
.init(color: .clear, location: 0.7)
|
||
]),
|
||
center: UnitPoint(x: 0.5, y: 1.3),
|
||
startRadius: 0,
|
||
endRadius: 280
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.stroke(Color.white.opacity(0.035), lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Shared sub-components
|
||
|
||
struct AgentWho: View {
|
||
let task: AgentTask?
|
||
let label: String
|
||
|
||
var body: some View {
|
||
HStack(spacing: 7) {
|
||
if let task = task {
|
||
Circle().fill(Color(hex: task.color)).frame(width: 8, height: 8)
|
||
Text(task.name).font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8"))
|
||
}
|
||
Text(label).font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
}
|
||
}
|
||
|
||
struct CodeBlock: View {
|
||
let text: String
|
||
|
||
var body: some View {
|
||
Text(text)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.padding(.horizontal, 10).padding(.vertical, 5)
|
||
.background(Color.white.opacity(0.07))
|
||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06)))
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
.foregroundColor(Color(hex: "#E8E9EC"))
|
||
}
|
||
}
|
||
|
||
struct ContextChip: View {
|
||
let context: PromptContext
|
||
@State private var glowing = false
|
||
|
||
var label: String {
|
||
switch context {
|
||
case .window(let app, _, let url):
|
||
if let url = url, let host = URL(string: url)?.host { return "\(app) · \(host)" }
|
||
return app
|
||
case .file(let name, _): return name
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(LinearGradient(colors: [Color(hex: "#FF6B5B"), Color(hex: "#F7B32B"), Color(hex: "#2DD4A7"), Color(hex: "#38BDF8"), Color(hex: "#A78BFA")], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||
.frame(width: 7, height: 7)
|
||
Text(label)
|
||
.font(.system(size: 11.5))
|
||
.foregroundColor(Color(hex: "#F1F2F4"))
|
||
}
|
||
.padding(.horizontal, 10).padding(.vertical, 4)
|
||
.background(Color.white.opacity(0.1))
|
||
.clipShape(Capsule())
|
||
.overlay(Capsule().stroke(Color.white.opacity(glowing ? 0.75 : 0), lineWidth: 1.5))
|
||
.scaleEffect(glowing ? 1.06 : 1.0)
|
||
.onAppear {
|
||
withAnimation(.spring(response: 0.25, dampingFraction: 0.55)) { glowing = true }
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
||
withAnimation(.easeOut(duration: 0.3)) { glowing = false }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct MailField: View {
|
||
let label: String
|
||
let placeholder: String
|
||
@Binding var text: String
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
Text(label)
|
||
.font(.system(size: 12.5))
|
||
.foregroundColor(Color(hex: "#80858E"))
|
||
.frame(width: 44, alignment: .leading)
|
||
TextField(placeholder, text: $text)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 12.5))
|
||
.foregroundColor(Color(hex: "#F5F6F8"))
|
||
}
|
||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||
.background(Color.white.opacity(0.06))
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
}
|
||
|
||
struct ShimmeringText: View {
|
||
let text: String
|
||
init(_ text: String) { self.text = text }
|
||
|
||
var body: some View {
|
||
Text(text)
|
||
.foregroundStyle(
|
||
LinearGradient(
|
||
stops: [
|
||
.init(color: Color(hex: "#7c818a"), location: 0),
|
||
.init(color: .white, location: 0.4),
|
||
.init(color: Color(hex: "#7c818a"), location: 0.7)
|
||
],
|
||
startPoint: .leading,
|
||
endPoint: .trailing
|
||
)
|
||
)
|
||
}
|
||
}
|
||
|
||
struct ShimmerOverlay: View {
|
||
@State private var phase: CGFloat = 0.0
|
||
|
||
var body: some View {
|
||
LinearGradient(
|
||
stops: [
|
||
// Clamp all locations to [0,1] and keep them ordered
|
||
.init(color: .clear, location: max(0, phase - 0.3)),
|
||
.init(color: Color.white.opacity(0.6), location: max(0, min(1, phase))),
|
||
.init(color: .clear, location: min(1, phase + 0.3))
|
||
],
|
||
startPoint: .leading, endPoint: .trailing
|
||
)
|
||
.blendMode(.overlay)
|
||
.onAppear {
|
||
withAnimation(.linear(duration: 2.2).repeatForever(autoreverses: false)) {
|
||
phase = 1.3 // travels left→right, exits right edge cleanly
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Button styles
|
||
|
||
struct PrimaryButton: View {
|
||
let title: String
|
||
let kbd: String?
|
||
let action: () -> Void
|
||
|
||
init(_ title: String, kbd: String? = nil, action: @escaping () -> Void) {
|
||
self.title = title; self.kbd = kbd; self.action = action
|
||
}
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 7) {
|
||
Text(title).font(.system(size: 12.5, weight: .medium))
|
||
if let k = kbd {
|
||
Text(k).font(.system(size: 10.5))
|
||
.padding(.horizontal, 4)
|
||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.black.opacity(0.4)))
|
||
.opacity(0.55)
|
||
}
|
||
}
|
||
.padding(.horizontal, 13).padding(.vertical, 7)
|
||
.background(Color(hex: "#F5F6F8"))
|
||
.foregroundColor(Color(hex: "#0B0C0E"))
|
||
.clipShape(Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
struct SecondaryButton: View {
|
||
let title: String
|
||
let kbd: String?
|
||
let action: () -> Void
|
||
|
||
init(_ title: String, kbd: String? = nil, action: @escaping () -> Void) {
|
||
self.title = title; self.kbd = kbd; self.action = action
|
||
}
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 7) {
|
||
Text(title).font(.system(size: 12.5, weight: .medium))
|
||
if let k = kbd {
|
||
Text(k).font(.system(size: 10.5))
|
||
.padding(.horizontal, 4)
|
||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.white.opacity(0.4)))
|
||
.opacity(0.55)
|
||
}
|
||
}
|
||
.padding(.horizontal, 13).padding(.vertical, 7)
|
||
.background(Color.white.opacity(0.09))
|
||
.foregroundColor(Color(hex: "#F1F2F4"))
|
||
.clipShape(Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
struct IconButtonStyle: ButtonStyle {
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.frame(width: 28, height: 28)
|
||
.background(Color.white.opacity(0.08))
|
||
.clipShape(Circle())
|
||
.scaleEffect(configuration.isPressed ? 0.94 : 1)
|
||
}
|
||
}
|
||
|
||
struct SendButtonStyle: ButtonStyle {
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.frame(width: 28, height: 28)
|
||
.background(Color(hex: "#F5F6F8"))
|
||
.clipShape(Circle())
|
||
.scaleEffect(configuration.isPressed ? 0.94 : 1)
|
||
}
|
||
}
|
||
|
||
// MARK: - Settings island view (Point 7)
|
||
|
||
struct SettingsIslandView: View {
|
||
@ObservedObject var state: AppState
|
||
|
||
private var claudeConnected: Bool {
|
||
let url = FileManager.default.homeDirectoryForCurrentUser
|
||
.appendingPathComponent(".claude/settings.json")
|
||
guard let data = try? Data(contentsOf: url),
|
||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
let hooks = json["hooks"] as? [String: Any],
|
||
let ss = hooks["SessionStart"] as? [[String: Any]] else { return false }
|
||
return ss.contains { matcher in
|
||
(matcher["hooks"] as? [[String: Any]])?.contains {
|
||
($0["command"] as? String)?.contains("NotchBuddy") == true
|
||
} ?? false
|
||
}
|
||
}
|
||
|
||
private var apiConnected: Bool {
|
||
KeychainStore.shared.get("anthropic-api-key") != nil
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .leading) {
|
||
CardBackground(wash: nil)
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
// Sound row
|
||
HStack(spacing: 10) {
|
||
Toggle("", isOn: $state.soundEnabled)
|
||
.toggleStyle(.switch)
|
||
.labelsHidden()
|
||
.scaleEffect(0.75)
|
||
.frame(width: 44)
|
||
Text("Звук")
|
||
.font(.system(size: 12.5))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
Slider(value: $state.soundVolume, in: 0...0.2)
|
||
.frame(width: 72)
|
||
.opacity(state.soundEnabled ? 1 : 0.4)
|
||
}
|
||
|
||
// Auto-close row
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "timer")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.frame(width: 16)
|
||
Text("Автозакрытие · \(Int(state.autoCloseInterval)) с")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(Color(hex: "#C5C8CD"))
|
||
Spacer()
|
||
HStack(spacing: 6) {
|
||
ForEach([10, 15, 30], id: \.self) { s in
|
||
Button("\(s) с") {
|
||
state.autoCloseInterval = Double(s)
|
||
}
|
||
.font(.system(size: 11))
|
||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||
.background(state.autoCloseInterval == Double(s) ? Color(hex: "#252830") : Color.clear)
|
||
.foregroundColor(state.autoCloseInterval == Double(s) ? Color(hex: "#F5F6F8") : Color(hex: "#6B7079"))
|
||
.clipShape(Capsule())
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Connection status
|
||
HStack(spacing: 14) {
|
||
StatusBadge(label: "Claude Code", ok: claudeConnected)
|
||
StatusBadge(label: "API", ok: apiConnected)
|
||
Spacer()
|
||
Button("Настройки…") {
|
||
NotificationCenter.default.post(name: .openFullSettings, object: nil)
|
||
}
|
||
.font(.system(size: 11.5))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.leading, 84)
|
||
.padding(.trailing, 16)
|
||
.padding(.vertical, 14)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct StatusBadge: View {
|
||
let label: String
|
||
let ok: Bool
|
||
|
||
var body: some View {
|
||
HStack(spacing: 4) {
|
||
Circle()
|
||
.fill(ok ? Color(hex: "#22C55E") : Color(hex: "#F4505E"))
|
||
.frame(width: 6, height: 6)
|
||
Text(label)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(Color(hex: "#8E939C"))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Color extension (lighten)
|
||
|
||
// MARK: - Chat-provider switch (used by AI pill buttons and ↗ action)
|
||
|
||
/// Mirrors ModelPickerView provider-chip tap: animates, fires surprised emote + "pop" sound,
|
||
/// then opens the chat view. No-op if provider is already selected (just opens chat).
|
||
@MainActor
|
||
func switchChatProvider(_ provider: ChatProvider) {
|
||
let state = AppState.shared
|
||
if provider != state.chatProvider {
|
||
withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) {
|
||
state.chatProvider = provider
|
||
}
|
||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised)
|
||
SoundEngine.shared.play("pop")
|
||
}
|
||
state.view = .prompt
|
||
}
|
||
|
||
extension Color {
|
||
func lighter(by amount: Double) -> Color {
|
||
guard let components = NSColor(self).usingColorSpace(.sRGB) else { return self }
|
||
return Color(
|
||
red: min(1, Double(components.redComponent) + amount),
|
||
green: min(1, Double(components.greenComponent) + amount),
|
||
blue: min(1, Double(components.blueComponent) + amount)
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Russian plural form picker: 1 вклад, 2 вклада, 5 вкладов.
|
||
fileprivate func ruPlural(_ n: Int, _ one: String, _ few: String, _ many: String) -> String {
|
||
let n10 = abs(n) % 10, n100 = abs(n) % 100
|
||
if n10 == 1 && n100 != 11 { return one }
|
||
if (2...4).contains(n10) && !(12...14).contains(n100) { return few }
|
||
return many
|
||
}
|