From 8b094691f522292533de2f07c076805153399cdc Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Wed, 3 Jun 2026 12:27:45 +0300 Subject: [PATCH] feat: home/lock widgets, Health-aware streaks, workout editor, and entitlement fixes Widgets (new KisaniCalWidgets extension) - Event Countdown widget: configurable via AppIntent, pick from your events or set a custom name/date; dot-grid progress toward the event. - Day Progress and Tasks-Done-Today widgets; lock screen widgets. - Shared dot grid sizes circles to fill the widget evenly at any count. - Tasks/calendar prefs now persist to the App Group so widgets read live data. Health & streaks - Persist HealthKit authorization and re-establish on launch (fixes the Today dashboard and streak sync disappearing after relaunch). - Add a Health permission toggle to onboarding; unify Settings/Profile on the manager's state. - Day streak counts from a logged Health workout OR marking all exercises complete; nudge to mark exercises complete even when Health logged the workout. Workout editor - Drag to reorder exercises and move them across sections (drag-and-drop); swipe to delete. - Add-exercise sheet: global search and keep-adding-multiple with a running count. Fixes - Restore app entitlements link (Sign in with Apple, HealthKit, App Group, iCloud). - Add HealthKit usage strings; widget Info.plist NSExtension + nested bundle id. - Calendar "Connect" routes to Settings when access was denied. - Bake DEVELOPMENT_TEAM and shared versions into project.yml. Co-Authored-By: Claude Opus 4.8 --- KisaniCal.xcodeproj/project.pbxproj | 242 ++++++++++++--- KisaniCal/Components/FloatingTabState.swift | 5 +- KisaniCal/ContentView.swift | 13 +- KisaniCal/KisaniCal.entitlements | 4 + KisaniCal/Managers/AppGroup.swift | 10 + KisaniCal/Managers/AuthManager.swift | 6 +- KisaniCal/Managers/CloudSyncManager.swift | 8 +- KisaniCal/Managers/HealthKitManager.swift | 23 ++ KisaniCal/Managers/NotificationManager.swift | 48 ++- KisaniCal/Models/ExerciseModels.swift | 57 +++- KisaniCal/Models/TaskItem.swift | 16 +- KisaniCal/Views/AddExerciseSheet.swift | 50 ++- KisaniCal/Views/CalendarView.swift | 55 +++- KisaniCal/Views/OnboardingView.swift | 45 +++ KisaniCal/Views/SettingsView.swift | 26 +- KisaniCal/Views/WorkoutView.swift | 27 +- KisaniCalWidgets/EventCountdownWidget.swift | 153 ++++++++++ KisaniCalWidgets/Info.plist | 29 ++ .../KisaniCalWidgets.entitlements | 10 + KisaniCalWidgets/KisaniCalWidgets.swift | 114 +++++++ KisaniCalWidgets/WidgetData.swift | 120 ++++++++ KisaniCalWidgets/WidgetViews.swift | 289 ++++++++++++++++++ project.yml | 37 +++ 23 files changed, 1272 insertions(+), 115 deletions(-) create mode 100644 KisaniCal/Managers/AppGroup.swift create mode 100644 KisaniCalWidgets/EventCountdownWidget.swift create mode 100644 KisaniCalWidgets/Info.plist create mode 100644 KisaniCalWidgets/KisaniCalWidgets.entitlements create mode 100644 KisaniCalWidgets/KisaniCalWidgets.swift create mode 100644 KisaniCalWidgets/WidgetData.swift create mode 100644 KisaniCalWidgets/WidgetViews.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index f1326d9..d047b6c 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -7,63 +7,102 @@ objects = { /* Begin PBXBuildFile section */ + 06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */; }; 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; }; + 12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; }; 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; }; 1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; }; + 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; }; + 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; }; + 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; }; 3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; }; 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; }; + 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; }; + 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; }; + 5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; }; 67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; }; 6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; }; + 79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; }; 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; }; + 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; }; 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; }; 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; }; - A1B2C3D4E5F6A7B8C9D0E1F2 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.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 */; }; - B2C3D4E5F6A7B8C9D0E1F2A3 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */; }; - B38135962FCAF9CE0037DC41 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */; }; + AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; }; + BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */; }; BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.swift */; }; + C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; }; C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; }; CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */; }; D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; }; - CC11DD22EE33FF44AA55BB66 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.swift */; }; + ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; }; EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; }; - F1A2B3C4D5E6F7A8B9C0D1E2 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */; }; - F5A6B7C8D9E0F1A2B3C4D5E6 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */; }; - F9B0C1D2E3F4A5B6C7D8E9F0 /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AF6DE7A812408E3742522E90 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 28B43516AD88946E21D9BFE0; + remoteInfo = KisaniCalWidgets; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + B3BE3000B362E4DDC4EE9E76 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = ""; }; + 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = ""; }; + 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; }; + 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = ""; }; 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = ""; }; 23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = ""; }; 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = ""; }; + 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = ""; }; + 429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = ""; }; + 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = ""; }; + 4AD014B7E3E30A34E18696A0 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; 5722CC4B59E3939724142710 /* ExerciseModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExerciseModels.swift; sourceTree = ""; }; 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = ""; }; 72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = ""; }; + 89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; + 8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = ""; }; 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = ""; }; 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; - CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = ""; }; 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; - A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = ""; }; ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; - B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; }; - B38135942FCAF0BD0037DC41 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = ""; }; - B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = ""; }; + AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = ""; }; BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = ""; }; BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; }; D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = ""; }; DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = ""; }; - F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; - F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; - F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; }; + FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; }; + FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = ""; }; + FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -89,6 +128,7 @@ isa = PBXGroup; children = ( F70DA4746C68CD405435DAB6 /* KisaniCal */, + E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */, 51BD1B5DEDE9FAD9CA2FF6DA /* Products */, ); sourceTree = ""; @@ -97,6 +137,7 @@ isa = PBXGroup; children = ( 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */, + 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */, ); name = Products; sourceTree = ""; @@ -104,16 +145,16 @@ 5E9A0E064E153429180400E6 /* Views */ = { isa = PBXGroup; children = ( - F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */, - F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */, - B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */, 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */, + 4AD014B7E3E30A34E18696A0 /* AuthView.swift */, ADF6CCD95A587E26E30F5712 /* CalendarView.swift */, D44530A77DF12A17E52AAF34 /* MatrixView.swift */, 106EEF572C6F8990408329F0 /* OnboardingView.swift */, + 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */, 72308FEE0226F45414C04DDD /* SettingsView.swift */, BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */, C786EBC7DF879D64EB28165E /* TodayView.swift */, + FA3D5289C5F93484E22DEB63 /* TutorialView.swift */, 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */, ); path = Views; @@ -135,12 +176,25 @@ path = Theme; sourceTree = ""; }; + E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */ = { + isa = PBXGroup; + children = ( + FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */, + 8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */, + 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */, + 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */, + 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */, + 429806CE1021C8DE2EB770CE /* WidgetViews.swift */, + ); + path = KisaniCalWidgets; + sourceTree = ""; + }; F70DA4746C68CD405435DAB6 /* KisaniCal */ = { isa = PBXGroup; children = ( - B38135942FCAF0BD0037DC41 /* KisaniCal.entitlements */, 72FDF9C8DD37134576356B89 /* Assets.xcassets */, BC5E179A9189B0A8C3F856F6 /* ContentView.swift */, + 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */, 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */, 21B93C269F283F11B415B18C /* Components */, FB9BF734B9E493EEB09ACE21 /* Managers */, @@ -155,12 +209,13 @@ FB9BF734B9E493EEB09ACE21 /* Managers */ = { isa = PBXGroup; children = ( - F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */, - B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */, + AF905C574F34B4EE51A8D21E /* AppGroup.swift */, + 89550F2CD19B950CCC6AD37F /* AuthManager.swift */, + 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */, + FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */, 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */, - CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.swift */, 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */, - A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */, + 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */, ); path = Managers; sourceTree = ""; @@ -168,16 +223,35 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 63EDFF56CC5312509E567D37 /* Build configuration list for PBXNativeTarget "KisaniCalWidgets" */; + buildPhases = ( + B1361CC76F252F2A0F8D64CD /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = KisaniCalWidgets; + packageProductDependencies = ( + ); + productName = KisaniCalWidgets; + productReference = 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; 297C0C2BFBA10AB52D5D49CE /* KisaniCal */ = { isa = PBXNativeTarget; buildConfigurationList = B60FDD2C88D1D33CF1EC02B3 /* Build configuration list for PBXNativeTarget "KisaniCal" */; buildPhases = ( 82EA49FE155821D424C46912 /* Sources */, E1F1A3EE4469B851B972E4AE /* Resources */, + B3BE3000B362E4DDC4EE9E76 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 8B1A9D9F3CF53BD8088AEC59 /* PBXTargetDependency */, ); name = KisaniCal; packageProductDependencies = ( @@ -195,7 +269,12 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 2620; TargetAttributes = { + 28B43516AD88946E21D9BFE0 = { + DevelopmentTeam = K8BLMMR883; + ProvisioningStyle = Automatic; + }; 297C0C2BFBA10AB52D5D49CE = { + DevelopmentTeam = K8BLMMR883; ProvisioningStyle = Automatic; }; }; @@ -215,6 +294,7 @@ projectRoot = ""; targets = ( 297C0C2BFBA10AB52D5D49CE /* KisaniCal */, + 28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */, ); }; /* End PBXProject section */ @@ -236,35 +316,56 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F1A2B3C4D5E6F7A8B9C0D1E2 /* AuthManager.swift in Sources */, - F5A6B7C8D9E0F1A2B3C4D5E6 /* AuthView.swift in Sources */, - F9B0C1D2E3F4A5B6C7D8E9F0 /* ProfileSetupView.swift in Sources */, - A1B2C3D4E5F6A7B8C9D0E1F2 /* TutorialManager.swift in Sources */, - B2C3D4E5F6A7B8C9D0E1F2A3 /* TutorialView.swift in Sources */, A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */, + 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */, + 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */, + ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */, 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */, + 06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */, 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */, A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */, 1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */, CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */, + BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */, D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */, BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */, - B38135962FCAF9CE0037DC41 /* HealthKitManager.swift in Sources */, - CC11DD22EE33FF44AA55BB66 /* CloudSyncManager.swift in Sources */, - EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */, + EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */, 67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */, + 5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */, A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */, 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */, 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */, 6921CB73A3257502FF778381 /* SplashView.swift in Sources */, 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */, C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */, + 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */, + 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */, 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + B1361CC76F252F2A0F8D64CD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */, + 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */, + C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */, + AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */, + 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 8B1A9D9F3CF53BD8088AEC59 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */; + targetProxy = D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 0CEA6637F45C32A237FBA20D /* Release */ = { isa = XCBuildConfiguration; @@ -274,27 +375,23 @@ CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; - INFOPLIST_KEY_NSHealthShareUsageDescription = "KisaniCal reads your steps, active calories, resting heart rate, and workout history to display your fitness stats."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "KisaniCal saves your completed strength training workouts to Apple Health."; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Kisani Cal saves workouts you complete to the Health app."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = ""; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; - NEW_SETTING = ""; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; @@ -336,6 +433,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = K8BLMMR883; ENABLE_NS_ASSERTIONS = NO; @@ -350,6 +448,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -361,6 +460,27 @@ }; name = Release; }; + B60AA89AB378D4EA773989AA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KisaniCalWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; BD527AA54887489CC665FB9D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -395,6 +515,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = K8BLMMR883; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -415,6 +536,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -427,6 +549,27 @@ }; name = Debug; }; + DF56CDD73E9B177D57AE17FB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = KisaniCalWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; F364512BEB70ECB7CB83FBFE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -435,27 +578,23 @@ CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; - INFOPLIST_KEY_NSHealthShareUsageDescription = "KisaniCal reads your steps, active calories, resting heart rate, and workout history to display your fitness stats."; - INFOPLIST_KEY_NSHealthUpdateUsageDescription = "KisaniCal saves your completed strength training workouts to Apple Health."; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Kisani Cal saves workouts you complete to the Health app."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = ""; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; - NEW_SETTING = ""; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; @@ -466,6 +605,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 63EDFF56CC5312509E567D37 /* Build configuration list for PBXNativeTarget "KisaniCalWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DF56CDD73E9B177D57AE17FB /* Debug */, + B60AA89AB378D4EA773989AA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; B60FDD2C88D1D33CF1EC02B3 /* Build configuration list for PBXNativeTarget "KisaniCal" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/KisaniCal/Components/FloatingTabState.swift b/KisaniCal/Components/FloatingTabState.swift index f1591d6..ab9bcd1 100644 --- a/KisaniCal/Components/FloatingTabState.swift +++ b/KisaniCal/Components/FloatingTabState.swift @@ -40,7 +40,10 @@ private struct TabBarScrollModifier: ViewModifier { } ) .onPreferenceChange(ScrollYKey.self) { y in - tabState.report(scrollY: y) + // Defer off the current view-update cycle: this callback can fire during + // layout, and mutating tabState's @Published state there triggers + // "Modifying state during view update". + Task { @MainActor in tabState.report(scrollY: y) } } } } diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index 1e3b7d9..f4e239f 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -1,4 +1,5 @@ import SwiftUI +import WidgetKit struct ContentView: View { @StateObject private var taskVM = TaskViewModel() @@ -86,7 +87,13 @@ struct ContentView: View { if !hasOnboarded { showOnboarding = true } CloudSyncManager.shared.restoreSettings() CloudSyncManager.shared.backupSettings() + HealthKitManager.shared.bootstrap() NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + // Pull workouts from Health, then reschedule so a detected workout updates reminders. + Task { + await workoutVM.syncFromHealthKit() + NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + } } .onChange(of: scenePhase) { phase in if phase == .active { @@ -94,11 +101,15 @@ struct ContentView: View { workoutVM.checkPendingHealthConfirm() CloudSyncManager.shared.backupSettings() NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) - Task { await workoutVM.syncFromHealthKit() } + Task { + await workoutVM.syncFromHealthKit() + NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + } } } .onChange(of: taskVM.tasks) { _ in NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + WidgetCenter.shared.reloadAllTimelines() } .onChange(of: workoutVM.doneSets) { _ in NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) diff --git a/KisaniCal/KisaniCal.entitlements b/KisaniCal/KisaniCal.entitlements index e84875b..6a379ee 100644 --- a/KisaniCal/KisaniCal.entitlements +++ b/KisaniCal/KisaniCal.entitlements @@ -10,5 +10,9 @@ com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) + com.apple.security.application-groups + + group.com.kutesir.KisaniCal + diff --git a/KisaniCal/Managers/AppGroup.swift b/KisaniCal/Managers/AppGroup.swift new file mode 100644 index 0000000..b3bc579 --- /dev/null +++ b/KisaniCal/Managers/AppGroup.swift @@ -0,0 +1,10 @@ +import Foundation + +let kisaniAppGroupID = "group.com.kutesir.KisaniCal" + +extension UserDefaults { + /// Shared container readable by both the main app and the widget extension. + static var kisani: UserDefaults { + UserDefaults(suiteName: kisaniAppGroupID) ?? .standard + } +} diff --git a/KisaniCal/Managers/AuthManager.swift b/KisaniCal/Managers/AuthManager.swift index 59de4ff..5107001 100644 --- a/KisaniCal/Managers/AuthManager.swift +++ b/KisaniCal/Managers/AuthManager.swift @@ -28,7 +28,7 @@ final class AuthManager: NSObject, ObservableObject { super.init() currentUser = loadUser() if let uid = currentUser?.id { - UserDefaults.standard.set(uid, forKey: Self.activeUserIdKey) + UserDefaults.kisani.set(uid, forKey: Self.activeUserIdKey) } } @@ -75,7 +75,7 @@ final class AuthManager: NSObject, ObservableObject { func signOut() { currentUser = nil - UserDefaults.standard.removeObject(forKey: Self.activeUserIdKey) + UserDefaults.kisani.removeObject(forKey: Self.activeUserIdKey) deleteKeychain(key: keychainUser) } @@ -83,7 +83,7 @@ final class AuthManager: NSObject, ObservableObject { private func persist(_ user: AppUser) { currentUser = user - UserDefaults.standard.set(user.id, forKey: Self.activeUserIdKey) + UserDefaults.kisani.set(user.id, forKey: Self.activeUserIdKey) if let data = try? JSONEncoder().encode(user) { saveKeychain(key: keychainUser, data: data) } diff --git a/KisaniCal/Managers/CloudSyncManager.swift b/KisaniCal/Managers/CloudSyncManager.swift index dc64a82..60b1e78 100644 --- a/KisaniCal/Managers/CloudSyncManager.swift +++ b/KisaniCal/Managers/CloudSyncManager.swift @@ -49,10 +49,10 @@ final class CloudSyncManager { /// Copy iCloud value into UserDefaults ONLY when UserDefaults has no value yet. /// This safely restores data after a fresh install or new build without overwriting live data. func restoreIfMissing(key: String) { - guard UserDefaults.standard.object(forKey: key) == nil, + guard UserDefaults.kisani.object(forKey: key) == nil, let icloudValue = kv.object(forKey: key) else { return } - UserDefaults.standard.set(icloudValue, forKey: key) + UserDefaults.kisani.set(icloudValue, forKey: key) } // MARK: - Bulk helpers @@ -60,7 +60,7 @@ final class CloudSyncManager { /// Backup all small settings keys from UserDefaults → iCloud func backupSettings() { for key in settingsKeys { - if let val = UserDefaults.standard.object(forKey: key) { + if let val = UserDefaults.kisani.object(forKey: key) { kv.set(val, forKey: key) } } @@ -85,7 +85,7 @@ final class CloudSyncManager { for key in changedKeys { if let val = kv.object(forKey: key) { - UserDefaults.standard.set(val, forKey: key) + UserDefaults.kisani.set(val, forKey: key) } } diff --git a/KisaniCal/Managers/HealthKitManager.swift b/KisaniCal/Managers/HealthKitManager.swift index 7471b2b..ef35b49 100644 --- a/KisaniCal/Managers/HealthKitManager.swift +++ b/KisaniCal/Managers/HealthKitManager.swift @@ -14,6 +14,11 @@ final class HealthKitManager: ObservableObject { var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() } + // Persists across launches: HealthKit can't report read-authorization status, + // so we remember that the user opted in and re-establish on launch. + private let connectedKey = "kisani.health.connected" + var isConnected: Bool { UserDefaults.kisani.bool(forKey: connectedKey) } + private init() {} // MARK: - Auth @@ -23,6 +28,7 @@ final class HealthKitManager: ObservableObject { do { try await store.requestAuthorization(toShare: shareTypes, read: readTypes) authorized = true + UserDefaults.kisani.set(true, forKey: connectedKey) await refresh() return true } catch { @@ -30,6 +36,23 @@ final class HealthKitManager: ObservableObject { } } + /// Re-establish authorization on app launch if the user previously connected Health. + func bootstrap() { + guard isAvailable, isConnected else { return } + authorized = true + Task { await refresh() } + } + + /// Turn the integration off: hides the dashboard and stops Health reads. + func disconnect() { + authorized = false + UserDefaults.kisani.set(false, forKey: connectedKey) + stepsToday = 0 + activeCaloriesToday = 0 + restingHeartRate = 0 + workoutsThisWeek = 0 + } + // MARK: - Refresh all stats func refresh() async { diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index 238cabf..ae725c4 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -40,7 +40,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable // MARK: - Complete task directly in UserDefaults (called from background) private func markTaskComplete(taskId: String, userId: String) { let key = "kisani.tasks.v2.\(userId)" - guard let data = UserDefaults.standard.data(forKey: key), + guard let data = UserDefaults.kisani.data(forKey: key), var tasks = try? JSONDecoder().decode([TaskItem].self, from: data), let uuid = UUID(uuidString: taskId), let idx = tasks.firstIndex(where: { $0.id == uuid }) @@ -48,7 +48,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable tasks[idx].isComplete = true tasks[idx].completedAt = Date() if let encoded = try? JSONEncoder().encode(tasks) { - UserDefaults.standard.set(encoded, forKey: key) + UserDefaults.kisani.set(encoded, forKey: key) } } @@ -77,6 +77,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable let schedule = workoutVM.schedule let programs = workoutVM.programs let progress = workoutVM.progress + let recorded = workoutVM.isTodayRecorded let tasks = taskVM.tasks center.getNotificationSettings { [weak self] settings in @@ -84,7 +85,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable let ok = settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional guard ok else { return } - self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress) + self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress, recordedToday: recorded) self.scheduleWorkoutConfirm(schedule: schedule, programs: programs) self.scheduleTasks(snapshot: tasks) self.updateBadge(snapshot: tasks) @@ -105,9 +106,10 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable // MARK: - Workout reminders + check-in - private func scheduleWorkout(schedule: [Int: UUID], programs: [WorkoutProgram], progress: Double) { - let ud = UserDefaults.standard - let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] } + private func scheduleWorkout(schedule: [Int: UUID], programs: [WorkoutProgram], progress: Double, recordedToday: Bool) { + let ud = UserDefaults.kisani + let markCompleteId = "kisani.workout.markcomplete" + let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] } + [markCompleteId] center.removePendingNotificationRequests(withIdentifiers: existingIds) let reminderOn = ud.object(forKey: "workoutReminderEnabled") as? Bool ?? true @@ -118,9 +120,13 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable let cMinute = ud.object(forKey: "checkInMinute") as? Int ?? 30 let todayWD = Calendar.current.component(.weekday, from: Date()) + // In-app completion vs. "recorded" (in-app OR detected from Apple Health). + let inAppDoneToday = progress >= 1.0 + for (weekday, pid) in schedule { guard let program = programs.first(where: { $0.id == pid }) else { continue } - let doneToday = weekday == todayWD && progress >= 1.0 + // Suppress "time to work out" if today is already done by either path. + let doneToday = weekday == todayWD && (inAppDoneToday || recordedToday) if reminderOn && !doneToday { let content = UNMutableNotificationContent() @@ -152,12 +158,34 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable )) } } + + // Mark-complete nudge: Apple Health recorded a workout today (streak is safe), + // but the user hasn't marked their exercises complete in the app yet. + if recordedToday && !inAppDoneToday { + let cal = Calendar.current + var fireComps = cal.dateComponents([.year, .month, .day], from: Date()) + fireComps.hour = rHour; fireComps.minute = rMinute + var fire = cal.date(from: fireComps) ?? Date() + if fire <= Date() { fire = Date().addingTimeInterval(3600) } // reminder time passed → nudge in 1h + + let content = UNMutableNotificationContent() + content.title = "Mark your workout complete" + content.body = "We logged today's workout from Apple Health — mark your exercises complete to update your log." + content.sound = .default + content.userInfo = ["deeplink": "workout"] + let trigComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: fire) + center.add(UNNotificationRequest( + identifier: markCompleteId, + content: content, + trigger: UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: false) + )) + } } // MARK: - Workout completion confirmation private func scheduleWorkoutConfirm(schedule: [Int: UUID], programs: [WorkoutProgram]) { - let ud = UserDefaults.standard + let ud = UserDefaults.kisani guard ud.object(forKey: "workoutConfirmEnabled") as? Bool ?? false else { center.removePendingNotificationRequests(withIdentifiers: (1...7).map { "\(confirmPrefix).\($0)" }) return @@ -277,7 +305,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { - let userId = UserDefaults.standard.string(forKey: AuthManager.activeUserIdKey) ?? "shared" + let userId = UserDefaults.kisani.string(forKey: AuthManager.activeUserIdKey) ?? "shared" let info = response.notification.request.content.userInfo let deeplink = info["deeplink"] as? String let taskId = info["taskId"] as? String @@ -290,7 +318,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate { case Self.logWorkoutActId: // "Yes, log it" from workout confirm notification — store date for WorkoutViewModel to pick up on next foreground let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd" - UserDefaults.standard.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingConfirm") + UserDefaults.kisani.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingConfirm") default: if let taskId { markTaskComplete(taskId: taskId, userId: userId) } diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index 1960d91..8e52af7 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -223,7 +223,7 @@ class WorkoutViewModel: ObservableObject { CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(uid)") // ── Attempt to restore persisted state ── - if let data = UserDefaults.standard.data(forKey: kPrograms), + if let data = UserDefaults.kisani.data(forKey: kPrograms), let saved = try? JSONDecoder().decode([WorkoutProgram].self, from: data), !saved.isEmpty { @@ -254,7 +254,7 @@ class WorkoutViewModel: ObservableObject { : saved var sched: [Int: UUID] = [:] - if let dict = UserDefaults.standard.dictionary(forKey: kSchedule) as? [String: String] { + if let dict = UserDefaults.kisani.dictionary(forKey: kSchedule) as? [String: String] { for (k, v) in dict { if let day = Int(k), let uid = UUID(uuidString: v) { sched[day] = uid } } @@ -262,7 +262,7 @@ class WorkoutViewModel: ObservableObject { schedule = sched var pid = saved[0].id - if let str = UserDefaults.standard.string(forKey: kActivePid), + if let str = UserDefaults.kisani.string(forKey: kActivePid), let uid = UUID(uuidString: str), saved.contains(where: { $0.id == uid }) { pid = uid } activeProgramId = pid @@ -283,18 +283,18 @@ class WorkoutViewModel: ObservableObject { activeProgramId = blank.id activeSections = blank.sections } - workoutDates = UserDefaults.standard.stringArray(forKey: kWorkoutDates) ?? [] + workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? [] } private func save() { if let data = try? JSONEncoder().encode(programs) { - UserDefaults.standard.set(data, forKey: kPrograms) + UserDefaults.kisani.set(data, forKey: kPrograms) CloudSyncManager.shared.push(data: data, forKey: kPrograms) } let schedDict = schedule.reduce(into: [String: String]()) { $0[String($1.key)] = $1.value.uuidString } - UserDefaults.standard.set(schedDict, forKey: kSchedule) - UserDefaults.standard.set(activeProgramId.uuidString, forKey: kActivePid) - UserDefaults.standard.set(workoutDates, forKey: kWorkoutDates) + UserDefaults.kisani.set(schedDict, forKey: kSchedule) + UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid) + UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates) CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule) CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid) CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates) @@ -326,8 +326,8 @@ class WorkoutViewModel: ObservableObject { @MainActor func checkPendingHealthConfirm() { let key = "kisani.workout.pendingConfirm" - guard let dateStr = UserDefaults.standard.string(forKey: key) else { return } - UserDefaults.standard.removeObject(forKey: key) + guard let dateStr = UserDefaults.kisani.string(forKey: key) else { return } + UserDefaults.kisani.removeObject(forKey: key) guard !workoutDates.contains(dateStr) else { return } workoutDates.append(dateStr) save() @@ -363,6 +363,10 @@ class WorkoutViewModel: ObservableObject { var doneSets: Int { activeSections.reduce(0) { $0 + $1.doneSets } } var progress: Double { totalSets > 0 ? Double(doneSets) / Double(totalSets) : 0 } + /// True if today already counts toward the streak — either marked complete in-app + /// or detected from Apple Health (both land in `workoutDates`). + var isTodayRecorded: Bool { workoutDates.contains(iso.string(from: Date())) } + // MARK: - Program management func switchProgram(to id: UUID) { syncBack() @@ -480,6 +484,34 @@ class WorkoutViewModel: ObservableObject { syncBack() } + /// Relocate an exercise (within or across sections) for any program via drag-and-drop. + /// Drops the exercise just before `beforeId`; pass nil to append to the target section. + func moveExercise(programId: UUID, exerciseId: UUID, toSection targetSectionId: UUID, before beforeId: UUID?) { + guard exerciseId != beforeId else { return } + guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return } + + // Remove from its current section. + var moved: Exercise? + for si in programs[pi].sections.indices { + if let ei = programs[pi].sections[si].exercises.firstIndex(where: { $0.id == exerciseId }) { + moved = programs[pi].sections[si].exercises.remove(at: ei) + break + } + } + guard let ex = moved, + let ti = programs[pi].sections.firstIndex(where: { $0.id == targetSectionId }) else { return } + + // Insert before the target row, or append if none specified. + if let beforeId, let idx = programs[pi].sections[ti].exercises.firstIndex(where: { $0.id == beforeId }) { + programs[pi].sections[ti].exercises.insert(ex, at: idx) + } else { + programs[pi].sections[ti].exercises.append(ex) + } + + if programId == activeProgramId { activeSections = programs[pi].sections } + save() + } + // 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 }), @@ -494,6 +526,7 @@ class WorkoutViewModel: ObservableObject { Exercise(name: name, sfSymbol: symbol, colorIndex: colorIdx, sets: sets, isDumbbell: isDumbb) ) if programId == activeProgramId { activeSections = programs[pi].sections } + save() } func deleteExercise(programId: UUID, sectionId: UUID, exerciseId: UUID) { @@ -502,18 +535,21 @@ class WorkoutViewModel: ObservableObject { else { return } programs[pi].sections[si].exercises.removeAll { $0.id == exerciseId } if programId == activeProgramId { activeSections = programs[pi].sections } + save() } 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 } + save() } 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 } + save() } func renameSection(in programId: UUID, sectionId: UUID, name: String) { @@ -522,6 +558,7 @@ class WorkoutViewModel: ObservableObject { else { return } programs[pi].sections[si].name = name if programId == activeProgramId { activeSections = programs[pi].sections } + save() } func toggleExpanded(sectionId: UUID, exerciseId: UUID) { diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index 08a6f9a..ea1f681 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -1,4 +1,5 @@ import SwiftUI +import WidgetKit // MARK: - Task Model struct TaskItem: Identifiable, Codable, Equatable { @@ -129,8 +130,16 @@ class TaskViewModel: ObservableObject { init() { let uid = AuthManager.shared.currentUser?.id ?? "shared" self.persistKey = "kisani.tasks.v2.\(uid)" + // One-time migration: tasks used to live in UserDefaults.standard, but the + // widgets read from the shared App Group. Move legacy data over if present. + if UserDefaults.kisani.data(forKey: persistKey) == nil, + let legacy = UserDefaults.standard.data(forKey: persistKey) { + UserDefaults.kisani.set(legacy, forKey: persistKey) + UserDefaults.standard.removeObject(forKey: persistKey) + } + // Restore from iCloud only if the App Group still has nothing (fresh install / new device). CloudSyncManager.shared.restoreIfMissing(key: persistKey) - if let data = UserDefaults.standard.data(forKey: persistKey), + if let data = UserDefaults.kisani.data(forKey: persistKey), let saved = try? JSONDecoder().decode([TaskItem].self, from: data), !saved.isEmpty { tasks = saved } else { @@ -139,7 +148,7 @@ class TaskViewModel: ObservableObject { } func reload() { - guard let data = UserDefaults.standard.data(forKey: persistKey), + guard let data = UserDefaults.kisani.data(forKey: persistKey), let saved = try? JSONDecoder().decode([TaskItem].self, from: data) else { return } tasks = saved @@ -147,8 +156,9 @@ class TaskViewModel: ObservableObject { private func save() { if let data = try? JSONEncoder().encode(tasks) { - UserDefaults.standard.set(data, forKey: persistKey) + UserDefaults.kisani.set(data, forKey: persistKey) CloudSyncManager.shared.push(data: data, forKey: persistKey) + WidgetCenter.shared.reloadAllTimelines() } } diff --git a/KisaniCal/Views/AddExerciseSheet.swift b/KisaniCal/Views/AddExerciseSheet.swift index 4abaf22..967f81e 100644 --- a/KisaniCal/Views/AddExerciseSheet.swift +++ b/KisaniCal/Views/AddExerciseSheet.swift @@ -15,6 +15,8 @@ struct ExercisePickerSheet: View { @State private var setsText = "3" @State private var repsText = "10" @State private var showCustom = false + @State private var addedCount = 0 + @State private var flashedId: UUID? = nil @FocusState private var customFocused: Bool private func addExercise(name: String) { @@ -25,27 +27,48 @@ struct ExercisePickerSheet: View { } else { vm.addExercise(to: sectionId, name: name, setsCount: sets, repsCount: reps) } + addedCount += 1 + } + + // Briefly mark a template row as "added" so the user gets feedback while keeping the sheet open. + private func flash(_ id: UUID) { + withAnimation(KisaniSpring.micro) { flashedId = id } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { + if flashedId == id { withAnimation(KisaniSpring.micro) { flashedId = nil } } + } } private var filtered: [ExerciseTemplate] { - let byMuscle = exerciseLibrary.filter { $0.muscle == selectedMuscle } - guard !searchText.isEmpty else { return byMuscle } - return byMuscle.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + // When searching, look across the whole library (ignore the muscle tab). + guard searchText.isEmpty else { + return exerciseLibrary.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + return exerciseLibrary.filter { $0.muscle == selectedMuscle } } var body: some View { VStack(spacing: 0) { // ── Header ── - HStack { + HStack(spacing: 8) { Text("Add Exercise") .font(AppFonts.sans(17, weight: .bold)) .foregroundColor(AppColors.text(cs)) + if addedCount > 0 { + Text("\(addedCount) added") + .font(AppFonts.mono(9.5, weight: .bold)) + .foregroundColor(AppColors.green) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(AppColors.greenSoft) + .clipShape(Capsule()) + .transition(.scale.combined(with: .opacity)) + } Spacer() - Button("Cancel") { dismiss() } - .font(AppFonts.sans(13)) - .foregroundColor(AppColors.text3(cs)) + Button(addedCount > 0 ? "Done" : "Cancel") { dismiss() } + .font(AppFonts.sans(13, weight: addedCount > 0 ? .bold : .regular)) + .foregroundColor(addedCount > 0 ? AppColors.accent : AppColors.text3(cs)) .buttonStyle(.plain) } + .animation(KisaniSpring.snappy, value: addedCount) .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 12) Divider().background(AppColors.border(cs)) @@ -114,7 +137,8 @@ struct ExercisePickerSheet: View { let n = customName.trimmingCharacters(in: .whitespaces) guard !n.isEmpty else { return } addExercise(name: n) - dismiss() + customName = "" + customFocused = true } label: { Text("Add Custom Exercise") .font(AppFonts.sans(13, weight: .bold)).foregroundColor(.white) @@ -134,9 +158,9 @@ struct ExercisePickerSheet: View { // Library exercises ForEach(filtered) { template in - ExerciseTemplateRow(template: template) { + ExerciseTemplateRow(template: template, added: flashedId == template.id) { addExercise(name: template.name) - dismiss() + flash(template.id) } Divider().background(AppColors.border(cs)).padding(.leading, 60) } @@ -188,6 +212,7 @@ struct ExercisePickerSheet: View { private struct ExerciseTemplateRow: View { @Environment(\.colorScheme) private var cs let template: ExerciseTemplate + var added: Bool = false let onAdd: () -> Void var body: some View { @@ -206,9 +231,10 @@ private struct ExerciseTemplateRow: View { Spacer() - Image(systemName: "plus.circle.fill") + Image(systemName: added ? "checkmark.circle.fill" : "plus.circle.fill") .font(.system(size: 18)) - .foregroundColor(AppColors.accent.opacity(0.7)) + .foregroundColor(added ? AppColors.green : AppColors.accent.opacity(0.7)) + .scaleEffect(added ? 1.15 : 1) } .padding(.vertical, 10) .contentShape(Rectangle()) diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index e9829fe..6cfade6 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -22,9 +22,13 @@ final class CalendarStore: ObservableObject { init() { authStatus = EKEventStore.authorizationStatus(for: .event) - hiddenCalendarIDs = Set(UserDefaults.standard.stringArray(forKey: "kisani.cal.hiddenIDs") ?? []) - localCalendarsEnabled = UserDefaults.standard.object(forKey: "kisani.cal.localEnabled") as? Bool ?? true - doNotDisturb = UserDefaults.standard.bool(forKey: "kisani.cal.dnd") + // Migrate legacy prefs from UserDefaults.standard into the App Group (one-time). + Self.migrateCalPrefIfNeeded(hiddenIDsKey) + Self.migrateCalPrefIfNeeded(calEnabledKey) + Self.migrateCalPrefIfNeeded(dndKey) + hiddenCalendarIDs = Set(UserDefaults.kisani.stringArray(forKey: hiddenIDsKey) ?? []) + localCalendarsEnabled = UserDefaults.kisani.object(forKey: calEnabledKey) as? Bool ?? true + doNotDisturb = UserDefaults.kisani.bool(forKey: dndKey) changeToken = NotificationCenter.default.addObserver( forName: .EKEventStoreChanged, object: ekStore, @@ -45,7 +49,10 @@ final class CalendarStore: ObservableObject { let current = EKEventStore.authorizationStatus(for: .event) guard current != authStatus else { return } authStatus = current - if isAuthorized { fetchMonth(containing: fetchedMonth ?? Date()) } + if isAuthorized { + fetchMonth(containing: fetchedMonth ?? Date()) + loadLocalCalendars() + } } deinit { @@ -108,6 +115,10 @@ final class CalendarStore: ObservableObject { return authStatus == .authorized } + /// Only `.notDetermined` can show the system prompt; otherwise we must deep-link to Settings. + var canRequest: Bool { authStatus == .notDetermined } + var isDenied: Bool { authStatus == .denied || authStatus == .restricted } + // MARK: - Local Calendar Management func loadLocalCalendars() { @@ -137,12 +148,23 @@ final class CalendarStore: ObservableObject { func setDoNotDisturb(_ on: Bool) { doNotDisturb = on - UserDefaults.standard.set(on, forKey: dndKey) + UserDefaults.kisani.set(on, forKey: dndKey) + CloudSyncManager.shared.push(value: on, forKey: dndKey) } private func saveCalPrefs() { - UserDefaults.standard.set(Array(hiddenCalendarIDs), forKey: hiddenIDsKey) - UserDefaults.standard.set(localCalendarsEnabled, forKey: calEnabledKey) + UserDefaults.kisani.set(Array(hiddenCalendarIDs), forKey: hiddenIDsKey) + UserDefaults.kisani.set(localCalendarsEnabled, forKey: calEnabledKey) + CloudSyncManager.shared.push(value: Array(hiddenCalendarIDs), forKey: hiddenIDsKey) + CloudSyncManager.shared.push(value: localCalendarsEnabled, forKey: calEnabledKey) + } + + private static func migrateCalPrefIfNeeded(_ key: String) { + if UserDefaults.kisani.object(forKey: key) == nil, + let legacy = UserDefaults.standard.object(forKey: key) { + UserDefaults.kisani.set(legacy, forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } } func groupedCalendars() -> [CalendarGroup] { @@ -890,6 +912,8 @@ struct CalendarConnectSheet: View { @ObservedObject var calStore: CalendarStore @Environment(\.colorScheme) var cs @Environment(\.dismiss) var dismiss + @Environment(\.openURL) private var openURL + @Environment(\.scenePhase) private var scenePhase var body: some View { NavigationStack { @@ -906,10 +930,19 @@ struct CalendarConnectSheet: View { .clipShape(RoundedRectangle(cornerRadius: 9)) VStack(alignment: .leading, spacing: 2) { Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs)) - Text("Tap to connect and show events").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + Text(calStore.isDenied + ? "Access denied — enable in Settings" + : "Tap to connect and show events") + .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) } Spacer() - Button("Connect") { calStore.requestAccess() } + Button(calStore.isDenied ? "Settings" : "Connect") { + if calStore.canRequest { + calStore.requestAccess() + } else if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } .font(AppFonts.mono(9.5, weight: .bold)) .foregroundColor(AppColors.accent) .padding(.horizontal, 10).padding(.vertical, 5) @@ -992,6 +1025,10 @@ struct CalendarConnectSheet: View { .foregroundColor(AppColors.accent) } } + .onAppear { calStore.refreshStatus() } + .onChange(of: scenePhase) { phase in + if phase == .active { calStore.refreshStatus() } + } } } } diff --git a/KisaniCal/Views/OnboardingView.swift b/KisaniCal/Views/OnboardingView.swift index 1a01690..418fba8 100644 --- a/KisaniCal/Views/OnboardingView.swift +++ b/KisaniCal/Views/OnboardingView.swift @@ -27,6 +27,7 @@ struct OnboardingView: View { @State private var heightStr = "" @State private var selectedDays: Set = [] @State private var calAuthStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event) + @ObservedObject private var hk = HealthKitManager.shared @FocusState private var focusedField: Field? private let ekStore = EKEventStore() @@ -284,6 +285,50 @@ struct OnboardingView: View { .padding(.horizontal, 20) .padding(.bottom, 28) + // ══════════════════════════════ + // MARK: Health + // ══════════════════════════════ + if hk.isAvailable { + OnboardingSectionHeader(title: "Health") + + HStack(spacing: 12) { + Image(systemName: hk.authorized ? "heart.fill" : "heart") + .font(.system(size: 15)) + .foregroundColor(hk.authorized ? AppColors.green : AppColors.accent) + .frame(width: 32, height: 32) + .background(hk.authorized ? AppColors.greenSoft : AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .animation(KisaniSpring.micro, value: hk.authorized) + + VStack(alignment: .leading, spacing: 2) { + Text("Apple Health") + .font(AppFonts.sans(13, weight: .semibold)) + .foregroundColor(AppColors.text(cs)) + Text(hk.authorized + ? "Steps, calories & workouts on your dashboard" + : "Show your activity stats above today's tasks") + .font(AppFonts.sans(11)) + .foregroundColor(AppColors.text3(cs)) + } + + Spacer() + + Toggle("", isOn: Binding( + get: { hk.authorized }, + set: { on in + if on { Task { _ = await hk.requestAuthorization() } } + else { hk.disconnect() } + } + )) + .labelsHidden() + .tint(AppColors.accent) + } + .padding(.horizontal, 14).padding(.vertical, 14) + .cardStyle() + .padding(.horizontal, 20) + .padding(.bottom, 28) + } + // ══════════════════════════════ // MARK: Workout // ══════════════════════════════ diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift index c95f538..5c03dbe 100644 --- a/KisaniCal/Views/SettingsView.swift +++ b/KisaniCal/Views/SettingsView.swift @@ -9,7 +9,6 @@ struct SettingsView: View { // 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 @@ -116,7 +115,7 @@ struct SettingsView: View { 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 } - HealthToggleRow(isOn: $healthOn, isLast: true) + HealthToggleRow(isLast: true) } // ── ACCOUNT ── @@ -176,7 +175,6 @@ struct SettingsView: View { // MARK: - Health Toggle Row (requests auth when turned on) private struct HealthToggleRow: View { @Environment(\.colorScheme) private var cs - @Binding var isOn: Bool var isLast: Bool = false @ObservedObject private var hk = HealthKitManager.shared @@ -193,25 +191,26 @@ private struct HealthToggleRow: View { Text("Health Integration") .font(AppFonts.sans(13)) .foregroundColor(AppColors.text(cs)) - if isOn && hk.authorized { + if hk.authorized { Text("Connected") .font(AppFonts.mono(9)) .foregroundColor(AppColors.green) - } else if isOn && !hk.isAvailable { + } else if !hk.isAvailable { Text("Not available on this device") .font(AppFonts.mono(9)) .foregroundColor(AppColors.text3(cs)) } } Spacer() - Toggle("", isOn: $isOn) - .labelsHidden() - .tint(AppColors.green) - .onChange(of: isOn) { enabled in - if enabled { - Task { await HealthKitManager.shared.requestAuthorization() } - } + Toggle("", isOn: Binding( + get: { hk.authorized }, + set: { on in + if on { Task { _ = await hk.requestAuthorization() } } + else { hk.disconnect() } } + )) + .labelsHidden() + .tint(AppColors.green) } .padding(.horizontal, 14).padding(.vertical, 11) if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) } @@ -1031,7 +1030,6 @@ private struct ProfileStatsSheet: View { @EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var workoutVM: WorkoutViewModel @AppStorage("streakGoal") private var streakGoal = 5 - @AppStorage("healthOn") private var healthOn = false @ObservedObject private var hk = HealthKitManager.shared @ObservedObject private var auth = AuthManager.shared @@ -1225,7 +1223,7 @@ private struct ProfileStatsSheet: View { .overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1)) // ── Health card ── - if healthOn && hk.authorized { + if hk.authorized { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { Text("HEALTH") diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift index 2093d2d..bdcb8a6 100644 --- a/KisaniCal/Views/WorkoutView.swift +++ b/KisaniCal/Views/WorkoutView.swift @@ -1039,11 +1039,19 @@ struct ProgramDetailSheet: View { ForEach(prog.sections) { section in Section { if section.exercises.isEmpty { - Text("No exercises — tap + to add") + Text("No exercises — tap + or drag one here") .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) .frame(maxWidth: .infinity).padding(.vertical, 10) .listRowBackground(Color.clear) .listRowSeparator(.hidden) + .dropDestination(for: String.self) { items, _ in + guard let s = items.first, let id = UUID(uuidString: s) else { return false } + withAnimation(KisaniSpring.snappy) { + vm.moveExercise(programId: programId, exerciseId: id, + toSection: section.id, before: nil) + } + return true + } } else { ForEach(section.exercises) { exercise in HStack(spacing: 10) { @@ -1065,6 +1073,15 @@ struct ProgramDetailSheet: View { .padding(.vertical, 4) .listRowBackground(AppColors.surface(cs)) .listRowSeparator(.hidden) + .draggable(exercise.id.uuidString) + .dropDestination(for: String.self) { items, _ in + guard let s = items.first, let id = UUID(uuidString: s) else { return false } + withAnimation(KisaniSpring.snappy) { + vm.moveExercise(programId: programId, exerciseId: id, + toSection: section.id, before: exercise.id) + } + return true + } .swipeActions(edge: .trailing) { Button(role: .destructive) { vm.deleteExercise(programId: programId, sectionId: section.id, exerciseId: exercise.id) @@ -1093,6 +1110,14 @@ struct ProgramDetailSheet: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .padding(.vertical, 2) + .dropDestination(for: String.self) { items, _ in + guard let s = items.first, let id = UUID(uuidString: s) else { return false } + withAnimation(KisaniSpring.snappy) { + vm.moveExercise(programId: programId, exerciseId: id, + toSection: section.id, before: nil) + } + return true + } } header: { HStack { diff --git a/KisaniCalWidgets/EventCountdownWidget.swift b/KisaniCalWidgets/EventCountdownWidget.swift new file mode 100644 index 0000000..23d57f2 --- /dev/null +++ b/KisaniCalWidgets/EventCountdownWidget.swift @@ -0,0 +1,153 @@ +import WidgetKit +import SwiftUI +import AppIntents + +// MARK: - Configuration Intent +// +// Each widget instance stores its own event. The user sets a name, the day the +// countdown starts ("Counting from" — defaults to the day they add the widget, +// which acts as the creation anchor) and the event date. Because these values are +// persisted per-instance by WidgetKit, the widget is fully self-contained and does +// not depend on App Group data sharing. + +struct EventConfigIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Event Countdown" + static var description = IntentDescription("Count down to any upcoming event with a dot grid.") + + // Pick one of your existing events (tasks with a due date). When set, it + // overrides the manual name/date fields below. + @Parameter(title: "Track event") + var event: EventEntity? + + @Parameter(title: "Or event name", default: "My Event") + var eventName: String + + @Parameter(title: "Event date", default: Date.now) + var targetDate: Date + + @Parameter(title: "Counting from", default: Date.now) + var startDate: Date +} + +// MARK: - Event picker (reads your tasks from the App Group) + +struct EventEntity: AppEntity { + let id: String // task UUID string + let title: String + let dueDate: Date? + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event" + static var defaultQuery = EventQuery() + + var displayRepresentation: DisplayRepresentation { + if let d = dueDate { + let f = DateFormatter(); f.dateStyle = .medium + return DisplayRepresentation(title: "\(title)", subtitle: "\(f.string(from: d))") + } + return DisplayRepresentation(title: "\(title)") + } +} + +struct EventQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [EventEntity] { + allEvents().filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [EventEntity] { + allEvents() + } + + private func allEvents() -> [EventEntity] { + loadWidgetTasks() + .filter { !$0.isComplete && $0.dueDate != nil } + .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + .map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate) } + } +} + +// MARK: - Entry + +struct EventEntry: TimelineEntry { + let date: Date + let eventName: String + let start: Date + let target: Date + + /// Fraction of the countdown window that has elapsed (0…1). + var progress: Double { + let span = target.timeIntervalSince(start) + guard span > 0 else { return target <= date ? 1 : 0 } + return min(1, max(0, date.timeIntervalSince(start) / span)) + } + + /// Whole days from start to target (the number of dots in the grid). + var totalDays: Int { + let cal = Calendar.current + let d = cal.dateComponents([.day], + from: cal.startOfDay(for: start), + to: cal.startOfDay(for: target)).day ?? 0 + return max(1, d) + } + + /// Days remaining until the event (never negative). + var daysLeft: Int { + let cal = Calendar.current + let d = cal.dateComponents([.day], + from: cal.startOfDay(for: date), + to: cal.startOfDay(for: target)).day ?? 0 + return max(0, d) + } +} + +// MARK: - Provider + +struct EventProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> EventEntry { + EventEntry(date: .now, + eventName: "Mum's Birthday", + start: Date().addingTimeInterval(-86400 * 320), + target: Date().addingTimeInterval(86400 * 60)) + } + + func snapshot(for configuration: EventConfigIntent, in context: Context) async -> EventEntry { + entry(for: configuration) + } + + func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline { + let entry = entry(for: configuration) + // Recompute at the next midnight so the grid advances one dot per day. + let nextMidnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400)) + return Timeline(entries: [entry], policy: .after(nextMidnight)) + } + + private func entry(for config: EventConfigIntent) -> EventEntry { + let name: String + let target: Date + if let ev = config.event, let due = ev.dueDate { + // A picked event supplies its own name + date. + name = ev.title + target = due + } else { + name = config.eventName.isEmpty ? "My Event" : config.eventName + target = config.targetDate + } + // Never let the start sit after the target. + let start = min(config.startDate, target) + return EventEntry(date: .now, eventName: name, start: start, target: target) + } +} + +// MARK: - Widget + +struct EventCountdownWidget: Widget { + let kind = "KisaniEventCountdown" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: EventConfigIntent.self, provider: EventProvider()) { entry in + EventCountdownView(entry: entry) + } + .configurationDisplayName("Event Countdown") + .description("Track the days until any upcoming event with a dot grid.") + .supportedFamilies([.systemMedium, .systemSmall]) + } +} diff --git a/KisaniCalWidgets/Info.plist b/KisaniCalWidgets/Info.plist new file mode 100644 index 0000000..39f6d1b --- /dev/null +++ b/KisaniCalWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Kisani Cal Widgets + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/KisaniCalWidgets/KisaniCalWidgets.entitlements b/KisaniCalWidgets/KisaniCalWidgets.entitlements new file mode 100644 index 0000000..2a25f0b --- /dev/null +++ b/KisaniCalWidgets/KisaniCalWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.kutesir.KisaniCal + + + diff --git a/KisaniCalWidgets/KisaniCalWidgets.swift b/KisaniCalWidgets/KisaniCalWidgets.swift new file mode 100644 index 0000000..36ef86d --- /dev/null +++ b/KisaniCalWidgets/KisaniCalWidgets.swift @@ -0,0 +1,114 @@ +import WidgetKit +import SwiftUI +import AppIntents + +// MARK: - Shared Entry + +struct KisaniEntry: TimelineEntry { + let date: Date + let task: WidgetTask? + let allTasks: [WidgetTask] + let streak: Int +} + +// MARK: - Shared Provider + +struct KisaniProvider: TimelineProvider { + func placeholder(in context: Context) -> KisaniEntry { + let sample = WidgetTask(id: UUID(), title: "Mums Birthday", dueDate: Date().addingTimeInterval(86400 * 60), + isComplete: false, quadrant: "Urgent + Important", category: "birthday") + return KisaniEntry(date: .now, task: sample, allTasks: [sample], streak: 7) + } + + func getSnapshot(in context: Context, completion: @escaping (KisaniEntry) -> Void) { + let tasks = loadWidgetTasks() + completion(KisaniEntry(date: .now, task: nextDueTask(from: tasks), allTasks: tasks, streak: loadWorkoutStreak())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let tasks = loadWidgetTasks() + let entry = KisaniEntry(date: .now, task: nextDueTask(from: tasks), allTasks: tasks, streak: loadWorkoutStreak()) + // Refresh every 30 minutes or when app triggers WidgetCenter.reloadAllTimelines() + let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)! + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +// MARK: - Day Progress Widget ("Day done %") + +struct DayProgressWidget: Widget { + let kind = "KisaniDayProgress" + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in + ProgressDotView(period: .day, accent: Color(red: 0.48, green: 0.87, blue: 0.80)) + } + .configurationDisplayName("Day Progress") + .description("How much of today has elapsed, as a dot grid.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +// MARK: - Tasks Done Today Widget ("Tasks complete %") + +struct TasksCompleteWidget: Widget { + let kind = "KisaniTasksComplete" + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in + TasksCompleteView() + } + .configurationDisplayName("Tasks Done Today") + .description("Percentage of today's tasks you've completed.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +// MARK: - Lock Screen Widgets + +struct LockScreenWidget: Widget { + let kind = "KisaniLockScreen" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: KisaniProvider()) { entry in + if let task = entry.task { + LockScreenRectView(task: task) + } else { + LockScreenRectView(task: WidgetTask(id: UUID(), title: "No tasks", + dueDate: nil, isComplete: false, quadrant: "Urgent + Important", category: "reminder")) + } + } + .configurationDisplayName("Task (Lock Screen)") + .description("Glass countdown on your lock screen.") + .supportedFamilies([.accessoryRectangular]) + } +} + +struct LockScreenCircularWidget: Widget { + let kind = "KisaniLockCircle" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: KisaniProvider()) { entry in + if let task = entry.task { + LockScreenCircularView(task: task) + } else { + LockScreenCircularView(task: WidgetTask(id: UUID(), title: "–", + dueDate: nil, isComplete: false, quadrant: "Urgent + Important", category: "reminder")) + } + } + .configurationDisplayName("Days Left (Lock Screen)") + .description("Circular countdown on your lock screen.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Widget Bundle + +@main +struct KisaniWidgetBundle: WidgetBundle { + var body: some Widget { + EventCountdownWidget() + DayProgressWidget() + TasksCompleteWidget() + LockScreenWidget() + LockScreenCircularWidget() + } +} diff --git a/KisaniCalWidgets/WidgetData.swift b/KisaniCalWidgets/WidgetData.swift new file mode 100644 index 0000000..e3986ce --- /dev/null +++ b/KisaniCalWidgets/WidgetData.swift @@ -0,0 +1,120 @@ +import Foundation +import SwiftUI + +// Minimal task model shared between widget and main app via App Group +struct WidgetTask: Codable, Identifiable { + let id: UUID + let title: String + let dueDate: Date? + let isComplete: Bool + let quadrant: String // raw value of Quadrant enum + let category: String + + // Days remaining until due date + var daysLeft: Int? { + guard let due = dueDate else { return nil } + let d = Calendar.current.dateComponents([.day], from: Calendar.current.startOfDay(for: .now), to: due).day ?? 0 + return max(0, d) + } + + // Hours remaining (for sub-day precision) + var hoursLeft: Int? { + guard let due = dueDate else { return nil } + let h = Calendar.current.dateComponents([.hour], from: .now, to: due).hour ?? 0 + return max(0, h) + } + + var timeLeftLabel: String { + guard dueDate != nil else { return "" } + if isComplete { return "Done" } + let days = daysLeft ?? 0 + if days == 0 { + let hrs = hoursLeft ?? 0 + return hrs <= 0 ? "Due now" : "\(hrs)hr left" + } + if days < 30 { return "\(days)d left" } + let months = days / 30 + let remDays = days % 30 + if remDays == 0 { return "\(months)mth left" } + return "\(months)mth \(remDays)d..." + } + + var accentColor: Color { + switch quadrant { + case "Urgent + Important": return Color(red: 0.96, green: 0.42, blue: 0.20) + case "Schedule It": return Color(red: 0.95, green: 0.78, blue: 0.10) + case "Delegate": return Color(red: 0.24, green: 0.58, blue: 0.95) + case "Eliminate": return Color(red: 0.20, green: 0.78, blue: 0.50) + default: return Color(red: 0.96, green: 0.42, blue: 0.20) + } + } +} + +// Load tasks from the shared App Group UserDefaults +func loadWidgetTasks() -> [WidgetTask] { + let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared" + let key = "kisani.tasks.v2.\(uid)" + guard let data = UserDefaults.kisani.data(forKey: key), + let raw = try? JSONDecoder().decode([RawTask].self, from: data) + else { return [] } + return raw.map { + WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate, + isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category) + } +} + +// Partial decode of TaskItem — only the fields widgets need +private struct RawTask: Codable { + let id: UUID + let title: String + let dueDate: Date? + var isComplete: Bool = false + var quadrant: String = "Urgent + Important" + var category: String = "reminder" + enum CodingKeys: String, CodingKey { + case id, title, dueDate, isComplete, quadrant, category + } +} + +// Next upcoming task with a due date (for default widget display) +func nextDueTask(from tasks: [WidgetTask]) -> WidgetTask? { + tasks + .filter { !$0.isComplete && $0.dueDate != nil } + .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } + .first +} + +// Today's task completion: (done, total) for tasks due today. +func todayTaskCompletion() -> (done: Int, total: Int) { + let cal = Calendar.current + let start = cal.startOfDay(for: Date()) + let end = cal.date(byAdding: .day, value: 1, to: start)! + let tasks = loadWidgetTasks() + func dueToday(_ t: WidgetTask) -> Bool { + guard let due = t.dueDate else { return false } + return due >= start && due < end + } + let active = tasks.filter { !$0.isComplete && dueToday($0) } + let completed = tasks.filter { $0.isComplete && dueToday($0) } + return (completed.count, active.count + completed.count) +} + +// Workout streak count +func loadWorkoutStreak() -> Int { + let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared" + let key = "kisani.workout.completedDates.\(uid)" + let dates = UserDefaults.kisani.stringArray(forKey: key) ?? [] + let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd" + let cal = Calendar.current + let dateSet = Set(dates) + var streak = 0 + var check = cal.startOfDay(for: Date()) + if !dateSet.contains(fmt.string(from: check)) { + check = cal.date(byAdding: .day, value: -1, to: check)! + } + while dateSet.contains(fmt.string(from: check)) { + streak += 1 + check = cal.date(byAdding: .day, value: -1, to: check)! + } + return streak +} diff --git a/KisaniCalWidgets/WidgetViews.swift b/KisaniCalWidgets/WidgetViews.swift new file mode 100644 index 0000000..8b244b0 --- /dev/null +++ b/KisaniCalWidgets/WidgetViews.swift @@ -0,0 +1,289 @@ +import SwiftUI +import WidgetKit + +// MARK: - Dot-grid progress (day / week / month / year) + +enum TimePeriod { case day, week, month, year } + +struct ProgressDotView: View { + let period: TimePeriod + let accent: Color + @Environment(\.widgetFamily) private var family + + private var label: String { + switch period { + case .day: let f = DateFormatter(); f.dateFormat = "EEEE d"; return f.string(from: Date()) + case .week: return "This week" + case .month: let f = DateFormatter(); f.dateFormat = "MMMM"; return f.string(from: Date()) + case .year: let f = DateFormatter(); f.dateFormat = "yyyy"; return f.string(from: Date()) + } + } + + private var progress: Double { + let cal = Calendar.current; let now = Date() + switch period { + case .day: + let sod = cal.startOfDay(for: now) + return (now.timeIntervalSince(sod)) / 86400 + case .week: + let comps = cal.dateComponents([.weekday], from: now) + let wd = (comps.weekday ?? 1) - 1 + let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now)) + return (Double(wd) * 86400 + secIntoDay) / (7 * 86400) + case .month: + let range = cal.range(of: .day, in: .month, for: now)! + let day = cal.component(.day, from: now) - 1 + let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now)) + return (Double(day) * 86400 + secIntoDay) / (Double(range.count) * 86400) + case .year: + let start = cal.date(from: cal.dateComponents([.year], from: now))! + let end = cal.date(byAdding: .year, value: 1, to: start)! + return now.timeIntervalSince(start) / end.timeIntervalSince(start) + } + } + + private var subLabel: String { + let pct = Int(progress * 100) + return "\(pct)%" + } + + var body: some View { + let isMedium = family == .systemMedium + VStack(alignment: .leading, spacing: isMedium ? 10 : 6) { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.system(size: isMedium ? 22 : 14, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + Spacer() + Text(subLabel) + .font(.system(size: isMedium ? 22 : 13, weight: .bold)) + .foregroundColor(Color(white: 0.5)) + } + DotGrid(total: 100, filled: Int(progress * 100), color: accent) + } + .padding(.horizontal, isMedium ? 4 : 0) + .containerBackground(.black, for: .widget) + } +} + +// MARK: - Tasks completed today + +struct TasksCompleteView: View { + @Environment(\.widgetFamily) private var family + private let teal = Color(red: 0.48, green: 0.87, blue: 0.80) + + private var stats: (done: Int, total: Int) { todayTaskCompletion() } + private var percent: Int { + let s = stats + guard s.total > 0 else { return 0 } + return Int((Double(s.done) / Double(s.total) * 100).rounded()) + } + + var body: some View { + let s = stats + let isMedium = family == .systemMedium + VStack(alignment: .leading, spacing: isMedium ? 10 : 6) { + HStack(alignment: .firstTextBaseline) { + Text("Today's Tasks") + .font(.system(size: isMedium ? 22 : 14, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + Spacer() + Text("\(percent)%") + .font(.system(size: isMedium ? 22 : 13, weight: .bold)) + .foregroundColor(Color(white: 0.5)) + } + if s.total == 0 { + Spacer(minLength: 0) + Text("No tasks due today") + .font(.system(size: 12)) + .foregroundColor(Color(white: 0.5)) + Spacer(minLength: 0) + } else { + Text("\(s.done) of \(s.total) done") + .font(.system(size: 12)) + .foregroundColor(teal) + Spacer(minLength: 2) + DotGrid(total: s.total, filled: s.done, color: teal) + } + } + .padding(.horizontal, isMedium ? 4 : 0) + .containerBackground(.black, for: .widget) + } +} + +// MARK: - Circular dot grid (event countdown motif) + +struct DotGrid: View { + let total: Int + let filled: Int + let color: Color + var gap: CGFloat = 3 + + var body: some View { + GeometryReader { geo in + let l = layout(for: total, in: geo.size) + VStack(spacing: gap) { + ForEach(0.. some View { + if filled { + Circle().fill(color).frame(width: size, height: size) + } else { + Circle() + .strokeBorder(color.opacity(0.55), lineWidth: max(0.8, size * 0.13)) + .frame(width: size, height: size) + } + } + + /// Pick the column count that yields the largest uniform circle that still fits + /// every dot inside the available area — an even grid that fills the widget. + private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat) { + guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6) } + var best = (cols: 1, rows: count, size: CGFloat(0)) + for cols in 1...count { + let rows = Int(ceil(Double(count) / Double(cols))) + let w = (size.width - gap * CGFloat(cols - 1)) / CGFloat(cols) + let h = (size.height - gap * CGFloat(rows - 1)) / CGFloat(rows) + let s = min(w, h) + if s > best.size { best = (cols, rows, s) } + } + return best + } +} + +// MARK: - Event Countdown Widget (small + medium) + +struct EventCountdownView: View { + let entry: EventEntry + @Environment(\.widgetFamily) var family + + private let teal = Color(red: 0.48, green: 0.87, blue: 0.80) + + private var percent: Int { Int((entry.progress * 100).rounded()) } + + var body: some View { + Group { + if family == .systemSmall { + small + } else { + medium + } + } + .containerBackground(.black, for: .widget) + } + + // Wide layout that mirrors the reference: title + percent, then a dot grid. + private var medium: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(LocalizedStringKey(entry.eventName)) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + Spacer() + Text("\(percent)%") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(Color(white: 0.5)) + } + DotGrid(total: entry.totalDays, + filled: Int(entry.progress * Double(entry.totalDays)), + color: teal) + } + .padding(.horizontal, 4) + } + + private var small: some View { + VStack(alignment: .leading, spacing: 6) { + Text(LocalizedStringKey(entry.eventName)) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .lineLimit(2) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(entry.daysLeft)") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(teal) + Text(entry.daysLeft == 1 ? "day" : "days") + .font(.system(size: 12)) + .foregroundColor(Color(white: 0.5)) + } + Spacer(minLength: 2) + DotGrid(total: entry.totalDays, + filled: Int(entry.progress * Double(entry.totalDays)), + color: teal) + } + } +} + +// MARK: - Lock Screen Widget (accessoryRectangular) + +struct LockScreenRectView: View { + let task: WidgetTask + + var body: some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(task.title) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + Text(task.timeLeftLabel) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + Spacer() + if !task.isComplete, let days = task.daysLeft, days <= 365 { + ProgressView(value: Double(365 - days), total: 365) + .progressViewStyle(.circular) + .frame(width: 22, height: 22) + .tint(.white) + } else if task.isComplete { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } + } + .containerBackground(.thinMaterial, for: .widget) + } +} + +// MARK: - Lock Screen Circular + +struct LockScreenCircularView: View { + let task: WidgetTask + + var body: some View { + ZStack { + if !task.isComplete, let days = task.daysLeft, days <= 365 { + ProgressView(value: Double(365 - days), total: 365) + .progressViewStyle(.circular) + .tint(.white) + } + VStack(spacing: 0) { + if let days = task.daysLeft { + Text("\(days)").font(.system(size: 14, weight: .bold)) + Text("days").font(.system(size: 7)) + } else { + Image(systemName: "checkmark").font(.system(size: 14, weight: .bold)) + } + } + } + .containerBackground(.thinMaterial, for: .widget) + } +} diff --git a/project.yml b/project.yml index 55ba7b0..23b5a28 100644 --- a/project.yml +++ b/project.yml @@ -6,6 +6,12 @@ options: xcodeVersion: "15.0" createIntermediateGroups: true +settings: + base: + DEVELOPMENT_TEAM: K8BLMMR883 + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + schemes: KisaniCal: build: @@ -29,6 +35,9 @@ targets: deploymentTarget: "16.0" sources: - path: KisaniCal + dependencies: + - target: KisaniCalWidgets + embed: true settings: base: SWIFT_VERSION: 5.9 @@ -43,6 +52,34 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor CODE_SIGN_STYLE: Automatic + CODE_SIGN_ENTITLEMENTS: KisaniCal/KisaniCal.entitlements 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." + INFOPLIST_KEY_NSHealthShareUsageDescription: "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard." + INFOPLIST_KEY_NSHealthUpdateUsageDescription: "Kisani Cal saves workouts you complete to the Health app." + + KisaniCalWidgets: + type: app-extension + platform: iOS + deploymentTarget: "18.0" + sources: + - path: KisaniCalWidgets + - path: KisaniCal/Managers/AppGroup.swift + info: + path: KisaniCalWidgets/Info.plist + properties: + CFBundleDisplayName: "Kisani Cal Widgets" + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + settings: + base: + SWIFT_VERSION: 5.9 + CODE_SIGN_STYLE: Automatic + GENERATE_INFOPLIST_FILE: NO + PRODUCT_BUNDLE_IDENTIFIER: com.kutesir.KisaniCal.KisaniCalWidgets + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks" + APPLICATION_EXTENSION_API_ONLY: YES + CODE_SIGN_ENTITLEMENTS: KisaniCalWidgets/KisaniCalWidgets.entitlements