feat: onboarding flow
This commit is contained in:
@@ -8,10 +8,25 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
@StateObject private var settingsViewModel = SettingsViewModel()
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
|
|
||||||
var body: some View {
|
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 userBlurb: String
|
||||||
var systemPrompt: String
|
var systemPrompt: String
|
||||||
var selectedTemplateId: UUID?
|
var selectedTemplateId: UUID?
|
||||||
|
var hasCompletedOnboarding: Bool
|
||||||
|
var hasAcceptedTerms: Bool
|
||||||
|
|
||||||
// System prompt default loading
|
// System prompt default loading
|
||||||
static func defaultSystemPrompt() -> String {
|
static func defaultSystemPrompt() -> String {
|
||||||
@@ -36,10 +38,14 @@ struct Settings: Codable {
|
|||||||
init(openAIKey: String = "",
|
init(openAIKey: String = "",
|
||||||
userBlurb: String = "",
|
userBlurb: String = "",
|
||||||
systemPrompt: String = "",
|
systemPrompt: String = "",
|
||||||
selectedTemplateId: UUID? = nil) {
|
selectedTemplateId: UUID? = nil,
|
||||||
|
hasCompletedOnboarding: Bool = false,
|
||||||
|
hasAcceptedTerms: Bool = false) {
|
||||||
self.openAIKey = openAIKey
|
self.openAIKey = openAIKey
|
||||||
self.userBlurb = userBlurb
|
self.userBlurb = userBlurb
|
||||||
self.systemPrompt = systemPrompt.isEmpty ? Settings.defaultSystemPrompt() : systemPrompt
|
self.systemPrompt = systemPrompt.isEmpty ? Settings.defaultSystemPrompt() : systemPrompt
|
||||||
self.selectedTemplateId = selectedTemplateId
|
self.selectedTemplateId = selectedTemplateId
|
||||||
|
self.hasCompletedOnboarding = hasCompletedOnboarding
|
||||||
|
self.hasAcceptedTerms = hasAcceptedTerms
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,10 @@ class SettingsViewModel: ObservableObject {
|
|||||||
settings.userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? ""
|
settings.userBlurb = KeychainHelper.shared.get(forKey: "userBlurb") ?? ""
|
||||||
settings.systemPrompt = KeychainHelper.shared.get(forKey: "systemPrompt") ?? Settings.defaultSystemPrompt()
|
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
|
// Load selected template ID
|
||||||
if let templateIdString = KeychainHelper.shared.get(forKey: "selectedTemplateId"),
|
if let templateIdString = KeychainHelper.shared.get(forKey: "selectedTemplateId"),
|
||||||
let templateId = UUID(uuidString: templateIdString) {
|
let templateId = UUID(uuidString: templateIdString) {
|
||||||
@@ -61,6 +65,10 @@ class SettingsViewModel: ObservableObject {
|
|||||||
let openAISaved = KeychainHelper.shared.save(settings.openAIKey, forKey: "openAIKey")
|
let openAISaved = KeychainHelper.shared.save(settings.openAIKey, forKey: "openAIKey")
|
||||||
let blurbSaved = KeychainHelper.shared.save(settings.userBlurb, forKey: "userBlurb")
|
let blurbSaved = KeychainHelper.shared.save(settings.userBlurb, forKey: "userBlurb")
|
||||||
let promptSaved = KeychainHelper.shared.save(settings.systemPrompt, forKey: "systemPrompt")
|
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
|
// Save selected template ID
|
||||||
var templateIdSaved = true
|
var templateIdSaved = true
|
||||||
@@ -69,7 +77,7 @@ class SettingsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if showMessage {
|
if showMessage {
|
||||||
if openAISaved && blurbSaved && promptSaved && templateIdSaved {
|
if openAISaved && blurbSaved && promptSaved && templateIdSaved && onboardingSaved && termsSaved {
|
||||||
saveMessage = "Settings saved successfully!"
|
saveMessage = "Settings saved successfully!"
|
||||||
} else {
|
} else {
|
||||||
saveMessage = "Error saving settings"
|
saveMessage = "Error saving settings"
|
||||||
@@ -84,7 +92,23 @@ class SettingsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func completeOnboarding() {
|
||||||
|
settings.hasCompletedOnboarding = true
|
||||||
|
settings.hasAcceptedTerms = true
|
||||||
|
saveSettings(showMessage: false)
|
||||||
|
}
|
||||||
|
|
||||||
func resetToDefaults() {
|
func resetToDefaults() {
|
||||||
settings.systemPrompt = Settings.defaultSystemPrompt()
|
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 {
|
struct MeetingListView: View {
|
||||||
@StateObject private var viewModel = MeetingListViewModel()
|
@StateObject private var viewModel = MeetingListViewModel()
|
||||||
|
@ObservedObject var settingsViewModel: SettingsViewModel
|
||||||
@State private var navigationPath = NavigationPath()
|
@State private var navigationPath = NavigationPath()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -74,7 +75,7 @@ struct MeetingListView: View {
|
|||||||
}
|
}
|
||||||
.navigationDestination(for: String.self) { path in
|
.navigationDestination(for: String.self) { path in
|
||||||
if path == "settings" {
|
if path == "settings" {
|
||||||
SettingsView(viewModel: SettingsViewModel(), navigationPath: $navigationPath)
|
SettingsView(viewModel: settingsViewModel, navigationPath: $navigationPath)
|
||||||
} else if path == "templates" {
|
} else if path == "templates" {
|
||||||
TemplateListView()
|
TemplateListView()
|
||||||
}
|
}
|
||||||
@@ -160,5 +161,5 @@ struct MeetingRowView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#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)
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
// Link to GitHub repository
|
// Link to GitHub repository
|
||||||
Link("https://github.com/owengretzinger/meetingnotes",
|
Link("GitHub",
|
||||||
destination: URL(string: "https://github.com/owengretzinger/meetingnotes")!)
|
destination: URL(string: "https://github.com/owengretzinger/meetingnotes")!)
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
// Link to landing page
|
// Link to landing page
|
||||||
Link("https://meetingnotes.owengretzinger.com",
|
Link("Landing Page",
|
||||||
destination: URL(string: "https://meetingnotes.owengretzinger.com")!)
|
destination: URL(string: "https://meetingnotes.owengretzinger.com")!)
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
@@ -124,6 +124,18 @@ struct SettingsView: View {
|
|||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Development Section
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Development")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Button("Reset Onboarding") {
|
||||||
|
viewModel.resetOnboarding()
|
||||||
|
}
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
// Save button
|
// Save button
|
||||||
Button {
|
Button {
|
||||||
viewModel.saveSettings()
|
viewModel.saveSettings()
|
||||||
|
|||||||
Reference in New Issue
Block a user