762 lines
28 KiB
Swift
762 lines
28 KiB
Swift
//
|
|
// CompanionPanelView.swift
|
|
// leanring-buddy
|
|
//
|
|
// The SwiftUI content hosted inside the menu bar panel. Shows the companion
|
|
// voice status, push-to-talk shortcut, and quick settings. Designed to feel
|
|
// like Loom's recording panel — dark, rounded, minimal, and special.
|
|
//
|
|
|
|
import AVFoundation
|
|
import SwiftUI
|
|
|
|
struct CompanionPanelView: View {
|
|
@ObservedObject var companionManager: CompanionManager
|
|
@State private var emailInput: String = ""
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
panelHeader
|
|
Divider()
|
|
.background(DS.Colors.borderSubtle)
|
|
.padding(.horizontal, 16)
|
|
|
|
permissionsCopySection
|
|
.padding(.top, 16)
|
|
.padding(.horizontal, 16)
|
|
|
|
if companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted {
|
|
Spacer()
|
|
.frame(height: 12)
|
|
|
|
modelPickerRow
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
if !companionManager.allPermissionsGranted {
|
|
Spacer()
|
|
.frame(height: 16)
|
|
|
|
settingsSection
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
if !companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted {
|
|
Spacer()
|
|
.frame(height: 16)
|
|
|
|
startButton
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
// Show Clicky toggle — hidden for now
|
|
// if companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted {
|
|
// Spacer()
|
|
// .frame(height: 16)
|
|
//
|
|
// showClickyCursorToggleRow
|
|
// .padding(.horizontal, 16)
|
|
// }
|
|
|
|
if companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted {
|
|
Spacer()
|
|
.frame(height: 16)
|
|
|
|
dmFarzaButton
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
Spacer()
|
|
.frame(height: 12)
|
|
|
|
Divider()
|
|
.background(DS.Colors.borderSubtle)
|
|
.padding(.horizontal, 16)
|
|
|
|
footerSection
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.frame(width: 320)
|
|
.background(panelBackground)
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var panelHeader: some View {
|
|
HStack {
|
|
HStack(spacing: 8) {
|
|
// Animated status dot
|
|
Circle()
|
|
.fill(statusDotColor)
|
|
.frame(width: 8, height: 8)
|
|
.shadow(color: statusDotColor.opacity(0.6), radius: 4)
|
|
|
|
Text("Clicky")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(DS.Colors.textPrimary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(statusText)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
|
|
Button(action: {
|
|
NotificationCenter.default.post(name: .clickyDismissPanel, object: nil)
|
|
}) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
.frame(width: 20, height: 20)
|
|
.background(
|
|
Circle()
|
|
.fill(Color.white.opacity(0.08))
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
}
|
|
|
|
// MARK: - Permissions Copy
|
|
|
|
@ViewBuilder
|
|
private var permissionsCopySection: some View {
|
|
if companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted {
|
|
Text("Hold Control+Option to talk.")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
} else if companionManager.allPermissionsGranted && !companionManager.hasSubmittedEmail {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Drop your email to get started.")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
Text("If I keep building this, I'll keep you in the loop.")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
} else if companionManager.allPermissionsGranted {
|
|
Text("You're all set. Hit Start to meet Clicky.")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
} else if companionManager.hasCompletedOnboarding {
|
|
// Permissions were revoked after onboarding — tell user to re-grant
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Permissions needed")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
|
|
Text("Some permissions were revoked. Grant all four below to keep using Clicky.")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Hi, I'm Farza. This is Clicky.")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
|
|
Text("A side project I made for fun to help me learn stuff as I use my computer.")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Text("Nothing runs in the background. Clicky will only take a screenshot when you press the hot key. So, you can give that permission in peace. If you are still sus, eh, I can't do much there champ.")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(Color(red: 0.9, green: 0.4, blue: 0.4))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
// MARK: - Email + Start Button
|
|
|
|
@ViewBuilder
|
|
private var startButton: some View {
|
|
if !companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted {
|
|
if !companionManager.hasSubmittedEmail {
|
|
VStack(spacing: 8) {
|
|
TextField("Enter your email", text: $emailInput)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(DS.Colors.textPrimary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: DS.CornerRadius.medium, style: .continuous)
|
|
.fill(Color.white.opacity(0.08))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: DS.CornerRadius.medium, style: .continuous)
|
|
.stroke(DS.Colors.borderSubtle, lineWidth: 0.5)
|
|
)
|
|
|
|
Button(action: {
|
|
companionManager.submitEmail(emailInput)
|
|
}) {
|
|
Text("Submit")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(DS.Colors.textOnAccent)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: DS.CornerRadius.large, style: .continuous)
|
|
.fill(emailInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
? DS.Colors.accent.opacity(0.4)
|
|
: DS.Colors.accent)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
.disabled(emailInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
} else {
|
|
Button(action: {
|
|
companionManager.triggerOnboarding()
|
|
}) {
|
|
Text("Start")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(DS.Colors.textOnAccent)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: DS.CornerRadius.large, style: .continuous)
|
|
.fill(DS.Colors.accent)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Permissions
|
|
|
|
private var settingsSection: some View {
|
|
VStack(spacing: 2) {
|
|
Text("PERMISSIONS")
|
|
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.bottom, 6)
|
|
|
|
microphonePermissionRow
|
|
|
|
accessibilityPermissionRow
|
|
|
|
screenRecordingPermissionRow
|
|
|
|
if companionManager.hasScreenRecordingPermission {
|
|
screenContentPermissionRow
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
private var accessibilityPermissionRow: some View {
|
|
let isGranted = companionManager.hasAccessibilityPermission
|
|
return HStack {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "hand.raised")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(isGranted ? DS.Colors.textTertiary : DS.Colors.warning)
|
|
.frame(width: 16)
|
|
|
|
Text("Accessibility")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isGranted {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(DS.Colors.success)
|
|
.frame(width: 6, height: 6)
|
|
Text("Granted")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(DS.Colors.success)
|
|
}
|
|
} else {
|
|
HStack(spacing: 6) {
|
|
Button(action: {
|
|
// Triggers the system accessibility prompt (AXIsProcessTrustedWithOptions)
|
|
// on first attempt, then opens System Settings on subsequent attempts.
|
|
WindowPositionManager.requestAccessibilityPermission()
|
|
}) {
|
|
Text("Grant")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(DS.Colors.textOnAccent)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(DS.Colors.accent)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
|
|
Button(action: {
|
|
// Reveals the app in Finder so the user can drag it into
|
|
// the Accessibility list if it doesn't appear automatically
|
|
// (common with unsigned dev builds).
|
|
WindowPositionManager.revealAppInFinder()
|
|
WindowPositionManager.openAccessibilitySettings()
|
|
}) {
|
|
Text("Find App")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.stroke(DS.Colors.borderSubtle, lineWidth: 0.8)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private var screenRecordingPermissionRow: some View {
|
|
let isGranted = companionManager.hasScreenRecordingPermission
|
|
return HStack {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "rectangle.dashed.badge.record")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(isGranted ? DS.Colors.textTertiary : DS.Colors.warning)
|
|
.frame(width: 16)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text("Screen Recording")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
|
|
Text(isGranted
|
|
? "Only takes a screenshot when you use the hotkey"
|
|
: "Quit and reopen after granting")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isGranted {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(DS.Colors.success)
|
|
.frame(width: 6, height: 6)
|
|
Text("Granted")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(DS.Colors.success)
|
|
}
|
|
} else {
|
|
Button(action: {
|
|
// Triggers the native macOS screen recording prompt on first
|
|
// attempt (auto-adds app to the list), then opens System Settings
|
|
// on subsequent attempts.
|
|
WindowPositionManager.requestScreenRecordingPermission()
|
|
}) {
|
|
Text("Grant")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(DS.Colors.textOnAccent)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(DS.Colors.accent)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private var screenContentPermissionRow: some View {
|
|
let isGranted = companionManager.hasScreenContentPermission
|
|
return HStack {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "eye")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(isGranted ? DS.Colors.textTertiary : DS.Colors.warning)
|
|
.frame(width: 16)
|
|
|
|
Text("Screen Content")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isGranted {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(DS.Colors.success)
|
|
.frame(width: 6, height: 6)
|
|
Text("Granted")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(DS.Colors.success)
|
|
}
|
|
} else {
|
|
Button(action: {
|
|
companionManager.requestScreenContentPermission()
|
|
}) {
|
|
Text("Grant")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(DS.Colors.textOnAccent)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(DS.Colors.accent)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private var microphonePermissionRow: some View {
|
|
let isGranted = companionManager.hasMicrophonePermission
|
|
return HStack {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "mic")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(isGranted ? DS.Colors.textTertiary : DS.Colors.warning)
|
|
.frame(width: 16)
|
|
|
|
Text("Microphone")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isGranted {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(DS.Colors.success)
|
|
.frame(width: 6, height: 6)
|
|
Text("Granted")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(DS.Colors.success)
|
|
}
|
|
} else {
|
|
Button(action: {
|
|
// Triggers the native macOS microphone permission dialog on
|
|
// first attempt. If already denied, opens System Settings.
|
|
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
if status == .notDetermined {
|
|
AVCaptureDevice.requestAccess(for: .audio) { _ in }
|
|
} else {
|
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
}) {
|
|
Text("Grant")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(DS.Colors.textOnAccent)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(DS.Colors.accent)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private func permissionRow(
|
|
label: String,
|
|
iconName: String,
|
|
isGranted: Bool,
|
|
settingsURL: String
|
|
) -> some View {
|
|
HStack {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: iconName)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(isGranted ? DS.Colors.textTertiary : DS.Colors.warning)
|
|
.frame(width: 16)
|
|
|
|
Text(label)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isGranted {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(DS.Colors.success)
|
|
.frame(width: 6, height: 6)
|
|
Text("Granted")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(DS.Colors.success)
|
|
}
|
|
} else {
|
|
Button(action: {
|
|
if let url = URL(string: settingsURL) {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}) {
|
|
Text("Grant")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(DS.Colors.textOnAccent)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(DS.Colors.accent)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Show Clicky Cursor Toggle
|
|
|
|
private var showClickyCursorToggleRow: some View {
|
|
HStack {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "cursorarrow")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
.frame(width: 16)
|
|
|
|
Text("Show Clicky")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Toggle("", isOn: Binding(
|
|
get: { companionManager.isClickyCursorEnabled },
|
|
set: { companionManager.setClickyCursorEnabled($0) }
|
|
))
|
|
.toggleStyle(.switch)
|
|
.labelsHidden()
|
|
.tint(DS.Colors.accent)
|
|
.scaleEffect(0.8)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private var speechToTextProviderRow: some View {
|
|
HStack {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "mic.badge.waveform")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
.frame(width: 16)
|
|
|
|
Text("Speech to Text")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(companionManager.buddyDictationManager.transcriptionProviderDisplayName)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
// MARK: - Model Picker
|
|
|
|
private var modelPickerRow: some View {
|
|
HStack {
|
|
Text("Model")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 0) {
|
|
modelOptionButton(label: "Sonnet", modelID: "claude-sonnet-4-6")
|
|
modelOptionButton(label: "Opus", modelID: "claude-opus-4-6")
|
|
}
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.fill(Color.white.opacity(0.06))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.stroke(DS.Colors.borderSubtle, lineWidth: 0.5)
|
|
)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func modelOptionButton(label: String, modelID: String) -> some View {
|
|
let isSelected = companionManager.selectedModel == modelID
|
|
return Button(action: {
|
|
companionManager.setSelectedModel(modelID)
|
|
}) {
|
|
Text(label)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(isSelected ? DS.Colors.textPrimary : DS.Colors.textTertiary)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
|
.fill(isSelected ? Color.white.opacity(0.1) : Color.clear)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
}
|
|
|
|
// MARK: - DM Farza Button
|
|
|
|
private var dmFarzaButton: some View {
|
|
Button(action: {
|
|
if let url = URL(string: "https://x.com/farzatv") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "bubble.left.fill")
|
|
.font(.system(size: 12, weight: .medium))
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Got feedback? DM me")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
Text("Bugs, ideas, anything — I read every message.")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
}
|
|
}
|
|
.foregroundColor(DS.Colors.textSecondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: DS.CornerRadius.medium, style: .continuous)
|
|
.fill(Color.white.opacity(0.06))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: DS.CornerRadius.medium, style: .continuous)
|
|
.stroke(DS.Colors.borderSubtle, lineWidth: 0.5)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
}
|
|
|
|
// MARK: - Footer
|
|
|
|
private var footerSection: some View {
|
|
HStack {
|
|
Button(action: {
|
|
NSApp.terminate(nil)
|
|
}) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "power")
|
|
.font(.system(size: 11, weight: .medium))
|
|
Text("Quit Clicky")
|
|
.font(.system(size: 12, weight: .medium))
|
|
}
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
|
|
if companionManager.hasCompletedOnboarding {
|
|
Spacer()
|
|
|
|
Button(action: {
|
|
companionManager.replayOnboarding()
|
|
}) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "play.circle")
|
|
.font(.system(size: 11, weight: .medium))
|
|
Text("Watch Onboarding Again")
|
|
.font(.system(size: 12, weight: .medium))
|
|
}
|
|
.foregroundColor(DS.Colors.textTertiary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.pointerCursor()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Visual Helpers
|
|
|
|
private var panelBackground: some View {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(DS.Colors.background)
|
|
.shadow(color: Color.black.opacity(0.5), radius: 20, x: 0, y: 10)
|
|
.shadow(color: Color.black.opacity(0.3), radius: 4, x: 0, y: 2)
|
|
}
|
|
|
|
private var statusDotColor: Color {
|
|
if !companionManager.isOverlayVisible {
|
|
return DS.Colors.textTertiary
|
|
}
|
|
switch companionManager.voiceState {
|
|
case .idle:
|
|
return DS.Colors.success
|
|
case .listening:
|
|
return DS.Colors.blue400
|
|
case .processing, .responding:
|
|
return DS.Colors.blue400
|
|
}
|
|
}
|
|
|
|
private var statusText: String {
|
|
if !companionManager.hasCompletedOnboarding || !companionManager.allPermissionsGranted {
|
|
return "Setup"
|
|
}
|
|
if !companionManager.isOverlayVisible {
|
|
return "Ready"
|
|
}
|
|
switch companionManager.voiceState {
|
|
case .idle:
|
|
return "Active"
|
|
case .listening:
|
|
return "Listening"
|
|
case .processing:
|
|
return "Processing"
|
|
case .responding:
|
|
return "Responding"
|
|
}
|
|
}
|
|
|
|
}
|