feat: onboarding flow

This commit is contained in:
Owen Gretzinger
2025-07-13 19:04:49 -04:00
parent f8dad5b2c4
commit 6d47e7e406
6 changed files with 311 additions and 7 deletions
+16 -1
View File
@@ -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()
}
}
}
+7 -1
View File
@@ -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
}
}
@@ -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)
}
}
+3 -2
View File
@@ -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())
}
+246
View File
@@ -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)
}
+14 -2
View File
@@ -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()