247 lines
11 KiB
Swift
247 lines
11 KiB
Swift
import SwiftUI
|
|
import AVFoundation
|
|
|
|
struct OnboardingView: View {
|
|
@ObservedObject var settingsViewModel: SettingsViewModel
|
|
@State private var apiKey = ""
|
|
@State private var hasAcceptedTerms = false
|
|
@State private var micPermissionGranted = false
|
|
@State private var screenPermissionGranted = false
|
|
@State private var showingPermissionAlert = false
|
|
@State private var permissionAlertMessage = ""
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
VStack(spacing: 0) {
|
|
// Content area
|
|
ScrollView {
|
|
VStack(spacing: 32) {
|
|
// Permissions Section
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Required Permissions")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
VStack(spacing: 12) {
|
|
PermissionRow(
|
|
title: "Microphone Access",
|
|
description: "Required to transcribe what you say in meetings",
|
|
isGranted: micPermissionGranted,
|
|
action: requestMicrophonePermission
|
|
)
|
|
|
|
PermissionRow(
|
|
title: "System Recording",
|
|
description: "Required to transcribe what others say in meetings",
|
|
isGranted: screenPermissionGranted,
|
|
action: requestScreenPermission
|
|
)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// API Key Section
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("OpenAI API Key")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
Text("Uses gpt-4o-mini-transcribe and gpt-4.1. Typical cost is ~$0.20/hour. Your Mac communicates directly with OpenAI.")
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Button("Get API Key from OpenAI") {
|
|
if let url = URL(string: "https://platform.openai.com/api-keys") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
.buttonStyle(.link)
|
|
|
|
SecureField("OpenAI API Key", text: $apiKey)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.body)
|
|
|
|
HStack {
|
|
Image(systemName: "info.circle")
|
|
.foregroundColor(.blue)
|
|
Text("Stored locally and encrypted.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// Terms Section
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Terms and Privacy")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
Text("Of note, I am tracking a few basic anonymous metrics (installs, opens, meetings created) using PostHog, because I'm trying to gauge interest in the app and whether it's worth it to pay for an Apple Developer License & domain name. Everything is completely anonymous (feel free to check the source code). If you have any concerns, please reach out.")
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Button("Privacy Policy") {
|
|
if let url = URL(string: "https://meetingnotes.owengretzinger.com/privacy") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
.buttonStyle(.link)
|
|
|
|
Text("•")
|
|
.foregroundColor(.secondary)
|
|
|
|
Button("Terms of Service") {
|
|
if let url = URL(string: "https://meetingnotes.owengretzinger.com/terms") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
.buttonStyle(.link)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
HStack {
|
|
Button(action: { hasAcceptedTerms.toggle() }) {
|
|
Image(systemName: hasAcceptedTerms ? "checkmark.square.fill" : "square")
|
|
.foregroundColor(hasAcceptedTerms ? .blue : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Text("I have read and agree to the Terms of Service and Privacy Policy")
|
|
.font(.body)
|
|
.foregroundColor(.primary)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
HStack {
|
|
Spacer()
|
|
Button("Get Started") {
|
|
// Complete onboarding
|
|
settingsViewModel.settings.openAIKey = apiKey
|
|
settingsViewModel.completeOnboarding()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.large)
|
|
.disabled(!canProceed)
|
|
}
|
|
}
|
|
.padding(.vertical, 30)
|
|
.padding(.horizontal, 24)
|
|
.frame(maxWidth: .infinity)
|
|
|
|
}
|
|
.frame(maxHeight: .infinity)
|
|
}
|
|
}
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.alert("Permission Required", isPresented: $showingPermissionAlert) {
|
|
Button("OK") { }
|
|
} message: {
|
|
Text(permissionAlertMessage)
|
|
}
|
|
.onAppear {
|
|
checkPermissions()
|
|
settingsViewModel.loadAPIKey()
|
|
apiKey = settingsViewModel.settings.openAIKey
|
|
hasAcceptedTerms = settingsViewModel.settings.hasAcceptedTerms
|
|
}
|
|
}
|
|
|
|
private var canProceed: Bool {
|
|
return micPermissionGranted &&
|
|
screenPermissionGranted &&
|
|
!apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
hasAcceptedTerms
|
|
}
|
|
|
|
private func checkPermissions() {
|
|
// Check microphone permission using AVCaptureDevice (macOS compatible)
|
|
micPermissionGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
|
|
|
// Check screen recording permission
|
|
screenPermissionGranted = CGPreflightScreenCaptureAccess()
|
|
}
|
|
|
|
private func requestMicrophonePermission() {
|
|
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
|
DispatchQueue.main.async {
|
|
micPermissionGranted = granted
|
|
if !granted {
|
|
permissionAlertMessage = "Microphone access is required for recording meetings. Please enable it in System Preferences > Security & Privacy > Privacy > Microphone."
|
|
showingPermissionAlert = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func requestScreenPermission() {
|
|
let success = CGRequestScreenCaptureAccess()
|
|
DispatchQueue.main.async {
|
|
screenPermissionGranted = success
|
|
if !success {
|
|
permissionAlertMessage = "System recording access is required to capture system audio. Please enable it in System Preferences > Security & Privacy > Privacy > Screen Recording."
|
|
showingPermissionAlert = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PermissionRow: View {
|
|
let title: String
|
|
let description: String
|
|
let isGranted: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
|
|
Text(description)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isGranted {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
Text("Granted")
|
|
.font(.caption)
|
|
.foregroundColor(.green)
|
|
}
|
|
} else {
|
|
Button("Enable") {
|
|
action()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(8)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color(NSColor.separatorColor), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
OnboardingView(settingsViewModel: SettingsViewModel())
|
|
.frame(width: 600, height: 700)
|
|
} |