commit f67429de0bb649144a1f5afb2b0791e43de3489f Author: kutesir Date: Wed May 27 02:09:44 2026 +0300 Initial commit: KisaniCal iOS app Task management, Eisenhower matrix, workout logging with persistence, and calendar with 6 view modes (Month, List, Year, Week, 3 Day, Day). Custom floating tab bar, dynamic calendar icon, and workout data seeded. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02191b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Xcode +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcworkspace/contents.xcworkspacedata +DerivedData/ +build/ +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +.build/ +.swiftpm/ + +# CocoaPods +Pods/ +Podfile.lock + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Obj-C/Swift specific +*.moved-aside +*.gcno + +# Editor +.idea/ +*.swp +*.swo diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj new file mode 100644 index 0000000..84a8307 --- /dev/null +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -0,0 +1,430 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; }; + 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; }; + 1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; }; + 3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; }; + 7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; }; + 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; }; + 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; }; + 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; }; + A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; }; + A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; }; + A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; }; + BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.swift */; }; + C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; }; + D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; }; + E7F1A2B3C4D5E6F7A8B9C0D1 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A1B2C3D4E5F6A7B8C9D0E1 /* NotificationManager.swift */; }; + B1C2D3E4F5A6B7C8D9E0F1A2 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D3E4F5A6B7C8D9E0F1A2B3 /* OnboardingView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = ""; }; + 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = ""; }; + 23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = ""; }; + 5722CC4B59E3939724142710 /* ExerciseModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExerciseModels.swift; sourceTree = ""; }; + 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = ""; }; + 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; + ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; + BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = ""; }; + C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; }; + C2D3E4F5A6B7C8D9E0F1A2B3 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = ""; }; + DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = ""; }; + F7A1B2C3D4E5F6A7B8C9D0E1 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 21B93C269F283F11B415B18C /* Components */ = { + isa = PBXGroup; + children = ( + 23A4491BFA50721082024756 /* SharedComponents.swift */, + ); + path = Components; + sourceTree = ""; + }; + 23CBCF100C5EF55E737379CA /* Models */ = { + isa = PBXGroup; + children = ( + 5722CC4B59E3939724142710 /* ExerciseModels.swift */, + DCC2AFB4FA765383740767CB /* TaskItem.swift */, + ); + path = Models; + sourceTree = ""; + }; + 4D2F2B3472A5A6D99F2FFCD2 = { + isa = PBXGroup; + children = ( + F70DA4746C68CD405435DAB6 /* KisaniCal */, + 51BD1B5DEDE9FAD9CA2FF6DA /* Products */, + ); + sourceTree = ""; + }; + 51BD1B5DEDE9FAD9CA2FF6DA /* Products */ = { + isa = PBXGroup; + children = ( + 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */, + ); + name = Products; + sourceTree = ""; + }; + 5E9A0E064E153429180400E6 /* Views */ = { + isa = PBXGroup; + children = ( + 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */, + ADF6CCD95A587E26E30F5712 /* CalendarView.swift */, + D44530A77DF12A17E52AAF34 /* MatrixView.swift */, + 72308FEE0226F45414C04DDD /* SettingsView.swift */, + C786EBC7DF879D64EB28165E /* TodayView.swift */, + 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */, + C2D3E4F5A6B7C8D9E0F1A2B3 /* OnboardingView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 9981C53A4E263A0DBC3DF194 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + E0A03E79A679740978E61BF1 /* Theme */ = { + isa = PBXGroup; + children = ( + BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */, + ); + path = Theme; + sourceTree = ""; + }; + A1B2C3D4E5F6070A8B9C0D1E /* Managers */ = { + isa = PBXGroup; + children = ( + F7A1B2C3D4E5F6A7B8C9D0E1 /* NotificationManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; + F70DA4746C68CD405435DAB6 /* KisaniCal */ = { + isa = PBXGroup; + children = ( + 72FDF9C8DD37134576356B89 /* Assets.xcassets */, + BC5E179A9189B0A8C3F856F6 /* ContentView.swift */, + 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */, + 21B93C269F283F11B415B18C /* Components */, + A1B2C3D4E5F6070A8B9C0D1E /* Managers */, + 23CBCF100C5EF55E737379CA /* Models */, + 9981C53A4E263A0DBC3DF194 /* Preview Content */, + E0A03E79A679740978E61BF1 /* Theme */, + 5E9A0E064E153429180400E6 /* Views */, + ); + path = KisaniCal; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 297C0C2BFBA10AB52D5D49CE /* KisaniCal */ = { + isa = PBXNativeTarget; + buildConfigurationList = B60FDD2C88D1D33CF1EC02B3 /* Build configuration list for PBXNativeTarget "KisaniCal" */; + buildPhases = ( + 82EA49FE155821D424C46912 /* Sources */, + E1F1A3EE4469B851B972E4AE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = KisaniCal; + packageProductDependencies = ( + ); + productName = KisaniCal; + productReference = 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AF6DE7A812408E3742522E90 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + 297C0C2BFBA10AB52D5D49CE = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = EF1C4F3BDA4AA7E008FC2CDE /* Build configuration list for PBXProject "KisaniCal" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 4D2F2B3472A5A6D99F2FFCD2; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 51BD1B5DEDE9FAD9CA2FF6DA /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 297C0C2BFBA10AB52D5D49CE /* KisaniCal */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E1F1A3EE4469B851B972E4AE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */, + 7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 82EA49FE155821D424C46912 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */, + 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */, + 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */, + A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */, + 1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */, + D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */, + BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */, + A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */, + 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */, + 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */, + E7F1A2B3C4D5E6F7A8B9C0D1 /* NotificationManager.swift in Sources */, + C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */, + 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */, + B1C2D3E4F5A6B7C8D9E0F1A2 /* OnboardingView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 0CEA6637F45C32A237FBA20D /* 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; + DEVELOPMENT_TEAM = K8BLMMR883; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; + INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = ""; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 20E23A89279CC26778E21856 /* 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 = 16.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; + }; + BD527AA54887489CC665FB9D /* 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 = 16.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; + }; + F364512BEB70ECB7CB83FBFE /* 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; + DEVELOPMENT_TEAM = K8BLMMR883; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; + INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = ""; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B60FDD2C88D1D33CF1EC02B3 /* Build configuration list for PBXNativeTarget "KisaniCal" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F364512BEB70ECB7CB83FBFE /* Debug */, + 0CEA6637F45C32A237FBA20D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + EF1C4F3BDA4AA7E008FC2CDE /* Build configuration list for PBXProject "KisaniCal" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BD527AA54887489CC665FB9D /* Debug */, + 20E23A89279CC26778E21856 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = AF6DE7A812408E3742522E90 /* Project object */; +} diff --git a/KisaniCal/Assets.xcassets/AccentColor.colorset/Contents.json b/KisaniCal/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..91372b6 --- /dev/null +++ b/KisaniCal/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.165", + "green" : "0.384", + "red" : "0.910" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KisaniCal/Assets.xcassets/AppIcon.appiconset/Contents.json b/KisaniCal/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/KisaniCal/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/KisaniCal/Assets.xcassets/Contents.json b/KisaniCal/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/KisaniCal/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KisaniCal/Components/SharedComponents.swift b/KisaniCal/Components/SharedComponents.swift new file mode 100644 index 0000000..201d312 --- /dev/null +++ b/KisaniCal/Components/SharedComponents.swift @@ -0,0 +1,184 @@ +import SwiftUI + +// MARK: - Press Button Styles +struct PressButtonStyle: ButtonStyle { + var scale: CGFloat = 0.95 + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? scale : 1.0) + .animation(KisaniSpring.micro, value: configuration.isPressed) + } +} + +struct FabButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.88 : 1.0) + .opacity(configuration.isPressed ? 0.92 : 1.0) + .animation(KisaniSpring.micro, value: configuration.isPressed) + } +} + +// MARK: - Icon Button (circular) +struct IButton: View { + let icon: String + var action: (() -> Void)? = nil + @Environment(\.colorScheme) private var cs + + var body: some View { + Button { action?() } label: { + Image(systemName: icon) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(AppColors.text2(cs)) + .frame(width: 34, height: 34) + .background(AppColors.surface2(cs)) + .clipShape(Circle()) + .overlay(Circle().stroke(AppColors.border(cs), lineWidth: 1)) + } + .buttonStyle(PressButtonStyle(scale: 0.92)) + } +} + +// MARK: - Month Pill +struct MonthPill: View { + let title: String + var action: (() -> Void)? = nil + @Environment(\.colorScheme) private var cs + + var body: some View { + Button { action?() } label: { + HStack(spacing: 6) { + Text(title) + .font(AppFonts.sans(12.5, weight: .medium)) + .foregroundColor(AppColors.text(cs)) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + } + .padding(.horizontal, 13) + .padding(.vertical, 6) + .background(AppColors.surface2(cs)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(AppColors.border(cs), lineWidth: 1)) + } + .buttonStyle(.plain) + } +} + +// MARK: - Floating Action Button +struct FAB: View { + var action: (() -> Void)? = nil + + var body: some View { + Button { action?() } label: { + Image(systemName: "plus") + .font(.system(size: 20, weight: .light)) + .foregroundColor(.white) + .frame(width: 46, height: 46) + .background(AppColors.accent) + .clipShape(Circle()) + .shadow(color: AppColors.accent.opacity(0.45), radius: 12, x: 0, y: 4) + .shadow(color: AppColors.accent.opacity(0.14), radius: 30, x: 0, y: 0) + } + .buttonStyle(FabButtonStyle()) + } +} + +// MARK: - Section Label +struct SectionLabel: View { + let text: String + @Environment(\.colorScheme) private var cs + + var body: some View { + Text(text) + .font(AppFonts.sans(10.5, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Badge +struct AppBadge: View { + let text: String + let bg: Color + let fg: Color + + var body: some View { + Text(text) + .font(AppFonts.sans(9, weight: .semibold)) + .foregroundColor(fg) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(bg) + .cornerRadius(5) + } +} + +// MARK: - Dashed Add Button +struct DashedAddButton: View { + let label: String + let action: () -> Void + @Environment(\.colorScheme) private var cs + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: "plus") + .font(.system(size: 14)) + .foregroundColor(AppColors.text3(cs)) + Text(label) + .font(AppFonts.sans(12, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + } + .frame(maxWidth: .infinity) + .padding(11) + .background(Color.clear) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.large) + .strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4])) + .foregroundColor(AppColors.borderHi(cs)) + ) + } + .buttonStyle(PressButtonStyle()) + } +} + +// MARK: - Tag Chip +struct TagChip: View { + let text: String + let color: Color + let bg: Color + + var body: some View { + Text(text) + .font(AppFonts.sans(9, weight: .semibold)) + .foregroundColor(color) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(bg) + .cornerRadius(5) + } +} + +// MARK: - Card style modifier +struct CardStyle: ViewModifier { + @Environment(\.colorScheme) private var cs + var radius: CGFloat = AppRadius.large + var borderColor: Color? = nil + + func body(content: Content) -> some View { + content + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .overlay( + RoundedRectangle(cornerRadius: radius) + .stroke(borderColor ?? AppColors.border(cs), lineWidth: 1) + ) + } +} + +extension View { + func cardStyle(radius: CGFloat = AppRadius.large, borderColor: Color? = nil) -> some View { + modifier(CardStyle(radius: radius, borderColor: borderColor)) + } +} diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift new file mode 100644 index 0000000..545857c --- /dev/null +++ b/KisaniCal/ContentView.swift @@ -0,0 +1,258 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var taskVM = TaskViewModel() + @StateObject private var workoutVM = WorkoutViewModel() + @Environment(\.horizontalSizeClass) var hSizeClass + @Environment(\.scenePhase) private var scenePhase + @AppStorage("appearanceMode") private var appearanceMode = 0 + @AppStorage("showMatrixTab") private var showMatrixTab = true + @AppStorage("showWorkoutTab") private var showWorkoutTab = true + @AppStorage("hasOnboarded") private var hasOnboarded = false + @State private var showOnboarding = false + @State private var selectedTab = 0 + + private var calendarIcon: Image { + let day = Calendar.current.component(.day, from: Date()) + let size = CGSize(width: 26, height: 26) + let renderer = UIGraphicsImageRenderer(size: size) + let img = renderer.image { _ in + let color = UIColor.black + let lw: CGFloat = 1.5 + let cr: CGFloat = 4.5 + + // Outer rounded rectangle body + let body = CGRect(x: lw / 2, y: 3, width: size.width - lw, height: size.height - 3 - lw / 2) + let bodyPath = UIBezierPath(roundedRect: body, cornerRadius: cr) + color.setStroke() + bodyPath.lineWidth = lw + bodyPath.stroke() + + // Header divider line + let divY = body.minY + 7.5 + let divPath = UIBezierPath() + divPath.move(to: CGPoint(x: body.minX + lw / 2, y: divY)) + divPath.addLine(to: CGPoint(x: body.maxX - lw / 2, y: divY)) + color.withAlphaComponent(0.45).setStroke() + divPath.lineWidth = 1 + divPath.stroke() + + // Ring bumps at top + color.setFill() + for rx in [size.width * 0.3, size.width * 0.7] { + UIBezierPath(roundedRect: CGRect(x: rx - 1.5, y: 0.5, width: 3, height: 5.5), cornerRadius: 1.5).fill() + } + + // Date number centred in body below header + let s = "\(day)" + let font = UIFont.systemFont(ofSize: floor(size.height * 0.38), weight: .bold) + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] + let ts = s.size(withAttributes: attrs) + let contentMidY = divY + (body.maxY - divY) / 2 + s.draw(at: CGPoint(x: (size.width - ts.width) / 2, y: contentMidY - ts.height / 2), withAttributes: attrs) + } + return Image(uiImage: img.withRenderingMode(.alwaysTemplate)) + } + + private var preferredScheme: ColorScheme? { + switch appearanceMode { + case 1: return .light + case 2: return .dark + default: return nil + } + } + + var body: some View { + Group { + if hSizeClass == .regular { + iPadLayout() + } else { + iPhoneLayout() + } + } + .preferredColorScheme(preferredScheme) + .fullScreenCover(isPresented: $showOnboarding) { + OnboardingView(isPresented: $showOnboarding) + .onDisappear { hasOnboarded = true } + } + .onAppear { if !hasOnboarded { showOnboarding = true } } + .onChange(of: scenePhase) { phase in + if phase == .active { + NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + } + } + .onChange(of: workoutVM.doneSets) { _ in + NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + } + .onChange(of: taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count) { _ in + NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + } + } + + // MARK: - iPhone + @ViewBuilder + private func iPhoneLayout() -> some View { + ZStack(alignment: .bottom) { + TabView(selection: $selectedTab) { + TodayView() + .environmentObject(taskVM) + .environmentObject(workoutVM) + .tag(0) + .tabBarInset() + + CalendarView() + .environmentObject(taskVM) + .environmentObject(workoutVM) + .tag(1) + .tabBarInset() + + if showMatrixTab { + MatrixView() + .environmentObject(taskVM) + .tag(2) + .tabBarInset() + } else { + Color.clear.tag(2) + } + + if showWorkoutTab { + WorkoutView() + .environmentObject(workoutVM) + .tag(3) + .tabBarInset() + } else { + Color.clear.tag(3) + } + + SettingsView() + .environmentObject(workoutVM) + .tag(4) + .tabBarInset() + } + .tint(AppColors.accent) + + KisaniTabBar( + selection: $selectedTab, + calIcon: calendarIcon, + showMatrix: showMatrixTab, + showWorkout: showWorkoutTab + ) + .padding(.horizontal, 24) + .padding(.bottom, 8) + } + .onAppear { UITabBar.appearance().isHidden = true } + } + + // MARK: - iPad + @ViewBuilder + private func iPadLayout() -> some View { + NavigationSplitView(columnVisibility: .constant(.all)) { + List { + iPadSidebarItem("Tasks", icon: "checkmark", tag: 0) + iPadSidebarItem("Calendar", icon: "calendar", tag: 1) + if showMatrixTab { + iPadSidebarItem("Matrix", icon: "square.grid.2x2", tag: 2) + } + if showWorkoutTab { + iPadSidebarItem("Workout", icon: "dumbbell", tag: 3) + } + iPadSidebarItem("Settings", icon: "gearshape", tag: 4) + } + .navigationTitle("Kisani Cal") + .listStyle(.sidebar) + } detail: { + TodayView().environmentObject(taskVM) + } + .tint(AppColors.accent) + } + + private func iPadSidebarItem(_ label: String, icon: String, tag: Int) -> some View { + NavigationLink(destination: iPadDetail(tag: tag)) { + Label(label, systemImage: icon) + } + } + + @ViewBuilder + private func iPadDetail(tag: Int) -> some View { + switch tag { + case 0: TodayView().environmentObject(taskVM).environmentObject(workoutVM) + case 1: CalendarView().environmentObject(taskVM).environmentObject(workoutVM) + case 2: MatrixView().environmentObject(taskVM) + case 3: WorkoutView().environmentObject(workoutVM) + default: SettingsView().environmentObject(workoutVM) + } + } +} + +// MARK: - Custom Tab Bar + +// Adds a transparent bottom safe-area inset equal to the floating tab bar height, +// so scroll content and FABs inside each tab clear the bar. +private extension View { + func tabBarInset() -> some View { + safeAreaInset(edge: .bottom, spacing: 0) { + Color.clear.frame(height: 62) + } + } +} + +struct KisaniTabBar: View { + @Binding var selection: Int + let calIcon: Image + var showMatrix: Bool + var showWorkout: Bool + @Environment(\.colorScheme) private var cs + + private struct Entry: Identifiable { + let id: Int + let sfIcon: String? + let custom: Image? + init(_ id: Int, _ icon: String) { self.id = id; sfIcon = icon; custom = nil } + init(_ id: Int, custom img: Image) { self.id = id; sfIcon = nil; custom = img } + } + + private var entries: [Entry] { + var e: [Entry] = [Entry(0, "checkmark"), Entry(1, custom: calIcon)] + if showMatrix { e.append(Entry(2, "square.grid.2x2.fill")) } + if showWorkout { e.append(Entry(3, "dumbbell.fill")) } + e.append(Entry(4, "gearshape.fill")) + return e + } + + var body: some View { + HStack(spacing: 0) { + ForEach(entries) { entry in + Button { + withAnimation(KisaniSpring.micro) { selection = entry.id } + } label: { + ZStack { + if selection == entry.id { + RoundedRectangle(cornerRadius: 14) + .fill(AppColors.surface2(cs)) + .frame(width: 52, height: 44) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + if let img = entry.custom { + img + .font(.system(size: 20)) + .foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs)) + } else if let icon = entry.sfIcon { + Image(systemName: icon) + .font(.system(size: 17, weight: selection == entry.id ? .semibold : .regular)) + .foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs)) + } + } + .frame(maxWidth: .infinity).frame(height: 50) + } + .buttonStyle(PressButtonStyle(scale: 0.88)) + } + } + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 28) + .fill(AppColors.surface(cs)) + .shadow(color: .black.opacity(0.10), radius: 20, x: 0, y: 6) + .shadow(color: .black.opacity(0.04), radius: 4, x: 0, y: 1) + ) + } +} diff --git a/KisaniCal/KisaniCalApp.swift b/KisaniCal/KisaniCalApp.swift new file mode 100644 index 0000000..7ada3d4 --- /dev/null +++ b/KisaniCal/KisaniCalApp.swift @@ -0,0 +1,22 @@ +import SwiftUI + +@main +struct KisaniCalApp: App { + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +final class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + NotificationManager.shared.requestPermission() + return true + } +} diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift new file mode 100644 index 0000000..495132c --- /dev/null +++ b/KisaniCal/Managers/NotificationManager.swift @@ -0,0 +1,127 @@ +import SwiftUI +import UserNotifications + +final class NotificationManager: NSObject, ObservableObject { + static let shared = NotificationManager() + + @Published var isAuthorized = false + + private let center = UNUserNotificationCenter.current() + private let workoutPrefix = "kisani.workout.weekday" + private let tasksId = "kisani.tasks.evening" + + override private init() { + super.init() + center.delegate = self + refreshAuthStatus() + } + + // MARK: - Permission + + func requestPermission() { + center.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, _ in + DispatchQueue.main.async { self?.isAuthorized = granted } + } + } + + private func refreshAuthStatus() { + center.getNotificationSettings { [weak self] settings in + DispatchQueue.main.async { + self?.isAuthorized = (settings.authorizationStatus == .authorized + || settings.authorizationStatus == .provisional) + } + } + } + + // MARK: - Reschedule (call on foreground + state changes) + + func reschedule(workoutVM: WorkoutViewModel, taskVM: TaskViewModel) { + center.getNotificationSettings { [weak self] settings in + guard let self else { return } + let ok = settings.authorizationStatus == .authorized + || settings.authorizationStatus == .provisional + guard ok else { return } + self.scheduleWorkout(workoutVM) + self.scheduleTasks(taskVM) + } + } + + // MARK: - Workout (7:00 PM on each scheduled weekday) + + private func scheduleWorkout(_ workoutVM: WorkoutViewModel) { + let existingIds = (1...7).map { "\(workoutPrefix).\($0)" } + center.removePendingNotificationRequests(withIdentifiers: existingIds) + + let todayWeekday = Calendar.current.component(.weekday, from: Date()) + + for (weekday, pid) in workoutVM.schedule { + guard let program = workoutVM.programs.first(where: { $0.id == pid }) else { continue } + + // Today's workout is already done — skip so notification won't fire this evening + if weekday == todayWeekday && workoutVM.progress >= 1.0 { continue } + + let content = UNMutableNotificationContent() + content.title = "\(program.emoji) Workout check-in" + content.body = "Did you finish your \(program.name)? Mark your sets complete." + content.sound = .default + content.userInfo = ["deeplink": "workout"] + + var comps = DateComponents() + comps.weekday = weekday + comps.hour = 19 + comps.minute = 0 + + let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + let request = UNNotificationRequest( + identifier: "\(workoutPrefix).\(weekday)", + content: content, + trigger: trigger + ) + center.add(request) + } + } + + // MARK: - Urgent tasks (7:30 PM daily, only when there are open urgent tasks) + + private func scheduleTasks(_ taskVM: TaskViewModel) { + center.removePendingNotificationRequests(withIdentifiers: [tasksId]) + + let count = taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count + guard count > 0 else { return } + + let content = UNMutableNotificationContent() + content.title = "Evening task check-in" + content.body = "\(count) urgent task\(count == 1 ? "" : "s") still open — mark done or reschedule." + content.sound = .default + content.userInfo = ["deeplink": "tasks"] + + var comps = DateComponents() + comps.hour = 19 + comps.minute = 30 + + let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + let request = UNNotificationRequest(identifier: tasksId, content: content, trigger: trigger) + center.add(request) + } +} + +// MARK: - UNUserNotificationCenterDelegate + +extension NotificationManager: UNUserNotificationCenterDelegate { + // Show banners even when app is foregrounded + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + completionHandler() + } +} diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift new file mode 100644 index 0000000..6d1eadd --- /dev/null +++ b/KisaniCal/Models/ExerciseModels.swift @@ -0,0 +1,601 @@ +import Foundation +import Combine + +// MARK: - Exercise Models +struct ExerciseSet: Identifiable, Codable { + var id = UUID() + var weight: Double = 0 + var reps: Int = 10 + var isDone: Bool = false +} + +struct Exercise: Identifiable, Codable { + var id = UUID() + var name: String + var sfSymbol: String + var colorIndex: Int = 0 + var sets: [ExerciseSet] + var isExpanded: Bool = true + var isDumbbell: Bool = false // per-side input → total = weight × 2 + + var totalSets: Int { sets.count } + var doneSets: Int { sets.filter(\.isDone).count } + var isPartialDone: Bool { doneSets > 0 && doneSets < totalSets } + var isAllDone: Bool { doneSets == totalSets && totalSets > 0 } +} + +// MARK: - Workout Section +struct WorkoutSection: Identifiable, Codable { + var id = UUID() + var name: String + var exercises: [Exercise] + var isExpanded: Bool = true + + var totalSets: Int { exercises.reduce(0) { $0 + $1.sets.count } } + var doneSets: Int { exercises.reduce(0) { $0 + $1.doneSets } } + var exerciseCount: Int { exercises.count } +} + +// MARK: - Workout Program +struct WorkoutProgram: Identifiable, Codable { + let id: UUID + var name: String + var emoji: String + var colorIndex: Int + var sections: [WorkoutSection] + + var allExercises: [Exercise] { sections.flatMap(\.exercises) } + var totalSets: Int { sections.reduce(0) { $0 + $1.totalSets } } + var doneSets: Int { sections.reduce(0) { $0 + $1.doneSets } } + var totalExercises: Int { sections.reduce(0) { $0 + $1.exerciseCount } } + + init(id: UUID = UUID(), name: String, emoji: String, colorIndex: Int = 0, sections: [WorkoutSection] = []) { + self.id = id; self.name = name; self.emoji = emoji + self.colorIndex = colorIndex; self.sections = sections + } +} + +// MARK: - Exercise Template Library +enum ExerciseMuscle: String, CaseIterable, Identifiable { + var id: String { rawValue } + case chest = "Chest" + case back = "Back" + case legs = "Legs" + case shoulders = "Shoulders" + case arms = "Arms" + case core = "Core" + case cardio = "Cardio" + + var sfSymbol: String { + switch self { + case .chest: return "figure.strengthtraining.traditional" + case .back: return "figure.pull.up" + case .legs: return "figure.squat" + case .shoulders: return "figure.arms.open" + case .arms: return "dumbbell.fill" + case .core: return "figure.core.training" + case .cardio: return "figure.run" + } + } +} + +struct ExerciseTemplate: Identifiable { + let id = UUID() + let name: String + let sfSymbol: String + let muscle: ExerciseMuscle + var isDumbbell: Bool = false +} + +let exerciseLibrary: [ExerciseTemplate] = [ + // Chest + ExerciseTemplate(name: "Bench Press", sfSymbol: "figure.strengthtraining.traditional", muscle: .chest), + ExerciseTemplate(name: "Incline Bench Press", sfSymbol: "figure.strengthtraining.traditional", muscle: .chest, isDumbbell: true), + ExerciseTemplate(name: "Incline Dumbbell Press", sfSymbol: "dumbbell.fill", muscle: .chest, isDumbbell: true), + ExerciseTemplate(name: "Decline Bench Press", sfSymbol: "figure.strengthtraining.traditional", muscle: .chest), + ExerciseTemplate(name: "Cable Fly", sfSymbol: "figure.strengthtraining.functional", muscle: .chest), + ExerciseTemplate(name: "Chest Flys", sfSymbol: "dumbbell.fill", muscle: .chest, isDumbbell: true), + ExerciseTemplate(name: "Push-ups", sfSymbol: "figure.gymnastics", muscle: .chest), + ExerciseTemplate(name: "Chest Dips", sfSymbol: "figure.gymnastics", muscle: .chest), + ExerciseTemplate(name: "Cable Crossover", sfSymbol: "figure.strengthtraining.functional", muscle: .chest), + // Back + ExerciseTemplate(name: "Pull-ups", sfSymbol: "figure.pull.up", muscle: .back), + ExerciseTemplate(name: "Wide Pull-ups", sfSymbol: "figure.pull.up", muscle: .back), + ExerciseTemplate(name: "Lat Pulldown", sfSymbol: "figure.pull.up", muscle: .back), + ExerciseTemplate(name: "Barbell Row", sfSymbol: "figure.strengthtraining.traditional", muscle: .back), + ExerciseTemplate(name: "Seated Cable Row", sfSymbol: "figure.rowing", muscle: .back), + ExerciseTemplate(name: "Cable Row", sfSymbol: "figure.rowing", muscle: .back), + ExerciseTemplate(name: "Incline High Row", sfSymbol: "figure.strengthtraining.functional", muscle: .back), + ExerciseTemplate(name: "Face Pull", sfSymbol: "figure.strengthtraining.functional", muscle: .back), + ExerciseTemplate(name: "Deadlift", sfSymbol: "figure.strengthtraining.traditional", muscle: .back), + ExerciseTemplate(name: "T-Bar Row", sfSymbol: "figure.strengthtraining.traditional", muscle: .back), + ExerciseTemplate(name: "Shrugs", sfSymbol: "figure.arms.open", muscle: .back), + ExerciseTemplate(name: "Lat Pulldown Wide Grip", sfSymbol: "figure.pull.up", muscle: .back), + // Legs + ExerciseTemplate(name: "Squat", sfSymbol: "figure.squat", muscle: .legs), + ExerciseTemplate(name: "Hack Squat", sfSymbol: "figure.squat", muscle: .legs), + ExerciseTemplate(name: "Romanian Deadlift", sfSymbol: "figure.strengthtraining.traditional", muscle: .legs), + ExerciseTemplate(name: "Leg Press", sfSymbol: "figure.strengthtraining.functional", muscle: .legs), + ExerciseTemplate(name: "Leg Extension", sfSymbol: "figure.strengthtraining.functional", muscle: .legs), + ExerciseTemplate(name: "Adductor Machine", sfSymbol: "figure.strengthtraining.functional", muscle: .legs), + ExerciseTemplate(name: "Lying Leg Curl", sfSymbol: "figure.strengthtraining.functional", muscle: .legs), + ExerciseTemplate(name: "Seated Leg Curl", sfSymbol: "figure.strengthtraining.functional", muscle: .legs), + ExerciseTemplate(name: "Hamstring Curl", sfSymbol: "figure.strengthtraining.functional", muscle: .legs), + ExerciseTemplate(name: "Standing Calf Raise", sfSymbol: "figure.walk", muscle: .legs), + ExerciseTemplate(name: "Seated Calf Raise", sfSymbol: "figure.strengthtraining.functional", muscle: .legs), + ExerciseTemplate(name: "Bulgarian Split Squat", sfSymbol: "figure.squat", muscle: .legs), + ExerciseTemplate(name: "Lunges", sfSymbol: "figure.walk", muscle: .legs), + ExerciseTemplate(name: "Stiff-Legged Deadlift", sfSymbol: "figure.strengthtraining.traditional", muscle: .legs), + ExerciseTemplate(name: "Leg Curl", sfSymbol: "figure.strengthtraining.functional", muscle: .legs), + // Shoulders + ExerciseTemplate(name: "Overhead Press", sfSymbol: "figure.arms.open", muscle: .shoulders), + ExerciseTemplate(name: "Dumbbell Shoulder Press",sfSymbol: "dumbbell.fill", muscle: .shoulders, isDumbbell: true), + ExerciseTemplate(name: "Arnold Press", sfSymbol: "dumbbell.fill", muscle: .shoulders, isDumbbell: true), + ExerciseTemplate(name: "Lateral Raises", sfSymbol: "figure.arms.open", muscle: .shoulders, isDumbbell: true), + ExerciseTemplate(name: "Front Raises", sfSymbol: "figure.arms.open", muscle: .shoulders, isDumbbell: true), + ExerciseTemplate(name: "Cable Lateral Raise", sfSymbol: "figure.strengthtraining.functional", muscle: .shoulders), + ExerciseTemplate(name: "Reverse Fly", sfSymbol: "dumbbell.fill", muscle: .shoulders, isDumbbell: true), + // Arms + ExerciseTemplate(name: "Bicep Curls", sfSymbol: "dumbbell.fill", muscle: .arms, isDumbbell: true), + ExerciseTemplate(name: "Hammer Curls", sfSymbol: "dumbbell.fill", muscle: .arms, isDumbbell: true), + ExerciseTemplate(name: "Incline Dumbbell Curls", sfSymbol: "dumbbell.fill", muscle: .arms, isDumbbell: true), + ExerciseTemplate(name: "Incline Hammer Curl", sfSymbol: "dumbbell.fill", muscle: .arms, isDumbbell: true), + ExerciseTemplate(name: "Barbell Curls", sfSymbol: "figure.strengthtraining.traditional", muscle: .arms), + ExerciseTemplate(name: "Preacher Curl", sfSymbol: "dumbbell.fill", muscle: .arms), + ExerciseTemplate(name: "Concentration Curls", sfSymbol: "dumbbell.fill", muscle: .arms, isDumbbell: true), + ExerciseTemplate(name: "Dips", sfSymbol: "figure.gymnastics", muscle: .arms), + ExerciseTemplate(name: "Tricep Dips", sfSymbol: "figure.gymnastics", muscle: .arms), + ExerciseTemplate(name: "Tricep Pushdown", sfSymbol: "figure.strengthtraining.functional", muscle: .arms), + ExerciseTemplate(name: "Straight Bar Pushdown", sfSymbol: "figure.strengthtraining.functional", muscle: .arms), + ExerciseTemplate(name: "Skull Crushers", sfSymbol: "figure.strengthtraining.traditional", muscle: .arms), + ExerciseTemplate(name: "Overhead Tricep Ext", sfSymbol: "dumbbell.fill", muscle: .arms), + ExerciseTemplate(name: "Rope Pushdown", sfSymbol: "figure.strengthtraining.functional", muscle: .arms), + // Core + ExerciseTemplate(name: "Plank", sfSymbol: "figure.core.training", muscle: .core), + ExerciseTemplate(name: "Crunches", sfSymbol: "figure.core.training", muscle: .core), + ExerciseTemplate(name: "Ab Wheel Rollout", sfSymbol: "figure.roll", muscle: .core), + ExerciseTemplate(name: "Russian Twists", sfSymbol: "figure.cross.training", muscle: .core), + ExerciseTemplate(name: "Hanging Leg Raise", sfSymbol: "figure.pull.up", muscle: .core), + ExerciseTemplate(name: "Cable Crunches", sfSymbol: "figure.cooldown", muscle: .core), + // Cardio + ExerciseTemplate(name: "Treadmill", sfSymbol: "figure.run", muscle: .cardio), + ExerciseTemplate(name: "Cycling", sfSymbol: "figure.outdoor.cycle", muscle: .cardio), + ExerciseTemplate(name: "Jump Rope", sfSymbol: "figure.jumprope", muscle: .cardio), + ExerciseTemplate(name: "Rowing Machine", sfSymbol: "figure.rowing", muscle: .cardio), + ExerciseTemplate(name: "Stair Climber", sfSymbol: "figure.stair.stepper", muscle: .cardio), +] + +let exerciseSFSymbol: [String: String] = Dictionary( + uniqueKeysWithValues: exerciseLibrary.map { ($0.name, $0.sfSymbol) } +) + +// MARK: - Workout View Model +class WorkoutViewModel: ObservableObject { + @Published var programs: [WorkoutProgram] + @Published var schedule: [Int: UUID] + @Published var activeProgramId: UUID + @Published var activeSections: [WorkoutSection] + @Published var streakDays: Int = 5 + @Published var restTimerSeconds: Int = 60 + @Published var showAddExercise: Bool = false + @Published var addExerciseTargetSectionId: UUID? + + private static let kPrograms = "kisani.workout.programs" + private static let kSchedule = "kisani.workout.schedule" + private static let kActivePid = "kisani.workout.activePid" + + init() { + // ── Attempt to restore persisted state ── + if let data = UserDefaults.standard.data(forKey: Self.kPrograms), + let saved = try? JSONDecoder().decode([WorkoutProgram].self, from: data), + !saved.isEmpty { + + programs = saved + + var sched: [Int: UUID] = [:] + if let dict = UserDefaults.standard.dictionary(forKey: Self.kSchedule) as? [String: String] { + for (k, v) in dict { + if let day = Int(k), let uid = UUID(uuidString: v) { sched[day] = uid } + } + } + schedule = sched + + var pid = saved[0].id + if let str = UserDefaults.standard.string(forKey: Self.kActivePid), + let uid = UUID(uuidString: str), + saved.contains(where: { $0.id == uid }) { pid = uid } + activeProgramId = pid + activeSections = saved.first { $0.id == pid }?.sections ?? saved[0].sections + + } else { + // ── First launch: seed with sample programs ── + let progs = WorkoutViewModel.samplePrograms() + programs = progs + + var sched: [Int: UUID] = [:] + if progs.count >= 6 { + sched[2] = progs[0].id + sched[3] = progs[1].id + sched[4] = progs[2].id + sched[5] = progs[3].id + sched[6] = progs[4].id + sched[7] = progs[5].id + } + schedule = sched + + let today = Calendar.current.component(.weekday, from: Date()) + if let pid = sched[today], progs.contains(where: { $0.id == pid }) { + activeProgramId = pid + activeSections = progs.first { $0.id == pid }!.sections + } else { + activeProgramId = progs[0].id + activeSections = progs[0].sections + } + } + } + + private func save() { + if let data = try? JSONEncoder().encode(programs) { + UserDefaults.standard.set(data, forKey: Self.kPrograms) + } + let schedDict = schedule.reduce(into: [String: String]()) { $0[String($1.key)] = $1.value.uuidString } + UserDefaults.standard.set(schedDict, forKey: Self.kSchedule) + UserDefaults.standard.set(activeProgramId.uuidString, forKey: Self.kActivePid) + } + + var activeProgram: WorkoutProgram? { programs.first { $0.id == activeProgramId } } + var workoutTitle: String { activeProgram?.name ?? "Workout" } + var workoutEmoji: String { activeProgram?.emoji ?? "💪" } + var totalSets: Int { activeSections.reduce(0) { $0 + $1.totalSets } } + var doneSets: Int { activeSections.reduce(0) { $0 + $1.doneSets } } + var progress: Double { totalSets > 0 ? Double(doneSets) / Double(totalSets) : 0 } + + // MARK: - Program management + func switchProgram(to id: UUID) { + syncBack() + guard let prog = programs.first(where: { $0.id == id }) else { return } + activeProgramId = id + activeSections = prog.sections + save() + } + + func addProgram(name: String, emoji: String, colorIndex: Int = 0) { + let defaultSection = WorkoutSection(name: "Main", exercises: []) + programs.append(WorkoutProgram(name: name, emoji: emoji, colorIndex: colorIndex, sections: [defaultSection])) + save() + } + + func deleteProgram(_ id: UUID) { + guard id != activeProgramId, programs.count > 1 else { return } + programs.removeAll { $0.id == id } + schedule = schedule.filter { $0.value != id } + save() + } + + func renameProgram(_ id: UUID, name: String, emoji: String? = nil) { + guard let idx = programs.firstIndex(where: { $0.id == id }) else { return } + programs[idx].name = name + if let emoji { programs[idx].emoji = emoji } + if id == activeProgramId { objectWillChange.send() } + save() + } + + func setSchedule(weekday: Int, programId: UUID?) { + if let pid = programId { schedule[weekday] = pid } + else { schedule.removeValue(forKey: weekday) } + save() + } + + func program(for date: Date) -> WorkoutProgram? { + let weekday = Calendar.current.component(.weekday, from: date) + guard let pid = schedule[weekday] else { return nil } + return programs.first { $0.id == pid } + } + + // MARK: - Section management + func addSection(name: String) { + activeSections.append(WorkoutSection(name: name, exercises: [])) + syncBack() + } + + func deleteSection(_ id: UUID) { + activeSections.removeAll { $0.id == id } + syncBack() + } + + func renameSection(_ id: UUID, name: String) { + guard let si = activeSections.firstIndex(where: { $0.id == id }) else { return } + activeSections[si].name = name + syncBack() + } + + func moveSectionUp(_ id: UUID) { + guard let idx = activeSections.firstIndex(where: { $0.id == id }), idx > 0 else { return } + activeSections.swapAt(idx, idx - 1) + syncBack() + } + + func moveSectionDown(_ id: UUID) { + guard let idx = activeSections.firstIndex(where: { $0.id == id }), + idx < activeSections.count - 1 else { return } + activeSections.swapAt(idx, idx + 1) + syncBack() + } + + func toggleSectionExpanded(_ id: UUID) { + guard let si = activeSections.firstIndex(where: { $0.id == id }) else { return } + activeSections[si].isExpanded.toggle() + } + + // MARK: - Exercise management + func toggleSet(sectionId: UUID, exerciseId: UUID, setId: UUID) { + guard let si = activeSections.firstIndex(where: { $0.id == sectionId }), + let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId }), + let setIdx = activeSections[si].exercises[ei].sets.firstIndex(where: { $0.id == setId }) + else { return } + activeSections[si].exercises[ei].sets[setIdx].isDone.toggle() + syncBack() + } + + func addSet(sectionId: UUID, to exerciseId: UUID) { + guard let si = activeSections.firstIndex(where: { $0.id == sectionId }), + let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId }) + else { return } + let last = activeSections[si].exercises[ei].sets.last + activeSections[si].exercises[ei].sets.append(ExerciseSet(weight: last?.weight ?? 0, reps: last?.reps ?? 10)) + syncBack() + } + + // Active-program convenience wrappers + func addExercise(to sectionId: UUID, name: String, setsCount: Int, repsCount: Int) { + addExercise(programId: activeProgramId, sectionId: sectionId, name: name, setsCount: setsCount, repsCount: repsCount) + } + + func deleteExercise(sectionId: UUID, exerciseId: UUID) { + deleteExercise(programId: activeProgramId, sectionId: sectionId, exerciseId: exerciseId) + } + + func moveExercise(sectionId: UUID, from: IndexSet, to: Int) { + guard let si = activeSections.firstIndex(where: { $0.id == sectionId }) else { return } + activeSections[si].exercises.move(fromOffsets: from, toOffset: to) + syncBack() + } + + // MARK: - Universal (any program) operations + func addExercise(programId: UUID, sectionId: UUID, name: String, setsCount: Int, repsCount: Int) { + guard let pi = programs.firstIndex(where: { $0.id == programId }), + let si = programs[pi].sections.firstIndex(where: { $0.id == sectionId }) + else { return } + let template = exerciseLibrary.first { $0.name == name } + let symbol = template?.sfSymbol ?? "figure.strengthtraining.functional" + let isDumbb = template?.isDumbbell ?? false + let sets = (0.. [WorkoutProgram] { + func s(_ count: Int, reps: Int = 12) -> [ExerciseSet] { + (0.. Color { + switch self { + case .orange: return AppColors.accent + case .green: return AppColors.green + case .blue: return AppColors.blue + case .yellow: return AppColors.yellow + } + } + func soft() -> Color { + switch self { + case .orange: return AppColors.accentSoft + case .green: return AppColors.greenSoft + case .blue: return AppColors.blueSoft + case .yellow: return AppColors.yellowSoft + } + } +} + +// MARK: - Task View Model +class TaskViewModel: ObservableObject { + @Published var tasks: [TaskItem] = sampleTasks + + var overdueTasks: [TaskItem] { + let startOfDay = Calendar.current.startOfDay(for: Date()) + return tasks.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay } + } + var todayTasks: [TaskItem] { + let start = Calendar.current.startOfDay(for: Date()) + let end = Calendar.current.date(byAdding: .day, value: 1, to: start)! + return tasks + .filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end } + .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + } + var upcomingTasks: [TaskItem] { + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, + to: Calendar.current.startOfDay(for: Date()))! + return tasks + .filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= tomorrow } + .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + } + var completedTodayTasks: [TaskItem] { + let startOfDay = Calendar.current.startOfDay(for: Date()) + return tasks + .filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay } + .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } + } + + func toggle(_ task: TaskItem) { + guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } + tasks[idx].isComplete.toggle() + tasks[idx].completedAt = tasks[idx].isComplete ? Date() : nil + } + func tasks(for quadrant: Quadrant) -> [TaskItem] { + tasks.filter { $0.quadrant == quadrant } + } + + func addTask(title: String, dueDate: Date?, quadrant: Quadrant = .urgent, + category: TaskCategory = .reminder, taskColor: TaskColor = .orange, + isRecurring: Bool = false) { + tasks.append(TaskItem(title: title, dueDate: dueDate, quadrant: quadrant, + category: category, taskColor: taskColor, isRecurring: isRecurring)) + } + + func postpone(_ task: TaskItem, days: Int = 1) { + guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } + let base = tasks[idx].dueDate ?? Date() + tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base) + } + + func postponeAllOverdue(days: Int = 1) { + let ids = overdueTasks.map { $0.id } + for id in ids { + guard let idx = tasks.firstIndex(where: { $0.id == id }) else { continue } + let base = tasks[idx].dueDate ?? Date() + tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base) + } + } + + func delete(_ task: TaskItem) { + tasks.removeAll { $0.id == task.id } + } +} + +// MARK: - Sample Data +private let sampleTasks: [TaskItem] = [ + .init(title: "Azure exam preparation", + dueDate: cal.date(byAdding: .day, value: -7, to: Date()), + quadrant: .urgent, category: .custom, taskColor: .orange), + .init(title: "Leona's Birthday", + dueDate: md(6, 17), quadrant: .urgent, category: .birthday, taskColor: .orange, isRecurring: true), + .init(title: "Provoc.ug Domain Expiry", + dueDate: md(7, 3), quadrant: .urgent, category: .domain, taskColor: .blue), + .init(title: "Farouk's Birthday", + dueDate: md(11, 8), quadrant: .delegate_, category: .birthday, taskColor: .green, isRecurring: true), + .init(title: "Enoch's Birthday", + dueDate: md(2, 23, 2027), quadrant: .schedule, category: .annual, taskColor: .yellow, isRecurring: true), + .init(title: "Dad's birthday", + dueDate: md(6, 8), quadrant: .delegate_, category: .birthday, taskColor: .blue, isRecurring: true), + .init(title: "Moms bd", + dueDate: md(2, 15, 2027), quadrant: .delegate_, category: .birthday, taskColor: .blue, isRecurring: true), + .init(title: "Amooti's birthday", + dueDate: md(6, 26), quadrant: .urgent, category: .birthday, taskColor: .orange, isRecurring: true), + .init(title: "Provoc hosting", + dueDate: md(11, 25, 2027), quadrant: .delegate_, category: .domain, taskColor: .blue), + .init(title: "DV lottery preps", + dueDate: md(9, 30, 2025), isComplete: true, quadrant: .eliminate, category: .custom, taskColor: .green), + .init(title: "Catch up Umutoni", + dueDate: md(11, 15, 2025), isComplete: true, quadrant: .eliminate, category: .custom, taskColor: .green), + .init(title: "Siraj wedding preps", + dueDate: md(8, 24, 2025), isComplete: true, quadrant: .eliminate, category: .custom, taskColor: .green), +] + +private let cal = Calendar.current +private func md(_ month: Int, _ day: Int, _ year: Int = 2026) -> Date { + var c = DateComponents(); c.year = year; c.month = month; c.day = day + return cal.date(from: c) ?? Date() +} diff --git a/KisaniCal/Preview Content/Preview Assets.xcassets/Contents.json b/KisaniCal/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/KisaniCal/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/KisaniCal/Theme/DesignTokens.swift b/KisaniCal/Theme/DesignTokens.swift new file mode 100644 index 0000000..613a94f --- /dev/null +++ b/KisaniCal/Theme/DesignTokens.swift @@ -0,0 +1,123 @@ +import SwiftUI + +// MARK: - Accent & Semantic Colors +struct AppColors { + + // Fixed accent palette + static let accent = Color(r: 232, g: 98, b: 42) + static let green = Color(r: 62, g: 207, b: 142) + static let blue = Color(r: 77, g: 157, b: 224) + static let yellow = Color(r: 232, g: 184, b: 42) + static let red = Color(r: 232, g: 66, b: 66) + + static let accentSoft = Color(UIColor { $0.userInterfaceStyle == .dark + ? UIColor(r: 52, g: 28, b: 14) // dark: deep warm orange + : UIColor(r: 252, g: 238, b: 228) })// light: warm peach wash + static let accentGhost = accent.opacity(0.05) + static let greenSoft = Color(UIColor { $0.userInterfaceStyle == .dark + ? UIColor(r: 14, g: 40, b: 28) + : UIColor(r: 220, g: 248, b: 236) }) + static let blueSoft = Color(UIColor { $0.userInterfaceStyle == .dark + ? UIColor(r: 14, g: 30, b: 50) + : UIColor(r: 222, g: 237, b: 252) }) + static let yellowSoft = Color(UIColor { $0.userInterfaceStyle == .dark + ? UIColor(r: 44, g: 34, b: 10) + : UIColor(r: 252, g: 243, b: 215) }) + static let redSoft = Color(UIColor { $0.userInterfaceStyle == .dark + ? UIColor(r: 50, g: 14, b: 14) + : UIColor(r: 252, g: 224, b: 224) }) + + // Adaptive surfaces + static func background(_ cs: ColorScheme) -> Color { + cs == .dark ? Color(r: 9, g: 9, b: 9) + : Color(r: 245, g: 243, b: 240) + } + static func surface(_ cs: ColorScheme) -> Color { + cs == .dark ? Color(r: 19, g: 19, b: 19) + : Color(r: 255, g: 253, b: 250) // warm white, not clinical pure white + } + static func surface2(_ cs: ColorScheme) -> Color { + cs == .dark ? Color(r: 26, g: 26, b: 26) + : Color(r: 240, g: 237, b: 232) + } + static func surface3(_ cs: ColorScheme) -> Color { + cs == .dark ? Color(r: 34, g: 34, b: 34) + : Color(r: 232, g: 228, b: 222) + } + static func text(_ cs: ColorScheme) -> Color { + cs == .dark ? Color(r: 240, g: 237, b: 232) + : Color(r: 26, g: 23, b: 20) + } + static func text2(_ cs: ColorScheme) -> Color { + text(cs).opacity(cs == .dark ? 0.44 : 0.50) + } + static func text3(_ cs: ColorScheme) -> Color { + text(cs).opacity(cs == .dark ? 0.24 : 0.34) + } + static func border(_ cs: ColorScheme) -> Color { + cs == .dark ? Color.white.opacity(0.07) + : Color.black.opacity(0.09) + } + static func borderHi(_ cs: ColorScheme) -> Color { + cs == .dark ? Color.white.opacity(0.13) + : Color.black.opacity(0.17) + } + static func phoneSheetBg(_ cs: ColorScheme) -> Color { + cs == .dark ? Color(r: 18, g: 18, b: 18) + : Color.white + } +} + +private extension Color { + init(r: Double, g: Double, b: Double) { + self.init(.sRGB, red: r / 255, green: g / 255, blue: b / 255) + } +} + +private extension UIColor { + convenience init(r: Int, g: Int, b: Int) { + self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: 1) + } +} + +// MARK: - Typography +struct AppFonts { + static func sans(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight, design: .default) + } + static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } +} + +// MARK: - Motion Tokens +enum KisaniSpring { + /// General entrance — slight overshoot, feels alive (≈ 320ms settle) + static let entrance = Animation.spring(response: 0.38, dampingFraction: 0.72) + /// Exit — snappier, ~20% shorter settle than entrance + static let exit = Animation.spring(response: 0.28, dampingFraction: 0.88) + /// Micro-interactions — button presses, toggles, chips (< 150ms feel) + static let micro = Animation.spring(response: 0.22, dampingFraction: 0.70) + /// Snappy — section expands, panel slides (≈ 250ms settle) + static let snappy = Animation.spring(response: 0.32, dampingFraction: 0.78) + /// Bouncy — checkmarks, completion states — noticeable overshoot + static let bounce = Animation.spring(response: 0.28, dampingFraction: 0.52) +} + +// MARK: - Corner Radii +enum AppRadius { + static let large: CGFloat = 16 + static let medium: CGFloat = 13 + static let small: CGFloat = 10 + static let pill: CGFloat = 20 +} + +// MARK: - Icon color helpers +extension Int { + var iconColor: Color { + [AppColors.accent, AppColors.blue, AppColors.green, AppColors.yellow][self % 4] + } + var iconBg: Color { + [AppColors.accentSoft, AppColors.blueSoft, AppColors.greenSoft, AppColors.yellowSoft][self % 4] + } +} diff --git a/KisaniCal/Views/AddExerciseSheet.swift b/KisaniCal/Views/AddExerciseSheet.swift new file mode 100644 index 0000000..4abaf22 --- /dev/null +++ b/KisaniCal/Views/AddExerciseSheet.swift @@ -0,0 +1,261 @@ +import SwiftUI + +// MARK: - Exercise Picker Sheet +struct ExercisePickerSheet: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + + let sectionId: UUID + var programId: UUID? = nil // nil = active program + + @State private var selectedMuscle: ExerciseMuscle = .chest + @State private var searchText = "" + @State private var customName = "" + @State private var setsText = "3" + @State private var repsText = "10" + @State private var showCustom = false + @FocusState private var customFocused: Bool + + private func addExercise(name: String) { + let sets = Int(setsText) ?? 3 + let reps = Int(repsText) ?? 10 + if let pid = programId { + vm.addExercise(programId: pid, sectionId: sectionId, name: name, setsCount: sets, repsCount: reps) + } else { + vm.addExercise(to: sectionId, name: name, setsCount: sets, repsCount: reps) + } + } + + private var filtered: [ExerciseTemplate] { + let byMuscle = exerciseLibrary.filter { $0.muscle == selectedMuscle } + guard !searchText.isEmpty else { return byMuscle } + return byMuscle.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + var body: some View { + VStack(spacing: 0) { + // ── Header ── + HStack { + Text("Add Exercise") + .font(AppFonts.sans(17, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + Spacer() + Button("Cancel") { dismiss() } + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text3(cs)) + .buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 12) + + Divider().background(AppColors.border(cs)) + + // ── Search ── + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 13)) + .foregroundColor(AppColors.text3(cs)) + TextField("Search exercises…", text: $searchText) + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text(cs)) + if !searchText.isEmpty { + Button { searchText = "" } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 13)) + .foregroundColor(AppColors.text3(cs)) + } + .buttonStyle(.plain) + } + } + .padding(10) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8) + + // ── Category Tabs ── + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(ExerciseMuscle.allCases) { muscle in + let isSel = selectedMuscle == muscle + Button { withAnimation(KisaniSpring.micro) { selectedMuscle = muscle } } label: { + HStack(spacing: 4) { + Image(systemName: muscle.sfSymbol).font(.system(size: 10)) + Text(muscle.rawValue).font(AppFonts.mono(9.5, weight: .bold)) + } + .foregroundColor(isSel ? AppColors.accent : AppColors.text3(cs)) + .padding(.horizontal, 11).padding(.vertical, 6) + .background(isSel ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(isSel ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + } + .buttonStyle(PressButtonStyle(scale: 0.94)) + } + } + .padding(.horizontal, 16) + } + .padding(.bottom, 8) + + // ── Exercise List ── + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 0) { + // Custom exercise entry + if showCustom { + VStack(alignment: .leading, spacing: 8) { + Text("CUSTOM EXERCISE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + TextField("Exercise name", text: $customName) + .font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + .focused($customFocused).padding(11) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(customFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + SetRepsRow(setsText: $setsText, repsText: $repsText) + Button { + let n = customName.trimmingCharacters(in: .whitespaces) + guard !n.isEmpty else { return } + addExercise(name: n) + dismiss() + } label: { + Text("Add Custom Exercise") + .font(AppFonts.sans(13, weight: .bold)).foregroundColor(.white) + .frame(maxWidth: .infinity).padding(11) + .background(!customName.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + } + .buttonStyle(.plain) + .disabled(customName.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(14) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.medium)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.medium).stroke(AppColors.accent.opacity(0.3), lineWidth: 1)) + .padding(.horizontal, 16).padding(.bottom, 8) + } + + // Library exercises + ForEach(filtered) { template in + ExerciseTemplateRow(template: template) { + addExercise(name: template.name) + dismiss() + } + Divider().background(AppColors.border(cs)).padding(.leading, 60) + } + + if filtered.isEmpty && !showCustom { + VStack(spacing: 6) { + Text("No exercises found") + .font(AppFonts.sans(13, weight: .medium)) + .foregroundColor(AppColors.text2(cs)) + Text("Try a different category or add a custom one") + .font(AppFonts.sans(11)) + .foregroundColor(AppColors.text3(cs)) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity).padding(.vertical, 30) + } + } + .padding(.horizontal, 16) + } + + // ── Footer: Default sets/reps + custom toggle ── + Divider().background(AppColors.border(cs)) + VStack(spacing: 10) { + SetRepsRow(setsText: $setsText, repsText: $repsText) + Button { + withAnimation(KisaniSpring.snappy) { + showCustom.toggle() + if showCustom { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { customFocused = true } } + } + } label: { + HStack(spacing: 6) { + Image(systemName: showCustom ? "xmark.circle" : "plus.circle") + .font(.system(size: 13)) + Text(showCustom ? "Cancel Custom" : "Add Custom Exercise") + .font(AppFonts.sans(12, weight: .semibold)) + } + .foregroundColor(AppColors.accent) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16).padding(.vertical, 12) + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - Exercise Template Row +private struct ExerciseTemplateRow: View { + @Environment(\.colorScheme) private var cs + let template: ExerciseTemplate + let onAdd: () -> Void + + var body: some View { + Button(action: onAdd) { + HStack(spacing: 12) { + Image(systemName: template.sfSymbol) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(AppColors.accent) + .frame(width: 36, height: 36) + .background(AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 9)) + + Text(template.name) + .font(AppFonts.sans(13, weight: .medium)) + .foregroundColor(AppColors.text(cs)) + + Spacer() + + Image(systemName: "plus.circle.fill") + .font(.system(size: 18)) + .foregroundColor(AppColors.accent.opacity(0.7)) + } + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +// MARK: - Sets / Reps Row (shared) +private struct SetRepsRow: View { + @Environment(\.colorScheme) private var cs + @Binding var setsText: String + @Binding var repsText: String + + var body: some View { + HStack(spacing: 8) { + HStack(spacing: 6) { + Text("Sets").font(AppFonts.mono(9.5, weight: .bold)).foregroundColor(AppColors.text3(cs)) + TextField("3", text: $setsText) + .font(AppFonts.mono(12, weight: .semibold)).foregroundColor(AppColors.text(cs)) + .keyboardType(.numberPad).multilineTextAlignment(.center) + .frame(width: 36).padding(.vertical, 5).padding(.horizontal, 4) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(AppColors.border(cs), lineWidth: 1)) + } + .padding(8) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(AppColors.border(cs), lineWidth: 1)) + + HStack(spacing: 6) { + Text("Reps").font(AppFonts.mono(9.5, weight: .bold)).foregroundColor(AppColors.text3(cs)) + TextField("10", text: $repsText) + .font(AppFonts.mono(12, weight: .semibold)).foregroundColor(AppColors.text(cs)) + .keyboardType(.numberPad).multilineTextAlignment(.center) + .frame(width: 36).padding(.vertical, 5).padding(.horizontal, 4) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(AppColors.border(cs), lineWidth: 1)) + } + .padding(8) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(AppColors.border(cs), lineWidth: 1)) + + Spacer() + } + } +} diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift new file mode 100644 index 0000000..3af0ec7 --- /dev/null +++ b/KisaniCal/Views/CalendarView.swift @@ -0,0 +1,1037 @@ +import SwiftUI +import EventKit + +// MARK: - Calendar Store +@MainActor +final class CalendarStore: ObservableObject { + @Published var authStatus: EKAuthorizationStatus = .notDetermined + @Published var monthEvents: [EKEvent] = [] + + private let ekStore = EKEventStore() + private var changeToken: NSObjectProtocol? + private var fetchedMonth: Date? + + init() { + authStatus = EKEventStore.authorizationStatus(for: .event) + changeToken = NotificationCenter.default.addObserver( + forName: .EKEventStoreChanged, + object: ekStore, + queue: .main + ) { [weak self] _ in + guard let self, let m = self.fetchedMonth else { return } + self.fetchMonth(containing: m) + } + } + + deinit { + if let t = changeToken { NotificationCenter.default.removeObserver(t) } + } + + // MARK: - Permission + + func requestAccess() { + if #available(iOS 17.0, *) { + ekStore.requestFullAccessToEvents { [weak self] granted, _ in + Task { @MainActor [weak self] in + self?.authStatus = granted ? .fullAccess : .denied + if granted { self?.fetchMonth(containing: Date()) } + } + } + } else { + ekStore.requestAccess(to: .event) { [weak self] granted, _ in + Task { @MainActor [weak self] in + self?.authStatus = granted ? .authorized : .denied + if granted { self?.fetchMonth(containing: Date()) } + } + } + } + } + + // MARK: - Fetching + + func fetchMonth(containing date: Date) { + guard isAuthorized else { return } + let cal = Calendar.current + guard let interval = cal.dateInterval(of: .month, for: date) else { return } + // Extend a week each side to cover leading/trailing grid overflow days + let start = cal.date(byAdding: .weekOfYear, value: -1, to: interval.start) ?? interval.start + let end = cal.date(byAdding: .weekOfYear, value: 1, to: interval.end) ?? interval.end + let pred = ekStore.predicateForEvents(withStart: start, end: end, calendars: nil) + fetchedMonth = date + monthEvents = ekStore.events(matching: pred) + .sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) } + } + + // Events for a specific day, derived from the cached month set + func events(for date: Date) -> [EKEvent] { + let cal = Calendar.current + let start = cal.startOfDay(for: date) + let end = cal.date(byAdding: .day, value: 1, to: start)! + return monthEvents.filter { event in + guard let s = event.startDate, let e = event.endDate else { return false } + return s < end && e > start + } + } + + var isAuthorized: Bool { + if #available(iOS 17.0, *) { + return authStatus == .authorized || authStatus == .fullAccess + } + return authStatus == .authorized + } +} + +// MARK: - Calendar View Mode +enum CalendarViewMode: String, CaseIterable { + case list = "List" + case year = "Year" + case month = "Month" + case week = "Week" + case threeDay = "3 Day" + case day = "Day" + + var sfSymbol: String { + switch self { + case .list: return "list.bullet.below.rectangle" + case .year: return "square.grid.4x3.fill" + case .month: return "square.grid.3x2.fill" + case .week: return "rectangle.split.3x1.fill" + case .threeDay: return "square.split.2x1.fill" + case .day: return "rectangle.portrait.fill" + } + } +} + +// MARK: - Calendar View +struct CalendarView: View { + @Environment(\.colorScheme) var cs + @EnvironmentObject var taskVM: TaskViewModel + @EnvironmentObject var workoutVM: WorkoutViewModel + @StateObject private var calStore = CalendarStore() + @State private var selectedDate: Date = Date() + @State private var displayMonth: Date = { + let c = Calendar.current + return c.date(from: c.dateComponents([.year, .month], from: Date())) ?? Date() + }() + @State private var viewMode: CalendarViewMode = .month + @State private var showAddTask = false + @State private var showCalendarConnect = false + @State private var showWorkoutSession = false + + private let cal = Calendar.current + private let weekdays = ["S","M","T","W","T","F","S"] + + var body: some View { + ZStack(alignment: .bottomTrailing) { + AppColors.background(cs).ignoresSafeArea() + + VStack(spacing: 0) { + // ── Header ── + HStack { + Text(monthShortTitle) + .font(AppFonts.sans(26, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + Spacer() + HStack(spacing: 0) { + // View mode picker + Menu { + Picker(selection: $viewMode, label: EmptyView()) { + ForEach(CalendarViewMode.allCases, id: \.self) { mode in + Label(mode.rawValue, systemImage: mode.sfSymbol).tag(mode) + } + } + } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.text(cs)) + .frame(width: 46, height: 36) + } + .buttonStyle(.plain) + // Divider + Rectangle() + .fill(AppColors.border(cs)) + .frame(width: 1, height: 18) + // More options + Menu { + Button { } label: { Label("Filter View Range", systemImage: "line.3.horizontal.decrease") } + Button { } label: { Label("View Options", systemImage: "gearshape.2") } + Button { } label: { Label("Arrange Tasks", systemImage: "list.bullet.rectangle") } + Divider() + Button { showCalendarConnect = true } label: { + Label("Calendar Subscription", systemImage: calStore.isAuthorized ? "calendar.badge.checkmark" : "calendar.badge.plus") + } + Divider() + Button { } label: { Label("Share", systemImage: "square.and.arrow.up") }.disabled(true) + Button { } label: { Label("Print", systemImage: "printer") }.disabled(true) + Button { } label: { Label("Select", systemImage: "checkmark.circle") }.disabled(true) + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.text(cs)) + .frame(width: 46, height: 36) + } + .buttonStyle(.plain) + } + .background(AppColors.surface2(cs)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(AppColors.border(cs), lineWidth: 1)) + } + .padding(.horizontal, 18).padding(.top, 8) + + calendarContent + } + + FAB { showAddTask = true }.padding(.trailing, 20).padding(.bottom, 10) + } + .sheet(isPresented: $showAddTask) { + AddTaskSheet(prefilledDate: selectedDate) + .environmentObject(taskVM) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showCalendarConnect) { + CalendarConnectSheet(calStore: calStore) + .presentationDetents([.height(340)]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showWorkoutSession) { + WorkoutSessionSheet() + .environmentObject(workoutVM) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .onAppear { + if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) } + } + .onChange(of: displayMonth) { newMonth in + if calStore.isAuthorized { calStore.fetchMonth(containing: newMonth) } + } + } + + private var monthTitle: String { + let f = DateFormatter(); f.dateFormat = "MMMM yyyy" + return f.string(from: displayMonth) + } + + private var monthShortTitle: String { + let f = DateFormatter(); f.dateFormat = "MMMM" + return f.string(from: displayMonth) + } + + private func navigateMonth(_ offset: Int) { + withAnimation(KisaniSpring.snappy) { + displayMonth = cal.date(byAdding: .month, value: offset, to: displayMonth) ?? displayMonth + } + } + + private func gridDays() -> [Date] { + guard + let interval = cal.dateInterval(of: .month, for: displayMonth), + let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start), + let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1) + else { return [] } + var days: [Date] = []; var cur = firstWeek.start + while cur < lastWeek.end { + days.append(cur) + cur = cal.date(byAdding: .day, value: 1, to: cur)! + } + return days + } + + private func eventDots(for date: Date) -> [Color] { + var dots: [Color] = [] + if workoutVM.program(for: date) != nil { dots.append(AppColors.blue) } + if calStore.isAuthorized && dots.count < 3 { + let calDay = calStore.events(for: date) + if !calDay.isEmpty { + let calColor = calDay.first.flatMap { Color(cgColor: $0.calendar.cgColor) } ?? AppColors.green + dots.append(calColor) + } + } + let taskDots = taskVM.tasks + .filter { !$0.isComplete } + .filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) } + .prefix(max(0, 3 - dots.count)) + .map { $0.taskColor.color() } + dots.append(contentsOf: taskDots) + return dots + } + + // MARK: - Content switcher + + @ViewBuilder private var calendarContent: some View { + switch viewMode { + case .month: monthView + case .list: listView + case .year: yearView + case .week: weekView + case .threeDay: threeDayView + case .day: dayView + } + } + + // MARK: - Month + + private var monthView: some View { + Group { + HStack(spacing: 0) { + ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in + Text(d).font(AppFonts.mono(9, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 3) + + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 1), count: 7), spacing: 1) { + ForEach(gridDays(), id: \.self) { date in + DayCell( + date: date, + isCurrentMonth: cal.isDate(date, equalTo: displayMonth, toGranularity: .month), + isToday: cal.isDateInToday(date), + isSelected: cal.isDate(date, inSameDayAs: selectedDate), + dots: eventDots(for: date) + ) + .onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } } + } + } + .padding(.horizontal, 16) + .gesture(DragGesture(minimumDistance: 40).onEnded { v in + if v.translation.width < -40 { navigateMonth(1) } + else if v.translation.width > 40 { navigateMonth(-1) } + }) + + Divider().background(AppColors.border(cs)).padding(.horizontal, 16).padding(.top, 6) + + ScrollView(showsIndicators: false) { + DayStripView( + date: selectedDate, + tasks: taskVM.tasks.filter { !$0.isComplete }, + calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [], + workout: workoutVM.program(for: selectedDate), + onWorkoutTap: { + if let w = workoutVM.program(for: selectedDate), + workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) } + showWorkoutSession = true + } + ) + .padding(.horizontal, 16).padding(.vertical, 10) + } + } + } + + // MARK: - List + + private var listView: some View { + VStack(spacing: 0) { + cvWeekStrip.padding(.top, 12).padding(.bottom, 6) + Divider().background(AppColors.border(cs)).padding(.horizontal, 16) + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + let dayTasks = taskVM.tasks.filter { t in + guard !t.isComplete, let d = t.dueDate else { return false } + return cal.isDate(d, inSameDayAs: selectedDate) + } + let events = calStore.isAuthorized ? calStore.events(for: selectedDate) : [] + if dayTasks.isEmpty && events.isEmpty { + cvEmptyDay + } else { + ForEach(events, id: \.eventIdentifier) { ev in + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 2) + .fill(Color(cgColor: ev.calendar.cgColor)) + .frame(width: 3, height: 38) + VStack(alignment: .leading, spacing: 2) { + Text(ev.title ?? "Event") + .font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text(cs)) + if let s = ev.startDate { + Text(cvTimeFmt.string(from: s)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs)) + } + } + Spacer() + } + .padding(.horizontal, 16).padding(.vertical, 10) + Divider().background(AppColors.border(cs)).padding(.leading, 44) + } + ForEach(dayTasks) { task in + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 3) + .strokeBorder(task.taskColor.color(), lineWidth: 1.5) + .frame(width: 18, height: 18) + Text(task.title).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + if let d = task.dueDate { + let h = cal.component(.hour, from: d) + let m = cal.component(.minute, from: d) + if h != 0 || m != 0 { + Text(cvTimeFmt.string(from: d)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs)) + } + } + } + .padding(.horizontal, 16).padding(.vertical, 10) + Divider().background(AppColors.border(cs)).padding(.leading, 44) + } + } + } + .padding(.top, 4) + } + } + } + + // MARK: - Year + + private var yearView: some View { + let year = cal.component(.year, from: displayMonth) + let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) } + return ScrollView(showsIndicators: false) { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) { + ForEach(months, id: \.self) { m in + cvMiniMonth(m) + } + } + .padding(16) + } + } + + private func cvMiniMonth(_ month: Date) -> some View { + let mDays = cvMiniGridDays(month) + return VStack(spacing: 3) { + Text(cvMonthFmt.string(from: month)) + .font(AppFonts.sans(10, weight: .semibold)).foregroundColor(AppColors.text(cs)) + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 1) { + ForEach(Array(mDays.enumerated()), id: \.offset) { _, date in + let isToday = cal.isDateInToday(date) + let inMon = cal.isDate(date, equalTo: month, toGranularity: .month) + ZStack { + if isToday { Circle().fill(AppColors.accent).frame(width: 14, height: 14) } + Text("\(cal.component(.day, from: date))") + .font(AppFonts.sans(7, weight: isToday ? .bold : .regular)) + .foregroundColor(isToday ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs)) + } + .frame(height: 14) + .onTapGesture { + withAnimation(KisaniSpring.micro) { + selectedDate = date + displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? date + viewMode = .month + } + } + } + } + } + } + + private func cvMiniGridDays(_ month: Date) -> [Date] { + guard let interval = cal.dateInterval(of: .month, for: month), + let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start), + let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1) + else { return [] } + var days: [Date] = []; var cur = firstWeek.start + while cur < lastWeek.end { days.append(cur); cur = cal.date(byAdding: .day, value: 1, to: cur)! } + return days + } + + // MARK: - Week + + private var weekView: some View { + HStack(alignment: .top, spacing: 0) { + VStack(spacing: 0) { + HStack(spacing: 0) { + ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in + Text(d).font(AppFonts.mono(7, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 6).padding(.top, 10) + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) { + ForEach(Array(gridDays().enumerated()), id: \.offset) { _, date in + let isTod = cal.isDateInToday(date) + let isSel = cal.isDate(date, inSameDayAs: selectedDate) + let inMon = cal.isDate(date, equalTo: displayMonth, toGranularity: .month) + ZStack { + if isTod { Circle().fill(AppColors.accent).frame(width: 20, height: 20) } + else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 20, height: 20) } + Text("\(cal.component(.day, from: date))") + .font(AppFonts.sans(9, weight: isTod ? .bold : .regular)) + .foregroundColor(isTod ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs)) + } + .frame(height: 22) + .onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } } + } + } + .padding(.horizontal, 4) + .gesture(DragGesture(minimumDistance: 40).onEnded { v in + if v.translation.width < -40 { navigateMonth(1) } + else if v.translation.width > 40 { navigateMonth(-1) } + }) + Spacer() + } + .frame(maxWidth: .infinity) + + Rectangle().fill(AppColors.border(cs)).frame(width: 1) + + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + ForEach(cvWeekDays(), id: \.self) { date in + let isTod = cal.isDateInToday(date) + let isSel = cal.isDate(date, inSameDayAs: selectedDate) + let dots = eventDots(for: date) + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 4) { + Text(cvDayFmt.string(from: date)) + .font(AppFonts.mono(9, weight: .semibold)) + .foregroundColor(isTod ? AppColors.accent : AppColors.text2(cs)) + Text("\(cal.component(.day, from: date))") + .font(AppFonts.sans(12, weight: isTod ? .bold : .medium)) + .foregroundColor(isTod ? AppColors.accent : AppColors.text(cs)) + } + if dots.isEmpty { + Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs)) + } else { + ForEach(dots.indices, id: \.self) { i in + RoundedRectangle(cornerRadius: 2).fill(dots[i].opacity(0.5)) + .frame(maxWidth: .infinity).frame(height: 7) + } + } + } + .padding(10) + .background(isSel ? AppColors.accentSoft : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } } + Divider().background(AppColors.border(cs)) + } + } + } + .frame(maxWidth: .infinity) + } + } + + // MARK: - 3 Day + + private var threeDayView: some View { + let days = cvAdjacentDays(3) + return VStack(spacing: 0) { + cvDayColumnHeader(days) + Divider().background(AppColors.border(cs)) + ScrollView(showsIndicators: false) { + cvTimeGrid(days: days).padding(.bottom, 40) + } + } + } + + // MARK: - Day + + private var dayView: some View { + VStack(spacing: 0) { + cvWeekStrip.padding(.top, 12).padding(.bottom, 6) + Divider().background(AppColors.border(cs)) + ScrollView(showsIndicators: false) { + cvTimeGrid(days: [selectedDate]).padding(.bottom, 40) + } + } + } + + // MARK: - Time grid + + private let cvStartH = 6 + private let cvEndH = 22 + private let cvHourH: CGFloat = 52 + + private func cvDayColumnHeader(_ days: [Date]) -> some View { + HStack(spacing: 0) { + Color.clear.frame(width: 36) + ForEach(days, id: \.self) { date in + let isTod = cal.isDateInToday(date) + VStack(spacing: 2) { + Text(cvDayFmt.string(from: date).uppercased()) + .font(AppFonts.mono(8, weight: .semibold)).foregroundColor(AppColors.text3(cs)) + ZStack { + if isTod { Circle().fill(AppColors.accent).frame(width: 28, height: 28) } + Text("\(cal.component(.day, from: date))") + .font(AppFonts.sans(14, weight: isTod ? .bold : .medium)) + .foregroundColor(isTod ? .white : AppColors.text(cs)) + } + } + .frame(maxWidth: .infinity) + } + } + .padding(.vertical, 10).padding(.horizontal, 8) + } + + private func cvTimeGrid(days: [Date]) -> some View { + let totalH = CGFloat(cvEndH - cvStartH) * cvHourH + return ZStack(alignment: .topLeading) { + VStack(spacing: 0) { + ForEach(cvStartH...cvEndH, id: \.self) { h in + HStack(spacing: 0) { + Text(h == 12 ? "12" : h > 12 ? "\(h - 12)" : "\(h)") + .font(AppFonts.mono(8.5)).foregroundColor(AppColors.text3(cs)) + .frame(width: 28, alignment: .trailing) + Rectangle().fill(AppColors.border(cs)).frame(height: 0.5).padding(.leading, 8) + } + .frame(height: cvHourH, alignment: .top) + } + } + .padding(.horizontal, 8) + + HStack(alignment: .top, spacing: 2) { + Color.clear.frame(width: 36) + ForEach(days, id: \.self) { date in + cvTimeColumn(date: date, totalH: totalH) + } + } + .padding(.horizontal, 8) + .frame(height: totalH) + } + } + + private func cvTimeColumn(date: Date, totalH: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + Color.clear.frame(maxWidth: .infinity).frame(height: totalH) + ForEach(cvTimeItems(date)) { item in + let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH) + let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28) + RoundedRectangle(cornerRadius: 4) + .fill(item.color.opacity(0.28)) + .overlay(alignment: .leading) { + Rectangle().fill(item.color).frame(width: 3) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + .overlay(alignment: .topLeading) { + Text(item.title) + .font(AppFonts.sans(9, weight: .medium)) + .foregroundColor(item.color) + .padding(.horizontal, 6).padding(.top, 4) + .lineLimit(2) + } + .frame(maxWidth: .infinity).frame(height: dur) + .padding(.trailing, 2) + .offset(y: top) + } + } + .frame(maxWidth: .infinity) + } + + // MARK: - Shared helpers + + private var cvWeekStrip: some View { + HStack(spacing: 0) { + ForEach(cvWeekDays(), id: \.self) { date in + let isTod = cal.isDateInToday(date) + let isSel = cal.isDate(date, inSameDayAs: selectedDate) + VStack(spacing: 3) { + Text(cvDayFmt.string(from: date)) + .font(AppFonts.mono(9, weight: .semibold)).foregroundColor(AppColors.text3(cs)) + ZStack { + if isTod { Circle().fill(AppColors.accent).frame(width: 28, height: 28) } + else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 28, height: 28) } + Text("\(cal.component(.day, from: date))") + .font(AppFonts.sans(13, weight: isTod ? .bold : .regular)) + .foregroundColor(isTod ? .white : AppColors.text(cs)) + } + } + .frame(maxWidth: .infinity) + .onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } } + } + } + .padding(.horizontal, 16) + .gesture(DragGesture(minimumDistance: 40).onEnded { v in + let delta = v.translation.width < -40 ? 7 : v.translation.width > 40 ? -7 : 0 + guard delta != 0 else { return } + withAnimation(KisaniSpring.snappy) { + selectedDate = cal.date(byAdding: .day, value: delta, to: selectedDate) ?? selectedDate + displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: selectedDate)) ?? displayMonth + } + }) + } + + private var cvEmptyDay: some View { + VStack(spacing: 4) { + Text("Nothing scheduled").font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text2(cs)) + Text("Tap + to add a task").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + } + .frame(maxWidth: .infinity).padding(.vertical, 48) + } + + private func cvWeekDays() -> [Date] { + let wd = cal.component(.weekday, from: selectedDate) + let start = cal.date(byAdding: .day, value: -(wd - 1), to: selectedDate) ?? selectedDate + return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) } + } + + private func cvAdjacentDays(_ count: Int) -> [Date] { + let half = count / 2 + return (-half...(count - half - 1)).compactMap { cal.date(byAdding: .day, value: $0, to: selectedDate) } + } + + private func cvTimeItems(_ date: Date) -> [CVTimeItem] { + var items: [CVTimeItem] = [] + for task in taskVM.tasks where !task.isComplete { + guard let d = task.dueDate, cal.isDate(d, inSameDayAs: date) else { continue } + let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d) + guard h != 0 || m != 0 else { continue } + items.append(CVTimeItem(title: task.title, color: task.taskColor.color(), startMin: h * 60 + m, endMin: h * 60 + m + 30)) + } + for ev in calStore.isAuthorized ? calStore.events(for: date) : [] { + guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue } + let sh = cal.component(.hour, from: s), sm = cal.component(.minute, from: s) + let eh = cal.component(.hour, from: e), em = cal.component(.minute, from: e) + items.append(CVTimeItem(title: ev.title ?? "Event", color: Color(cgColor: ev.calendar.cgColor), + startMin: sh * 60 + sm, endMin: max(eh * 60 + em, sh * 60 + sm + 30))) + } + return items + } + + private let cvTimeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm a"; return f }() + private let cvDayFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE"; return f }() + private let cvMonthFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "MMM"; return f }() +} + +struct CVTimeItem: Identifiable { + let id: String + let title: String + let color: Color + let startMin: Int + let endMin: Int + + init(title: String, color: Color, startMin: Int, endMin: Int) { + self.id = "\(title)-\(startMin)" + self.title = title + self.color = color + self.startMin = startMin + self.endMin = endMin + } +} + +// MARK: - Calendar Connect Sheet +struct CalendarConnectSheet: View { + @ObservedObject var calStore: CalendarStore + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + @Environment(\.openURL) var openURL + @State private var webCalURL = "" + @FocusState private var webCalFocused: Bool + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Calendars").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs)) + Spacer() + Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) + + Divider().background(AppColors.border(cs)) + + VStack(spacing: 0) { + // Local Calendar row + HStack(spacing: 14) { + Image(systemName: "calendar") + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(AppColors.accent) + .clipShape(RoundedRectangle(cornerRadius: 9)) + + VStack(alignment: .leading, spacing: 2) { + Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Text(calStore.isAuthorized ? "Connected — events shown in day view" : "Tap to connect") + .font(AppFonts.sans(11)).foregroundColor(calStore.isAuthorized ? AppColors.green : AppColors.text3(cs)) + } + Spacer() + if calStore.isAuthorized { + Image(systemName: "checkmark.circle.fill").foregroundColor(AppColors.green).font(.system(size: 18)) + } else { + Button("Connect") { + calStore.requestAccess() + } + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(AppColors.accent) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(AppColors.accentSoft) + .clipShape(Capsule()) + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16).padding(.vertical, 12) + + Divider().background(AppColors.border(cs)).padding(.leading, 66) + + // WebCal row + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 14) { + Image(systemName: "globe") + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(AppColors.blue) + .clipShape(RoundedRectangle(cornerRadius: 9)) + VStack(alignment: .leading, spacing: 2) { + Text("Web Calendar (WebCal)").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Text("Subscribe to an iCal / .ics feed").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + } + .padding(.horizontal, 16).padding(.top, 12) + + HStack(spacing: 8) { + TextField("webcal:// or https://…", text: $webCalURL) + .font(AppFonts.sans(12)).foregroundColor(AppColors.text(cs)) + .focused($webCalFocused).padding(10) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(webCalFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + .autocorrectionDisabled().textInputAutocapitalization(.never) + + Button { + var urlString = webCalURL.trimmingCharacters(in: .whitespaces) + if urlString.hasPrefix("https://") { urlString = urlString.replacingOccurrences(of: "https://", with: "webcal://") } + if let url = URL(string: urlString) { openURL(url) } + } label: { + Text("Subscribe").font(AppFonts.sans(12, weight: .semibold)).foregroundColor(.white) + .padding(.horizontal, 12).padding(.vertical, 10) + .background(!webCalURL.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.blue : AppColors.borderHi(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + } + .buttonStyle(.plain).disabled(webCalURL.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(.horizontal, 16).padding(.bottom, 12) + } + + Divider().background(AppColors.border(cs)).padding(.leading, 66) + + // Google Calendar row + HStack(spacing: 14) { + Image(systemName: "g.circle.fill") + .font(.system(size: 18)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(Color(red: 0.26, green: 0.52, blue: 0.96)) + .clipShape(RoundedRectangle(cornerRadius: 9)) + VStack(alignment: .leading, spacing: 2) { + Text("Google Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Text("Open in Google Calendar app").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + Image(systemName: "arrow.up.right").font(.system(size: 11)).foregroundColor(AppColors.text3(cs)) + } + .padding(.horizontal, 16).padding(.vertical, 12) + .contentShape(Rectangle()) + .onTapGesture { + if let url = URL(string: "googlegmail://") { + openURL(url) + } + } + } + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 20).padding(.top, 16) + + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - Day Cell +struct DayCell: View { + @Environment(\.colorScheme) private var cs + let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let dots: [Color] + private let cal = Calendar.current + var dayNum: String { "\(cal.component(.day, from: date))" } + var weekNum: Int { cal.component(.weekOfYear, from: date) } + var isSunday: Bool { cal.component(.weekday, from: date) == 1 } + + var body: some View { + VStack(spacing: 2) { + ZStack { + if isToday { Circle().fill(AppColors.accent).frame(width: 26, height: 26) } + else if isSelected { Circle().fill(AppColors.accentSoft).frame(width: 26, height: 26) } + Text(dayNum) + .font(AppFonts.sans(12.5, weight: isToday ? .bold : (isCurrentMonth ? .medium : .regular))) + .foregroundColor(isToday ? .white : !isCurrentMonth ? AppColors.text3(cs) : AppColors.text(cs)) + } + .frame(width: 26, height: 26) + .overlay(alignment: .topLeading) { + if isSunday { + Text("W\(weekNum)").font(AppFonts.mono(6.5)).foregroundColor(AppColors.text3(cs)).offset(x: -2, y: -10) + } + } + HStack(spacing: 2) { + ForEach(dots.indices, id: \.self) { i in + Circle().fill(dots[i]).frame(width: 3.5, height: 3.5) + } + } + .frame(height: 4) + } + .frame(maxWidth: .infinity).frame(height: 42).contentShape(Rectangle()) + } +} + +// MARK: - Day Strip +struct DayStripView: View { + @Environment(\.colorScheme) private var cs + let date: Date + let tasks: [TaskItem] + let calEvents: [EKEvent] + var workout: WorkoutProgram? = nil + var onWorkoutTap: (() -> Void)? = nil + + private let cal = Calendar.current + private let fmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE d"; return f }() + private let timeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm a"; return f }() + + var dayTasks: [TaskItem] { + tasks.filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) } + } + + var isEmpty: Bool { dayTasks.isEmpty && calEvents.isEmpty && workout == nil } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text(fmt.string(from: date).uppercased()) + .font(AppFonts.mono(10.5, weight: .bold)) + .foregroundColor(AppColors.accent) + let total = dayTasks.count + calEvents.count + (workout != nil ? 1 : 0) + Text(isEmpty ? "Nothing scheduled" : "\(total) event\(total == 1 ? "" : "s")") + .font(AppFonts.sans(10.5)) + .foregroundColor(AppColors.text3(cs)) + } + + if isEmpty { + VStack(spacing: 2) { + Text("You have a free day").font(AppFonts.sans(12.5, weight: .medium)).foregroundColor(AppColors.text2(cs)) + Text("Take it easy").font(AppFonts.sans(10.5)).foregroundColor(AppColors.text3(cs)) + } + .frame(maxWidth: .infinity).padding(.vertical, 6) + } else { + // Workout block + if let w = workout { + CalendarWorkoutBlock(program: w, onTap: onWorkoutTap) + } + + // Calendar events + ForEach(calEvents, id: \.eventIdentifier) { event in + HStack(spacing: 8) { + Circle().fill(Color(cgColor: event.calendar.cgColor)).frame(width: 6, height: 6) + VStack(alignment: .leading, spacing: 1) { + Text(event.title ?? "Event") + .font(AppFonts.sans(12, weight: .medium)) + .foregroundColor(AppColors.text(cs)) + if let start = event.startDate { + Text(timeFmt.string(from: start)) + .font(AppFonts.mono(9.5)) + .foregroundColor(AppColors.text3(cs)) + } + } + Spacer() + TagChip(text: "Cal", color: Color(cgColor: event.calendar.cgColor), bg: Color(cgColor: event.calendar.cgColor).opacity(0.12)) + } + .padding(.vertical, 5).padding(.horizontal, 10) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1)) + } + + // Task items + ForEach(dayTasks) { task in + HStack(spacing: 8) { + Circle().fill(task.taskColor.color()).frame(width: 6, height: 6) + Text(task.title).font(AppFonts.sans(12)).foregroundColor(AppColors.text(cs)) + Spacer() + TagChip(text: task.category.rawValue, color: task.taskColor.color(), bg: task.taskColor.soft()) + } + .padding(.vertical, 5).padding(.horizontal, 10) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1)) + } + } + } + } +} + +// MARK: - Calendar Workout Block +struct CalendarWorkoutBlock: View { + @Environment(\.colorScheme) private var cs + let program: WorkoutProgram + var onTap: (() -> Void)? = nil + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // ── Header row — taps open session ── + HStack(spacing: 8) { + Text(program.emoji).font(.system(size: 16)) + .frame(width: 30, height: 30) + .background(AppColors.blueSoft) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 1) { + Text(program.name) + .font(AppFonts.sans(12, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + Text("\(program.totalExercises) exercise\(program.totalExercises == 1 ? "" : "s") · \(program.sections.count) section\(program.sections.count == 1 ? "" : "s")") + .font(AppFonts.mono(9)) + .foregroundColor(AppColors.text3(cs)) + } + Spacer() + HStack(spacing: 6) { + TagChip(text: "Workout", color: AppColors.blue, bg: AppColors.blueSoft) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(AppColors.blue.opacity(0.45)) + } + } + .padding(.horizontal, 10).padding(.vertical, 8) + .contentShape(Rectangle()) + .onTapGesture { onTap?() } + + // ── Expand toggle ── + if !program.sections.isEmpty { + Button { + withAnimation(KisaniSpring.snappy) { isExpanded.toggle() } + } label: { + HStack(spacing: 4) { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 7, weight: .bold)) + Text(isExpanded ? "Less" : "Show \(program.sections.count) sections") + .font(AppFonts.mono(8.5, weight: .bold)) + } + .foregroundColor(AppColors.blue.opacity(0.55)) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + + // ── Section breakdown (expanded) ── + if isExpanded { + Divider().background(AppColors.blue.opacity(0.12)) + VStack(alignment: .leading, spacing: 6) { + ForEach(program.sections) { section in + HStack(alignment: .top, spacing: 6) { + Text(section.name.uppercased()) + .font(AppFonts.mono(8, weight: .bold)) + .foregroundColor(AppColors.blue.opacity(0.75)) + .frame(width: 64, alignment: .leading) + Text(section.exercises.isEmpty + ? "No exercises" + : section.exercises.map(\.name).joined(separator: " · ")) + .font(AppFonts.sans(10.5)) + .foregroundColor(AppColors.text2(cs)) + .lineLimit(2) + Spacer() + } + } + } + .padding(.horizontal, 10).padding(.top, 6).padding(.bottom, 10) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + .background(AppColors.blue.opacity(0.07)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.blue.opacity(0.2), lineWidth: 1)) + } +} diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift new file mode 100644 index 0000000..97997d3 --- /dev/null +++ b/KisaniCal/Views/MatrixView.swift @@ -0,0 +1,192 @@ +import SwiftUI + +struct MatrixView: View { + @Environment(\.colorScheme) var cs + @EnvironmentObject var taskVM: TaskViewModel + @State private var showAddTask = false + + var body: some View { + ZStack(alignment: .bottomTrailing) { + AppColors.background(cs).ignoresSafeArea() + + VStack(spacing: 0) { + // ── Header ── + HStack { + Text("Eisenhower Matrix") + .font(AppFonts.sans(15, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + Spacer() + IButton(icon: "ellipsis") + } + .padding(.horizontal, 18) + .padding(.top, 12) + .padding(.bottom, 10) + + // ── 2×2 Grid — fixed equal quadrants ── + VStack(spacing: 7) { + HStack(spacing: 7) { + QuadrantCard( + roman: "I", label: "Urgent & Important", + badgeBg: AppColors.accentSoft, badgeColor: AppColors.accent, + tasks: taskVM.tasks(for: .urgent) + ) { taskVM.toggle($0) } + + QuadrantCard( + roman: "II", label: "Not Urgent & Important", + badgeBg: AppColors.yellowSoft, badgeColor: AppColors.yellow, + tasks: taskVM.tasks(for: .schedule) + ) { taskVM.toggle($0) } + } + .frame(maxHeight: .infinity) + + HStack(spacing: 7) { + QuadrantCard( + roman: "III", label: "Urgent & Unimportant", + badgeBg: AppColors.blueSoft, badgeColor: AppColors.blue, + tasks: taskVM.tasks(for: .delegate_) + ) { taskVM.toggle($0) } + + QuadrantCard( + roman: "IV", label: "Not Urgent & Unimportant", + badgeBg: AppColors.greenSoft, badgeColor: AppColors.green, + tasks: taskVM.tasks(for: .eliminate) + ) { taskVM.toggle($0) } + } + .frame(maxHeight: .infinity) + } + .frame(maxHeight: .infinity) + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + .frame(maxHeight: .infinity) + + FAB { showAddTask = true } + .padding(.trailing, 20) + .padding(.bottom, 10) + } + .sheet(isPresented: $showAddTask) { + AddTaskSheet() + .environmentObject(taskVM) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + } +} + +// MARK: - Quadrant Card +struct QuadrantCard: View { + @Environment(\.colorScheme) private var cs + let roman: String + let label: String + let badgeBg: Color + let badgeColor: Color + let tasks: [TaskItem] + let onToggle: (TaskItem) -> Void + + private let shortFmt: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d MMM" + return f + }() + + private let shortFmtYear: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d/MM/yyyy" + return f + }() + + private func dateLabel(_ date: Date) -> String { + let cal = Calendar.current + if cal.isDateInThisYear(date) { + return shortFmt.string(from: date) + } + return shortFmtYear.string(from: date) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // ── Header badge ── + HStack(spacing: 4) { + Text(roman) + .font(AppFonts.mono(7.5, weight: .heavy)) + .foregroundColor(.white) + .frame(width: 13, height: 13) + .background(badgeColor) + .clipShape(Circle()) + Text(label.uppercased()) + .font(AppFonts.mono(8, weight: .heavy)) + .foregroundColor(badgeColor) + .lineLimit(1) + .minimumScaleFactor(0.65) + } + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(badgeBg) + .cornerRadius(20) + .padding(.bottom, 7) + + // ── Scrollable task list ── + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(tasks) { task in + HStack(alignment: .top, spacing: 6) { + RoundedRectangle(cornerRadius: 3) + .stroke( + task.isComplete ? AppColors.border(cs) : badgeColor, + lineWidth: 1.5 + ) + .background( + task.isComplete + ? RoundedRectangle(cornerRadius: 3) + .fill(AppColors.border(cs).opacity(0.5)) + : nil + ) + .overlay( + task.isComplete + ? Image(systemName: "checkmark") + .font(.system(size: 6.5, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + : nil + ) + .frame(width: 13, height: 13) + .padding(.top, 1.5) + .contentShape(Rectangle().size(CGSize(width: 28, height: 28))) + .onTapGesture { + withAnimation(KisaniSpring.bounce) { onToggle(task) } + } + + VStack(alignment: .leading, spacing: 1) { + Text(task.title) + .font(AppFonts.sans(10.5)) + .foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs)) + .strikethrough(task.isComplete, color: AppColors.text3(cs)) + .lineLimit(2) + if let d = task.dueDate { + Text(dateLabel(d)) + .font(AppFonts.mono(8.5)) + .foregroundColor(task.isComplete ? AppColors.text3(cs) : badgeColor) + } + } + } + .opacity(task.isComplete ? 0.38 : 1) + .padding(.vertical, 3.5) + } + } + } + } + .padding(10) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.medium)) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.medium) + .stroke(AppColors.border(cs), lineWidth: 1) + ) + } +} + +private extension Calendar { + func isDateInThisYear(_ date: Date) -> Bool { + component(.year, from: date) == component(.year, from: Date()) + } +} diff --git a/KisaniCal/Views/OnboardingView.swift b/KisaniCal/Views/OnboardingView.swift new file mode 100644 index 0000000..72ac46b --- /dev/null +++ b/KisaniCal/Views/OnboardingView.swift @@ -0,0 +1,223 @@ +import SwiftUI + +struct OnboardingView: View { + @Environment(\.colorScheme) private var cs + @Binding var isPresented: Bool + + @AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 0 + @AppStorage("heightCm") private var heightCm: Double = 0 + @AppStorage("weightUnit") private var weightUnit: Int = 0 // 0 = kg, 1 = lbs + + @State private var weightStr = "" + @State private var heightStr = "" + @FocusState private var focusedField: Field? + + fileprivate enum Field { case weight, height } + + private var canContinue: Bool { + (Double(weightStr) ?? 0) > 0 && (Double(heightStr) ?? 0) > 0 + } + + var body: some View { + ZStack { + AppColors.background(cs).ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + // ── Brand mark ── + VStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 22) + .fill(AppColors.accentSoft) + .frame(width: 72, height: 72) + Image(systemName: "dumbbell.fill") + .font(.system(size: 32, weight: .semibold)) + .foregroundColor(AppColors.accent) + } + VStack(spacing: 6) { + Text("Welcome to Kisani") + .font(AppFonts.sans(26, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + Text("Personalizes your warm-up and body stats") + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text3(cs)) + } + } + .padding(.bottom, 40) + + // ── Form card ── + VStack(spacing: 0) { + // Unit toggle + HStack(spacing: 0) { + ForEach([("kg", 0), ("lbs", 1)], id: \.1) { label, idx in + Button { + withAnimation(KisaniSpring.micro) { + // Convert existing weight string when toggling + if let val = Double(weightStr) { + let kg = weightUnit == 1 ? val / 2.20462 : val + weightUnit = idx + let display = weightUnit == 1 ? kg * 2.20462 : kg + weightStr = display.truncatingRemainder(dividingBy: 1) == 0 + ? "\(Int(display))" + : String(format: "%.1f", display) + } else { + weightUnit = idx + } + } + } label: { + Text(label) + .font(AppFonts.mono(12, weight: .bold)) + .foregroundColor(weightUnit == idx ? AppColors.accent : AppColors.text3(cs)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(weightUnit == idx ? AppColors.accentSoft : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(PressButtonStyle(scale: 0.96)) + } + } + .padding(4) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 28) + .padding(.bottom, 16) + + // Fields + VStack(spacing: 0) { + OnboardingField( + icon: "scalemass.fill", + label: "Body Weight", + unit: weightUnit == 0 ? "kg" : "lbs", + placeholder: weightUnit == 0 ? "e.g. 80" : "e.g. 176", + text: $weightStr, + keyboard: .decimalPad, + focused: $focusedField, + tag: .weight + ) + Divider().background(AppColors.border(cs)).padding(.leading, 56) + OnboardingField( + icon: "ruler.fill", + label: "Height", + unit: "cm", + placeholder: "e.g. 175", + text: $heightStr, + keyboard: .numberPad, + focused: $focusedField, + tag: .height + ) + } + .cardStyle() + .padding(.horizontal, 20) + } + + // ── Hint ── + Text("Used for bodyweight exercises and warm-up loads.\nEdit anytime in Settings → Workout Settings.") + .font(AppFonts.sans(11)) + .foregroundColor(AppColors.text3(cs)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .padding(.top, 14) + + Spacer() + + // ── CTA ── + Button { commit() } label: { + HStack(spacing: 8) { + Text("Get Started") + .font(AppFonts.sans(15, weight: .bold)) + Image(systemName: "arrow.right") + .font(.system(size: 13, weight: .bold)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(canContinue ? AppColors.accent : AppColors.borderHi(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + } + .buttonStyle(PressButtonStyle()) + .disabled(!canContinue) + .padding(.horizontal, 20) + .padding(.bottom, 36) + } + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { focusedField = nil } + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.accent) + } + } + .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { focusedField = .weight } } + } + + private func commit() { + guard canContinue else { return } + let rawWeight = Double(weightStr) ?? 0 + // Always store in kg internally + bodyWeightKg = weightUnit == 1 ? rawWeight / 2.20462 : rawWeight + heightCm = Double(heightStr) ?? 0 + withAnimation(KisaniSpring.exit) { isPresented = false } + } +} + +// MARK: - Field Row +private struct OnboardingField: View { + @Environment(\.colorScheme) private var cs + let icon: String + let label: String + let unit: String + let placeholder: String + @Binding var text: String + let keyboard: UIKeyboardType + var focused: FocusState.Binding + let tag: OnboardingView.Field + + var isFocused: Bool { focused.wrappedValue == tag } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 15)) + .foregroundColor(isFocused ? AppColors.accent : AppColors.text3(cs)) + .frame(width: 32, height: 32) + .background(isFocused ? AppColors.accentSoft : AppColors.surface3(cs)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .animation(KisaniSpring.micro, value: isFocused) + + Text(label) + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text(cs)) + + Spacer() + + HStack(spacing: 5) { + TextField(placeholder, text: $text) + .font(AppFonts.mono(14, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + .multilineTextAlignment(.trailing) + .keyboardType(keyboard) + .focused(focused, equals: tag) + .frame(width: 70) + .onChange(of: text) { val in + let clean: String + if keyboard == .decimalPad { + clean = String(val.filter { $0.isNumber || $0 == "." }.prefix(6)) + } else { + clean = String(val.filter { $0.isNumber }.prefix(3)) + } + if clean != val { text = clean } + } + + Text(unit) + .font(AppFonts.mono(10, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + .frame(width: 26, alignment: .leading) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + } +} diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift new file mode 100644 index 0000000..0fbf864 --- /dev/null +++ b/KisaniCal/Views/SettingsView.swift @@ -0,0 +1,765 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(\.colorScheme) var cs + @EnvironmentObject var workoutVM: WorkoutViewModel + + // Persisted settings + @AppStorage("appearanceMode") private var appearanceMode = 0 + @AppStorage("soundsOn") private var soundsOn = true + @AppStorage("healthOn") private var healthOn = false + @AppStorage("showMatrixTab") private var showMatrixTab = true + @AppStorage("showWorkoutTab") private var showWorkoutTab = true + @AppStorage("dateFormat") private var dateFormat = 0 + @AppStorage("firstDayMonday") private var firstDayMonday = false + @AppStorage("showCompleted") private var showCompleted = false + @AppStorage("weightUnit") private var weightUnit = 0 + @AppStorage("defaultSets") private var defaultSets = 3 + @AppStorage("defaultReps") private var defaultReps = 10 + @AppStorage("iCloudSync") private var iCloudSync = true + @AppStorage("googleCalSync") private var googleCalSync = false + @AppStorage("iCloudCalSync") private var iCloudCalSync = true + + // Sheet presentation + @State private var showTabBar = false + @State private var showAppearance = false + @State private var showDateTime = false + @State private var showWidgets = false + @State private var showGeneral = false + @State private var showImport = false + @State private var showBackup = false + @State private var showWorkoutSettings = false + @State private var showRestTimer = false + @State private var showHelp = false + @State private var showFollow = false + @State private var showAbout = false + + private var appearanceLabel: String { ["System", "Light", "Dark"][appearanceMode] } + private var restLabel: String { "\(workoutVM.restTimerSeconds)s" } + private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" } + private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] } + + var body: some View { + ZStack { + AppColors.background(cs).ignoresSafeArea() + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + + Text("Settings") + .font(AppFonts.sans(15, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + .padding(.top, 10).padding(.bottom, 4) + + // ── Profile ── + HStack(spacing: 12) { + ZStack { + Circle().fill(AppColors.accentSoft) + Text("RK") + .font(AppFonts.sans(15, weight: .bold)) + .foregroundColor(AppColors.accent) + } + .frame(width: 44, height: 44) + .overlay(Circle().stroke(AppColors.accent.opacity(0.18), lineWidth: 1.5)) + + VStack(alignment: .leading, spacing: 5) { + Text("Robin Kutesa") + .font(AppFonts.sans(14, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + HStack(spacing: 5) { + AppBadge( + text: workoutVM.streakDays > 0 ? "\(workoutVM.streakDays)d streak 🔥" : "No streak yet", + bg: AppColors.accentSoft, fg: AppColors.accent + ) + AppBadge( + text: weightUnit == 0 ? "kg" : "lbs", + bg: AppColors.surface3(cs), fg: AppColors.text2(cs) + ) + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 10)) + .foregroundColor(AppColors.text3(cs)) + } + .padding(14).cardStyle() + .padding(.horizontal, 14).padding(.top, 10).padding(.bottom, 14) + + // ── CUSTOMIZE ── + SttSection(label: "Customize") { + SttNavRow(icon: "menucard", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Tab Bar", value: "\(showMatrixTab && showWorkoutTab ? "5" : showMatrixTab || showWorkoutTab ? "4" : "3") tabs") { showTabBar = true } + SttNavRow(icon: "paintpalette", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Appearance", value: appearanceLabel) { showAppearance = true } + SttToggleRow(icon: "bell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Sounds & Notifications", isOn: $soundsOn) + SttNavRow(icon: "clock", bg: AppColors.greenSoft, fg: AppColors.green, label: "Date & Time", value: dateLabel) { showDateTime = true } + SttNavRow(icon: "puzzlepiece", bg: AppColors.yellowSoft, fg: AppColors.yellow, label: "Widgets") { showWidgets = true } + SttNavRow(icon: "gearshape", bg: AppColors.surface3(cs), label: "General", isLast: true) { showGeneral = true } + } + + // ── DATA ── + SttSection(label: "Data") { + SttNavRow(icon: "arrow.up.right", bg: AppColors.greenSoft, fg: AppColors.green, label: "Connected Sources", value: googleCalSync || iCloudCalSync ? "Connected" : nil) { showImport = true } + SttNavRow(icon: "cloud", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Library Sync", value: iCloudSync ? "● Synced" : "Off", valueColor: iCloudSync ? AppColors.green : nil, isLast: true) { showBackup = true } + } + + // ── FITNESS ── + SttSection(label: "Fitness") { + SttNavRow(icon: "dumbbell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Workout Settings", value: weightLabel) { showWorkoutSettings = true } + SttNavRow(icon: "figure.run", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Rest Timer", value: restLabel) { showRestTimer = true } + SttToggleRow(icon: "heart", bg: AppColors.greenSoft, fg: AppColors.green, label: "Health Integration", isOn: $healthOn, isLast: true) + } + + // ── SUPPORT ── + SttSection(label: "Support", secondary: true) { + SttNavRow(icon: "star", bg: AppColors.yellowSoft, fg: AppColors.yellow, label: "Help & Feedback") { showHelp = true } + SttNavRow(icon: "message", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Follow Us", value: "𝕏 👾 📸") { showFollow = true } + SttNavRow(icon: "info.circle", bg: AppColors.surface3(cs), label: "About", value: "v1.0.0", isLast: true) { showAbout = true } + } + + Spacer().frame(height: 40) + } + } + } + .sheet(isPresented: $showTabBar) { TabBarSheet(showMatrix: $showMatrixTab, showWorkout: $showWorkoutTab).presentationDetents([.height(320)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showAppearance) { AppearanceSheet(mode: $appearanceMode).presentationDetents([.height(290)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showDateTime) { DateTimeSheet(format: $dateFormat, mondayFirst: $firstDayMonday).presentationDetents([.height(300)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showWidgets) { WidgetsSheet().presentationDetents([.height(320)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showGeneral) { GeneralSheet(showCompleted: $showCompleted, mondayFirst: $firstDayMonday).presentationDetents([.height(260)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showImport) { ImportSheet(googleCal: $googleCalSync, iCloudCal: $iCloudCalSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).presentationDetents([.large]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds).presentationDetents([.height(340)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showAbout) { AboutSheet().presentationDetents([.height(340)]).presentationDragIndicator(.visible) } + } +} + +// MARK: - Section +private struct SttSection: View { + @Environment(\.colorScheme) private var cs + let label: String + var secondary: Bool = false + @ViewBuilder let content: Content + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(label) + .font(AppFonts.sans(secondary ? 9.5 : 10, weight: .semibold)) + .foregroundColor(secondary ? AppColors.text3(cs) : AppColors.text2(cs)) + .padding(.horizontal, 18).padding(.bottom, 5) + VStack(spacing: 0) { content } + .cardStyle().padding(.horizontal, 14).padding(.bottom, secondary ? 6 : 8) + } + } +} + +// MARK: - Nav Row +private struct SttNavRow: View { + @Environment(\.colorScheme) private var cs + let icon: String; let bg: Color + var fg: Color? = nil + let label: String + var value: String? = nil; var valueColor: Color? = nil; var isLast: Bool = false + let action: () -> Void + var body: some View { + Button(action: action) { + VStack(spacing: 0) { + HStack(spacing: 11) { + Image(systemName: icon).font(.system(size: 14)) + .foregroundColor(fg ?? AppColors.text2(cs)) + .frame(width: 28, height: 28).background(bg).cornerRadius(8) + Text(label).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + if let v = value { + Text(v).font(AppFonts.mono(11)) + .foregroundColor(valueColor ?? AppColors.text2(cs)).lineLimit(1) + } + Image(systemName: "chevron.right").font(.system(size: 10)) + .foregroundColor(AppColors.text3(cs)) + } + .padding(.horizontal, 14).padding(.vertical, 11) + if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) } + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + } +} + +// MARK: - Toggle Row +private struct SttToggleRow: View { + @Environment(\.colorScheme) private var cs + let icon: String; let bg: Color + var fg: Color? = nil + let label: String + @Binding var isOn: Bool; var isLast: Bool = false + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 11) { + Image(systemName: icon).font(.system(size: 14)) + .foregroundColor(fg ?? AppColors.text2(cs)) + .frame(width: 28, height: 28).background(bg).cornerRadius(8) + Text(label).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + Toggle("", isOn: $isOn).labelsHidden().tint(AppColors.accent) + } + .padding(.horizontal, 14).padding(.vertical, 11) + if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) } + } + } +} + +// MARK: - Shared Header +private struct SheetHeader: View { + @Environment(\.colorScheme) private var cs + let title: String; let onDone: () -> Void + var body: some View { + HStack { + Text(title).font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs)) + Spacer() + Button("Done", action: onDone).font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.accent).buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 16) + } +} + +// MARK: - Tab Bar Sheet +private struct TabBarSheet: View { + @Binding var showMatrix: Bool + @Binding var showWorkout: Bool + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var cs + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "Tab Bar") { dismiss() } + VStack(spacing: 0) { + TabToggleRow(icon: "checkmark", label: "Tasks", isOn: .constant(true), locked: true) + TabToggleRow(icon: "calendar", label: "Calendar", isOn: .constant(true), locked: true) + TabToggleRow(icon: "square.grid.2x2", label: "Matrix", isOn: $showMatrix, locked: false) + TabToggleRow(icon: "dumbbell", label: "Workout", isOn: $showWorkout, locked: false) + TabToggleRow(icon: "gearshape", label: "Settings", isOn: .constant(true), locked: true, isLast: true) + } + .cardStyle().padding(.horizontal, 20) + Text("Tasks, Calendar, and Settings are always visible.") + .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + .multilineTextAlignment(.center).padding(.horizontal, 20).padding(.top, 12) + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +private struct TabToggleRow: View { + @Environment(\.colorScheme) private var cs + let icon: String; let label: String + @Binding var isOn: Bool; let locked: Bool; var isLast: Bool = false + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: icon).font(.system(size: 13)).foregroundColor(locked ? AppColors.text3(cs) : AppColors.text(cs)).frame(width: 22) + Text(label).font(AppFonts.sans(13)).foregroundColor(locked ? AppColors.text3(cs) : AppColors.text(cs)) + Spacer() + if locked { + Text("Always on").font(AppFonts.mono(9.5)).foregroundColor(AppColors.text3(cs)) + } else { + Toggle("", isOn: $isOn).labelsHidden().tint(AppColors.accent) + } + } + .padding(.horizontal, 14).padding(.vertical, 12) + if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 48) } + } + } +} + +// MARK: - Appearance Sheet +private struct AppearanceSheet: View { + @Binding var mode: Int + @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs + private let options = [("System", "circle.lefthalf.filled"), ("Light", "sun.max"), ("Dark", "moon.stars")] + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "Appearance") { dismiss() } + VStack(spacing: 8) { + ForEach(options.indices, id: \.self) { i in + Button { withAnimation(KisaniSpring.micro) { mode = i } } label: { + HStack(spacing: 14) { + Image(systemName: options[i].1).font(.system(size: 15, weight: .medium)) + .foregroundColor(mode == i ? AppColors.accent : AppColors.text2(cs)).frame(width: 24) + Text(options[i].0).font(AppFonts.sans(14, weight: mode == i ? .semibold : .regular)) + .foregroundColor(AppColors.text(cs)) + Spacer() + if mode == i { Image(systemName: "checkmark").font(.system(size: 12, weight: .bold)).foregroundColor(AppColors.accent) } + } + .padding(.horizontal, 16).padding(.vertical, 14) + .background(mode == i ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.medium)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.medium).stroke(mode == i ? AppColors.accent.opacity(0.35) : AppColors.border(cs), lineWidth: 1)) + }.buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - Date & Time Sheet +private struct DateTimeSheet: View { + @Binding var format: Int + @Binding var mondayFirst: Bool + @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs + private let formats = ["MMM d", "MM/DD/YYYY", "DD/MM/YYYY"] + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "Date & Time") { dismiss() } + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("DATE FORMAT").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + HStack(spacing: 8) { + ForEach(formats.indices, id: \.self) { i in + Button { withAnimation { format = i } } label: { + Text(formats[i]).font(AppFonts.mono(11, weight: .bold)) + .foregroundColor(format == i ? AppColors.accent : AppColors.text2(cs)) + .frame(maxWidth: .infinity).padding(.vertical, 10) + .background(format == i ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(format == i ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + }.buttonStyle(.plain) + } + } + } + VStack(spacing: 0) { + HStack { + Text("First day of week").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + Text(mondayFirst ? "Monday" : "Sunday").font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs)) + Toggle("", isOn: $mondayFirst).labelsHidden().tint(AppColors.accent) + } + .padding(.horizontal, 14).padding(.vertical, 12) + } + .cardStyle() + } + .padding(.horizontal, 20) + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - Widgets Sheet +private struct WidgetsSheet: View { + @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs + private let widgets = [("Small", "1×1 — Today's task count", "square.grid.2x2"), ("Medium", "2×1 — Upcoming tasks list", "rectangle.grid.1x2"), ("Large", "2×2 — Full task view", "square.split.2x2")] + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "Widgets") { dismiss() } + VStack(spacing: 10) { + ForEach(widgets, id: \.0) { w in + HStack(spacing: 14) { + Image(systemName: w.2).font(.system(size: 20)).foregroundColor(AppColors.accent) + .frame(width: 44, height: 44).background(AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text(w.0).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Text(w.1).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + } + .padding(12).cardStyle() + } + Text("Long-press your home screen → tap + → search Kisani Cal to add widgets.") + .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + .multilineTextAlignment(.center).padding(.top, 4) + } + .padding(.horizontal, 20) + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - General Sheet +private struct GeneralSheet: View { + @Binding var showCompleted: Bool + @Binding var mondayFirst: Bool + @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "General") { dismiss() } + VStack(spacing: 0) { + HStack { + Text("Show completed tasks").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + Toggle("", isOn: $showCompleted).labelsHidden().tint(AppColors.accent) + } + .padding(.horizontal, 14).padding(.vertical, 12) + Divider().background(AppColors.border(cs)).padding(.leading, 14) + HStack { + Text("Week starts on Monday").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + Toggle("", isOn: $mondayFirst).labelsHidden().tint(AppColors.accent) + } + .padding(.horizontal, 14).padding(.vertical, 12) + } + .cardStyle().padding(.horizontal, 20) + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - Import Sheet +private struct ImportSheet: View { + @Binding var googleCal: Bool + @Binding var iCloudCal: Bool + @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "Import & Integration") { dismiss() } + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "calendar.badge.plus").font(.system(size: 16)) + .foregroundColor(AppColors.blue).frame(width: 28, height: 28) + .background(AppColors.blueSoft).cornerRadius(8) + Text("Google Calendar").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + Toggle("", isOn: $googleCal).labelsHidden().tint(AppColors.accent) + } + .padding(.horizontal, 14).padding(.vertical, 12) + Divider().background(AppColors.border(cs)).padding(.leading, 53) + HStack(spacing: 12) { + Image(systemName: "cloud").font(.system(size: 16)) + .foregroundColor(AppColors.blue).frame(width: 28, height: 28) + .background(AppColors.blueSoft).cornerRadius(8) + Text("iCloud Calendar").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + Toggle("", isOn: $iCloudCal).labelsHidden().tint(AppColors.accent) + } + .padding(.horizontal, 14).padding(.vertical, 12) + } + .cardStyle().padding(.horizontal, 20) + Text("Enabling integrations will sync events to your Kisani calendar.") + .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + .multilineTextAlignment(.center).padding(.horizontal, 20).padding(.top, 12) + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - Backup Sheet +private struct BackupSheet: View { + @Binding var iCloudSync: Bool + @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "Backup & Sync") { dismiss() } + VStack(spacing: 14) { + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "icloud").font(.system(size: 16)) + .foregroundColor(AppColors.blue).frame(width: 28, height: 28) + .background(AppColors.blueSoft).cornerRadius(8) + Text("iCloud Sync").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + Toggle("", isOn: $iCloudSync).labelsHidden().tint(AppColors.accent) + } + .padding(.horizontal, 14).padding(.vertical, 12) + if iCloudSync { + Divider().background(AppColors.border(cs)).padding(.leading, 53) + HStack { + Text("Last synced").font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs)) + Spacer() + Text("Today · just now").font(AppFonts.mono(11)).foregroundColor(AppColors.green) + } + .padding(.horizontal, 14).padding(.vertical, 10) + } + } + .cardStyle().padding(.horizontal, 20) + .animation(KisaniSpring.snappy, value: iCloudSync) + + if iCloudSync { + Button { + // trigger sync + } label: { + Text("Sync Now") + .font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent) + .frame(maxWidth: .infinity).padding(13) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.accent, lineWidth: 1.5)) + } + .buttonStyle(.plain).padding(.horizontal, 20) + } + } + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - Workout Settings Sheet +private struct WorkoutSettingsSheet: View { + @Binding var unit: Int + @Binding var sets: Int + @Binding var reps: Int + @AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 80 + @AppStorage("heightCm") private var heightCm: Double = 175 + @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs + + @State private var bwStr = "" + @State private var htStr = "" + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "Workout Settings") { dismiss() } + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + + // ── Body Profile ── + VStack(alignment: .leading, spacing: 8) { + Text("BODY PROFILE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + VStack(spacing: 0) { + HStack { + Text("Body Weight").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + HStack(spacing: 4) { + TextField("80", text: $bwStr) + .font(AppFonts.mono(13, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + .multilineTextAlignment(.trailing) + .keyboardType(.decimalPad) + .frame(width: 56) + .onChange(of: bwStr) { val in + let clean = String(val.filter { $0.isNumber || $0 == "." }.prefix(5)) + if clean != val { bwStr = clean } + if let d = Double(clean) { bodyWeightKg = d } + } + Text("kg") + .font(AppFonts.mono(10, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + } + } + .padding(.horizontal, 14).padding(.vertical, 11) + Divider().background(AppColors.border(cs)).padding(.leading, 14) + HStack { + Text("Height").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + HStack(spacing: 4) { + TextField("175", text: $htStr) + .font(AppFonts.mono(13, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + .multilineTextAlignment(.trailing) + .keyboardType(.numberPad) + .frame(width: 56) + .onChange(of: htStr) { val in + let clean = String(val.filter { $0.isNumber }.prefix(3)) + if clean != val { htStr = clean } + if let d = Double(clean) { heightCm = d } + } + Text("cm") + .font(AppFonts.mono(10, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + } + } + .padding(.horizontal, 14).padding(.vertical, 11) + } + .cardStyle() + } + + // ── Weight Unit ── + VStack(alignment: .leading, spacing: 8) { + Text("WEIGHT UNIT").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + HStack(spacing: 10) { + ForEach(["kg", "lbs"].indices, id: \.self) { i in + Button { withAnimation(KisaniSpring.micro) { unit = i } } label: { + Text(["kg", "lbs"][i]).font(AppFonts.mono(13, weight: .bold)) + .foregroundColor(unit == i ? AppColors.accent : AppColors.text2(cs)) + .frame(maxWidth: .infinity).padding(.vertical, 12) + .background(unit == i ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).stroke(unit == i ? AppColors.accent : AppColors.border(cs), lineWidth: unit == i ? 1.5 : 1)) + }.buttonStyle(.plain) + } + } + } + + // ── Defaults ── + VStack(spacing: 0) { + StepperRow(label: "Default Sets", value: $sets, range: 1...10) + Divider().background(AppColors.border(cs)).padding(.leading, 14) + StepperRow(label: "Default Reps", value: $reps, range: 1...30, isLast: true) + } + .cardStyle() + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + .background(AppColors.background(cs).ignoresSafeArea()) + .onAppear { + bwStr = bodyWeightKg > 0 ? (bodyWeightKg.truncatingRemainder(dividingBy: 1) == 0 ? "\(Int(bodyWeightKg))" : String(format: "%.1f", bodyWeightKg)) : "" + htStr = heightCm > 0 ? "\(Int(heightCm))" : "" + } + } +} + +private struct StepperRow: View { + @Environment(\.colorScheme) private var cs + let label: String; @Binding var value: Int; let range: ClosedRange; var isLast: Bool = false + var body: some View { + HStack { + Text(label).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + HStack(spacing: 0) { + Button { if value > range.lowerBound { value -= 1 } } label: { + Image(systemName: "minus").font(.system(size: 13, weight: .semibold)) + .foregroundColor(value > range.lowerBound ? AppColors.accent : AppColors.text3(cs)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + Text("\(value)").font(AppFonts.mono(13, weight: .bold)) + .foregroundColor(AppColors.text(cs)).frame(width: 32).multilineTextAlignment(.center) + Button { if value < range.upperBound { value += 1 } } label: { + Image(systemName: "plus").font(.system(size: 13, weight: .semibold)) + .foregroundColor(value < range.upperBound ? AppColors.accent : AppColors.text3(cs)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + } + .background(AppColors.surface2(cs)).clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(AppColors.border(cs), lineWidth: 1)) + } + .padding(.horizontal, 14).padding(.vertical, 10) + } +} + +// MARK: - Rest Timer Sheet +private struct RestTimerSheet: View { + @Binding var seconds: Int + @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs + private let options = [30, 45, 60, 90, 120, 180] + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "Rest Timer") { dismiss() } + Text("\(seconds)s").font(AppFonts.mono(52, weight: .bold)).foregroundColor(AppColors.accent).padding(.bottom, 20) + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 3), spacing: 10) { + ForEach(options, id: \.self) { opt in + Button { withAnimation(KisaniSpring.micro) { seconds = opt } } label: { + Text("\(opt)s").font(AppFonts.mono(14, weight: .bold)) + .foregroundColor(seconds == opt ? AppColors.accent : AppColors.text2(cs)) + .frame(maxWidth: .infinity).padding(.vertical, 13) + .background(seconds == opt ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(seconds == opt ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + }.buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - Help & Feedback Sheet +private struct HelpSheet: View { + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var cs + @Environment(\.openURL) var openURL + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "Help & Feedback") { dismiss() } + VStack(spacing: 10) { + HelpRow(icon: "envelope", label: "Send Feedback", sub: "Report a bug or suggest a feature") { + if let url = URL(string: "mailto:feedback@kisanicaI.app?subject=Kisani%20Cal%20Feedback") { openURL(url) } + } + HelpRow(icon: "questionmark.circle", label: "FAQ", sub: "Frequently asked questions") {} + HelpRow(icon: "book", label: "User Guide", sub: "Learn how to use Kisani Cal") {} + } + .padding(.horizontal, 20) + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +private struct HelpRow: View { + @Environment(\.colorScheme) private var cs + let icon: String; let label: String; let sub: String; let action: () -> Void + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: icon).font(.system(size: 16)).foregroundColor(AppColors.accent) + .frame(width: 40, height: 40).background(AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text(label).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Text(sub).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + Image(systemName: "chevron.right").font(.system(size: 10)).foregroundColor(AppColors.text3(cs)) + } + .padding(12).cardStyle() + }.buttonStyle(.plain) + } +} + +// MARK: - Follow Us Sheet +private struct FollowSheet: View { + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var cs + @Environment(\.openURL) var openURL + private let socials: [(String, String, String, String)] = [ + ("𝕏 Twitter / X", "Share your streaks", "at.circle", "https://x.com"), + ("Discord", "Join the community", "bubble.left.and.bubble.right", "https://discord.com"), + ("Instagram", "Behind the scenes", "camera", "https://instagram.com"), + ] + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "Follow Us") { dismiss() } + VStack(spacing: 8) { + ForEach(socials, id: \.0) { s in + Button { if let url = URL(string: s.3) { openURL(url) } } label: { + HStack(spacing: 14) { + Image(systemName: s.2).font(.system(size: 16)).foregroundColor(AppColors.accent) + .frame(width: 40, height: 40).background(AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text(s.0).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Text(s.1).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + Image(systemName: "arrow.up.right").font(.system(size: 10)).foregroundColor(AppColors.text3(cs)) + } + .padding(12).cardStyle() + }.buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - About Sheet +private struct AboutSheet: View { + @Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: "About") { dismiss() } + VStack(spacing: 16) { + Image(systemName: "calendar.badge.clock") + .font(.system(size: 48, weight: .light)).foregroundColor(AppColors.accent) + VStack(spacing: 4) { + Text("Kisani Cal").font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs)) + Text("Version 1.0.0").font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs)) + } + Text("A calm, focused productivity app for tasks, calendar, and fitness.") + .font(AppFonts.sans(13)).foregroundColor(AppColors.text2(cs)) + .multilineTextAlignment(.center).padding(.horizontal, 20) + } + .padding(.top, 10) + Spacer() + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift new file mode 100644 index 0000000..408d77e --- /dev/null +++ b/KisaniCal/Views/TodayView.swift @@ -0,0 +1,1027 @@ +import SwiftUI + +struct TodayView: View { + @Environment(\.colorScheme) var cs + @EnvironmentObject var taskVM: TaskViewModel + @EnvironmentObject var workoutVM: WorkoutViewModel + @State private var showAddTask = false + @State private var showWorkoutSession = false + @State private var workoutExpanded = false + + private let dateFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f + }() + + var body: some View { + ZStack(alignment: .bottomTrailing) { + AppColors.background(cs).ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + + // ── Header ── + HStack { + IButton(icon: "line.3.horizontal") + Spacer() + HStack(spacing: 6) { + Text("💡").font(.system(size: 14)) + Text("···") + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text2(cs)) + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(AppColors.surface2(cs)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(AppColors.border(cs), lineWidth: 1)) + } + .padding(.horizontal, 18) + .padding(.top, 8) + + // ── Title ── + Text("Today") + .font(AppFonts.sans(34, weight: .bold)) + .tracking(-0.8) + .foregroundColor(AppColors.text(cs)) + .padding(.horizontal, 18) + .padding(.top, 6) + + Text(dateFmt.string(from: Date())) + .font(AppFonts.mono(10)) + .foregroundColor(AppColors.text3(cs).opacity(0.7)) + .padding(.horizontal, 18) + .padding(.bottom, 12) + + // ── Overdue ── + if !taskVM.overdueTasks.isEmpty { + OverdueCard( + tasks: taskVM.overdueTasks, + onPostpone: { + withAnimation(KisaniSpring.snappy) { taskVM.postponeAllOverdue() } + }, + onToggle: { task in + withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) } + } + ) + .padding(.horizontal, 18) + } + + // ── Today's Workout ── + if let workout = workoutVM.program(for: Date()) { + WorkoutTodayBanner( + program: workout, + vm: workoutVM, + isExpanded: $workoutExpanded, + onTap: { + if workoutVM.activeProgramId != workout.id { + workoutVM.switchProgram(to: workout.id) + } + showWorkoutSession = true + } + ) + .padding(.horizontal, 18) + .padding(.top, taskVM.overdueTasks.isEmpty ? 0 : 10) + .padding(.bottom, 4) + } + + // ── Today's Tasks ── + if !taskVM.todayTasks.isEmpty { + HStack(alignment: .firstTextBaseline) { + Text("Today") + .font(AppFonts.sans(11, weight: .semibold)) + .foregroundColor(AppColors.text2(cs)) + Spacer() + Text("\(taskVM.todayTasks.count) task\(taskVM.todayTasks.count == 1 ? "" : "s")") + .font(AppFonts.mono(9)) + .foregroundColor(AppColors.text3(cs)) + } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 6) + + ForEach(taskVM.todayTasks) { task in + TaskRowView(task: task) { + withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) } + } + .contextMenu { + Button { withAnimation { taskVM.postpone(task) } } label: { + Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") + } + Divider() + Button(role: .destructive) { withAnimation { taskVM.delete(task) } } label: { + Label("Delete", systemImage: "trash") + } + } + .padding(.horizontal, 18) + .padding(.bottom, 5) + } + } + + // ── Completed Today ── + if !taskVM.completedTodayTasks.isEmpty { + CompletedTodaySection( + tasks: taskVM.completedTodayTasks, + onUndo: { task in + withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) } + } + ) + .padding(.horizontal, 18) + .padding(.top, 14) + } + + // ── Upcoming ── + if !taskVM.upcomingTasks.isEmpty { + HStack { + SectionLabel(text: "Upcoming") + Text("\(taskVM.upcomingTasks.count)") + .font(AppFonts.mono(9)) + .foregroundColor(AppColors.text3(cs).opacity(0.55)) + } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 6) + + ForEach(taskVM.upcomingTasks.prefix(20)) { task in + TaskRowView(task: task) { + withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) } + } + .contextMenu { + Button { withAnimation { taskVM.postpone(task) } } label: { + Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") + } + Divider() + Button(role: .destructive) { withAnimation { taskVM.delete(task) } } label: { + Label("Delete", systemImage: "trash") + } + } + .padding(.horizontal, 18) + .padding(.bottom, 5) + } + } + + let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty + || !taskVM.upcomingTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty + || workoutVM.program(for: Date()) != nil + if !hasAnything { + VStack(spacing: 6) { + Text("All caught up") + .font(AppFonts.sans(15, weight: .semibold)) + .foregroundColor(AppColors.text2(cs)) + Text("Nothing on your plate — tap + to start") + .font(AppFonts.sans(12)) + .foregroundColor(AppColors.text3(cs)) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + Spacer(minLength: 100) + } + } + + FAB { showAddTask = true } + .padding(.trailing, 20) + .padding(.bottom, 10) + } + .sheet(isPresented: $showAddTask) { + AddTaskSheet() + .environmentObject(taskVM) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showWorkoutSession) { + WorkoutSessionSheet() + .environmentObject(workoutVM) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + } +} + +// MARK: - Overdue Card +struct OverdueCard: View { + @Environment(\.colorScheme) private var cs + let tasks: [TaskItem] + let onPostpone: () -> Void + let onToggle: (TaskItem) -> Void + + @State private var expanded = true + + var body: some View { + VStack(spacing: 0) { + // ── Header ── + HStack { + Text("OVERDUE") + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(AppColors.accent) + Spacer() + HStack(spacing: 7) { + Button(action: onPostpone) { + Text("POSTPONE") + .font(AppFonts.mono(9, weight: .bold)) + .foregroundColor(AppColors.accent) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(AppColors.accentSoft) + .cornerRadius(5) + } + .buttonStyle(.plain) + + Text("\(tasks.count)") + .font(AppFonts.mono(9.5)) + .foregroundColor(AppColors.text3(cs)) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + .rotationEffect(.degrees(expanded ? 0 : -90)) + .animation(KisaniSpring.micro, value: expanded) + } + } + .padding(.horizontal, 13).padding(.vertical, 10) + .contentShape(Rectangle()) + .onTapGesture { withAnimation(KisaniSpring.snappy) { expanded.toggle() } } + + if expanded { + Divider().background(AppColors.accent.opacity(0.10)) + + ForEach(tasks) { task in + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 5) + .stroke(AppColors.accent, lineWidth: 1.5) + .frame(width: 20, height: 20) + } + .frame(width: 36, height: 44) + .contentShape(Rectangle()) + + Text(task.title) + .font(AppFonts.sans(12.5)) + .foregroundColor(AppColors.text(cs)) + Spacer() + if let d = task.dueDate { + Text(d, style: .date) + .font(AppFonts.mono(10)) + .foregroundColor(AppColors.accent) + } + } + .padding(.horizontal, 10) + .contentShape(Rectangle()) + .onTapGesture { onToggle(task) } + .contextMenu { + Button { + withAnimation { onToggle(task) } + } label: { + Label("Mark Complete", systemImage: "checkmark.circle") + } + Button { + // postpone this single task via the viewmodel — accessed through onToggle pattern + } label: { + Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath") + } + } + } + } + } + .background(AppColors.accent.opacity(0.07)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large) + .stroke(AppColors.accent.opacity(0.18), lineWidth: 1)) + } +} + +// MARK: - Workout Today Banner +struct WorkoutTodayBanner: View { + @Environment(\.colorScheme) private var cs + let program: WorkoutProgram + let vm: WorkoutViewModel + @Binding var isExpanded: Bool + let onTap: () -> Void + + private var isActiveProgram: Bool { vm.activeProgramId == program.id } + private var isDone: Bool { isActiveProgram && vm.progress >= 1 && vm.totalSets > 0 } + private var accent: Color { isDone ? AppColors.green : AppColors.blue } + private var setsText: String { + guard isActiveProgram && vm.totalSets > 0 else { return "\(program.totalExercises) exercises" } + return "\(vm.doneSets)/\(vm.totalSets) sets" + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // ── Header row — taps open session ── + HStack(spacing: 6) { + Image(systemName: isDone ? "checkmark.seal.fill" : "dumbbell.fill") + .font(.system(size: 10)) + Text(isDone ? "WORKOUT COMPLETE" : "TODAY'S WORKOUT") + .font(AppFonts.mono(9, weight: .bold)) + Spacer() + Text(setsText).font(AppFonts.mono(9)).foregroundColor(AppColors.text3(cs)) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(accent.opacity(0.5)) + } + .foregroundColor(accent) + .padding(.horizontal, 13).padding(.top, 10).padding(.bottom, 8) + .contentShape(Rectangle()) + .onTapGesture { onTap() } + + Divider().background(accent.opacity(0.15)) + + // ── Program identity row — taps open session ── + HStack(spacing: 10) { + Text(program.emoji).font(.system(size: 18)) + .frame(width: 36, height: 36) + .background(isDone ? AppColors.greenSoft : AppColors.blueSoft) + .clipShape(RoundedRectangle(cornerRadius: 9)) + + VStack(alignment: .leading, spacing: 2) { + Text(program.name) + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + Text("\(program.sections.count) section\(program.sections.count == 1 ? "" : "s") · \(program.totalExercises) exercise\(program.totalExercises == 1 ? "" : "s")") + .font(AppFonts.mono(9.5)) + .foregroundColor(AppColors.text3(cs)) + } + Spacer() + + if isActiveProgram && vm.totalSets > 0 { + ZStack { + Circle().stroke(AppColors.borderHi(cs), lineWidth: 2.5).frame(width: 34, height: 34) + Circle().trim(from: 0, to: CGFloat(vm.progress)) + .stroke(accent, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .frame(width: 34, height: 34).rotationEffect(.degrees(-90)) + .animation(KisaniSpring.entrance, value: vm.progress) + if isDone { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(AppColors.green) + } else { + Text("\(Int(vm.progress * 100))%") + .font(AppFonts.mono(8.5, weight: .bold)) + .foregroundColor(accent) + } + } + } + } + .padding(.horizontal, 13).padding(.vertical, 10) + .contentShape(Rectangle()) + .onTapGesture { onTap() } + + // ── Expand toggle ── + if !program.sections.isEmpty { + Button { + withAnimation(KisaniSpring.snappy) { isExpanded.toggle() } + } label: { + HStack(spacing: 4) { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 8, weight: .bold)) + Text(isExpanded ? "Less" : "Show \(program.sections.count) sections") + .font(AppFonts.mono(8.5, weight: .bold)) + } + .foregroundColor(accent.opacity(0.55)) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + } + .buttonStyle(.plain) + + // ── Section breakdown (expanded only) ── + if isExpanded { + Divider().background(accent.opacity(0.10)) + VStack(alignment: .leading, spacing: 6) { + ForEach(program.sections) { section in + HStack(alignment: .top, spacing: 8) { + Text(section.name.uppercased()) + .font(AppFonts.mono(8.5, weight: .bold)) + .foregroundColor(accent.opacity(0.8)) + .frame(width: 72, alignment: .leading) + Text(section.exercises.isEmpty + ? "No exercises" + : section.exercises.map(\.name).joined(separator: " · ")) + .font(AppFonts.sans(10.5)) + .foregroundColor(AppColors.text2(cs)) + .lineLimit(1) + Spacer() + } + } + } + .padding(.horizontal, 13).padding(.top, 8).padding(.bottom, 12) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + .background(accent.opacity(0.07)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(accent.opacity(0.2), lineWidth: 1)) + .animation(KisaniSpring.entrance, value: isDone) + } +} + +// MARK: - Workout Session Sheet +struct WorkoutSessionSheet: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(spacing: 0) { + // ── Header ── + HStack(spacing: 12) { + Text(vm.workoutEmoji).font(.system(size: 22)) + .frame(width: 44, height: 44) + .background(AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + VStack(alignment: .leading, spacing: 2) { + Text(vm.workoutTitle) + .font(AppFonts.sans(17, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + Text(vm.totalSets > 0 + ? "\(vm.doneSets) of \(vm.totalSets) sets done" + : "No exercises added yet") + .font(AppFonts.mono(10)) + .foregroundColor(AppColors.text3(cs)) + } + Spacer() + Button("Done") { dismiss() } + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.accent) + .buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) + + // ── Progress Bar ── + VStack(spacing: 6) { + HStack { + Text("PROGRESS") + .font(AppFonts.mono(8.5, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + Spacer() + Text(vm.totalSets > 0 ? "\(Int(vm.progress * 100))%" : "0%") + .font(AppFonts.mono(10, weight: .bold)) + .foregroundColor(vm.progress >= 1 ? AppColors.green : AppColors.accent) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 5) + .fill(AppColors.borderHi(cs)) + .frame(height: 8) + if vm.totalSets > 0 { + RoundedRectangle(cornerRadius: 5) + .fill(vm.progress >= 1 ? AppColors.green : AppColors.accent) + .frame(width: geo.size.width * CGFloat(min(vm.progress, 1)), height: 8) + .animation(KisaniSpring.entrance, value: vm.progress) + } + } + } + .frame(height: 8) + } + .padding(.horizontal, 20).padding(.bottom, 14) + + // ── Completion Banner ── + if vm.progress >= 1 && vm.totalSets > 0 { + HStack(spacing: 8) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 15)).foregroundColor(AppColors.green) + Text("Workout complete! Great work 🎉") + .font(AppFonts.sans(12.5, weight: .semibold)) + .foregroundColor(AppColors.green) + Spacer() + } + .padding(.horizontal, 20).padding(.vertical, 11) + .background(AppColors.green.opacity(0.09)) + } + + Divider().background(AppColors.border(cs)) + + // ── Exercise List ── + List { + ForEach($vm.activeSections) { $section in + Section { + if section.isExpanded { + if section.exercises.isEmpty { + Text("No exercises in this section") + .font(AppFonts.sans(11)) + .foregroundColor(AppColors.text3(cs)) + .frame(maxWidth: .infinity).padding(.vertical, 12) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else { + ForEach($section.exercises) { $ex in + ExerciseCard(exercise: $ex, sectionId: section.id) + .environmentObject(vm) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14)) + } + } + } + } header: { + SessionSectionHeader(section: $section) + .environmentObject(vm) + } + } + Color.clear.frame(height: 30) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.immediately) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil + ) + } + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.accent) + } + } + } + .background(AppColors.background(cs).ignoresSafeArea()) + } +} + +// MARK: - Session Section Header (expand/collapse + done count only) +private struct SessionSectionHeader: View { + @Binding var section: WorkoutSection + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + + private var doneSets: Int { section.exercises.reduce(0) { $0 + $1.doneSets } } + private var totalSets: Int { section.exercises.reduce(0) { $0 + $1.totalSets } } + private var isComplete: Bool { totalSets > 0 && doneSets == totalSets } + + var body: some View { + HStack(spacing: 8) { + Button { + withAnimation(KisaniSpring.snappy) { vm.toggleSectionExpanded(section.id) } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + .rotationEffect(.degrees(section.isExpanded ? 90 : 0)) + .frame(width: 18) + } + .buttonStyle(.plain) + + Text(section.name.uppercased()) + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(AppColors.text2(cs)) + .textCase(nil) + + if totalSets > 0 { + HStack(spacing: 3) { + if isComplete { + Image(systemName: "checkmark") + .font(.system(size: 7, weight: .bold)) + .foregroundColor(AppColors.green) + } + Text("\(doneSets)/\(totalSets)") + .font(AppFonts.mono(8.5, weight: .bold)) + .foregroundColor(isComplete ? AppColors.green : AppColors.text3(cs)) + } + .padding(.horizontal, 6).padding(.vertical, 2) + .background(isComplete ? AppColors.greenSoft : AppColors.surface2(cs)) + .clipShape(Capsule()) + } + + Spacer() + } + .padding(.horizontal, 14).padding(.vertical, 6) + .background(AppColors.background(cs)) + } +} + +// MARK: - Completed Today Section +struct CompletedTodaySection: View { + @Environment(\.colorScheme) private var cs + let tasks: [TaskItem] + let onUndo: (TaskItem) -> Void + + @State private var expanded = true + + private let timeFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "h:mm a"; return f + }() + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 11)) + .foregroundColor(AppColors.green) + Text("COMPLETED TODAY") + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(AppColors.green) + } + Spacer() + HStack(spacing: 6) { + Text("\(tasks.count)") + .font(AppFonts.mono(9.5)) + .foregroundColor(AppColors.text3(cs)) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + .rotationEffect(.degrees(expanded ? 0 : -90)) + .animation(KisaniSpring.micro, value: expanded) + } + } + .padding(.horizontal, 13).padding(.vertical, 10) + .contentShape(Rectangle()) + .onTapGesture { withAnimation(KisaniSpring.snappy) { expanded.toggle() } } + + if expanded { + Divider().background(AppColors.green.opacity(0.15)) + + ForEach(tasks) { task in + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18)) + .foregroundColor(AppColors.green) + .frame(width: 36, height: 44) + + VStack(alignment: .leading, spacing: 1) { + Text(task.title) + .font(AppFonts.sans(12, weight: .medium)) + .foregroundColor(AppColors.text3(cs)) + .strikethrough(true, color: AppColors.text3(cs)) + if let t = task.completedAt { + Text("Done at \(timeFmt.string(from: t))") + .font(AppFonts.mono(9.5)) + .foregroundColor(AppColors.text3(cs).opacity(0.6)) + } + } + Spacer() + Button { + withAnimation(KisaniSpring.snappy) { onUndo(task) } + } label: { + Text("UNDO") + .font(AppFonts.mono(8.5, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(AppColors.surface2(cs)) + .cornerRadius(5) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(AppColors.border(cs), lineWidth: 1)) + } + .buttonStyle(.plain) + .padding(.trailing, 4) + } + .padding(.horizontal, 10) + } + .padding(.bottom, 4) + } + } + .background(AppColors.green.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large) + .stroke(AppColors.green.opacity(0.18), lineWidth: 1)) + } +} + +// MARK: - Task Row +struct TaskRowView: View { + @Environment(\.colorScheme) private var cs + let task: TaskItem + let onToggle: () -> Void + + private let dueFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "MMM d"; return f + }() + + var body: some View { + Button(action: onToggle) { + HStack(spacing: 10) { + ZStack { + Circle() + .stroke(task.taskColor.color(), lineWidth: 1.5) + .frame(width: 18, height: 18) + } + .frame(width: 36, height: 44) + .padding(.leading, 4) + + VStack(alignment: .leading, spacing: 2) { + Text(task.title) + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text(cs)) + if let d = task.dueDate { + Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · Annual" : "")") + .font(AppFonts.mono(9.5)) + .foregroundColor(AppColors.text3(cs)) + } + } + + Spacer() + + TagChip( + text: task.category.rawValue, + color: task.taskColor.color(), + bg: task.taskColor.soft() + ) + .padding(.trailing, 9) + } + .frame(minHeight: 48) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small) + .stroke(AppColors.border(cs), lineWidth: 1)) + .overlay(alignment: .leading) { + Rectangle() + .fill(task.taskColor.color()) + .frame(width: 2.5) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + .contentShape(Rectangle()) + } + .buttonStyle(PressButtonStyle()) + } +} + +// MARK: - Add Task Sheet +struct AddTaskSheet: View { + @EnvironmentObject var taskVM: TaskViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + + @State private var title = "" + @State private var hasDueDate: Bool + @State private var dueDate: Date + @State private var selectedQuadrant: Quadrant + @State private var selectedCategory: TaskCategory = .reminder + @State private var selectedColor: TaskColor = .orange + @State private var isRecurring = false + @FocusState private var titleFocused: Bool + + var prefilledDate: Date? + var prefilledQuadrant: Quadrant + + init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) { + self.prefilledDate = prefilledDate + self.prefilledQuadrant = prefilledQuadrant + _hasDueDate = State(initialValue: prefilledDate != nil) + _dueDate = State(initialValue: prefilledDate ?? Date()) + _selectedQuadrant = State(initialValue: prefilledQuadrant) + } + + private var canAdd: Bool { + !title.trimmingCharacters(in: .whitespaces).isEmpty + } + + var body: some View { + VStack(spacing: 0) { + // ── Header ── + HStack { + Text("New Task") + .font(AppFonts.sans(17, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + Spacer() + Button("Cancel") { dismiss() } + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text3(cs)) + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 14) + + Divider().background(AppColors.border(cs)) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + + // ── Task name ── + TaskFormBlock(label: "TASK") { + TextField("What needs to be done?", text: $title) + .font(AppFonts.sans(14)) + .foregroundColor(AppColors.text(cs)) + .focused($titleFocused) + .padding(13) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.small) + .stroke(titleFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1) + ) + } + + // ── Due date ── + TaskFormBlock(label: "DUE DATE") { + VStack(spacing: hasDueDate ? 10 : 0) { + HStack { + Text("Set a due date") + .font(AppFonts.sans(13)) + .foregroundColor(AppColors.text2(cs)) + Spacer() + Toggle("", isOn: $hasDueDate) + .labelsHidden() + .tint(AppColors.accent) + } + if hasDueDate { + DatePicker("", selection: $dueDate, displayedComponents: .date) + .datePickerStyle(.graphical) + .tint(AppColors.accent) + .padding(.top, -4) + } + } + .padding(13) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.small) + .stroke(AppColors.border(cs), lineWidth: 1) + ) + .animation(KisaniSpring.snappy, value: hasDueDate) + } + + // ── Priority ── + TaskFormBlock(label: "PRIORITY") { + HStack(spacing: 7) { + ForEach(Quadrant.allCases) { q in + let isSel = selectedQuadrant == q + let color = quadrantColor(q) + Button { + withAnimation(KisaniSpring.micro) { selectedQuadrant = q } + } label: { + VStack(spacing: 3) { + Text(quadrantRoman(q)) + .font(AppFonts.mono(13, weight: .heavy)) + .foregroundColor(isSel ? color : AppColors.text3(cs)) + Text(quadrantShort(q)) + .font(AppFonts.mono(7.5, weight: .bold)) + .foregroundColor(isSel ? color : AppColors.text3(cs)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .background(isSel ? color.opacity(0.10) : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .overlay( + RoundedRectangle(cornerRadius: 9) + .stroke(isSel ? color : AppColors.border(cs), + lineWidth: isSel ? 1.5 : 1) + ) + } + .buttonStyle(.plain) + } + } + } + + // ── Category ── + TaskFormBlock(label: "CATEGORY") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], + id: \.self) { cat in + let isSel = selectedCategory == cat + Button { + withAnimation(KisaniSpring.micro) { selectedCategory = cat } + } label: { + Text(cat.rawValue) + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(isSel ? AppColors.accent : AppColors.text3(cs)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(isSel ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(Capsule()) + .overlay( + Capsule().stroke( + isSel ? AppColors.accent : AppColors.border(cs), + lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + } + } + + // ── Color ── + TaskFormBlock(label: "COLOR") { + HStack(spacing: 14) { + ForEach([TaskColor.orange, .green, .blue, .yellow], id: \.self) { c in + let isSel = selectedColor == c + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedColor = c + } + } label: { + ZStack { + Circle() + .fill(c.color()) + .frame(width: 28, height: 28) + if isSel { + Circle() + .stroke(c.color(), lineWidth: 2.5) + .frame(width: 40, height: 40) + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white) + } + } + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + } + Spacer() + } + } + + // ── Recurring ── + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("RECURRING") + .font(AppFonts.mono(9, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + Text("Repeats every year") + .font(AppFonts.sans(12)) + .foregroundColor(AppColors.text2(cs)) + } + Spacer() + Toggle("", isOn: $isRecurring) + .labelsHidden() + .tint(AppColors.accent) + } + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, 16) + } + + // ── Add Button ── + Divider().background(AppColors.border(cs)) + + Button { + guard canAdd else { return } + withAnimation { + taskVM.addTask( + title: title.trimmingCharacters(in: .whitespaces), + dueDate: hasDueDate ? dueDate : nil, + quadrant: selectedQuadrant, + category: selectedCategory, + taskColor: selectedColor, + isRecurring: isRecurring + ) + } + dismiss() + } label: { + Text("Add Task") + .font(AppFonts.sans(14, weight: .bold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(14) + .background(canAdd ? AppColors.accent : AppColors.borderHi(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .animation(KisaniSpring.micro, value: canAdd) + } + .buttonStyle(.plain) + .disabled(!canAdd) + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + titleFocused = true + } + } + } + + // MARK: - Helpers + private func quadrantColor(_ q: Quadrant) -> Color { + switch q { + case .urgent: return AppColors.accent + case .schedule: return AppColors.yellow + case .delegate_: return AppColors.blue + case .eliminate: return AppColors.green + } + } + private func quadrantRoman(_ q: Quadrant) -> String { + switch q { case .urgent: return "I"; case .schedule: return "II"; + case .delegate_: return "III"; case .eliminate: return "IV" } + } + private func quadrantShort(_ q: Quadrant) -> String { + switch q { case .urgent: return "URGENT"; case .schedule: return "SCHED"; + case .delegate_: return "DELEG"; case .eliminate: return "ELIM" } + } +} + +// MARK: - Form Block helper +private struct TaskFormBlock: View { + @Environment(\.colorScheme) private var cs + let label: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Text(label) + .font(AppFonts.mono(9, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + content + } + } +} diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift new file mode 100644 index 0000000..8dbc9b3 --- /dev/null +++ b/KisaniCal/Views/WorkoutView.swift @@ -0,0 +1,1252 @@ +import SwiftUI + +// MARK: - Workout View +struct WorkoutView: View { + @Environment(\.colorScheme) var cs + @EnvironmentObject var vm: WorkoutViewModel + @State private var showManage = false + @State private var showAddSection = false + + private var headerDate: String { + let f = DateFormatter(); f.dateFormat = "EEE, MMM d" + return f.string(from: Date()) + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + AppColors.background(cs).ignoresSafeArea() + + List { + // ── Header ── + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(vm.workoutEmoji).font(.system(size: 15)) + Text(vm.workoutTitle) + .font(AppFonts.sans(15, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + } + Text(headerDate) + .font(AppFonts.mono(10)) + .foregroundColor(AppColors.text3(cs)) + } + Spacer() + IButton(icon: "ellipsis") { showManage = true } + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 18, bottom: 4, trailing: 18)) + .moveDisabled(true) + + // ── Progress Ring ── + ProgressRingCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14)) + .moveDisabled(true) + + // ── Streak ── + StreakBannerView(streak: vm.streakDays) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 10, trailing: 14)) + .moveDisabled(true) + + // ── Workout Sections ── + ForEach($vm.activeSections) { $section in + Section { + if section.isExpanded { + if section.exercises.isEmpty { + Text("No exercises — tap + to add") + .font(AppFonts.sans(11)) + .foregroundColor(AppColors.text3(cs)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 0, trailing: 14)) + .moveDisabled(true) + } else { + ForEach($section.exercises) { $ex in + ExerciseCard(exercise: $ex, sectionId: section.id) + .environmentObject(vm) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14)) + .contextMenu { + Button(role: .destructive) { + withAnimation { vm.deleteExercise(sectionId: section.id, exerciseId: ex.id) } + } label: { + Label("Delete Exercise", systemImage: "trash") + } + } + } + .onMove { from, to in vm.moveExercise(sectionId: section.id, from: from, to: to) } + } + + // Add exercise to this section + Button { + vm.addExerciseTargetSectionId = section.id + vm.showAddExercise = true + } label: { + HStack(spacing: 6) { + Image(systemName: "plus").font(.system(size: 12)) + Text("Add Exercise").font(AppFonts.mono(10.5, weight: .semibold)).textCase(.uppercase) + } + .foregroundColor(AppColors.text3(cs)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3])) + .foregroundColor(AppColors.borderHi(cs)) + ) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 2, leading: 14, bottom: 10, trailing: 14)) + .moveDisabled(true) + } + } header: { + WorkoutSectionHeader(section: $section) + .environmentObject(vm) + } + } + + // ── Add Section ── + Button { showAddSection = true } label: { + HStack(spacing: 8) { + Image(systemName: "folder.badge.plus").font(.system(size: 14)).foregroundColor(AppColors.accent) + Text("Add Section").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent) + } + .frame(maxWidth: .infinity).padding(12) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.medium) + .strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4])) + .foregroundColor(AppColors.accent.opacity(0.4)) + ) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 4, leading: 14, bottom: 100, trailing: 14)) + .moveDisabled(true) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .environment(\.editMode, .constant(.active)) + .scrollDismissesKeyboard(.immediately) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil + ) + } + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.accent) + } + } + } + .sheet(isPresented: $vm.showAddExercise) { + if let sid = vm.addExerciseTargetSectionId { + ExercisePickerSheet(sectionId: sid) + .environmentObject(vm) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + } + .sheet(isPresented: $showManage) { + ManageWorkoutsSheet().environmentObject(vm) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showAddSection) { + AddSectionSheet().environmentObject(vm) + .presentationDetents([.height(240)]) + .presentationDragIndicator(.visible) + } + } +} + +// MARK: - Section Header +struct WorkoutSectionHeader: View { + @Binding var section: WorkoutSection + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + @State private var showRename = false + @State private var showDeleteConfirm = false + + var body: some View { + HStack(spacing: 8) { + Button { withAnimation(KisaniSpring.micro) { vm.toggleSectionExpanded(section.id) } } label: { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + .rotationEffect(.degrees(section.isExpanded ? 90 : 0)) + .frame(width: 18) + } + .buttonStyle(.plain) + + Text(section.name.uppercased()) + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(AppColors.text2(cs)) + .textCase(nil) + + Text("\(section.exercises.count)") + .font(AppFonts.mono(8.5, weight: .bold)) + .foregroundColor(AppColors.text3(cs)) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(AppColors.surface2(cs)) + .clipShape(Capsule()) + + Spacer() + + Menu { + Button { showRename = true } label: { Label("Rename", systemImage: "pencil") } + Button { withAnimation { vm.moveSectionUp(section.id) } } label: { Label("Move Up", systemImage: "chevron.up") } + Button { withAnimation { vm.moveSectionDown(section.id) } } label: { Label("Move Down", systemImage: "chevron.down") } + Divider() + Button(role: .destructive) { showDeleteConfirm = true } label: { Label("Delete Section", systemImage: "trash") } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 11)) + .foregroundColor(AppColors.text3(cs)) + .frame(width: 28, height: 28) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 14).padding(.vertical, 6) + .background(AppColors.background(cs)) + .confirmationDialog("Delete \"\(section.name)\"?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { + Button("Delete Section", role: .destructive) { withAnimation { vm.deleteSection(section.id) } } + } + .sheet(isPresented: $showRename) { + RenameSectionSheet(section: section).environmentObject(vm) + .presentationDetents([.height(220)]) + .presentationDragIndicator(.visible) + } + } +} + +// MARK: - Rename Section Sheet +struct RenameSectionSheet: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + let section: WorkoutSection + @State private var name: String + @FocusState private var focused: Bool + + init(section: WorkoutSection) { + self.section = section + _name = State(initialValue: section.name) + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Rename Section").font(AppFonts.sans(16, weight: .bold)).foregroundColor(AppColors.text(cs)) + Spacer() + Button("Cancel") { dismiss() }.font(AppFonts.sans(13)).foregroundColor(AppColors.text3(cs)).buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) + + Divider().background(AppColors.border(cs)) + + TextField("Section name", text: $name) + .font(AppFonts.sans(15)).foregroundColor(AppColors.text(cs)) + .focused($focused).padding(14) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(focused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + .padding(.horizontal, 20).padding(.top, 18) + + Spacer() + + Button { + guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return } + vm.renameSection(section.id, name: name.trimmingCharacters(in: .whitespaces)) + dismiss() + } label: { + Text("Save").font(AppFonts.sans(14, weight: .bold)).foregroundColor(.white) + .frame(maxWidth: .infinity).padding(14) + .background(!name.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + } + .buttonStyle(.plain).disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .padding(.horizontal, 20).padding(.bottom, 20) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { focused = true } } + } +} + +// MARK: - Add Section Sheet +struct AddSectionSheet: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + @State private var name = "" + @FocusState private var focused: Bool + + private let presets = ["Warm Up", "Main Lifts", "Accessories", "Cool Down", "Cardio", "Supersets"] + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("New Section").font(AppFonts.sans(16, weight: .bold)).foregroundColor(AppColors.text(cs)) + Spacer() + Button("Cancel") { dismiss() }.font(AppFonts.sans(13)).foregroundColor(AppColors.text3(cs)).buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) + + Divider().background(AppColors.border(cs)) + + VStack(alignment: .leading, spacing: 12) { + TextField("e.g. Warm Up, Main Lifts…", text: $name) + .font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs)) + .focused($focused).padding(13) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(focused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(presets, id: \.self) { preset in + Button { name = preset } label: { + Text(preset) + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(name == preset ? AppColors.accent : AppColors.text3(cs)) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(name == preset ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(name == preset ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + } + .buttonStyle(.plain) + } + } + } + } + .padding(.horizontal, 20).padding(.top, 14) + + Spacer() + + Button { + guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return } + vm.addSection(name: name.trimmingCharacters(in: .whitespaces)) + dismiss() + } label: { + Text("Add Section").font(AppFonts.sans(14, weight: .bold)).foregroundColor(.white) + .frame(maxWidth: .infinity).padding(14) + .background(!name.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + } + .buttonStyle(.plain).disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .padding(.horizontal, 20).padding(.bottom, 20) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { focused = true } } + } +} + +// MARK: - Progress Ring Card +struct ProgressRingCard: View { + @Environment(\.colorScheme) private var cs + let progress: Double; let done: Int; let total: Int + var pct: Int { Int(progress * 100) } + + var body: some View { + HStack(spacing: 10) { + ZStack { + Circle().stroke(AppColors.borderHi(cs), lineWidth: 3.5).frame(width: 44, height: 44) + Circle().trim(from: 0, to: CGFloat(progress)) + .stroke(AppColors.accent, style: StrokeStyle(lineWidth: 3.5, lineCap: .round)) + .frame(width: 44, height: 44).rotationEffect(.degrees(-90)) + .animation(KisaniSpring.entrance, value: progress) + Text("\(pct)%").font(AppFonts.mono(11, weight: .bold)).foregroundColor(AppColors.accent) + } + VStack(alignment: .leading, spacing: 2) { + Text("\(done) / \(total) sets done") + .font(AppFonts.sans(12.5, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Text(total == 0 ? "Add exercises to get started" : done == total && total > 0 ? "Workout complete! 🎉" : "Keep going 🔥") + .font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + } + .padding(10).cardStyle() + } +} + +// MARK: - Streak Banner +struct StreakBannerView: View { + @Environment(\.colorScheme) private var cs + let streak: Int + var body: some View { + HStack(spacing: 10) { + Text("\(streak)").font(AppFonts.mono(26, weight: .bold)).foregroundColor(AppColors.accent) + VStack(alignment: .leading, spacing: 1) { + Text("Day Streak").font(AppFonts.sans(12, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Text("Don't break the chain").font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + HStack(spacing: 4) { + ForEach(0..<7, id: \.self) { i in + Circle().fill(i < streak ? AppColors.accent : AppColors.borderHi(cs)).frame(width: 7, height: 7) + } + } + } + .padding(11) + .background(LinearGradient(colors: [AppColors.accent.opacity(0.12), AppColors.yellow.opacity(0.08)], startPoint: .topLeading, endPoint: .bottomTrailing)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.accent.opacity(0.18), lineWidth: 1)) + } +} + +// MARK: - Exercise Card +struct ExerciseCard: View { + @Binding var exercise: Exercise + let sectionId: UUID + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + + var borderColor: Color { exercise.isPartialDone ? AppColors.accent : AppColors.border(cs) } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Image(systemName: exercise.sfSymbol).font(.system(size: 15, weight: .medium)) + .foregroundColor(exercise.colorIndex.iconColor) + .frame(width: 32, height: 32).background(exercise.colorIndex.iconBg).cornerRadius(9) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(exercise.name).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + // ×2 dumbbell pill — inline with title, compact + if exercise.isDumbbell { + Text("×2") + .font(AppFonts.mono(7.5, weight: .bold)) + .foregroundColor(AppColors.accent) + .padding(.horizontal, 4).padding(.vertical, 2) + .background(AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + } + Text("\(exercise.totalSets) sets · \(exercise.doneSets) done") + .font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + // Toggle button — visible but not loud + Button { + withAnimation(KisaniSpring.micro) { + vm.toggleDumbbell(sectionId: sectionId, exerciseId: exercise.id) + } + } label: { + Image(systemName: "dumbbell.fill") + .font(.system(size: 10)) + .foregroundColor(exercise.isDumbbell ? AppColors.accent : AppColors.text3(cs).opacity(0.5)) + .padding(6) + .background(exercise.isDumbbell ? AppColors.accentSoft : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(PressButtonStyle(scale: 0.9)) + Image(systemName: "chevron.right").font(.system(size: 10, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + .rotationEffect(.degrees(exercise.isExpanded ? 90 : 0)) + .animation(KisaniSpring.micro, value: exercise.isExpanded) + .padding(.leading, 10) + } + .padding(11).contentShape(Rectangle()) + .onTapGesture { withAnimation(KisaniSpring.micro) { vm.toggleExpanded(sectionId: sectionId, exerciseId: exercise.id) } } + + if exercise.isExpanded { + Divider().background(AppColors.border(cs)) + SetsArea(exercise: $exercise, sectionId: sectionId).environmentObject(vm) + } + } + .cardStyle(borderColor: borderColor) + } +} + +private let bodyweightExercises: Set = [ + "Wide Pull-ups", "Pull-ups", "Dips", "Tricep Dips", "Push-ups", + "Ab Wheel Rollout", "Hanging Leg Raise", "Chest Dips" +] + +// MARK: - Sets Area +struct SetsArea: View { + @Binding var exercise: Exercise + let sectionId: UUID + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) private var cs + + private var isBodyweight: Bool { bodyweightExercises.contains(exercise.name) } + + private var isDumbbell: Bool { exercise.isDumbbell } + + private var weightHeader: String { + if isBodyweight { return "Bodyweight" } + return isDumbbell ? "Each Side" : "Weight" + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 4) { + Text("#").frame(width: 28, alignment: .leading) + Text(weightHeader.lowercased()).frame(maxWidth: .infinity) + Text("reps").frame(maxWidth: .infinity) + Text(isDumbbell ? "total" : "").frame(maxWidth: .infinity) + Image(systemName: "checkmark") + .font(.system(size: 8, weight: .bold)) + .frame(width: 40) + } + .font(AppFonts.mono(8, weight: .semibold)).foregroundColor(AppColors.text3(cs)) + .padding(.horizontal, 13).padding(.top, 8).padding(.bottom, 4) + + ForEach($exercise.sets) { $set in + let idx = exercise.sets.firstIndex(where: { $0.id == set.id }) ?? 0 + SetRowView(set: $set, number: idx + 1, sectionId: sectionId, exerciseId: exercise.id, + isBodyweight: isBodyweight, isDumbbell: isDumbbell) + .environmentObject(vm) + if idx < exercise.sets.count - 1 { + Divider().background(AppColors.border(cs)).padding(.horizontal, 13) + } + } + + Button { vm.addSet(sectionId: sectionId, to: exercise.id) } label: { + HStack(spacing: 6) { + Image(systemName: "plus").font(.system(size: 14)) + Text("Add Set").font(AppFonts.mono(11, weight: .semibold)).textCase(.uppercase) + } + .foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity).padding(.vertical, 7) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3])).foregroundColor(AppColors.borderHi(cs))) + } + .buttonStyle(.plain).padding(.horizontal, 13).padding(.top, 7).padding(.bottom, 10) + } + } +} + +// MARK: - Set Row +struct SetRowView: View { + @Binding var set: ExerciseSet + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) private var cs + @AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 80 + @AppStorage("weightUnit") private var globalUnit: Int = 0 // 0 = kg, 1 = lbs + let number: Int; let sectionId: UUID; let exerciseId: UUID + var isBodyweight: Bool = false + var isDumbbell: Bool = false + + // String state — fixes backspace (value:format: swallows delete events) + @State private var weightStr = "" + @State private var repsStr = "" + // Local unit for this row, defaults to global but user can override + @State private var rowUnit: Int = 0 + + private static let kgToLbs = 2.20462 + private static let lbsToKg = 1.0 / 2.20462 + + private func toDisplay(_ kg: Double) -> String { + let val = rowUnit == 1 ? kg * Self.kgToLbs : kg + guard val > 0 else { return "" } + return val.truncatingRemainder(dividingBy: 1) == 0 + ? "\(Int(val))" + : String(format: "%.1f", val) + } + + private func toKg(_ str: String) -> Double { + guard let val = Double(str) else { return 0 } + return rowUnit == 1 ? val * Self.lbsToKg : val + } + + private func syncDisplay() { + let base = (isBodyweight && set.weight == 0) ? bodyWeightKg : set.weight + weightStr = toDisplay(base) + } + + var body: some View { + HStack(spacing: 4) { + Text("\(number)") + .font(AppFonts.mono(10.5, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + .frame(width: 28, alignment: .leading) + + // ── Weight + unit toggle ── + HStack(spacing: 0) { + TextField(isBodyweight ? toDisplay(bodyWeightKg) : "0", text: $weightStr) + .font(AppFonts.mono(12)).foregroundColor(AppColors.text(cs)) + .multilineTextAlignment(.center) + .keyboardType(.decimalPad) + .onChange(of: weightStr) { val in + let clean = String(val.filter { $0.isNumber || $0 == "." }.prefix(6)) + if clean != val { weightStr = clean } + set.weight = toKg(clean) + } + + // Tappable unit pill — switches this row's unit and reconverts display + Button { + let kg = set.weight + withAnimation(KisaniSpring.micro) { rowUnit = rowUnit == 0 ? 1 : 0 } + weightStr = toDisplay(kg) + } label: { + Text(rowUnit == 0 ? "kg" : "lbs") + .font(AppFonts.mono(8, weight: .bold)) + .foregroundColor(rowUnit == 1 ? AppColors.accent : AppColors.text3(cs)) + .padding(.horizontal, 5).padding(.vertical, 3) + .background(rowUnit == 1 ? AppColors.accentSoft : AppColors.surface3(cs)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(PressButtonStyle(scale: 0.92)) + .padding(.trailing, 4) + } + .padding(.vertical, 4).padding(.leading, 6) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke( + isBodyweight ? AppColors.blue.opacity(0.25) : AppColors.border(cs), lineWidth: 1)) + .frame(maxWidth: .infinity) + + // ── Reps field ── + TextField("0", text: $repsStr) + .font(AppFonts.mono(12)).foregroundColor(AppColors.text(cs)) + .multilineTextAlignment(.center) + .keyboardType(.numberPad) + .onChange(of: repsStr) { val in + let clean = String(val.filter { $0.isNumber }.prefix(3)) + if clean != val { repsStr = clean } + set.reps = Int(clean) ?? 0 + } + .padding(.vertical, 5).padding(.horizontal, 4) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(AppColors.border(cs), lineWidth: 1)) + .frame(maxWidth: .infinity) + + // ── Dumbbell total or empty spacer ── + Group { + if isDumbbell && set.weight > 0 { + let totalKg = set.weight * 2 + let displayTotal = rowUnit == 1 + ? totalKg * Self.kgToLbs + : totalKg + let unit = rowUnit == 1 ? "lbs" : "kg" + let totalStr = displayTotal.truncatingRemainder(dividingBy: 1) == 0 + ? "\(Int(displayTotal))\(unit)" + : String(format: "%.1f\(unit)", displayTotal) + VStack(spacing: 1) { + Text("= \(totalStr)") + .font(AppFonts.mono(9, weight: .bold)) + .foregroundColor(AppColors.accent) + Text("total") + .font(AppFonts.mono(7)) + .foregroundColor(AppColors.text3(cs)) + } + .frame(maxWidth: .infinity) + } else { + Spacer().frame(maxWidth: .infinity) + } + } + + Button { vm.toggleSet(sectionId: sectionId, exerciseId: exerciseId, setId: set.id) } label: { + Image(systemName: set.isDone ? "checkmark" : "circle") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(set.isDone ? AppColors.green : AppColors.text3(cs)) + .frame(width: 28, height: 28) + .background(set.isDone ? AppColors.greenSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).stroke( + set.isDone ? AppColors.green : AppColors.border(cs), lineWidth: 1)) + .scaleEffect(set.isDone ? 1.0 : 0.92) + .animation(KisaniSpring.bounce, value: set.isDone) + } + .buttonStyle(PressButtonStyle(scale: 0.88)).frame(width: 40, alignment: .center) + } + .padding(.horizontal, 13).padding(.vertical, 4) + .onAppear { + rowUnit = globalUnit + if isBodyweight && set.weight == 0 { set.weight = bodyWeightKg } + syncDisplay() + repsStr = set.reps > 0 ? "\(set.reps)" : "" + } + // When the user changes the global default in Settings, reformat all open rows + .onChange(of: globalUnit) { newUnit in + withAnimation(KisaniSpring.micro) { rowUnit = newUnit } + weightStr = toDisplay(set.weight) + } + } +} + +// MARK: - Manage Workouts Sheet +struct ManageWorkoutsSheet: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + @State private var showAddProgram = false + + private let weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + private let weekdayNumbers = [2, 3, 4, 5, 6, 7, 1] + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Manage Workouts").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs)) + Spacer() + Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) + + Divider().background(AppColors.border(cs)) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("PROGRAMS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + + VStack(spacing: 0) { + ForEach(vm.programs) { prog in + ProgramRow(program: prog) + if prog.id != vm.programs.last?.id { + Divider().background(AppColors.border(cs)).padding(.leading, 52) + } + } + } + .cardStyle() + + Button { showAddProgram = true } label: { + HStack(spacing: 8) { + Image(systemName: "plus.circle.fill").font(.system(size: 16)).foregroundColor(AppColors.accent) + Text("New Program").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent) + } + .frame(maxWidth: .infinity).padding(12) + .overlay(RoundedRectangle(cornerRadius: AppRadius.medium).strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4])).foregroundColor(AppColors.accent.opacity(0.4))) + } + .buttonStyle(.plain) + } + + VStack(alignment: .leading, spacing: 8) { + Text("WEEKLY SCHEDULE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + Text("Tap a day to assign a workout program") + .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + + HStack(spacing: 6) { + ForEach(weekdays.indices, id: \.self) { i in + DayScheduleCell(day: weekdays[i], weekdayNum: weekdayNumbers[i]).environmentObject(vm) + } + } + } + } + .padding(.horizontal, 20).padding(.top, 18).padding(.bottom, 30) + } + } + .background(AppColors.background(cs).ignoresSafeArea()) + .sheet(isPresented: $showAddProgram) { + AddProgramSheet().environmentObject(vm) + .presentationDetents([.height(420)]) + .presentationDragIndicator(.visible) + } + } +} + +// MARK: - Program Row +private struct ProgramRow: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + let program: WorkoutProgram + @State private var showConfirmDelete = false + @State private var showRename = false + @State private var showDetail = false + + var isActive: Bool { vm.activeProgramId == program.id } + var subtitle: String { + let s = program.sections.count + let e = program.totalExercises + return "\(s) section\(s == 1 ? "" : "s") · \(e) exercise\(e == 1 ? "" : "s")" + } + + var body: some View { + HStack(spacing: 12) { + // Tappable body → program detail + Button { showDetail = true } label: { + HStack(spacing: 12) { + Text(program.emoji).font(.system(size: 22)) + .frame(width: 40, height: 40) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 2) { + Text(program.name).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) + Text(subtitle).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs)) + } + } + } + .buttonStyle(.plain) + + Spacer() + + Button { showRename = true } label: { + Image(systemName: "pencil").font(.system(size: 12)) + .foregroundColor(AppColors.text3(cs)) + .frame(width: 28, height: 28) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain) + + if isActive { + Text("ACTIVE").font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.accent) + .padding(.horizontal, 7).padding(.vertical, 3).background(AppColors.accentSoft).cornerRadius(5) + } else { + Button { withAnimation { vm.switchProgram(to: program.id) } } label: { + Text("Switch").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + .padding(.horizontal, 8).padding(.vertical, 4) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(AppColors.border(cs), lineWidth: 1)) + } + .buttonStyle(.plain) + + Button { showConfirmDelete = true } label: { + Image(systemName: "trash").font(.system(size: 12)).foregroundColor(AppColors.red.opacity(0.7)) + .frame(width: 28, height: 28).background(AppColors.surface2(cs)).clipShape(RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain) + .confirmationDialog("Delete \"\(program.name)\"?", isPresented: $showConfirmDelete, titleVisibility: .visible) { + Button("Delete", role: .destructive) { withAnimation { vm.deleteProgram(program.id) } } + } + } + } + .padding(.horizontal, 14).padding(.vertical, 10) + .sheet(isPresented: $showRename) { + RenameProgramSheet(program: program).environmentObject(vm) + .presentationDetents([.height(380)]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showDetail) { + ProgramDetailSheet(programId: program.id).environmentObject(vm) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + } +} + +// MARK: - Program Detail Sheet +private struct SectionTarget: Identifiable { let id: UUID } + +struct ProgramDetailSheet: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + + let programId: UUID + @State private var showAddSection = false + @State private var addExerciseTargetSection: SectionTarget? = nil + + var program: WorkoutProgram? { vm.programs.first { $0.id == programId } } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack(spacing: 10) { + if let p = program { + Text(p.emoji).font(.system(size: 20)) + Text(p.name).font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs)) + } + Spacer() + Button("Done") { dismiss() } + .font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) + + Divider().background(AppColors.border(cs)) + + if let prog = program { + if prog.sections.isEmpty { + VStack(spacing: 8) { + Image(systemName: "dumbbell").font(.system(size: 28)).foregroundColor(AppColors.text3(cs)) + Text("No sections yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs)) + Text("Add a section to start building this program") + .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + List { + ForEach(prog.sections) { section in + Section { + if section.exercises.isEmpty { + Text("No exercises — tap + to add") + .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + .frame(maxWidth: .infinity).padding(.vertical, 10) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else { + ForEach(section.exercises) { exercise in + HStack(spacing: 10) { + Image(systemName: exercise.sfSymbol) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(exercise.colorIndex.iconColor) + .frame(width: 28, height: 28) + .background(exercise.colorIndex.iconBg) + .cornerRadius(7) + VStack(alignment: .leading, spacing: 1) { + Text(exercise.name) + .font(AppFonts.sans(12.5, weight: .medium)) + .foregroundColor(AppColors.text(cs)) + Text("\(exercise.sets.count) sets") + .font(AppFonts.mono(9.5)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + } + .padding(.vertical, 4) + .listRowBackground(AppColors.surface(cs)) + .listRowSeparator(.hidden) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + vm.deleteExercise(programId: programId, sectionId: section.id, exerciseId: exercise.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + + // Add exercise to this section + Button { + addExerciseTargetSection = SectionTarget(id: section.id) + } label: { + HStack(spacing: 6) { + Image(systemName: "plus").font(.system(size: 11)) + Text("Add Exercise").font(AppFonts.mono(10, weight: .semibold)).textCase(.uppercase) + } + .foregroundColor(AppColors.accent) + .frame(maxWidth: .infinity).padding(.vertical, 8) + .overlay(RoundedRectangle(cornerRadius: 8) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3])) + .foregroundColor(AppColors.accent.opacity(0.4))) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 2) + + } header: { + HStack { + Text(section.name.uppercased()) + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(AppColors.text2(cs)) + .textCase(nil) + Text("\(section.exercises.count)") + .font(AppFonts.mono(8.5)) + .foregroundColor(AppColors.text3(cs)) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(AppColors.surface2(cs)) + .clipShape(Capsule()) + Spacer() + Button(role: .destructive) { + vm.deleteSection(from: programId, sectionId: section.id) + } label: { + Image(systemName: "trash").font(.system(size: 11)).foregroundColor(AppColors.red.opacity(0.6)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 14).padding(.vertical, 6) + .background(AppColors.background(cs)) + } + } + + // Add Section + Button { showAddSection = true } label: { + HStack(spacing: 8) { + Image(systemName: "folder.badge.plus").font(.system(size: 13)).foregroundColor(AppColors.accent) + Text("Add Section").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent) + } + .frame(maxWidth: .infinity).padding(11) + .overlay(RoundedRectangle(cornerRadius: AppRadius.medium) + .strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4])) + .foregroundColor(AppColors.accent.opacity(0.4))) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 14, bottom: 20, trailing: 14)) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + } + .background(AppColors.background(cs).ignoresSafeArea()) + .sheet(isPresented: $showAddSection) { + ProgramAddSectionSheet(programId: programId).environmentObject(vm) + .presentationDetents([.height(240)]) + .presentationDragIndicator(.visible) + } + .sheet(item: $addExerciseTargetSection) { target in + ExercisePickerSheet(sectionId: target.id, programId: programId) + .environmentObject(vm) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + } +} + +// MARK: - Program Add Section Sheet (for non-active programs) +private struct ProgramAddSectionSheet: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + let programId: UUID + @State private var name = "" + @FocusState private var focused: Bool + + private let presets = ["Warm Up", "Main Lifts", "Accessories", "Cool Down", "Cardio", "Supersets"] + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("New Section").font(AppFonts.sans(16, weight: .bold)).foregroundColor(AppColors.text(cs)) + Spacer() + Button("Cancel") { dismiss() }.font(AppFonts.sans(13)).foregroundColor(AppColors.text3(cs)).buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) + + Divider().background(AppColors.border(cs)) + + VStack(alignment: .leading, spacing: 12) { + TextField("e.g. Warm Up, Main Lifts…", text: $name) + .font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs)) + .focused($focused).padding(13) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(focused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(presets, id: \.self) { preset in + Button { name = preset } label: { + Text(preset) + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(name == preset ? AppColors.accent : AppColors.text3(cs)) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(name == preset ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(name == preset ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + } + .buttonStyle(.plain) + } + } + } + } + .padding(.horizontal, 20).padding(.top, 14) + + Spacer() + + Button { + guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return } + vm.addSection(to: programId, name: name.trimmingCharacters(in: .whitespaces)) + dismiss() + } label: { + Text("Add Section").font(AppFonts.sans(14, weight: .bold)).foregroundColor(.white) + .frame(maxWidth: .infinity).padding(14) + .background(!name.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + } + .buttonStyle(.plain).disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .padding(.horizontal, 20).padding(.bottom, 20) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { focused = true } } + } +} + +// MARK: - Day Schedule Cell +private struct DayScheduleCell: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + let day: String; let weekdayNum: Int + @State private var showPicker = false + + var assignedProgram: WorkoutProgram? { + guard let pid = vm.schedule[weekdayNum] else { return nil } + return vm.programs.first { $0.id == pid } + } + var isToday: Bool { Calendar.current.component(.weekday, from: Date()) == weekdayNum } + + var body: some View { + Button { showPicker = true } label: { + VStack(spacing: 4) { + Text(day).font(AppFonts.mono(8.5, weight: .bold)) + .foregroundColor(isToday ? AppColors.accent : AppColors.text3(cs)) + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(assignedProgram != nil ? AppColors.accentSoft : AppColors.surface2(cs)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(isToday ? AppColors.accent : AppColors.border(cs), lineWidth: isToday ? 1.5 : 1)) + if let prog = assignedProgram { + Text(prog.emoji).font(.system(size: 16)) + } else { + Text("─").font(.system(size: 14)).foregroundColor(AppColors.text3(cs)) + } + } + .frame(height: 44) + Text(assignedProgram?.name.components(separatedBy: " ").first ?? "REST") + .font(AppFonts.mono(7, weight: .bold)) + .foregroundColor(AppColors.text3(cs)).lineLimit(1) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .confirmationDialog("Assign \(day)", isPresented: $showPicker, titleVisibility: .visible) { + ForEach(vm.programs) { prog in + Button("\(prog.emoji) \(prog.name)") { vm.setSchedule(weekday: weekdayNum, programId: prog.id) } + } + Button("Rest Day", role: .destructive) { vm.setSchedule(weekday: weekdayNum, programId: nil) } + Button("Cancel", role: .cancel) {} + } + } +} + +// MARK: - Rename Program Sheet +struct RenameProgramSheet: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + let program: WorkoutProgram + @State private var name: String + @State private var selectedEmoji: String + @FocusState private var nameFocused: Bool + private let emojis = ["💪", "🦵", "🔄", "🏃", "🤸", "🏋️", "🧘", "🚴", "🥊", "🎯", "⚡️", "🔥"] + + init(program: WorkoutProgram) { + self.program = program + _name = State(initialValue: program.name) + _selectedEmoji = State(initialValue: program.emoji) + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Rename Program").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs)) + Spacer() + Button("Cancel") { dismiss() }.font(AppFonts.sans(13)).foregroundColor(AppColors.text3(cs)).buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) + Divider().background(AppColors.border(cs)) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 7) { + Text("PROGRAM NAME").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + TextField("Program name", text: $name) + .font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs)) + .focused($nameFocused).padding(13) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(nameFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + } + VStack(alignment: .leading, spacing: 7) { + Text("ICON").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 6), spacing: 8) { + ForEach(emojis, id: \.self) { em in + Button { withAnimation(KisaniSpring.micro) { selectedEmoji = em } } label: { + Text(em).font(.system(size: 22)) + .frame(maxWidth: .infinity).frame(height: 44) + .background(selectedEmoji == em ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(selectedEmoji == em ? AppColors.accent : AppColors.border(cs), lineWidth: selectedEmoji == em ? 1.5 : 1)) + } + .buttonStyle(.plain) + } + } + } + } + .padding(.horizontal, 20).padding(.top, 18).padding(.bottom, 16) + } + Divider().background(AppColors.border(cs)) + Button { + guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return } + vm.renameProgram(program.id, name: name.trimmingCharacters(in: .whitespaces), emoji: selectedEmoji) + dismiss() + } label: { + Text("Save Changes").font(AppFonts.sans(14, weight: .bold)).foregroundColor(.white) + .frame(maxWidth: .infinity).padding(14) + .background(!name.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + } + .buttonStyle(.plain).disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .padding(.horizontal, 20).padding(.vertical, 14) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { nameFocused = true } } + } +} + +// MARK: - Add Program Sheet +struct AddProgramSheet: View { + @EnvironmentObject var vm: WorkoutViewModel + @Environment(\.colorScheme) var cs + @Environment(\.dismiss) var dismiss + @State private var name = "" + @State private var selectedEmoji = "💪" + @FocusState private var nameFocused: Bool + private let emojis = ["💪", "🦵", "🔄", "🏃", "🤸", "🏋️", "🧘", "🚴", "🥊", "🎯", "⚡️", "🔥"] + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("New Program").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs)) + Spacer() + Button("Cancel") { dismiss() }.font(AppFonts.sans(13)).foregroundColor(AppColors.text3(cs)).buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14) + Divider().background(AppColors.border(cs)) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 7) { + Text("PROGRAM NAME").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + TextField("e.g. Push Day, Cardio…", text: $name) + .font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs)) + .focused($nameFocused).padding(13) + .background(AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(nameFocused ? AppColors.accent : AppColors.border(cs), lineWidth: 1)) + } + VStack(alignment: .leading, spacing: 7) { + Text("ICON").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 6), spacing: 8) { + ForEach(emojis, id: \.self) { em in + Button { withAnimation(KisaniSpring.micro) { selectedEmoji = em } } label: { + Text(em).font(.system(size: 22)) + .frame(maxWidth: .infinity).frame(height: 44) + .background(selectedEmoji == em ? AppColors.accentSoft : AppColors.surface2(cs)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(selectedEmoji == em ? AppColors.accent : AppColors.border(cs), lineWidth: selectedEmoji == em ? 1.5 : 1)) + } + .buttonStyle(.plain) + } + } + } + } + .padding(.horizontal, 20).padding(.top, 18).padding(.bottom, 16) + } + Divider().background(AppColors.border(cs)) + Button { + guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return } + vm.addProgram(name: name.trimmingCharacters(in: .whitespaces), emoji: selectedEmoji) + dismiss() + } label: { + Text("Create Program").font(AppFonts.sans(14, weight: .bold)).foregroundColor(.white) + .frame(maxWidth: .infinity).padding(14) + .background(!name.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.accent : AppColors.borderHi(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.large)) + .animation(KisaniSpring.micro, value: name.isEmpty) + } + .buttonStyle(.plain).disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .padding(.horizontal, 20).padding(.vertical, 14) + } + .background(AppColors.background(cs).ignoresSafeArea()) + .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { nameFocused = true } } + } +} diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..e54cc9a --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,35 @@ +# Product + +## Register + +product + +## Users + +Robin — a working professional who trains consistently 6 days a week and manages his day through a personal task system. Uses the app alone, primarily on iPhone, during morning planning and at the gym. Context is always personal: this is his own tool, not a team product. + +## Product Purpose + +Kisani Cal is a personal productivity and fitness companion: daily task management (Eisenhower matrix, overdue/today/upcoming), workout logging with weighted sets and bodyweight exercises, and a personal calendar. It exists because off-the-shelf tools feel impersonal. This one is his. + +## Brand Personality + +Calm · Personal · Sharp. Quiet confidence. Like a well-worn notebook that holds everything important but never demands attention. Nothing decorative, nothing gamified, nothing that belongs on a leaderboard. + +## Anti-references + +- Notion / Linear: over-engineered productivity, panel overload, too much structural chrome +- Generic fitness apps (MyFitnessPal, Fitbit): loud gradients, trophy animations, gamification badges, progress celebrations +- Apple Health / Fitness: over-polished and impersonal, feels like a device report, not yours + +## Design Principles + +1. Nothing shouts. Hierarchy through restraint, not decoration. +2. Personal over polished. Feels made for one person, not designed for a product screenshot. +3. Data is primary. Weights, reps, tasks are the content. The UI serves them, not the other way around. +4. Color earns its place. Orange for urgency/action, green for completion, red for overdue. Never decorative. +5. Sharp where it matters. Decisive weight contrasts and type scale, not soft or apologetic. + +## Accessibility & Inclusion + +Standard iOS Dynamic Type support. WCAG AA contrast minimum. Respect reduce-motion preference. diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..8267f64 --- /dev/null +++ b/project.yml @@ -0,0 +1,32 @@ +name: KisaniCal +options: + bundleIdPrefix: com.kutesir + deploymentTarget: + iOS: "16.0" + xcodeVersion: "15.0" + createIntermediateGroups: true + +targets: + KisaniCal: + type: application + platform: iOS + deploymentTarget: "16.0" + sources: + - path: KisaniCal + settings: + base: + SWIFT_VERSION: 5.9 + TARGETED_DEVICE_FAMILY: "1,2" + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_UILaunchStoryboardName: "" + INFOPLIST_KEY_CFBundleDisplayName: "Kisani Cal" + INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleDefault + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + CODE_SIGN_STYLE: Automatic + ENABLE_PREVIEWS: YES + INFOPLIST_KEY_NSCalendarsUsageDescription: "Kisani Cal shows your calendar events alongside tasks." + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "Kisani Cal shows your calendar events alongside tasks."