diff --git a/meetingnotes/ContentView.swift b/meetingnotes/ContentView.swift index 801fed7..e48c687 100644 --- a/meetingnotes/ContentView.swift +++ b/meetingnotes/ContentView.swift @@ -8,10 +8,25 @@ import SwiftUI struct ContentView: View { + @StateObject private var settingsViewModel = SettingsViewModel() @State private var showingSettings = false var body: some View { - MeetingListView() + Group { + if !settingsViewModel.settings.hasCompletedOnboarding { + OnboardingView(settingsViewModel: settingsViewModel) + } else { + MeetingListView(settingsViewModel: settingsViewModel) + } + } + .onAppear { + // Force load settings to check onboarding status + settingsViewModel.loadSettings() + } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("OnboardingReset"))) { _ in + // Reload settings when onboarding is reset + settingsViewModel.loadSettings() + } } } diff --git a/meetingnotes/Models/Settings.swift b/meetingnotes/Models/Settings.swift index 5b03449..809f951 100644 --- a/meetingnotes/Models/Settings.swift +++ b/meetingnotes/Models/Settings.swift @@ -5,6 +5,8 @@ struct Settings: Codable { var userBlurb: String var systemPrompt: String var selectedTemplateId: UUID? + var hasCompletedOnboarding: Bool + var hasAcceptedTerms: Bool // System prompt default loading static func defaultSystemPrompt() -> String { @@ -36,10 +38,14 @@ struct Settings: Codable { init(openAIKey: String = "", userBlurb: String = "", systemPrompt: String = "", - selectedTemplateId: UUID? = nil) { + selectedTemplateId: UUID? = nil, + hasCompletedOnboarding: Bool = false, + hasAcceptedTerms: Bool = false) { self.openAIKey = openAIKey self.userBlurb = userBlurb self.systemPrompt = systemPrompt.isEmpty ? Settings.defaultSystemPrompt() : systemPrompt self.selectedTemplateId = selectedTemplateId + self.hasCompletedOnboarding = hasCompletedOnboarding + self.hasAcceptedTerms = hasAcceptedTerms } } \ No newline at end of file diff --git a/meetingnotes/ViewModels/SettingsViewModel.swift b/meetingnotes/ViewModels/SettingsViewModel.swift index 16fd024..47ea9bf 100644 --- a/meetingnotes/ViewModels/SettingsViewModel.swift +++ b/meetingnotes/ViewModels/SettingsViewModel.swift @@ -17,6 +17,10 @@ class SettingsViewModel: ObservableObject { settings.userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? "" settings.systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt() + // Load onboarding status + settings.hasCompletedOnboarding = KeychainHelper.shared.get(forKey: "hasCompletedOnboarding") == "true" + settings.hasAcceptedTerms = KeychainHelper.shared.get(forKey: "hasAcceptedTerms") == "true" + // Load selected template ID if let templateIdString = KeychainHelper.shared.get(forKey: "selectedTemplateId"), let templateId = UUID(uuidString: templateIdString) { @@ -61,6 +65,10 @@ class SettingsViewModel: ObservableObject { let openAISaved = KeychainHelper.shared.save(settings.openAIKey, forKey: "openAIKey") let blurbSaved = KeychainHelper.shared.save(settings.userBlurb, forKey: "userBlurb") let promptSaved = KeychainHelper.shared.save(settings.systemPrompt, forKey: "systemPrompt") + + // Save onboarding status + let onboardingSaved = KeychainHelper.shared.save(settings.hasCompletedOnboarding ? "true" : "false", forKey: "hasCompletedOnboarding") + let termsSaved = KeychainHelper.shared.save(settings.hasAcceptedTerms ? "true" : "false", forKey: "hasAcceptedTerms") // Save selected template ID var templateIdSaved = true @@ -69,7 +77,7 @@ class SettingsViewModel: ObservableObject { } if showMessage { - if openAISaved && blurbSaved && promptSaved && templateIdSaved { + if openAISaved && blurbSaved && promptSaved && templateIdSaved && onboardingSaved && termsSaved { saveMessage = "Settings saved successfully!" } else { saveMessage = "Error saving settings" @@ -84,7 +92,23 @@ class SettingsViewModel: ObservableObject { } } + func completeOnboarding() { + settings.hasCompletedOnboarding = true + settings.hasAcceptedTerms = true + saveSettings(showMessage: false) + } + func resetToDefaults() { settings.systemPrompt = Settings.defaultSystemPrompt() } + + func resetOnboarding() { + settings.hasCompletedOnboarding = false + settings.hasAcceptedTerms = false + saveSettings(showMessage: false) + + // Force app to restart or recreate views by posting a notification + // This will cause ContentView to re-evaluate and show onboarding + NotificationCenter.default.post(name: Notification.Name("OnboardingReset"), object: nil) + } } \ No newline at end of file diff --git a/meetingnotes/Views/MeetingListView.swift b/meetingnotes/Views/MeetingListView.swift index 0aeb4c1..f99b307 100644 --- a/meetingnotes/Views/MeetingListView.swift +++ b/meetingnotes/Views/MeetingListView.swift @@ -2,6 +2,7 @@ import SwiftUI struct MeetingListView: View { @StateObject private var viewModel = MeetingListViewModel() + @ObservedObject var settingsViewModel: SettingsViewModel @State private var navigationPath = NavigationPath() var body: some View { @@ -74,7 +75,7 @@ struct MeetingListView: View { } .navigationDestination(for: String.self) { path in if path == "settings" { - SettingsView(viewModel: SettingsViewModel(), navigationPath: $navigationPath) + SettingsView(viewModel: settingsViewModel, navigationPath: $navigationPath) } else if path == "templates" { TemplateListView() } @@ -160,5 +161,5 @@ struct MeetingRowView: View { } #Preview { - MeetingListView() + MeetingListView(settingsViewModel: SettingsViewModel()) } \ No newline at end of file diff --git a/meetingnotes/Views/OnboardingView.swift b/meetingnotes/Views/OnboardingView.swift new file mode 100644 index 0000000..3c3b3a8 --- /dev/null +++ b/meetingnotes/Views/OnboardingView.swift @@ -0,0 +1,246 @@ +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("Please review and accept the terms of service and privacy policy.") + .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() + // Load existing API key if available + apiKey = settingsViewModel.settings.openAIKey + } + } + + 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) +} \ No newline at end of file diff --git a/meetingnotes/Views/SettingsView.swift b/meetingnotes/Views/SettingsView.swift index b091dca..ad690ed 100644 --- a/meetingnotes/Views/SettingsView.swift +++ b/meetingnotes/Views/SettingsView.swift @@ -104,12 +104,12 @@ struct SettingsView: View { .foregroundColor(.primary) // Link to GitHub repository - Link("https://github.com/owengretzinger/meetingnotes", + Link("GitHub", destination: URL(string: "https://github.com/owengretzinger/meetingnotes")!) .foregroundColor(.blue) // Link to landing page - Link("https://meetingnotes.owengretzinger.com", + Link("Landing Page", destination: URL(string: "https://meetingnotes.owengretzinger.com")!) .foregroundColor(.blue) @@ -124,6 +124,18 @@ struct SettingsView: View { .foregroundColor(.blue) } + // Development Section + VStack(alignment: .leading, spacing: 8) { + Text("Development") + .font(.headline) + .foregroundColor(.primary) + + Button("Reset Onboarding") { + viewModel.resetOnboarding() + } + .foregroundColor(.blue) + } + // Save button Button { viewModel.saveSettings()