focus, brain dump, speech recognition and some argus implementation
This commit is contained in:
@@ -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 */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
315
LockInBro/APIClient.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
LockInBro/AuthManager.swift
Normal file
74
LockInBro/AuthManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
430
LockInBro/BrainDumpView.swift
Normal file
430
LockInBro/BrainDumpView.swift
Normal 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 5–15 min chunks.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
// Parsed task previews — 2-column grid
|
||||||
|
if !parsedTasks.isEmpty {
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||||
|
ForEach(parsedTasks) { task in
|
||||||
|
ParsedTaskPreviewRow(task: task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unparseable fragments
|
||||||
|
if !unparseableFragments.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Couldn't parse:")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
ForEach(unparseableFragments, id: \.self) { fragment in
|
||||||
|
Text("• \(fragment)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.secondary.opacity(0.08))
|
||||||
|
.clipShape(.rect(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Step generation section
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Generate Steps")
|
||||||
|
.font(.headline)
|
||||||
|
if isFetchingTasks {
|
||||||
|
ProgressView().scaleEffect(0.8)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let err = planError {
|
||||||
|
Text(err)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
if savedTasks.isEmpty || isFetchingTasks {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView().scaleEffect(0.8)
|
||||||
|
Text("Generating steps…")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(savedTasks.prefix(parsedTasks.count)) { task in
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
// Task header
|
||||||
|
HStack {
|
||||||
|
Text(task.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
if task.planType == nil {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ProgressView().scaleEffect(0.7)
|
||||||
|
Text("Generating…")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps list
|
||||||
|
if let steps = generatedSteps[task.id], !steps.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
ForEach(Array(steps.enumerated()), id: \.element.id) { i, step in
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Text("\(i + 1).")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 16, alignment: .trailing)
|
||||||
|
Text(step.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if let mins = step.estimatedMinutes {
|
||||||
|
Spacer()
|
||||||
|
Text("~\(mins)m")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.clipShape(.rect(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Bottom actions
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
onGoToTasks?()
|
||||||
|
} label: {
|
||||||
|
Label("Go to Task Board", systemImage: "checklist")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button("Dump more") {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice Dump Bar
|
||||||
|
|
||||||
|
private var voiceDumpBar: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
if recorder.isRecording {
|
||||||
|
Task { await recorder.stopRecording() }
|
||||||
|
} else {
|
||||||
|
Task { await startVoiceRecording() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
recorder.isRecording ? "Stop" : (recorder.isTranscribing ? "Transcribing…" : "Voice Dump"),
|
||||||
|
systemImage: recorder.isRecording ? "stop.circle.fill" : "mic.fill"
|
||||||
|
)
|
||||||
|
.foregroundStyle(recorder.isRecording ? .red : .accentColor)
|
||||||
|
.symbolEffect(.pulse, isActive: recorder.isRecording || recorder.isTranscribing)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(recorder.isTranscribing)
|
||||||
|
|
||||||
|
if recorder.isRecording {
|
||||||
|
Text("Listening…")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else if recorder.isTranscribing {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ProgressView().scaleEffect(0.7)
|
||||||
|
Text("Whisper is transcribing…")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else if recorder.permissionDenied {
|
||||||
|
Text("Microphone access denied in System Settings.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: recorder.isTranscribing) { _, isNowTranscribing in
|
||||||
|
// Append transcript into rawText once Whisper finishes
|
||||||
|
if !isNowTranscribing && !recorder.liveTranscript.isEmpty {
|
||||||
|
if !rawText.isEmpty { rawText += "\n" }
|
||||||
|
rawText += recorder.liveTranscript
|
||||||
|
recorder.liveTranscript = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startVoiceRecording() async {
|
||||||
|
await recorder.requestPermissions()
|
||||||
|
guard !recorder.permissionDenied else { return }
|
||||||
|
recorder.startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func parseDump() async {
|
||||||
|
isParsing = true
|
||||||
|
errorMessage = nil
|
||||||
|
do {
|
||||||
|
// Backend parses AND saves tasks in one call — we just display the result
|
||||||
|
let response = try await APIClient.shared.brainDump(rawText: rawText)
|
||||||
|
parsedTasks = response.parsedTasks
|
||||||
|
unparseableFragments = response.unparseableFragments
|
||||||
|
isDone = true
|
||||||
|
// Fetch actual tasks (with IDs) so user can generate steps
|
||||||
|
await fetchLatestTasks()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
isParsing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the most recently created tasks, then auto-generate steps for all of them
|
||||||
|
private func fetchLatestTasks() async {
|
||||||
|
isFetchingTasks = true
|
||||||
|
do {
|
||||||
|
let all = try await APIClient.shared.getTasks()
|
||||||
|
let parsedTitles = Set(parsedTasks.map(\.title))
|
||||||
|
savedTasks = all.filter { parsedTitles.contains($0.title) }
|
||||||
|
if savedTasks.isEmpty {
|
||||||
|
savedTasks = Array(all.prefix(parsedTasks.count))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
isFetchingTasks = false
|
||||||
|
|
||||||
|
// Auto-generate steps for any task that doesn't have a plan yet
|
||||||
|
let tasksNeedingPlan = savedTasks.filter { $0.planType == nil }
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
for task in tasksNeedingPlan {
|
||||||
|
group.addTask { await generatePlan(task) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generatePlan(_ task: AppTask) async {
|
||||||
|
planningTaskId = task.id
|
||||||
|
planError = nil
|
||||||
|
do {
|
||||||
|
let response = try await APIClient.shared.planTask(taskId: task.id)
|
||||||
|
generatedSteps[task.id] = response.steps.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
|
// Mark the task as planned locally
|
||||||
|
if let idx = savedTasks.firstIndex(where: { $0.id == task.id }) {
|
||||||
|
savedTasks[idx].planType = response.planType
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
planError = error.localizedDescription
|
||||||
|
}
|
||||||
|
planningTaskId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reset() {
|
||||||
|
rawText = ""
|
||||||
|
parsedTasks = []
|
||||||
|
unparseableFragments = []
|
||||||
|
savedTasks = []
|
||||||
|
generatedSteps = [:]
|
||||||
|
errorMessage = nil
|
||||||
|
planningTaskId = nil
|
||||||
|
planError = nil
|
||||||
|
isDone = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Parsed Task Preview Row
|
||||||
|
|
||||||
|
private struct ParsedTaskPreviewRow: View {
|
||||||
|
let task: ParsedTask
|
||||||
|
|
||||||
|
private var priorityColor: Color {
|
||||||
|
switch task.priority {
|
||||||
|
case 4: return .red
|
||||||
|
case 3: return .orange
|
||||||
|
case 2: return .yellow
|
||||||
|
default: return .green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(priorityColor)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
.padding(.top, 7)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(task.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let mins = task.estimatedMinutes {
|
||||||
|
Label("~\(mins)m", systemImage: "clock")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let dl = task.deadline {
|
||||||
|
Label(shortDate(dl), systemImage: "calendar")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach(task.tags, id: \.self) { tag in
|
||||||
|
Text(tag)
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.accentColor.opacity(0.1))
|
||||||
|
.clipShape(.capsule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.clipShape(.rect(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shortDate(_ iso: String) -> String {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let d = f.date(from: iso) {
|
||||||
|
return d.formatted(.dateTime.month(.abbreviated).day())
|
||||||
|
}
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
if let d = f.date(from: iso) {
|
||||||
|
return d.formatted(.dateTime.month(.abbreviated).day())
|
||||||
|
}
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
var body: some View {
|
||||||
ContentView()
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings Sheet
|
||||||
|
|
||||||
|
private struct SettingsSheet: View {
|
||||||
|
@Binding var geminiApiKey: String
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var draft = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
Text("Settings")
|
||||||
|
.font(.title2.bold())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Gemini API Key")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
Text("Required for the VLM screen analysis agent.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
SecureField("AIza…", text: $draft)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.escape)
|
||||||
|
Button("Save") {
|
||||||
|
geminiApiKey = draft.trimmingCharacters(in: .whitespaces)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.keyboardShortcut(.return)
|
||||||
|
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 420)
|
||||||
|
.onAppear { draft = geminiApiKey }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Start Session Placeholder
|
||||||
|
|
||||||
|
private struct StartSessionPlaceholder: View {
|
||||||
|
let onGoToTasks: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "target")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("No active session")
|
||||||
|
.font(.title2.bold())
|
||||||
|
|
||||||
|
Text("Go to your task board and tap the play button to start a focus session on a task.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 320)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onGoToTasks()
|
||||||
|
} label: {
|
||||||
|
Label("Go to Tasks", systemImage: "checklist")
|
||||||
|
.frame(width: 160)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
463
LockInBro/FocusSessionView.swift
Normal file
463
LockInBro/FocusSessionView.swift
Normal 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
8
LockInBro/Info.plist
Normal 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>
|
||||||
12
LockInBro/LockInBro.entitlements
Normal file
12
LockInBro/LockInBro.entitlements
Normal 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>
|
||||||
@@ -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
213
LockInBro/LoginView.swift
Normal 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
160
LockInBro/MenuBarView.swift
Normal 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
343
LockInBro/Models.swift
Normal 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
|
||||||
|
}
|
||||||
570
LockInBro/SessionManager.swift
Normal file
570
LockInBro/SessionManager.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
500
LockInBro/TaskBoardView.swift
Normal file
500
LockInBro/TaskBoardView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
93
LockInBro/VoiceDumpRecorder.swift
Normal file
93
LockInBro/VoiceDumpRecorder.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user