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