focus, brain dump, speech recognition and some argus implementation

This commit is contained in:
joyzhuo
2026-03-28 22:45:29 -04:00
parent 0d6eb99720
commit 15943b4759
18 changed files with 3591 additions and 28 deletions

View File

@@ -6,6 +6,12 @@
objectVersion = 77; objectVersion = 77;
objects = { 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 */ /* Begin PBXFileReference section */
FF3296C22F785B3300C734EB /* LockInBro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LockInBro.app; sourceTree = BUILT_PRODUCTS_DIR; }; FF3296C22F785B3300C734EB /* LockInBro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LockInBro.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -23,6 +29,9 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
FF935B222F78A83100ED3330 /* WhisperKit in Frameworks */,
FF935B1E2F78A83100ED3330 /* SpeakerKit in Frameworks */,
FF935B202F78A83100ED3330 /* TTSKit in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -65,6 +74,9 @@
); );
name = LockInBro; name = LockInBro;
packageProductDependencies = ( packageProductDependencies = (
FF935B1D2F78A83100ED3330 /* SpeakerKit */,
FF935B1F2F78A83100ED3330 /* TTSKit */,
FF935B212F78A83100ED3330 /* WhisperKit */,
); );
productName = LockInBro; productName = LockInBro;
productReference = FF3296C22F785B3300C734EB /* LockInBro.app */; productReference = FF3296C22F785B3300C734EB /* LockInBro.app */;
@@ -94,6 +106,9 @@
); );
mainGroup = FF3296B92F785B3300C734EB; mainGroup = FF3296B92F785B3300C734EB;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
FF935B1C2F78A83100ED3330 /* XCRemoteSwiftPackageReference "WhisperKit" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = FF3296C32F785B3300C734EB /* Products */; productRefGroup = FF3296C32F785B3300C734EB /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -248,21 +263,31 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 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; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = YK2DB9NT3S;
ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = LockInBro/Info.plist;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; 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 = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = yhack.LockInBro; PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBro;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "Joy Zhuo";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -278,21 +303,31 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 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; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = YK2DB9NT3S;
ENABLE_APP_SANDBOX = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = LockInBro/Info.plist;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; 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 = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = yhack.LockInBro; PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBro;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "Joy Zhuo";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -325,6 +360,35 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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 */; rootObject = FF3296BA2F785B3300C734EB /* Project object */;
} }

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -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
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildLocationStyle</key>
<string>UseAppPreferences</string>
<key>CompilationCachingSetting</key>
<string>Default</string>
<key>CustomBuildLocationType</key>
<string>RelativeToDerivedData</string>
<key>DerivedDataLocationStyle</key>
<string>Default</string>
<key>ShowSharedSchemesAutomaticallyEnabled</key>
<true/>
</dict>
</plist>

315
LockInBro/APIClient.swift Normal file
View File

@@ -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<T: Decodable>(_ 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)
}
}

View File

@@ -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
}
}

View File

@@ -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 515 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
}
}

View File

@@ -1,24 +1,225 @@
// // ContentView.swift Auth gate + main tab navigation
// ContentView.swift
// LockInBro
//
// Created by Joy Zhuo on 3/28/26.
//
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
var body: some View { @Environment(AuthManager.self) private var auth
VStack { @Environment(SessionManager.self) private var session
Image(systemName: "globe")
.imageScale(.large) @State private var selectedTab: AppTab = .tasks
.foregroundStyle(.tint) @State private var showingSettings = false
Text("Hello, world!") @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 { // MARK: - Settings Sheet
ContentView()
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)
}
} }

View File

@@ -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)
}

8
LockInBro/Info.plist Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSSpeeachRecognitionUsageDescription</key>
<string>LockInBro uses speech recognition to convert your spoken thoughts into tasks. </string>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.security.app-sandbox</key>
<false/>
</dict>
</plist>

View File

@@ -1,17 +1,35 @@
// // LockInBroApp.swift App entry point with menu bar + main window
// LockInBroApp.swift
// LockInBro
//
// Created by Joy Zhuo on 3/28/26.
//
import SwiftUI import SwiftUI
@main @main
struct LockInBroApp: App { struct LockInBroApp: App {
@State private var auth = AuthManager.shared
@State private var session = SessionManager.shared
var body: some Scene { var body: some Scene {
WindowGroup { // Main window
WindowGroup("LockInBro") {
ContentView() 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)
} }
} }

213
LockInBro/LoginView.swift Normal file
View File

@@ -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<ASAuthorization, Error>) -> 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<ASAuthorization, Error>) {
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)
}

160
LockInBro/MenuBarView.swift Normal file
View File

@@ -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
}
}

343
LockInBro/Models.swift Normal file
View File

@@ -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
}

View File

