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:
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal 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
8
.gitignore
vendored
Normal 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/
|
||||
1311
LockInBroMobile.xcodeproj/project.pbxproj
Normal file
1311
LockInBroMobile.xcodeproj/project.pbxproj
Normal file
File diff suppressed because it is too large
Load Diff
7
LockInBroMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
LockInBroMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
BIN
LockInBroMobile/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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 |
6
LockInBroMobile/Assets.xcassets/Contents.json
Normal file
6
LockInBroMobile/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
26
LockInBroMobile/ContentView.swift
Normal file
26
LockInBroMobile/ContentView.swift
Normal 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())
|
||||
}
|
||||
14
LockInBroMobile/Info.plist
Normal file
14
LockInBroMobile/Info.plist
Normal 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>
|
||||
18
LockInBroMobile/LockInBroMobile.entitlements
Normal file
18
LockInBroMobile/LockInBroMobile.entitlements
Normal 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>
|
||||
137
LockInBroMobile/LockInBroMobileApp.swift
Normal file
137
LockInBroMobile/LockInBroMobileApp.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
38
LockInBroMobile/Models/FocusSessionAttributes.swift
Normal file
38
LockInBroMobile/Models/FocusSessionAttributes.swift
Normal 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
|
||||
}
|
||||
}
|
||||
375
LockInBroMobile/Models/Models.swift
Normal file
375
LockInBroMobile/Models/Models.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
375
LockInBroMobile/Services/APIClient.swift
Normal file
375
LockInBroMobile/Services/APIClient.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
137
LockInBroMobile/Services/ActivityManager.swift
Normal file
137
LockInBroMobile/Services/ActivityManager.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
131
LockInBroMobile/Services/AppState.swift
Normal file
131
LockInBroMobile/Services/AppState.swift
Normal 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 } }
|
||||
}
|
||||
82
LockInBroMobile/Services/KeychainService.swift
Normal file
82
LockInBroMobile/Services/KeychainService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
187
LockInBroMobile/Services/NotificationService.swift
Normal file
187
LockInBroMobile/Services/NotificationService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
134
LockInBroMobile/Services/ScreenTimeManager.swift
Normal file
134
LockInBroMobile/Services/ScreenTimeManager.swift
Normal 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")
|
||||
}
|
||||
106
LockInBroMobile/Services/SharedDefaults.swift
Normal file
106
LockInBroMobile/Services/SharedDefaults.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
194
LockInBroMobile/Services/SpeechService.swift
Normal file
194
LockInBroMobile/Services/SpeechService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
174
LockInBroMobile/Views/AuthView.swift
Normal file
174
LockInBroMobile/Views/AuthView.swift
Normal 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())
|
||||
}
|
||||
685
LockInBroMobile/Views/BrainDumpView.swift
Normal file
685
LockInBroMobile/Views/BrainDumpView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
105
LockInBroMobile/Views/CreateTaskView.swift
Normal file
105
LockInBroMobile/Views/CreateTaskView.swift
Normal 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())
|
||||
}
|
||||
259
LockInBroMobile/Views/DashboardView.swift
Normal file
259
LockInBroMobile/Views/DashboardView.swift
Normal 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())
|
||||
}
|
||||
32
LockInBroMobile/Views/MainTabView.swift
Normal file
32
LockInBroMobile/Views/MainTabView.swift
Normal 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())
|
||||
}
|
||||
208
LockInBroMobile/Views/SettingsView.swift
Normal file
208
LockInBroMobile/Views/SettingsView.swift
Normal 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())
|
||||
}
|
||||
172
LockInBroMobile/Views/SharedComponents.swift
Normal file
172
LockInBroMobile/Views/SharedComponents.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
209
LockInBroMobile/Views/TaskBoardView.swift
Normal file
209
LockInBroMobile/Views/TaskBoardView.swift
Normal 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())
|
||||
}
|
||||
510
LockInBroMobile/Views/TaskDetailView.swift
Normal file
510
LockInBroMobile/Views/TaskDetailView.swift
Normal 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 5–15 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
LockInBroMobileTests/LockInBroMobileTests.swift
Normal file
19
LockInBroMobileTests/LockInBroMobileTests.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
43
LockInBroMobileUITests/LockInBroMobileUITests.swift
Normal file
43
LockInBroMobileUITests/LockInBroMobileUITests.swift
Normal 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 it’s 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
61
LockInBroMonitor/DeviceActivityMonitorExtension.swift
Normal file
61
LockInBroMonitor/DeviceActivityMonitorExtension.swift
Normal 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")
|
||||
}
|
||||
13
LockInBroMonitor/Info.plist
Normal file
13
LockInBroMonitor/Info.plist
Normal 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>
|
||||
12
LockInBroMonitor/LockInBroMonitor.entitlements
Normal file
12
LockInBroMonitor/LockInBroMonitor.entitlements
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.family-controls</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.adipu.LockInBroMobile</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
13
LockInBroShield/Info.plist
Normal file
13
LockInBroShield/Info.plist
Normal 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>
|
||||
12
LockInBroShield/LockInBroShield.entitlements
Normal file
12
LockInBroShield/LockInBroShield.entitlements
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.family-controls</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.adipu.LockInBroMobile</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
88
LockInBroShield/ShieldConfigurationExtension.swift
Normal file
88
LockInBroShield/ShieldConfigurationExtension.swift
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
13
LockInBroShieldAction/Info.plist
Normal file
13
LockInBroShieldAction/Info.plist
Normal 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>
|
||||
12
LockInBroShieldAction/LockInBroShieldAction.entitlements
Normal file
12
LockInBroShieldAction/LockInBroShieldAction.entitlements
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.family-controls</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.adipu.LockInBroMobile</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
114
LockInBroShieldAction/ShieldActionExtension.swift
Normal file
114
LockInBroShieldAction/ShieldActionExtension.swift
Normal 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")
|
||||
}
|
||||
18
LockInBroWidget/AppIntent.swift
Normal file
18
LockInBroWidget/AppIntent.swift
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
6
LockInBroWidget/Assets.xcassets/Contents.json
Normal file
6
LockInBroWidget/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
15
LockInBroWidget/Info.plist
Normal file
15
LockInBroWidget/Info.plist
Normal 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>
|
||||
88
LockInBroWidget/LockInBroWidget.swift
Normal file
88
LockInBroWidget/LockInBroWidget.swift
Normal 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)
|
||||
}
|
||||
18
LockInBroWidget/LockInBroWidgetBundle.swift
Normal file
18
LockInBroWidget/LockInBroWidgetBundle.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
77
LockInBroWidget/LockInBroWidgetControl.swift
Normal file
77
LockInBroWidget/LockInBroWidgetControl.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
120
LockInBroWidget/LockInBroWidgetLiveActivity.swift
Normal file
120
LockInBroWidget/LockInBroWidgetLiveActivity.swift
Normal 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
|
||||
}
|
||||
61
Scripts/download_whisper_model.sh
Executable file
61
Scripts/download_whisper_model.sh
Executable 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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Reference in New Issue
Block a user