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 = "LockInBro uses your microphone to transcribe voice brain dumps into tasks.";
+ INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "LockInBro 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 = "LockInBro uses your microphone to transcribe voice brain dumps into tasks.";
+ INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "LockInBro 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
+ LockInBro 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)
+ }
+}