@@ -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<Void, Never>?
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<Void, Never>?
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..<last6.count {
if last6[i] == last6[i - 1] { return nil }
}
return (apps: Array(Set(last6)).sorted(), count: 3)
}
// MARK: - Argus Subprocess (device-side VLM)
/// Launch the argus Python daemon as a subprocess.
/// Argus captures screenshots itself, runs them through a local VLM (Ollama/Gemini),
/// posts results to the backend, and emits RESULT:{json} lines to stdout for Swift to consume.
/// Falls back to the internal `startCapture()` loop if the process cannot be launched.
private func startArgus(session: FocusSession, task: AppTask?) {
guard FileManager.default.fileExists(atPath: argusPythonPath),
FileManager.default.fileExists(atPath: argusRepoPath) else {
argusStatus = "⚠️ Argus not found — using fallback capture"
startCapture()
return
}
// Encode steps as JSON for --steps-json arg
var stepsJSONString = "[]"
if !activeSteps.isEmpty {
let stepsArray: [[String: Any]] = activeSteps.map { step 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
}
if let data = try? JSONSerialization.data(withJSONObject: stepsArray),
let str = String(data: data, encoding: .utf8) {
stepsJSONString = str
}
}
let jwt = TokenStore.shared.token ?? ""
let geminiKey = UserDefaults.standard.string(forKey: "geminiApiKey") ?? ""
var arguments = [
"-m", "argus",
"--session-id", session.id,
"--task-title", task?.title ?? "(no task)",
"--task-goal", task?.description ?? "",
"--steps-json", stepsJSONString,
"--window-title", NSWorkspace.shared.frontmostApplication?.localizedName ?? "",
"--vlm", "gemini",
"--jwt", jwt,
"--backend-url", "https://wahwa.com/api/v1",
"--swift-ipc"
]
if !geminiKey.isEmpty {
arguments += ["--gemini-key", geminiKey]
}
let process = Process()
process.executableURL = URL(fileURLWithPath: argusPythonPath)
process.currentDirectoryURL = URL(fileURLWithPath: argusRepoPath)
process.arguments = arguments
// Pipe stdout for RESULT: lines; redirect stderr so argus logs don't clutter console
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.launch()
} catch {
argusStatus = "⚠️ Argus failed to launch — using fallback capture"
startCapture()
return
}
argusProcess = process
isCapturing = true
let taskLabel = task?.title ?? "session"
argusStatus = "🚀 Argus started — waiting for first screenshot…"
sendDebugNotification(title: "🚀 Argus VLM Started", body: "Screen monitoring active for \(taskLabel)")
// Read RESULT: lines from argus stdout in a background task
let fileHandle = stdoutPipe.fileHandleForReading
sendDebugNotification(title: "🚀 Argus VLM Started", body: "Screen monitoring active for \(taskLabel)")
argusReadTask = Task { [weak self] in
do {
for try await line in fileHandle.bytes.lines {
guard let self, !Task.isCancelled else { break }
if line.hasPrefix("STATUS:") {
let event = String(line.dropFirst("STATUS:".count))
await MainActor.run { self.handleArgusStatus(event) }
} else if line.hasPrefix("RESULT:") {
let jsonStr = String(line.dropFirst("RESULT:".count))
guard let data = jsonStr.data(using: .utf8) else { continue }
if let result = try? JSONDecoder().decode(DistractionAnalysisResponse.self, from: data) {
await MainActor.run {
let summary = result.vlmSummary ?? "no summary"
self.argusStatus = "\(summary)"
self.sendDebugNotification(title: "✅ VLM Result", body: summary)
self.applyDistractionResult(result)
}
}
}
}
} catch {
// Pipe closed argus process ended
}
}
}
private func stopArgus() {
argusReadTask?.cancel()
argusReadTask = nil
if let proc = argusProcess {
proc.terminate()
argusProcess = nil
isCapturing = false
}
}
// MARK: - Screenshot Capture Loop (fallback when argus is unavailable)
private func startCapture() {
isCapturing = true
captureTask = Task { [weak self] in
guard let self else { return }
// Capture immediately on session start, then repeat on interval
await self.captureAndAnalyze()
while !Task.isCancelled && self.isSessionActive {
try? await Task.sleep(for: .seconds(self.captureInterval))
guard !Task.isCancelled && self.isSessionActive else { break }
await self.captureAndAnalyze()
}
}
}
private func stopCapture() {
captureTask?.cancel()
captureTask = nil
isCapturing = false
}
private func captureAndAnalyze() async {
guard let session = activeSession else { return }
guard let imageData = await captureScreen() else { return }
let windowTitle = NSWorkspace.shared.frontmostApplication?.localizedName ?? "Unknown"
var context = buildTaskContext()
// Inject rolling history so the VLM has temporal context across captures.
// Only summaries (text) are sent not the raw images to keep token cost low.
if !screenshotHistory.isEmpty {
let iso = ISO8601DateFormatter()
context["screenshot_history"] = screenshotHistory.map { entry in
["summary": entry.summary, "timestamp": iso.string(from: entry.timestamp)]
}
}
do {
let result = try await APIClient.shared.analyzeScreenshot(
imageData: imageData,
windowTitle: windowTitle,
sessionId: session.id,
taskContext: context
)
// Append this result's summary to the rolling buffer (max 4 entries)
if let summary = result.vlmSummary {
screenshotHistory.append(ScreenshotHistoryEntry(summary: summary, timestamp: Date()))
if screenshotHistory.count > 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])
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}