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.
This commit is contained in:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -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
|
||||
430
KisaniCal.xcodeproj/project.pbxproj
Normal file
430
KisaniCal.xcodeproj/project.pbxproj
Normal file
@@ -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 = "<group>"; };
|
||||
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; };
|
||||
23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
|
||||
5722CC4B59E3939724142710 /* ExerciseModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExerciseModels.swift; sourceTree = "<group>"; };
|
||||
57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = "<group>"; };
|
||||
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
|
||||
C2D3E4F5A6B7C8D9E0F1A2B3 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; };
|
||||
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
|
||||
F7A1B2C3D4E5F6A7B8C9D0E1 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
21B93C269F283F11B415B18C /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23A4491BFA50721082024756 /* SharedComponents.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23CBCF100C5EF55E737379CA /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5722CC4B59E3939724142710 /* ExerciseModels.swift */,
|
||||
DCC2AFB4FA765383740767CB /* TaskItem.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4D2F2B3472A5A6D99F2FFCD2 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F70DA4746C68CD405435DAB6 /* KisaniCal */,
|
||||
51BD1B5DEDE9FAD9CA2FF6DA /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
51BD1B5DEDE9FAD9CA2FF6DA /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
9981C53A4E263A0DBC3DF194 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E0A03E79A679740978E61BF1 /* Theme */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */,
|
||||
);
|
||||
path = Theme;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A1B2C3D4E5F6070A8B9C0D1E /* Managers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F7A1B2C3D4E5F6A7B8C9D0E1 /* NotificationManager.swift */,
|
||||
);
|
||||
path = Managers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
20
KisaniCal/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
20
KisaniCal/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
13
KisaniCal/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
13
KisaniCal/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
KisaniCal/Assets.xcassets/Contents.json
Normal file
6
KisaniCal/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
184
KisaniCal/Components/SharedComponents.swift
Normal file
184
KisaniCal/Components/SharedComponents.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
258
KisaniCal/ContentView.swift
Normal file
258
KisaniCal/ContentView.swift
Normal file
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
22
KisaniCal/KisaniCalApp.swift
Normal file
22
KisaniCal/KisaniCalApp.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
127
KisaniCal/Managers/NotificationManager.swift
Normal file
127
KisaniCal/Managers/NotificationManager.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
601
KisaniCal/Models/ExerciseModels.swift
Normal file
601
KisaniCal/Models/ExerciseModels.swift
Normal file
@@ -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..<setsCount).map { _ in ExerciseSet(weight: 0, reps: repsCount) }
|
||||
let colorIdx = programs[pi].allExercises.count % 4
|
||||
programs[pi].sections[si].exercises.append(
|
||||
Exercise(name: name, sfSymbol: symbol, colorIndex: colorIdx, sets: sets, isDumbbell: isDumbb)
|
||||
)
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
}
|
||||
|
||||
func deleteExercise(programId: UUID, sectionId: UUID, exerciseId: UUID) {
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }),
|
||||
let si = programs[pi].sections.firstIndex(where: { $0.id == sectionId })
|
||||
else { return }
|
||||
programs[pi].sections[si].exercises.removeAll { $0.id == exerciseId }
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
}
|
||||
|
||||
func addSection(to programId: UUID, name: String) {
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||||
programs[pi].sections.append(WorkoutSection(name: name, exercises: []))
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
}
|
||||
|
||||
func deleteSection(from programId: UUID, sectionId: UUID) {
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||||
programs[pi].sections.removeAll { $0.id == sectionId }
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
}
|
||||
|
||||
func renameSection(in programId: UUID, sectionId: UUID, name: String) {
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }),
|
||||
let si = programs[pi].sections.firstIndex(where: { $0.id == sectionId })
|
||||
else { return }
|
||||
programs[pi].sections[si].name = name
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
}
|
||||
|
||||
func toggleExpanded(sectionId: UUID, exerciseId: UUID) {
|
||||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
|
||||
else { return }
|
||||
activeSections[si].exercises[ei].isExpanded.toggle()
|
||||
}
|
||||
|
||||
func toggleDumbbell(sectionId: UUID, exerciseId: UUID) {
|
||||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
|
||||
else { return }
|
||||
activeSections[si].exercises[ei].isDumbbell.toggle()
|
||||
syncBack()
|
||||
}
|
||||
|
||||
private func syncBack() {
|
||||
guard let idx = programs.firstIndex(where: { $0.id == activeProgramId }) else { return }
|
||||
programs[idx].sections = activeSections
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Sample data
|
||||
static func samplePrograms() -> [WorkoutProgram] {
|
||||
func s(_ count: Int, reps: Int = 12) -> [ExerciseSet] {
|
||||
(0..<count).map { _ in ExerciseSet(reps: reps) }
|
||||
}
|
||||
|
||||
let warmUp = WorkoutSection(name: "Warm Up", exercises: [
|
||||
Exercise(name: "Wide Pull-ups", sfSymbol: "figure.pull.up", colorIndex: 1, sets: s(3, reps: 10)),
|
||||
Exercise(name: "Dips", sfSymbol: "figure.gymnastics", colorIndex: 0, sets: s(3, reps: 10)),
|
||||
Exercise(name: "Ab Wheel Rollout", sfSymbol: "figure.roll", colorIndex: 2, sets: s(3, reps: 10)),
|
||||
])
|
||||
|
||||
// lbs → kg conversion factor
|
||||
let lb: Double = 0.453592
|
||||
|
||||
// ── Monday: Chest & Triceps ──────────────────────────────────────────
|
||||
let mon = WorkoutProgram(name: "Chest & Triceps", emoji: "🏋️", colorIndex: 0, sections: [
|
||||
warmUp,
|
||||
WorkoutSection(name: "Chest", exercises: [
|
||||
// Incline first — 70 / 65 / 45 / 35 lbs × 12 reps (per-side dumbbell)
|
||||
Exercise(name: "Incline Bench Press", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: [
|
||||
ExerciseSet(weight: 70 * lb, reps: 12),
|
||||
ExerciseSet(weight: 65 * lb, reps: 12),
|
||||
ExerciseSet(weight: 45 * lb, reps: 12),
|
||||
ExerciseSet(weight: 35 * lb, reps: 12),
|
||||
], isDumbbell: true),
|
||||
// Flat — 65 / 45 / 35 lbs × 12 reps
|
||||
Exercise(name: "Bench Press", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: [
|
||||
ExerciseSet(weight: 65 * lb, reps: 12),
|
||||
ExerciseSet(weight: 45 * lb, reps: 12),
|
||||
ExerciseSet(weight: 35 * lb, reps: 12),
|
||||
]),
|
||||
// Chest Flys — 35 / 25 / 25 lbs × 12 reps (per-side dumbbell)
|
||||
Exercise(name: "Chest Flys", sfSymbol: "dumbbell.fill", colorIndex: 2, sets: [
|
||||
ExerciseSet(weight: 35 * lb, reps: 12),
|
||||
ExerciseSet(weight: 25 * lb, reps: 12),
|
||||
ExerciseSet(weight: 25 * lb, reps: 12),
|
||||
], isDumbbell: true),
|
||||
]),
|
||||
WorkoutSection(name: "Triceps", exercises: [
|
||||
// Tricep Pushdown — 50 / 45 / 45 kg × 12 reps
|
||||
Exercise(name: "Tricep Pushdown", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: [
|
||||
ExerciseSet(weight: 50, reps: 12),
|
||||
ExerciseSet(weight: 45, reps: 12),
|
||||
ExerciseSet(weight: 45, reps: 12),
|
||||
]),
|
||||
// Overhead — 50 kg × 3 sets
|
||||
Exercise(name: "Overhead Tricep Ext", sfSymbol: "dumbbell.fill", colorIndex: 3, sets: [
|
||||
ExerciseSet(weight: 50, reps: 12),
|
||||
ExerciseSet(weight: 50, reps: 12),
|
||||
ExerciseSet(weight: 50, reps: 12),
|
||||
]),
|
||||
// Skull Crushers — 15 kg × 3 sets
|
||||
Exercise(name: "Skull Crushers", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: [
|
||||
ExerciseSet(weight: 15, reps: 12),
|
||||
ExerciseSet(weight: 15, reps: 12),
|
||||
ExerciseSet(weight: 15, reps: 12),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
|
||||
// ── Tuesday: Back & Biceps ───────────────────────────────────────────
|
||||
let tue = WorkoutProgram(name: "Back & Biceps", emoji: "💪", colorIndex: 1, sections: [
|
||||
warmUp,
|
||||
WorkoutSection(name: "Back", exercises: [
|
||||
Exercise(name: "Lat Pulldown", sfSymbol: "figure.pull.up", colorIndex: 1, sets: [
|
||||
ExerciseSet(weight: 70, reps: 12),
|
||||
ExerciseSet(weight: 60, reps: 12),
|
||||
ExerciseSet(weight: 60, reps: 12),
|
||||
]),
|
||||
Exercise(name: "Cable Row", sfSymbol: "figure.rowing", colorIndex: 0, sets: [
|
||||
ExerciseSet(weight: 60 * lb, reps: 12),
|
||||
ExerciseSet(weight: 55 * lb, reps: 12),
|
||||
ExerciseSet(weight: 55 * lb, reps: 12),
|
||||
]),
|
||||
Exercise(name: "Deadlift", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 2, sets: [
|
||||
ExerciseSet(weight: 70 * lb, reps: 10),
|
||||
ExerciseSet(weight: 70, reps: 10),
|
||||
ExerciseSet(weight: 70, reps: 10),
|
||||
], isDumbbell: true),
|
||||
]),
|
||||
WorkoutSection(name: "Biceps", exercises: [
|
||||
Exercise(name: "Barbell Curls", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: [
|
||||
ExerciseSet(weight: 10, reps: 12),
|
||||
ExerciseSet(weight: 10, reps: 12),
|
||||
ExerciseSet(weight: 10, reps: 12),
|
||||
], isDumbbell: true),
|
||||
Exercise(name: "Incline Hammer Curl", sfSymbol: "dumbbell.fill", colorIndex: 3, sets: [
|
||||
ExerciseSet(weight: 30, reps: 12),
|
||||
ExerciseSet(weight: 30, reps: 12),
|
||||
ExerciseSet(weight: 30, reps: 12),
|
||||
], isDumbbell: true),
|
||||
Exercise(name: "Preacher Curl", sfSymbol: "dumbbell.fill", colorIndex: 1, sets: [
|
||||
ExerciseSet(weight: 60, reps: 12),
|
||||
ExerciseSet(weight: 60, reps: 12),
|
||||
ExerciseSet(weight: 60, reps: 12),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
|
||||
// ── Wednesday: Shoulders, Triceps & Biceps ───────────────────────────
|
||||
let wed = WorkoutProgram(name: "Shoulders & Arms", emoji: "🔥", colorIndex: 2, sections: [
|
||||
warmUp,
|
||||
WorkoutSection(name: "Shoulders", exercises: [
|
||||
Exercise(name: "Reverse Fly", sfSymbol: "dumbbell.fill", colorIndex: 2, sets: s(3)),
|
||||
Exercise(name: "Front Raises", sfSymbol: "figure.arms.open", colorIndex: 0, sets: s(3)),
|
||||
Exercise(name: "Lateral Raises", sfSymbol: "figure.arms.open", colorIndex: 1, sets: s(3)),
|
||||
Exercise(name: "Dumbbell Shoulder Press",sfSymbol: "dumbbell.fill", colorIndex: 3, sets: s(3)),
|
||||
]),
|
||||
WorkoutSection(name: "Triceps", exercises: [
|
||||
Exercise(name: "Tricep Pushdown", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: s(3)),
|
||||
Exercise(name: "Overhead Tricep Ext",sfSymbol: "dumbbell.fill", colorIndex: 0, sets: s(3)),
|
||||
]),
|
||||
WorkoutSection(name: "Biceps", exercises: [
|
||||
Exercise(name: "Barbell Curls", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)),
|
||||
Exercise(name: "Hammer Curls", sfSymbol: "dumbbell.fill", colorIndex: 3, sets: s(3)),
|
||||
]),
|
||||
])
|
||||
|
||||
// ── Thursday: Legs & Chest ───────────────────────────────────────────
|
||||
let thu = WorkoutProgram(name: "Legs & Chest", emoji: "🦵", colorIndex: 3, sections: [
|
||||
warmUp,
|
||||
WorkoutSection(name: "Legs", exercises: [
|
||||
Exercise(name: "Hack Squat", sfSymbol: "figure.squat", colorIndex: 2, sets: s(3)),
|
||||
Exercise(name: "Leg Extension", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: s(3)),
|
||||
Exercise(name: "Adductor Machine",sfSymbol: "figure.strengthtraining.functional", colorIndex: 3, sets: s(3)),
|
||||
Exercise(name: "Lying Leg Curl", sfSymbol: "figure.strengthtraining.functional", colorIndex: 0, sets: s(3)),
|
||||
Exercise(name: "Seated Leg Curl", sfSymbol: "figure.strengthtraining.functional", colorIndex: 2, sets: s(3)),
|
||||
Exercise(name: "Leg Press", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: s(3)),
|
||||
]),
|
||||
WorkoutSection(name: "Chest", exercises: [
|
||||
Exercise(name: "Bench Press", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)),
|
||||
Exercise(name: "Chest Flys", sfSymbol: "dumbbell.fill", colorIndex: 2, sets: s(3)),
|
||||
]),
|
||||
])
|
||||
|
||||
// ── Friday: Full Body ────────────────────────────────────────────────
|
||||
let fri = WorkoutProgram(name: "Full Body", emoji: "⚡", colorIndex: 0, sections: [
|
||||
warmUp,
|
||||
WorkoutSection(name: "Upper Body", exercises: [
|
||||
Exercise(name: "Bench Press", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)),
|
||||
Exercise(name: "Lat Pulldown", sfSymbol: "figure.pull.up", colorIndex: 1, sets: s(3)),
|
||||
Exercise(name: "Dumbbell Shoulder Press", sfSymbol: "dumbbell.fill", colorIndex: 3, sets: s(3)),
|
||||
Exercise(name: "Tricep Pushdown", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: s(3)),
|
||||
Exercise(name: "Barbell Curls", sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)),
|
||||
]),
|
||||
WorkoutSection(name: "Lower Body", exercises: [
|
||||
Exercise(name: "Leg Press", sfSymbol: "figure.strengthtraining.functional", colorIndex: 2, sets: s(3)),
|
||||
Exercise(name: "Hack Squat", sfSymbol: "figure.squat", colorIndex: 1, sets: s(3)),
|
||||
Exercise(name: "Lying Leg Curl", sfSymbol: "figure.strengthtraining.functional", colorIndex: 3, sets: s(3)),
|
||||
]),
|
||||
])
|
||||
|
||||
// ── Saturday: Full Body ──────────────────────────────────────────────
|
||||
let sat = WorkoutProgram(name: "Full Body", emoji: "💥", colorIndex: 1, sections: [
|
||||
warmUp,
|
||||
WorkoutSection(name: "Upper Body", exercises: [
|
||||
Exercise(name: "Incline Bench Press",sfSymbol: "figure.strengthtraining.traditional", colorIndex: 0, sets: s(3)),
|
||||
Exercise(name: "Cable Row", sfSymbol: "figure.rowing", colorIndex: 1, sets: s(3)),
|
||||
Exercise(name: "Lateral Raises", sfSymbol: "figure.arms.open", colorIndex: 3, sets: s(3)),
|
||||
Exercise(name: "Overhead Tricep Ext",sfSymbol: "dumbbell.fill", colorIndex: 0, sets: s(3)),
|
||||
Exercise(name: "Hammer Curls", sfSymbol: "dumbbell.fill", colorIndex: 2, sets: s(3)),
|
||||
]),
|
||||
WorkoutSection(name: "Lower Body", exercises: [
|
||||
Exercise(name: "Hack Squat", sfSymbol: "figure.squat", colorIndex: 2, sets: s(3)),
|
||||
Exercise(name: "Leg Extension", sfSymbol: "figure.strengthtraining.functional", colorIndex: 1, sets: s(3)),
|
||||
Exercise(name: "Seated Leg Curl", sfSymbol: "figure.strengthtraining.functional", colorIndex: 3, sets: s(3)),
|
||||
]),
|
||||
])
|
||||
|
||||
return [mon, tue, wed, thu, fri, sat]
|
||||
}
|
||||
}
|
||||
151
KisaniCal/Models/TaskItem.swift
Normal file
151
KisaniCal/Models/TaskItem.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Task Model
|
||||
struct TaskItem: Identifiable {
|
||||
var id = UUID()
|
||||
var title: String
|
||||
var dueDate: Date?
|
||||
var isComplete: Bool = false
|
||||
var completedAt: Date? = nil
|
||||
var quadrant: Quadrant = .urgent
|
||||
var category: TaskCategory = .reminder
|
||||
var taskColor: TaskColor = .orange
|
||||
var isRecurring: Bool = false
|
||||
}
|
||||
|
||||
enum Quadrant: String, CaseIterable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
case urgent = "Urgent + Important"
|
||||
case schedule = "Schedule It"
|
||||
case delegate_ = "Delegate"
|
||||
case eliminate = "Eliminate"
|
||||
}
|
||||
|
||||
enum TaskCategory: String {
|
||||
case reminder = "Reminder"
|
||||
case birthday = "Birthday"
|
||||
case domain = "Domain"
|
||||
case annual = "Annual"
|
||||
case custom = "Custom"
|
||||
}
|
||||
|
||||
enum TaskColor {
|
||||
case orange, green, blue, yellow
|
||||
|
||||
func color() -> 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()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
123
KisaniCal/Theme/DesignTokens.swift
Normal file
123
KisaniCal/Theme/DesignTokens.swift
Normal file
@@ -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]
|
||||
}
|
||||
}
|
||||
261
KisaniCal/Views/AddExerciseSheet.swift
Normal file
261
KisaniCal/Views/AddExerciseSheet.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
1037
KisaniCal/Views/CalendarView.swift
Normal file
1037
KisaniCal/Views/CalendarView.swift
Normal file
File diff suppressed because it is too large
Load Diff
192
KisaniCal/Views/MatrixView.swift
Normal file
192
KisaniCal/Views/MatrixView.swift
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
223
KisaniCal/Views/OnboardingView.swift
Normal file
223
KisaniCal/Views/OnboardingView.swift
Normal file
@@ -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<OnboardingView.Field?>.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)
|
||||
}
|
||||
}
|
||||
765
KisaniCal/Views/SettingsView.swift
Normal file
765
KisaniCal/Views/SettingsView.swift
Normal file
@@ -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<Content: View>: 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<Int>; 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())
|
||||
}
|
||||
}
|
||||
1027
KisaniCal/Views/TodayView.swift
Normal file
1027
KisaniCal/Views/TodayView.swift
Normal file
File diff suppressed because it is too large
Load Diff
1252
KisaniCal/Views/WorkoutView.swift
Normal file
1252
KisaniCal/Views/WorkoutView.swift
Normal file
File diff suppressed because it is too large
Load Diff
35
PRODUCT.md
Normal file
35
PRODUCT.md
Normal file
@@ -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.
|
||||
32
project.yml
Normal file
32
project.yml
Normal file
@@ -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."
|
||||
Reference in New Issue
Block a user