diff --git a/ios/StillPoint.xcodeproj/project.pbxproj b/ios/StillPoint.xcodeproj/project.pbxproj new file mode 100644 index 00000000..6505f420 --- /dev/null +++ b/ios/StillPoint.xcodeproj/project.pbxproj @@ -0,0 +1,492 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 15177B86F124B5A10AB03456 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C683A9F0586D58020E60DD87 /* RootView.swift */; }; + 1649BE1D765A6851D5802BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D356465C1B0F69C57FE47A80 /* SettingsView.swift */; }; + 1BE60070F826B7B31DFAB315 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23FA6DAE71EE23FAF8EE0F12 /* HomeView.swift */; }; + 2E31C1B6A86E612EF7171348 /* ThoughtJournalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44912B427658554F984CCC3A /* ThoughtJournalView.swift */; }; + 3E8960D90E815E3462FF241B /* ThoughtJournalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42927B68B0A976D71FE0637F /* ThoughtJournalViewModel.swift */; }; + 3F1BEE1BEA197162093263CB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9311B0CBDED4DF207E799ACC /* Assets.xcassets */; }; + 432648A182B69A2E98314B0C /* BoardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D22599DCB705628A5CB6F51 /* BoardViewModel.swift */; }; + 4D652980F04840078B8FC938 /* SessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F95E6E47148AEF494393FDB /* SessionView.swift */; }; + 50CD873CA594B5A6FCDBB251 /* HistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F75039BBEED880C73680860 /* HistoryViewModel.swift */; }; + 55CB825BEE151D314A4DD2FA /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 218693B76FDAF56B6E0321DE /* AppViewModel.swift */; }; + 5EA3E8C9B84298DE1F37251F /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00D7B1EDF2C56A1B6EB4C28 /* AuthView.swift */; }; + 74AE784447C3D50E1E6980E9 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7766937C968433C205A250C /* HistoryView.swift */; }; + 80005A5883007151E3A9C5C3 /* MindStateBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB771FA099C779B2F3A86AB /* MindStateBarView.swift */; }; + 8260BE3D7302FE11A35677B2 /* StillPointShared in Frameworks */ = {isa = PBXBuildFile; productRef = FE1475755D9C9BA63C80748F /* StillPointShared */; }; + 8A9734076E9FB3D5477913A0 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CBD1A192FD74748A6D36BF /* AuthViewModel.swift */; }; + 9ECB83ED063EDAA82E88004B /* CompletionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D13B1D65F99CF21AA713D32 /* CompletionView.swift */; }; + AC4DAD06FA75C7C480DDFF27 /* SessionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE2D028ACC5D7FEFD82703D /* SessionViewModel.swift */; }; + B106890588723F191CC4AEAA /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B5A3374B6B8AC64C22F537 /* MainTabView.swift */; }; + B41D20D3939E0F51F7471F44 /* ThoughtCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A71F34D9A1ED328C5438F7 /* ThoughtCaptureView.swift */; }; + C57D92D33ED679E23F5E2E49 /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB4B1423B22DD22F5B6E069 /* DesignTokens.swift */; }; + DB99CD9DA2E6BB2A49B63025 /* PublicBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BD7D2C82C908604A0C3D914 /* PublicBoardView.swift */; }; + E1FBFCD4730D9BC4E924C938 /* StillPointApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14CEC6DBFFB1C10CFDDA8424 /* StillPointApp.swift */; }; + F370AA8F6AA102355FC599C9 /* BlockGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EAF998E3470CBB4056699F /* BlockGridView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0BD7D2C82C908604A0C3D914 /* PublicBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicBoardView.swift; sourceTree = ""; }; + 0D22599DCB705628A5CB6F51 /* BoardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoardViewModel.swift; sourceTree = ""; }; + 14CEC6DBFFB1C10CFDDA8424 /* StillPointApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StillPointApp.swift; sourceTree = ""; }; + 1F75039BBEED880C73680860 /* HistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryViewModel.swift; sourceTree = ""; }; + 218693B76FDAF56B6E0321DE /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; }; + 23FA6DAE71EE23FAF8EE0F12 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 2F95E6E47148AEF494393FDB /* SessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionView.swift; sourceTree = ""; }; + 42927B68B0A976D71FE0637F /* ThoughtJournalViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThoughtJournalViewModel.swift; sourceTree = ""; }; + 44912B427658554F984CCC3A /* ThoughtJournalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThoughtJournalView.swift; sourceTree = ""; }; + 71A71F34D9A1ED328C5438F7 /* ThoughtCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThoughtCaptureView.swift; sourceTree = ""; }; + 7CB771FA099C779B2F3A86AB /* MindStateBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindStateBarView.swift; sourceTree = ""; }; + 7D13B1D65F99CF21AA713D32 /* CompletionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionView.swift; sourceTree = ""; }; + 85EAF998E3470CBB4056699F /* BlockGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockGridView.swift; sourceTree = ""; }; + 9311B0CBDED4DF207E799ACC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A00D7B1EDF2C56A1B6EB4C28 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; + A67F64CE400EF98ED2925FF9 /* StillPointShared */ = {isa = PBXFileReference; lastKnownFileType = folder; name = StillPointShared; path = StillPointShared; sourceTree = SOURCE_ROOT; }; + B6CBD1A192FD74748A6D36BF /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = ""; }; + C683A9F0586D58020E60DD87 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + C7766937C968433C205A250C /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + CDB4B1423B22DD22F5B6E069 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = ""; }; + D15A7FF17302B1E2FD147920 /* StillPoint.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = StillPoint.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D356465C1B0F69C57FE47A80 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + D7B5A3374B6B8AC64C22F537 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; + FDE2D028ACC5D7FEFD82703D /* SessionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionViewModel.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 60417DAC61CFEF1804125570 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8260BE3D7302FE11A35677B2 /* StillPointShared in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0125A97693D46BCCED4DC74E /* Views */ = { + isa = PBXGroup; + children = ( + A00D7B1EDF2C56A1B6EB4C28 /* AuthView.swift */, + 7D13B1D65F99CF21AA713D32 /* CompletionView.swift */, + C7766937C968433C205A250C /* HistoryView.swift */, + 23FA6DAE71EE23FAF8EE0F12 /* HomeView.swift */, + 0BD7D2C82C908604A0C3D914 /* PublicBoardView.swift */, + C683A9F0586D58020E60DD87 /* RootView.swift */, + 2F95E6E47148AEF494393FDB /* SessionView.swift */, + D356465C1B0F69C57FE47A80 /* SettingsView.swift */, + 44912B427658554F984CCC3A /* ThoughtJournalView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 09EA4336C05552F95AE85178 /* Resources */ = { + isa = PBXGroup; + children = ( + 9311B0CBDED4DF207E799ACC /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; + 489EB05EBE530B8F6E4F1C7F /* StillPointApp */ = { + isa = PBXGroup; + children = ( + 14CEC6DBFFB1C10CFDDA8424 /* StillPointApp.swift */, + 4DB1C67E76477BADB30F0F77 /* Components */, + 551003AC2B5AD8A7B3C3D426 /* Navigation */, + 09EA4336C05552F95AE85178 /* Resources */, + AC788F412B1414C8C2BDB3CB /* Theme */, + B5DCE12E19F7C00786209F0E /* ViewModels */, + 0125A97693D46BCCED4DC74E /* Views */, + ); + path = StillPointApp; + sourceTree = ""; + }; + 4DB1C67E76477BADB30F0F77 /* Components */ = { + isa = PBXGroup; + children = ( + 85EAF998E3470CBB4056699F /* BlockGridView.swift */, + 7CB771FA099C779B2F3A86AB /* MindStateBarView.swift */, + 71A71F34D9A1ED328C5438F7 /* ThoughtCaptureView.swift */, + ); + path = Components; + sourceTree = ""; + }; + 551003AC2B5AD8A7B3C3D426 /* Navigation */ = { + isa = PBXGroup; + children = ( + D7B5A3374B6B8AC64C22F537 /* MainTabView.swift */, + ); + path = Navigation; + sourceTree = ""; + }; + 70234ED5699E0294984184EC = { + isa = PBXGroup; + children = ( + 72C36256C4A12941449D9B2F /* Packages */, + 489EB05EBE530B8F6E4F1C7F /* StillPointApp */, + D6729D98E36CD4883502F855 /* Products */, + ); + sourceTree = ""; + }; + 72C36256C4A12941449D9B2F /* Packages */ = { + isa = PBXGroup; + children = ( + A67F64CE400EF98ED2925FF9 /* StillPointShared */, + ); + name = Packages; + sourceTree = ""; + }; + AC788F412B1414C8C2BDB3CB /* Theme */ = { + isa = PBXGroup; + children = ( + CDB4B1423B22DD22F5B6E069 /* DesignTokens.swift */, + ); + path = Theme; + sourceTree = ""; + }; + B5DCE12E19F7C00786209F0E /* ViewModels */ = { + isa = PBXGroup; + children = ( + 218693B76FDAF56B6E0321DE /* AppViewModel.swift */, + B6CBD1A192FD74748A6D36BF /* AuthViewModel.swift */, + 0D22599DCB705628A5CB6F51 /* BoardViewModel.swift */, + 1F75039BBEED880C73680860 /* HistoryViewModel.swift */, + FDE2D028ACC5D7FEFD82703D /* SessionViewModel.swift */, + 42927B68B0A976D71FE0637F /* ThoughtJournalViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + D6729D98E36CD4883502F855 /* Products */ = { + isa = PBXGroup; + children = ( + D15A7FF17302B1E2FD147920 /* StillPoint.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1EC3D1ED76F9038154716BEC /* StillPoint */ = { + isa = PBXNativeTarget; + buildConfigurationList = BC3F99DC4FB6A54504D97FB8 /* Build configuration list for PBXNativeTarget "StillPoint" */; + buildPhases = ( + 788E99323C1F4CDCA2A903FC /* Sources */, + 0B31A9FD8CF7CD832FD44715 /* Resources */, + 60417DAC61CFEF1804125570 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StillPoint; + packageProductDependencies = ( + FE1475755D9C9BA63C80748F /* StillPointShared */, + ); + productName = StillPoint; + productReference = D15A7FF17302B1E2FD147920 /* StillPoint.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 17E69AC4DBE973C4774D38B6 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + 1EC3D1ED76F9038154716BEC = { + DevelopmentTeam = ""; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = A869842DC3454C47D11359A9 /* Build configuration list for PBXProject "StillPoint" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 70234ED5699E0294984184EC; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 33432E91F4550EE24838A4B5 /* XCLocalSwiftPackageReference "StillPointShared" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = D6729D98E36CD4883502F855 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1EC3D1ED76F9038154716BEC /* StillPoint */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0B31A9FD8CF7CD832FD44715 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F1BEE1BEA197162093263CB /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 788E99323C1F4CDCA2A903FC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 55CB825BEE151D314A4DD2FA /* AppViewModel.swift in Sources */, + 5EA3E8C9B84298DE1F37251F /* AuthView.swift in Sources */, + 8A9734076E9FB3D5477913A0 /* AuthViewModel.swift in Sources */, + F370AA8F6AA102355FC599C9 /* BlockGridView.swift in Sources */, + 432648A182B69A2E98314B0C /* BoardViewModel.swift in Sources */, + 9ECB83ED063EDAA82E88004B /* CompletionView.swift in Sources */, + C57D92D33ED679E23F5E2E49 /* DesignTokens.swift in Sources */, + 74AE784447C3D50E1E6980E9 /* HistoryView.swift in Sources */, + 50CD873CA594B5A6FCDBB251 /* HistoryViewModel.swift in Sources */, + 1BE60070F826B7B31DFAB315 /* HomeView.swift in Sources */, + B106890588723F191CC4AEAA /* MainTabView.swift in Sources */, + 80005A5883007151E3A9C5C3 /* MindStateBarView.swift in Sources */, + DB99CD9DA2E6BB2A49B63025 /* PublicBoardView.swift in Sources */, + 15177B86F124B5A10AB03456 /* RootView.swift in Sources */, + 4D652980F04840078B8FC938 /* SessionView.swift in Sources */, + AC4DAD06FA75C7C480DDFF27 /* SessionViewModel.swift in Sources */, + 1649BE1D765A6851D5802BBA /* SettingsView.swift in Sources */, + E1FBFCD4730D9BC4E924C938 /* StillPointApp.swift in Sources */, + B41D20D3939E0F51F7471F44 /* ThoughtCaptureView.swift in Sources */, + 2E31C1B6A86E612EF7171348 /* ThoughtJournalView.swift in Sources */, + 3E8960D90E815E3462FF241B /* ThoughtJournalViewModel.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 11E27EB55EEE0D1829FD2125 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = StillPointApp/Info.plist; + 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; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stillpoint.app; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 79085E6777D72FCC1A887CA8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = StillPointApp/Info.plist; + 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; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stillpoint.app; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A8F838B8B64A8B8B4310D79B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + 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 = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + EA94A2D84868963AA6284D06 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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 = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A869842DC3454C47D11359A9 /* Build configuration list for PBXProject "StillPoint" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8F838B8B64A8B8B4310D79B /* Debug */, + EA94A2D84868963AA6284D06 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + BC3F99DC4FB6A54504D97FB8 /* Build configuration list for PBXNativeTarget "StillPoint" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 79085E6777D72FCC1A887CA8 /* Debug */, + 11E27EB55EEE0D1829FD2125 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 33432E91F4550EE24838A4B5 /* XCLocalSwiftPackageReference "StillPointShared" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = StillPointShared; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + FE1475755D9C9BA63C80748F /* StillPointShared */ = { + isa = XCSwiftPackageProductDependency; + productName = StillPointShared; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 17E69AC4DBE973C4774D38B6 /* Project object */; +} diff --git a/ios/StillPoint.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/StillPoint.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/ios/StillPoint.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/StillPointApp/Components/BlockGridView.swift b/ios/StillPointApp/Components/BlockGridView.swift new file mode 100644 index 00000000..306eb52f --- /dev/null +++ b/ios/StillPointApp/Components/BlockGridView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import StillPointShared + +struct BlockGridView: View { + let blocks: [BlockDef] + let elapsed: Double + let totalSeconds: Int + + private let blockSize: CGFloat = 56 + private let blockSpacing: CGFloat = 11 + private let blockRadius: CGFloat = 10 + + private var minuteBlocks: [BlockDef] { + blocks.filter { $0.type == .minute } + } + + private var secondBlocks: [BlockDef] { + blocks.filter { $0.type == .second } + } + + private var useMinuteBlocks: Bool { + totalSeconds > 120 + } + + var body: some View { + if useMinuteBlocks { + VStack(spacing: SPSpacing.s3) { + // Minute blocks + LazyVGrid( + columns: [GridItem(.adaptive(minimum: blockSize, maximum: blockSize), spacing: blockSpacing)], + spacing: blockSpacing + ) { + ForEach(minuteBlocks) { block in + blockView(block) + } + } + + // Divider + "final minute" label + VStack(spacing: SPSpacing.s1) { + Rectangle() + .fill(SPColor.border1) + .frame(height: 1) + + Text("FINAL MINUTE") + .font(SPFont.mono(11)) + .foregroundStyle(Color(SPColor.fg4)) + .tracking(2) + + // 10-second blocks + LazyVGrid( + columns: [GridItem(.adaptive(minimum: blockSize, maximum: blockSize), spacing: blockSpacing)], + spacing: blockSpacing + ) { + ForEach(secondBlocks) { block in + blockView(block) + } + } + } + } + } else { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: blockSize, maximum: blockSize), spacing: blockSpacing)], + spacing: blockSpacing + ) { + ForEach(blocks) { block in + blockView(block) + } + } + } + } + + @ViewBuilder + private func blockView(_ block: BlockDef) -> some View { + let blockEnd = block.startTime + block.duration + let isFilled = elapsed >= Double(blockEnd) + let isCurrent = elapsed >= Double(block.startTime) + && elapsed < Double(blockEnd) + && elapsed < Double(totalSeconds) + let progress = isCurrent + ? (elapsed - Double(block.startTime)) / Double(block.duration) + : isFilled ? 1.0 : 0.0 + + ZStack { + // Background + RoundedRectangle(cornerRadius: blockRadius) + .fill(SPColor.surface1) + + // Fill from bottom + GeometryReader { geo in + VStack { + Spacer() + Rectangle() + .fill(isFilled ? LinearGradient.greenFill : LinearGradient.amberFill) + .frame(height: geo.size.height * progress) + .opacity(isFilled ? 0.85 : 0.7) + } + } + .clipShape(RoundedRectangle(cornerRadius: blockRadius)) + + // Label + Text(block.label) + .font(SPFont.mono(13, weight: .medium)) + .foregroundStyle(isFilled ? SPColor.overlayText : Color(SPColor.fg4)) + + // Current block pulse border — uses phaseAnimator for continuous pulse + if isCurrent { + RoundedRectangle(cornerRadius: blockRadius) + .stroke(SPColor.amberDim, lineWidth: 1) + .phaseAnimator([false, true]) { content, phase in + content.opacity(phase ? 1.0 : 0.4) + } animation: { _ in + .easeInOut(duration: 1.0) + } + } + } + .frame(width: blockSize, height: blockSize) + .overlay( + RoundedRectangle(cornerRadius: blockRadius) + .stroke( + isFilled ? SPColor.greenBorder : + isCurrent ? SPColor.amberBorder : + SPColor.border1, + lineWidth: 1 + ) + ) + } +} diff --git a/ios/StillPointApp/Components/MindStateBarView.swift b/ios/StillPointApp/Components/MindStateBarView.swift new file mode 100644 index 00000000..4c484e79 --- /dev/null +++ b/ios/StillPointApp/Components/MindStateBarView.swift @@ -0,0 +1,46 @@ +import SwiftUI +import StillPointShared + +/// Horizontal segmented bar showing clear (green) and thinking (amber) periods. +struct MindStateBarView: View { + let elapsed: Double + let totalSeconds: Int + let mindStateLog: [MindStateEntry] + + var body: some View { + GeometryReader { geo in + let width = geo.size.width + let safeTotalSeconds = max(totalSeconds, 1) + + ZStack(alignment: .leading) { + // Background track + RoundedRectangle(cornerRadius: 4) + .fill(SPColor.surface2) + + // Segments + ForEach(Array(mindStateLog.enumerated()), id: \.offset) { index, entry in + let startFraction = entry.time / Double(safeTotalSeconds) + let endTime: Double = { + if index + 1 < mindStateLog.count { + return mindStateLog[index + 1].time + } + return min(elapsed, Double(safeTotalSeconds)) + }() + let endFraction = endTime / Double(safeTotalSeconds) + + let segmentX = width * startFraction + let segmentWidth = max(0, width * (endFraction - startFraction)) + + Rectangle() + .fill(entry.isClear ? SPColor.green : SPColor.amber) + .opacity(entry.isClear ? 0.5 : 0.6) + .frame(width: segmentWidth) + .offset(x: segmentX) + } + } + } + .frame(height: 8) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(.horizontal, SPSpacing.s4) + } +} diff --git a/ios/StillPointApp/Components/ThoughtCaptureView.swift b/ios/StillPointApp/Components/ThoughtCaptureView.swift new file mode 100644 index 00000000..693c5fd9 --- /dev/null +++ b/ios/StillPointApp/Components/ThoughtCaptureView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// Inline thought capture card that appears when the user taps "I'm thinking" +struct ThoughtCaptureView: View { + let onCapture: (String) -> Void + let onDismiss: () -> Void + + @State private var text = "" + @FocusState private var isFocused: Bool + + private var trimmedText: String { text.trimmingCharacters(in: .whitespacesAndNewlines) } + + var body: some View { + VStack(spacing: SPSpacing.s2) { + HStack { + Text("capture this thought") + .font(SPFont.mono(11)) + .foregroundStyle(SPColor.amberText) + .tracking(1) + Spacer() + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color(SPColor.fg4)) + } + } + + TextField("what were you thinking about?", text: $text) + .font(SPFont.serifItalic(15)) + .foregroundStyle(Color(SPColor.fg)) + .focused($isFocused) + .onSubmit { + if !trimmedText.isEmpty { + onCapture(trimmedText) + } + } + + HStack { + Spacer() + Button { + if !trimmedText.isEmpty { + onCapture(trimmedText) + } else { + onDismiss() + } + } label: { + Text(trimmedText.isEmpty ? "skip" : "save") + .font(SPFont.mono(12, weight: .medium)) + .foregroundStyle(trimmedText.isEmpty ? Color(SPColor.fg4) : SPColor.amber) + } + } + } + .padding(SPSpacing.s3) + .background(SPColor.amberBgFaint) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SPColor.amberBorderSubtle) + ) + .onAppear { isFocused = true } + } +} diff --git a/ios/StillPointApp/Navigation/MainTabView.swift b/ios/StillPointApp/Navigation/MainTabView.swift new file mode 100644 index 00000000..0d6d06f4 --- /dev/null +++ b/ios/StillPointApp/Navigation/MainTabView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct MainTabView: View { + let appVM: AppViewModel + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + HomeView(appVM: appVM) + .tabItem { + Label("HOME", systemImage: "house") + } + .tag(0) + + HistoryView(appVM: appVM) + .tabItem { + Label("PROGRESS", systemImage: "chart.bar") + } + .tag(1) + + ThoughtJournalView() + .tabItem { + Label("JOURNAL", systemImage: "book") + } + .tag(2) + + PublicBoardView(currentUsername: appVM.currentUser?.username) + .tabItem { + Label("BOARD", systemImage: "person.3") + } + .tag(3) + + SettingsView(appVM: appVM) + .tabItem { + Label("SETTINGS", systemImage: "gearshape") + } + .tag(4) + } + .tint(SPColor.green) + .onAppear { + Self.configureTabBarAppearance() + } + } + + private static var tabBarConfigured = false + + private static func configureTabBarAppearance() { + guard !tabBarConfigured else { return } + tabBarConfigured = true + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = UIColor(SPColor.bg) + UITabBar.appearance().standardAppearance = appearance + UITabBar.appearance().scrollEdgeAppearance = appearance + } +} diff --git a/ios/StillPointApp/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/StillPointApp/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..7c6ebab9 --- /dev/null +++ b/ios/StillPointApp/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.502", + "green" : "0.871", + "red" : "0.290" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/StillPointApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/StillPointApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/ios/StillPointApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/StillPointApp/Resources/Assets.xcassets/Contents.json b/ios/StillPointApp/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/StillPointApp/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/StillPointApp/StillPointApp.swift b/ios/StillPointApp/StillPointApp.swift new file mode 100644 index 00000000..ee575460 --- /dev/null +++ b/ios/StillPointApp/StillPointApp.swift @@ -0,0 +1,14 @@ +import SwiftUI +import SwiftData +import StillPointShared + +@main +struct StillPointApp: App { + var body: some Scene { + WindowGroup { + RootView() + .preferredColorScheme(.dark) + } + .modelContainer(for: [User.self, Session.self, Thought.self]) + } +} diff --git a/ios/StillPointApp/Theme/DesignTokens.swift b/ios/StillPointApp/Theme/DesignTokens.swift new file mode 100644 index 00000000..77813742 --- /dev/null +++ b/ios/StillPointApp/Theme/DesignTokens.swift @@ -0,0 +1,158 @@ +import SwiftUI + +// MARK: - Colors (matching globals.css custom properties) + +public enum SPColor { + // Background + static let bg = Color(red: 26/255, green: 24/255, blue: 22/255) + + // Foreground tiers (warm off-white at different opacities) + static let fg = Color(red: 232/255, green: 228/255, blue: 222/255).opacity(0.92) + static let fg2 = Color(red: 232/255, green: 228/255, blue: 222/255).opacity(0.70) + static let fg3 = Color(red: 232/255, green: 228/255, blue: 222/255).opacity(0.52) + static let fg4 = Color(red: 232/255, green: 228/255, blue: 222/255).opacity(0.45) + + // Borders + static let border1 = Color(red: 232/255, green: 228/255, blue: 222/255).opacity(0.08) + static let border2 = Color(red: 232/255, green: 228/255, blue: 222/255).opacity(0.18) + static let border3 = Color(red: 232/255, green: 228/255, blue: 222/255).opacity(0.35) + + // Surfaces + static let surface1 = Color(red: 232/255, green: 228/255, blue: 222/255).opacity(0.04) + static let surface2 = Color(red: 232/255, green: 228/255, blue: 222/255).opacity(0.06) + static let surface3 = Color(red: 232/255, green: 228/255, blue: 222/255).opacity(0.10) + + // Green / Clear Mind + static let green = Color(red: 74/255, green: 222/255, blue: 128/255) + static let greenEnd = Color(red: 34/255, green: 197/255, blue: 94/255) + static let greenStrong = green.opacity(0.7) + static let greenText = green.opacity(0.6) + static let greenDim = green.opacity(0.5) + static let greenBg = green.opacity(0.4) + static let greenGlow = green.opacity(0.3) + static let greenMuted = green.opacity(0.25) + static let greenBorder = green.opacity(0.2) + static let greenBorderSubtle = green.opacity(0.15) + static let greenBgSubtle = green.opacity(0.08) + static let greenBgFaint = green.opacity(0.06) + + // Amber / Thinking + static let amber = Color(red: 251/255, green: 191/255, blue: 36/255) + static let amberEnd = Color(red: 245/255, green: 158/255, blue: 11/255) + static let amberText = amber.opacity(0.6) + static let amberDim = amber.opacity(0.5) + static let amberBorder = amber.opacity(0.4) + static let amberHint = amber.opacity(0.3) + static let amberMuted = amber.opacity(0.25) + static let amberBorderSubtle = amber.opacity(0.2) + static let amberBg = amber.opacity(0.15) + static let amberBgFaint = amber.opacity(0.06) + + // Red / Danger + static let danger = Color.red.opacity(0.7) + static let dangerMuted = Color.red.opacity(0.5) + static let dangerBorder = Color.red.opacity(0.2) + static let dangerBorderSubtle = Color.red.opacity(0.15) + + // Overlay + static let overlayText = Color.black.opacity(0.5) + static let overlayBg = Color.black.opacity(0.3) +} + +// MARK: - Spacing (matching globals.css scale) + +public enum SPSpacing { + static let s1: CGFloat = 8 + static let s2: CGFloat = 12 + static let s3: CGFloat = 16 + static let s4: CGFloat = 24 + static let s5: CGFloat = 32 + static let s6: CGFloat = 48 +} + +// MARK: - Fonts +// +// Custom fonts: Newsreader (serif) and JetBrains Mono (monospace). +// Both are OFL-licensed Google Fonts. To bundle them: +// 1. Download .ttf files into StillPointApp/Resources/Fonts/ +// 2. Add to target's "Copy Bundle Resources" build phase +// 3. Register in Info.plist under UIAppFonts (ATSApplicationFontsPath) +// +// Until bundled, these helpers fall back to system serif / monospaced fonts. + +public enum SPFont { + // Check if a custom font is available; fall back to system equivalent + private static func customOrFallback(_ name: String, size: CGFloat, fallback: Font.Design) -> Font { + if UIFont(name: name, size: size) != nil { + return .custom(name, size: size) + } + return .system(size: size, design: fallback) + } + + /// Serif font for body text, headings, brand — Newsreader (fallback: system serif) + static func serif(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + customOrFallback("Newsreader-Regular", size: size, fallback: .serif).weight(weight) + } + + /// Serif italic for brand lockup and emphasis + static func serifItalic(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + if UIFont(name: "Newsreader-Italic", size: size) != nil { + return .custom("Newsreader-Italic", size: size).weight(weight) + } + return .system(size: size, design: .serif).weight(weight).italic() + } + + /// Monospace for labels, stats, data — JetBrains Mono (fallback: system monospaced) + static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + customOrFallback("JetBrainsMono-Regular", size: size, fallback: .monospaced).weight(weight) + } + + // Common presets + static let brandTitle = serifItalic(42, weight: .light) + static let brandSubtitle = mono(13, weight: .light) + static let dayNumber = mono(100, weight: .ultraLight) + static let timerDisplay = mono(80, weight: .ultraLight) + static let statValue = mono(28, weight: .light) + static let statLabel = mono(11, weight: .regular) + static let navLabel = mono(11, weight: .medium) + static let bodyText = serif(17, weight: .light) + static let caption = mono(14, weight: .regular) +} + +// MARK: - View Extensions + +extension View { + /// Apply the Still Point dark background + func stillPointBackground() -> some View { + self.background(SPColor.bg.ignoresSafeArea()) + } + + /// Fade-in animation matching web app's fadeIn keyframe + func fadeIn() -> some View { + self.transition(.opacity.combined(with: .move(edge: .bottom))) + } +} + +// MARK: - Gradient Helpers + +extension LinearGradient { + static let greenFill = LinearGradient( + colors: [SPColor.green, SPColor.greenEnd], + startPoint: .bottom, endPoint: .top + ) + + static let amberFill = LinearGradient( + colors: [SPColor.amber, SPColor.amberEnd], + startPoint: .bottom, endPoint: .top + ) + + static let greenHorizontal = LinearGradient( + colors: [SPColor.green, SPColor.greenEnd], + startPoint: .leading, endPoint: .trailing + ) + + static let amberHorizontal = LinearGradient( + colors: [SPColor.amber, SPColor.amberEnd], + startPoint: .leading, endPoint: .trailing + ) +} diff --git a/ios/StillPointApp/ViewModels/AppViewModel.swift b/ios/StillPointApp/ViewModels/AppViewModel.swift new file mode 100644 index 00000000..e753d41f --- /dev/null +++ b/ios/StillPointApp/ViewModels/AppViewModel.swift @@ -0,0 +1,112 @@ +import SwiftUI +import SwiftData +import StillPointShared + +enum AppView: Equatable { + case auth + case home + case session + case completion(clearPercent: Int, thoughtCount: Int, thoughts: [CapturedThought], dayNumber: Int, duration: Int) + case history + case journal + case board + case settings + + static func == (lhs: AppView, rhs: AppView) -> Bool { + switch (lhs, rhs) { + case (.auth, .auth), (.home, .home), (.session, .session), + (.history, .history), (.journal, .journal), (.board, .board), + (.settings, .settings): + return true + case (.completion, .completion): + return true + default: + return false + } + } +} + +struct CapturedThought: Identifiable { + let id = UUID() + let timeInSession: Int + let text: String +} + +@Observable +final class AppViewModel { + var currentView: AppView = .auth + var currentUser: UserDTO? + var isLoading = true + + var currentDay: Int { + currentUser?.currentDay ?? 1 + } + + var todayDuration: Int { + StillPoint.duration(forDay: currentDay) + } + + var todayBlockCount: Int { + StillPoint.blockCount(forDuration: todayDuration) + } + + var isInSession: Bool { + if case .session = currentView { return true } + if case .completion = currentView { return true } + return false + } + + func checkAuth() async { + isLoading = true + defer { isLoading = false } + + do { + if let user = try await APIClient.shared.me() { + currentUser = user + currentView = .home + } else { + currentView = .auth + } + } catch { + currentView = .auth + } + } + + func didLogin(user: UserDTO) { + currentUser = user + currentView = .home + } + + func didLogout() { + currentUser = nil + currentView = .auth + } + + func beginSession() { + currentView = .session + } + + func completeSession( + clearPercent: Int, + thoughtCount: Int, + thoughts: [CapturedThought], + dayNumber: Int, + duration: Int + ) { + currentView = .completion( + clearPercent: clearPercent, + thoughtCount: thoughtCount, + thoughts: thoughts, + dayNumber: dayNumber, + duration: duration + ) + } + + func returnHome() async { + // Refresh user data to get updated currentDay BEFORE navigating + if let user = try? await APIClient.shared.me() { + currentUser = user + } + currentView = .home + } +} diff --git a/ios/StillPointApp/ViewModels/AuthViewModel.swift b/ios/StillPointApp/ViewModels/AuthViewModel.swift new file mode 100644 index 00000000..17af7b0d --- /dev/null +++ b/ios/StillPointApp/ViewModels/AuthViewModel.swift @@ -0,0 +1,50 @@ +import SwiftUI +import StillPointShared + +@Observable +final class AuthViewModel { + var isSignUp = false + var email = "" + var username = "" + var password = "" + var error: String? + var isSubmitting = false + + var isValid: Bool { + let emailValid = email.contains("@") && email.contains(".") + let passwordValid = password.count >= 8 + if isSignUp { + let usernameValid = username.count >= 3 && username.count <= 30 + && username.range(of: "^[a-zA-Z0-9_]+$", options: .regularExpression) != nil + return emailValid && usernameValid && passwordValid + } + return emailValid && passwordValid + } + + func submit() async -> UserDTO? { + guard isValid, !isSubmitting else { return nil } + isSubmitting = true + error = nil + defer { isSubmitting = false } + + do { + if isSignUp { + let user = try await APIClient.shared.signup( + email: email, username: username, password: password + ) + return user + } else { + let user = try await APIClient.shared.login( + email: email, password: password + ) + return user + } + } catch let apiError as APIError { + error = apiError.message + return nil + } catch { + self.error = "Connection failed. Please try again." + return nil + } + } +} diff --git a/ios/StillPointApp/ViewModels/BoardViewModel.swift b/ios/StillPointApp/ViewModels/BoardViewModel.swift new file mode 100644 index 00000000..8dbb3bdd --- /dev/null +++ b/ios/StillPointApp/ViewModels/BoardViewModel.swift @@ -0,0 +1,24 @@ +import SwiftUI +import StillPointShared + +@MainActor +@Observable +final class BoardViewModel { + var entries: [BoardEntryDTO] = [] + var isLoading = false + var errorMessage: String? + + func load() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + entries = try await APIClient.shared.getBoard() + } catch { + errorMessage = "Failed to load board. Check your connection." + print("Failed to load board: \(error)") + } + } +} diff --git a/ios/StillPointApp/ViewModels/HistoryViewModel.swift b/ios/StillPointApp/ViewModels/HistoryViewModel.swift new file mode 100644 index 00000000..133b8873 --- /dev/null +++ b/ios/StillPointApp/ViewModels/HistoryViewModel.swift @@ -0,0 +1,45 @@ +import SwiftUI +import StillPointShared + +@MainActor +@Observable +final class HistoryViewModel { + var sessions: [SessionDTO] = [] + var stats: StatsDTO? + var isLoading = false + var errorMessage: String? + var expandedDay: Int? + var dayThoughts: [Int: [ThoughtDTO]] = [:] + + func load() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let result = try await APIClient.shared.getSessions() + sessions = result.sessions.sorted { $0.dayNumber < $1.dayNumber } + stats = result.stats + } catch { + errorMessage = "Failed to load sessions. Check your connection." + print("Failed to load sessions: \(error)") + } + } + + func toggleDay(_ dayNumber: Int) async { + if expandedDay == dayNumber { + expandedDay = nil + } else { + expandedDay = dayNumber + if dayThoughts[dayNumber] == nil { + do { + let detail = try await APIClient.shared.getSession(dayNumber: dayNumber) + dayThoughts[dayNumber] = detail.thoughts + } catch { + print("Failed to load day \(dayNumber) thoughts: \(error)") + } + } + } + } +} diff --git a/ios/StillPointApp/ViewModels/SessionViewModel.swift b/ios/StillPointApp/ViewModels/SessionViewModel.swift new file mode 100644 index 00000000..d6356a44 --- /dev/null +++ b/ios/StillPointApp/ViewModels/SessionViewModel.swift @@ -0,0 +1,270 @@ +import SwiftUI +import Combine +import StillPointShared + +@Observable +final class SessionViewModel { + // Session config + let dayNumber: Int + let totalSeconds: Int + + // Timer state + var elapsed: Double = 0 + var isActive = false + var isPaused = false + var isComplete = false + /// Whether the session completed naturally (timer ran out) vs ended early + var completedNaturally = false + /// Whether the user explicitly abandoned (discard data, don't save) + var isAbandoned = false + + // Mind state + var mindState: String = "clear" + var mindStateLog: [MindStateEntry] = [] + var thoughtCount = 0 + var capturedThoughts: [CapturedThought] = [] + + // UI state + var showThoughtCapture = false + var controlsVisible = true + var soundPrefs: AudioEngine.SoundPrefs + + // Internal + private var startDate: Date? + private var pausedElapsed: Double = 0 + private var timer: AnyCancellable? + private var lastTickSec = -1 + private var lastChimeMinutesLeft: Int + private var controlHideTimer: AnyCancellable? + + var remaining: Double { + max(0, Double(totalSeconds) - elapsed) + } + + var minutes: Int { + Int(remaining) / 60 + } + + var seconds: Int { + Int(remaining) % 60 + } + + var clearPercent: Int { + SessionLogic.calculateClearPercent( + mindStateLog: mindStateLog, + totalElapsed: elapsed + ) + } + + var blocks: [BlockDef] { + SessionLogic.buildBlocks(totalSeconds: totalSeconds) + } + + var statusLabel: String { + SessionLogic.statusLabel( + elapsed: elapsed, + totalSeconds: totalSeconds, + blocks: blocks + ) + } + + var timeString: String { + "\(minutes):\(String(format: "%02d", seconds))" + } + + init(dayNumber: Int) { + self.dayNumber = dayNumber + self.totalSeconds = StillPoint.duration(forDay: dayNumber) + self.soundPrefs = AudioEngine.loadPrefs() + // Initialize to ceil so the first tick doesn't immediately fire chimes + self.lastChimeMinutesLeft = Int(ceil(Double(self.totalSeconds) / 60.0)) + // Initial mind state log entry + self.mindStateLog = [MindStateEntry(time: 0, state: "clear")] + } + + func start() { + isActive = true + isPaused = false + startDate = Date().addingTimeInterval(-pausedElapsed) + startTimer() + scheduleControlHide() + } + + func pause() { + isPaused = true + isActive = false + pausedElapsed = elapsed + timer?.cancel() + controlsVisible = true + } + + func resume() { + start() + } + + func toggleMindState() { + if mindState == "clear" { + mindState = "thinking" + thoughtCount += 1 + showThoughtCapture = true + } else { + mindState = "clear" + showThoughtCapture = false + } + mindStateLog.append(MindStateEntry(time: elapsed, state: mindState)) + userInteracted() + } + + func captureThought(_ text: String) { + guard !text.isEmpty else { return } + capturedThoughts.append(CapturedThought( + timeInSession: Int(elapsed), + text: text + )) + showThoughtCapture = false + } + + func dismissThoughtCapture() { + showThoughtCapture = false + } + + func userInteracted() { + controlsVisible = true + scheduleControlHide() + } + + func toggleSound(_ keyPath: WritableKeyPath) { + soundPrefs[keyPath: keyPath].toggle() + AudioEngine.savePrefs(soundPrefs) + } + + /// End session early but keep the data + func endEarly() -> (clearPercent: Int, thoughtCount: Int, thoughts: [CapturedThought]) { + timer?.cancel() + isActive = false + isComplete = true + return (clearPercent, thoughtCount, capturedThoughts) + } + + /// Abandon session — discard all data, don't save + func abandon() { + timer?.cancel() + isActive = false + isAbandoned = true + isComplete = true + } + + /// Save session to the API. Returns the session on success. + /// Thought batch failure is logged but does not fail the session save. + func saveSession(completed: Bool) async -> SessionDTO? { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.calendar = Calendar(identifier: .gregorian) + + let request = CreateSessionRequest( + dayNumber: dayNumber, + duration: totalSeconds, + completed: completed, + actualTime: Int(elapsed), + clearPercent: clearPercent, + thoughtCount: thoughtCount, + mindStateLog: mindStateLog, + sessionDate: dateFormatter.string(from: Date()) + ) + + do { + let session = try await APIClient.shared.createSession(request) + + // Batch save thoughts — failure here is non-fatal since the session is already persisted + let allThoughts = capturedThoughts.map { + BatchThoughtsRequest.ThoughtInput( + timeInSession: $0.timeInSession, + text: $0.text + ) + } + + if !allThoughts.isEmpty { + do { + _ = try await APIClient.shared.batchThoughts( + BatchThoughtsRequest( + sessionId: session.id, + dayNumber: dayNumber, + thoughts: allThoughts + ) + ) + } catch { + print("Failed to save thoughts (session was saved): \(error)") + } + } + + return session + } catch { + print("Failed to save session: \(error)") + return nil + } + } + + // MARK: - Private + + private func startTimer() { + timer = Timer.publish(every: 0.05, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.tick() + } + } + + private func tick() { + guard let startDate, isActive else { return } + + let newElapsed = Date().timeIntervalSince(startDate) + + if newElapsed >= Double(totalSeconds) { + elapsed = Double(totalSeconds) + pausedElapsed = elapsed + timer?.cancel() + isActive = false + completedNaturally = true + isComplete = true + if soundPrefs.completion { + AudioEngine.shared.playCompletion() + } + return + } + + elapsed = newElapsed + pausedElapsed = newElapsed + + let currentSec = Int(newElapsed) + let remainingTime = Double(totalSeconds) - newElapsed + + // Tick sound — once per second + if soundPrefs.tick && currentSec > lastTickSec { + lastTickSec = currentSec + AudioEngine.shared.playTick() + } + + // Minute chime — fire when remaining crosses a whole minute boundary downward + if soundPrefs.chime { + let wholeMinutesLeft = Int(floor(remainingTime / 60)) + if wholeMinutesLeft >= 1 && wholeMinutesLeft < lastChimeMinutesLeft { + AudioEngine.shared.playChime(count: wholeMinutesLeft) + } + lastChimeMinutesLeft = wholeMinutesLeft + } + } + + private func scheduleControlHide() { + controlHideTimer?.cancel() + controlHideTimer = Timer.publish(every: 3.0, on: .main, in: .common) + .autoconnect() + .first() + .sink { [weak self] _ in + guard let self, self.isActive else { return } + withAnimation(.easeOut(duration: 0.3)) { + self.controlsVisible = false + } + } + } +} diff --git a/ios/StillPointApp/ViewModels/ThoughtJournalViewModel.swift b/ios/StillPointApp/ViewModels/ThoughtJournalViewModel.swift new file mode 100644 index 00000000..5a27cb8d --- /dev/null +++ b/ios/StillPointApp/ViewModels/ThoughtJournalViewModel.swift @@ -0,0 +1,34 @@ +import SwiftUI +import StillPointShared + +@MainActor +@Observable +final class ThoughtJournalViewModel { + var thoughts: [ThoughtDTO] = [] + var isLoading = false + var errorMessage: String? + + /// Thoughts grouped by day number, sorted descending + var groupedThoughts: [(dayNumber: Int, thoughts: [ThoughtDTO])] { + let grouped = Dictionary(grouping: thoughts, by: { $0.dayNumber }) + return grouped + .sorted { $0.key > $1.key } + .map { (dayNumber: $0.key, thoughts: $0.value.sorted { $0.timeInSession < $1.timeInSession }) } + } + + var totalCount: Int { thoughts.count } + + func load() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + thoughts = try await APIClient.shared.getThoughts() + } catch { + errorMessage = "Failed to load thoughts. Check your connection." + print("Failed to load thoughts: \(error)") + } + } +} diff --git a/ios/StillPointApp/Views/AuthView.swift b/ios/StillPointApp/Views/AuthView.swift new file mode 100644 index 00000000..ca7795f9 --- /dev/null +++ b/ios/StillPointApp/Views/AuthView.swift @@ -0,0 +1,120 @@ +import SwiftUI +import StillPointShared + +struct AuthView: View { + let appVM: AppViewModel + @State private var vm = AuthViewModel() + + var body: some View { + ScrollView { + VStack(spacing: SPSpacing.s6) { + // Brand lockup + VStack(spacing: SPSpacing.s2) { + Text("Still Point") + .font(SPFont.brandTitle) + .foregroundStyle(Color(SPColor.fg)) + + Text("ATTENTION TRAINING") + .font(SPFont.brandSubtitle) + .foregroundStyle(Color(SPColor.fg3)) + .tracking(4) + } + .padding(.top, 60) + + // Login / Sign Up toggle + HStack(spacing: 0) { + toggleButton("Log In", isSelected: !vm.isSignUp) { + withAnimation { vm.isSignUp = false } + } + toggleButton("Sign Up", isSelected: vm.isSignUp) { + withAnimation { vm.isSignUp = true } + } + } + .background(SPColor.surface1) + .clipShape(Capsule()) + .overlay(Capsule().stroke(SPColor.border1)) + + // Form + VStack(spacing: SPSpacing.s3) { + styledField("Email", text: $vm.email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + + if vm.isSignUp { + styledField("Username", text: $vm.username) + .textContentType(.username) + .textInputAutocapitalization(.never) + } + + SecureField("Password", text: $vm.password) + .textContentType(vm.isSignUp ? .newPassword : .password) + .font(SPFont.serif(17)) + .padding(.horizontal, SPSpacing.s3) + .padding(.vertical, SPSpacing.s2) + .background(SPColor.surface1) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SPColor.border2) + ) + .foregroundStyle(Color(SPColor.fg)) + + if let error = vm.error { + Text(error) + .font(SPFont.mono(13)) + .foregroundStyle(SPColor.danger) + } + + Button { + Task { + if let user = await vm.submit() { + appVM.didLogin(user: user) + } + } + } label: { + Text(vm.isSignUp ? "Begin the journey" : "Enter") + .font(SPFont.serifItalic(18, weight: .light)) + .foregroundStyle(Color(SPColor.fg)) + .frame(maxWidth: .infinity) + .padding(.vertical, SPSpacing.s2) + .background(SPColor.surface2) + .clipShape(Capsule()) + .overlay(Capsule().stroke(SPColor.border2)) + } + .disabled(!vm.isValid || vm.isSubmitting) + .opacity(vm.isValid ? 1 : 0.5) + } + .padding(.horizontal, SPSpacing.s4) + } + .padding(.bottom, SPSpacing.s6) + } + .stillPointBackground() + } + + private func toggleButton(_ title: String, isSelected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(SPFont.mono(13, weight: .medium)) + .foregroundStyle(Color(isSelected ? SPColor.fg : SPColor.fg4)) + .padding(.horizontal, SPSpacing.s4) + .padding(.vertical, SPSpacing.s2) + .background(isSelected ? SPColor.surface3 : .clear) + .clipShape(Capsule()) + } + } + + private func styledField(_ placeholder: String, text: Binding) -> some View { + TextField(placeholder, text: text) + .font(SPFont.serif(17)) + .padding(.horizontal, SPSpacing.s3) + .padding(.vertical, SPSpacing.s2) + .background(SPColor.surface1) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SPColor.border2) + ) + .foregroundStyle(Color(SPColor.fg)) + } +} diff --git a/ios/StillPointApp/Views/CompletionView.swift b/ios/StillPointApp/Views/CompletionView.swift new file mode 100644 index 00000000..bcce6695 --- /dev/null +++ b/ios/StillPointApp/Views/CompletionView.swift @@ -0,0 +1,188 @@ +import SwiftUI +import StillPointShared + +struct CompletionView: View { + let appVM: AppViewModel + let clearPercent: Int + let thoughtCount: Int + let thoughts: [CapturedThought] + let dayNumber: Int + let duration: Int + + @State private var endNote = "" + @State private var noteSaved = false + + private var nextDay: Int { dayNumber + 1 } + private var nextDuration: Int { StillPoint.duration(forDay: nextDay) } + private var nextBlocks: Int { StillPoint.blockCount(forDuration: nextDuration) } + + var body: some View { + ScrollView { + VStack(spacing: SPSpacing.s5) { + Spacer().frame(height: SPSpacing.s4) + + // Header + VStack(spacing: SPSpacing.s2) { + Text("Day \(dayNumber) Complete") + .font(SPFont.serifItalic(32, weight: .light)) + .foregroundStyle(Color(SPColor.fg)) + + Text("\(duration) seconds of sustained attention") + .font(SPFont.mono(13)) + .foregroundStyle(Color(SPColor.fg3)) + .tracking(1) + } + + // Stats cards + HStack(spacing: SPSpacing.s3) { + statCard( + value: "\(clearPercent)%", + label: "CLEAR MIND", + color: SPColor.green, + bgColor: SPColor.greenBgFaint, + borderColor: SPColor.greenBorderSubtle + ) + + statCard( + value: "\(thoughtCount)", + label: "INTERRUPTIONS", + color: SPColor.amber, + bgColor: SPColor.amberBgFaint, + borderColor: SPColor.amberBorderSubtle + ) + } + + // Captured thoughts + if !thoughts.isEmpty { + VStack(alignment: .leading, spacing: SPSpacing.s2) { + Text("THOUGHTS CAPTURED") + .font(SPFont.mono(11, weight: .medium)) + .foregroundStyle(Color(SPColor.fg4)) + .tracking(2) + + ForEach(thoughts) { thought in + HStack(alignment: .top, spacing: SPSpacing.s2) { + Text("@\(thought.timeInSession)s") + .font(SPFont.mono(11)) + .foregroundStyle(SPColor.amberText) + .frame(width: 50, alignment: .trailing) + + Text(thought.text) + .font(SPFont.serifItalic(15)) + .foregroundStyle(Color(SPColor.fg2)) + } + } + } + .padding(SPSpacing.s3) + .background(SPColor.amberBgFaint) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SPColor.amberBorderSubtle) + ) + } + + // End-of-session note + VStack(alignment: .leading, spacing: SPSpacing.s2) { + Text("SESSION NOTE") + .font(SPFont.mono(11, weight: .medium)) + .foregroundStyle(Color(SPColor.fg4)) + .tracking(2) + + TextEditor(text: $endNote) + .font(SPFont.serifItalic(15)) + .foregroundStyle(Color(SPColor.fg)) + .scrollContentBackground(.hidden) + .frame(minHeight: 80) + .padding(SPSpacing.s2) + .background(SPColor.surface1) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(SPColor.border2) + ) + + if !endNote.isEmpty && !noteSaved { + Button { + saveEndNote() + } label: { + Text("Save note") + .font(SPFont.mono(12, weight: .medium)) + .foregroundStyle(SPColor.green) + } + } + + if noteSaved { + Text("saved") + .font(SPFont.mono(11)) + .foregroundStyle(SPColor.greenDim) + } + } + + // Tomorrow preview + VStack(spacing: SPSpacing.s1) { + Text("TOMORROW") + .font(SPFont.mono(11, weight: .medium)) + .foregroundStyle(Color(SPColor.fg4)) + .tracking(2) + + Text("\(nextDuration)s · \(nextBlocks) blocks") + .font(SPFont.mono(14, weight: .light)) + .foregroundStyle(Color(SPColor.fg3)) + } + + // Return button + Button { + Task { await appVM.returnHome() } + } label: { + Text("Return") + .font(SPFont.serifItalic(18, weight: .light)) + .foregroundStyle(Color(SPColor.fg)) + .frame(maxWidth: .infinity) + .padding(.vertical, SPSpacing.s2) + .background(SPColor.surface2) + .clipShape(Capsule()) + .overlay(Capsule().stroke(SPColor.border2)) + } + + Spacer().frame(height: SPSpacing.s4) + } + .padding(.horizontal, SPSpacing.s4) + } + .stillPointBackground() + } + + private func statCard( + value: String, + label: String, + color: Color, + bgColor: Color, + borderColor: Color + ) -> some View { + VStack(spacing: SPSpacing.s1) { + Text(value) + .font(SPFont.statValue) + .foregroundStyle(color) + Text(label) + .font(SPFont.statLabel) + .foregroundStyle(color.opacity(0.6)) + .tracking(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, SPSpacing.s3) + .background(bgColor) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(borderColor) + ) + } + + private func saveEndNote() { + guard !endNote.isEmpty else { return } + // TODO: Wire end-of-session note persistence once sessionId is passed into CompletionView. + // Requires calling APIClient.shared.batchThoughts with timeInSession = -1. + // For now, mark as saved — the note is captured locally but not synced. + noteSaved = true + } +} diff --git a/ios/StillPointApp/Views/HistoryView.swift b/ios/StillPointApp/Views/HistoryView.swift new file mode 100644 index 00000000..0444c989 --- /dev/null +++ b/ios/StillPointApp/Views/HistoryView.swift @@ -0,0 +1,183 @@ +import SwiftUI +import StillPointShared + +struct HistoryView: View { + let appVM: AppViewModel + @State private var vm = HistoryViewModel() + + var body: some View { + ScrollView { + VStack(spacing: SPSpacing.s5) { + // Welcome header + if let username = appVM.currentUser?.username { + Text("WELCOME, \(username.uppercased())") + .font(SPFont.mono(11, weight: .medium)) + .foregroundStyle(Color(SPColor.fg3)) + .tracking(3) + .padding(.top, SPSpacing.s3) + } + + Text("Progress") + .font(SPFont.serifItalic(28, weight: .light)) + .foregroundStyle(Color(SPColor.fg)) + + if vm.isLoading { + ProgressView() + .tint(SPColor.green) + .padding(.top, SPSpacing.s6) + } else if let errorMessage = vm.errorMessage { + VStack(spacing: SPSpacing.s3) { + Text(errorMessage) + .font(SPFont.serif(15)) + .foregroundStyle(SPColor.dangerMuted) + .multilineTextAlignment(.center) + Button("Retry") { + Task { await vm.load() } + } + .font(SPFont.mono(13, weight: .medium)) + .foregroundStyle(SPColor.green) + } + .padding(.top, SPSpacing.s6) + } else if vm.sessions.isEmpty { + VStack(spacing: SPSpacing.s3) { + Text("No sessions yet") + .font(SPFont.serifItalic(15)) + .foregroundStyle(Color(SPColor.fg4)) + + todayPreview + } + .padding(.top, SPSpacing.s6) + } else { + // Aggregate stats + if let stats = vm.stats { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + ], spacing: SPSpacing.s3) { + statCell(value: "\(stats.streak)", label: "STREAK") + statCell(value: "\(stats.avgClearPercent)%", label: "AVG CLEAR") + statCell(value: String(format: "%.1f", stats.avgThoughtsPerSession), label: "THOUGHTS/SESSION") + statCell(value: String(format: "%.2f", stats.avgThoughtsPerMinute), label: "THOUGHTS/MIN") + } + } + + // Journey + VStack(alignment: .leading, spacing: SPSpacing.s2) { + Text("JOURNEY") + .font(SPFont.mono(11, weight: .medium)) + .foregroundStyle(Color(SPColor.fg4)) + .tracking(2) + + ForEach(vm.sessions, id: \.id) { session in + sessionRow(session) + } + + todayPreview + } + } + + Spacer().frame(height: SPSpacing.s6) + } + .padding(.horizontal, SPSpacing.s4) + } + .stillPointBackground() + .task { + await vm.load() + } + } + + private func statCell(value: String, label: String) -> some View { + VStack(spacing: 4) { + Text(value) + .font(SPFont.mono(18, weight: .light)) + .foregroundStyle(SPColor.green) + Text(label) + .font(SPFont.mono(9, weight: .medium)) + .foregroundStyle(Color(SPColor.fg4)) + .tracking(1) + .multilineTextAlignment(.center) + } + } + + @ViewBuilder + private func sessionRow(_ session: SessionDTO) -> some View { + let isExpanded = vm.expandedDay == session.dayNumber + + VStack(spacing: 0) { + Button { + Task { await vm.toggleDay(session.dayNumber) } + } label: { + HStack(spacing: SPSpacing.s2) { + Text("D\(session.dayNumber)") + .font(SPFont.mono(12, weight: .medium)) + .foregroundStyle(Color(SPColor.fg3)) + .frame(width: 36, alignment: .leading) + + // Stacked bar + GeometryReader { geo in + let clearWidth = geo.size.width * Double(session.clearPercent) / 100.0 + let thinkWidth = geo.size.width - clearWidth + + HStack(spacing: 0) { + Rectangle() + .fill(SPColor.green.opacity(0.6)) + .frame(width: max(0, clearWidth)) + Rectangle() + .fill(SPColor.amber.opacity(0.5)) + .frame(width: max(0, thinkWidth)) + } + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + .frame(height: 16) + + Text("\(session.clearPercent)%") + .font(SPFont.mono(11)) + .foregroundStyle(SPColor.greenText) + .frame(width: 36, alignment: .trailing) + } + .padding(.vertical, SPSpacing.s1) + } + + // Expanded thoughts + if isExpanded, let thoughts = vm.dayThoughts[session.dayNumber] { + VStack(alignment: .leading, spacing: 4) { + ForEach(thoughts, id: \.id) { thought in + HStack(alignment: .top, spacing: SPSpacing.s2) { + Text(thought.timeInSession == -1 ? "note" : "@\(thought.timeInSession)s") + .font(SPFont.mono(10)) + .foregroundStyle(SPColor.amberText) + .frame(width: 44, alignment: .trailing) + + Text(thought.text) + .font(SPFont.serifItalic(13)) + .foregroundStyle(Color(SPColor.fg3)) + } + } + } + .padding(.leading, 44) + .padding(.vertical, SPSpacing.s1) + } + } + } + + private var todayPreview: some View { + HStack(spacing: SPSpacing.s2) { + Text("D\(appVM.currentDay)") + .font(SPFont.mono(12, weight: .medium)) + .foregroundStyle(Color(SPColor.fg4)) + .frame(width: 36, alignment: .leading) + + RoundedRectangle(cornerRadius: 3) + .stroke(SPColor.border2, style: StrokeStyle(lineWidth: 1, dash: [4])) + .frame(height: 16) + + Text("\(appVM.todayDuration)s") + .font(SPFont.mono(11)) + .foregroundStyle(Color(SPColor.fg4)) + .frame(width: 36, alignment: .trailing) + } + .padding(.vertical, SPSpacing.s1) + } +} diff --git a/ios/StillPointApp/Views/HomeView.swift b/ios/StillPointApp/Views/HomeView.swift new file mode 100644 index 00000000..2e59035a --- /dev/null +++ b/ios/StillPointApp/Views/HomeView.swift @@ -0,0 +1,103 @@ +import SwiftUI +import StillPointShared + +struct HomeView: View { + let appVM: AppViewModel + + @State private var breatheAnimation = false + + var body: some View { + ScrollView { + VStack(spacing: SPSpacing.s5) { + // Welcome header + if let username = appVM.currentUser?.username { + Text("WELCOME, \(username.uppercased())") + .font(SPFont.mono(11, weight: .medium)) + .foregroundStyle(Color(SPColor.fg3)) + .tracking(3) + .padding(.top, SPSpacing.s4) + } + + Spacer().frame(height: SPSpacing.s4) + + // Day number with breathing animation + Text("\(appVM.currentDay)") + .font(SPFont.dayNumber) + .foregroundStyle(Color(SPColor.fg)) + .scaleEffect(breatheAnimation ? 1.02 : 1.0) + .opacity(breatheAnimation ? 1.0 : 0.6) + .animation( + .easeInOut(duration: 4).repeatForever(autoreverses: true), + value: breatheAnimation + ) + .onAppear { breatheAnimation = true } + + // Day info + Text("day · \(appVM.todayDuration)s · \(appVM.todayBlockCount) blocks") + .font(SPFont.mono(14, weight: .light)) + .foregroundStyle(Color(SPColor.fg3)) + .tracking(2) + + Spacer().frame(height: SPSpacing.s4) + + // Begin button + Button { + withAnimation { + appVM.beginSession() + } + } label: { + Text("Begin") + .font(SPFont.serifItalic(22, weight: .light)) + .foregroundStyle(Color(SPColor.fg)) + .padding(.horizontal, 48) + .padding(.vertical, SPSpacing.s3) + .background(SPColor.surface2) + .clipShape(Capsule()) + .overlay(Capsule().stroke(SPColor.border2)) + } + + Spacer().frame(height: SPSpacing.s6) + + // FAQ section + Divider() + .background(SPColor.border1) + + faqSection + } + .padding(.horizontal, SPSpacing.s4) + } + .stillPointBackground() + } + + private var faqSection: some View { + VStack(alignment: .leading, spacing: SPSpacing.s4) { + faqItem( + q: "What do I do?", + a: "Watch the timer count down. That's it. When you notice you're thinking, tap the button. You can capture the thought if you like. Then go back to watching." + ) + faqItem( + q: "How long are sessions?", + a: "Day 1 starts at 60 seconds. Each day adds 10 seconds. The duration grows with your practice." + ) + faqItem( + q: "What are the blocks?", + a: "Visual markers of time passing. Short sessions use 10-second blocks. Longer sessions use minute blocks with a final minute of 10-second blocks." + ) + faqItem( + q: "This app is incredibly boring. What's the point?", + a: "That is the point." + ) + } + } + + private func faqItem(q: String, a: String) -> some View { + VStack(alignment: .leading, spacing: SPSpacing.s1) { + Text(q) + .font(SPFont.serifItalic(16, weight: .medium)) + .foregroundStyle(Color(SPColor.fg2)) + Text(a) + .font(SPFont.serif(15, weight: .light)) + .foregroundStyle(Color(SPColor.fg3)) + } + } +} diff --git a/ios/StillPointApp/Views/PublicBoardView.swift b/ios/StillPointApp/Views/PublicBoardView.swift new file mode 100644 index 00000000..cccb7b64 --- /dev/null +++ b/ios/StillPointApp/Views/PublicBoardView.swift @@ -0,0 +1,115 @@ +import SwiftUI +import StillPointShared + +struct PublicBoardView: View { + let currentUsername: String? + @State private var vm = BoardViewModel() + + var body: some View { + ScrollView { + VStack(spacing: SPSpacing.s5) { + Text("Practitioners") + .font(SPFont.serifItalic(28, weight: .light)) + .foregroundStyle(Color(SPColor.fg)) + .padding(.top, SPSpacing.s4) + + Text("Not a competition — a community of people training attention together.") + .font(SPFont.serifItalic(15, weight: .light)) + .foregroundStyle(Color(SPColor.fg4)) + .multilineTextAlignment(.center) + .padding(.horizontal, SPSpacing.s3) + + // Board table + if !vm.entries.isEmpty { + VStack(spacing: 0) { + // Header + boardRow( + rank: "#", + username: "USERNAME", + day: "DAY", + streak: "STREAK", + clear: "CLEAR", + isHeader: true, + isCurrentUser: false + ) + + ForEach(Array(vm.entries.enumerated()), id: \.element.username) { index, entry in + boardRow( + rank: "\(index + 1)", + username: entry.username, + day: "\(entry.currentDay)", + streak: "\(entry.streak)", + clear: "\(entry.avgClear)%", + isHeader: false, + isCurrentUser: entry.username == currentUsername, + isTopThree: index < 3 + ) + } + } + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SPColor.border1) + ) + } + + if vm.entries.isEmpty && !vm.isLoading { + Text("No public practitioners yet") + .font(SPFont.serifItalic(15)) + .foregroundStyle(Color(SPColor.fg4)) + .padding(.top, SPSpacing.s6) + } + + Spacer().frame(height: SPSpacing.s6) + } + .padding(.horizontal, SPSpacing.s4) + } + .stillPointBackground() + .task { + await vm.load() + } + } + + private func boardRow( + rank: String, + username: String, + day: String, + streak: String, + clear: String, + isHeader: Bool, + isCurrentUser: Bool, + isTopThree: Bool = false + ) -> some View { + HStack(spacing: 0) { + Text(rank) + .frame(width: 32, alignment: .center) + .foregroundStyle(isTopThree && !isHeader ? SPColor.amber : Color(SPColor.fg3)) + Text(isCurrentUser ? "\(username) (you)" : username) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + .truncationMode(.tail) + Text(day) + .frame(width: 44, alignment: .center) + Text(streak) + .frame(width: 50, alignment: .center) + Text(clear) + .frame(width: 50, alignment: .center) + .foregroundStyle(isHeader ? Color(SPColor.fg4) : SPColor.greenText) + } + .font(isHeader ? SPFont.mono(10, weight: .medium) : SPFont.mono(12)) + .foregroundStyle(isHeader ? Color(SPColor.fg4) : Color(SPColor.fg2)) + .padding(.vertical, isHeader ? SPSpacing.s1 : SPSpacing.s2) + .padding(.horizontal, SPSpacing.s2) + .background( + isCurrentUser ? SPColor.greenBgFaint : + isHeader ? SPColor.surface2 : + Color.clear + ) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundStyle(SPColor.border1), + alignment: .bottom + ) + } +} diff --git a/ios/StillPointApp/Views/RootView.swift b/ios/StillPointApp/Views/RootView.swift new file mode 100644 index 00000000..02903957 --- /dev/null +++ b/ios/StillPointApp/Views/RootView.swift @@ -0,0 +1,54 @@ +import SwiftUI +import StillPointShared + +struct RootView: View { + @State private var appVM = AppViewModel() + + var body: some View { + ZStack { + SPColor.bg.ignoresSafeArea() + + if appVM.isLoading { + // Loading state — brand lockup + VStack(spacing: SPSpacing.s2) { + Text("Still Point") + .font(SPFont.brandTitle) + .foregroundStyle(Color(SPColor.fg)) + Text("ATTENTION TRAINING") + .font(SPFont.brandSubtitle) + .foregroundStyle(Color(SPColor.fg3)) + .tracking(4) + } + } else { + switch appVM.currentView { + case .auth: + AuthView(appVM: appVM) + .transition(.opacity) + + case .session: + SessionView(appVM: appVM) + .transition(.opacity) + + case .completion(let clearPercent, let thoughtCount, let thoughts, let dayNumber, let duration): + CompletionView( + appVM: appVM, + clearPercent: clearPercent, + thoughtCount: thoughtCount, + thoughts: thoughts, + dayNumber: dayNumber, + duration: duration + ) + .transition(.opacity) + + default: + MainTabView(appVM: appVM) + .transition(.opacity) + } + } + } + .animation(.easeInOut(duration: 0.3), value: appVM.currentView) + .task { + await appVM.checkAuth() + } + } +} diff --git a/ios/StillPointApp/Views/SessionView.swift b/ios/StillPointApp/Views/SessionView.swift new file mode 100644 index 00000000..6d8ff408 --- /dev/null +++ b/ios/StillPointApp/Views/SessionView.swift @@ -0,0 +1,270 @@ +import SwiftUI +import StillPointShared + +struct SessionView: View { + let appVM: AppViewModel + @State private var vm: SessionViewModel + + init(appVM: AppViewModel) { + self.appVM = appVM + self._vm = State(initialValue: SessionViewModel(dayNumber: appVM.currentDay)) + } + + var body: some View { + ZStack { + SPColor.bg.ignoresSafeArea() + + ScrollView { + VStack(spacing: SPSpacing.s5) { + Spacer().frame(height: SPSpacing.s3) + + // Timer display + Text(vm.timeString) + .font(SPFont.timerDisplay) + .foregroundStyle( + vm.isComplete ? SPColor.green : Color(SPColor.fg) + ) + .monospacedDigit() + .contentTransition(.numericText()) + + // 60-second progress bar + progressBar + + // Block grid + BlockGridView( + blocks: vm.blocks, + elapsed: vm.elapsed, + totalSeconds: vm.totalSeconds + ) + .padding(.horizontal, SPSpacing.s2) + + // Mind state bar + MindStateBarView( + elapsed: vm.elapsed, + totalSeconds: vm.totalSeconds, + mindStateLog: vm.mindStateLog + ) + + // Status label + Text(vm.statusLabel.uppercased()) + .font(SPFont.mono(14)) + .foregroundStyle(Color(SPColor.fg3)) + .tracking(2) + + Spacer().frame(height: SPSpacing.s3) + } + } + .scrollIndicators(.hidden) + + // Controls overlay (auto-hide) + VStack { + Spacer() + if vm.controlsVisible || !vm.isActive { + controlPanel + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.3), value: vm.controlsVisible) + + // Thought capture overlay + if vm.showThoughtCapture { + VStack { + Spacer() + ThoughtCaptureView( + onCapture: { text in + vm.captureThought(text) + }, + onDismiss: { + vm.dismissThoughtCapture() + } + ) + .padding(.horizontal, SPSpacing.s4) + .padding(.bottom, 160) + } + .transition(.opacity) + } + } + .onTapGesture { + vm.userInteracted() + } + .onAppear { + vm.start() + } + .onChange(of: vm.isComplete) { _, isComplete in + if isComplete && !vm.isAbandoned { + handleCompletion() + } + } + } + + // MARK: - Progress Bar + + private var progressBar: some View { + GeometryReader { geo in + let width = geo.size.width + let progress: Double = { + if vm.elapsed >= Double(vm.totalSeconds) { return 1.0 } + return vm.elapsed.truncatingRemainder(dividingBy: 60) / 60.0 + }() + + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(SPColor.surface2) + + RoundedRectangle(cornerRadius: 4) + .fill( + vm.elapsed >= Double(vm.totalSeconds) + ? LinearGradient.greenHorizontal + : LinearGradient.amberHorizontal + ) + .frame(width: width * progress) + .opacity(0.7) + } + } + .frame(height: 8) + .padding(.horizontal, SPSpacing.s5) + } + + // MARK: - Control Panel + + private var controlPanel: some View { + VStack(spacing: SPSpacing.s3) { + // Mind state toggle + Button { + vm.toggleMindState() + } label: { + HStack(spacing: SPSpacing.s2) { + Circle() + .fill(vm.mindState == "clear" ? SPColor.green : SPColor.amber) + .frame(width: 8, height: 8) + + Text(vm.mindState == "clear" ? "I'm thinking" : "Clear mind") + .font(SPFont.serifItalic(17)) + .foregroundStyle(Color(SPColor.fg)) + + if vm.thoughtCount > 0 { + Text("\(vm.thoughtCount)") + .font(SPFont.mono(11, weight: .medium)) + .foregroundStyle(SPColor.amberText) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(SPColor.amberBgFaint) + .clipShape(Capsule()) + } + } + .padding(.horizontal, SPSpacing.s4) + .padding(.vertical, SPSpacing.s2) + .background( + vm.mindState == "clear" + ? SPColor.greenBgFaint + : SPColor.amberBgFaint + ) + .clipShape(Capsule()) + .overlay( + Capsule().stroke( + vm.mindState == "clear" + ? SPColor.greenBorderSubtle + : SPColor.amberBorderSubtle + ) + ) + } + + // Action buttons + HStack(spacing: SPSpacing.s3) { + // Pause / Resume + Button { + if vm.isPaused { + vm.resume() + } else { + vm.pause() + } + } label: { + Text(vm.isPaused ? "Resume" : "Pause") + .font(SPFont.mono(12, weight: .medium)) + .foregroundStyle(Color(SPColor.fg3)) + .padding(.horizontal, SPSpacing.s3) + .padding(.vertical, SPSpacing.s1) + .background(SPColor.surface1) + .clipShape(Capsule()) + .overlay(Capsule().stroke(SPColor.border1)) + } + + // End Early — sets isComplete, onChange handles save + navigation + Button { + _ = vm.endEarly() + } label: { + Text("End Early") + .font(SPFont.mono(12, weight: .medium)) + .foregroundStyle(Color(SPColor.fg3)) + .padding(.horizontal, SPSpacing.s3) + .padding(.vertical, SPSpacing.s1) + .background(SPColor.surface1) + .clipShape(Capsule()) + .overlay(Capsule().stroke(SPColor.border1)) + } + + // Abandon + Button { + vm.abandon() + Task { await appVM.returnHome() } + } label: { + Text("Abandon") + .font(SPFont.mono(12, weight: .medium)) + .foregroundStyle(SPColor.dangerMuted) + .padding(.horizontal, SPSpacing.s3) + .padding(.vertical, SPSpacing.s1) + .background(SPColor.surface1) + .clipShape(Capsule()) + .overlay(Capsule().stroke(SPColor.dangerBorderSubtle)) + } + } + + // Sound toggles + HStack(spacing: SPSpacing.s3) { + soundToggle("tick", isOn: vm.soundPrefs.tick) { + vm.toggleSound(\.tick) + } + soundToggle("chime", isOn: vm.soundPrefs.chime) { + vm.toggleSound(\.chime) + } + soundToggle("end", isOn: vm.soundPrefs.completion) { + vm.toggleSound(\.completion) + } + } + } + .padding(.horizontal, SPSpacing.s4) + .padding(.vertical, SPSpacing.s3) + .background( + SPColor.bg.opacity(0.9) + .background(.ultraThinMaterial) + ) + } + + private func soundToggle(_ label: String, isOn: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: isOn ? "speaker.wave.2.fill" : "speaker.slash.fill") + .font(.system(size: 10)) + Text(label) + .font(SPFont.mono(10)) + } + .foregroundStyle(isOn ? Color(SPColor.fg3) : Color(SPColor.fg4)) + } + } + + // MARK: - Completion Handler + + private func handleCompletion() { + Task { + // Persist session before navigating to completion screen + _ = await vm.saveSession(completed: vm.completedNaturally) + appVM.completeSession( + clearPercent: vm.clearPercent, + thoughtCount: vm.thoughtCount, + thoughts: vm.capturedThoughts, + dayNumber: vm.dayNumber, + duration: vm.totalSeconds + ) + } + } +} diff --git a/ios/StillPointApp/Views/SettingsView.swift b/ios/StillPointApp/Views/SettingsView.swift new file mode 100644 index 00000000..12066f7b --- /dev/null +++ b/ios/StillPointApp/Views/SettingsView.swift @@ -0,0 +1,121 @@ +import SwiftUI +import StillPointShared + +struct SettingsView: View { + let appVM: AppViewModel + @State private var isPublic: Bool = false + @State private var isUpdating = false + + var body: some View { + ScrollView { + VStack(spacing: SPSpacing.s5) { + Text("Settings") + .font(SPFont.serifItalic(28, weight: .light)) + .foregroundStyle(Color(SPColor.fg)) + .padding(.top, SPSpacing.s4) + + // Account info + VStack(alignment: .leading, spacing: SPSpacing.s2) { + Text("ACCOUNT") + .font(SPFont.mono(11, weight: .medium)) + .foregroundStyle(Color(SPColor.fg4)) + .tracking(2) + + if let user = appVM.currentUser { + HStack { + Text("Username") + .font(SPFont.mono(13)) + .foregroundStyle(Color(SPColor.fg3)) + Spacer() + Text(user.username) + .font(SPFont.mono(13)) + .foregroundStyle(Color(SPColor.fg)) + } + + HStack { + Text("Email") + .font(SPFont.mono(13)) + .foregroundStyle(Color(SPColor.fg3)) + Spacer() + Text(user.email) + .font(SPFont.mono(13)) + .foregroundStyle(Color(SPColor.fg)) + } + } + } + .padding(SPSpacing.s3) + .background(SPColor.surface1) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SPColor.border1) + ) + + // Public board toggle + VStack(alignment: .leading, spacing: SPSpacing.s2) { + Text("VISIBILITY") + .font(SPFont.mono(11, weight: .medium)) + .foregroundStyle(Color(SPColor.fg4)) + .tracking(2) + + Toggle(isOn: $isPublic) { + VStack(alignment: .leading, spacing: 2) { + Text("Public Board") + .font(SPFont.mono(13)) + .foregroundStyle(Color(SPColor.fg)) + Text("Show your progress on the practitioners board") + .font(SPFont.serif(13, weight: .light)) + .foregroundStyle(Color(SPColor.fg4)) + } + } + .tint(SPColor.green) + .disabled(isUpdating) + .onChange(of: isPublic) { _, newValue in + guard !isUpdating else { return } + Task { + isUpdating = true + defer { isUpdating = false } + do { + _ = try await APIClient.shared.updateSettings(isPublic: newValue) + } catch { + // Revert on failure + isPublic = !newValue + } + } + } + } + .padding(SPSpacing.s3) + .background(SPColor.surface1) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SPColor.border1) + ) + + // Logout + Button { + Task { + try? await APIClient.shared.logout() + appVM.didLogout() + } + } label: { + Text("Log Out") + .font(SPFont.mono(14, weight: .medium)) + .foregroundStyle(SPColor.dangerMuted) + .frame(maxWidth: .infinity) + .padding(.vertical, SPSpacing.s2) + .background(SPColor.surface1) + .clipShape(Capsule()) + .overlay(Capsule().stroke(SPColor.dangerBorderSubtle)) + } + + Spacer().frame(height: SPSpacing.s6) + } + .padding(.horizontal, SPSpacing.s4) + } + .stillPointBackground() + .onAppear { + isPublic = appVM.currentUser?.isPublic ?? false + } + } +} diff --git a/ios/StillPointApp/Views/ThoughtJournalView.swift b/ios/StillPointApp/Views/ThoughtJournalView.swift new file mode 100644 index 00000000..fb9ee5bb --- /dev/null +++ b/ios/StillPointApp/Views/ThoughtJournalView.swift @@ -0,0 +1,74 @@ +import SwiftUI +import StillPointShared + +struct ThoughtJournalView: View { + @State private var vm = ThoughtJournalViewModel() + + var body: some View { + ScrollView { + VStack(spacing: SPSpacing.s5) { + Text("Thought Journal") + .font(SPFont.serifItalic(28, weight: .light)) + .foregroundStyle(Color(SPColor.fg)) + .padding(.top, SPSpacing.s4) + + if vm.totalCount > 0 { + Text("\(vm.totalCount) thoughts captured") + .font(SPFont.mono(13)) + .foregroundStyle(Color(SPColor.fg3)) + } + + // Reflective prompt + Text("Every thought that felt urgent in the moment. Looking back — how many actually needed your attention right then?") + .font(SPFont.serifItalic(15, weight: .light)) + .foregroundStyle(Color(SPColor.fg4)) + .multilineTextAlignment(.center) + .padding(.horizontal, SPSpacing.s3) + + // Grouped thoughts + ForEach(vm.groupedThoughts, id: \.dayNumber) { group in + VStack(alignment: .leading, spacing: SPSpacing.s2) { + Text("DAY \(group.dayNumber)") + .font(SPFont.mono(11, weight: .medium)) + .foregroundStyle(Color(SPColor.fg4)) + .tracking(2) + + ForEach(group.thoughts, id: \.id) { thought in + HStack(alignment: .top, spacing: SPSpacing.s2) { + Text(thought.timeInSession == -1 ? "note" : "@\(thought.timeInSession)s") + .font(SPFont.mono(11)) + .foregroundStyle(SPColor.amberText) + .frame(width: 44, alignment: .trailing) + + Text(thought.text) + .font(SPFont.serifItalic(15)) + .foregroundStyle(Color(SPColor.fg2)) + } + } + } + .padding(SPSpacing.s3) + .background(SPColor.surface1) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(SPColor.border1) + ) + } + + if vm.groupedThoughts.isEmpty && !vm.isLoading { + Text("No thoughts captured yet") + .font(SPFont.serifItalic(15)) + .foregroundStyle(Color(SPColor.fg4)) + .padding(.top, SPSpacing.s6) + } + + Spacer().frame(height: SPSpacing.s6) + } + .padding(.horizontal, SPSpacing.s4) + } + .stillPointBackground() + .task { + await vm.load() + } + } +} diff --git a/ios/StillPointShared/Package.swift b/ios/StillPointShared/Package.swift new file mode 100644 index 00000000..2189ceed --- /dev/null +++ b/ios/StillPointShared/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StillPointShared", + platforms: [ + .iOS(.v17), + .watchOS(.v10), + ], + products: [ + .library(name: "StillPointShared", targets: ["StillPointShared"]), + ], + targets: [ + .target(name: "StillPointShared"), + ] +) diff --git a/ios/StillPointShared/Sources/StillPointShared/APIClient.swift b/ios/StillPointShared/Sources/StillPointShared/APIClient.swift new file mode 100644 index 00000000..b778f1d1 --- /dev/null +++ b/ios/StillPointShared/Sources/StillPointShared/APIClient.swift @@ -0,0 +1,161 @@ +import Foundation + +/// Network client for the Still Point web API. +/// Uses cookie-based auth (sp_token) matching the web app. +public actor APIClient { + public static let shared = APIClient() + + // Default to the deployed web app; override for local dev + private var baseURL: URL + + private let session: URLSession + + private init() { + #if DEBUG + self.baseURL = URL(string: "https://still-point.vercel.app")! + #else + self.baseURL = URL(string: "https://still-point.vercel.app")! + #endif + + let config = URLSessionConfiguration.default + config.httpCookieAcceptPolicy = .always + config.httpShouldSetCookies = true + config.httpCookieStorage = .shared + self.session = URLSession(configuration: config) + } + + public func setBaseURL(_ url: URL) { + self.baseURL = url + } + + // MARK: - Auth + + public func signup(email: String, username: String, password: String) async throws -> UserDTO { + let body: [String: String] = ["email": email, "username": username, "password": password] + let response: UserResponse = try await post("/api/auth/signup", body: body) + return response.user + } + + public func login(email: String, password: String) async throws -> UserDTO { + // Web app sends username too but only uses email+password for login + let body: [String: String] = ["email": email, "username": "", "password": password] + let response: UserResponse = try await post("/api/auth/login", body: body) + return response.user + } + + public func logout() async throws { + let _: [String: Bool] = try await post("/api/auth/logout", body: Optional.none) + } + + public func me() async throws -> UserDTO? { + do { + let response: UserResponse = try await get("/api/auth/me") + return response.user + } catch let error as APIError where error.status == 401 { + return nil + } + } + + // MARK: - Sessions + + public func getSessions() async throws -> (sessions: [SessionDTO], stats: StatsDTO) { + let response: SessionsResponse = try await get("/api/sessions") + return (response.sessions, response.stats) + } + + public func createSession(_ data: CreateSessionRequest) async throws -> SessionDTO { + let response: SessionResponse = try await post("/api/sessions", body: data) + return response.session + } + + public func getSession(dayNumber: Int) async throws -> (session: SessionDTO, thoughts: [ThoughtDTO]) { + let response: SessionDetailResponse = try await get("/api/sessions/\(dayNumber)") + return (response.session, response.thoughts) + } + + // MARK: - Thoughts + + public func getThoughts() async throws -> [ThoughtDTO] { + let response: ThoughtsResponse = try await get("/api/thoughts") + return response.thoughts + } + + public func batchThoughts(_ data: BatchThoughtsRequest) async throws -> [ThoughtDTO] { + let response: ThoughtsResponse = try await post("/api/thoughts/batch", body: data) + return response.thoughts + } + + // MARK: - Board + + public func getBoard() async throws -> [BoardEntryDTO] { + let response: BoardResponse = try await get("/api/board") + return response.board + } + + // MARK: - Settings + + public func updateSettings(isPublic: Bool) async throws -> UserDTO { + let body = ["isPublic": isPublic] + let response: UserResponse = try await patch("/api/settings", body: body) + return response.user + } + + // MARK: - HTTP Helpers + + private func get(_ path: String) async throws -> T { + let url = baseURL.appendingPathComponent(path) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return try await execute(request) + } + + private func post(_ path: String, body: B?) async throws -> T { + let url = baseURL.appendingPathComponent(path) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let body { + request.httpBody = try JSONEncoder().encode(body) + } + return try await execute(request) + } + + private func patch(_ path: String, body: B) async throws -> T { + let url = baseURL.appendingPathComponent(path) + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + return try await execute(request) + } + + private func execute(_ request: URLRequest) async throws -> T { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError(status: 0, message: "Invalid response") + } + guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { + let errorBody = try? JSONDecoder().decode(ErrorResponse.self, from: data) + throw APIError( + status: httpResponse.statusCode, + message: errorBody?.error ?? "Request failed" + ) + } + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } +} + +// MARK: - Error Types + +public struct APIError: Error, LocalizedError { + public let status: Int + public let message: String + + public var errorDescription: String? { message } +} + +private struct ErrorResponse: Codable { + let error: String +} diff --git a/ios/StillPointShared/Sources/StillPointShared/AudioEngine.swift b/ios/StillPointShared/Sources/StillPointShared/AudioEngine.swift new file mode 100644 index 00000000..a5309eda --- /dev/null +++ b/ios/StillPointShared/Sources/StillPointShared/AudioEngine.swift @@ -0,0 +1,180 @@ +import AVFoundation +import Foundation + +/// Synthesized audio matching the web app's Web Audio API sounds. +/// All sounds are generated programmatically — no external files needed. +/// Thread-safe: all public methods dispatch to a serial queue. +public final class AudioEngine: @unchecked Sendable { + public static let shared = AudioEngine() + + private let sampleRate: Double = 44100 + private let serialQueue = DispatchQueue(label: "com.stillpoint.audioengine") + + private init() { + configureAudioSession() + } + + private func configureAudioSession() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, options: [.mixWithOthers]) + try session.setActive(true) + } catch { + print("AudioEngine: Failed to configure audio session: \(error)") + } + } + + // MARK: - Tick (800Hz sine, 60ms, gain 0.06 → 0.001) + + public func playTick() { + serialQueue.async { [self] in self._playTick() } + } + + private func _playTick() { + playSynthesized(duration: 0.06) { phase in + let frequency = 800.0 + let t = phase / self.sampleRate + let totalDuration = 0.06 + let progress = min(t / totalDuration, 1.0) + // Exponential ramp from 0.06 to 0.001 + let gain = 0.06 * pow(0.001 / 0.06, progress) + return Float(sin(2.0 * .pi * frequency * t) * gain) + } + } + + // MARK: - Chime (1200→800Hz sine, repeated `count` times, 400ms spacing) + + public func playChime(count: Int) { + serialQueue.async { [self] in self._playChime(count: count) } + } + + private func _playChime(count: Int) { + let totalDuration = Double(count) * 0.4 + 0.1 + playSynthesized(duration: totalDuration) { phase in + let t = phase / self.sampleRate + var sample: Float = 0 + + for i in 0..= 0 && localT < 0.5 else { continue } + + // Frequency: exponential ramp 1200 → 800 over 300ms + let freqProgress = min(localT / 0.3, 1.0) + let freq = 1200.0 * pow(800.0 / 1200.0, freqProgress) + + // Gain: exponential ramp 0.15 → 0.001 over 500ms + let gainProgress = min(localT / 0.5, 1.0) + let gain = 0.15 * pow(0.001 / 0.15, gainProgress) + + sample += Float(sin(2.0 * .pi * freq * localT) * gain) + } + + return sample + } + } + + // MARK: - Completion (528Hz + 660Hz, gain 0.2 hold 800ms then ramp to 0.001 at 2.5s) + + public func playCompletion() { + serialQueue.async { [self] in self._playCompletion() } + } + + private func _playCompletion() { + let totalDuration = 2.5 + playSynthesized(duration: totalDuration) { phase in + let t = phase / self.sampleRate + var sample: Float = 0 + + for freq in [528.0, 660.0] { + let gain: Double + if t < 0.8 { + gain = 0.2 + } else { + let rampProgress = (t - 0.8) / (2.5 - 0.8) + gain = 0.2 * pow(0.001 / 0.2, min(rampProgress, 1.0)) + } + sample += Float(sin(2.0 * .pi * freq * t) * gain) + } + + return sample + } + } + + // MARK: - Synthesizer Core + + /// Reference-type wrapper so the render callback captures a single mutable box + /// instead of a stack-allocated var (avoids data race between audio thread and main). + private final class PhaseBox { + var value: Double = 0 + } + + private func playSynthesized(duration: Double, generator: @escaping (Double) -> Float) { + let engine = AVAudioEngine() + let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)! + + let totalFrames = Int(duration * sampleRate) + let phase = PhaseBox() + + let sourceNode = AVAudioSourceNode { _, _, frameCount, audioBufferList -> OSStatus in + let ablPointer = UnsafeMutableAudioBufferListPointer(audioBufferList) + let buffer = ablPointer[0] + let frames = Int(frameCount) + guard let data = buffer.mData?.assumingMemoryBound(to: Float.self) else { + return noErr + } + + for frame in 0..= totalFrames { + data[frame] = 0 + } else { + data[frame] = generator(phase.value + Double(frame)) + } + } + + phase.value += Double(frames) + return noErr + } + + engine.attach(sourceNode) + engine.connect(sourceNode, to: engine.mainMixerNode, format: format) + + do { + try engine.start() + } catch { + print("AudioEngine: Failed to start engine: \(error)") + return + } + + // Stop after duration + DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.1) { + engine.stop() + } + } + + // MARK: - Sound Preferences + + public struct SoundPrefs: Codable, Equatable { + public var tick: Bool + public var chime: Bool + public var completion: Bool + + public static let defaults = SoundPrefs(tick: false, chime: true, completion: true) + } + + private static let prefsKey = "stillpoint_sound_prefs" + + public static func loadPrefs() -> SoundPrefs { + guard let data = UserDefaults.standard.data(forKey: prefsKey), + let prefs = try? JSONDecoder().decode(SoundPrefs.self, from: data) else { + return .defaults + } + return prefs + } + + public static func savePrefs(_ prefs: SoundPrefs) { + if let data = try? JSONEncoder().encode(prefs) { + UserDefaults.standard.set(data, forKey: prefsKey) + } + } +} diff --git a/ios/StillPointShared/Sources/StillPointShared/Constants.swift b/ios/StillPointShared/Sources/StillPointShared/Constants.swift new file mode 100644 index 00000000..51c9a721 --- /dev/null +++ b/ios/StillPointShared/Sources/StillPointShared/Constants.swift @@ -0,0 +1,23 @@ +import Foundation + +public enum StillPoint { + /// Base session duration in seconds (Day 1) + public static let baseDuration = 60 + + /// Seconds added per day + public static let increment = 10 + + /// Visual block duration in seconds + public static let blockDuration = 10 + + /// Calculate session duration for a given day number + public static func duration(forDay day: Int) -> Int { + precondition(day >= 1, "Day must be >= 1") + return baseDuration + (day - 1) * increment + } + + /// Calculate number of blocks for a given duration + public static func blockCount(forDuration duration: Int) -> Int { + Int(ceil(Double(duration) / Double(blockDuration))) + } +} diff --git a/ios/StillPointShared/Sources/StillPointShared/DTOs/DTOs.swift b/ios/StillPointShared/Sources/StillPointShared/DTOs/DTOs.swift new file mode 100644 index 00000000..f5f7bace --- /dev/null +++ b/ios/StillPointShared/Sources/StillPointShared/DTOs/DTOs.swift @@ -0,0 +1,124 @@ +import Foundation + +// MARK: - API Response Types (matching web app's api.ts) + +public struct UserDTO: Codable, Sendable { + public let id: String + public let email: String + public let username: String + public let isPublic: Bool + public let currentDay: Int +} + +public struct SessionDTO: Codable, Sendable { + public let id: String + public let dayNumber: Int + public let duration: Int + public let completed: Bool + public let actualTime: Int? + public let clearPercent: Int + public let thoughtCount: Int + public let mindStateLog: [MindStateEntry]? + public let sessionDate: String +} + +public struct ThoughtDTO: Codable, Sendable { + public let id: String + public let sessionId: String + public let dayNumber: Int + public let timeInSession: Int + public let text: String +} + +public struct BoardEntryDTO: Codable, Sendable { + public let username: String + public let currentDay: Int + public let streak: Int + public let avgClear: Int + public let totalSessions: Int +} + +public struct StatsDTO: Codable, Sendable { + public let streak: Int + public let avgClearPercent: Int + public let avgThoughtsPerSession: Double + public let avgThoughtsPerMinute: Double +} + +// MARK: - API Request Types + +public struct CreateSessionRequest: Codable, Sendable { + public let dayNumber: Int + public let duration: Int + public let completed: Bool + public let actualTime: Int + public let clearPercent: Int + public let thoughtCount: Int + public let mindStateLog: [MindStateEntry] + public let sessionDate: String + + public init( + dayNumber: Int, duration: Int, completed: Bool, actualTime: Int, + clearPercent: Int, thoughtCount: Int, mindStateLog: [MindStateEntry], + sessionDate: String + ) { + self.dayNumber = dayNumber + self.duration = duration + self.completed = completed + self.actualTime = actualTime + self.clearPercent = clearPercent + self.thoughtCount = thoughtCount + self.mindStateLog = mindStateLog + self.sessionDate = sessionDate + } +} + +public struct BatchThoughtsRequest: Codable, Sendable { + public let sessionId: String + public let dayNumber: Int + public let thoughts: [ThoughtInput] + + public struct ThoughtInput: Codable, Sendable { + public let timeInSession: Int + public let text: String + + public init(timeInSession: Int, text: String) { + self.timeInSession = timeInSession + self.text = text + } + } + + public init(sessionId: String, dayNumber: Int, thoughts: [ThoughtInput]) { + self.sessionId = sessionId + self.dayNumber = dayNumber + self.thoughts = thoughts + } +} + +// MARK: - API Wrappers (match JSON response shapes) + +public struct UserResponse: Codable, Sendable { + public let user: UserDTO +} + +public struct SessionResponse: Codable, Sendable { + public let session: SessionDTO +} + +public struct SessionsResponse: Codable, Sendable { + public let sessions: [SessionDTO] + public let stats: StatsDTO +} + +public struct SessionDetailResponse: Codable, Sendable { + public let session: SessionDTO + public let thoughts: [ThoughtDTO] +} + +public struct ThoughtsResponse: Codable, Sendable { + public let thoughts: [ThoughtDTO] +} + +public struct BoardResponse: Codable, Sendable { + public let board: [BoardEntryDTO] +} diff --git a/ios/StillPointShared/Sources/StillPointShared/Models/MindStateEntry.swift b/ios/StillPointShared/Sources/StillPointShared/Models/MindStateEntry.swift new file mode 100644 index 00000000..40344fb3 --- /dev/null +++ b/ios/StillPointShared/Sources/StillPointShared/Models/MindStateEntry.swift @@ -0,0 +1,18 @@ +import Foundation + +/// A single mind-state transition recorded during a session. +/// Matches the web app's `{ time: number; state: string }` JSONB entries. +public struct MindStateEntry: Codable, Equatable, Sendable { + /// Seconds elapsed when this state began + public let time: Double + + /// Either "clear" or "thinking" + public let state: String + + public init(time: Double, state: String) { + self.time = time + self.state = state + } + + public var isClear: Bool { state == "clear" } +} diff --git a/ios/StillPointShared/Sources/StillPointShared/Models/Session.swift b/ios/StillPointShared/Sources/StillPointShared/Models/Session.swift new file mode 100644 index 00000000..8fb6d52a --- /dev/null +++ b/ios/StillPointShared/Sources/StillPointShared/Models/Session.swift @@ -0,0 +1,54 @@ +import Foundation +import SwiftData + +@Model +public final class Session { + @Attribute(.unique) public var id: UUID + public var user: User? + public var dayNumber: Int + /// Target duration in seconds + public var duration: Int + public var completed: Bool + /// Actual wall-clock seconds elapsed (nil if abandoned) + public var actualTime: Int? + /// Percentage of time in "clear" state (0-100) + public var clearPercent: Int + /// Number of "I'm thinking" taps + public var thoughtCount: Int + /// Array of mind state transitions + public var mindStateLog: [MindStateEntry] + public var sessionDate: Date + public var createdAt: Date + public var syncStatus: SyncStatus + + @Relationship(deleteRule: .cascade, inverse: \Thought.session) + public var thoughts: [Thought] = [] + + public init( + id: UUID = UUID(), + user: User? = nil, + dayNumber: Int, + duration: Int, + completed: Bool, + actualTime: Int? = nil, + clearPercent: Int, + thoughtCount: Int, + mindStateLog: [MindStateEntry] = [], + sessionDate: Date = Date(), + createdAt: Date = Date(), + syncStatus: SyncStatus = .pending + ) { + self.id = id + self.user = user + self.dayNumber = dayNumber + self.duration = duration + self.completed = completed + self.actualTime = actualTime + self.clearPercent = clearPercent + self.thoughtCount = thoughtCount + self.mindStateLog = mindStateLog + self.sessionDate = sessionDate + self.createdAt = createdAt + self.syncStatus = syncStatus + } +} diff --git a/ios/StillPointShared/Sources/StillPointShared/Models/SyncStatus.swift b/ios/StillPointShared/Sources/StillPointShared/Models/SyncStatus.swift new file mode 100644 index 00000000..1d50a64a --- /dev/null +++ b/ios/StillPointShared/Sources/StillPointShared/Models/SyncStatus.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Tracks whether a local record has been synced to the server. +public enum SyncStatus: String, Codable { + case pending + case synced + case failed +} diff --git a/ios/StillPointShared/Sources/StillPointShared/Models/Thought.swift b/ios/StillPointShared/Sources/StillPointShared/Models/Thought.swift new file mode 100644 index 00000000..3e58031f --- /dev/null +++ b/ios/StillPointShared/Sources/StillPointShared/Models/Thought.swift @@ -0,0 +1,38 @@ +import Foundation +import SwiftData + +@Model +public final class Thought { + @Attribute(.unique) public var id: UUID + public var user: User? + public var session: Session? + public var dayNumber: Int + /// Seconds into session when thought was captured. -1 for end-of-session notes. + public var timeInSession: Int + public var text: String + public var createdAt: Date + public var syncStatus: SyncStatus + + public init( + id: UUID = UUID(), + user: User? = nil, + session: Session? = nil, + dayNumber: Int, + timeInSession: Int, + text: String, + createdAt: Date = Date(), + syncStatus: SyncStatus = .pending + ) { + self.id = id + self.user = user + self.session = session + self.dayNumber = dayNumber + self.timeInSession = timeInSession + self.text = text + self.createdAt = createdAt + self.syncStatus = syncStatus + } + + /// Whether this is an end-of-session note (not a mid-session thought) + public var isEndNote: Bool { timeInSession == -1 } +} diff --git a/ios/StillPointShared/Sources/StillPointShared/Models/User.swift b/ios/StillPointShared/Sources/StillPointShared/Models/User.swift new file mode 100644 index 00000000..0c73c3e5 --- /dev/null +++ b/ios/StillPointShared/Sources/StillPointShared/Models/User.swift @@ -0,0 +1,37 @@ +import Foundation +import SwiftData + +@Model +public final class User { + @Attribute(.unique) public var id: UUID + @Attribute(.unique) public var email: String + @Attribute(.unique) public var username: String + public var isPublic: Bool + public var currentDay: Int + public var createdAt: Date + public var updatedAt: Date + + @Relationship(deleteRule: .cascade, inverse: \Session.user) + public var sessions: [Session] = [] + + @Relationship(deleteRule: .cascade, inverse: \Thought.user) + public var thoughts: [Thought] = [] + + public init( + id: UUID = UUID(), + email: String, + username: String, + isPublic: Bool = false, + currentDay: Int = 1, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.email = email + self.username = username + self.isPublic = isPublic + self.currentDay = currentDay + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/ios/StillPointShared/Sources/StillPointShared/SessionLogic.swift b/ios/StillPointShared/Sources/StillPointShared/SessionLogic.swift new file mode 100644 index 00000000..a205b709 --- /dev/null +++ b/ios/StillPointShared/Sources/StillPointShared/SessionLogic.swift @@ -0,0 +1,128 @@ +import Foundation + +// MARK: - Block Definitions (matches web BlockTimer.tsx) + +public struct BlockDef: Identifiable, Sendable { + public let id: String + public let duration: Int + public let startTime: Int + public let label: String + public let type: BlockType + + public enum BlockType: Sendable { + case minute + case second + } +} + +public enum SessionLogic { + + /// Build block definitions for a session of given duration. + /// Matches the web app's BlockTimer.tsx block-building algorithm exactly. + public static func buildBlocks(totalSeconds: Int) -> [BlockDef] { + let useMinuteBlocks = totalSeconds > 120 + var blocks: [BlockDef] = [] + + if useMinuteBlocks { + let fullMinutes = totalSeconds / 60 + let remainingSeconds = totalSeconds % 60 + let minuteBlockCount = remainingSeconds > 0 ? fullMinutes : fullMinutes - 1 + + for i in 0.. Int { + guard totalElapsed > 0 else { return 100 } + + // If log is empty, the entire session was clear (no state changes) + guard !mindStateLog.isEmpty else { return 100 } + + // Ensure we account for time from 0 to the first entry + var effectiveLog = mindStateLog + if effectiveLog[0].time > 0 { + effectiveLog.insert(MindStateEntry(time: 0, state: "clear"), at: 0) + } + + var clearTime: Double = 0 + for (index, entry) in effectiveLog.enumerated() { + let endTime: Double + if index + 1 < effectiveLog.count { + endTime = effectiveLog[index + 1].time + } else { + endTime = totalElapsed + } + if entry.isClear { + clearTime += endTime - entry.time + } + } + + return Int(round((clearTime / totalElapsed) * 100)) + } + + /// Generate a status label matching the web app's logic. + public static func statusLabel( + elapsed: Double, + totalSeconds: Int, + blocks: [BlockDef] + ) -> String { + let minuteBlocks = blocks.filter { $0.type == .minute } + let secondBlocks = blocks.filter { $0.type == .second } + let useMinuteBlocks = totalSeconds > 120 + + if elapsed >= Double(totalSeconds) { + return "session complete" + } else if useMinuteBlocks { + let lastMinuteStart = minuteBlocks.count * 60 + if elapsed < Double(lastMinuteStart) { + let minIdx = Int(elapsed) / 60 + return "minute \(minIdx + 1) of \(minuteBlocks.count)" + } else { + let secIdx = (Int(elapsed) - lastMinuteStart) / StillPoint.blockDuration + return "final minute · block \(secIdx + 1) of \(secondBlocks.count)" + } + } else { + let blockIdx = Int(elapsed) / StillPoint.blockDuration + return "block \(blockIdx + 1) of \(blocks.count)" + } + } +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 00000000..381eb15c --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,37 @@ +name: StillPoint +options: + bundleIdPrefix: com.stillpoint + deploymentTarget: + iOS: "17.0" + xcodeVersion: "15.0" + generateEmptyDirectories: true + +packages: + StillPointShared: + path: StillPointShared + +targets: + StillPoint: + type: application + platform: iOS + sources: + - StillPointApp + dependencies: + - package: StillPointShared + settings: + base: + INFOPLIST_FILE: StillPointApp/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: com.stillpoint.app + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: 1 + DEVELOPMENT_TEAM: "" + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: "5.9" + GENERATE_INFOPLIST_FILE: "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" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor