import SwiftUI import AppKit struct ModelCardRowView: View { let model: any TranscriptionModel let isDownloaded: Bool let isCurrent: Bool let downloadProgress: [String: Double] let modelURL: URL? // Actions var deleteAction: () -> Void var setDefaultAction: () -> Void var downloadAction: () -> Void var body: some View { Group { switch model.provider { case .local: if let localModel = model as? LocalModel { LocalModelCardView( model: localModel, isDownloaded: isDownloaded, isCurrent: isCurrent, downloadProgress: downloadProgress, modelURL: modelURL, deleteAction: deleteAction, setDefaultAction: setDefaultAction, downloadAction: downloadAction ) } case .groq, .elevenLabs: if let cloudModel = model as? CloudModel { CloudModelCardView( model: cloudModel, isCurrent: isCurrent, setDefaultAction: setDefaultAction ) } } } } } // MARK: - Local Model Card View struct LocalModelCardView: View { let model: LocalModel let isDownloaded: Bool let isCurrent: Bool let downloadProgress: [String: Double] let modelURL: URL? // Actions var deleteAction: () -> Void var setDefaultAction: () -> Void var downloadAction: () -> Void private var isDownloading: Bool { downloadProgress.keys.contains(model.name + "_main") || downloadProgress.keys.contains(model.name + "_coreml") } var body: some View { HStack(alignment: .top, spacing: 16) { // Main Content VStack(alignment: .leading, spacing: 6) { headerSection metadataSection descriptionSection progressSection } .frame(maxWidth: .infinity, alignment: .leading) // Action Controls actionSection } .padding(16) .background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent)) } private var headerSection: some View { HStack(alignment: .firstTextBaseline) { Text(model.displayName) .font(.system(size: 13, weight: .semibold)) .foregroundColor(Color(.labelColor)) statusBadge Spacer() } } private var statusBadge: some View { Group { if isCurrent { Text("Default") .font(.system(size: 11, weight: .medium)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Capsule().fill(Color.accentColor)) .foregroundColor(.white) } else if isDownloaded { Text("Downloaded") .font(.system(size: 11, weight: .medium)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Capsule().fill(Color(.quaternaryLabelColor))) .foregroundColor(Color(.labelColor)) } } } private var metadataSection: some View { HStack(spacing: 12) { // Language Label(model.language, systemImage: "globe") .font(.system(size: 11)) .foregroundColor(Color(.secondaryLabelColor)) .lineLimit(1) // Size Label(model.size, systemImage: "internaldrive") .font(.system(size: 11)) .foregroundColor(Color(.secondaryLabelColor)) .lineLimit(1) // Speed HStack(spacing: 3) { Text("Speed") .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(.secondaryLabelColor)) progressDotsWithNumber(value: model.speed * 10) } .lineLimit(1) .fixedSize(horizontal: true, vertical: false) // Accuracy HStack(spacing: 3) { Text("Accuracy") .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(.secondaryLabelColor)) progressDotsWithNumber(value: model.accuracy * 10) } .lineLimit(1) .fixedSize(horizontal: true, vertical: false) } .lineLimit(1) } private var descriptionSection: some View { Text(model.description) .font(.system(size: 11)) .foregroundColor(Color(.secondaryLabelColor)) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) .padding(.top, 4) } private var progressSection: some View { Group { if isDownloading { DownloadProgressView( modelName: model.name, downloadProgress: downloadProgress ) .padding(.top, 8) .frame(maxWidth: .infinity, alignment: .leading) } } } private var actionSection: some View { HStack(spacing: 8) { if isCurrent { Text("Default Model") .font(.system(size: 12)) .foregroundColor(Color(.secondaryLabelColor)) } else if isDownloaded { Button(action: setDefaultAction) { Text("Set as Default") .font(.system(size: 12)) } .buttonStyle(.bordered) .controlSize(.small) } else { Button(action: downloadAction) { HStack(spacing: 4) { Text(isDownloading ? "Downloading..." : "Download") .font(.system(size: 12, weight: .medium)) Image(systemName: "arrow.down.circle") .font(.system(size: 12, weight: .medium)) } .foregroundColor(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background( Capsule() .fill(Color(.controlAccentColor)) .shadow(color: Color(.controlAccentColor).opacity(0.2), radius: 2, x: 0, y: 1) ) } .buttonStyle(.plain) .disabled(isDownloading) } if isDownloaded { Menu { Button(action: deleteAction) { Label("Delete Model", systemImage: "trash") } Button { if let modelURL = modelURL { NSWorkspace.shared.selectFile(modelURL.path, inFileViewerRootedAtPath: "") } } label: { Label("Show in Finder", systemImage: "folder") } } label: { Image(systemName: "ellipsis.circle") .font(.system(size: 14)) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .frame(width: 20, height: 20) } } } } // MARK: - Cloud Model Card View struct CloudModelCardView: View { let model: CloudModel let isCurrent: Bool var setDefaultAction: () -> Void @EnvironmentObject private var whisperState: WhisperState @StateObject private var aiService = AIService() @State private var isExpanded = false @State private var apiKey = "" @State private var isVerifying = false @State private var verificationStatus: VerificationStatus = .none @State private var isConfiguredState: Bool = false enum VerificationStatus { case none, verifying, success, failure } private var isConfigured: Bool { guard let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") else { return false } return !savedKey.isEmpty } private var providerKey: String { switch model.provider { case .groq: return "GROQ" case .elevenLabs: return "ElevenLabs" default: return model.provider.rawValue } } var body: some View { VStack(alignment: .leading, spacing: 0) { // Main card content HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 6) { headerSection metadataSection descriptionSection } .frame(maxWidth: .infinity, alignment: .leading) actionSection } .padding(16) // Expandable configuration section if isExpanded { Divider() .padding(.horizontal, 16) configurationSection .padding(16) } } .background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent)) .onAppear { loadSavedAPIKey() isConfiguredState = isConfigured } } private var headerSection: some View { HStack(alignment: .firstTextBaseline) { Text(model.displayName) .font(.system(size: 13, weight: .semibold)) .foregroundColor(Color(.labelColor)) statusBadge Spacer() } } private var statusBadge: some View { Group { if isCurrent { Text("Default") .font(.system(size: 11, weight: .medium)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Capsule().fill(Color.accentColor)) .foregroundColor(.white) } else if isConfiguredState { Text("Configured") .font(.system(size: 11, weight: .medium)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Capsule().fill(Color(.systemGreen).opacity(0.2))) .foregroundColor(Color(.systemGreen)) } else { Text("Setup Required") .font(.system(size: 11, weight: .medium)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Capsule().fill(Color(.systemOrange).opacity(0.2))) .foregroundColor(Color(.systemOrange)) } } } private var metadataSection: some View { HStack(spacing: 12) { // Provider Label(model.provider.rawValue, systemImage: "cloud") .font(.system(size: 11)) .foregroundColor(Color(.secondaryLabelColor)) .lineLimit(1) // Language Label(model.language, systemImage: "globe") .font(.system(size: 11)) .foregroundColor(Color(.secondaryLabelColor)) .lineLimit(1) Label("Cloud Model", systemImage: "icloud") .font(.system(size: 11)) .foregroundColor(Color(.secondaryLabelColor)) .lineLimit(1) // Accuracy HStack(spacing: 3) { Text("Accuracy") .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(.secondaryLabelColor)) progressDotsWithNumber(value: model.accuracy * 10) } .lineLimit(1) .fixedSize(horizontal: true, vertical: false) } .lineLimit(1) } private var descriptionSection: some View { Text(model.description) .font(.system(size: 11)) .foregroundColor(Color(.secondaryLabelColor)) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) .padding(.top, 4) } private var actionSection: some View { HStack(spacing: 8) { if isCurrent { Text("Default Model") .font(.system(size: 12)) .foregroundColor(Color(.secondaryLabelColor)) } else if isConfiguredState { Button(action: setDefaultAction) { Text("Set as Default") .font(.system(size: 12)) } .buttonStyle(.bordered) .controlSize(.small) } else { Button(action: { withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) { isExpanded.toggle() } }) { HStack(spacing: 4) { Text("Configure") .font(.system(size: 12, weight: .medium)) Image(systemName: "gear") .font(.system(size: 12, weight: .medium)) } .foregroundColor(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background( Capsule() .fill(Color(.controlAccentColor)) .shadow(color: Color(.controlAccentColor).opacity(0.2), radius: 2, x: 0, y: 1) ) } .buttonStyle(.plain) } if isConfiguredState { Menu { Button { clearAPIKey() } label: { Label("Remove API Key", systemImage: "trash") } } label: { Image(systemName: "ellipsis.circle") .font(.system(size: 14)) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .frame(width: 20, height: 20) } } } private var configurationSection: some View { VStack(alignment: .leading, spacing: 12) { Text("API Key Configuration") .font(.system(size: 13, weight: .semibold)) .foregroundColor(Color(.labelColor)) HStack(spacing: 8) { SecureField("Enter your \(model.provider.rawValue) API key", text: $apiKey) .textFieldStyle(.roundedBorder) .disabled(isVerifying) Button(action: verifyAPIKey) { HStack(spacing: 4) { if isVerifying { ProgressView() .scaleEffect(0.7) .frame(width: 12, height: 12) } else { Image(systemName: verificationStatus == .success ? "checkmark" : "checkmark.shield") .font(.system(size: 12, weight: .medium)) } Text(isVerifying ? "Verifying..." : "Verify") .font(.system(size: 12, weight: .medium)) } .foregroundColor(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background( Capsule() .fill(verificationStatus == .success ? Color(.systemGreen) : Color(.controlAccentColor)) ) } .buttonStyle(.plain) .disabled(apiKey.isEmpty || isVerifying) } if verificationStatus == .failure { Text("Invalid API key. Please check your key and try again.") .font(.caption) .foregroundColor(Color(.systemRed)) } else if verificationStatus == .success { Text("API key verified successfully!") .font(.caption) .foregroundColor(Color(.systemGreen)) } } } private func loadSavedAPIKey() { if let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") { apiKey = savedKey verificationStatus = .success } } private func verifyAPIKey() { guard !apiKey.isEmpty else { return } isVerifying = true verificationStatus = .verifying // Set the provider in AIService temporarily for verification let originalProvider = aiService.selectedProvider if model.provider == .groq { aiService.selectedProvider = .groq } else if model.provider == .elevenLabs { aiService.selectedProvider = .elevenLabs } aiService.verifyAPIKey(apiKey) { [self] isValid in DispatchQueue.main.async { self.isVerifying = false if isValid { self.verificationStatus = .success // Save the API key UserDefaults.standard.set(self.apiKey, forKey: "\(self.providerKey)APIKey") self.isConfiguredState = true // Collapse the configuration section after successful verification withAnimation(.easeInOut(duration: 0.3)) { self.isExpanded = false } } else { self.verificationStatus = .failure } // Restore original provider aiService.selectedProvider = originalProvider } } } private func clearAPIKey() { UserDefaults.standard.removeObject(forKey: "\(providerKey)APIKey") apiKey = "" verificationStatus = .none isConfiguredState = false // If this model is currently the default, clear it if isCurrent { Task { await MainActor.run { whisperState.currentTranscriptionModel = nil UserDefaults.standard.removeObject(forKey: "CurrentTranscriptionModel") } } } withAnimation(.easeInOut(duration: 0.3)) { isExpanded = false } } } // MARK: - Helper Views and Functions private func progressDotsWithNumber(value: Double) -> some View { HStack(spacing: 4) { progressDots(value: value) Text(String(format: "%.1f", value)) .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundColor(Color(.secondaryLabelColor)) } } private func progressDots(value: Double) -> some View { HStack(spacing: 2) { ForEach(0..<5) { index in Circle() .fill(index < Int(value / 2) ? performanceColor(value: value / 10) : Color(.quaternaryLabelColor)) .frame(width: 6, height: 6) } } } private func performanceColor(value: Double) -> Color { switch value { case 0.8...1.0: return Color(.systemGreen) case 0.6..<0.8: return Color(.systemYellow) case 0.4..<0.6: return Color(.systemOrange) default: return Color(.systemRed) } }