feat: onboarding flow
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user