diff --git a/LockInBro.xcodeproj/project.pbxproj b/LockInBro.xcodeproj/project.pbxproj index f200f81..0e2f3ff 100644 --- a/LockInBro.xcodeproj/project.pbxproj +++ b/LockInBro.xcodeproj/project.pbxproj @@ -6,6 +6,12 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + FF935B1E2F78A83100ED3330 /* SpeakerKit in Frameworks */ = {isa = PBXBuildFile; productRef = FF935B1D2F78A83100ED3330 /* SpeakerKit */; }; + FF935B202F78A83100ED3330 /* TTSKit in Frameworks */ = {isa = PBXBuildFile; productRef = FF935B1F2F78A83100ED3330 /* TTSKit */; }; + FF935B222F78A83100ED3330 /* WhisperKit in Frameworks */ = {isa = PBXBuildFile; productRef = FF935B212F78A83100ED3330 /* WhisperKit */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ FF3296C22F785B3300C734EB /* LockInBro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LockInBro.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -23,6 +29,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FF935B222F78A83100ED3330 /* WhisperKit in Frameworks */, + FF935B1E2F78A83100ED3330 /* SpeakerKit in Frameworks */, + FF935B202F78A83100ED3330 /* TTSKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -65,6 +74,9 @@ ); name = LockInBro; packageProductDependencies = ( + FF935B1D2F78A83100ED3330 /* SpeakerKit */, + FF935B1F2F78A83100ED3330 /* TTSKit */, + FF935B212F78A83100ED3330 /* WhisperKit */, ); productName = LockInBro; productReference = FF3296C22F785B3300C734EB /* LockInBro.app */; @@ -94,6 +106,9 @@ ); mainGroup = FF3296B92F785B3300C734EB; minimizedProjectReferenceProxies = 1; + packageReferences = ( + FF935B1C2F78A83100ED3330 /* XCRemoteSwiftPackageReference "WhisperKit" */, + ); preferredProjectObjectVersion = 77; productRefGroup = FF3296C32F785B3300C734EB /* Products */; projectDirPath = ""; @@ -248,21 +263,31 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = LockInBro/LockInBro.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - ENABLE_APP_SANDBOX = YES; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = YK2DB9NT3S; + ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBro/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Lock​In​Bro uses your microphone to transcribe voice brain dumps into tasks."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Lock​In​Bro uses speech recognition to convert your spoken thoughts into tasks."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = yhack.LockInBro; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBro; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "Joy Zhuo"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -278,21 +303,31 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = LockInBro/LockInBro.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - ENABLE_APP_SANDBOX = YES; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = YK2DB9NT3S; + ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBro/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Lock​In​Bro uses your microphone to transcribe voice brain dumps into tasks."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Lock​In​Bro uses speech recognition to convert your spoken thoughts into tasks."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = yhack.LockInBro; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBro; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "Joy Zhuo"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -325,6 +360,35 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + FF935B1C2F78A83100ED3330 /* XCRemoteSwiftPackageReference "WhisperKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/argmaxinc/WhisperKit"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + FF935B1D2F78A83100ED3330 /* SpeakerKit */ = { + isa = XCSwiftPackageProductDependency; + package = FF935B1C2F78A83100ED3330 /* XCRemoteSwiftPackageReference "WhisperKit" */; + productName = SpeakerKit; + }; + FF935B1F2F78A83100ED3330 /* TTSKit */ = { + isa = XCSwiftPackageProductDependency; + package = FF935B1C2F78A83100ED3330 /* XCRemoteSwiftPackageReference "WhisperKit" */; + productName = TTSKit; + }; + FF935B212F78A83100ED3330 /* WhisperKit */ = { + isa = XCSwiftPackageProductDependency; + package = FF935B1C2F78A83100ED3330 /* XCRemoteSwiftPackageReference "WhisperKit" */; + productName = WhisperKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = FF3296BA2F785B3300C734EB /* Project object */; } diff --git a/LockInBro.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/LockInBro.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/LockInBro.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/LockInBro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LockInBro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..8233b03 --- /dev/null +++ b/LockInBro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,78 @@ +{ + "originHash" : "e843284a09b9d7fb8d0032fe1a3fd1fbd38f28ea54d42a39ccbe396af16d225d", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "9f542610331815e29cc3821d3b6f488db8715517", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "fa308c07a6fa04a727212d793e761460e41049c3", + "version" : "4.3.0" + } + }, + { + "identity" : "swift-jinja", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-jinja.git", + "state" : { + "revision" : "0aeefadec459ce8e11a333769950fb86183aca43", + "version" : "2.3.5" + } + }, + { + "identity" : "swift-transformers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-transformers.git", + "state" : { + "revision" : "150169bfba0889c229a2ce7494cf8949f18e6906", + "version" : "1.1.9" + } + }, + { + "identity" : "whisperkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/argmaxinc/WhisperKit", + "state" : { + "branch" : "main", + "revision" : "3817d2833f73ceb30586cb285e0e0439a3860536" + } + }, + { + "identity" : "yyjson", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ibireme/yyjson.git", + "state" : { + "revision" : "8b4a38dc994a110abaec8a400615567bd996105f", + "version" : "0.12.0" + } + } + ], + "version" : 3 +} diff --git a/LockInBro.xcodeproj/project.xcworkspace/xcuserdata/joyzhuo.xcuserdatad/WorkspaceSettings.xcsettings b/LockInBro.xcodeproj/project.xcworkspace/xcuserdata/joyzhuo.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..723a561 --- /dev/null +++ b/LockInBro.xcodeproj/project.xcworkspace/xcuserdata/joyzhuo.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,16 @@ + + + + + BuildLocationStyle + UseAppPreferences + CompilationCachingSetting + Default + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/LockInBro/APIClient.swift b/LockInBro/APIClient.swift new file mode 100644 index 0000000..c261ad3 --- /dev/null +++ b/LockInBro/APIClient.swift @@ -0,0 +1,315 @@ +// APIClient.swift — Backend networking for wahwa.com/api/v1 + +import Foundation + +// MARK: - Errors + +enum NetworkError: Error, LocalizedError { + case noToken + case httpError(Int, String) + case decodingError(Error) + case unknown(Error) + + var errorDescription: String? { + switch self { + case .noToken: return "Not authenticated. Please log in." + case .httpError(let code, let msg): return "Server error \(code): \(msg)" + case .decodingError(let e): return "Parse error: \(e.localizedDescription)" + case .unknown(let e): return e.localizedDescription + } + } +} + +// MARK: - Token Storage (UserDefaults for hackathon simplicity) + +final class TokenStore { + static let shared = TokenStore() + private let key = "lockInBro.jwt" + private init() {} + + var token: String? { + get { UserDefaults.standard.string(forKey: key) } + set { + if let v = newValue { UserDefaults.standard.set(v, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + } + } +} + +// MARK: - APIClient + +final class APIClient { + static let shared = APIClient() + private let base = "https://wahwa.com/api/v1" + private let urlSession = URLSession.shared + private init() {} + + // MARK: Core Request + + private func req( + _ path: String, + method: String = "GET", + body: Data? = nil, + contentType: String = "application/json", + auth: Bool = true, + timeout: TimeInterval = 30 + ) async throws -> Data { + guard let url = URL(string: base + path) else { + throw NetworkError.unknown(URLError(.badURL)) + } + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = timeout + + if auth { + guard let token = TokenStore.shared.token else { throw NetworkError.noToken } + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let body { + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.httpBody = body + } + + let (data, response) = try await urlSession.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw NetworkError.unknown(URLError(.badServerResponse)) + } + guard http.statusCode < 400 else { + let msg = (try? JSONDecoder().decode(APIErrorResponse.self, from: data))?.detail + ?? String(data: data, encoding: .utf8) + ?? "Unknown error" + throw NetworkError.httpError(http.statusCode, msg) + } + return data + } + + private func decode(_ type: T.Type, from data: Data) throws -> T { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + do { + return try decoder.decode(type, from: data) + } catch { + throw NetworkError.decodingError(error) + } + } + + // MARK: - Auth + + func login(email: String, password: String) async throws -> AuthResponse { + let body = try JSONSerialization.data(withJSONObject: [ + "email": email, "password": password + ]) + let data = try await req("/auth/login", method: "POST", body: body, auth: false) + return try decode(AuthResponse.self, from: data) + } + + func appleAuth(identityToken: String, authorizationCode: String, fullName: String?) async throws -> AuthResponse { + var dict: [String: Any] = [ + "identity_token": identityToken, + "authorization_code": authorizationCode + ] + if let name = fullName { dict["full_name"] = name } + let body = try JSONSerialization.data(withJSONObject: dict) + let data = try await req("/auth/apple", method: "POST", body: body, auth: false) + return try decode(AuthResponse.self, from: data) + } + + func register(email: String, password: String, displayName: String) async throws -> AuthResponse { + let body = try JSONSerialization.data(withJSONObject: [ + "email": email, + "password": password, + "display_name": displayName, + "timezone": TimeZone.current.identifier + ]) + let data = try await req("/auth/register", method: "POST", body: body, auth: false) + return try decode(AuthResponse.self, from: data) + } + + // MARK: - Tasks + + func getTasks(status: String? = nil) async throws -> [AppTask] { + var path = "/tasks" + if let status { path += "?status=\(status)" } + let data = try await req(path) + return try decode([AppTask].self, from: data) + } + + func getUpcomingTasks() async throws -> [AppTask] { + let data = try await req("/tasks/upcoming") + return try decode([AppTask].self, from: data) + } + + func createTask(title: String, description: String?, priority: Int, deadline: String?, estimatedMinutes: Int?, tags: [String]) async throws -> AppTask { + var dict: [String: Any] = ["title": title, "priority": priority, "tags": tags] + if let d = description { dict["description"] = d } + if let dl = deadline { dict["deadline"] = dl } + if let em = estimatedMinutes { dict["estimated_minutes"] = em } + let body = try JSONSerialization.data(withJSONObject: dict) + let data = try await req("/tasks", method: "POST", body: body) + return try decode(AppTask.self, from: data) + } + + func updateTask(taskId: String, title: String? = nil, description: String? = nil, priority: Int? = nil, status: String? = nil, deadline: String? = nil, estimatedMinutes: Int? = nil, tags: [String]? = nil) async throws -> AppTask { + var dict: [String: Any] = [:] + if let v = title { dict["title"] = v } + if let v = description { dict["description"] = v } + if let v = priority { dict["priority"] = v } + if let v = status { dict["status"] = v } + if let v = deadline { dict["deadline"] = v } + if let v = estimatedMinutes { dict["estimated_minutes"] = v } + if let v = tags { dict["tags"] = v } + let body = try JSONSerialization.data(withJSONObject: dict) + let data = try await req("/tasks/\(taskId)", method: "PATCH", body: body) + return try decode(AppTask.self, from: data) + } + + func deleteTask(taskId: String) async throws { + _ = try await req("/tasks/\(taskId)", method: "DELETE") + } + + func brainDump(rawText: String) async throws -> BrainDumpResponse { + let body = try JSONSerialization.data(withJSONObject: [ + "raw_text": rawText, + "source": "manual", + "timezone": TimeZone.current.identifier + ]) + let data = try await req("/tasks/brain-dump", method: "POST", body: body, timeout: 120) + return try decode(BrainDumpResponse.self, from: data) + } + + func planTask(taskId: String) async throws -> StepPlanResponse { + let body = try JSONSerialization.data(withJSONObject: ["plan_type": "llm_generated"]) + let data = try await req("/tasks/\(taskId)/plan", method: "POST", body: body) + return try decode(StepPlanResponse.self, from: data) + } + + // MARK: - Steps + + func getSteps(taskId: String) async throws -> [Step] { + let data = try await req("/tasks/\(taskId)/steps") + return try decode([Step].self, from: data) + } + + func updateStep(stepId: String, status: String? = nil, checkpointNote: String? = nil) async throws -> Step { + var dict: [String: Any] = [:] + if let v = status { dict["status"] = v } + if let v = checkpointNote { dict["checkpoint_note"] = v } + let body = try JSONSerialization.data(withJSONObject: dict) + let data = try await req("/steps/\(stepId)", method: "PATCH", body: body) + return try decode(Step.self, from: data) + } + + func completeStep(stepId: String) async throws -> Step { + let data = try await req("/steps/\(stepId)/complete", method: "POST") + return try decode(Step.self, from: data) + } + + // MARK: - Sessions + + func startSession(taskId: String?) async throws -> FocusSession { + var dict: [String: Any] = ["platform": "mac"] + if let tid = taskId { dict["task_id"] = tid } + let body = try JSONSerialization.data(withJSONObject: dict) + let data = try await req("/sessions/start", method: "POST", body: body) + return try decode(FocusSession.self, from: data) + } + + func endSession(sessionId: String, status: String = "completed") async throws -> FocusSession { + let body = try JSONSerialization.data(withJSONObject: ["status": status]) + let data = try await req("/sessions/\(sessionId)/end", method: "POST", body: body) + return try decode(FocusSession.self, from: data) + } + + func resumeSession(sessionId: String) async throws -> ResumeResponse { + let data = try await req("/sessions/\(sessionId)/resume") + return try decode(ResumeResponse.self, from: data) + } + + func checkpointSession( + sessionId: String, + currentStepId: String? = nil, + lastActionSummary: String? = nil, + nextUp: String? = nil, + goal: String? = nil, + activeApp: String? = nil, + lastScreenshotAnalysis: String? = nil, + attentionScore: Int? = nil, + distractionCount: Int? = nil + ) async throws { + var dict: [String: Any] = [:] + if let v = currentStepId { dict["current_step_id"] = v } + if let v = lastActionSummary { dict["last_action_summary"] = v } + if let v = nextUp { dict["next_up"] = v } + if let v = goal { dict["goal"] = v } + if let v = activeApp { dict["active_app"] = v } + if let v = lastScreenshotAnalysis { dict["last_screenshot_analysis"] = v } + if let v = attentionScore { dict["attention_score"] = v } + if let v = distractionCount { dict["distraction_count"] = v } + let body = try JSONSerialization.data(withJSONObject: dict) + _ = try await req("/sessions/\(sessionId)/checkpoint", method: "POST", body: body) + } + + // MARK: - App Activity + + func appActivity( + sessionId: String, + appBundleId: String, + appName: String, + durationSeconds: Int, + returnedToTask: Bool = false + ) async throws { + let body = try JSONSerialization.data(withJSONObject: [ + "session_id": sessionId, + "app_bundle_id": appBundleId, + "app_name": appName, + "duration_seconds": durationSeconds, + "returned_to_task": returnedToTask + ] as [String: Any]) + _ = try await req("/distractions/app-activity", method: "POST", body: body) + } + + // MARK: - Distraction / Screenshot Analysis + // Note: spec primary endpoint is POST /distractions/analyze-result (device-side VLM, JSON only). + // Backend currently implements analyze-screenshot (legacy fallback) — using that until analyze-result is deployed. + + func analyzeScreenshot( + imageData: Data, + windowTitle: String, + sessionId: String, + taskContext: [String: Any] + ) async throws -> DistractionAnalysisResponse { + let boundary = "LockInBro-\(UUID().uuidString.prefix(8))" + var body = Data() + + func appendField(_ name: String, value: String) { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) + body.append(value.data(using: .utf8)!) + body.append("\r\n".data(using: .utf8)!) + } + + // Screenshot binary + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"screenshot\"; filename=\"screenshot.jpg\"\r\n".data(using: .utf8)!) + body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!) + body.append(imageData) + body.append("\r\n".data(using: .utf8)!) + + appendField("window_title", value: windowTitle) + appendField("session_id", value: sessionId) + + let contextJSON = String(data: (try? JSONSerialization.data(withJSONObject: taskContext)) ?? Data(), encoding: .utf8) ?? "{}" + appendField("task_context", value: contextJSON) + + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + + let data = try await req( + "/distractions/analyze-screenshot", + method: "POST", + body: body, + contentType: "multipart/form-data; boundary=\(boundary)", + timeout: 60 + ) + return try decode(DistractionAnalysisResponse.self, from: data) + } +} diff --git a/LockInBro/AuthManager.swift b/LockInBro/AuthManager.swift new file mode 100644 index 0000000..57221a4 --- /dev/null +++ b/LockInBro/AuthManager.swift @@ -0,0 +1,74 @@ +// AuthManager.swift — Authentication state + +import SwiftUI + +@Observable +@MainActor +final class AuthManager { + static let shared = AuthManager() + + var isLoggedIn: Bool = false + var currentUser: User? + var isLoading: Bool = false + var errorMessage: String? + + private init() { + isLoggedIn = TokenStore.shared.token != nil + } + + func login(email: String, password: String) async { + isLoading = true + errorMessage = nil + do { + let response = try await APIClient.shared.login(email: email, password: password) + TokenStore.shared.token = response.accessToken + currentUser = response.user + isLoggedIn = true + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + func register(email: String, password: String, displayName: String) async { + isLoading = true + errorMessage = nil + do { + let response = try await APIClient.shared.register( + email: email, + password: password, + displayName: displayName + ) + TokenStore.shared.token = response.accessToken + currentUser = response.user + isLoggedIn = true + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + func loginWithApple(identityToken: String, authorizationCode: String, fullName: String?) async { + isLoading = true + errorMessage = nil + do { + let response = try await APIClient.shared.appleAuth( + identityToken: identityToken, + authorizationCode: authorizationCode, + fullName: fullName + ) + TokenStore.shared.token = response.accessToken + currentUser = response.user + isLoggedIn = true + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + func logout() { + TokenStore.shared.token = nil + currentUser = nil + isLoggedIn = false + } +} diff --git a/LockInBro/BrainDumpView.swift b/LockInBro/BrainDumpView.swift new file mode 100644 index 0000000..6b38efb --- /dev/null +++ b/LockInBro/BrainDumpView.swift @@ -0,0 +1,430 @@ +// BrainDumpView.swift — Brain-dump text → Claude parses + saves tasks → generate step plans + +import SwiftUI + +struct BrainDumpView: View { + /// Called when user wants to navigate to the task board + var onGoToTasks: (() -> Void)? + + @State private var rawText = "" + @State private var recorder = VoiceDumpRecorder() + @State private var parsedTasks: [ParsedTask] = [] + @State private var unparseableFragments: [String] = [] + @State private var isParsing = false + @State private var errorMessage: String? + @State private var isDone = false + + // After dump, fetch actual tasks (with IDs) for step generation + @State private var savedTasks: [AppTask] = [] + @State private var isFetchingTasks = false + @State private var planningTaskId: String? + @State private var planError: String? + @State private var generatedSteps: [String: [Step]] = [:] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if isDone { + donePhase + } else { + inputPhase + } + } + .padding() + } + .onAppear { recorder.warmUp() } + } + + // MARK: - Input Phase + + private var inputPhase: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Brain Dump") + .font(.title2.bold()) + Text("Just type everything on your mind. Claude will organize it into tasks for you.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + TextEditor(text: $rawText) + .font(.body) + .frame(minHeight: 200) + .padding(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + .overlay(alignment: .topLeading) { + if rawText.isEmpty { + Text("e.g. I need to email Sarah about the project, dentist Thursday, presentation due Friday, buy groceries…") + .foregroundStyle(.secondary.opacity(0.5)) + .font(.body) + .padding(12) + .allowsHitTesting(false) + } + } + + // Voice dump bar + voiceDumpBar + + if let err = errorMessage { + Text(err) + .font(.caption) + .foregroundStyle(.red) + } + + Button { + Task { await parseDump() } + } label: { + Group { + if isParsing { + HStack(spacing: 8) { + ProgressView() + Text("Claude is parsing your tasks…") + } + } else { + Label("Parse & Save Tasks", systemImage: "wand.and.stars") + } + } + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.borderedProminent) + .disabled(rawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isParsing) + } + } + + // MARK: - Done Phase + + private var donePhase: some View { + VStack(spacing: 20) { + // Success header + VStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(.green) + Text("\(parsedTasks.count) task\(parsedTasks.count == 1 ? "" : "s") saved!") + .font(.title2.bold()) + Text("Tasks are in your board. Generate steps to break them into 5–15 min chunks.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + + // Parsed task previews — 2-column grid + if !parsedTasks.isEmpty { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + ForEach(parsedTasks) { task in + ParsedTaskPreviewRow(task: task) + } + } + } + + // Unparseable fragments + if !unparseableFragments.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Couldn't parse:") + .font(.caption.bold()) + .foregroundStyle(.secondary) + ForEach(unparseableFragments, id: \.self) { fragment in + Text("• \(fragment)") + .font(.caption) + .foregroundStyle(.secondary) + .italic() + } + } + .padding() + .background(Color.secondary.opacity(0.08)) + .clipShape(.rect(cornerRadius: 8)) + } + + Divider() + + // Step generation section + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Generate Steps") + .font(.headline) + if isFetchingTasks { + ProgressView().scaleEffect(0.8) + } + Spacer() + } + + if let err = planError { + Text(err) + .font(.caption) + .foregroundStyle(.red) + } + + if savedTasks.isEmpty || isFetchingTasks { + HStack(spacing: 8) { + ProgressView().scaleEffect(0.8) + Text("Generating steps…") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + ForEach(savedTasks.prefix(parsedTasks.count)) { task in + VStack(alignment: .leading, spacing: 6) { + // Task header + HStack { + Text(task.title) + .font(.subheadline.bold()) + .lineLimit(1) + Spacer() + if task.planType == nil { + HStack(spacing: 4) { + ProgressView().scaleEffect(0.7) + Text("Generating…") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + + // Steps list + if let steps = generatedSteps[task.id], !steps.isEmpty { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(steps.enumerated()), id: \.element.id) { i, step in + HStack(alignment: .top, spacing: 6) { + Text("\(i + 1).") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: 16, alignment: .trailing) + Text(step.title) + .font(.caption) + .foregroundStyle(.primary) + if let mins = step.estimatedMinutes { + Spacer() + Text("~\(mins)m") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .padding(.leading, 4) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.06)) + .clipShape(.rect(cornerRadius: 8)) + } + } + } + + Divider() + + // Bottom actions + HStack(spacing: 12) { + Button { + onGoToTasks?() + } label: { + Label("Go to Task Board", systemImage: "checklist") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + Button("Dump more") { + reset() + } + .buttonStyle(.bordered) + } + } + } + + // MARK: - Voice Dump Bar + + private var voiceDumpBar: some View { + HStack(spacing: 10) { + Button { + if recorder.isRecording { + Task { await recorder.stopRecording() } + } else { + Task { await startVoiceRecording() } + } + } label: { + Label( + recorder.isRecording ? "Stop" : (recorder.isTranscribing ? "Transcribing…" : "Voice Dump"), + systemImage: recorder.isRecording ? "stop.circle.fill" : "mic.fill" + ) + .foregroundStyle(recorder.isRecording ? .red : .accentColor) + .symbolEffect(.pulse, isActive: recorder.isRecording || recorder.isTranscribing) + } + .buttonStyle(.bordered) + .disabled(recorder.isTranscribing) + + if recorder.isRecording { + Text("Listening…") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else if recorder.isTranscribing { + HStack(spacing: 6) { + ProgressView().scaleEffect(0.7) + Text("Whisper is transcribing…") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if recorder.permissionDenied { + Text("Microphone access denied in System Settings.") + .font(.caption) + .foregroundStyle(.red) + } + } + .onChange(of: recorder.isTranscribing) { _, isNowTranscribing in + // Append transcript into rawText once Whisper finishes + if !isNowTranscribing && !recorder.liveTranscript.isEmpty { + if !rawText.isEmpty { rawText += "\n" } + rawText += recorder.liveTranscript + recorder.liveTranscript = "" + } + } + } + + private func startVoiceRecording() async { + await recorder.requestPermissions() + guard !recorder.permissionDenied else { return } + recorder.startRecording() + } + + // MARK: - Actions + + private func parseDump() async { + isParsing = true + errorMessage = nil + do { + // Backend parses AND saves tasks in one call — we just display the result + let response = try await APIClient.shared.brainDump(rawText: rawText) + parsedTasks = response.parsedTasks + unparseableFragments = response.unparseableFragments + isDone = true + // Fetch actual tasks (with IDs) so user can generate steps + await fetchLatestTasks() + } catch { + errorMessage = error.localizedDescription + } + isParsing = false + } + + /// Fetch the most recently created tasks, then auto-generate steps for all of them + private func fetchLatestTasks() async { + isFetchingTasks = true + do { + let all = try await APIClient.shared.getTasks() + let parsedTitles = Set(parsedTasks.map(\.title)) + savedTasks = all.filter { parsedTitles.contains($0.title) } + if savedTasks.isEmpty { + savedTasks = Array(all.prefix(parsedTasks.count)) + } + } catch {} + isFetchingTasks = false + + // Auto-generate steps for any task that doesn't have a plan yet + let tasksNeedingPlan = savedTasks.filter { $0.planType == nil } + await withTaskGroup(of: Void.self) { group in + for task in tasksNeedingPlan { + group.addTask { await generatePlan(task) } + } + } + } + + private func generatePlan(_ task: AppTask) async { + planningTaskId = task.id + planError = nil + do { + let response = try await APIClient.shared.planTask(taskId: task.id) + generatedSteps[task.id] = response.steps.sorted { $0.sortOrder < $1.sortOrder } + // Mark the task as planned locally + if let idx = savedTasks.firstIndex(where: { $0.id == task.id }) { + savedTasks[idx].planType = response.planType + } + } catch { + planError = error.localizedDescription + } + planningTaskId = nil + } + + private func reset() { + rawText = "" + parsedTasks = [] + unparseableFragments = [] + savedTasks = [] + generatedSteps = [:] + errorMessage = nil + planningTaskId = nil + planError = nil + isDone = false + } +} + +// MARK: - Parsed Task Preview Row + +private struct ParsedTaskPreviewRow: View { + let task: ParsedTask + + private var priorityColor: Color { + switch task.priority { + case 4: return .red + case 3: return .orange + case 2: return .yellow + default: return .green + } + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(priorityColor) + .frame(width: 7, height: 7) + .padding(.top, 7) + + VStack(alignment: .leading, spacing: 3) { + Text(task.title) + .font(.subheadline.bold()) + + HStack(spacing: 8) { + if let mins = task.estimatedMinutes { + Label("~\(mins)m", systemImage: "clock") + .font(.caption2) + .foregroundStyle(.secondary) + } + if let dl = task.deadline { + Label(shortDate(dl), systemImage: "calendar") + .font(.caption2) + .foregroundStyle(.secondary) + } + ForEach(task.tags, id: \.self) { tag in + Text(tag) + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.1)) + .clipShape(.capsule) + } + } + } + } + .padding() + .background(Color.secondary.opacity(0.06)) + .clipShape(.rect(cornerRadius: 10)) + } + + private func shortDate(_ iso: String) -> String { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = f.date(from: iso) { + return d.formatted(.dateTime.month(.abbreviated).day()) + } + f.formatOptions = [.withInternetDateTime] + if let d = f.date(from: iso) { + return d.formatted(.dateTime.month(.abbreviated).day()) + } + return iso + } +} diff --git a/LockInBro/ContentView.swift b/LockInBro/ContentView.swift index 465cd0b..6513027 100644 --- a/LockInBro/ContentView.swift +++ b/LockInBro/ContentView.swift @@ -1,24 +1,225 @@ -// -// ContentView.swift -// LockInBro -// -// Created by Joy Zhuo on 3/28/26. -// +// ContentView.swift — Auth gate + main tab navigation import SwiftUI struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + @Environment(AuthManager.self) private var auth + @Environment(SessionManager.self) private var session + + @State private var selectedTab: AppTab = .tasks + @State private var showingSettings = false + @AppStorage("geminiApiKey") private var geminiApiKey = "" + + enum AppTab: String, CaseIterable { + case tasks = "Tasks" + case brainDump = "Brain Dump" + case focusSession = "Focus" + + var systemImage: String { + switch self { + case .tasks: return "checklist" + case .brainDump: return "brain.head.profile" + case .focusSession: return "target" + } } - .padding() + } + + var body: some View { + if !auth.isLoggedIn { + LoginView() + } else { + mainContent + } + } + + private var mainContent: some View { + NavigationSplitView { + // Sidebar + List(AppTab.allCases, id: \.self, selection: $selectedTab) { tab in + Label(tab.rawValue, systemImage: tab.systemImage) + .badge(tab == .focusSession && session.isSessionActive ? "●" : nil) + } + .navigationSplitViewColumnWidth(min: 160, ideal: 180) + .navigationTitle("LockInBro") + .toolbar { + ToolbarItem { + Button { + showingSettings = true + } label: { + Image(systemName: "gear") + } + .help("Settings") + } + ToolbarItem { + Button { + auth.logout() + } label: { + Image(systemName: "rectangle.portrait.and.arrow.right") + } + .help("Sign out") + } + } + .sheet(isPresented: $showingSettings) { + SettingsSheet(geminiApiKey: $geminiApiKey) + } + } detail: { + NavigationStack { + detailView + .navigationTitle(selectedTab.rawValue) + } + } + .frame(minWidth: 700, minHeight: 500) + // Auto-navigate to Focus tab when a session becomes active + .onChange(of: session.isSessionActive) { _, isActive in + if isActive { selectedTab = .focusSession } + } + // Active session banner at bottom + .safeAreaInset(edge: .bottom) { + if session.isSessionActive { + sessionBanner + } + } + } + + @ViewBuilder + private var detailView: some View { + switch selectedTab { + case .tasks: + TaskBoardView() + case .brainDump: + BrainDumpView(onGoToTasks: { selectedTab = .tasks }) + case .focusSession: + if session.isSessionActive { + FocusSessionView() + } else { + StartSessionPlaceholder { + selectedTab = .tasks + } + } + } + } + + private var sessionBanner: some View { + HStack(spacing: 12) { + Circle() + .fill(.green) + .frame(width: 8, height: 8) + .overlay( + Circle() + .fill(.green.opacity(0.3)) + .frame(width: 16, height: 16) + ) + + if let task = session.activeTask { + Text("Focusing on: \(task.title)") + .font(.subheadline.bold()) + .lineLimit(1) + } else { + Text("Focus session active") + .font(.subheadline.bold()) + } + + if let step = session.currentStep { + Text("· \(step.title)") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Button("View Session") { + selectedTab = .focusSession + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button { + Task { await session.endSession() } + } label: { + Image(systemName: "stop.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .help("End session") + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(.thinMaterial) + .overlay(alignment: .top) { Divider() } } } -#Preview { - ContentView() +// MARK: - Settings Sheet + +private struct SettingsSheet: View { + @Binding var geminiApiKey: String + @Environment(\.dismiss) private var dismiss + @State private var draft = "" + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Settings") + .font(.title2.bold()) + + VStack(alignment: .leading, spacing: 6) { + Text("Gemini API Key") + .font(.subheadline.bold()) + Text("Required for the VLM screen analysis agent.") + .font(.caption) + .foregroundStyle(.secondary) + SecureField("AIza…", text: $draft) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.escape) + Button("Save") { + geminiApiKey = draft.trimmingCharacters(in: .whitespaces) + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return) + .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(24) + .frame(width: 420) + .onAppear { draft = geminiApiKey } + } +} + +// MARK: - Start Session Placeholder + +private struct StartSessionPlaceholder: View { + let onGoToTasks: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "target") + .font(.system(size: 64)) + .foregroundStyle(.secondary) + + Text("No active session") + .font(.title2.bold()) + + Text("Go to your task board and tap the play button to start a focus session on a task.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 320) + + Button { + onGoToTasks() + } label: { + Label("Go to Tasks", systemImage: "checklist") + .frame(width: 160) + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } diff --git a/LockInBro/FocusSessionView.swift b/LockInBro/FocusSessionView.swift new file mode 100644 index 0000000..f262aa0 --- /dev/null +++ b/LockInBro/FocusSessionView.swift @@ -0,0 +1,463 @@ +// FocusSessionView.swift — Active focus session overlay + +import SwiftUI +import Combine + +struct FocusSessionView: View { + @Environment(SessionManager.self) private var session + + @State private var elapsed: TimeInterval = 0 + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + ZStack { + mainContent + + // Resume card overlay + if session.showingResumeCard, let card = session.resumeCard { + ResumeCardOverlay(card: card) { + session.showingResumeCard = false + } + .transition(.scale.combined(with: .opacity)) + } + } + .onReceive(timer) { _ in + elapsed = session.sessionElapsed + } + } + + private var mainContent: some View { + VStack(spacing: 0) { + // Header bar + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(session.activeTask?.title ?? "Open Focus Session") + .font(.headline) + .lineLimit(1) + Text(formatElapsed(elapsed)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + Spacer() + + HStack(spacing: 12) { + // Distraction count + if session.distractionCount > 0 { + Label("\(session.distractionCount)", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + + Button("End Session") { + Task { await session.endSession() } + } + .buttonStyle(.bordered) + .controlSize(.small) + .foregroundStyle(.red) + } + } + .padding() + .background(Color.blue.opacity(0.08)) + + Divider() + + // Argus pipeline status bar (debug — always visible when session is active) + if !session.argusStatus.isEmpty { + HStack(spacing: 6) { + Text(session.argusStatus) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.06)) + Divider() + } + + // Proactive action card (VLM friction or app-switch loop) + if let card = session.proactiveCard { + ProactiveCardView(card: card, onDismiss: { + withAnimation { session.proactiveCard = nil } + }, onApprove: { label in + session.approvedActionLabel = label + withAnimation { session.proactiveCard = nil } + // Clear toast after 4 seconds + Task { + try? await Task.sleep(for: .seconds(4)) + session.approvedActionLabel = nil + } + }) + .padding(.horizontal) + .padding(.top, 10) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + // Approved action confirmation toast + if let label = session.approvedActionLabel { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Approved: \(label)") + .font(.caption.bold()) + .foregroundStyle(.green) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 6) + .background(Color.green.opacity(0.08)) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + + // Current step card + if let step = session.currentStep { + CurrentStepCard( + step: step, + onMarkDone: { Task { await session.completeCurrentStep() } } + ) + } + + // Step progress + if !session.activeSteps.isEmpty { + StepProgressSection( + steps: session.activeSteps, + currentIndex: session.currentStepIndex + ) + } + + // Latest nudge + if let nudge = session.lastNudge { + NudgeCard(message: nudge) + } + + // No task message + if session.activeTask == nil { + VStack(spacing: 8) { + Image(systemName: "target") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text("No task selected") + .font(.headline) + .foregroundStyle(.secondary) + Text("You can still track time and detect distractions") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding() + } + } + .padding() + } + + // No active session + } + } +} + +// MARK: - Current Step Card + +private struct CurrentStepCard: View { + let step: Step + let onMarkDone: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Now", systemImage: "arrow.right.circle.fill") + .font(.caption.bold()) + .foregroundStyle(.blue) + Spacer() + if let mins = step.estimatedMinutes { + Text("~\(mins) min") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Text(step.title) + .font(.title3.bold()) + + if let desc = step.description { + Text(desc) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if let note = step.checkpointNote { + HStack(spacing: 6) { + Image(systemName: "bookmark.fill") + .font(.caption) + .foregroundStyle(.blue) + Text(note) + .font(.caption) + .foregroundStyle(.blue) + .italic() + } + .padding(8) + .background(Color.blue.opacity(0.08)) + .clipShape(.rect(cornerRadius: 6)) + } + + Button { + onMarkDone() + } label: { + Label("Mark Step Done", systemImage: "checkmark") + .frame(maxWidth: .infinity) + .frame(height: 32) + } + .buttonStyle(.borderedProminent) + } + .padding() + .background(Color.blue.opacity(0.06)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.blue.opacity(0.2), lineWidth: 1) + ) + .clipShape(.rect(cornerRadius: 12)) + } +} + +// MARK: - Step Progress Section + +private struct StepProgressSection: View { + let steps: [Step] + let currentIndex: Int + + private var completed: Int { steps.filter(\.isDone).count } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Progress") + .font(.subheadline.bold()) + Spacer() + Text("\(completed) / \(steps.count) steps") + .font(.caption) + .foregroundStyle(.secondary) + } + + ProgressView(value: Double(completed), total: Double(steps.count)) + .progressViewStyle(.linear) + .tint(.blue) + + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(steps.enumerated()), id: \.element.id) { index, step in + HStack(spacing: 8) { + // Status icon + Group { + if step.isDone { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } else if index == currentIndex { + Image(systemName: "circle.fill").foregroundStyle(.blue) + } else { + Image(systemName: "circle").foregroundStyle(.secondary) + } + } + .font(.system(size: 12)) + + Text(step.title) + .font(.caption) + .foregroundStyle(step.isDone ? .secondary : .primary) + .strikethrough(step.isDone) + + Spacer() + } + } + } + } + } +} + +// MARK: - Nudge Card + +private struct NudgeCard: View { + let message: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "hand.wave.fill") + .foregroundStyle(.orange) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 2) { + Text("Hey!") + .font(.caption.bold()) + .foregroundStyle(.orange) + Text(message) + .font(.subheadline) + } + } + .padding() + .background(Color.orange.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.orange.opacity(0.2), lineWidth: 1) + ) + .clipShape(.rect(cornerRadius: 10)) + } +} + +// MARK: - Resume Card Overlay + +struct ResumeCardOverlay: View { + let card: ResumeCard + let onDismiss: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { onDismiss() } + + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "sparkles") + .foregroundStyle(.blue) + Text("Welcome back!") + .font(.headline) + Spacer() + Button { onDismiss() } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + + Divider() + + VStack(alignment: .leading, spacing: 10) { + ResumeRow(icon: "clock", color: .blue, text: card.youWereDoing) + ResumeRow(icon: "arrow.right.circle", color: .green, text: card.nextStep) + ResumeRow(icon: "star.fill", color: .yellow, text: card.motivation) + } + + Button { + onDismiss() + } label: { + Text("Let's go!") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.borderedProminent) + } + .padding(24) + .background(.regularMaterial) + .clipShape(.rect(cornerRadius: 16)) + .shadow(radius: 20) + .frame(maxWidth: 380) + .padding() + } + } +} + +private struct ResumeRow: View { + let icon: String + let color: Color + let text: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .foregroundStyle(color) + .frame(width: 20) + Text(text) + .font(.subheadline) + } + } +} + +// MARK: - Proactive Card + +private struct ProactiveCardView: View { + let card: ProactiveCard + let onDismiss: () -> Void + let onApprove: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + // Header + HStack(alignment: .top, spacing: 8) { + Image(systemName: card.icon) + .foregroundStyle(.purple) + .font(.title3) + + VStack(alignment: .leading, spacing: 2) { + Text(card.title) + .font(.subheadline.bold()) + .foregroundStyle(.purple) + Text(bodyText) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Button { onDismiss() } label: { + Image(systemName: "xmark") + .font(.caption.bold()) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + + // Action buttons — shown for VLM-detected friction with proposed actions + if case .vlmFriction(_, _, let actions) = card.source, !actions.isEmpty { + HStack(spacing: 8) { + // Show up to 2 proposed actions + ForEach(Array(actions.prefix(2).enumerated()), id: \.offset) { _, action in + Button { + onApprove(action.label) + } label: { + Text(action.label) + .font(.caption.bold()) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.purple.opacity(0.15)) + .clipShape(.capsule) + } + .buttonStyle(.plain) + .foregroundStyle(.purple) + } + Spacer() + Button("Not now") { onDismiss() } + .font(.caption) + .foregroundStyle(.secondary) + .buttonStyle(.plain) + } + } + } + .padding(12) + .background(Color.purple.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.purple.opacity(0.2), lineWidth: 1) + ) + .clipShape(.rect(cornerRadius: 10)) + } + + private var bodyText: String { + switch card.source { + case .vlmFriction(_, let description, _): + return description ?? "I noticed something that might be slowing you down." + case .appSwitchLoop(let apps, let count): + return "You've switched between \(apps.joined(separator: " ↔ ")) \(count)× in a row — are you stuck?" + } + } +} + +// MARK: - Helpers + +private func formatElapsed(_ elapsed: TimeInterval) -> String { + let minutes = Int(elapsed) / 60 + let seconds = Int(elapsed) % 60 + return String(format: "%02d:%02d", minutes, seconds) +} diff --git a/LockInBro/Info.plist b/LockInBro/Info.plist new file mode 100644 index 0000000..5017bd4 --- /dev/null +++ b/LockInBro/Info.plist @@ -0,0 +1,8 @@ + + + + + NSSpeeachRecognitionUsageDescription + Lock​In​Bro uses speech recognition to convert your spoken thoughts into tasks. + + diff --git a/LockInBro/LockInBro.entitlements b/LockInBro/LockInBro.entitlements new file mode 100644 index 0000000..5f10c1e --- /dev/null +++ b/LockInBro/LockInBro.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.applesignin + + Default + + com.apple.security.app-sandbox + + + diff --git a/LockInBro/LockInBroApp.swift b/LockInBro/LockInBroApp.swift index 219ea62..d2ca6f4 100644 --- a/LockInBro/LockInBroApp.swift +++ b/LockInBro/LockInBroApp.swift @@ -1,17 +1,35 @@ -// -// LockInBroApp.swift -// LockInBro -// -// Created by Joy Zhuo on 3/28/26. -// +// LockInBroApp.swift — App entry point with menu bar + main window import SwiftUI @main struct LockInBroApp: App { + @State private var auth = AuthManager.shared + @State private var session = SessionManager.shared + var body: some Scene { - WindowGroup { + // Main window + WindowGroup("LockInBro") { ContentView() + .environment(auth) + .environment(session) } + .defaultSize(width: 840, height: 580) + + // Menu bar extra + MenuBarExtra { + MenuBarView() + .environment(auth) + .environment(session) + } label: { + // Show a filled icon when a session is active + if session.isSessionActive { + Image(systemName: "brain.head.profile") + .symbolEffect(.pulse) + } else { + Image(systemName: "brain.head.profile") + } + } + .menuBarExtraStyle(.window) } } diff --git a/LockInBro/LoginView.swift b/LockInBro/LoginView.swift new file mode 100644 index 0000000..358fe0f --- /dev/null +++ b/LockInBro/LoginView.swift @@ -0,0 +1,213 @@ +// LoginView.swift — Email/password + Sign in with Apple + +import SwiftUI +import AuthenticationServices + +// MARK: - Apple Sign In Coordinator (macOS window anchor) + +@MainActor +final class AppleSignInCoordinator: NSObject, + ASAuthorizationControllerDelegate, + ASAuthorizationControllerPresentationContextProviding +{ + var onResult: ((Result) -> Void)? + + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + NSApplication.shared.windows.first { $0.isKeyWindow } + ?? NSApplication.shared.windows.first + ?? NSWindow() + } + + func authorizationController(controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization) { + onResult?(.success(authorization)) + } + + func authorizationController(controller: ASAuthorizationController, + didCompleteWithError error: Error) { + onResult?(.failure(error)) + } + + func start(scopes: [ASAuthorization.Scope]) { + let provider = ASAuthorizationAppleIDProvider() + let request = provider.createRequest() + request.requestedScopes = scopes + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } +} + +// MARK: - LoginView + +struct LoginView: View { + @Environment(AuthManager.self) private var auth + + @State private var email = "" + @State private var password = "" + @State private var displayName = "" + @State private var isRegistering = false + @State private var coordinator = AppleSignInCoordinator() + + var body: some View { + VStack(spacing: 0) { + // Header + VStack(spacing: 8) { + Image(systemName: "brain.head.profile") + .font(.system(size: 48)) + .foregroundStyle(.blue) + Text("LockInBro") + .font(.largeTitle.bold()) + Text("ADHD-aware focus assistant") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.top, 40) + .padding(.bottom, 28) + + VStack(spacing: 14) { + + // ── Sign in with Apple ────────────────────────────────── + Button { + triggerAppleSignIn() + } label: { + HStack(spacing: 8) { + Image(systemName: "applelogo") + .font(.system(size: 16, weight: .medium)) + Text(isRegistering ? "Sign up with Apple" : "Sign in with Apple") + .font(.system(size: 15, weight: .medium)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color.primary) + .foregroundStyle(Color(nsColor: .windowBackgroundColor)) + .clipShape(.rect(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(auth.isLoading) + + // ── Divider ───────────────────────────────────────────── + HStack { + Rectangle().fill(Color.secondary.opacity(0.3)).frame(height: 1) + Text("or") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + Rectangle().fill(Color.secondary.opacity(0.3)).frame(height: 1) + } + + // ── Email / password form ─────────────────────────────── + if isRegistering { + TextField("Display Name", text: $displayName) + .textFieldStyle(.roundedBorder) + } + + TextField("Email", text: $email) + .textFieldStyle(.roundedBorder) + .textContentType(.emailAddress) + + SecureField("Password", text: $password) + .textFieldStyle(.roundedBorder) + .textContentType(isRegistering ? .newPassword : .password) + + if let err = auth.errorMessage { + Text(err) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + } + + Button { + Task { + if isRegistering { + await auth.register(email: email, password: password, displayName: displayName) + } else { + await auth.login(email: email, password: password) + } + } + } label: { + Group { + if auth.isLoading { + ProgressView() + } else { + Text(isRegistering ? "Create Account" : "Sign In") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.borderedProminent) + .disabled(auth.isLoading || email.isEmpty || password.isEmpty) + + Button { + isRegistering.toggle() + auth.errorMessage = nil + } label: { + Text(isRegistering ? "Already have an account? Sign in" : "New here? Create account") + .font(.footnote) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 40) + + Spacer() + } + .frame(width: 360, height: 520) + } + + // MARK: - Apple Sign In trigger + + private func triggerAppleSignIn() { + coordinator.onResult = { result in + Task { @MainActor in + handleAppleResult(result) + } + } + coordinator.start(scopes: [.fullName, .email]) + } + + private func handleAppleResult(_ result: Result) { + switch result { + case .success(let authorization): + guard + let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let tokenData = credential.identityToken, + let identityToken = String(data: tokenData, encoding: .utf8) + else { + auth.errorMessage = "Apple Sign In failed — could not read identity token" + return + } + + let authorizationCode = credential.authorizationCode + .flatMap { String(data: $0, encoding: .utf8) } ?? "" + + let fullName: String? = { + let parts = [credential.fullName?.givenName, credential.fullName?.familyName] + .compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " ") + }() + + Task { + await auth.loginWithApple( + identityToken: identityToken, + authorizationCode: authorizationCode, + fullName: fullName + ) + } + + case .failure(let error): + if (error as? ASAuthorizationError)?.code != .canceled { + auth.errorMessage = error.localizedDescription + } + } + } +} + +#Preview { + LoginView() + .environment(AuthManager.shared) +} diff --git a/LockInBro/MenuBarView.swift b/LockInBro/MenuBarView.swift new file mode 100644 index 0000000..bdc1854 --- /dev/null +++ b/LockInBro/MenuBarView.swift @@ -0,0 +1,160 @@ +// MenuBarView.swift — Menu bar popover content + +import SwiftUI + +struct MenuBarView: View { + @Environment(AuthManager.self) private var auth + @Environment(SessionManager.self) private var session + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Session status + sessionStatusSection + + Divider() + + // Actions + actionsSection + + Divider() + + // Bottom + HStack { + Text(auth.currentUser?.displayName ?? auth.currentUser?.email ?? "LockInBro") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Sign Out") { auth.logout() } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(width: 280) + } + + private var sessionStatusSection: some View { + HStack(spacing: 10) { + Circle() + .fill(session.isSessionActive ? Color.green : Color.secondary.opacity(0.4)) + .frame(width: 8, height: 8) + + VStack(alignment: .leading, spacing: 2) { + Text(session.isSessionActive ? "Session Active" : "No Active Session") + .font(.subheadline.bold()) + + if let task = session.activeTask { + Text(task.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } else if session.isSessionActive { + Text("No task selected") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + if session.isSessionActive, session.distractionCount > 0 { + Label("\(session.distractionCount)", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private var actionsSection: some View { + VStack(spacing: 2) { + if session.isSessionActive { + MenuBarButton( + icon: "stop.circle", + title: "End Session", + color: .red + ) { + Task { await session.endSession() } + } + + if let step = session.currentStep { + MenuBarButton( + icon: "checkmark.circle", + title: "Mark '\(step.title.prefix(25))…' Done", + color: .green + ) { + Task { await session.completeCurrentStep() } + } + } + + MenuBarButton( + icon: "arrow.uturn.backward.circle", + title: "Show Resume Card", + color: .blue + ) { + Task { await session.fetchResumeCard() } + } + } else { + MenuBarButton( + icon: "play.circle", + title: "Start Focus Session", + color: .blue + ) { + // Opens main window — user picks task there + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } + } + + MenuBarButton( + icon: "macwindow", + title: "Open LockInBro", + color: .primary + ) { + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Menu Bar Button + +private struct MenuBarButton: View { + let icon: String + let title: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundStyle(color) + .frame(width: 16) + Text(title) + .font(.subheadline) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .contentShape(.rect) + } + .buttonStyle(.plain) + .background(Color.clear) + .hoverEffect() + } +} + +// hoverEffect for macOS (no-op style that adds highlight on hover) +private extension View { + @ViewBuilder + func hoverEffect() -> some View { + self.onHover { _ in } // triggers redraw; real hover highlight handled below + } +} diff --git a/LockInBro/Models.swift b/LockInBro/Models.swift new file mode 100644 index 0000000..71297d6 --- /dev/null +++ b/LockInBro/Models.swift @@ -0,0 +1,343 @@ +// Models.swift — LockInBro data models + +import Foundation + +// MARK: - Auth + +struct User: Codable, Identifiable { + let id: String + let email: String? + let displayName: String? + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id, email + case displayName = "display_name" + case createdAt = "created_at" + } +} + +struct AuthResponse: Codable { + let accessToken: String + let refreshToken: String + let expiresIn: Int + let user: User + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + case user + } +} + +// MARK: - Tasks + +struct AppTask: Identifiable, Codable, Hashable { + let id: String + var title: String + var description: String? + var priority: Int + var status: String + var deadline: String? + var estimatedMinutes: Int? + let source: String + var tags: [String] + var planType: String? + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id, title, description, priority, status, deadline, tags, source + case estimatedMinutes = "estimated_minutes" + case planType = "plan_type" + case createdAt = "created_at" + } + + var priorityLabel: String { + switch priority { + case 4: return "Urgent" + case 3: return "High" + case 2: return "Medium" + case 1: return "Low" + default: return "Unset" + } + } + + var priorityColor: String { + switch priority { + case 4: return "red" + case 3: return "orange" + case 2: return "yellow" + case 1: return "green" + default: return "gray" + } + } + + var isActive: Bool { status == "in_progress" } + var isDone: Bool { status == "done" } +} + +// MARK: - Steps + +struct Step: Identifiable, Codable, Hashable { + let id: String + let taskId: String + let sortOrder: Int + var title: String + var description: String? + var estimatedMinutes: Int? + var status: String + var checkpointNote: String? + var lastCheckedAt: String? + var completedAt: String? + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id, title, description, status + case taskId = "task_id" + case sortOrder = "sort_order" + case estimatedMinutes = "estimated_minutes" + case checkpointNote = "checkpoint_note" + case lastCheckedAt = "last_checked_at" + case completedAt = "completed_at" + case createdAt = "created_at" + } + + var isDone: Bool { status == "done" } + var isActive: Bool { status == "in_progress" } +} + +// MARK: - Focus Session + +struct FocusSession: Identifiable, Codable { + let id: String + let userId: String + var taskId: String? + let platform: String + let startedAt: String + var endedAt: String? + var status: String + + enum CodingKeys: String, CodingKey { + case id, platform, status + case userId = "user_id" + case taskId = "task_id" + case startedAt = "started_at" + case endedAt = "ended_at" + } +} + +// MARK: - Brain Dump + +struct BrainDumpResponse: Codable { + let parsedTasks: [ParsedTask] + let unparseableFragments: [String] + let askForPlans: Bool + + enum CodingKeys: String, CodingKey { + case parsedTasks = "parsed_tasks" + case unparseableFragments = "unparseable_fragments" + case askForPlans = "ask_for_plans" + } +} + +struct ParsedTask: Codable, Identifiable { + // local UUID for list identity before saving + var localId: String = UUID().uuidString + var id: String { localId } + + let title: String + let description: String? + let priority: Int + let deadline: String? + let estimatedMinutes: Int? + let tags: [String] + + enum CodingKeys: String, CodingKey { + case title, description, priority, deadline, tags + case estimatedMinutes = "estimated_minutes" + } +} + +// MARK: - Step Planning + +struct StepPlanResponse: Codable { + let taskId: String + let planType: String + let steps: [Step] + + enum CodingKeys: String, CodingKey { + case taskId = "task_id" + case planType = "plan_type" + case steps + } +} + +// MARK: - Distraction Analysis + +/// A single action the proactive agent can take on the user's behalf. +struct ProposedAction: Codable { + let label: String // e.g. "Extract all 14 events" + let actionType: String // e.g. "auto_extract", "brain_dump" + let details: String? + + enum CodingKeys: String, CodingKey { + case label, details + case actionType = "action_type" + } +} + +/// Friction pattern detected by the upgraded Argus VLM prompt. +struct FrictionInfo: Codable { + /// repetitive_loop | stalled | tedious_manual | context_overhead | task_resumption | none + let type: String + let confidence: Double + let description: String? + let proposedActions: [ProposedAction] + let sourceContext: String? + let targetContext: String? + + enum CodingKeys: String, CodingKey { + case type, confidence, description + case proposedActions = "proposed_actions" + case sourceContext = "source_context" + case targetContext = "target_context" + } + + var isActionable: Bool { type != "none" && confidence > 0.5 } + var isResumption: Bool { type == "task_resumption" } +} + +struct DistractionAnalysisResponse: Codable { + let onTask: Bool + let currentStepId: String? + let checkpointNoteUpdate: String? + let stepsCompleted: [String] + // Upgraded Argus prompt fields (nil when backend uses legacy prompt) + let friction: FrictionInfo? + let intent: String? // skimming | engaged | unclear | null + let distractionType: String? + let appName: String? + let confidence: Double + let gentleNudge: String? + let vlmSummary: String? + + enum CodingKeys: String, CodingKey { + case onTask = "on_task" + case currentStepId = "current_step_id" + case checkpointNoteUpdate = "checkpoint_note_update" + case stepsCompleted = "steps_completed" + case friction, intent + case distractionType = "distraction_type" + case appName = "app_name" + case confidence + case gentleNudge = "gentle_nudge" + case vlmSummary = "vlm_summary" + } +} + +// MARK: - Session Resume + +struct ResumeCard: Codable { + let welcomeBack: String + let youWereDoing: String + let nextStep: String + let motivation: String + + enum CodingKeys: String, CodingKey { + case welcomeBack = "welcome_back" + case youWereDoing = "you_were_doing" + case nextStep = "next_step" + case motivation + } +} + +struct StepSummary: Codable { + let id: String + let title: String + let checkpointNote: String? + + enum CodingKeys: String, CodingKey { + case id, title + case checkpointNote = "checkpoint_note" + } +} + +struct ProgressSummary: Codable { + let completed: Int + let total: Int + let attentionScore: Int? + let distractionCount: Int? + + enum CodingKeys: String, CodingKey { + case completed, total + case attentionScore = "attention_score" + case distractionCount = "distraction_count" + } +} + +struct ResumeResponse: Codable { + let sessionId: String + let resumeCard: ResumeCard + let currentStep: StepSummary? + let progress: ProgressSummary? + + enum CodingKeys: String, CodingKey { + case sessionId = "session_id" + case resumeCard = "resume_card" + case currentStep = "current_step" + case progress + } +} + +// MARK: - Proactive Agent + +struct ProactiveCard: Identifiable { + enum Source { + /// VLM detected a friction pattern (primary signal — from upgraded Argus prompt). + case vlmFriction(frictionType: String, description: String?, actions: [ProposedAction]) + /// Heuristic app-switch loop detected by NSWorkspace observer (fallback when VLM hasn't returned friction yet). + case appSwitchLoop(apps: [String], switchCount: Int) + } + + let id = UUID() + let source: Source + + /// Human-readable title for the card header. + var title: String { + switch source { + case .vlmFriction(let frictionType, _, _): + switch frictionType { + case "repetitive_loop": return "Repetitive Pattern Detected" + case "stalled": return "Looks Like You're Stuck" + case "tedious_manual": return "I Can Help With This" + case "context_overhead": return "Too Many Windows?" + default: return "I Noticed Something" + } + case .appSwitchLoop: + return "Repetitive Pattern Detected" + } + } + + /// SF Symbol name for the card icon. + var icon: String { + switch source { + case .vlmFriction(let frictionType, _, _): + switch frictionType { + case "repetitive_loop": return "arrow.triangle.2.circlepath" + case "stalled": return "pause.circle" + case "tedious_manual": return "wand.and.stars" + case "context_overhead": return "macwindow" + default: return "sparkles" + } + case .appSwitchLoop: + return "arrow.triangle.2.circlepath" + } + } +} + +// MARK: - API Error + +struct APIErrorResponse: Codable { + let detail: String +} diff --git a/LockInBro/SessionManager.swift b/LockInBro/SessionManager.swift new file mode 100644 index 0000000..34f444f --- /dev/null +++ b/LockInBro/SessionManager.swift @@ -0,0 +1,570 @@ +// SessionManager.swift — Focus session state, screenshot engine, distraction detection + +import AppKit +import SwiftUI +import UserNotifications +import ScreenCaptureKit + +@Observable +@MainActor +final class SessionManager { + static let shared = SessionManager() + + // MARK: - State + + var activeSession: FocusSession? + var activeTask: AppTask? + var activeSteps: [Step] = [] + var currentStepIndex: Int = 0 + var isSessionActive: Bool = false + var sessionStartDate: Date? + var distractionCount: Int = 0 + var lastNudge: String? + var resumeCard: ResumeCard? + var showingResumeCard: Bool = false + var errorMessage: String? + var isLoading: Bool = false + + // Proactive agent + var proactiveCard: ProactiveCard? + /// Set when the user approves a proposed action — shown as a confirmation toast + var approvedActionLabel: String? + + // Screenshot engine + var isCapturing: Bool = false + /// Live pipeline status shown in FocusSessionView (updated at each stage) + var argusStatus: String = "" + + private var captureTask: Task? + private let captureInterval: TimeInterval = 5.0 + + // Rolling screenshot history buffer (max 4 entries, ~20-second window) + // Provides temporal context to the VLM so it can detect patterns across captures. + private struct ScreenshotHistoryEntry { + let summary: String // vlm_summary text from the previous analysis + let timestamp: Date + } + @ObservationIgnored private var screenshotHistory: [ScreenshotHistoryEntry] = [] + + // App switch tracking + @ObservationIgnored private var appSwitches: [(name: String, bundleId: String, time: Date)] = [] + @ObservationIgnored private var appSwitchObserver: (any NSObjectProtocol)? + @ObservationIgnored private var lastApp: (name: String, bundleId: String) = ("", "") + @ObservationIgnored private var lastAppEnteredAt: Date = Date() + + // Argus subprocess (device-side VLM) + @ObservationIgnored private var argusProcess: Process? + @ObservationIgnored private var argusReadTask: Task? + private let argusPythonPath = "/Users/joyzhuo/miniconda3/envs/gmr/bin/python3" + private let argusRepoPath = "/Users/joyzhuo/yhack/lockinbro-argus" + + private init() {} + + // MARK: - Computed + + var currentStep: Step? { + guard currentStepIndex < activeSteps.count else { return nil } + return activeSteps[currentStepIndex] + } + + var completedCount: Int { activeSteps.filter(\.isDone).count } + var totalSteps: Int { activeSteps.count } + + var sessionElapsed: TimeInterval { + guard let start = sessionStartDate else { return 0 } + return Date().timeIntervalSince(start) + } + + // MARK: - Session Lifecycle + + // Persisted so we can end a stale session after an app restart + private var persistedSessionId: String? { + get { UserDefaults.standard.string(forKey: "lockInBro.lastSessionId") } + set { + if let v = newValue { UserDefaults.standard.set(v, forKey: "lockInBro.lastSessionId") } + else { UserDefaults.standard.removeObject(forKey: "lockInBro.lastSessionId") } + } + } + + func startSession(task: AppTask?) async { + isLoading = true + errorMessage = nil + do { + let session: FocusSession + do { + session = try await APIClient.shared.startSession(taskId: task?.id) + } catch NetworkError.httpError(409, _) { + // End whichever session is active — prefer the locally known one, + // fall back to the last persisted ID (survives app restarts) + let staleId = activeSession?.id ?? persistedSessionId + if let id = staleId { + _ = try? await APIClient.shared.endSession(sessionId: id, status: "completed") + } + session = try await APIClient.shared.startSession(taskId: task?.id) + } + activeSession = session + persistedSessionId = session.id + activeTask = task + activeSteps = [] + currentStepIndex = 0 + isSessionActive = true + sessionStartDate = Date() + distractionCount = 0 + lastNudge = nil + + if let task { + let steps = try await APIClient.shared.getSteps(taskId: task.id) + activeSteps = steps.sorted { $0.sortOrder < $1.sortOrder } + // Pick first in-progress or first pending step + currentStepIndex = activeSteps.firstIndex(where: { $0.isActive }) + ?? activeSteps.firstIndex(where: { $0.status == "pending" }) + ?? 0 + } + + screenshotHistory = [] + await requestNotificationPermission() + startArgus(session: session, task: task) + startAppObserver() + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + func endSession(status: String = "completed") async { + stopArgus() + stopCapture() + stopAppObserver() + if let session = activeSession { + _ = try? await APIClient.shared.endSession(sessionId: session.id, status: status) + } + activeSession = nil + activeTask = nil + activeSteps = [] + isSessionActive = false + sessionStartDate = nil + lastNudge = nil + resumeCard = nil + showingResumeCard = false + proactiveCard = nil + approvedActionLabel = nil + screenshotHistory = [] + persistedSessionId = nil + } + + func fetchResumeCard() async { + guard let session = activeSession else { return } + do { + let response = try await APIClient.shared.resumeSession(sessionId: session.id) + resumeCard = response.resumeCard + showingResumeCard = true + } catch { + errorMessage = error.localizedDescription + } + } + + func completeCurrentStep() async { + guard let step = currentStep else { return } + do { + let updated = try await APIClient.shared.completeStep(stepId: step.id) + if let idx = activeSteps.firstIndex(where: { $0.id == updated.id }) { + activeSteps[idx] = updated + } + // Advance to next pending + if let next = activeSteps.firstIndex(where: { $0.status == "pending" }) { + currentStepIndex = next + } + } catch { + errorMessage = error.localizedDescription + } + } + + // MARK: - App Switch Observer + + private func startAppObserver() { + let current = NSWorkspace.shared.frontmostApplication + lastApp = (current?.localizedName ?? "", current?.bundleIdentifier ?? "") + lastAppEnteredAt = Date() + appSwitches = [] + + appSwitchObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + else { return } + Task { @MainActor [weak self] in self?.handleAppSwitch(app: app) } + } + } + + private func stopAppObserver() { + if let observer = appSwitchObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + appSwitchObserver = nil + } + appSwitches = [] + } + + private func handleAppSwitch(app: NSRunningApplication) { + let name = app.localizedName ?? "Unknown" + let bundleId = app.bundleIdentifier ?? "" + let now = Date() + + guard name != lastApp.name else { return } + + // Log previous app's dwell time to backend (fire-and-forget) + let duration = max(1, Int(now.timeIntervalSince(lastAppEnteredAt))) + let prev = lastApp + if let session = activeSession, !prev.name.isEmpty { + Task { + _ = try? await APIClient.shared.appActivity( + sessionId: session.id, + appBundleId: prev.bundleId, + appName: prev.name, + durationSeconds: duration + ) + } + } + + lastApp = (name, bundleId) + lastAppEnteredAt = now + + appSwitches.append((name: name, bundleId: bundleId, time: now)) + if appSwitches.count > 30 { appSwitches.removeFirst() } + + // Only trigger card during active session and when none is already showing + guard isSessionActive, proactiveCard == nil else { return } + if let loop = detectRepetitiveLoop() { + proactiveCard = ProactiveCard(source: .appSwitchLoop(apps: loop.apps, switchCount: loop.count)) + } + } + + // Detects a back-and-forth pattern between exactly 2 apps within a 5-minute window. + // Requires 3 full cycles (6 consecutive alternating switches) to avoid false positives. + private func detectRepetitiveLoop() -> (apps: [String], count: Int)? { + let cutoff = Date().addingTimeInterval(-300) + let recent = appSwitches.filter { $0.time > cutoff }.map(\.name) + guard recent.count >= 6 else { return nil } + + let last6 = Array(recent.suffix(6)) + guard Set(last6).count == 2 else { return nil } + + // Strictly alternating — no two consecutive identical app names + for i in 1.. 4 { screenshotHistory.removeFirst() } + } + + applyDistractionResult(result) + } catch { + // Silent fail — don't interrupt the user + } + } + + private func captureScreen() async -> Data? { + do { + let content = try await SCShareableContent.current + guard let display = content.displays.first else { return nil } + + let config = SCStreamConfiguration() + config.width = 1280 + config.height = 720 + + let filter = SCContentFilter(display: display, excludingWindows: []) + let image = try await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: config + ) + return cgImageToJPEG(image) + } catch { + return nil + } + } + + private func cgImageToJPEG(_ image: CGImage) -> Data? { + let nsImage = NSImage(cgImage: image, size: .zero) + guard let tiff = nsImage.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.5]) + else { return nil } + return jpeg + } + + private func buildTaskContext() -> [String: Any] { + var ctx: [String: Any] = [:] + guard let task = activeTask else { return ctx } + ctx["task_title"] = task.title + ctx["task_goal"] = task.description ?? task.title + ctx["steps"] = activeSteps.map { step -> [String: Any] in + var s: [String: Any] = [ + "id": step.id, + "sort_order": step.sortOrder, + "title": step.title, + "status": step.status + ] + if let note = step.checkpointNote { s["checkpoint_note"] = note } + return s + } + return ctx + } + + private func applyDistractionResult(_ result: DistractionAnalysisResponse) { + // 1. Apply step side-effects (always) + for completedId in result.stepsCompleted { + if let idx = activeSteps.firstIndex(where: { $0.id == completedId }) { + activeSteps[idx].status = "done" + } + } + if let note = result.checkpointNoteUpdate, + let stepId = result.currentStepId, + let idx = activeSteps.firstIndex(where: { $0.id == stepId }) { + activeSteps[idx].checkpointNote = note + } + if let stepId = result.currentStepId, + let idx = activeSteps.firstIndex(where: { $0.id == stepId }) { + currentStepIndex = idx + } + + // 2. Notification priority (design spec §1.5): + // Proactive friction help → Context resume → Gentle nudge + // NEVER nudge when the system could help instead. + if let friction = result.friction, friction.isActionable { + if friction.isResumption { + // Task resumption detected — auto-surface resume card without button press + Task { await fetchResumeCard() } + } else if proactiveCard == nil { + proactiveCard = ProactiveCard(source: .vlmFriction( + frictionType: friction.type, + description: friction.description, + actions: friction.proposedActions + )) + } + } else if !result.onTask, result.confidence > 0.7, let nudge = result.gentleNudge { + // Only nudge if VLM found no actionable friction + distractionCount += 1 + lastNudge = nudge + sendNudgeNotification(nudge) + } + } + + // MARK: - Notifications + + private func handleArgusStatus(_ event: String) { + switch event { + case "screenshot_captured": + argusStatus = "📸 Screenshot captured — sending to VLM…" + sendDebugNotification(title: "📸 Screenshot Captured", body: "Sending to VLM for analysis…") + case "vlm_running": + argusStatus = "🤖 VLM analyzing screen…" + sendDebugNotification(title: "🤖 VLM Running", body: "Gemini is analyzing your screen…") + case "vlm_done": + argusStatus = "🧠 VLM done — applying result…" + sendDebugNotification(title: "🧠 VLM Done", body: "Analysis complete, processing result…") + default: + break + } + } + + private func sendDebugNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + let req = UNNotificationRequest( + identifier: "debug-\(UUID().uuidString)", + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(req) + } + + private func sendNudgeNotification(_ nudge: String) { + let content = UNMutableNotificationContent() + content.title = "Hey, quick check-in!" + content.body = nudge + content.sound = .default + let req = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(req) + } + + private func requestNotificationPermission() async { + try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound]) + } +} diff --git a/LockInBro/TaskBoardView.swift b/LockInBro/TaskBoardView.swift new file mode 100644 index 0000000..17bee4a --- /dev/null +++ b/LockInBro/TaskBoardView.swift @@ -0,0 +1,500 @@ +// TaskBoardView.swift — Task list with priority sorting and step progress + +import SwiftUI + +struct TaskBoardView: View { + @Environment(SessionManager.self) private var session + + @State private var tasks: [AppTask] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var selectedFilter: String? = nil + @State private var expandedTaskId: String? + @State private var taskSteps: [String: [Step]] = [:] + @State private var loadingStepsFor: String? + @State private var editingTask: AppTask? + + // (statusValue, displayLabel) — nil statusValue means "all tasks" + private let filters: [(String?, String)] = [ + (nil, "All"), + ("in_progress", "In Progress"), + ("pending", "Pending"), + ("done", "Done") + ] + + var body: some View { + VStack(spacing: 0) { + // Filter tabs + HStack(spacing: 4) { + ForEach(filters, id: \.1) { filter in + Button(filter.1) { + selectedFilter = filter.0 + Task { await loadTasks() } + } + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(selectedFilter == filter.0 ? Color.accentColor : Color.clear) + .foregroundStyle(selectedFilter == filter.0 ? .white : .primary) + .clipShape(.capsule) + .fontWeight(selectedFilter == filter.0 ? .semibold : .regular) + } + Spacer() + Button { + Task { await loadTasks() } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .help("Refresh") + } + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + if isLoading { + ProgressView("Loading tasks…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if tasks.isEmpty { + ContentUnavailableView( + "No tasks", + systemImage: "checklist", + description: Text("Use Brain Dump to capture your tasks") + ) + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(sortedTasks) { task in + TaskRow( + task: task, + isExpanded: expandedTaskId == task.id, + steps: taskSteps[task.id] ?? [], + isLoadingSteps: loadingStepsFor == task.id, + onToggle: { toggleExpanded(task) }, + onStartFocus: { startFocus(task) }, + onEdit: { editingTask = task }, + onDelete: { Task { await deleteTask(task) } }, + onCompleteStep: { step in + Task { await completeStep(step, taskId: task.id) } + } + ) + } + } + .padding() + } + } + + if let err = errorMessage ?? session.errorMessage { + Text(err) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal) + .padding(.bottom, 8) + } + if session.isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Starting session…") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.bottom, 8) + } + } + .task { await loadTasks() } + .sheet(item: $editingTask) { task in + EditTaskSheet(task: task) { updated in + if let idx = tasks.firstIndex(where: { $0.id == updated.id }) { + tasks[idx] = updated + } + editingTask = nil + } onDismiss: { + editingTask = nil + } + } + } + + private var sortedTasks: [AppTask] { + tasks.sorted { a, b in + if a.priority != b.priority { return a.priority > b.priority } + return a.createdAt > b.createdAt + } + } + + private func loadTasks() async { + isLoading = true + errorMessage = nil + do { + var all = try await APIClient.shared.getTasks(status: selectedFilter) + // Soft-deleted tasks have status='deferred' — hide them from the All view + if selectedFilter == nil { + all = all.filter { $0.status != "deferred" } + } + tasks = all + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + private func toggleExpanded(_ task: AppTask) { + if expandedTaskId == task.id { + expandedTaskId = nil + } else { + expandedTaskId = task.id + if taskSteps[task.id] == nil { + Task { await loadSteps(taskId: task.id) } + } + } + } + + private func loadSteps(taskId: String) async { + loadingStepsFor = taskId + do { + taskSteps[taskId] = try await APIClient.shared.getSteps(taskId: taskId) + } catch { + // Silently fail for step loading + } + loadingStepsFor = nil + } + + private func startFocus(_ task: AppTask) { + session.errorMessage = nil + Task { await session.startSession(task: task) } + } + + private func deleteTask(_ task: AppTask) async { + // Optimistically remove from UI immediately + tasks.removeAll { $0.id == task.id } + do { + try await APIClient.shared.deleteTask(taskId: task.id) + } catch { + // Restore on failure + tasks.append(task) + errorMessage = error.localizedDescription + } + } + + private func completeStep(_ step: Step, taskId: String) async { + do { + let updated = try await APIClient.shared.completeStep(stepId: step.id) + if var steps = taskSteps[taskId] { + if let idx = steps.firstIndex(where: { $0.id == updated.id }) { + steps[idx] = updated + taskSteps[taskId] = steps + } + } + } catch {} + } +} + +// MARK: - Task Row + +private struct TaskRow: View { + let task: AppTask + let isExpanded: Bool + let steps: [Step] + let isLoadingSteps: Bool + let onToggle: () -> Void + let onStartFocus: () -> Void + let onEdit: () -> Void + let onDelete: () -> Void + let onCompleteStep: (Step) -> Void + + private var completedSteps: Int { steps.filter(\.isDone).count } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header row + HStack(spacing: 10) { + // Priority badge + PriorityBadge(priority: task.priority) + + VStack(alignment: .leading, spacing: 2) { + Text(task.title) + .font(.headline) + .lineLimit(2) + + HStack(spacing: 8) { + if let deadline = task.deadline { + Label(formatDeadline(deadline), systemImage: "calendar") + .font(.caption) + .foregroundStyle(.secondary) + } + if !steps.isEmpty { + Label("\(completedSteps)/\(steps.count) steps", systemImage: "checklist") + .font(.caption) + .foregroundStyle(.secondary) + } + if let mins = task.estimatedMinutes { + Label("~\(mins)m", systemImage: "clock") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Spacer() + + // Actions + HStack(spacing: 6) { + Button { + onStartFocus() + } label: { + Image(systemName: "play.circle.fill") + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + .help("Start focus session") + + Button { + onEdit() + } label: { + Image(systemName: "pencil.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Edit task") + + Button(role: .destructive) { + onDelete() + } label: { + Image(systemName: "trash") + .foregroundStyle(.red.opacity(0.7)) + } + .buttonStyle(.plain) + .help("Delete task") + + Button { + onToggle() + } label: { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding() + + // Step progress bar + if !steps.isEmpty { + ProgressView(value: Double(completedSteps), total: Double(steps.count)) + .progressViewStyle(.linear) + .padding(.horizontal) + .padding(.bottom, isExpanded ? 0 : 8) + } + + // Expanded steps list + if isExpanded { + Divider().padding(.horizontal) + + if isLoadingSteps { + ProgressView() + .frame(maxWidth: .infinity) + .padding() + } else if steps.isEmpty { + Text("No steps yet — generate a plan to get started") + .font(.caption) + .foregroundStyle(.secondary) + .padding() + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(steps.sorted { $0.sortOrder < $1.sortOrder }) { step in + StepRow(step: step, onComplete: { onCompleteStep(step) }) + } + } + .padding() + } + } + } + .background(.background) + .clipShape(.rect(cornerRadius: 10)) + .shadow(color: .black.opacity(0.07), radius: 4, x: 0, y: 2) + } + + private func formatDeadline(_ iso: String) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = formatter.date(from: iso) else { + formatter.formatOptions = [.withInternetDateTime] + guard let date2 = formatter.date(from: iso) else { return iso } + return RelativeDateTimeFormatter().localizedString(for: date2, relativeTo: .now) + } + return RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now) + } +} + +// MARK: - Step Row + +private struct StepRow: View { + let step: Step + let onComplete: () -> Void + + var body: some View { + HStack(spacing: 10) { + Button { + if !step.isDone { onComplete() } + } label: { + Image(systemName: step.isDone ? "checkmark.circle.fill" : (step.isActive ? "circle.fill" : "circle")) + .foregroundStyle(step.isDone ? .green : (step.isActive ? .blue : .secondary)) + } + .buttonStyle(.plain) + + VStack(alignment: .leading, spacing: 2) { + Text(step.title) + .font(.subheadline) + .strikethrough(step.isDone) + .foregroundStyle(step.isDone ? .secondary : .primary) + + if let note = step.checkpointNote { + Text(note) + .font(.caption) + .foregroundStyle(.blue) + .italic() + } + } + + Spacer() + + if let mins = step.estimatedMinutes { + Text("~\(mins)m") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Edit Task Sheet + +private struct EditTaskSheet: View { + let task: AppTask + let onSave: (AppTask) -> Void + let onDismiss: () -> Void + + @State private var title: String + @State private var description: String + @State private var priority: Int + @State private var isSaving = false + @State private var error: String? + + init(task: AppTask, onSave: @escaping (AppTask) -> Void, onDismiss: @escaping () -> Void) { + self.task = task + self.onSave = onSave + self.onDismiss = onDismiss + _title = State(initialValue: task.title) + _description = State(initialValue: task.description ?? "") + _priority = State(initialValue: task.priority) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Edit Task").font(.headline) + Spacer() + Button("Cancel") { onDismiss() }.buttonStyle(.bordered) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Title").font(.caption).foregroundStyle(.secondary) + TextField("Title", text: $title) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Description").font(.caption).foregroundStyle(.secondary) + TextEditor(text: $description) + .font(.body) + .frame(height: 80) + .padding(4) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.3))) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Priority").font(.caption).foregroundStyle(.secondary) + Picker("Priority", selection: $priority) { + Text("Low").tag(1) + Text("Medium").tag(2) + Text("High").tag(3) + Text("Urgent").tag(4) + } + .pickerStyle(.segmented) + } + + if let err = error { + Text(err).font(.caption).foregroundStyle(.red) + } + + Button { + Task { await save() } + } label: { + Group { + if isSaving { ProgressView() } + else { Text("Save Changes") } + } + .frame(maxWidth: .infinity) + .frame(height: 32) + } + .buttonStyle(.borderedProminent) + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving) + } + .padding(24) + .frame(width: 380) + } + + private func save() async { + isSaving = true + error = nil + do { + let updated = try await APIClient.shared.updateTask( + taskId: task.id, + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + description: description.isEmpty ? nil : description, + priority: priority + ) + onSave(updated) + } catch { + self.error = error.localizedDescription + } + isSaving = false + } +} + +// MARK: - Priority Badge + +private struct PriorityBadge: View { + let priority: Int + + private var color: Color { + switch priority { + case 4: return .red + case 3: return .orange + case 2: return .yellow + case 1: return .green + default: return .gray + } + } + + private var label: String { + switch priority { + case 4: return "URGENT" + case 3: return "HIGH" + case 2: return "MED" + case 1: return "LOW" + default: return "—" + } + } + + var body: some View { + Text(label) + .font(.system(size: 9, weight: .bold)) + .padding(.horizontal, 5) + .padding(.vertical, 3) + .background(color.opacity(0.2)) + .foregroundStyle(color) + .clipShape(.capsule) + } +} diff --git a/LockInBro/VoiceDumpRecorder.swift b/LockInBro/VoiceDumpRecorder.swift new file mode 100644 index 0000000..10a31f6 --- /dev/null +++ b/LockInBro/VoiceDumpRecorder.swift @@ -0,0 +1,93 @@ +// VoiceDumpRecorder.swift — Voice recording + local Whisper transcription via WhisperKit +import Foundation +import AVFoundation +import WhisperKit + +@Observable +@MainActor +final class VoiceDumpRecorder: NSObject { + var isRecording = false + var isTranscribing = false + var liveTranscript = "" + var permissionDenied = false + + private var audioRecorder: AVAudioRecorder? + private var recordingURL: URL? + private var whisperKit: WhisperKit? + + // MARK: - Permissions + + func requestPermissions() async { + let granted = await AVAudioApplication.requestRecordPermission() + permissionDenied = !granted + } + + // MARK: - Recording + + func startRecording() { + guard !isRecording, !permissionDenied else { return } + liveTranscript = "" + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("voicedump_\(UUID().uuidString).wav") + recordingURL = url + + // 16 kHz mono PCM — Whisper's native format, no conversion needed + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: 16000.0, + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + ] + + do { + audioRecorder = try AVAudioRecorder(url: url, settings: settings) + audioRecorder?.record() + isRecording = true + } catch { + print("VoiceDumpRecorder: failed to start — \(error)") + } + } + + func stopRecording() async { + guard isRecording, let recorder = audioRecorder, let url = recordingURL else { return } + recorder.stop() + audioRecorder = nil + isRecording = false + + isTranscribing = true + do { + liveTranscript = try await transcribe(url: url) + } catch { + print("VoiceDumpRecorder: transcription failed — \(error)") + } + isTranscribing = false + + try? FileManager.default.removeItem(at: url) + recordingURL = nil + } + + // MARK: - Whisper Transcription + + /// Call this early (e.g. onAppear) so the model is ready before the user records. + func warmUp() { + guard whisperKit == nil else { return } + Task { + do { whisperKit = try await WhisperKit(model: "tiny") } + catch { print("VoiceDumpRecorder: warm-up failed — \(error)") } + } + } + + private func transcribe(url: URL) async throws -> String { + // Model should already be loaded from warmUp(); load now if not + if whisperKit == nil { + whisperKit = try await WhisperKit(model: "tiny") + } + guard let pipe = whisperKit else { return "" } + let results = try await pipe.transcribe(audioPath: url.path) + return results.map(\.text).joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +}