commit cc353e37ae021b9b33383ecf45d1819043a1328d Author: pulipakaa24 Date: Wed Apr 1 15:52:27 2026 -0500 Initial commit Whisper model weights excluded from git — auto-downloaded at first Xcode build via Scripts/download_whisper_model.sh (~600 MB, one-time). diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..8f5319e Binary files /dev/null and b/.DS_Store differ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f0980df --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6717e6 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LockInBroMobile.xcodeproj/project.pbxproj b/LockInBroMobile.xcodeproj/project.pbxproj new file mode 100644 index 0000000..34dd36b --- /dev/null +++ b/LockInBroMobile.xcodeproj/project.pbxproj @@ -0,0 +1,1311 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 86671E252F78D22800AECA00 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 86671E242F78D22800AECA00 /* WidgetKit.framework */; }; + 86671E272F78D22800AECA00 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 86671E262F78D22800AECA00 /* SwiftUI.framework */; }; + 86671E382F78D22A00AECA00 /* LockInBroWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 86671E222F78D22700AECA00 /* LockInBroWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 867837FD2F790A6D009A3DB6 /* DeviceActivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 867837FC2F790A6C009A3DB6 /* DeviceActivity.framework */; }; + 867838052F790A6D009A3DB6 /* LockInBroMonitor.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 867837FB2F790A6C009A3DB6 /* LockInBroMonitor.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 867838102F790B7A009A3DB6 /* ManagedSettings.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8678380F2F790B7A009A3DB6 /* ManagedSettings.framework */; }; + 867838122F790B7A009A3DB6 /* ManagedSettingsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 867838112F790B7A009A3DB6 /* ManagedSettingsUI.framework */; }; + 8678381A2F790B7A009A3DB6 /* LockInBroShield.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8678380E2F790B7A009A3DB6 /* LockInBroShield.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 867838242F790BA3009A3DB6 /* ManagedSettings.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8678380F2F790B7A009A3DB6 /* ManagedSettings.framework */; }; + 8678382C2F790BA3009A3DB6 /* LockInBroShieldAction.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 867838232F790BA3009A3DB6 /* LockInBroShieldAction.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 86A1B0012F78C00000000001 /* distil-whisper_distil-large-v3_594MB in Resources */ = {isa = PBXBuildFile; fileRef = 86A1B0002F78C00000000001 /* distil-whisper_distil-large-v3_594MB */; }; + 86DB67782F78ACC4006F5BB4 /* WhisperKit in Frameworks */ = {isa = PBXBuildFile; productRef = 86DB67772F78ACC4006F5BB4 /* WhisperKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 86671E362F78D22A00AECA00 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 86C526A82F78617A003020AD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 86671E212F78D22700AECA00; + remoteInfo = LockInBroWidgetExtension; + }; + 867838032F790A6D009A3DB6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 86C526A82F78617A003020AD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 867837FA2F790A6C009A3DB6; + remoteInfo = LockInBroMonitor; + }; + 867838182F790B7A009A3DB6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 86C526A82F78617A003020AD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8678380D2F790B7A009A3DB6; + remoteInfo = LockInBroShield; + }; + 8678382A2F790BA3009A3DB6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 86C526A82F78617A003020AD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 867838222F790BA3009A3DB6; + remoteInfo = LockInBroShieldAction; + }; + 86C526BE2F78617D003020AD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 86C526A82F78617A003020AD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 86C526AF2F78617A003020AD; + remoteInfo = LockInBroMobile; + }; + 86C526C82F78617D003020AD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 86C526A82F78617A003020AD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 86C526AF2F78617A003020AD; + remoteInfo = LockInBroMobile; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 86671E392F78D22A00AECA00 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 8678381A2F790B7A009A3DB6 /* LockInBroShield.appex in Embed Foundation Extensions */, + 8678382C2F790BA3009A3DB6 /* LockInBroShieldAction.appex in Embed Foundation Extensions */, + 86671E382F78D22A00AECA00 /* LockInBroWidgetExtension.appex in Embed Foundation Extensions */, + 867838052F790A6D009A3DB6 /* LockInBroMonitor.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 86671E222F78D22700AECA00 /* LockInBroWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LockInBroWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 86671E242F78D22800AECA00 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 86671E262F78D22800AECA00 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 867837FB2F790A6C009A3DB6 /* LockInBroMonitor.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LockInBroMonitor.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 867837FC2F790A6C009A3DB6 /* DeviceActivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DeviceActivity.framework; path = System/Library/Frameworks/DeviceActivity.framework; sourceTree = SDKROOT; }; + 8678380E2F790B7A009A3DB6 /* LockInBroShield.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LockInBroShield.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 8678380F2F790B7A009A3DB6 /* ManagedSettings.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ManagedSettings.framework; path = System/Library/Frameworks/ManagedSettings.framework; sourceTree = SDKROOT; }; + 867838112F790B7A009A3DB6 /* ManagedSettingsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ManagedSettingsUI.framework; path = System/Library/Frameworks/ManagedSettingsUI.framework; sourceTree = SDKROOT; }; + 867838232F790BA3009A3DB6 /* LockInBroShieldAction.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LockInBroShieldAction.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 86A1B0002F78C00000000001 /* distil-whisper_distil-large-v3_594MB */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "distil-whisper_distil-large-v3_594MB"; path = "LockInBroMobile/distil-whisper_distil-large-v3_594MB"; sourceTree = ""; }; + 86C526B02F78617A003020AD /* LockInBroMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LockInBroMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 86C526BD2F78617D003020AD /* LockInBroMobileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LockInBroMobileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 86C526C72F78617D003020AD /* LockInBroMobileUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LockInBroMobileUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 86671E3D2F78D22A00AECA00 /* Exceptions for "LockInBroWidget" folder in "LockInBroWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 86671E212F78D22700AECA00 /* LockInBroWidgetExtension */; + }; + 86671E452F78D63000AECA00 /* Exceptions for "LockInBroMobile" folder in "LockInBroWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Models/FocusSessionAttributes.swift, + ); + target = 86671E212F78D22700AECA00 /* LockInBroWidgetExtension */; + }; + 8668468D2F78B58300E1A5DF /* Exceptions for "LockInBroMobile" folder in "LockInBroMobile" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 86C526AF2F78617A003020AD /* LockInBroMobile */; + }; + 867838092F790A6D009A3DB6 /* Exceptions for "LockInBroMonitor" folder in "LockInBroMonitor" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 867837FA2F790A6C009A3DB6 /* LockInBroMonitor */; + }; + 8678381E2F790B7A009A3DB6 /* Exceptions for "LockInBroShield" folder in "LockInBroShield" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 8678380D2F790B7A009A3DB6 /* LockInBroShield */; + }; + 867838302F790BA3009A3DB6 /* Exceptions for "LockInBroShieldAction" folder in "LockInBroShieldAction" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 867838222F790BA3009A3DB6 /* LockInBroShieldAction */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 86671E282F78D22800AECA00 /* LockInBroWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 86671E3D2F78D22A00AECA00 /* Exceptions for "LockInBroWidget" folder in "LockInBroWidgetExtension" target */, + ); + path = LockInBroWidget; + sourceTree = ""; + }; + 867837FE2F790A6D009A3DB6 /* LockInBroMonitor */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 867838092F790A6D009A3DB6 /* Exceptions for "LockInBroMonitor" folder in "LockInBroMonitor" target */, + ); + path = LockInBroMonitor; + sourceTree = ""; + }; + 867838132F790B7A009A3DB6 /* LockInBroShield */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 8678381E2F790B7A009A3DB6 /* Exceptions for "LockInBroShield" folder in "LockInBroShield" target */, + ); + path = LockInBroShield; + sourceTree = ""; + }; + 867838252F790BA3009A3DB6 /* LockInBroShieldAction */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 867838302F790BA3009A3DB6 /* Exceptions for "LockInBroShieldAction" folder in "LockInBroShieldAction" target */, + ); + path = LockInBroShieldAction; + sourceTree = ""; + }; + 86C526B22F78617A003020AD /* LockInBroMobile */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 8668468D2F78B58300E1A5DF /* Exceptions for "LockInBroMobile" folder in "LockInBroMobile" target */, + 86671E452F78D63000AECA00 /* Exceptions for "LockInBroMobile" folder in "LockInBroWidgetExtension" target */, + ); + path = LockInBroMobile; + sourceTree = ""; + }; + 86C526C02F78617D003020AD /* LockInBroMobileTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = LockInBroMobileTests; + sourceTree = ""; + }; + 86C526CA2F78617D003020AD /* LockInBroMobileUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = LockInBroMobileUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 86671E1F2F78D22700AECA00 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 86671E272F78D22800AECA00 /* SwiftUI.framework in Frameworks */, + 86671E252F78D22800AECA00 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 867837F82F790A6C009A3DB6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 867837FD2F790A6D009A3DB6 /* DeviceActivity.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8678380B2F790B7A009A3DB6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 867838102F790B7A009A3DB6 /* ManagedSettings.framework in Frameworks */, + 867838122F790B7A009A3DB6 /* ManagedSettingsUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 867838202F790BA3009A3DB6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 867838242F790BA3009A3DB6 /* ManagedSettings.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 86C526AD2F78617A003020AD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 86DB67782F78ACC4006F5BB4 /* WhisperKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 86C526BA2F78617D003020AD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 86C526C42F78617D003020AD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 86671E232F78D22700AECA00 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 86671E242F78D22800AECA00 /* WidgetKit.framework */, + 86671E262F78D22800AECA00 /* SwiftUI.framework */, + 867837FC2F790A6C009A3DB6 /* DeviceActivity.framework */, + 8678380F2F790B7A009A3DB6 /* ManagedSettings.framework */, + 867838112F790B7A009A3DB6 /* ManagedSettingsUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 86C526A72F78617A003020AD = { + isa = PBXGroup; + children = ( + 86A1B0002F78C00000000001 /* distil-whisper_distil-large-v3_594MB */, + 86C526B22F78617A003020AD /* LockInBroMobile */, + 86C526C02F78617D003020AD /* LockInBroMobileTests */, + 86C526CA2F78617D003020AD /* LockInBroMobileUITests */, + 86671E282F78D22800AECA00 /* LockInBroWidget */, + 867837FE2F790A6D009A3DB6 /* LockInBroMonitor */, + 867838132F790B7A009A3DB6 /* LockInBroShield */, + 867838252F790BA3009A3DB6 /* LockInBroShieldAction */, + 86671E232F78D22700AECA00 /* Frameworks */, + 86C526B12F78617A003020AD /* Products */, + ); + sourceTree = ""; + }; + 86C526B12F78617A003020AD /* Products */ = { + isa = PBXGroup; + children = ( + 86C526B02F78617A003020AD /* LockInBroMobile.app */, + 86C526BD2F78617D003020AD /* LockInBroMobileTests.xctest */, + 86C526C72F78617D003020AD /* LockInBroMobileUITests.xctest */, + 86671E222F78D22700AECA00 /* LockInBroWidgetExtension.appex */, + 867837FB2F790A6C009A3DB6 /* LockInBroMonitor.appex */, + 8678380E2F790B7A009A3DB6 /* LockInBroShield.appex */, + 867838232F790BA3009A3DB6 /* LockInBroShieldAction.appex */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 86671E212F78D22700AECA00 /* LockInBroWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 86671E3C2F78D22A00AECA00 /* Build configuration list for PBXNativeTarget "LockInBroWidgetExtension" */; + buildPhases = ( + 86671E1E2F78D22700AECA00 /* Sources */, + 86671E1F2F78D22700AECA00 /* Frameworks */, + 86671E202F78D22700AECA00 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 86671E282F78D22800AECA00 /* LockInBroWidget */, + ); + name = LockInBroWidgetExtension; + packageProductDependencies = ( + ); + productName = LockInBroWidgetExtension; + productReference = 86671E222F78D22700AECA00 /* LockInBroWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 867837FA2F790A6C009A3DB6 /* LockInBroMonitor */ = { + isa = PBXNativeTarget; + buildConfigurationList = 867838082F790A6D009A3DB6 /* Build configuration list for PBXNativeTarget "LockInBroMonitor" */; + buildPhases = ( + 867837F72F790A6C009A3DB6 /* Sources */, + 867837F82F790A6C009A3DB6 /* Frameworks */, + 867837F92F790A6C009A3DB6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 867837FE2F790A6D009A3DB6 /* LockInBroMonitor */, + ); + name = LockInBroMonitor; + packageProductDependencies = ( + ); + productName = LockInBroMonitor; + productReference = 867837FB2F790A6C009A3DB6 /* LockInBroMonitor.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 8678380D2F790B7A009A3DB6 /* LockInBroShield */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8678381B2F790B7A009A3DB6 /* Build configuration list for PBXNativeTarget "LockInBroShield" */; + buildPhases = ( + 8678380A2F790B7A009A3DB6 /* Sources */, + 8678380B2F790B7A009A3DB6 /* Frameworks */, + 8678380C2F790B7A009A3DB6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 867838132F790B7A009A3DB6 /* LockInBroShield */, + ); + name = LockInBroShield; + packageProductDependencies = ( + ); + productName = LockInBroShield; + productReference = 8678380E2F790B7A009A3DB6 /* LockInBroShield.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 867838222F790BA3009A3DB6 /* LockInBroShieldAction */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8678382D2F790BA3009A3DB6 /* Build configuration list for PBXNativeTarget "LockInBroShieldAction" */; + buildPhases = ( + 8678381F2F790BA3009A3DB6 /* Sources */, + 867838202F790BA3009A3DB6 /* Frameworks */, + 867838212F790BA3009A3DB6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 867838252F790BA3009A3DB6 /* LockInBroShieldAction */, + ); + name = LockInBroShieldAction; + packageProductDependencies = ( + ); + productName = LockInBroShieldAction; + productReference = 867838232F790BA3009A3DB6 /* LockInBroShieldAction.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 86C526AF2F78617A003020AD /* LockInBroMobile */ = { + isa = PBXNativeTarget; + buildConfigurationList = 86C526D12F78617D003020AD /* Build configuration list for PBXNativeTarget "LockInBroMobile" */; + buildPhases = ( + 86DD0A010F78617A003020AD /* Download Whisper Model */, + 86C526AC2F78617A003020AD /* Sources */, + 86C526AD2F78617A003020AD /* Frameworks */, + 86C526AE2F78617A003020AD /* Resources */, + 86671E392F78D22A00AECA00 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 86671E372F78D22A00AECA00 /* PBXTargetDependency */, + 867838042F790A6D009A3DB6 /* PBXTargetDependency */, + 867838192F790B7A009A3DB6 /* PBXTargetDependency */, + 8678382B2F790BA3009A3DB6 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 86C526B22F78617A003020AD /* LockInBroMobile */, + ); + name = LockInBroMobile; + packageProductDependencies = ( + 86DB67772F78ACC4006F5BB4 /* WhisperKit */, + ); + productName = LockInBroMobile; + productReference = 86C526B02F78617A003020AD /* LockInBroMobile.app */; + productType = "com.apple.product-type.application"; + }; + 86C526BC2F78617D003020AD /* LockInBroMobileTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 86C526D42F78617D003020AD /* Build configuration list for PBXNativeTarget "LockInBroMobileTests" */; + buildPhases = ( + 86C526B92F78617D003020AD /* Sources */, + 86C526BA2F78617D003020AD /* Frameworks */, + 86C526BB2F78617D003020AD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 86C526BF2F78617D003020AD /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 86C526C02F78617D003020AD /* LockInBroMobileTests */, + ); + name = LockInBroMobileTests; + packageProductDependencies = ( + ); + productName = LockInBroMobileTests; + productReference = 86C526BD2F78617D003020AD /* LockInBroMobileTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 86C526C62F78617D003020AD /* LockInBroMobileUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 86C526D72F78617D003020AD /* Build configuration list for PBXNativeTarget "LockInBroMobileUITests" */; + buildPhases = ( + 86C526C32F78617D003020AD /* Sources */, + 86C526C42F78617D003020AD /* Frameworks */, + 86C526C52F78617D003020AD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 86C526C92F78617D003020AD /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 86C526CA2F78617D003020AD /* LockInBroMobileUITests */, + ); + name = LockInBroMobileUITests; + packageProductDependencies = ( + ); + productName = LockInBroMobileUITests; + productReference = 86C526C72F78617D003020AD /* LockInBroMobileUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 86C526A82F78617A003020AD /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + 86671E212F78D22700AECA00 = { + CreatedOnToolsVersion = 26.4; + }; + 867837FA2F790A6C009A3DB6 = { + CreatedOnToolsVersion = 26.4; + }; + 8678380D2F790B7A009A3DB6 = { + CreatedOnToolsVersion = 26.4; + }; + 867838222F790BA3009A3DB6 = { + CreatedOnToolsVersion = 26.4; + }; + 86C526AF2F78617A003020AD = { + CreatedOnToolsVersion = 26.4; + }; + 86C526BC2F78617D003020AD = { + CreatedOnToolsVersion = 26.4; + TestTargetID = 86C526AF2F78617A003020AD; + }; + 86C526C62F78617D003020AD = { + CreatedOnToolsVersion = 26.4; + TestTargetID = 86C526AF2F78617A003020AD; + }; + }; + }; + buildConfigurationList = 86C526AB2F78617A003020AD /* Build configuration list for PBXProject "LockInBroMobile" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 86C526A72F78617A003020AD; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 86DB67762F78ACC4006F5BB4 /* XCRemoteSwiftPackageReference "WhisperKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 86C526B12F78617A003020AD /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 86C526AF2F78617A003020AD /* LockInBroMobile */, + 86C526BC2F78617D003020AD /* LockInBroMobileTests */, + 86C526C62F78617D003020AD /* LockInBroMobileUITests */, + 86671E212F78D22700AECA00 /* LockInBroWidgetExtension */, + 867837FA2F790A6C009A3DB6 /* LockInBroMonitor */, + 8678380D2F790B7A009A3DB6 /* LockInBroShield */, + 867838222F790BA3009A3DB6 /* LockInBroShieldAction */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 86671E202F78D22700AECA00 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 867837F92F790A6C009A3DB6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8678380C2F790B7A009A3DB6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 867838212F790BA3009A3DB6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 86C526AE2F78617A003020AD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 86A1B0012F78C00000000001 /* distil-whisper_distil-large-v3_594MB in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 86C526BB2F78617D003020AD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 86C526C52F78617D003020AD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 86671E1E2F78D22700AECA00 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 867837F72F790A6C009A3DB6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8678380A2F790B7A009A3DB6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8678381F2F790BA3009A3DB6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 86C526AC2F78617A003020AD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 86C526B92F78617D003020AD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 86C526C32F78617D003020AD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 86DD0A010F78617A003020AD /* Download Whisper Model */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Download Whisper Model"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/LockInBroMobile/distil-whisper_distil-large-v3_594MB/AudioEncoder.mlmodelc/weights/weight.bin", + "$(SRCROOT)/LockInBroMobile/distil-whisper_distil-large-v3_594MB/TextDecoder.mlmodelc/weights/weight.bin", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/bash; + shellScript = "\"${SRCROOT}/Scripts/download_whisper_model.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 86671E372F78D22A00AECA00 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 86671E212F78D22700AECA00 /* LockInBroWidgetExtension */; + targetProxy = 86671E362F78D22A00AECA00 /* PBXContainerItemProxy */; + }; + 867838042F790A6D009A3DB6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 867837FA2F790A6C009A3DB6 /* LockInBroMonitor */; + targetProxy = 867838032F790A6D009A3DB6 /* PBXContainerItemProxy */; + }; + 867838192F790B7A009A3DB6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8678380D2F790B7A009A3DB6 /* LockInBroShield */; + targetProxy = 867838182F790B7A009A3DB6 /* PBXContainerItemProxy */; + }; + 8678382B2F790BA3009A3DB6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 867838222F790BA3009A3DB6 /* LockInBroShieldAction */; + targetProxy = 8678382A2F790BA3009A3DB6 /* PBXContainerItemProxy */; + }; + 86C526BF2F78617D003020AD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 86C526AF2F78617A003020AD /* LockInBroMobile */; + targetProxy = 86C526BE2F78617D003020AD /* PBXContainerItemProxy */; + }; + 86C526C92F78617D003020AD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 86C526AF2F78617A003020AD /* LockInBroMobile */; + targetProxy = 86C526C82F78617D003020AD /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 86671E3A2F78D22A00AECA00 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBroWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LockInBroWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobile.LockInBroWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 86671E3B2F78D22A00AECA00 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBroWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LockInBroWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobile.LockInBroWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 867838062F790A6D009A3DB6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = LockInBroMonitor/LockInBroMonitor.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBroMonitor/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LockInBroMonitor; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobile.LockInBroMonitor; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 867838072F790A6D009A3DB6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = LockInBroMonitor/LockInBroMonitor.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBroMonitor/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LockInBroMonitor; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobile.LockInBroMonitor; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 8678381C2F790B7A009A3DB6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = LockInBroShield/LockInBroShield.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBroShield/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LockInBroShield; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobile.LockInBroShield; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8678381D2F790B7A009A3DB6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = LockInBroShield/LockInBroShield.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBroShield/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LockInBroShield; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobile.LockInBroShield; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 8678382E2F790BA3009A3DB6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = LockInBroShieldAction/LockInBroShieldAction.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBroShieldAction/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LockInBroShieldAction; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobile.LockInBroShieldAction; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8678382F2F790BA3009A3DB6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = LockInBroShieldAction/LockInBroShieldAction.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBroShieldAction/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = LockInBroShieldAction; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobile.LockInBroShieldAction; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 86C526CF2F78617D003020AD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = YK2DB9NT3S; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 86C526D02F78617D003020AD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = YK2DB9NT3S; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 86C526D22F78617D003020AD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = LockInBroMobile/LockInBroMobile.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBroMobile/Info.plist; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "To power BrainDump, we need to be able to turn your voice into text. Speak naturally! We'll take care of all the rest."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "To power BrainDump, we need to be able to turn your voice into text. Speak naturally! We'll take care of all the rest."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 86C526D32F78617D003020AD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = LockInBroMobile/LockInBroMobile.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LockInBroMobile/Info.plist; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "To power BrainDump, we need to be able to turn your voice into text. Speak naturally! We'll take care of all the rest."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "To power BrainDump, we need to be able to turn your voice into text. Speak naturally! We'll take care of all the rest."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 86C526D52F78617D003020AD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobileTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LockInBroMobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/LockInBroMobile"; + }; + name = Debug; + }; + 86C526D62F78617D003020AD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobileTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LockInBroMobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/LockInBroMobile"; + }; + name = Release; + }; + 86C526D82F78617D003020AD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobileUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = LockInBroMobile; + }; + name = Debug; + }; + 86C526D92F78617D003020AD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YK2DB9NT3S; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adipu.LockInBroMobileUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = LockInBroMobile; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 86671E3C2F78D22A00AECA00 /* Build configuration list for PBXNativeTarget "LockInBroWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 86671E3A2F78D22A00AECA00 /* Debug */, + 86671E3B2F78D22A00AECA00 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 867838082F790A6D009A3DB6 /* Build configuration list for PBXNativeTarget "LockInBroMonitor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 867838062F790A6D009A3DB6 /* Debug */, + 867838072F790A6D009A3DB6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8678381B2F790B7A009A3DB6 /* Build configuration list for PBXNativeTarget "LockInBroShield" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8678381C2F790B7A009A3DB6 /* Debug */, + 8678381D2F790B7A009A3DB6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8678382D2F790BA3009A3DB6 /* Build configuration list for PBXNativeTarget "LockInBroShieldAction" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8678382E2F790BA3009A3DB6 /* Debug */, + 8678382F2F790BA3009A3DB6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 86C526AB2F78617A003020AD /* Build configuration list for PBXProject "LockInBroMobile" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 86C526CF2F78617D003020AD /* Debug */, + 86C526D02F78617D003020AD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 86C526D12F78617D003020AD /* Build configuration list for PBXNativeTarget "LockInBroMobile" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 86C526D22F78617D003020AD /* Debug */, + 86C526D32F78617D003020AD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 86C526D42F78617D003020AD /* Build configuration list for PBXNativeTarget "LockInBroMobileTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 86C526D52F78617D003020AD /* Debug */, + 86C526D62F78617D003020AD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 86C526D72F78617D003020AD /* Build configuration list for PBXNativeTarget "LockInBroMobileUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 86C526D82F78617D003020AD /* Debug */, + 86C526D92F78617D003020AD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 86DB67762F78ACC4006F5BB4 /* XCRemoteSwiftPackageReference "WhisperKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/argmaxinc/WhisperKit"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 86DB67772F78ACC4006F5BB4 /* WhisperKit */ = { + isa = XCSwiftPackageProductDependency; + package = 86DB67762F78ACC4006F5BB4 /* XCRemoteSwiftPackageReference "WhisperKit" */; + productName = WhisperKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 86C526A82F78617A003020AD /* Project object */; +} diff --git a/LockInBroMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/LockInBroMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/LockInBroMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/LockInBroMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LockInBroMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..8233b03 --- /dev/null +++ b/LockInBroMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/LockInBroMobile.xcodeproj/project.xcworkspace/xcuserdata/adipu.xcuserdatad/UserInterfaceState.xcuserstate b/LockInBroMobile.xcodeproj/project.xcworkspace/xcuserdata/adipu.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..dbf4e37 Binary files /dev/null and b/LockInBroMobile.xcodeproj/project.xcworkspace/xcuserdata/adipu.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/LockInBroMobile.xcodeproj/xcshareddata/xcschemes/LockInBroMobile.xcscheme b/LockInBroMobile.xcodeproj/xcshareddata/xcschemes/LockInBroMobile.xcscheme new file mode 100644 index 0000000..ba2447f --- /dev/null +++ b/LockInBroMobile.xcodeproj/xcshareddata/xcschemes/LockInBroMobile.xcscheme @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LockInBroMobile.xcodeproj/xcshareddata/xcschemes/LockInBroWidgetExtension.xcscheme b/LockInBroMobile.xcodeproj/xcshareddata/xcschemes/LockInBroWidgetExtension.xcscheme new file mode 100644 index 0000000..7d9da7f --- /dev/null +++ b/LockInBroMobile.xcodeproj/xcshareddata/xcschemes/LockInBroWidgetExtension.xcscheme @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LockInBroMobile.xcodeproj/xcuserdata/adipu.xcuserdatad/xcschemes/xcschememanagement.plist b/LockInBroMobile.xcodeproj/xcuserdata/adipu.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..05413a2 --- /dev/null +++ b/LockInBroMobile.xcodeproj/xcuserdata/adipu.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,47 @@ + + + + + SchemeUserState + + LockInBroMobile.xcscheme_^#shared#^_ + + orderHint + 1 + + LockInBroMonitor.xcscheme_^#shared#^_ + + orderHint + 3 + + LockInBroShield.xcscheme_^#shared#^_ + + orderHint + 4 + + LockInBroShieldAction.xcscheme_^#shared#^_ + + orderHint + 2 + + LockInBroWidgetExtension.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + 86671E212F78D22700AECA00 + + primary + + + 86C526AF2F78617A003020AD + + primary + + + + + diff --git a/LockInBroMobile/.DS_Store b/LockInBroMobile/.DS_Store new file mode 100644 index 0000000..3c20ea3 Binary files /dev/null and b/LockInBroMobile/.DS_Store differ diff --git a/LockInBroMobile/Assets.xcassets/AccentColor.colorset/Contents.json b/LockInBroMobile/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/LockInBroMobile/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LockInBroMobile/Assets.xcassets/AppIcon.appiconset/Contents.json b/LockInBroMobile/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..be2724a --- /dev/null +++ b/LockInBroMobile/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/LockInBroMobile/Assets.xcassets/AppIcon.appiconset/Gemini_Generated_Image_5244jc5244jc5244 1.png b/LockInBroMobile/Assets.xcassets/AppIcon.appiconset/Gemini_Generated_Image_5244jc5244jc5244 1.png new file mode 100644 index 0000000..fbc8888 Binary files /dev/null and b/LockInBroMobile/Assets.xcassets/AppIcon.appiconset/Gemini_Generated_Image_5244jc5244jc5244 1.png differ diff --git a/LockInBroMobile/Assets.xcassets/AppIcon.appiconset/Gemini_Generated_Image_5244jc5244jc5244.png b/LockInBroMobile/Assets.xcassets/AppIcon.appiconset/Gemini_Generated_Image_5244jc5244jc5244.png new file mode 100644 index 0000000..fbc8888 Binary files /dev/null and b/LockInBroMobile/Assets.xcassets/AppIcon.appiconset/Gemini_Generated_Image_5244jc5244jc5244.png differ diff --git a/LockInBroMobile/Assets.xcassets/Contents.json b/LockInBroMobile/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/LockInBroMobile/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LockInBroMobile/ContentView.swift b/LockInBroMobile/ContentView.swift new file mode 100644 index 0000000..334c5f0 --- /dev/null +++ b/LockInBroMobile/ContentView.swift @@ -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()) +} diff --git a/LockInBroMobile/Info.plist b/LockInBroMobile/Info.plist new file mode 100644 index 0000000..1fb384d --- /dev/null +++ b/LockInBroMobile/Info.plist @@ -0,0 +1,14 @@ + + + + + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + + UIBackgroundModes + + remote-notification + + + diff --git a/LockInBroMobile/LockInBroMobile.entitlements b/LockInBroMobile/LockInBroMobile.entitlements new file mode 100644 index 0000000..d2bdbbb --- /dev/null +++ b/LockInBroMobile/LockInBroMobile.entitlements @@ -0,0 +1,18 @@ + + + + + aps-environment + production + com.apple.developer.applesignin + + Default + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.com.adipu.LockInBroMobile + + + diff --git a/LockInBroMobile/LockInBroMobileApp.swift b/LockInBroMobile/LockInBroMobileApp.swift new file mode 100644 index 0000000..b13f0fa --- /dev/null +++ b/LockInBroMobile/LockInBroMobileApp.swift @@ -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=&open= + // 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= + // 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= + // Deadline / morning brief notification tap → navigate to task + if let taskId = components?.queryItems?.first(where: { $0.name == "id" })?.value { + appState.pendingOpenTaskId = taskId + } + + default: + break + } + } +} diff --git a/LockInBroMobile/Models/FocusSessionAttributes.swift b/LockInBroMobile/Models/FocusSessionAttributes.swift new file mode 100644 index 0000000..6d51d8c --- /dev/null +++ b/LockInBroMobile/Models/FocusSessionAttributes.swift @@ -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 + } +} diff --git a/LockInBroMobile/Models/Models.swift b/LockInBroMobile/Models/Models.swift new file mode 100644 index 0000000..91621ea --- /dev/null +++ b/LockInBroMobile/Models/Models.swift @@ -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" + } +} diff --git a/LockInBroMobile/Services/APIClient.swift b/LockInBroMobile/Services/APIClient.swift new file mode 100644 index 0000000..5c5b536 --- /dev/null +++ b/LockInBroMobile/Services/APIClient.swift @@ -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] = [] + + // 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(_ 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") + } +} diff --git a/LockInBroMobile/Services/ActivityManager.swift b/LockInBroMobile/Services/ActivityManager.swift new file mode 100644 index 0000000..286d772 --- /dev/null +++ b/LockInBroMobile/Services/ActivityManager.swift @@ -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] = [] + /// Per-activity tasks keyed by activity ID — prevents duplicate observers on re-yields. + private var activityTasks: [String: [Task]] = [:] + + private init() {} + + func endAllActivities() { + Task { + for activity in Activity.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.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.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)") + } + } + } +} diff --git a/LockInBroMobile/Services/AppState.swift b/LockInBroMobile/Services/AppState.swift new file mode 100644 index 0000000..c8fdb83 --- /dev/null +++ b/LockInBroMobile/Services/AppState.swift @@ -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 } } +} diff --git a/LockInBroMobile/Services/KeychainService.swift b/LockInBroMobile/Services/KeychainService.swift new file mode 100644 index 0000000..c3a71ca --- /dev/null +++ b/LockInBroMobile/Services/KeychainService.swift @@ -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) + } +} diff --git a/LockInBroMobile/Services/NotificationService.swift b/LockInBroMobile/Services/NotificationService.swift new file mode 100644 index 0000000..6e59b3d --- /dev/null +++ b/LockInBroMobile/Services/NotificationService.swift @@ -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 + } + } +} diff --git a/LockInBroMobile/Services/ScreenTimeManager.swift b/LockInBroMobile/Services/ScreenTimeManager.swift new file mode 100644 index 0000000..d7593b9 --- /dev/null +++ b/LockInBroMobile/Services/ScreenTimeManager.swift @@ -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") +} diff --git a/LockInBroMobile/Services/SharedDefaults.swift b/LockInBroMobile/Services/SharedDefaults.swift new file mode 100644 index 0000000..2def54d --- /dev/null +++ b/LockInBroMobile/Services/SharedDefaults.swift @@ -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) + } +} diff --git a/LockInBroMobile/Services/SpeechService.swift b/LockInBroMobile/Services/SpeechService.swift new file mode 100644 index 0000000..dd54832 --- /dev/null +++ b/LockInBroMobile/Services/SpeechService.swift @@ -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() + + 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.. 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 + } +} diff --git a/LockInBroMobile/Views/AuthView.swift b/LockInBroMobile/Views/AuthView.swift new file mode 100644 index 0000000..5dc1af0 --- /dev/null +++ b/LockInBroMobile/Views/AuthView.swift @@ -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) { + 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()) +} diff --git a/LockInBroMobile/Views/BrainDumpView.swift b/LockInBroMobile/Views/BrainDumpView.swift new file mode 100644 index 0000000..03d8b8b --- /dev/null +++ b/LockInBroMobile/Views/BrainDumpView.swift @@ -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 = [] + @State private var acceptedSuggestions: Set = [] + @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.. + 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) + } +} diff --git a/LockInBroMobile/Views/CreateTaskView.swift b/LockInBroMobile/Views/CreateTaskView.swift new file mode 100644 index 0000000..73b037d --- /dev/null +++ b/LockInBroMobile/Views/CreateTaskView.swift @@ -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()) +} diff --git a/LockInBroMobile/Views/DashboardView.swift b/LockInBroMobile/Views/DashboardView.swift new file mode 100644 index 0000000..4be4199 --- /dev/null +++ b/LockInBroMobile/Views/DashboardView.swift @@ -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()) +} diff --git a/LockInBroMobile/Views/MainTabView.swift b/LockInBroMobile/Views/MainTabView.swift new file mode 100644 index 0000000..d9196e8 --- /dev/null +++ b/LockInBroMobile/Views/MainTabView.swift @@ -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()) +} diff --git a/LockInBroMobile/Views/SettingsView.swift b/LockInBroMobile/Views/SettingsView.swift new file mode 100644 index 0000000..748b475 --- /dev/null +++ b/LockInBroMobile/Views/SettingsView.swift @@ -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()) +} diff --git a/LockInBroMobile/Views/SharedComponents.swift b/LockInBroMobile/Views/SharedComponents.swift new file mode 100644 index 0000000..102a779 --- /dev/null +++ b/LockInBroMobile/Views/SharedComponents.swift @@ -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) + } + } +} diff --git a/LockInBroMobile/Views/TaskBoardView.swift b/LockInBroMobile/Views/TaskBoardView.swift new file mode 100644 index 0000000..decdb12 --- /dev/null +++ b/LockInBroMobile/Views/TaskBoardView.swift @@ -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()) +} diff --git a/LockInBroMobile/Views/TaskDetailView.swift b/LockInBroMobile/Views/TaskDetailView.swift new file mode 100644 index 0000000..1807aef --- /dev/null +++ b/LockInBroMobile/Views/TaskDetailView.swift @@ -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) + } + } + } + } +} diff --git a/LockInBroMobileTests/LockInBroMobileTests.swift b/LockInBroMobileTests/LockInBroMobileTests.swift new file mode 100644 index 0000000..2c5073c --- /dev/null +++ b/LockInBroMobileTests/LockInBroMobileTests.swift @@ -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 + } + +} diff --git a/LockInBroMobileUITests/LockInBroMobileUITests.swift b/LockInBroMobileUITests/LockInBroMobileUITests.swift new file mode 100644 index 0000000..8e8a119 --- /dev/null +++ b/LockInBroMobileUITests/LockInBroMobileUITests.swift @@ -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() + } + } +} diff --git a/LockInBroMobileUITests/LockInBroMobileUITestsLaunchTests.swift b/LockInBroMobileUITests/LockInBroMobileUITestsLaunchTests.swift new file mode 100644 index 0000000..45266df --- /dev/null +++ b/LockInBroMobileUITests/LockInBroMobileUITestsLaunchTests.swift @@ -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) + } +} diff --git a/LockInBroMonitor/DeviceActivityMonitorExtension.swift b/LockInBroMonitor/DeviceActivityMonitorExtension.swift new file mode 100644 index 0000000..f4dcaab --- /dev/null +++ b/LockInBroMonitor/DeviceActivityMonitorExtension.swift @@ -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") +} diff --git a/LockInBroMonitor/Info.plist b/LockInBroMonitor/Info.plist new file mode 100644 index 0000000..13f9299 --- /dev/null +++ b/LockInBroMonitor/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.deviceactivity.monitor-extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).DeviceActivityMonitorExtension + + + diff --git a/LockInBroMonitor/LockInBroMonitor.entitlements b/LockInBroMonitor/LockInBroMonitor.entitlements new file mode 100644 index 0000000..5887cb4 --- /dev/null +++ b/LockInBroMonitor/LockInBroMonitor.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.com.adipu.LockInBroMobile + + + diff --git a/LockInBroShield/Info.plist b/LockInBroShield/Info.plist new file mode 100644 index 0000000..c89920e --- /dev/null +++ b/LockInBroShield/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.ManagedSettingsUI.shield-configuration-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShieldConfigurationExtension + + + diff --git a/LockInBroShield/LockInBroShield.entitlements b/LockInBroShield/LockInBroShield.entitlements new file mode 100644 index 0000000..5887cb4 --- /dev/null +++ b/LockInBroShield/LockInBroShield.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.com.adipu.LockInBroMobile + + + diff --git a/LockInBroShield/ShieldConfigurationExtension.swift b/LockInBroShield/ShieldConfigurationExtension.swift new file mode 100644 index 0000000..8b517e1 --- /dev/null +++ b/LockInBroShield/ShieldConfigurationExtension.swift @@ -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 + ) + ) + } +} diff --git a/LockInBroShieldAction/Info.plist b/LockInBroShieldAction/Info.plist new file mode 100644 index 0000000..e8acc18 --- /dev/null +++ b/LockInBroShieldAction/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.ManagedSettings.shield-action-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShieldActionExtension + + + diff --git a/LockInBroShieldAction/LockInBroShieldAction.entitlements b/LockInBroShieldAction/LockInBroShieldAction.entitlements new file mode 100644 index 0000000..5887cb4 --- /dev/null +++ b/LockInBroShieldAction/LockInBroShieldAction.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.com.adipu.LockInBroMobile + + + diff --git a/LockInBroShieldAction/ShieldActionExtension.swift b/LockInBroShieldAction/ShieldActionExtension.swift new file mode 100644 index 0000000..1bf4816 --- /dev/null +++ b/LockInBroShieldAction/ShieldActionExtension.swift @@ -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") +} diff --git a/LockInBroWidget/AppIntent.swift b/LockInBroWidget/AppIntent.swift new file mode 100644 index 0000000..89bb5f3 --- /dev/null +++ b/LockInBroWidget/AppIntent.swift @@ -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 +} diff --git a/LockInBroWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/LockInBroWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/LockInBroWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LockInBroWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/LockInBroWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/LockInBroWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/LockInBroWidget/Assets.xcassets/Contents.json b/LockInBroWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/LockInBroWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LockInBroWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/LockInBroWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/LockInBroWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LockInBroWidget/Info.plist b/LockInBroWidget/Info.plist new file mode 100644 index 0000000..77d9802 --- /dev/null +++ b/LockInBroWidget/Info.plist @@ -0,0 +1,15 @@ + + + + + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LockInBroWidget/LockInBroWidget.swift b/LockInBroWidget/LockInBroWidget.swift new file mode 100644 index 0000000..e90ae68 --- /dev/null +++ b/LockInBroWidget/LockInBroWidget.swift @@ -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 { + 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 { +// // 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) +} diff --git a/LockInBroWidget/LockInBroWidgetBundle.swift b/LockInBroWidget/LockInBroWidgetBundle.swift new file mode 100644 index 0000000..78783ae --- /dev/null +++ b/LockInBroWidget/LockInBroWidgetBundle.swift @@ -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() + } +} diff --git a/LockInBroWidget/LockInBroWidgetControl.swift b/LockInBroWidget/LockInBroWidgetControl.swift new file mode 100644 index 0000000..d7d93cc --- /dev/null +++ b/LockInBroWidget/LockInBroWidgetControl.swift @@ -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() + } +} diff --git a/LockInBroWidget/LockInBroWidgetLiveActivity.swift b/LockInBroWidget/LockInBroWidgetLiveActivity.swift new file mode 100644 index 0000000..8df44e5 --- /dev/null +++ b/LockInBroWidget/LockInBroWidgetLiveActivity.swift @@ -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 +} diff --git a/Scripts/download_whisper_model.sh b/Scripts/download_whisper_model.sh new file mode 100755 index 0000000..8c58214 --- /dev/null +++ b/Scripts/download_whisper_model.sh @@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"