Initial commit

Whisper model weights excluded from git — auto-downloaded at first Xcode
build via Scripts/download_whisper_model.sh (~600 MB, one-time).
This commit is contained in:
2026-04-01 15:52:27 -05:00
commit cc353e37ae
62 changed files with 6968 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
*.mlmodelc filter=lfs diff=lfs merge=lfs -text
*.mlmodelc/** filter=lfs diff=lfs merge=lfs -text
*.bin filter=lfs diff=lfs merge=lfs -text

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# ML model weights — downloaded automatically at build time via Scripts/download_whisper_model.sh
LockInBroMobile/distil-whisper_distil-large-v3_594MB/
# Xcode
xcuserdata/
*.xcuserstate
DerivedData/
.build/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,78 @@
{
"originHash" : "e843284a09b9d7fb8d0032fe1a3fd1fbd38f28ea54d42a39ccbe396af16d225d",
"pins" : [
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b",
"version" : "1.7.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "9f542610331815e29cc3821d3b6f488db8715517",
"version" : "1.6.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "6675bc0ff86e61436e615df6fc5174e043e57924",
"version" : "1.4.1"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "fa308c07a6fa04a727212d793e761460e41049c3",
"version" : "4.3.0"
}
},
{
"identity" : "swift-jinja",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-jinja.git",
"state" : {
"revision" : "0aeefadec459ce8e11a333769950fb86183aca43",
"version" : "2.3.5"
}
},
{
"identity" : "swift-transformers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-transformers.git",
"state" : {
"revision" : "150169bfba0889c229a2ce7494cf8949f18e6906",
"version" : "1.1.9"
}
},
{
"identity" : "whisperkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/argmaxinc/WhisperKit",
"state" : {
"branch" : "main",
"revision" : "3817d2833f73ceb30586cb285e0e0439a3860536"
}
},
{
"identity" : "yyjson",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ibireme/yyjson.git",
"state" : {
"revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
"version" : "0.12.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C526AF2F78617A003020AD"
BuildableName = "LockInBroMobile.app"
BlueprintName = "LockInBroMobile"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C526C62F78617D003020AD"
BuildableName = "LockInBroMobileUITests.xctest"
BlueprintName = "LockInBroMobileUITests"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C526BC2F78617D003020AD"
BuildableName = "LockInBroMobileTests.xctest"
BlueprintName = "LockInBroMobileTests"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C526AF2F78617A003020AD"
BuildableName = "LockInBroMobile.app"
BlueprintName = "LockInBroMobile"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C526AF2F78617A003020AD"
BuildableName = "LockInBroMobile.app"
BlueprintName = "LockInBroMobile"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86671E212F78D22700AECA00"
BuildableName = "LockInBroWidgetExtension.appex"
BlueprintName = "LockInBroWidgetExtension"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C526AF2F78617A003020AD"
BuildableName = "LockInBroMobile.app"
BlueprintName = "LockInBroMobile"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C526C62F78617D003020AD"
BuildableName = "LockInBroMobileUITests.xctest"
BlueprintName = "LockInBroMobileUITests"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C526BC2F78617D003020AD"
BuildableName = "LockInBroMobileTests.xctest"
BlueprintName = "LockInBroMobileTests"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2"
queueDebuggingEnableBacktraceRecording = "Yes">
<RemoteRunnable
runnableDebuggingMode = "2"
BundleIdentifier = "com.apple.springboard">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86671E212F78D22700AECA00"
BuildableName = "LockInBroWidgetExtension.appex"
BlueprintName = "LockInBroWidgetExtension"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</RemoteRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C526AF2F78617A003020AD"
BuildableName = "LockInBroMobile.app"
BlueprintName = "LockInBroMobile"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</MacroExpansion>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetKind"
value = "LockInBroWidget"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "86C526AF2F78617A003020AD"
BuildableName = "LockInBroMobile.app"
BlueprintName = "LockInBroMobile"
ReferencedContainer = "container:LockInBroMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,47 @@
<?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>SchemeUserState</key>
<dict>
<key>LockInBroMobile.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>LockInBroMonitor.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>LockInBroShield.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
</dict>
<key>LockInBroShieldAction.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>LockInBroWidgetExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>86671E212F78D22700AECA00</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>86C526AF2F78617A003020AD</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

BIN
LockInBroMobile/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,37 @@
{
"images" : [
{
"filename" : "Gemini_Generated_Image_5244jc5244jc5244.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Gemini_Generated_Image_5244jc5244jc5244 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,26 @@
//
// ContentView.swift
// LockInBroMobile
//
// Created by Aditya Pulipaka on 3/28/26.
//
import SwiftUI
/// Root view that gates between auth and the main app based on login state.
struct ContentView: View {
@Environment(AppState.self) private var appState
var body: some View {
if appState.isAuthenticated {
MainTabView()
} else {
AuthView()
}
}
}
#Preview {
ContentView()
.environment(AppState())
}

View File

@@ -0,0 +1,14 @@
<?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>NSSupportsLiveActivities</key>
<true/>
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,18 @@
<?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>aps-environment</key>
<string>production</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.adipu.LockInBroMobile</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,137 @@
//
// LockInBroMobileApp.swift
// LockInBroMobile
//
// Created by Aditya Pulipaka on 3/28/26.
//
import SwiftUI
import UIKit
import UserNotifications
// MARK: - AppDelegate
// Needed for APNs token callbacks, which have no SwiftUI equivalent
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
NotificationService.shared.configure()
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
NotificationService.shared.didRegisterWithToken(deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationService.shared.didFailToRegister(with: error)
}
}
// MARK: - App Entry Point
@main
struct LockInBroMobileApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
.onOpenURL { url in
handleDeepLink(url)
}
.onAppear {
// Wire notification taps same deep link handler
NotificationService.shared.onDeepLink = { url in
handleDeepLink(url)
}
if appState.isAuthenticated {
Task { await NotificationService.shared.registerForPushNotifications() }
NotificationService.shared.scheduleMorningBrief(hour: 9)
wireActivityManager()
ActivityManager.shared.configure()
ScreenTimeManager.shared.requestAuthorization()
}
}
.onChange(of: appState.isAuthenticated) { _, isAuthed in
if isAuthed {
Task { await NotificationService.shared.registerForPushNotifications() }
NotificationService.shared.scheduleMorningBrief(hour: 9)
wireActivityManager()
ActivityManager.shared.configure()
ScreenTimeManager.shared.requestAuthorization()
}
}
.onChange(of: appState.tasks) { _, tasks in
// Re-schedule deadline reminders whenever the task list changes
if appState.isAuthenticated {
NotificationService.shared.scheduleDeadlineReminders(for: tasks)
}
}
}
}
// MARK: - Live Activity AppState Bridge
private func wireActivityManager() {
ActivityManager.shared.onSessionStarted = {
await appState.loadActiveSession()
}
ActivityManager.shared.onSessionEnded = {
appState.activeSession = nil
}
}
// MARK: - Deep Link / Notification Tap Router
// Handles lockinbro:// URLs from both onOpenURL and notification taps
private func handleDeepLink(_ url: URL) {
guard url.scheme == "lockinbro" else { return }
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
switch url.host {
case "join-session":
// lockinbro://join-session?id=<session_id>&open=<url_encoded_app_scheme>
// Used by Live Activity tap and cross-device handoff push notification
let sessionId = components?.queryItems?.first(where: { $0.name == "id" })?.value
let encodedScheme = components?.queryItems?.first(where: { $0.name == "open" })?.value
// Chain-open the target work app immediately user sees Notes/Pages open, not LockInBro
if let encodedScheme,
let decoded = encodedScheme.removingPercentEncoding,
let targetURL = URL(string: decoded) {
UIApplication.shared.open(targetURL)
}
if let sessionId {
let platform = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
Task {
_ = try? await APIClient.shared.joinSession(sessionId: sessionId, platform: platform)
}
}
case "resume-session":
// lockinbro://resume-session?id=<session_id>
// Notification tap user wants to see the resume card
// AppState publishes the session ID so TaskDetailView can react
if let sessionId = components?.queryItems?.first(where: { $0.name == "id" })?.value {
appState.pendingResumeSessionId = sessionId
}
case "task":
// lockinbro://task?id=<task_id>
// Deadline / morning brief notification tap navigate to task
if let taskId = components?.queryItems?.first(where: { $0.name == "id" })?.value {
appState.pendingOpenTaskId = taskId
}
default:
break
}
}
}

View File

@@ -0,0 +1,38 @@
// FocusSessionAttributes.swift
import Foundation
import ActivityKit
import SwiftUI
import Combine
public struct FocusSessionAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
public var taskTitle: String
public var startedAt: Int
public var stepsCompleted: Int
public var stepsTotal: Int
public var currentStepTitle: String?
public var lastCompletedStepTitle: String?
public init(
taskTitle: String,
startedAt: Int,
stepsCompleted: Int = 0,
stepsTotal: Int = 0,
currentStepTitle: String? = nil,
lastCompletedStepTitle: String? = nil
) {
self.taskTitle = taskTitle
self.startedAt = startedAt
self.stepsCompleted = stepsCompleted
self.stepsTotal = stepsTotal
self.currentStepTitle = currentStepTitle
self.lastCompletedStepTitle = lastCompletedStepTitle
}
}
public var sessionType: String
public init(sessionType: String) {
self.sessionType = sessionType
}
}

View File

@@ -0,0 +1,375 @@
// Models.swift LockInBro iOS/iPadOS
// Codable structs matching the backend API schemas
import Foundation
// MARK: - Auth
struct AuthResponse: Codable {
let accessToken: String
let refreshToken: String
let expiresIn: Int
let user: UserOut
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresIn = "expires_in"
case user
}
}
struct UserOut: Codable, Identifiable {
let id: String
var email: String?
var displayName: String?
var timezone: String?
var createdAt: String?
enum CodingKeys: String, CodingKey {
case id, email, timezone
case displayName = "display_name"
case createdAt = "created_at"
}
}
// MARK: - Task
struct TaskOut: Codable, Identifiable, Equatable {
let id: String
var userId: String
var title: String
var description: String?
var priority: Int
var status: String
var deadline: String?
var estimatedMinutes: Int?
var source: String
var tags: [String]
var planType: String?
var brainDumpRaw: String?
var createdAt: String
var updatedAt: String
enum CodingKeys: String, CodingKey {
case id, title, description, priority, status, deadline, source, tags
case userId = "user_id"
case estimatedMinutes = "estimated_minutes"
case planType = "plan_type"
case brainDumpRaw = "brain_dump_raw"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
// Custom decoder: provide safe defaults for fields the backend may omit or null
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? ""
title = try c.decode(String.self, forKey: .title)
description = try c.decodeIfPresent(String.self, forKey: .description)
priority = try c.decodeIfPresent(Int.self, forKey: .priority) ?? 0
status = try c.decodeIfPresent(String.self, forKey: .status) ?? "pending"
deadline = try c.decodeIfPresent(String.self, forKey: .deadline)
estimatedMinutes = try c.decodeIfPresent(Int.self, forKey: .estimatedMinutes)
source = try c.decodeIfPresent(String.self, forKey: .source) ?? "manual"
tags = try c.decodeIfPresent([String].self, forKey: .tags) ?? []
planType = try c.decodeIfPresent(String.self, forKey: .planType)
brainDumpRaw = try c.decodeIfPresent(String.self, forKey: .brainDumpRaw)
createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt) ?? ""
}
var priorityLabel: String {
switch priority {
case 1: return "Low"
case 2: return "Med"
case 3: return "High"
case 4: return "Urgent"
default: return ""
}
}
var deadlineDate: Date? {
guard let dl = deadline else { return nil }
return ISO8601DateFormatter().date(from: dl)
}
var isOverdue: Bool {
guard let d = deadlineDate else { return false }
return d < Date() && status != "done"
}
}
// MARK: - Step
struct StepOut: Codable, Identifiable {
let id: String
var taskId: String
var sortOrder: Int
var title: String
var description: String?
var estimatedMinutes: Int?
var status: String
var checkpointNote: String?
var lastCheckedAt: String?
var completedAt: String?
var 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"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
taskId = try c.decodeIfPresent(String.self, forKey: .taskId) ?? ""
sortOrder = try c.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
title = try c.decode(String.self, forKey: .title)
description = try c.decodeIfPresent(String.self, forKey: .description)
estimatedMinutes = try c.decodeIfPresent(Int.self, forKey: .estimatedMinutes)
status = try c.decodeIfPresent(String.self, forKey: .status) ?? "pending"
checkpointNote = try c.decodeIfPresent(String.self, forKey: .checkpointNote)
lastCheckedAt = try c.decodeIfPresent(String.self, forKey: .lastCheckedAt)
completedAt = try c.decodeIfPresent(String.self, forKey: .completedAt)
createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
}
var isDone: Bool { status == "done" }
var isInProgress: Bool { status == "in_progress" }
}
// MARK: - Session
struct SessionOut: Codable, Identifiable {
let id: String
var userId: String
var taskId: String?
var platform: String
var startedAt: String
var endedAt: String?
var status: String
var createdAt: 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"
case createdAt = "created_at"
}
}
// MARK: - Brain Dump
struct BrainDumpResponse: Codable {
var parsedTasks: [ParsedTask]
var unparseableFragments: [String]
var askForPlans: Bool
enum CodingKeys: String, CodingKey {
case parsedTasks = "parsed_tasks"
case unparseableFragments = "unparseable_fragments"
case askForPlans = "ask_for_plans"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
parsedTasks = try c.decodeIfPresent([ParsedTask].self, forKey: .parsedTasks) ?? []
unparseableFragments = try c.decodeIfPresent([String].self, forKey: .unparseableFragments) ?? []
askForPlans = try c.decodeIfPresent(Bool.self, forKey: .askForPlans) ?? false
}
}
struct ParsedSubtask: Codable, Identifiable {
var id = UUID()
var title: String
var description: String?
var deadline: String?
var estimatedMinutes: Int?
var suggested: Bool
enum CodingKeys: String, CodingKey {
case title, description, deadline, suggested
case estimatedMinutes = "estimated_minutes"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
title = try c.decode(String.self, forKey: .title)
description = try c.decodeIfPresent(String.self, forKey: .description)
deadline = try c.decodeIfPresent(String.self, forKey: .deadline)
estimatedMinutes = try c.decodeIfPresent(Int.self, forKey: .estimatedMinutes)
suggested = try c.decodeIfPresent(Bool.self, forKey: .suggested) ?? false
}
}
struct ParsedTask: Codable, Identifiable {
var id = UUID()
var taskId: String?
var title: String
var description: String?
var priority: Int
var deadline: String?
var estimatedMinutes: Int?
var source: String
var tags: [String]
var subtasks: [ParsedSubtask]
enum CodingKeys: String, CodingKey {
case title, description, priority, deadline, source, tags, subtasks
case taskId = "task_id"
case estimatedMinutes = "estimated_minutes"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
taskId = try c.decodeIfPresent(String.self, forKey: .taskId)
title = try c.decode(String.self, forKey: .title)
description = try c.decodeIfPresent(String.self, forKey: .description)
priority = try c.decodeIfPresent(Int.self, forKey: .priority) ?? 0
deadline = try c.decodeIfPresent(String.self, forKey: .deadline)
estimatedMinutes = try c.decodeIfPresent(Int.self, forKey: .estimatedMinutes)
source = try c.decodeIfPresent(String.self, forKey: .source) ?? "brain_dump"
tags = try c.decodeIfPresent([String].self, forKey: .tags) ?? []
subtasks = try c.decodeIfPresent([ParsedSubtask].self, forKey: .subtasks) ?? []
}
var priorityLabel: String {
switch priority {
case 1: return "Low"
case 2: return "Med"
case 3: return "High"
case 4: return "Urgent"
default: return ""
}
}
}
// MARK: - Plan
struct PlanResponse: Codable {
var taskId: String
var planType: String
var steps: [StepOut]
enum CodingKeys: String, CodingKey {
case steps
case taskId = "task_id"
case planType = "plan_type"
}
}
// MARK: - Session Resume
struct ResumeResponse: Codable {
var sessionId: String
var task: ResumeTask
var currentStep: StepOut?
var progress: SessionProgress
var resumeCard: ResumeCard
enum CodingKeys: String, CodingKey {
case task, progress
case sessionId = "session_id"
case currentStep = "current_step"
case resumeCard = "resume_card"
}
}
struct ResumeTask: Codable {
var title: String
var overallGoal: String?
enum CodingKeys: String, CodingKey {
case title
case overallGoal = "overall_goal"
}
}
struct SessionProgress: Codable {
var completed: Int
var total: Int
var attentionScore: Int?
var distractionCount: Int
enum CodingKeys: String, CodingKey {
case completed, total
case attentionScore = "attention_score"
case distractionCount = "distraction_count"
}
}
struct ResumeCard: Codable {
var welcomeBack: String
var youWereDoing: String
var nextStep: String
var motivation: String
enum CodingKeys: String, CodingKey {
case motivation
case welcomeBack = "welcome_back"
case youWereDoing = "you_were_doing"
case nextStep = "next_step"
}
}
// MARK: - App Check (Distraction Intercept)
struct AppCheckResponse: Codable {
var isDistractionApp: Bool
var pendingTaskCount: Int
var mostUrgentTask: UrgentTask?
var nudge: String?
enum CodingKeys: String, CodingKey {
case nudge
case isDistractionApp = "is_distraction_app"
case pendingTaskCount = "pending_task_count"
case mostUrgentTask = "most_urgent_task"
}
}
struct UrgentTask: Codable {
var title: String
var priority: Int
var deadline: String?
var currentStep: String?
var stepsRemaining: Int?
enum CodingKeys: String, CodingKey {
case title, priority, deadline
case currentStep = "current_step"
case stepsRemaining = "steps_remaining"
}
}
// MARK: - Join Session
struct JoinSessionResponse: Codable {
var sessionId: String
var joined: Bool
var task: ResumeTask?
var currentStep: StepOut?
var allSteps: [StepOut]
var suggestedAppScheme: String?
var suggestedAppName: String?
enum CodingKeys: String, CodingKey {
case joined
case sessionId = "session_id"
case task
case currentStep = "current_step"
case allSteps = "all_steps"
case suggestedAppScheme = "suggested_app_scheme"
case suggestedAppName = "suggested_app_name"
}
}

View File

@@ -0,0 +1,375 @@
// APIClient.swift LockInBro
// All API calls to https://wahwa.com/api/v1
import Foundation
enum APIError: LocalizedError {
case unauthorized
case badRequest(String)
case conflict(String)
case serverError(String)
case decodingError(String)
case invalidURL
var errorDescription: String? {
switch self {
case .unauthorized: return "Session expired. Please log in again."
case .badRequest(let msg): return msg
case .conflict(let msg): return msg
case .serverError(let msg): return msg
case .decodingError(let msg): return "Data error: \(msg)"
case .invalidURL: return "Invalid URL"
}
}
}
final class APIClient {
static let shared = APIClient()
private init() {}
private let baseURL = "https://wahwa.com/api/v1"
var token: String?
/// Called when a refresh attempt fails AppState hooks into this to force logout.
var onAuthFailure: (() -> Void)?
/// Prevents multiple concurrent refresh attempts.
private var isRefreshing = false
private var refreshContinuations: [CheckedContinuation<Bool, Never>] = []
// MARK: - Core Request
private func rawRequest(
_ path: String,
method: String = "GET",
body: [String: Any]? = nil
) async throws -> Data {
guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw APIError.serverError("No HTTP response")
}
switch http.statusCode {
case 200...299:
return data
case 401:
throw APIError.unauthorized
case 409:
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Conflict"
throw APIError.conflict(msg)
case 400...499:
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String
?? String(data: data, encoding: .utf8) ?? "Bad request"
throw APIError.badRequest(msg)
default:
let msg = String(data: data, encoding: .utf8) ?? "Server error \(http.statusCode)"
throw APIError.serverError(msg)
}
}
/// Attempt to refresh the access token using the stored refresh token.
/// Returns true if refresh succeeded. Coalesces concurrent callers so only one
/// refresh request is in-flight at a time.
private func attemptTokenRefresh() async -> Bool {
if isRefreshing {
// Another call is already refreshing wait for it
return await withCheckedContinuation { continuation in
refreshContinuations.append(continuation)
}
}
isRefreshing = true
defer {
isRefreshing = false
// Notify all waiters with the result
let waiters = refreshContinuations
refreshContinuations = []
for waiter in waiters { waiter.resume(returning: token != nil) }
}
guard let refreshToken = KeychainService.shared.getRefreshToken() else {
return false
}
do {
let data = try await rawRequest("/auth/refresh", method: "POST", body: ["refresh_token": refreshToken])
let decoder = JSONDecoder()
let response = try decoder.decode(AuthResponse.self, from: data)
token = response.accessToken
KeychainService.shared.saveToken(response.accessToken)
KeychainService.shared.saveRefreshToken(response.refreshToken)
return true
} catch {
print("[APIClient] Token refresh failed: \(error)")
return false
}
}
/// Main entry point for all authenticated requests.
/// On 401, attempts a token refresh and retries once. If refresh fails, triggers onAuthFailure.
private func request(
_ path: String,
method: String = "GET",
body: [String: Any]? = nil
) async throws -> Data {
do {
return try await rawRequest(path, method: method, body: body)
} catch APIError.unauthorized {
// Don't try to refresh the refresh endpoint itself
guard path != "/auth/refresh" else { throw APIError.unauthorized }
let refreshed = await attemptTokenRefresh()
if refreshed {
return try await rawRequest(path, method: method, body: body)
}
// Refresh failed force logout
await MainActor.run { onAuthFailure?() }
throw APIError.unauthorized
}
}
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
let decoder = JSONDecoder()
do {
return try decoder.decode(type, from: data)
} catch let error as DecodingError {
let detail: String
switch error {
case .keyNotFound(let key, let ctx):
let path = (ctx.codingPath + [key]).map(\.stringValue).joined(separator: ".")
detail = "missing key '\(key.stringValue)' (path: \(path))"
case .valueNotFound(_, let ctx):
let path = ctx.codingPath.map(\.stringValue).joined(separator: ".")
detail = "unexpected null at '\(path)'"
case .typeMismatch(_, let ctx):
let path = ctx.codingPath.map(\.stringValue).joined(separator: ".")
detail = "wrong type at '\(path)': \(ctx.debugDescription)"
case .dataCorrupted(let ctx):
detail = "corrupted data: \(ctx.debugDescription)"
@unknown default:
detail = error.localizedDescription
}
// Also print raw response for debugging during development
if let raw = String(data: data, encoding: .utf8) {
print("[APIClient] Decode failed for \(T.self): \(detail)\nRaw response: \(raw.prefix(500))")
}
throw APIError.decodingError(detail)
} catch {
throw APIError.decodingError(error.localizedDescription)
}
}
// MARK: - Auth
func register(email: String, password: String, displayName: String) async throws -> AuthResponse {
let body: [String: Any] = [
"email": email,
"password": password,
"display_name": displayName,
"timezone": TimeZone.current.identifier
]
let data = try await request("/auth/register", method: "POST", body: body)
return try decode(AuthResponse.self, from: data)
}
func login(email: String, password: String) async throws -> AuthResponse {
let body: [String: Any] = ["email": email, "password": password]
let data = try await request("/auth/login", method: "POST", body: body)
return try decode(AuthResponse.self, from: data)
}
func signInWithApple(identityToken: String, authorizationCode: String, fullName: String?) async throws -> AuthResponse {
var body: [String: Any] = [
"identity_token": identityToken,
"authorization_code": authorizationCode
]
if let name = fullName { body["full_name"] = name }
let data = try await request("/auth/apple", method: "POST", body: body)
return try decode(AuthResponse.self, from: data)
}
func registerDeviceToken(platform: String, token deviceToken: String) async throws {
let body: [String: Any] = ["platform": platform, "token": deviceToken]
_ = try await request("/auth/device-token", method: "POST", body: body)
}
// MARK: - Tasks
func getTasks(status: String? = nil) async throws -> [TaskOut] {
var path = "/tasks"
if let status { path += "?status=\(status)" }
let data = try await request(path)
return try decode([TaskOut].self, from: data)
}
func getUpcomingTasks() async throws -> [TaskOut] {
let data = try await request("/tasks/upcoming")
return try decode([TaskOut].self, from: data)
}
func createTask(
title: String,
description: String?,
priority: Int,
deadline: String?,
estimatedMinutes: Int?,
tags: [String] = [],
source: String = "manual"
) async throws -> TaskOut {
var body: [String: Any] = ["title": title, "priority": priority, "source": source, "tags": tags]
if let d = description, !d.isEmpty { body["description"] = d }
if let dl = deadline { body["deadline"] = dl }
if let em = estimatedMinutes { body["estimated_minutes"] = em }
let data = try await request("/tasks", method: "POST", body: body)
return try decode(TaskOut.self, from: data)
}
func brainDump(text: String, source: String = "voice") async throws -> BrainDumpResponse {
let body: [String: Any] = [
"raw_text": text,
"source": source,
"timezone": TimeZone.current.identifier
]
let data = try await request("/tasks/brain-dump", method: "POST", body: body)
return try decode(BrainDumpResponse.self, from: data)
}
func planTask(taskId: String) async throws -> PlanResponse {
let body: [String: Any] = ["plan_type": "llm_generated"]
let data = try await request("/tasks/\(taskId)/plan", method: "POST", body: body)
return try decode(PlanResponse.self, from: data)
}
func updateTask(taskId: String, fields: [String: Any]) async throws -> TaskOut {
let data = try await request("/tasks/\(taskId)", method: "PATCH", body: fields)
return try decode(TaskOut.self, from: data)
}
func deleteTask(taskId: String) async throws {
_ = try await request("/tasks/\(taskId)", method: "DELETE")
}
// MARK: - Steps
func getSteps(taskId: String) async throws -> [StepOut] {
let data = try await request("/tasks/\(taskId)/steps")
return try decode([StepOut].self, from: data)
}
func addStep(taskId: String, title: String, description: String? = nil, estimatedMinutes: Int? = nil) async throws -> StepOut {
var body: [String: Any] = ["title": title]
if let d = description { body["description"] = d }
if let m = estimatedMinutes { body["estimated_minutes"] = m }
let data = try await request("/tasks/\(taskId)/steps", method: "POST", body: body)
return try decode(StepOut.self, from: data)
}
func updateStep(stepId: String, fields: [String: Any]) async throws -> StepOut {
let data = try await request("/steps/\(stepId)", method: "PATCH", body: fields)
return try decode(StepOut.self, from: data)
}
func completeStep(stepId: String) async throws -> StepOut {
let data = try await request("/steps/\(stepId)/complete", method: "POST", body: [:])
return try decode(StepOut.self, from: data)
}
// MARK: - Sessions
func getActiveSession() async throws -> SessionOut {
let data = try await request("/sessions/active")
return try decode(SessionOut.self, from: data)
}
func startSession(
taskId: String?,
platform: String,
workAppBundleIds: [String] = []
) async throws -> SessionOut {
var body: [String: Any] = ["platform": platform]
if let tid = taskId { body["task_id"] = tid }
if !workAppBundleIds.isEmpty { body["work_app_bundle_ids"] = workAppBundleIds }
let data = try await request("/sessions/start", method: "POST", body: body)
return try decode(SessionOut.self, from: data)
}
func checkpointSession(sessionId: String, fields: [String: Any]) async throws -> SessionOut {
let data = try await request("/sessions/\(sessionId)/checkpoint", method: "POST", body: fields)
return try decode(SessionOut.self, from: data)
}
func endSession(sessionId: String, status: String = "completed") async throws -> SessionOut {
let body: [String: Any] = ["status": status]
let data = try await request("/sessions/\(sessionId)/end", method: "POST", body: body)
return try decode(SessionOut.self, from: data)
}
func resumeSession(sessionId: String) async throws -> ResumeResponse {
let data = try await request("/sessions/\(sessionId)/resume")
return try decode(ResumeResponse.self, from: data)
}
func joinSession(sessionId: String, platform: String, workAppBundleIds: [String] = []) async throws -> JoinSessionResponse {
var body: [String: Any] = ["platform": platform]
if !workAppBundleIds.isEmpty { body["work_app_bundle_ids"] = workAppBundleIds }
let data = try await request("/sessions/\(sessionId)/join", method: "POST", body: body)
return try decode(JoinSessionResponse.self, from: data)
}
// MARK: - Distractions
func appCheck(bundleId: String) async throws -> AppCheckResponse {
let body: [String: Any] = ["app_bundle_id": bundleId]
let data = try await request("/distractions/app-check", method: "POST", body: body)
return try decode(AppCheckResponse.self, from: data)
}
func reportAppActivity(
sessionId: String,
appBundleId: String,
appName: String,
durationSeconds: Int,
returnedToTask: Bool
) async throws {
let body: [String: Any] = [
"session_id": sessionId,
"app_bundle_id": appBundleId,
"app_name": appName,
"duration_seconds": durationSeconds,
"returned_to_task": returnedToTask
]
_ = try await request("/distractions/app-activity", method: "POST", body: body)
}
// MARK: - Analytics
func getAnalyticsSummary() async throws -> Data {
return try await request("/analytics/summary")
}
func getDistractionAnalytics() async throws -> Data {
return try await request("/analytics/distractions")
}
func getFocusTrends() async throws -> Data {
return try await request("/analytics/focus-trends")
}
func getWeeklyReport() async throws -> Data {
return try await request("/analytics/weekly-report")
}
}

View File

@@ -0,0 +1,137 @@
import ActivityKit
import UIKit
@MainActor
final class ActivityManager {
static let shared = ActivityManager()
/// Called when a Live Activity becomes active on this device (started remotely via push-to-start).
var onSessionStarted: (() async -> Void)?
/// Called when a Live Activity ends on this device (ended remotely via push).
var onSessionEnded: (() -> Void)?
/// Called when a Live Activity's content state updates (step progress changed).
var onContentStateUpdated: ((FocusSessionAttributes.ContentState) -> Void)?
/// Top-level observation tasks cancelled and replaced on each configure() call.
private var configurationTasks: [Task<Void, Never>] = []
/// Per-activity tasks keyed by activity ID prevents duplicate observers on re-yields.
private var activityTasks: [String: [Task<Void, Never>]] = [:]
private init() {}
func endAllActivities() {
Task {
for activity in Activity<FocusSessionAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}
func configure() {
// Cancel all existing tasks before starting fresh (handles logout login cycles)
configurationTasks.forEach { $0.cancel() }
configurationTasks.removeAll()
activityTasks.values.flatMap { $0 }.forEach { $0.cancel() }
activityTasks.removeAll()
configurationTasks.append(Task { await observeActivityUpdateTokens() })
if #available(iOS 17.2, *) {
configurationTasks.append(Task { await observePushToStartToken() })
}
}
/// Observes push tokens for all running activity instances (existing + newly started).
/// These update tokens are required for the server to end/update a specific activity.
private func observeActivityUpdateTokens() async {
for await activity in Activity<FocusSessionAttributes>.activityUpdates {
// activityUpdates can re-yield the same activity on content state changes.
// Guard against spawning duplicate observers for an activity we already track.
guard activityTasks[activity.id] == nil else { continue }
let tokenTask = Task {
for await tokenData in activity.pushTokenUpdates {
let tokenStr = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
guard let uuid = UIDevice.current.identifierForVendor?.uuidString else { continue }
let platformKey = "liveactivity_update_\(uuid)"
do {
try await APIClient.shared.registerDeviceToken(platform: platformKey, token: tokenStr)
print("[ActivityManager] Registered activity update token for activity \(activity.id).")
} catch APIError.unauthorized {
print("[ActivityManager] Auth expired, stopping activity token observation.")
return
} catch {
print("[ActivityManager] Failed to register activity update token: \(error)")
}
}
}
let stateTask = Task {
for await state in activity.activityStateUpdates {
switch state {
case .active:
// Write initial context to SharedDefaults for shield extensions
let cs = activity.content.state
SharedDefaults.writeSessionContext(
taskTitle: cs.taskTitle,
stepsCompleted: cs.stepsCompleted,
stepsTotal: cs.stepsTotal,
currentStepTitle: cs.currentStepTitle,
lastCompletedStepTitle: cs.lastCompletedStepTitle
)
ScreenTimeManager.shared.startMonitoring()
await onSessionStarted?()
case .ended, .dismissed:
SharedDefaults.clearSessionContext()
ScreenTimeManager.shared.stopMonitoring()
onSessionEnded?()
activityTasks.removeValue(forKey: activity.id)
default:
break
}
}
}
// Keep SharedDefaults in sync with Live Activity content state updates
// so the shield always shows the latest step progress
let contentTask = Task {
for await content in activity.contentUpdates {
let cs = content.state
SharedDefaults.writeSessionContext(
taskTitle: cs.taskTitle,
stepsCompleted: cs.stepsCompleted,
stepsTotal: cs.stepsTotal,
currentStepTitle: cs.currentStepTitle,
lastCompletedStepTitle: cs.lastCompletedStepTitle
)
onContentStateUpdated?(cs)
}
}
activityTasks[activity.id] = [tokenTask, stateTask, contentTask]
}
}
@available(iOS 17.2, *)
private func observePushToStartToken() async {
for await data in Activity<FocusSessionAttributes>.pushToStartTokenUpdates {
let tokenString = data.map { String(format: "%02.2hhx", $0) }.joined()
print("Received push-to-start token: \(tokenString)")
guard let uuid = UIDevice.current.identifierForVendor?.uuidString else {
print("[ActivityManager] No vendor UUID available, skipping token registration")
continue
}
let platformKey = "liveactivity_\(uuid)"
do {
try await APIClient.shared.registerDeviceToken(platform: platformKey, token: tokenString)
print("[ActivityManager] Successfully registered liveactivity token.")
} catch APIError.unauthorized {
// Token refresh failed and user was logged out stop observing
print("[ActivityManager] Auth expired, stopping token observation.")
return
} catch {
print("[ActivityManager] Failed to register liveactivity token: \(error)")
}
}
}
}

View File

@@ -0,0 +1,131 @@
// AppState.swift LockInBro
// Central observable state shared across all views via @Environment
import SwiftUI
import Observation
@Observable
final class AppState {
// MARK: - Auth State
var isAuthenticated = false
var currentUser: UserOut?
// MARK: - Task State
var tasks: [TaskOut] = []
var isLoadingTasks = false
// MARK: - Session State
var activeSession: SessionOut?
// MARK: - UI State
var globalError: String?
var isLoading = false
// Set by deep link / notification tap to trigger navigation
var pendingOpenTaskId: String?
var pendingResumeSessionId: String?
// MARK: - Init
init() {
if let token = KeychainService.shared.getToken() {
APIClient.shared.token = token
isAuthenticated = true
}
APIClient.shared.onAuthFailure = { [weak self] in
self?.logout()
}
}
// MARK: - Auth
@MainActor
func login(email: String, password: String) async throws {
isLoading = true
globalError = nil
defer { isLoading = false }
let response = try await APIClient.shared.login(email: email, password: password)
await applyAuthResponse(response)
}
@MainActor
func register(email: String, password: String, displayName: String) async throws {
isLoading = true
globalError = nil
defer { isLoading = false }
let response = try await APIClient.shared.register(
email: email, password: password, displayName: displayName
)
await applyAuthResponse(response)
}
@MainActor
func applyAuthResponse(_ response: AuthResponse) async {
APIClient.shared.token = response.accessToken
KeychainService.shared.saveToken(response.accessToken)
KeychainService.shared.saveRefreshToken(response.refreshToken)
currentUser = response.user
isAuthenticated = true
await loadTasks()
}
@MainActor
func logout() {
APIClient.shared.token = nil
KeychainService.shared.deleteAll()
isAuthenticated = false
currentUser = nil
tasks = []
activeSession = nil
}
// MARK: - Tasks
@MainActor
func loadTasks() async {
isLoadingTasks = true
defer { isLoadingTasks = false }
do {
tasks = try await APIClient.shared.getTasks()
} catch {
globalError = error.localizedDescription
}
await loadActiveSession()
}
@MainActor
func loadActiveSession() async {
do {
activeSession = try await APIClient.shared.getActiveSession()
} catch {
activeSession = nil
}
}
@MainActor
func deleteTask(_ task: TaskOut) async {
do {
try await APIClient.shared.deleteTask(taskId: task.id)
tasks.removeAll { $0.id == task.id }
} catch {
globalError = error.localizedDescription
}
}
@MainActor
func markTaskDone(_ task: TaskOut) async {
do {
let updated = try await APIClient.shared.updateTask(taskId: task.id, fields: ["status": "done"])
if let idx = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[idx] = updated
}
} catch {
globalError = error.localizedDescription
}
}
// MARK: - Computed
var pendingTaskCount: Int { tasks.filter { $0.status != "done" }.count }
var urgentTasks: [TaskOut] { tasks.filter { $0.priority == 4 && $0.status != "done" } }
var overdueTasks: [TaskOut] { tasks.filter { $0.isOverdue } }
}

View File

@@ -0,0 +1,82 @@
// KeychainService.swift LockInBro
// Secure JWT token storage in the system Keychain
import Foundation
import Security
final class KeychainService {
static let shared = KeychainService()
private init() {}
private let service = "com.lockinbro.app"
private let tokenAccount = "jwt_access"
private let refreshAccount = "jwt_refresh"
// MARK: - Token
func saveToken(_ token: String) {
save(token, account: tokenAccount)
}
func getToken() -> String? {
return load(account: tokenAccount)
}
func saveRefreshToken(_ token: String) {
save(token, account: refreshAccount)
}
func getRefreshToken() -> String? {
return load(account: refreshAccount)
}
func deleteAll() {
delete(account: tokenAccount)
delete(account: refreshAccount)
}
// MARK: - Private Keychain Operations
private func save(_ value: String, account: String) {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let attributes: [String: Any] = [kSecValueData as String: data]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
var addQuery = query
addQuery[kSecValueData as String] = data
SecItemAdd(addQuery as CFDictionary, nil)
}
}
private func load(account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private func delete(account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -0,0 +1,187 @@
// NotificationService.swift LockInBro
// APNs token registration, local notification scheduling, and notification routing
import Foundation
import UserNotifications
import UIKit
// Published notification type strings (match backend payload "type" field)
enum PushType: String {
case sessionHandoff = "session_handoff"
case sessionEnded = "session_ended"
case deadlineApproach = "deadline_approaching"
case morningBrief = "morning_brief"
case focusNudge = "focus_nudge"
case focusStreak = "focus_streak"
case resumeSession = "resume_session"
}
final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationService()
private override init() { super.init() }
// Callback so the app can route taps to the correct screen
var onDeepLink: ((URL) -> Void)?
// MARK: - Setup
func configure() {
UNUserNotificationCenter.current().delegate = self
}
// MARK: - Permission + Registration
/// Request permission then register for remote (APNs) notifications.
/// Call this after the user logs in.
func registerForPushNotifications() async {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
guard granted else { return }
} catch {
print("[Notifications] Permission error: \(error)")
return
}
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
/// Called by AppDelegate when APNs returns a device token.
/// Sends the token to the backend for push delivery.
func didRegisterWithToken(_ tokenData: Data) {
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
print("[Notifications] APNs token: \(token)")
let platform = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
Task {
do {
try await APIClient.shared.registerDeviceToken(platform: platform, token: token)
print("[Notifications] Successfully registered APNs token.")
} catch APIError.unauthorized {
print("[Notifications] Auth expired during APNs token registration.")
} catch {
print("[Notifications] Failed to register APNs token: \(error)")
}
}
}
func didFailToRegister(with error: Error) {
print("[Notifications] APNs registration failed: \(error)")
}
// MARK: - Local Notification Scheduling
/// Schedule deadline reminders for tasks that have deadlines.
/// Fires at 24h before and 1h before the deadline.
func scheduleDeadlineReminders(for tasks: [TaskOut]) {
let center = UNUserNotificationCenter.current()
// Remove old deadline identifiers before re-scheduling
let oldIds = tasks.flatMap { ["\($0.id)-24h", "\($0.id)-1h"] }
center.removePendingNotificationRequests(withIdentifiers: oldIds)
let now = Date()
for task in tasks where task.status != "done" {
guard let deadline = task.deadlineDate else { continue }
for (suffix, offset) in [("-24h", -86400.0), ("-1h", -3600.0)] {
let fireDate = deadline.addingTimeInterval(offset)
guard fireDate > now else { continue }
let content = UNMutableNotificationContent()
content.title = "Deadline approaching"
let interval = offset == -86400 ? "tomorrow" : "in 1 hour"
content.body = "'\(task.title)' is due \(interval). \(task.estimatedMinutes.map { "~\($0) min estimated." } ?? "")"
content.sound = .default
content.badge = 1
if let url = URL(string: "lockinbro://task?id=\(task.id)") {
content.userInfo = ["deep_link": url.absoluteString, "type": PushType.deadlineApproach.rawValue]
}
let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate)
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: "\(task.id)\(suffix)", content: content, trigger: trigger)
center.add(request)
}
}
}
/// Schedule a daily morning brief at the given hour (default 9 AM).
func scheduleMorningBrief(hour: Int = 9) {
let center = UNUserNotificationCenter.current()
center.removePendingNotificationRequests(withIdentifiers: ["morning-brief"])
let content = UNMutableNotificationContent()
content.title = "Good morning! 🌅"
content.body = "Open LockInBro to see your tasks for today."
content.sound = .default
content.userInfo = ["type": PushType.morningBrief.rawValue]
var components = DateComponents()
components.hour = hour
components.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(identifier: "morning-brief", content: content, trigger: trigger)
center.add(request)
}
/// Fire a local nudge notification immediately (used by DeviceActivityMonitor extension,
/// or when the app detects the user has been idle for too long).
func sendLocalNudge(title: String, body: String, deepLink: String? = nil) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
if let deepLink { content.userInfo = ["deep_link": deepLink] }
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
let id = "nudge-\(UUID().uuidString)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
}
// MARK: - UNUserNotificationCenterDelegate
/// Show notifications as banners even when the app is in the foreground.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound, .badge])
}
/// Handle notification taps extract deep link and route.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
defer { completionHandler() }
let userInfo = response.notification.request.content.userInfo
// Prefer explicit deep_link field in payload
if let rawLink = userInfo["deep_link"] as? String,
let url = URL(string: rawLink) {
onDeepLink?(url)
return
}
// Fallback: construct deep link from type + ids in payload
let type = userInfo["type"] as? String ?? ""
switch type {
case PushType.sessionHandoff.rawValue, PushType.resumeSession.rawValue:
if let sessionId = userInfo["session_id"] as? String,
let url = URL(string: "lockinbro://join-session?id=\(sessionId)") {
onDeepLink?(url)
}
case PushType.deadlineApproach.rawValue, PushType.morningBrief.rawValue, PushType.focusNudge.rawValue:
if let taskId = userInfo["task_id"] as? String,
let url = URL(string: "lockinbro://task?id=\(taskId)") {
onDeepLink?(url)
}
default:
break
}
}
}

View File

@@ -0,0 +1,134 @@
// ScreenTimeManager.swift LockInBro
// Manages FamilyControls authorization, app selection, and DeviceActivity scheduling.
// When a focus session starts, schedules monitoring with threshold-based events.
// The DeviceActivityMonitor extension applies shields when thresholds are exceeded.
import Foundation
import FamilyControls
import DeviceActivity
import ManagedSettings
import SwiftUI
import Combine
class ScreenTimeManager: ObservableObject {
static let shared = ScreenTimeManager()
@Published var isAuthorized: Bool = false
@Published var selection = FamilyActivitySelection() {
didSet {
SharedDefaults.saveAppSelection(selection)
}
}
/// Named store that the Monitor extension also uses both must reference the same name.
let store = ManagedSettingsStore(named: .lockinbro)
private let center = DeviceActivityCenter()
private init() {
if let saved = SharedDefaults.loadAppSelection() {
selection = saved
}
isAuthorized = AuthorizationCenter.shared.authorizationStatus == .approved
}
// MARK: - Authorization
func requestAuthorization() {
Task {
do {
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
await MainActor.run {
self.isAuthorized = AuthorizationCenter.shared.authorizationStatus == .approved
}
} catch {
print("[ScreenTime] Failed to authorize: \(error)")
}
}
}
// MARK: - Session Lifecycle
/// Start monitoring distraction apps. Called when a focus session begins.
func startMonitoring() {
guard isAuthorized else {
print("[ScreenTime] Not authorized, skipping monitoring")
return
}
guard !selection.applicationTokens.isEmpty || !selection.categoryTokens.isEmpty else {
print("[ScreenTime] No apps selected, skipping monitoring")
return
}
// Clear any existing shields and stop previous schedule
store.shield.applications = nil
store.shield.applicationCategories = nil
center.stopMonitoring([.focusSession])
let threshold = SharedDefaults.distractionThresholdMinutes
let thresholdInterval = DateComponents(minute: threshold)
// Build events one per selected app token with the user's threshold
var events: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:]
for token in selection.applicationTokens {
let eventName = DeviceActivityEvent.Name("distraction_\(token.hashValue)")
events[eventName] = DeviceActivityEvent(
applications: [token],
categories: [],
webDomains: [],
threshold: thresholdInterval
)
}
// Also add category-level events
for token in selection.categoryTokens {
let eventName = DeviceActivityEvent.Name("distraction_cat_\(token.hashValue)")
events[eventName] = DeviceActivityEvent(
applications: [],
categories: [token],
webDomains: [],
threshold: thresholdInterval
)
}
let now = Date()
var startComp = Calendar.current.dateComponents([.hour, .minute], from: now)
// Prevent intervalStart == intervalEnd collision
if startComp.hour == 23 && startComp.minute == 59 {
startComp.hour = 0
startComp.minute = 0
}
// Schedule covers from exactly "now" to 23:59.
// Starting at `now` resets the cumulative tracking counter,
// ensuring any usage earlier in the day doesn't break our threshold logic.
let schedule = DeviceActivitySchedule(
intervalStart: startComp,
intervalEnd: DateComponents(hour: 23, minute: 59),
repeats: false
)
do {
try center.startMonitoring(.focusSession, during: schedule, events: events)
print("[ScreenTime] Started monitoring \(events.count) event(s), threshold=\(threshold)min")
} catch {
print("[ScreenTime] Failed to start monitoring: \(error)")
}
}
/// Stop monitoring and clear all shields. Called when a focus session ends.
func stopMonitoring() {
center.stopMonitoring([.focusSession, DeviceActivityName("lockinbro_extension_1m")])
store.shield.applications = nil
store.shield.applicationCategories = nil
print("[ScreenTime] Stopped monitoring, shields cleared")
}
}
// MARK: - Named constants
extension DeviceActivityName {
static let focusSession = DeviceActivityName("lockinbro_focus_session")
}
extension ManagedSettingsStore.Name {
static let lockinbro = ManagedSettingsStore.Name("lockinbro")
}

View File

@@ -0,0 +1,106 @@
// SharedDefaults.swift LockInBro
// Shared App Group UserDefaults for communication between main app and extensions.
// All Screen Time extensions (Monitor, Shield, ShieldAction) read from this store
// to get the current task context and user preferences.
import Foundation
import FamilyControls
enum SharedDefaults {
static let suiteName = "group.com.adipu.LockInBroMobile"
static var store: UserDefaults {
UserDefaults(suiteName: suiteName) ?? .standard
}
// MARK: - Keys
private enum Key {
static let distractionThreshold = "distractionThresholdMinutes"
static let appSelection = "screenTimeSelection"
static let taskTitle = "currentTaskTitle"
static let stepsCompleted = "currentStepsCompleted"
static let stepsTotal = "currentStepsTotal"
static let currentStepTitle = "currentStepTitle"
static let lastCompletedStepTitle = "lastCompletedStepTitle"
static let sessionActive = "sessionActive"
}
// MARK: - Distraction Threshold
static var distractionThresholdMinutes: Int {
get { store.object(forKey: Key.distractionThreshold) as? Int ?? 2 }
set { store.set(newValue, forKey: Key.distractionThreshold) }
}
// MARK: - App Selection (encoded FamilyActivitySelection)
static func saveAppSelection(_ selection: FamilyActivitySelection) {
if let data = try? JSONEncoder().encode(selection) {
store.set(data, forKey: Key.appSelection)
}
}
static func loadAppSelection() -> FamilyActivitySelection? {
guard let data = store.data(forKey: Key.appSelection) else { return nil }
return try? JSONDecoder().decode(FamilyActivitySelection.self, from: data)
}
// MARK: - Current Session Context (written by main app, read by shield extension)
static var sessionActive: Bool {
get { store.bool(forKey: Key.sessionActive) }
set { store.set(newValue, forKey: Key.sessionActive) }
}
static var taskTitle: String? {
get { store.string(forKey: Key.taskTitle) }
set { store.set(newValue, forKey: Key.taskTitle) }
}
static var stepsCompleted: Int {
get { store.integer(forKey: Key.stepsCompleted) }
set { store.set(newValue, forKey: Key.stepsCompleted) }
}
static var stepsTotal: Int {
get { store.integer(forKey: Key.stepsTotal) }
set { store.set(newValue, forKey: Key.stepsTotal) }
}
static var currentStepTitle: String? {
get { store.string(forKey: Key.currentStepTitle) }
set { store.set(newValue, forKey: Key.currentStepTitle) }
}
static var lastCompletedStepTitle: String? {
get { store.string(forKey: Key.lastCompletedStepTitle) }
set { store.set(newValue, forKey: Key.lastCompletedStepTitle) }
}
/// Write full session context atomically (called when session starts or Live Activity updates).
static func writeSessionContext(
taskTitle: String,
stepsCompleted: Int,
stepsTotal: Int,
currentStepTitle: String?,
lastCompletedStepTitle: String?
) {
self.sessionActive = true
self.taskTitle = taskTitle
self.stepsCompleted = stepsCompleted
self.stepsTotal = stepsTotal
self.currentStepTitle = currentStepTitle
self.lastCompletedStepTitle = lastCompletedStepTitle
}
/// Clear session context (called when session ends).
static func clearSessionContext() {
sessionActive = false
store.removeObject(forKey: Key.taskTitle)
store.removeObject(forKey: Key.stepsCompleted)
store.removeObject(forKey: Key.stepsTotal)
store.removeObject(forKey: Key.currentStepTitle)
store.removeObject(forKey: Key.lastCompletedStepTitle)
}
}

View File

@@ -0,0 +1,194 @@
// SpeechService.swift LockInBro
// On-device speech recognition via WhisperKit for Brain Dump voice input
import Foundation
import Combine
import AVFoundation
import WhisperKit
import Speech
import NaturalLanguage
@MainActor
final class SpeechService: NSObject, ObservableObject, AVAudioRecorderDelegate {
static let shared = SpeechService()
// WhisperKit Properties
private var audioRecorder: AVAudioRecorder?
private var whisperKit: WhisperKit?
private let tempAudioURL = FileManager.default.temporaryDirectory.appendingPathComponent("braindump.wav")
// Live Keyword Properties (For UI Bubbles)
private let liveRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
private var liveRecognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var liveRecognitionTask: SFSpeechRecognitionTask?
private let audioEngine = AVAudioEngine()
@Published var transcript = ""
@Published var latestKeyword: String? = nil
@Published var isRecording = false
@Published var isTranscribing = false
// iOS 17+ API for microphone permissions
@Published var authStatus = AVAudioApplication.shared.recordPermission
@Published var modelLoadingState: String = "Not Loaded"
private override init() {
super.init()
Task {
await setupWhisper()
}
}
// MARK: - Setup WhisperKit]
private func setupWhisper() async {
modelLoadingState = "Loading Local Model..."
// 1. More robust way to find the folder path
let folderName = "distil-whisper_distil-large-v3_594MB"
guard let resourceURL = Bundle.main.resourceURL else {
modelLoadingState = "Error: Resource bundle not found"
return
}
let modelURL = resourceURL.appendingPathComponent(folderName)
let fm = FileManager.default
// 2. Check if the folder actually exists before trying to load it
if !fm.fileExists(atPath: modelURL.path) {
modelLoadingState = "Error: Folder not found in bundle"
print("Looked for model at: \(modelURL.path)")
return
}
do {
// WhisperKit expects the directory path string
whisperKit = try await WhisperKit(modelFolder: modelURL.path)
modelLoadingState = "Ready"
} catch {
modelLoadingState = "Failed to load model: \(error.localizedDescription)"
print("WhisperKit Init Error: \(error)")
}
}
// MARK: - Authorization
func requestAuthorization() async {
// Modern iOS 17+ API for requesting mic permission
let audioGranted = await AVAudioApplication.requestRecordPermission()
let speechStatus = await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
self.authStatus = (audioGranted && speechStatus == .authorized) ? .granted : .denied
}
// MARK: - Recording
func startRecording() throws {
self.transcript = ""
self.latestKeyword = nil
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.duckOthers, .defaultToSpeaker])
try audioSession.setActive(true)
// 1. Setup high-quality file recording for WhisperKit
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatLinearPCM),
AVSampleRateKey: 16000.0,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
]
audioRecorder = try AVAudioRecorder(url: tempAudioURL, settings: settings)
audioRecorder?.delegate = self
audioRecorder?.record()
// 2. Setup lightweight live listener for UI Bubbles
let inputNode = audioEngine.inputNode
liveRecognitionRequest = SFSpeechAudioBufferRecognitionRequest()
liveRecognitionRequest?.shouldReportPartialResults = true
let tagger = NLTagger(tagSchemes: [.lexicalClass])
var seenWords = Set<String>()
liveRecognitionTask = liveRecognizer?.recognitionTask(with: liveRecognitionRequest!) { [weak self] result, error in
guard let self = self, let result = result else { return }
let newText = result.bestTranscription.formattedString
tagger.string = newText
tagger.enumerateTags(in: newText.startIndex..<newText.endIndex, unit: .word, scheme: .lexicalClass, options: [.omitWhitespace, .omitPunctuation]) { tag, tokenRange in
let word = String(newText[tokenRange]).capitalized
// Only spawn bubbles for unique Nouns, Verbs, or Names
if (tag == .noun || tag == .verb || tag == .organizationName) && !seenWords.contains(word) && word.count > 2 {
seenWords.insert(word)
DispatchQueue.main.async {
self.latestKeyword = word
}
}
return true
}
}
let recordingFormat = inputNode.outputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
self.liveRecognitionRequest?.append(buffer)
}
audioEngine.prepare()
try audioEngine.start()
isRecording = true
}
func stopRecordingAndTranscribe() async throws -> String {
// Stop file recorder
audioRecorder?.stop()
// Stop live listener
if audioEngine.isRunning {
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
liveRecognitionRequest?.endAudio()
liveRecognitionTask?.cancel()
}
isRecording = false
isTranscribing = true
guard let whisper = whisperKit else {
isTranscribing = false
throw NSError(domain: "SpeechService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Model not loaded yet."])
}
do {
let results = try await whisper.transcribe(audioPath: tempAudioURL.path)
let finalTranscript = results.map { $0.text }.joined(separator: " ")
self.transcript = finalTranscript
self.isTranscribing = false
return finalTranscript
} catch {
self.isTranscribing = false
throw error
}
}
func reset() {
if isRecording {
audioRecorder?.stop()
if audioEngine.isRunning {
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
}
isRecording = false
}
transcript = ""
latestKeyword = nil
}
}

View File

@@ -0,0 +1,174 @@
// AuthView.swift LockInBro
// Login / Register / Apple Sign In
import SwiftUI
import AuthenticationServices
struct AuthView: View {
@Environment(AppState.self) private var appState
@State private var isLogin = true
@State private var email = ""
@State private var password = ""
@State private var displayName = ""
@State private var localError: String?
@State private var isSubmitting = false
var body: some View {
ScrollView {
VStack(spacing: 32) {
// MARK: Logo / Header
VStack(spacing: 10) {
Image(systemName: "brain.head.profile")
.font(.system(size: 64))
.foregroundStyle(.blue)
.symbolEffect(.pulse)
Text("LockInBro")
.font(.largeTitle.bold())
Text("ADHD-Aware Focus Assistant")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 48)
// MARK: Form
VStack(spacing: 14) {
Picker("Mode", selection: $isLogin) {
Text("Log In").tag(true)
Text("Sign Up").tag(false)
}
.pickerStyle(.segmented)
if !isLogin {
TextField("Your name", text: $displayName)
.textFieldStyle(.roundedBorder)
.textContentType(.name)
}
TextField("Email address", text: $email)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.textContentType(isLogin ? .password : .newPassword)
if let err = localError ?? appState.globalError {
Text(err)
.foregroundStyle(.red)
.font(.caption)
.multilineTextAlignment(.center)
.padding(.horizontal, 4)
}
Button(action: submit) {
HStack {
if isSubmitting { ProgressView().tint(.white) }
Text(isLogin ? "Log In" : "Create Account")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(isFormValid ? Color.blue : Color.gray)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!isFormValid || isSubmitting)
}
.padding(.horizontal)
// MARK: Apple Sign In
VStack(spacing: 14) {
HStack {
Rectangle().frame(height: 1).foregroundStyle(.secondary.opacity(0.3))
Text("or").font(.caption).foregroundStyle(.secondary)
Rectangle().frame(height: 1).foregroundStyle(.secondary.opacity(0.3))
}
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
} onCompletion: { result in
handleAppleResult(result)
}
.frame(height: 50)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal)
Spacer(minLength: 40)
}
}
.onChange(of: isLogin) { _, _ in
localError = nil
appState.globalError = nil
}
}
private var isFormValid: Bool {
!email.isEmpty && !password.isEmpty && (isLogin || !displayName.isEmpty)
}
private func submit() {
localError = nil
appState.globalError = nil
isSubmitting = true
Task {
do {
if isLogin {
try await appState.login(email: email, password: password)
} else {
try await appState.register(email: email, password: password, displayName: displayName)
}
} catch {
localError = error.localizedDescription
}
isSubmitting = false
}
}
private func handleAppleResult(_ result: Result<ASAuthorization, Error>) {
localError = nil
switch result {
case .success(let auth):
guard let credential = auth.credential as? ASAuthorizationAppleIDCredential,
let tokenData = credential.identityToken,
let token = String(data: tokenData, encoding: .utf8),
let codeData = credential.authorizationCode,
let code = String(data: codeData, encoding: .utf8) else {
localError = "Apple Sign In failed — missing credentials"
return
}
let parts = [credential.fullName?.givenName, credential.fullName?.familyName]
let fullName = parts.compactMap { $0 }.joined(separator: " ")
Task {
do {
let response = try await APIClient.shared.signInWithApple(
identityToken: token,
authorizationCode: code,
fullName: fullName.isEmpty ? nil : fullName
)
await appState.applyAuthResponse(response)
} catch {
await MainActor.run { localError = error.localizedDescription }
}
}
case .failure(let error):
// User cancelled sign-in don't show error
if (error as NSError).code != ASAuthorizationError.canceled.rawValue {
localError = error.localizedDescription
}
}
}
}
#Preview {
AuthView()
.environment(AppState())
}

View File

@@ -0,0 +1,685 @@
// BrainDumpView.swift LockInBro
// Voice/text brain dump Local WhisperKit Transcription Claude Task Extraction
import SwiftUI
import AVFoundation
import NaturalLanguage
struct BrainDumpView: View {
@Environment(AppState.self) private var appState
@StateObject private var speech = SpeechService.shared
@State private var dumpText = ""
@State private var isParsing = false
@State private var parsedResult: BrainDumpResponse?
@State private var selectedIndices: Set<Int> = []
@State private var acceptedSuggestions: Set<UUID> = []
@State private var isSaving = false
@State private var error: String?
@State private var showConfirmation = false
@State private var floatingKeywords: [FloatingKeyword] = []
// Derived state for the loading screen
private var isProcessing: Bool {
speech.isTranscribing || isParsing
}
var body: some View {
NavigationStack {
Group {
if parsedResult != nil {
resultsView
} else if isProcessing {
processingView
} else if speech.isRecording {
recordingView
} else {
idleInputView
}
}
.navigationTitle(speech.isRecording ? "" : "Brain Dump")
.navigationBarTitleDisplayMode(.large)
.toolbar {
if parsedResult != nil || !dumpText.isEmpty {
ToolbarItem(placement: .topBarLeading) {
Button("Clear") { resetState() }
.foregroundStyle(.secondary)
}
}
}
.alert("Tasks Saved!", isPresented: $showConfirmation) {
Button("OK") { resetState() }
} message: {
Text("Your tasks have been added to your task board.")
}
// Smoothly animate between the different UI states
.animation(.default, value: speech.isRecording)
.animation(.default, value: isProcessing)
.animation(.default, value: parsedResult != nil)
}
}
// MARK: - 1. Idle / Text Input View
private var idleInputView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
Label("What's on your mind?", systemImage: "lightbulb.fill")
.font(.headline)
Text("Hit the mic and just start talking. We'll extract your tasks, deadlines, and priorities automatically.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// Big Audio Button Prominence
VStack(spacing: 8) {
Button(action: startRecording) {
HStack(spacing: 12) {
if speech.modelLoadingState != "Ready" {
ProgressView().tint(.white)
} else {
Image(systemName: "mic.fill")
.font(.title2)
}
Text(speech.modelLoadingState == "Ready" ? "Start Brain Dump" : "Loading AI Model...")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding()
.background(speech.modelLoadingState == "Ready" ? Color.blue : Color.gray)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: speech.modelLoadingState == "Ready" ? .blue.opacity(0.3) : .clear, radius: 8, x: 0, y: 4)
}
.disabled(speech.modelLoadingState != "Ready")
if speech.modelLoadingState != "Ready" {
Text(speech.modelLoadingState)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
Divider()
// Fallback Text Area
Text("Or type it out:")
.font(.subheadline.bold())
.foregroundStyle(.secondary)
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 14)
.fill(Color(.secondarySystemBackground))
.frame(minHeight: 150)
if dumpText.isEmpty {
Text("e.g. I need to email Sarah about the project, dentist appointment Thursday...")
.foregroundStyle(.secondary.opacity(0.6))
.font(.subheadline)
.padding(14)
}
TextEditor(text: $dumpText)
.scrollContentBackground(.hidden)
.padding(10)
.font(.subheadline)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
}
}
.frame(minHeight: 150)
if let error {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
if !dumpText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Button(action: {
Task { await parseDump(text: dumpText, source: "manual") }
}) {
Text("Parse Typed Text")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.gray.opacity(0.2))
.foregroundStyle(.primary)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
}
.padding()
}
}
// MARK: - 2. Active Recording View
private var recordingView: some View {
VStack(spacing: 40) {
Spacer()
// The Audio Visualizer & Bubble Canvas
ZStack {
// Background Pulses
Circle()
.fill(Color.blue.opacity(0.15))
.frame(width: 240, height: 240)
.scaleEffect(speech.isRecording ? 1.15 : 1.0)
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: speech.isRecording)
Circle()
.fill(Color.blue.opacity(0.25))
.frame(width: 180, height: 180)
.scaleEffect(speech.isRecording ? 1.05 : 1.0)
.animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: speech.isRecording)
Image(systemName: "waveform")
.font(.system(size: 80, weight: .light))
.foregroundStyle(.blue)
.symbolEffect(.variableColor.iterative, isActive: speech.isRecording)
// Floating Keywords Layer
ForEach(floatingKeywords) { keyword in
GlassBubbleView(keyword: keyword)
}
}
.frame(height: 300) // Give the bubbles room to float
VStack(spacing: 12) {
Text("Listening...")
.font(.title.bold())
Text("Speak freely. Pauses are fine.")
.font(.body)
.foregroundStyle(.secondary)
}
Spacer()
Button(action: stopAndProcess) {
HStack(spacing: 12) {
Image(systemName: "stop.fill")
Text("Done Talking")
}
.font(.title3.bold())
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.padding(.vertical, 8)
.background(Color.red)
.clipShape(RoundedRectangle(cornerRadius: 20))
.shadow(color: .red.opacity(0.3), radius: 10, y: 5)
}
.padding(.horizontal, 30)
.padding(.bottom, 40)
}
.onChange(of: speech.latestKeyword) { _, newWord in
if let newWord = newWord {
spawnKeywordBubble(word: newWord)
}
}
}
// MARK: - 3. Processing View
private var processingView: some View {
VStack(spacing: 24) {
Spacer()
ProgressView()
.scaleEffect(1.5)
.tint(.blue)
VStack(spacing: 8) {
Text(speech.isTranscribing ? "Transcribing audio locally..." : "Extracting tasks...")
.font(.headline)
Text(speech.isTranscribing ? "Running Whisper on Neural Engine" : "Claude is analyzing your dump")
.font(.subheadline)
.foregroundStyle(.secondary)
}
if let error {
Text(error)
.foregroundStyle(.red)
.font(.caption)
.multilineTextAlignment(.center)
.padding()
}
Spacer()
}
}
// MARK: - 4. Results View
private var resultsView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Found \(parsedResult?.parsedTasks.count ?? 0) tasks")
.font(.headline)
Text("Select the ones you want to save")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button(selectedIndices.count == (parsedResult?.parsedTasks.count ?? 0) ? "Deselect All" : "Select All") {
toggleSelectAll()
}
.font(.subheadline)
}
ForEach(Array((parsedResult?.parsedTasks ?? []).enumerated()), id: \.offset) { idx, task in
ParsedTaskCard(
task: task,
isSelected: selectedIndices.contains(idx),
acceptedSuggestions: $acceptedSuggestions
) {
if selectedIndices.contains(idx) { selectedIndices.remove(idx) }
else { selectedIndices.insert(idx) }
}
}
if let frags = parsedResult?.unparseableFragments, !frags.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Label("Couldn't parse these:", systemImage: "questionmark.circle")
.font(.caption.bold())
.foregroundStyle(.orange)
ForEach(frags, id: \.self) { frag in
Text("\(frag)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
.background(Color.orange.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
if let error {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
Button(action: saveTasks) {
HStack(spacing: 8) {
if isSaving { ProgressView().tint(.white) }
Text(isSaving ? "Saving…" : "Save \(selectedIndices.count) Task\(selectedIndices.count == 1 ? "" : "s")")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(selectedIndices.isEmpty ? Color.gray : Color.green)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(selectedIndices.isEmpty || isSaving)
}
.padding()
}
}
// MARK: - Core Actions
private func startRecording() {
error = nil
Task {
if speech.authStatus != .granted {
await speech.requestAuthorization()
}
if speech.authStatus == .granted {
do {
try speech.startRecording()
} catch {
self.error = "Mic error: \(error.localizedDescription)"
}
} else {
self.error = "Microphone access denied. Please enable in Settings."
}
}
}
private func stopAndProcess() {
Task {
do {
error = nil
let transcript = try await speech.stopRecordingAndTranscribe()
guard !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
self.error = "Couldn't hear anything. Tap clear and try again."
return
}
dumpText = transcript
await parseDump(text: transcript, source: "voice")
} catch {
self.error = "Transcription failed: \(error.localizedDescription)"
}
}
}
private func parseDump(text: String, source: String) async {
isParsing = true
do {
let result = try await APIClient.shared.brainDump(text: text, source: source)
await MainActor.run {
self.parsedResult = result
self.selectedIndices = Set(0..<result.parsedTasks.count)
self.isParsing = false
}
} catch {
await MainActor.run {
self.error = error.localizedDescription
self.isParsing = false
}
}
}
private func saveTasks() {
guard let result = parsedResult else { return }
isSaving = true
error = nil
Task {
let allTasks = result.parsedTasks
// Delete tasks the user deselected (they were already saved by the backend)
for (idx, task) in allTasks.enumerated() {
guard let taskId = task.taskId else { continue }
if !selectedIndices.contains(idx) {
try? await APIClient.shared.deleteTask(taskId: taskId)
}
}
// Add accepted suggested steps to kept tasks
for idx in selectedIndices.sorted() {
let task = allTasks[idx]
guard let taskId = task.taskId else { continue }
for sub in task.subtasks where sub.suggested && acceptedSuggestions.contains(sub.id) {
do {
_ = try await APIClient.shared.addStep(
taskId: taskId,
title: sub.title,
description: sub.description,
estimatedMinutes: sub.estimatedMinutes
)
} catch {
await MainActor.run { self.error = error.localizedDescription }
}
}
}
await appState.loadTasks()
await MainActor.run {
isSaving = false
showConfirmation = true
}
}
}
private func toggleSelectAll() {
let total = parsedResult?.parsedTasks.count ?? 0
if selectedIndices.count == total {
selectedIndices = []
} else {
selectedIndices = Set(0..<total)
}
}
private func resetState() {
parsedResult = nil
dumpText = ""
selectedIndices = []
acceptedSuggestions = []
error = nil
floatingKeywords.removeAll()
speech.reset()
}
// MARK: - Keyword Bubble Animation Logic
// Moved safely INSIDE the BrainDumpView struct
private func spawnKeywordBubble(word: String) {
let startX = CGFloat.random(in: -100...100)
let startY = CGFloat.random(in: 20...80)
let newKeyword = FloatingKeyword(
text: word,
xOffset: startX,
yOffset: startY
)
floatingKeywords.append(newKeyword)
let index = floatingKeywords.count - 1
// 1. Pop In
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
floatingKeywords[index].opacity = 1.0
floatingKeywords[index].scale = 1.0
}
// 2. Float Upwards slowly
withAnimation(.easeOut(duration: 3.0)) {
floatingKeywords[index].yOffset -= 150
}
// 3. Fade Out and Remove
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
guard let matchIndex = floatingKeywords.firstIndex(where: { $0.id == newKeyword.id }) else { return }
withAnimation(.easeOut(duration: 1.0)) {
floatingKeywords[matchIndex].opacity = 0.0
floatingKeywords[matchIndex].scale = 0.8
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
floatingKeywords.removeAll(where: { $0.id == newKeyword.id })
}
}
}
} // <--- End of BrainDumpView Struct
// MARK: - Parsed Task Card
struct ParsedTaskCard: View {
let task: ParsedTask
let isSelected: Bool
@Binding var acceptedSuggestions: Set<UUID>
let onTap: () -> Void
private var coreSteps: [ParsedSubtask] { task.subtasks.filter { !$0.suggested } }
private var suggestedSteps: [ParsedSubtask] { task.subtasks.filter { $0.suggested } }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onTap) {
HStack(alignment: .top, spacing: 14) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isSelected ? .green : .secondary)
VStack(alignment: .leading, spacing: 7) {
Text(task.title)
.font(.subheadline.bold())
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 10) {
if task.priority > 0 {
Text("Priority \(task.priority)")
.font(.caption)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.clipShape(Capsule())
}
if let dl = task.deadline, let date = ISO8601DateFormatter().date(from: dl) {
Label(date.formatted(.dateTime.month().day()), systemImage: "calendar")
.font(.caption)
.foregroundStyle(.secondary)
}
if let mins = task.estimatedMinutes {
Label("\(mins)m", systemImage: "clock")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !task.tags.isEmpty {
HStack(spacing: 4) {
ForEach(task.tags, id: \.self) { tag in
Text(tag)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
}
}
}
if let desc = task.description {
Text(desc)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
// Core steps (from the brain dump text)
if !coreSteps.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(coreSteps) { sub in
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(.green.opacity(0.6))
Text(sub.title)
.font(.caption)
if let mins = sub.estimatedMinutes {
Text("(\(mins)m)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.padding(.top, 2)
}
}
Spacer()
}
.padding()
.background(isSelected ? Color.green.opacity(0.06) : Color(.secondarySystemBackground))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(isSelected ? Color.green.opacity(0.5) : Color.clear, lineWidth: 1.5)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
// Suggested steps (toggleable, outside the main button)
if isSelected && !suggestedSteps.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("Suggested steps")
.font(.caption2.bold())
.foregroundStyle(.secondary)
.padding(.leading, 4)
ForEach(suggestedSteps) { sub in
Button {
if acceptedSuggestions.contains(sub.id) {
acceptedSuggestions.remove(sub.id)
} else {
acceptedSuggestions.insert(sub.id)
}
} label: {
HStack(spacing: 8) {
Image(systemName: acceptedSuggestions.contains(sub.id) ? "plus.circle.fill" : "plus.circle")
.foregroundStyle(acceptedSuggestions.contains(sub.id) ? .blue : .secondary)
Text(sub.title)
.font(.caption)
.foregroundStyle(.primary)
if let mins = sub.estimatedMinutes {
Text("(\(mins)m)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(acceptedSuggestions.contains(sub.id) ? Color.blue.opacity(0.08) : Color(.tertiarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14))
.padding(.top, -4)
}
} // end outer VStack
}
}
// MARK: - Floating Keyword Data Model
struct FloatingKeyword: Identifiable, Equatable {
let id = UUID()
let text: String
var xOffset: CGFloat
var yOffset: CGFloat
var opacity: Double = 0.0
var scale: CGFloat = 0.5
}
// MARK: - The Liquid Glass Bubble
struct GlassBubbleView: View {
let keyword: FloatingKeyword
var body: some View {
Text(keyword.text)
.font(.system(.callout, design: .rounded).weight(.semibold))
.foregroundStyle(.primary)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.ultraThinMaterial)
.background(
LinearGradient(
colors: [.orange.opacity(0.3), .pink.opacity(0.2), .yellow.opacity(0.2)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(Color.white.opacity(0.4), lineWidth: 1)
)
.shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 5)
.scaleEffect(keyword.scale)
.opacity(keyword.opacity)
.offset(x: keyword.xOffset, y: keyword.yOffset)
}
}

View File

@@ -0,0 +1,105 @@
// CreateTaskView.swift LockInBro
// Manual task creation form
import SwiftUI
struct CreateTaskView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var description = ""
@State private var priority = 0
@State private var hasDeadline = false
@State private var deadline = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date()
@State private var estimatedMinutesText = ""
@State private var isCreating = false
@State private var error: String?
var body: some View {
NavigationStack {
Form {
Section("Task") {
TextField("Title", text: $title)
TextField("Description (optional)", text: $description, axis: .vertical)
.lineLimit(2...5)
}
Section("Priority") {
Picker("Priority", selection: $priority) {
Text("").tag(0)
Text("Low").tag(1)
Text("Medium").tag(2)
Text("High").tag(3)
Text("Urgent").tag(4)
}
.pickerStyle(.segmented)
}
Section("Time") {
Toggle("Has deadline", isOn: $hasDeadline)
if hasDeadline {
DatePicker("Deadline", selection: $deadline, displayedComponents: [.date, .hourAndMinute])
}
HStack {
Text("Estimated time")
Spacer()
TextField("min", text: $estimatedMinutesText)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
Text("min")
.foregroundStyle(.secondary)
}
}
if let error {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.navigationTitle("New Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Create") { createTask() }
.fontWeight(.semibold)
.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isCreating)
}
}
}
}
private func createTask() {
isCreating = true
error = nil
Task {
do {
_ = try await APIClient.shared.createTask(
title: title.trimmingCharacters(in: .whitespacesAndNewlines),
description: description.isEmpty ? nil : description,
priority: priority,
deadline: hasDeadline ? ISO8601DateFormatter().string(from: deadline) : nil,
estimatedMinutes: Int(estimatedMinutesText)
)
await appState.loadTasks()
await MainActor.run { dismiss() }
} catch {
await MainActor.run {
self.error = error.localizedDescription
isCreating = false
}
}
}
}
}
#Preview {
CreateTaskView()
.environment(AppState())
}

View File

@@ -0,0 +1,259 @@
// DashboardView.swift LockInBro
// Analytics dashboard: task stats + Hex-powered distraction/focus trends
import SwiftUI
struct DashboardView: View {
@Environment(AppState.self) private var appState
@State private var analyticsJSON: [String: Any] = [:]
@State private var isLoadingAnalytics = false
@State private var analyticsError: String?
// Derived from local task state (no network needed)
private var totalTasks: Int { appState.tasks.count }
private var pendingCount: Int { appState.tasks.filter { $0.status == "pending" }.count }
private var inProgressCount: Int { appState.tasks.filter { $0.status == "in_progress" }.count }
private var doneCount: Int { appState.tasks.filter { $0.status == "done" }.count }
private var urgentCount: Int { appState.tasks.filter { $0.priority == 4 && $0.status != "done" }.count }
private var overdueCount: Int { appState.tasks.filter { $0.isOverdue }.count }
private var upcomingTasks: [TaskOut] {
appState.tasks
.filter { $0.deadlineDate != nil && $0.status != "done" }
.sorted { ($0.deadlineDate ?? .distantFuture) < ($1.deadlineDate ?? .distantFuture) }
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
overviewSection
statusBreakdownSection
upcomingSection
hexAnalyticsSection
}
.padding()
}
.navigationTitle("Dashboard")
.refreshable {
await appState.loadTasks()
await loadAnalytics()
}
.task { await loadAnalytics() }
}
}
// MARK: - Overview Grid
private var overviewSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Overview")
.font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
StatCard(value: "\(totalTasks)", label: "Total Tasks", icon: "list.bullet.clipboard", color: .blue)
StatCard(value: "\(inProgressCount)", label: "In Progress", icon: "arrow.triangle.2.circlepath", color: .orange)
StatCard(value: "\(doneCount)", label: "Completed", icon: "checkmark.seal.fill", color: .green)
StatCard(value: "\(urgentCount)", label: "Urgent", icon: "exclamationmark.triangle.fill", color: .red)
}
if overdueCount > 0 {
HStack(spacing: 8) {
Image(systemName: "alarm.fill")
.foregroundStyle(.red)
Text("\(overdueCount) overdue task\(overdueCount == 1 ? "" : "s") need attention")
.font(.subheadline)
.foregroundStyle(.red)
Spacer()
}
.padding()
.background(Color.red.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Status Breakdown
private var statusBreakdownSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Task Breakdown")
.font(.headline)
VStack(spacing: 10) {
StatusBarRow(label: "Pending", count: pendingCount, total: totalTasks, color: .gray)
StatusBarRow(label: "In Progress", count: inProgressCount, total: totalTasks, color: .blue)
StatusBarRow(label: "Done", count: doneCount, total: totalTasks, color: .green)
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
// MARK: - Upcoming Deadlines
private var upcomingSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Upcoming Deadlines")
.font(.headline)
if upcomingTasks.isEmpty {
Text("No upcoming deadlines")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding()
} else {
VStack(spacing: 0) {
ForEach(upcomingTasks.prefix(5)) { task in
HStack(spacing: 10) {
PriorityBadge(priority: task.priority)
Text(task.title)
.font(.subheadline)
.lineLimit(1)
Spacer()
if let date = task.deadlineDate {
Text(date, style: .relative)
.font(.caption)
.foregroundStyle(task.isOverdue ? .red : .secondary)
.multilineTextAlignment(.trailing)
}
}
.padding(.vertical, 8)
.padding(.horizontal)
if task.id != upcomingTasks.prefix(5).last?.id {
Divider().padding(.leading)
}
}
}
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
}
// MARK: - Hex Analytics Section
private var hexAnalyticsSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("Hex Analytics", systemImage: "chart.xyaxis.line")
.font(.headline)
Spacer()
if isLoadingAnalytics {
ProgressView()
}
}
if let err = analyticsError {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text(err)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(Color.orange.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
} else if !analyticsJSON.isEmpty {
// Display raw analytics data
analyticsContent
} else {
VStack(spacing: 8) {
Image(systemName: "chart.bar.xaxis")
.font(.largeTitle)
.foregroundStyle(.purple)
Text("Distraction patterns, focus trends, and personalized ADHD insights appear here after your first focus sessions.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.purple.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
}
@ViewBuilder
private var analyticsContent: some View {
VStack(alignment: .leading, spacing: 10) {
if let totalSessions = analyticsJSON["total_sessions"] as? Int {
HStack {
Label("Focus Sessions", systemImage: "timer")
Spacer()
Text("\(totalSessions)")
.font(.subheadline.bold())
}
}
if let totalMinutes = analyticsJSON["total_focus_minutes"] as? Double {
HStack {
Label("Focus Time", systemImage: "clock.fill")
Spacer()
Text("\(Int(totalMinutes))m")
.font(.subheadline.bold())
}
}
if let distractionCount = analyticsJSON["total_distractions"] as? Int {
HStack {
Label("Distractions", systemImage: "bell.badge")
Spacer()
Text("\(distractionCount)")
.font(.subheadline.bold())
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
// MARK: - Data Loading
private func loadAnalytics() async {
isLoadingAnalytics = true
analyticsError = nil
do {
let data = try await APIClient.shared.getAnalyticsSummary()
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
await MainActor.run { analyticsJSON = json }
}
} catch {
await MainActor.run { analyticsError = error.localizedDescription }
}
await MainActor.run { isLoadingAnalytics = false }
}
}
// MARK: - Status Bar Row
struct StatusBarRow: View {
let label: String
let count: Int
let total: Int
let color: Color
private var progress: Double { total > 0 ? Double(count) / Double(total) : 0 }
var body: some View {
HStack(spacing: 10) {
Text(label)
.font(.subheadline)
.frame(width: 80, alignment: .leading)
ProgressView(value: progress)
.tint(color)
Text("\(count)")
.font(.subheadline.bold())
.foregroundStyle(color)
.frame(width: 28, alignment: .trailing)
}
}
}
#Preview {
DashboardView()
.environment(AppState())
}

View File

@@ -0,0 +1,32 @@
// MainTabView.swift LockInBro
// Root tab navigation after login
import SwiftUI
struct MainTabView: View {
@Environment(AppState.self) private var appState
var body: some View {
TabView {
BrainDumpView()
.tabItem { Label("Brain Dump", systemImage: "brain") }
TaskBoardView()
.tabItem { Label("Tasks", systemImage: "checklist") }
DashboardView()
.tabItem { Label("Dashboard", systemImage: "chart.bar.xaxis") }
SettingsView()
.tabItem { Label("Settings", systemImage: "gear") }
}
.task {
await appState.loadTasks()
}
}
}
#Preview {
MainTabView()
.environment(AppState())
}

View File

@@ -0,0 +1,208 @@
// SettingsView.swift LockInBro
// App settings: notifications, focus preferences, account management
import SwiftUI
import UserNotifications
import FamilyControls
struct SettingsView: View {
@Environment(AppState.self) private var appState
@State private var showLogoutConfirmation = false
@State private var notificationsGranted = false
@State private var checkInIntervalMinutes = 10
@State private var distractionThresholdMinutes = SharedDefaults.distractionThresholdMinutes
private let checkInOptions = [5, 10, 15, 20]
private let thresholdOptions = [1, 2, 5]
var body: some View {
NavigationStack {
Form {
accountSection
notificationsSection
focusSection
appInfoSection
logoutSection
}
.navigationTitle("Settings")
.confirmationDialog("Log out of LockInBro?", isPresented: $showLogoutConfirmation, titleVisibility: .visible) {
Button("Log Out", role: .destructive) { appState.logout() }
Button("Cancel", role: .cancel) {}
}
.task { await checkNotificationStatus() }
.onChange(of: distractionThresholdMinutes) { _, newValue in
SharedDefaults.distractionThresholdMinutes = newValue
}
}
}
// MARK: - Account Section
private var accountSection: some View {
Section("Account") {
if let user = appState.currentUser {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(Color.blue.opacity(0.15))
.frame(width: 48, height: 48)
Text(String(user.displayName?.prefix(1) ?? "U"))
.font(.title2.bold())
.foregroundStyle(.blue)
}
VStack(alignment: .leading, spacing: 2) {
Text(user.displayName ?? "User")
.font(.headline)
if let email = user.email {
Text(email)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
if let tz = user.timezone {
LabeledContent("Timezone", value: tz)
}
}
}
}
// MARK: - Notifications Section
private var notificationsSection: some View {
Section {
HStack {
Label("Notifications", systemImage: notificationsGranted ? "bell.fill" : "bell.slash.fill")
Spacer()
if notificationsGranted {
Text("Enabled")
.font(.caption)
.foregroundStyle(.green)
} else {
Button("Enable") { requestNotifications() }
.font(.caption)
.foregroundStyle(.blue)
}
}
if notificationsGranted {
Label("Deadline alerts", systemImage: "calendar.badge.exclamationmark")
.foregroundStyle(.primary)
Label("Morning brief", systemImage: "sun.max")
.foregroundStyle(.primary)
Label("Focus streak rewards", systemImage: "flame")
.foregroundStyle(.primary)
Label("Gentle nudges (4+ hour idle)", systemImage: "hand.wave")
.foregroundStyle(.primary)
}
} header: {
Text("Notifications")
} footer: {
if !notificationsGranted {
Text("Enable notifications to receive deadline reminders, morning briefs, and focus nudges.")
}
}
}
@StateObject private var screenTimeManager = ScreenTimeManager.shared
@State private var isPresentingActivityPicker = false
// MARK: - Focus Section
private var focusSection: some View {
Section("Focus Session") {
Picker("Check-in interval", selection: $checkInIntervalMinutes) {
ForEach(checkInOptions, id: \.self) { mins in
Text("Every \(mins) minutes").tag(mins)
}
}
Picker("Distraction threshold", selection: $distractionThresholdMinutes) {
ForEach(thresholdOptions, id: \.self) { mins in
Text("\(mins) min off-task").tag(mins)
}
}
if screenTimeManager.isAuthorized {
Button(action: { isPresentingActivityPicker = true }) {
Label("Select Distraction Apps", systemImage: "app.badge")
}
.familyActivityPicker(
isPresented: $isPresentingActivityPicker,
selection: $screenTimeManager.selection
)
} else {
Button(action: { screenTimeManager.requestAuthorization() }) {
Label("Enable Screen Time", systemImage: "hourglass")
.foregroundStyle(.blue)
}
}
Label("Screenshot analysis: macOS only", systemImage: "camera.on.rectangle")
.font(.caption)
.foregroundStyle(.secondary)
Label("App monitoring: DeviceActivityMonitor", systemImage: "app.badge.checkmark")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: - App Info
private var appInfoSection: some View {
Section("About") {
LabeledContent("Backend", value: "wahwa.com")
LabeledContent("Platform") {
Text(UIDevice.current.userInterfaceIdiom == .pad ? "iPadOS" : "iOS")
.foregroundStyle(.secondary)
}
LabeledContent("Version", value: "1.0 — YHack 2026")
LabeledContent("AI", value: "Claude (Anthropic)")
LabeledContent("Analytics", value: "Hex")
}
}
// MARK: - Logout
private var logoutSection: some View {
Section {
Button(role: .destructive) {
showLogoutConfirmation = true
} label: {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
Text("Log Out")
}
}
}
}
// MARK: - Notification Helpers
private func checkNotificationStatus() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
await MainActor.run {
notificationsGranted = settings.authorizationStatus == .authorized
}
}
private func requestNotifications() {
Task {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
await MainActor.run { notificationsGranted = granted }
} catch {
// User denied
}
}
}
}
#Preview {
SettingsView()
.environment(AppState())
}

View File

@@ -0,0 +1,172 @@
// SharedComponents.swift LockInBro
// Reusable view components used across multiple screens
import SwiftUI
// MARK: - Priority Badge
struct PriorityBadge: View {
let priority: Int
private var label: String {
switch priority {
case 1: return "Low"
case 2: return "Med"
case 3: return "High"
case 4: return "Urgent"
default: return ""
}
}
private var color: Color {
switch priority {
case 1: return .gray
case 2: return .blue
case 3: return .orange
case 4: return .red
default: return .secondary
}
}
var body: some View {
Text(label)
.font(.caption.bold())
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.foregroundStyle(color)
.clipShape(Capsule())
}
}
// MARK: - Status Badge
struct StatusBadge: View {
let status: String
private var color: Color {
switch status {
case "done": return .green
case "in_progress": return .blue
case "ready": return .purple
case "planning": return .teal
case "deferred": return .gray
default: return .secondary
}
}
var body: some View {
Text(status.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.foregroundStyle(color)
.clipShape(Capsule())
}
}
// MARK: - Filter Chip
struct FilterChip: View {
let label: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.subheadline)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(isSelected ? Color.blue : Color.gray.opacity(0.15))
.foregroundStyle(isSelected ? .white : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
// MARK: - Info Card
struct InfoCard: View {
let icon: String
let title: String
let content: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: icon)
.font(.subheadline.bold())
.foregroundStyle(color)
Text(content)
.font(.subheadline)
.fixedSize(horizontal: false, vertical: true)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(color.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Stat Card
struct StatCard: View {
let value: String
let label: String
let icon: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Image(systemName: icon)
.foregroundStyle(color)
.font(.title3)
Text(value)
.font(.title.bold())
.foregroundStyle(color)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(color.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Tag Pill
struct TagPill: View {
let tag: String
var body: some View {
Text(tag)
.font(.caption2)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.12))
.foregroundStyle(.blue)
.clipShape(Capsule())
}
}
// MARK: - Deadline Label
struct DeadlineLabel: View {
let task: TaskOut
var body: some View {
if let date = task.deadlineDate {
HStack(spacing: 4) {
Image(systemName: "calendar")
Text(date, style: .date)
}
.font(.caption)
.foregroundStyle(task.isOverdue ? .red : .secondary)
}
}
}

View File

@@ -0,0 +1,209 @@
// TaskBoardView.swift LockInBro
// Priority-sorted task list with step progress indicators
import SwiftUI
struct TaskBoardView: View {
@Environment(AppState.self) private var appState
@State private var filterStatus = "active"
@State private var searchText = ""
@State private var showingCreate = false
@State private var navigationPath = NavigationPath()
private let filters: [(id: String, label: String)] = [
("active", "Active"),
("pending", "Pending"),
("in_progress", "In Progress"),
("done", "Done"),
("all", "All")
]
private var filteredTasks: [TaskOut] {
var list = appState.tasks
switch filterStatus {
case "active":
list = list.filter { $0.status != "done" && $0.status != "deferred" }
case "all":
break
default:
list = list.filter { $0.status == filterStatus }
}
if !searchText.isEmpty {
list = list.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
($0.description?.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
return list.sorted {
// Overdue first, then by priority desc, then deadline asc
if $0.isOverdue != $1.isOverdue { return $0.isOverdue }
if $0.priority != $1.priority { return $0.priority > $1.priority }
switch ($0.deadlineDate, $1.deadlineDate) {
case (let a?, let b?): return a < b
case (nil, _?): return false
case (_?, nil): return true
default: return false
}
}
}
var body: some View {
NavigationStack(path: $navigationPath) {
VStack(spacing: 0) {
filterBar
Divider()
content
}
.navigationTitle("Tasks")
.navigationDestination(for: String.self) { taskId in
TaskDetailView(taskId: taskId)
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showingCreate = true } label: {
Image(systemName: "plus")
}
}
}
.searchable(text: $searchText, prompt: "Search tasks")
.sheet(isPresented: $showingCreate) {
CreateTaskView()
}
.onChange(of: appState.pendingOpenTaskId) { _, taskId in
guard let taskId else { return }
navigationPath.append(taskId)
appState.pendingOpenTaskId = nil
}
}
}
// MARK: - Filter Bar
private var filterBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(filters, id: \.id) { f in
FilterChip(label: f.label, isSelected: filterStatus == f.id) {
filterStatus = f.id
}
}
}
.padding(.horizontal)
.padding(.vertical, 10)
}
}
// MARK: - Content
@ViewBuilder
private var content: some View {
if appState.isLoadingTasks && appState.tasks.isEmpty {
ProgressView("Loading tasks…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if filteredTasks.isEmpty {
ContentUnavailableView {
Label("No Tasks", systemImage: "checklist")
} description: {
Text(searchText.isEmpty
? "Use Brain Dump to capture what's on your mind."
: "No tasks match \"\(searchText)\".")
} actions: {
if searchText.isEmpty {
Button("New Task") { showingCreate = true }
.buttonStyle(.borderedProminent)
}
}
} else {
List {
ForEach(filteredTasks) { task in
NavigationLink(value: task.id) {
TaskRowView(task: task)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
Task { await appState.deleteTask(task) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if task.status != "done" {
Button {
Task { await appState.markTaskDone(task) }
} label: {
Label("Done", systemImage: "checkmark")
}
.tint(.green)
}
}
}
}
.listStyle(.insetGrouped)
.refreshable { await appState.loadTasks() }
}
}
}
// MARK: - Task Row
struct TaskRowView: View {
let task: TaskOut
@State private var steps: [StepOut] = []
@State private var loaded = false
private var completedCount: Int { steps.filter { $0.isDone }.count }
private var progress: Double { steps.isEmpty ? 0 : Double(completedCount) / Double(steps.count) }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Title row
HStack(alignment: .top) {
Text(task.title)
.font(.subheadline.bold())
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 8)
StatusBadge(status: task.status)
}
// Meta row
HStack(spacing: 10) {
PriorityBadge(priority: task.priority)
DeadlineLabel(task: task)
if let mins = task.estimatedMinutes {
Label("\(mins)m", systemImage: "clock")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
// Step progress
if loaded && !steps.isEmpty {
VStack(spacing: 3) {
ProgressView(value: progress)
.tint(progress >= 1 ? .green : .blue)
Text("\(completedCount) / \(steps.count) steps")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
.task {
guard !loaded else { return }
if let s = try? await APIClient.shared.getSteps(taskId: task.id) {
steps = s
loaded = true
}
}
}
}
#Preview {
TaskBoardView()
.environment(AppState())
}

View File

@@ -0,0 +1,510 @@
// TaskDetailView.swift LockInBro
// Full task view: metadata, steps, focus session controls, resume card
import SwiftUI
import ActivityKit
struct TaskDetailView: View {
let taskId: String
@Environment(AppState.self) private var appState
@State private var task: TaskOut?
@State private var steps: [StepOut] = []
@State private var isLoadingSteps = true
@State private var isGeneratingPlan = false
@State private var resumeResponse: ResumeResponse?
@State private var showResumeCard = false
@State private var isStartingSession = false
@State private var isEndingSession = false
@State private var error: String?
private var completedCount: Int { steps.filter { $0.isDone }.count }
private var progress: Double { steps.isEmpty ? 0 : Double(completedCount) / Double(steps.count) }
var body: some View {
Group {
if let task {
mainContent(task)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(task?.title ?? "Task")
.task { await load() }
.sheet(isPresented: $showResumeCard) {
if let resume = resumeResponse {
ResumeCardView(resume: resume) { showResumeCard = false }
}
}
}
// MARK: - Main Content
private func mainContent(_ task: TaskOut) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
taskHeader(task)
focusSection(task)
stepsSection(task)
if let error {
Text(error)
.foregroundStyle(.red)
.font(.caption)
.padding(.horizontal)
}
}
.padding()
}
}
// MARK: - Task Header Card
private func taskHeader(_ task: TaskOut) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
PriorityBadge(priority: task.priority)
StatusBadge(status: task.status)
Spacer()
if let mins = task.estimatedMinutes {
Label("\(mins)m", systemImage: "clock")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let desc = task.description {
Text(desc)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
DeadlineLabel(task: task)
if !task.tags.isEmpty {
HStack(spacing: 4) {
ForEach(task.tags, id: \.self) { TagPill(tag: $0) }
}
}
if !steps.isEmpty {
VStack(spacing: 5) {
ProgressView(value: progress)
.tint(progress >= 1 ? .green : .blue)
Text("\(completedCount) of \(steps.count) steps completed")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
// MARK: - Focus Session Section
private func focusSection(_ task: TaskOut) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Focus Session")
.font(.headline)
Spacer()
Button("Test Local") {
let attributes = FocusSessionAttributes(sessionType: "Test")
let state = FocusSessionAttributes.ContentState(taskTitle: "Local Test", startedAt: Int(Date().timeIntervalSince1970), stepsCompleted: 0, stepsTotal: 0)
do {
_ = try Activity.request(attributes: attributes, content: .init(state: state, staleDate: nil), pushType: .token)
print("Success: Started local Activity")
} catch {
print("Failed to start local Activity: \(error)")
}
}
.font(.caption)
.buttonStyle(.bordered)
}
if let session = appState.activeSession {
// Active session card
HStack {
VStack(alignment: .leading, spacing: 4) {
if session.taskId == taskId {
Label("Session Active", systemImage: "play.circle.fill")
.font(.subheadline.bold())
.foregroundStyle(.green)
Text("Focusing on this task")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Label("Session Active Elsewhere", systemImage: "exclamationmark.triangle.fill")
.font(.subheadline.bold())
.foregroundStyle(.orange)
Text("You are focusing on another task")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Button(action: endSession) {
if isEndingSession {
ProgressView()
} else {
Text("End Session")
.font(.subheadline)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Color.red.opacity(0.12))
.foregroundStyle(.red)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.disabled(isEndingSession)
}
.padding()
.background(session.taskId == taskId ? Color.green.opacity(0.07) : Color.orange.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: 14))
} else {
// Start session button
Button(action: startSession) {
HStack(spacing: 8) {
if isStartingSession { ProgressView().tint(.white) }
Image(systemName: "play.circle.fill")
Text("Start Focus Session")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(isStartingSession)
}
}
}
// MARK: - Steps Section
private func stepsSection(_ task: TaskOut) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Steps")
.font(.headline)
Spacer()
if steps.isEmpty && task.status != "done" {
Button(action: generatePlan) {
if isGeneratingPlan {
ProgressView()
} else {
Label("AI Plan", systemImage: "wand.and.stars")
.font(.subheadline)
}
}
.disabled(isGeneratingPlan)
}
}
if isLoadingSteps {
ProgressView().frame(maxWidth: .infinity)
} else if steps.isEmpty {
VStack(spacing: 10) {
Image(systemName: "list.number")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No steps yet")
.font(.subheadline.bold())
Text("Tap \"AI Plan\" to let Claude break this task into 515 minute steps.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14))
} else {
ForEach(Array(steps.enumerated()), id: \.element.id) { idx, step in
StepRowView(step: step) { updated in
steps[idx] = updated
}
}
}
}
}
// MARK: - Actions
private func load() async {
// Find task in state
task = appState.tasks.first(where: { $0.id == taskId })
// Load steps
isLoadingSteps = true
do {
steps = try await APIClient.shared.getSteps(taskId: taskId)
} catch {
self.error = error.localizedDescription
}
isLoadingSteps = false
// Refresh task from app state in case it was updated
if task == nil {
task = appState.tasks.first(where: { $0.id == taskId })
}
}
private func generatePlan() {
isGeneratingPlan = true
error = nil
Task {
do {
let plan = try await APIClient.shared.planTask(taskId: taskId)
await MainActor.run {
steps = plan.steps
isGeneratingPlan = false
}
} catch {
await MainActor.run {
self.error = error.localizedDescription
isGeneratingPlan = false
}
}
}
}
private func startSession() {
isStartingSession = true
error = nil
let platform = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
Task {
do {
let session = try await APIClient.shared.startSession(taskId: taskId, platform: platform)
await MainActor.run {
appState.activeSession = session
isStartingSession = false
}
} catch {
await MainActor.run {
self.error = error.localizedDescription
isStartingSession = false
}
}
}
}
private func endSession() {
guard let session = appState.activeSession else { return }
isEndingSession = true
Task {
do {
_ = try await APIClient.shared.endSession(sessionId: session.id)
// End Live Activity locally (belt-and-suspenders alongside the server push)
ActivityManager.shared.endAllActivities()
// Fetch resume card
let resume = try? await APIClient.shared.resumeSession(sessionId: session.id)
await MainActor.run {
appState.activeSession = nil
isEndingSession = false
if let resume {
resumeResponse = resume
showResumeCard = true
}
}
// Reload tasks to pick up updated step statuses
await appState.loadTasks()
steps = (try? await APIClient.shared.getSteps(taskId: taskId)) ?? steps
} catch {
await MainActor.run {
self.error = error.localizedDescription
isEndingSession = false
}
}
}
}
}
// MARK: - Step Row
struct StepRowView: View {
let step: StepOut
let onUpdate: (StepOut) -> Void
@State private var isUpdating = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Complete toggle
Button(action: toggleComplete) {
ZStack {
if isUpdating {
ProgressView().frame(width: 28, height: 28)
} else {
Image(systemName: iconName)
.font(.title2)
.foregroundStyle(iconColor)
}
}
.frame(width: 28, height: 28)
}
.disabled(isUpdating)
VStack(alignment: .leading, spacing: 5) {
Text(step.title)
.font(.subheadline)
.strikethrough(step.isDone, color: .secondary)
.foregroundStyle(step.isDone ? .secondary : .primary)
.fixedSize(horizontal: false, vertical: true)
// Checkpoint note from VLM
if let note = step.checkpointNote {
HStack(alignment: .top, spacing: 5) {
Image(systemName: "bookmark.fill")
.font(.caption2)
.foregroundStyle(.blue)
Text(note)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(7)
.background(Color.blue.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
HStack(spacing: 8) {
if let mins = step.estimatedMinutes {
Text("~\(mins)m")
.font(.caption2)
.foregroundStyle(.secondary)
}
StatusBadge(status: step.status)
}
}
Spacer()
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var iconName: String {
if step.isDone { return "checkmark.circle.fill" }
if step.isInProgress { return "play.circle.fill" }
return "circle"
}
private var iconColor: Color {
if step.isDone { return .green }
if step.isInProgress { return .blue }
return .secondary
}
private func toggleComplete() {
isUpdating = true
Task {
do {
let updated: StepOut
if step.isDone {
updated = try await APIClient.shared.updateStep(stepId: step.id, fields: ["status": "pending"])
} else {
updated = try await APIClient.shared.completeStep(stepId: step.id)
}
await MainActor.run {
onUpdate(updated)
isUpdating = false
}
} catch {
await MainActor.run { isUpdating = false }
}
}
}
}
// MARK: - Resume Card Modal
struct ResumeCardView: View {
let resume: ResumeResponse
let onDismiss: () -> Void
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Welcome
VStack(alignment: .leading, spacing: 4) {
Text(resume.resumeCard.welcomeBack)
.font(.title2.bold())
Text(resume.task.title)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Divider()
InfoCard(
icon: "arrow.uturn.backward.circle",
title: "Where you left off",
content: resume.resumeCard.youWereDoing,
color: .blue
)
InfoCard(
icon: "arrow.right.circle.fill",
title: "Next up",
content: resume.resumeCard.nextStep,
color: .green
)
// Progress
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Progress", systemImage: "chart.bar.fill")
.font(.subheadline.bold())
Spacer()
Text("\(resume.progress.completed) / \(resume.progress.total) steps")
.font(.caption)
.foregroundStyle(.secondary)
}
ProgressView(
value: Double(resume.progress.completed),
total: Double(max(resume.progress.total, 1))
)
.tint(.blue)
if resume.progress.distractionCount > 0 {
Text("\(resume.progress.distractionCount) distraction\(resume.progress.distractionCount == 1 ? "" : "s") this session")
.font(.caption)
.foregroundStyle(.orange)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14))
// Motivation
Text(resume.resumeCard.motivation)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
Button("Let's Go!", action: onDismiss)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
.fontWeight(.semibold)
}
.padding()
}
.navigationTitle("Welcome Back")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done", action: onDismiss)
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
//
// LockInBroMobileTests.swift
// LockInBroMobileTests
//
// Created by Aditya Pulipaka on 3/28/26.
//
import Testing
@testable import LockInBroMobile
struct LockInBroMobileTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation
// https://developer.apple.com/documentation/testing
}
}

View File

@@ -0,0 +1,43 @@
//
// LockInBroMobileUITests.swift
// LockInBroMobileUITests
//
// Created by Aditya Pulipaka on 3/28/26.
//
import XCTest
final class LockInBroMobileUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
// XCUIAutomation Documentation
// https://developer.apple.com/documentation/xcuiautomation
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}

View File

@@ -0,0 +1,35 @@
//
// LockInBroMobileUITestsLaunchTests.swift
// LockInBroMobileUITests
//
// Created by Aditya Pulipaka on 3/28/26.
//
import XCTest
final class LockInBroMobileUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
// XCUIAutomation Documentation
// https://developer.apple.com/documentation/xcuiautomation
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}

View File

@@ -0,0 +1,61 @@
//
// DeviceActivityMonitorExtension.swift
// LockInBroMonitor
//
// When a distraction-app usage event exceeds the user's threshold,
// this extension applies a ManagedSettings shield so the app shows
// a "get back to work" overlay. Shields are cleared when the focus
// session schedule ends or the main app calls stopMonitoring().
//
import DeviceActivity
import FamilyControls
import Foundation
import ManagedSettings
class DeviceActivityMonitorExtension: DeviceActivityMonitor {
private let store = ManagedSettingsStore(named: .lockinbro)
private let defaults = UserDefaults(suiteName: "group.com.adipu.LockInBroMobile")
// MARK: - Threshold Reached
override func eventDidReachThreshold(_ event: DeviceActivityEvent.Name, activity: DeviceActivityName) {
super.eventDidReachThreshold(event, activity: activity)
// Load the user's selected distraction apps from the shared App Group
guard let data = defaults?.data(forKey: "screenTimeSelection"),
let selection = try? JSONDecoder().decode(FamilyActivitySelection.self, from: data) else {
return
}
// Apply shield to all selected apps once ANY threshold fires, shield them all.
// This is simpler and gives the user a single nudge rather than per-app shields
// trickling in one-by-one.
store.shield.applications = selection.applicationTokens.isEmpty ? nil : selection.applicationTokens
store.shield.applicationCategories = selection.categoryTokens.isEmpty
? nil
: ShieldSettings.ActivityCategoryPolicy.specific(selection.categoryTokens)
}
// MARK: - Schedule Lifecycle
override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity)
// Ensure shields are clear at the start of each monitoring interval
store.shield.applications = nil
store.shield.applicationCategories = nil
}
override func intervalDidEnd(for activity: DeviceActivityName) {
super.intervalDidEnd(for: activity)
// Clean up shields when the schedule interval ends
store.shield.applications = nil
store.shield.applicationCategories = nil
}
}
// Mirror the named store constant from the main app
extension ManagedSettingsStore.Name {
static let lockinbro = ManagedSettingsStore.Name("lockinbro")
}

View File

@@ -0,0 +1,13 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.deviceactivity.monitor-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).DeviceActivityMonitorExtension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.adipu.LockInBroMobile</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ManagedSettingsUI.shield-configuration-service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShieldConfigurationExtension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.adipu.LockInBroMobile</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,88 @@
//
// ShieldConfigurationExtension.swift
// LockInBroShield
//
// Customizes the shield overlay that appears on distraction apps
// during a focus session. Shows the user's current task, step progress,
// and two action buttons: "Back to Focus" and "Allow X more min".
//
import ManagedSettings
import ManagedSettingsUI
import UIKit
class ShieldConfigurationExtension: ShieldConfigurationDataSource {
private let defaults = UserDefaults(suiteName: "group.com.adipu.LockInBroMobile")
override func configuration(shielding application: Application) -> ShieldConfiguration {
return buildShieldConfig(appName: application.localizedDisplayName)
}
override func configuration(shielding application: Application, in category: ActivityCategory) -> ShieldConfiguration {
return buildShieldConfig(appName: application.localizedDisplayName)
}
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
return buildShieldConfig(appName: webDomain.domain)
}
override func configuration(shielding webDomain: WebDomain, in category: ActivityCategory) -> ShieldConfiguration {
return buildShieldConfig(appName: webDomain.domain)
}
// MARK: - Build Shield
private func buildShieldConfig(appName: String?) -> ShieldConfiguration {
let taskTitle = defaults?.string(forKey: "currentTaskTitle") ?? "your task"
let completed = defaults?.integer(forKey: "currentStepsCompleted") ?? 0
let total = defaults?.integer(forKey: "currentStepsTotal") ?? 0
let currentStep = defaults?.string(forKey: "currentStepTitle")
let lastCompletedStep = defaults?.string(forKey: "lastCompletedStepTitle")
let threshold = defaults?.object(forKey: "distractionThresholdMinutes") as? Int ?? 2
// Build subtitle with task context
var subtitle: String
if total > 0 {
var secondLine = ""
if let last = lastCompletedStep, let next = currentStep {
secondLine = "You've just finished: \(last), next up is \(next)"
} else if let next = currentStep {
secondLine = "Next up is \(next)"
} else if let last = lastCompletedStep {
secondLine = "You've just finished: \(last)"
}
if secondLine.isEmpty {
subtitle = "You're working on \"\(taskTitle)\"\(completed)/\(total) steps done."
} else {
subtitle = "You're working on \"\(taskTitle)\"\(completed)/\(total) steps done.\n\(secondLine)"
}
} else {
subtitle = "You're supposed to be working on \"\(taskTitle)\"."
}
return ShieldConfiguration(
backgroundBlurStyle: .systemThickMaterial,
backgroundColor: UIColor.black.withAlphaComponent(0.85),
icon: UIImage(systemName: "brain.head.profile"),
title: ShieldConfiguration.Label(
text: "Time to lock back in!",
color: .white
),
subtitle: ShieldConfiguration.Label(
text: subtitle,
color: UIColor.white.withAlphaComponent(0.8)
),
primaryButtonLabel: ShieldConfiguration.Label(
text: "Back to Focus",
color: .white
),
primaryButtonBackgroundColor: UIColor.systemBlue,
secondaryButtonLabel: ShieldConfiguration.Label(
text: "\(threshold) more min",
color: UIColor.systemBlue
)
)
}
}

View File

@@ -0,0 +1,13 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ManagedSettings.shield-action-service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShieldActionExtension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.adipu.LockInBroMobile</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,114 @@
//
// ShieldActionExtension.swift
// LockInBroShieldAction
//
// Handles shield button taps:
// Primary "Back to Focus" closes the distraction app
// Secondary "X more min" dismisses the shield (user can keep using the app;
// the shield will reappear on the next monitoring interval reset)
//
import ManagedSettings
import DeviceActivity
import FamilyControls
import Foundation
class ShieldActionExtension: ShieldActionDelegate {
private let store = ManagedSettingsStore(named: .lockinbro)
override func handle(
action: ShieldAction,
for application: ApplicationToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
switch action {
case .primaryButtonPressed:
// "Back to Focus" close the distraction app
completionHandler(.close)
case .secondaryButtonPressed:
store.shield.applications = nil
store.shield.applicationCategories = nil
grantOneMoreMinute()
completionHandler(.none)
default:
completionHandler(.close)
}
}
override func handle(
action: ShieldAction,
for webDomain: WebDomainToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
switch action {
case .primaryButtonPressed:
completionHandler(.close)
case .secondaryButtonPressed:
store.shield.applications = nil
store.shield.applicationCategories = nil
grantOneMoreMinute()
completionHandler(.none)
default:
completionHandler(.close)
}
}
override func handle(
action: ShieldAction,
for category: ActivityCategoryToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
switch action {
case .primaryButtonPressed:
completionHandler(.close)
case .secondaryButtonPressed:
store.shield.applications = nil
store.shield.applicationCategories = nil
grantOneMoreMinute()
completionHandler(.none)
default:
completionHandler(.close)
}
}
private func grantOneMoreMinute() {
let defaults = UserDefaults(suiteName: "group.com.adipu.LockInBroMobile")
guard let data = defaults?.data(forKey: "screenTimeSelection"),
let selection = try? JSONDecoder().decode(FamilyActivitySelection.self, from: data) else {
return
}
let center = DeviceActivityCenter()
let now = Date()
var startComp = Calendar.current.dateComponents([.hour, .minute], from: now)
if startComp.hour == 23 && startComp.minute == 59 {
startComp.hour = 0
startComp.minute = 0
}
let schedule = DeviceActivitySchedule(
intervalStart: startComp,
intervalEnd: DateComponents(hour: 23, minute: 59),
repeats: false
)
var events: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:]
let threshold = DateComponents(minute: 1)
for token in selection.applicationTokens {
let eventName = DeviceActivityEvent.Name("dist_ext_\(token.hashValue)")
events[eventName] = DeviceActivityEvent(applications: [token], threshold: threshold)
}
for token in selection.categoryTokens {
let eventName = DeviceActivityEvent.Name("dist_cat_ext_\(token.hashValue)")
events[eventName] = DeviceActivityEvent(categories: [token], threshold: threshold)
}
let activityName = DeviceActivityName("lockinbro_extension_1m")
try? center.startMonitoring(activityName, during: schedule, events: events)
}
}
extension ManagedSettingsStore.Name {
static let lockinbro = ManagedSettingsStore.Name("lockinbro")
}

View File

@@ -0,0 +1,18 @@
//
// AppIntent.swift
// LockInBroWidget
//
// Created by Aditya Pulipaka on 3/28/26.
//
import WidgetKit
import AppIntents
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Configuration" }
static var description: IntentDescription { "This is an example widget." }
// An example configurable parameter.
@Parameter(title: "Favorite Emoji", default: "😃")
var favoriteEmoji: String
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,15 @@
<?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>NSSupportsLiveActivities</key>
<true/>
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,88 @@
//
// LockInBroWidget.swift
// LockInBroWidget
//
// Created by Aditya Pulipaka on 3/28/26.
//
import WidgetKit
import SwiftUI
struct Provider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
}
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
SimpleEntry(date: Date(), configuration: configuration)
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
var entries: [SimpleEntry] = []
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, configuration: configuration)
entries.append(entry)
}
return Timeline(entries: entries, policy: .atEnd)
}
// func relevances() async -> WidgetRelevances<ConfigurationAppIntent> {
// // Generate a list containing the contexts this widget is relevant in.
// }
}
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationAppIntent
}
struct LockInBroWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack {
Text("Time:")
Text(entry.date, style: .time)
Text("Favorite Emoji:")
Text(entry.configuration.favoriteEmoji)
}
}
}
struct LockInBroWidget: Widget {
let kind: String = "LockInBroWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
LockInBroWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
}
}
extension ConfigurationAppIntent {
fileprivate static var smiley: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "😀"
return intent
}
fileprivate static var starEyes: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "🤩"
return intent
}
}
#Preview(as: .systemSmall) {
LockInBroWidget()
} timeline: {
SimpleEntry(date: .now, configuration: .smiley)
SimpleEntry(date: .now, configuration: .starEyes)
}

View File

@@ -0,0 +1,18 @@
//
// LockInBroWidgetBundle.swift
// LockInBroWidget
//
// Created by Aditya Pulipaka on 3/28/26.
//
import WidgetKit
import SwiftUI
@main
struct LockInBroWidgetBundle: WidgetBundle {
var body: some Widget {
LockInBroWidget()
LockInBroWidgetControl()
LockInBroWidgetLiveActivity()
}
}

View File

@@ -0,0 +1,77 @@
//
// LockInBroWidgetControl.swift
// LockInBroWidget
//
// Created by Aditya Pulipaka on 3/28/26.
//
import AppIntents
import SwiftUI
import WidgetKit
struct LockInBroWidgetControl: ControlWidget {
static let kind: String = "com.adipu.LockInBroMobile.LockInBroWidget"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value.isRunning,
action: StartTimerIntent(value.name)
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension LockInBroWidgetControl {
struct Value {
var isRunning: Bool
var name: String
}
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
LockInBroWidgetControl.Value(isRunning: false, name: configuration.timerName)
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running
return LockInBroWidgetControl.Value(isRunning: isRunning, name: configuration.timerName)
}
}
}
struct TimerConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Timer Name Configuration"
@Parameter(title: "Timer Name", default: "Timer")
var timerName: String
}
struct StartTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Start a timer"
@Parameter(title: "Timer Name")
var name: String
@Parameter(title: "Timer is running")
var value: Bool
init() {}
init(_ name: String) {
self.name = name
}
func perform() async throws -> some IntentResult {
// Start the timer
return .result()
}
}

View File

@@ -0,0 +1,120 @@
//
// LockInBroWidgetLiveActivity.swift
// LockInBroWidget
//
import ActivityKit
import WidgetKit
import SwiftUI
struct LockInBroWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: FocusSessionAttributes.self) { context in
// Lock screen/banner UI
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "clock.fill")
.foregroundStyle(.blue)
Text("Focus Mode")
.font(.headline)
.fontWeight(.bold)
Spacer()
Text(Date(timeIntervalSince1970: TimeInterval(context.state.startedAt)), style: .timer)
.font(.title3.monospacedDigit())
.foregroundStyle(.blue)
.multilineTextAlignment(.trailing)
}
Text(context.state.taskTitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
if context.state.stepsTotal > 0 {
HStack(spacing: 6) {
ProgressView(value: Double(context.state.stepsCompleted), total: Double(context.state.stepsTotal))
.tint(.blue)
Text("\(context.state.stepsCompleted)/\(context.state.stepsTotal)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
if let step = context.state.currentStepTitle {
Text("Now: \(step)")
.font(.caption)
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
}
}
}
.padding()
.activityBackgroundTint(Color.black.opacity(0.8))
.activitySystemActionForegroundColor(Color.white)
} dynamicIsland: { context in
DynamicIsland {
// Expanded UI
DynamicIslandExpandedRegion(.leading) {
Label("Focus", systemImage: "clock.fill")
.font(.caption)
.foregroundStyle(.blue)
}
DynamicIslandExpandedRegion(.trailing) {
Text(Date(timeIntervalSince1970: TimeInterval(context.state.startedAt)), style: .timer)
.font(.caption.monospacedDigit())
.foregroundStyle(.blue)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.taskTitle)
.font(.subheadline)
.lineLimit(1)
}
DynamicIslandExpandedRegion(.bottom) {
if context.state.stepsTotal > 0 {
Text("\(context.state.stepsCompleted)/\(context.state.stepsTotal) steps — \(context.state.currentStepTitle ?? "Stay locked in!")")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
} else {
Text("Stay locked in!")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
} compactLeading: {
Image(systemName: "clock.fill")
.foregroundStyle(.blue)
} compactTrailing: {
Text(Date(timeIntervalSince1970: TimeInterval(context.state.startedAt)), style: .timer)
.font(.caption2.monospacedDigit())
.frame(maxWidth: 40)
} minimal: {
Image(systemName: "clock.fill")
.foregroundStyle(.blue)
}
.widgetURL(URL(string: "lockinbro://resume-session"))
.keylineTint(Color.blue)
}
}
}
extension FocusSessionAttributes {
fileprivate static var preview: FocusSessionAttributes {
FocusSessionAttributes(sessionType: "Focus")
}
}
extension FocusSessionAttributes.ContentState {
fileprivate static var dummy: FocusSessionAttributes.ContentState {
FocusSessionAttributes.ContentState(
taskTitle: "Finish Physics Assignment",
startedAt: Int(Date().addingTimeInterval(-120).timeIntervalSince1970),
stepsCompleted: 2,
stepsTotal: 5,
currentStepTitle: "Solve problem set 3"
)
}
}
#Preview("Notification", as: .content, using: FocusSessionAttributes.preview) {
LockInBroWidgetLiveActivity()
} contentStates: {
FocusSessionAttributes.ContentState.dummy
}

View File

@@ -0,0 +1,61 @@
#!/bin/bash
# download_whisper_model.sh
# Xcode pre-build script: downloads distil-whisper CoreML model if not present.
# Only runs once — subsequent builds skip it if the weight files already exist.
set -euo pipefail
MODEL_NAME="distil-whisper_distil-large-v3_594MB"
MODEL_DIR="${SRCROOT}/LockInBroMobile/${MODEL_NAME}"
HF_BASE="https://huggingface.co/argmaxinc/whisperkit-coreml/resolve/main/${MODEL_NAME}"
# Xcode checks outputPaths before running this script, but double-check here too.
if [ -f "${MODEL_DIR}/AudioEncoder.mlmodelc/weights/weight.bin" ] && \
[ -f "${MODEL_DIR}/TextDecoder.mlmodelc/weights/weight.bin" ]; then
echo "Whisper model already present — skipping download."
exit 0
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Downloading distil-whisper model (~600 MB, one-time)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
FILES=(
"config.json"
"generation_config.json"
"AudioEncoder.mlmodelc/metadata.json"
"AudioEncoder.mlmodelc/model.mil"
"AudioEncoder.mlmodelc/coremldata.bin"
"AudioEncoder.mlmodelc/analytics/coremldata.bin"
"AudioEncoder.mlmodelc/weights/weight.bin"
"MelSpectrogram.mlmodelc/metadata.json"
"MelSpectrogram.mlmodelc/model.mil"
"MelSpectrogram.mlmodelc/coremldata.bin"
"MelSpectrogram.mlmodelc/analytics/coremldata.bin"
"MelSpectrogram.mlmodelc/weights/weight.bin"
"TextDecoder.mlmodelc/metadata.json"
"TextDecoder.mlmodelc/model.mil"
"TextDecoder.mlmodelc/coremldata.bin"
"TextDecoder.mlmodelc/analytics/coremldata.bin"
"TextDecoder.mlmodelc/weights/weight.bin"
)
TOTAL=${#FILES[@]}
INDEX=0
for file in "${FILES[@]}"; do
INDEX=$((INDEX + 1))
dest="${MODEL_DIR}/${file}"
mkdir -p "$(dirname "$dest")"
if [ ! -f "$dest" ]; then
echo "[${INDEX}/${TOTAL}] Downloading ${file}..."
curl -L --retry 3 --retry-delay 2 --progress-bar \
-o "$dest" "${HF_BASE}/${file}"
else
echo "[${INDEX}/${TOTAL}] Already exists: ${file}"
fi
done
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Model download complete. Build continuing..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"