265 lines
9.4 KiB
Swift
265 lines
9.4 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import KeyboardShortcuts
|
|
|
|
// ViewType enum with all cases
|
|
enum ViewType: String, CaseIterable {
|
|
case metrics = "Dashboard"
|
|
case record = "Record Audio"
|
|
case transcribeAudio = "Transcribe Audio"
|
|
case history = "History"
|
|
case models = "AI Models"
|
|
case enhancement = "Enhancement"
|
|
case powerMode = "Power Mode"
|
|
case permissions = "Permissions"
|
|
case audioInput = "Audio Input"
|
|
case dictionary = "Dictionary"
|
|
case settings = "Settings"
|
|
case license = "VoiceInk Pro"
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .metrics: return "gauge.medium"
|
|
case .record: return "mic.circle.fill"
|
|
case .transcribeAudio: return "waveform.circle.fill"
|
|
case .history: return "doc.text.fill"
|
|
case .models: return "brain.head.profile"
|
|
case .enhancement: return "wand.and.stars"
|
|
case .powerMode: return "sparkles.square.fill.on.square"
|
|
case .permissions: return "shield.fill"
|
|
case .audioInput: return "mic.fill"
|
|
case .dictionary: return "character.book.closed.fill"
|
|
case .settings: return "gearshape.fill"
|
|
case .license: return "checkmark.seal.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct VisualEffectView: NSViewRepresentable {
|
|
let material: NSVisualEffectView.Material
|
|
let blendingMode: NSVisualEffectView.BlendingMode
|
|
|
|
func makeNSView(context: Context) -> NSVisualEffectView {
|
|
let visualEffectView = NSVisualEffectView()
|
|
visualEffectView.material = material
|
|
visualEffectView.blendingMode = blendingMode
|
|
visualEffectView.state = .active
|
|
return visualEffectView
|
|
}
|
|
|
|
func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) {
|
|
visualEffectView.material = material
|
|
visualEffectView.blendingMode = blendingMode
|
|
}
|
|
}
|
|
|
|
struct DynamicSidebar: View {
|
|
@Binding var selectedView: ViewType
|
|
@Binding var hoveredView: ViewType?
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@StateObject private var licenseViewModel = LicenseViewModel()
|
|
@Namespace private var buttonAnimation
|
|
|
|
var body: some View {
|
|
VStack(spacing: 15) {
|
|
// App Header
|
|
HStack(spacing: 6) {
|
|
if let appIcon = NSImage(named: "AppIcon") {
|
|
Image(nsImage: appIcon)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 28, height: 28)
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
Text("VoiceInk")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
|
|
if case .licensed = licenseViewModel.licenseState {
|
|
Text("PRO")
|
|
.font(.system(size: 9, weight: .heavy))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 2)
|
|
.background(Color.blue)
|
|
.cornerRadius(4)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
|
|
// Navigation Items
|
|
ForEach(ViewType.allCases, id: \.self) { viewType in
|
|
DynamicSidebarButton(
|
|
title: viewType.rawValue,
|
|
systemImage: viewType.icon,
|
|
isSelected: selectedView == viewType,
|
|
isHovered: hoveredView == viewType,
|
|
namespace: buttonAnimation
|
|
) {
|
|
selectedView = viewType
|
|
}
|
|
.onHover { isHovered in
|
|
hoveredView = isHovered ? viewType : nil
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
struct DynamicSidebarButton: View {
|
|
let title: String
|
|
let systemImage: String
|
|
let isSelected: Bool
|
|
let isHovered: Bool
|
|
let namespace: Namespace.ID
|
|
let action: () -> Void
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 18, weight: .medium))
|
|
.frame(width: 24, height: 24)
|
|
|
|
Text(title)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.lineLimit(1)
|
|
Spacer()
|
|
}
|
|
.foregroundColor(isSelected ? .white : (isHovered ? .accentColor : .primary))
|
|
.frame(height: 40)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.leading, 16)
|
|
.background(
|
|
ZStack {
|
|
if isSelected {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color.accentColor)
|
|
.shadow(color: Color.accentColor.opacity(0.5), radius: 5, x: 0, y: 2)
|
|
} else if isHovered {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(colorScheme == .dark ? Color.white.opacity(0.1) : Color.black.opacity(0.05))
|
|
}
|
|
}
|
|
)
|
|
.padding(.horizontal, 8)
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
}
|
|
}
|
|
|
|
struct ContentView: View {
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@EnvironmentObject private var whisperState: WhisperState
|
|
@EnvironmentObject private var hotkeyManager: HotkeyManager
|
|
@State private var selectedView: ViewType = .metrics
|
|
@State private var hoveredView: ViewType?
|
|
@State private var hasLoadedData = false
|
|
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
|
@StateObject private var licenseViewModel = LicenseViewModel()
|
|
|
|
private var isSetupComplete: Bool {
|
|
hasLoadedData &&
|
|
whisperState.currentModel != nil &&
|
|
KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil &&
|
|
AXIsProcessTrusted() &&
|
|
CGPreflightScreenCaptureAccess()
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
DynamicSidebar(
|
|
selectedView: $selectedView,
|
|
hoveredView: $hoveredView
|
|
)
|
|
.frame(width: 200)
|
|
.navigationSplitViewColumnWidth(200)
|
|
} detail: {
|
|
detailView
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.toolbar(.hidden, for: .automatic)
|
|
.navigationTitle("")
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
.frame(minWidth: 940, minHeight: 730)
|
|
.onAppear {
|
|
hasLoadedData = true
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToDestination)) { notification in
|
|
print("ContentView: Received navigation notification")
|
|
if let destination = notification.userInfo?["destination"] as? String {
|
|
print("ContentView: Destination received: \(destination)")
|
|
switch destination {
|
|
case "Settings":
|
|
print("ContentView: Navigating to Settings")
|
|
selectedView = .settings
|
|
case "AI Models":
|
|
print("ContentView: Navigating to AI Models")
|
|
selectedView = .models
|
|
case "VoiceInk Pro":
|
|
print("ContentView: Navigating to VoiceInk Pro")
|
|
selectedView = .license
|
|
case "History":
|
|
print("ContentView: Navigating to History")
|
|
selectedView = .history
|
|
case "Permissions":
|
|
print("ContentView: Navigating to Permissions")
|
|
selectedView = .permissions
|
|
case "Enhancement":
|
|
print("ContentView: Navigating to Enhancement")
|
|
selectedView = .enhancement
|
|
default:
|
|
print("ContentView: No matching destination found for: \(destination)")
|
|
break
|
|
}
|
|
} else {
|
|
print("ContentView: No destination in notification")
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var detailView: some View {
|
|
switch selectedView {
|
|
case .metrics:
|
|
if isSetupComplete {
|
|
MetricsView(skipSetupCheck: true)
|
|
} else {
|
|
MetricsSetupView()
|
|
}
|
|
case .models:
|
|
ModelManagementView(whisperState: whisperState)
|
|
case .enhancement:
|
|
EnhancementSettingsView()
|
|
case .record:
|
|
RecordView()
|
|
case .transcribeAudio:
|
|
AudioTranscribeView()
|
|
case .history:
|
|
TranscriptionHistoryView()
|
|
case .audioInput:
|
|
AudioInputSettingsView()
|
|
case .dictionary:
|
|
DictionarySettingsView(whisperPrompt: whisperState.whisperPrompt)
|
|
case .powerMode:
|
|
PowerModeView()
|
|
case .settings:
|
|
SettingsView()
|
|
.environmentObject(whisperState)
|
|
case .license:
|
|
LicenseManagementView()
|
|
case .permissions:
|
|
PermissionsView()
|
|
}
|
|
}
|
|
}
|