Compare commits
20 Commits
main
...
3134c064e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3134c064e9 | ||
|
|
0a87769561 | ||
|
|
e8d74d4f59 | ||
|
|
152b0bf9b5 | ||
|
|
372da6df5a | ||
|
|
c5285a2e30 | ||
|
|
bd43eecfbe | ||
|
|
2ec3278632 | ||
|
|
6456d8f69a | ||
|
|
a37807aff0 | ||
|
|
cac8cba38a | ||
|
|
28b4039593 | ||
| cbf369b754 | |||
| 5e7d476b59 | |||
| ce97de37bd | |||
| d235cce510 | |||
| 9e93172199 | |||
|
|
b540bddbe9 | ||
|
|
9ae94babb6 | ||
|
|
8d37021f78 |
@@ -36,31 +36,51 @@ jobs:
|
|||||||
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
|
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
|
||||||
-allowProvisioningUpdates
|
-allowProvisioningUpdates
|
||||||
|
|
||||||
# Write the App Store Connect API key to disk. xcodebuild's auto-discovery
|
- name: Export IPA
|
||||||
# looks for exactly this filename pattern under ~/private_keys.
|
|
||||||
- name: Write App Store Connect API key
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/private_keys
|
|
||||||
echo "${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}" > ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8
|
|
||||||
|
|
||||||
# Export AND upload in one step: passing the API key credentials to
|
|
||||||
# -exportArchive makes xcodebuild upload directly to App Store Connect
|
|
||||||
# (method "app-store" in ExportOptions.plist) — no separate altool/
|
|
||||||
# Transporter step needed. (altool's own upload path was retired by
|
|
||||||
# Apple in 2023; this is the current supported mechanism.)
|
|
||||||
- name: Export IPA and upload to TestFlight
|
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
xcodebuild -exportArchive \
|
xcodebuild -exportArchive \
|
||||||
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
|
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
|
||||||
-exportOptionsPlist ExportOptions.plist \
|
-exportOptionsPlist ExportOptions.plist \
|
||||||
-exportPath "$RUNNER_TEMP/export" \
|
-exportPath "$RUNNER_TEMP/export" \
|
||||||
-allowProvisioningUpdates \
|
-allowProvisioningUpdates
|
||||||
-authenticationKeyPath ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8 \
|
|
||||||
-authenticationKeyID ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} \
|
|
||||||
-authenticationKeyIssuerID ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
|
||||||
ls -la "$RUNNER_TEMP/export"
|
ls -la "$RUNNER_TEMP/export"
|
||||||
|
|
||||||
- name: Clean up API key
|
# ------------------------------------------------------------------
|
||||||
if: always()
|
# PLACEHOLDER: Upload to TestFlight / App Store Connect.
|
||||||
run: rm -rf ~/private_keys
|
#
|
||||||
|
# Add these as Gitea repo secrets: Settings → Actions → Secrets
|
||||||
|
# APP_STORE_CONNECT_API_KEY_ID (the "Key ID" from App Store Connect)
|
||||||
|
# APP_STORE_CONNECT_ISSUER_ID (the "Issuer ID")
|
||||||
|
# APP_STORE_CONNECT_API_KEY_P8 (contents of the AuthKey_XXXX.p8 file)
|
||||||
|
#
|
||||||
|
# Then uncomment ONE of the approaches below.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# --- Option A: modern notarytool / App Store Connect API key (recommended) ---
|
||||||
|
# - name: Write API key to file
|
||||||
|
# run: |
|
||||||
|
# mkdir -p ~/private_keys
|
||||||
|
# echo "${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}" > ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8
|
||||||
|
#
|
||||||
|
# - name: Upload to TestFlight (altool with API key)
|
||||||
|
# run: |
|
||||||
|
# xcrun altool --upload-app \
|
||||||
|
# --type ios \
|
||||||
|
# --file "$RUNNER_TEMP/export/KisaniCal.ipa" \
|
||||||
|
# --apiKey "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" \
|
||||||
|
# --apiIssuer "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}"
|
||||||
|
|
||||||
|
# --- Option B: notarytool (for notarizing a macOS build, not iOS TestFlight) ---
|
||||||
|
# - name: Notarize
|
||||||
|
# run: |
|
||||||
|
# xcrun notarytool submit "$RUNNER_TEMP/export/KisaniCal.ipa" \
|
||||||
|
# --key ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8 \
|
||||||
|
# --key-id "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" \
|
||||||
|
# --issuer "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" \
|
||||||
|
# --wait
|
||||||
|
|
||||||
|
- name: Notice
|
||||||
|
run: |
|
||||||
|
echo "IPA exported. TestFlight upload step is a commented-out placeholder."
|
||||||
|
echo "Add App Store Connect API-key secrets and uncomment Option A in release.yml."
|
||||||
|
|||||||
@@ -7,33 +7,26 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */; };
|
|
||||||
06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */; };
|
06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */; };
|
||||||
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; };
|
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; };
|
||||||
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
|
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
|
||||||
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; };
|
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; };
|
||||||
152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */; };
|
|
||||||
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */; };
|
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */; };
|
||||||
1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; };
|
1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; };
|
||||||
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; };
|
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; };
|
||||||
2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */; };
|
2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */; };
|
||||||
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
|
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
|
||||||
30D155A1314CBD4BB1744B29 /* RecurrenceRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */; };
|
|
||||||
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
|
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
|
||||||
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; };
|
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; };
|
||||||
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; };
|
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; };
|
||||||
3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; };
|
3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; };
|
||||||
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; };
|
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; };
|
||||||
42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */; };
|
|
||||||
45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */; };
|
45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */; };
|
||||||
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; };
|
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; };
|
||||||
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */; };
|
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */; };
|
||||||
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */; };
|
|
||||||
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
|
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
|
||||||
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
|
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
|
||||||
5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */; };
|
|
||||||
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; };
|
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; };
|
||||||
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */; };
|
|
||||||
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
|
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
|
||||||
6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; };
|
6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; };
|
||||||
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; };
|
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; };
|
||||||
@@ -43,35 +36,27 @@
|
|||||||
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
|
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
|
||||||
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */; };
|
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */; };
|
||||||
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */; };
|
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */; };
|
||||||
87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */; };
|
|
||||||
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
|
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
|
||||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
|
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
|
||||||
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
|
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
|
||||||
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; };
|
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; };
|
||||||
9AA3E4808E88CA639BF3F28B /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */; };
|
|
||||||
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; };
|
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; };
|
||||||
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; };
|
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; };
|
||||||
A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */; };
|
A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */; };
|
||||||
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; };
|
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; };
|
||||||
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; };
|
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; };
|
||||||
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; };
|
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; };
|
||||||
B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */; };
|
|
||||||
B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */; };
|
|
||||||
BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */; };
|
BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */; };
|
||||||
BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.swift */; };
|
BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.swift */; };
|
||||||
C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */; };
|
|
||||||
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; };
|
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; };
|
||||||
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; };
|
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; };
|
||||||
CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */; };
|
CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */; };
|
||||||
CD3B0C436EA2D013FC3A6B5B /* RecurrenceRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */; };
|
|
||||||
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */; };
|
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */; };
|
||||||
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; };
|
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; };
|
||||||
E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */; };
|
E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */; };
|
||||||
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */; };
|
|
||||||
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; };
|
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; };
|
||||||
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
|
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
|
||||||
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; };
|
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; };
|
||||||
FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -108,25 +93,19 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = "<group>"; };
|
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = "<group>"; };
|
||||||
01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = "<group>"; };
|
01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = "<group>"; };
|
||||||
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsModels.swift; sourceTree = "<group>"; };
|
|
||||||
0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
||||||
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; };
|
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; };
|
||||||
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
|
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
|
||||||
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGrid.swift; sourceTree = "<group>"; };
|
0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGrid.swift; sourceTree = "<group>"; };
|
||||||
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStore.swift; sourceTree = "<group>"; };
|
|
||||||
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
|
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
|
||||||
1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = "<group>"; };
|
|
||||||
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
|
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
|
||||||
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; };
|
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; };
|
||||||
20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
|
23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
|
||||||
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; };
|
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; };
|
||||||
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; };
|
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; };
|
||||||
3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceRule.swift; sourceTree = "<group>"; };
|
|
||||||
3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
|
|
||||||
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStoreTests.swift; sourceTree = "<group>"; };
|
|
||||||
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = "<group>"; };
|
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = "<group>"; };
|
||||||
429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = "<group>"; };
|
429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = "<group>"; };
|
||||||
449C34805DC6B2CB66886544 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = "<group>"; };
|
449C34805DC6B2CB66886544 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = "<group>"; };
|
||||||
@@ -136,26 +115,20 @@
|
|||||||
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = "<group>"; };
|
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = "<group>"; };
|
||||||
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = "<group>"; };
|
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = "<group>"; };
|
||||||
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityHistoryView.swift; sourceTree = "<group>"; };
|
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityHistoryView.swift; sourceTree = "<group>"; };
|
||||||
713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsAdapterTests.swift; sourceTree = "<group>"; };
|
|
||||||
72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = "<group>"; };
|
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = "<group>"; };
|
||||||
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; };
|
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; };
|
||||||
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngine.swift; sourceTree = "<group>"; };
|
|
||||||
7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsDeepLink.swift; sourceTree = "<group>"; };
|
|
||||||
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WenzaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WenzaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = "<group>"; };
|
86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = "<group>"; };
|
||||||
89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
||||||
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = "<group>"; };
|
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = "<group>"; };
|
||||||
9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; };
|
9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; };
|
||||||
91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopCountdownIntent.swift; sourceTree = "<group>"; };
|
|
||||||
92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = "<group>"; };
|
92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = "<group>"; };
|
||||||
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
|
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
|
||||||
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = "<group>"; };
|
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = "<group>"; };
|
||||||
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngineTests.swift; sourceTree = "<group>"; };
|
|
||||||
A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCoordinatorTests.swift; sourceTree = "<group>"; };
|
|
||||||
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; };
|
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; };
|
||||||
AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
|
AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
|
||||||
B4CFFDFE4653A9E901CEF28D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
B4CFFDFE4653A9E901CEF28D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
@@ -166,10 +139,8 @@
|
|||||||
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
|
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
|
||||||
D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakLogicTests.swift; sourceTree = "<group>"; };
|
D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakLogicTests.swift; sourceTree = "<group>"; };
|
||||||
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; };
|
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; };
|
||||||
D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCoordinator.swift; sourceTree = "<group>"; };
|
|
||||||
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceTests.swift; sourceTree = "<group>"; };
|
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceTests.swift; sourceTree = "<group>"; };
|
||||||
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
|
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
|
||||||
E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutAnalyticsAdapter.swift; sourceTree = "<group>"; };
|
|
||||||
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = "<group>"; };
|
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = "<group>"; };
|
||||||
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
|
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
|
||||||
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; };
|
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; };
|
||||||
@@ -182,10 +153,6 @@
|
|||||||
068B77B2F01C399C7A430292 /* KisaniCalTests */ = {
|
068B77B2F01C399C7A430292 /* KisaniCalTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */,
|
|
||||||
A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */,
|
|
||||||
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */,
|
|
||||||
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */,
|
|
||||||
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
|
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
|
||||||
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
|
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
|
||||||
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
|
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
|
||||||
@@ -203,14 +170,6 @@
|
|||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
2217B7EEC45957B820311EC7 /* Shared */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */,
|
|
||||||
);
|
|
||||||
path = Shared;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
23CBCF100C5EF55E737379CA /* Models */ = {
|
23CBCF100C5EF55E737379CA /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -237,7 +196,6 @@
|
|||||||
F70DA4746C68CD405435DAB6 /* KisaniCal */,
|
F70DA4746C68CD405435DAB6 /* KisaniCal */,
|
||||||
068B77B2F01C399C7A430292 /* KisaniCalTests */,
|
068B77B2F01C399C7A430292 /* KisaniCalTests */,
|
||||||
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */,
|
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */,
|
||||||
2217B7EEC45957B820311EC7 /* Shared */,
|
|
||||||
48146B56E740528496663D47 /* WenzaWatch */,
|
48146B56E740528496663D47 /* WenzaWatch */,
|
||||||
51BD1B5DEDE9FAD9CA2FF6DA /* Products */,
|
51BD1B5DEDE9FAD9CA2FF6DA /* Products */,
|
||||||
);
|
);
|
||||||
@@ -259,7 +217,6 @@
|
|||||||
children = (
|
children = (
|
||||||
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */,
|
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */,
|
||||||
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
|
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
|
||||||
1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */,
|
|
||||||
4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
|
4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
|
||||||
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
|
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
|
||||||
D44530A77DF12A17E52AAF34 /* MatrixView.swift */,
|
D44530A77DF12A17E52AAF34 /* MatrixView.swift */,
|
||||||
@@ -284,20 +241,6 @@
|
|||||||
path = "Preview Content";
|
path = "Preview Content";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
C7409CC354029293D424BEDA /* Analytics */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */,
|
|
||||||
7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */,
|
|
||||||
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */,
|
|
||||||
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */,
|
|
||||||
3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */,
|
|
||||||
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */,
|
|
||||||
E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */,
|
|
||||||
);
|
|
||||||
path = Analytics;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
E0A03E79A679740978E61BF1 /* Theme */ = {
|
E0A03E79A679740978E61BF1 /* Theme */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -314,7 +257,6 @@
|
|||||||
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
|
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
|
||||||
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
|
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
|
||||||
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */,
|
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */,
|
||||||
91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */,
|
|
||||||
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */,
|
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */,
|
||||||
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */,
|
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */,
|
||||||
429806CE1021C8DE2EB770CE /* WidgetViews.swift */,
|
429806CE1021C8DE2EB770CE /* WidgetViews.swift */,
|
||||||
@@ -331,7 +273,6 @@
|
|||||||
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */,
|
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */,
|
||||||
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */,
|
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */,
|
||||||
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */,
|
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */,
|
||||||
C7409CC354029293D424BEDA /* Analytics */,
|
|
||||||
21B93C269F283F11B415B18C /* Components */,
|
21B93C269F283F11B415B18C /* Components */,
|
||||||
FB9BF734B9E493EEB09ACE21 /* Managers */,
|
FB9BF734B9E493EEB09ACE21 /* Managers */,
|
||||||
23CBCF100C5EF55E737379CA /* Models */,
|
23CBCF100C5EF55E737379CA /* Models */,
|
||||||
@@ -501,10 +442,6 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */,
|
|
||||||
FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */,
|
|
||||||
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */,
|
|
||||||
87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */,
|
|
||||||
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
|
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
|
||||||
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
|
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
|
||||||
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
|
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
|
||||||
@@ -526,13 +463,6 @@
|
|||||||
files = (
|
files = (
|
||||||
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */,
|
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */,
|
||||||
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
|
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
|
||||||
023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */,
|
|
||||||
B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */,
|
|
||||||
152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */,
|
|
||||||
42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */,
|
|
||||||
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */,
|
|
||||||
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */,
|
|
||||||
9AA3E4808E88CA639BF3F28B /* AnalyticsView.swift in Sources */,
|
|
||||||
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
|
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
|
||||||
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
|
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
|
||||||
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
|
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
|
||||||
@@ -550,7 +480,6 @@
|
|||||||
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */,
|
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */,
|
||||||
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */,
|
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */,
|
||||||
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */,
|
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */,
|
||||||
30D155A1314CBD4BB1744B29 /* RecurrenceRule.swift in Sources */,
|
|
||||||
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */,
|
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */,
|
||||||
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */,
|
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */,
|
||||||
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */,
|
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */,
|
||||||
@@ -563,7 +492,6 @@
|
|||||||
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
|
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
|
||||||
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
|
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
|
||||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
|
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
|
||||||
C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */,
|
|
||||||
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */,
|
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -576,8 +504,6 @@
|
|||||||
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */,
|
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */,
|
||||||
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */,
|
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */,
|
||||||
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */,
|
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */,
|
||||||
CD3B0C436EA2D013FC3A6B5B /* RecurrenceRule.swift in Sources */,
|
|
||||||
5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */,
|
|
||||||
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */,
|
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */,
|
||||||
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */,
|
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */,
|
||||||
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */,
|
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */,
|
||||||
@@ -689,7 +615,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 10;
|
CURRENT_PROJECT_VERSION = 9;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
@@ -783,7 +709,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 10;
|
CURRENT_PROJECT_VERSION = 9;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Generates any missing immutable weekly/monthly reports for completed periods,
|
|
||||||
/// then prunes beyond the retention window. Idempotent: already-generated periods
|
|
||||||
/// are skipped (dedup by store key), so it is safe to run on every launch /
|
|
||||||
/// foreground without depending on exact background execution times.
|
|
||||||
///
|
|
||||||
/// Sample providers are injected so the reconcile logic stays independent of app
|
|
||||||
/// models / HealthKit and is unit-testable. The app wires the providers to
|
|
||||||
/// `WorkoutAnalyticsAdapter` + `WorkoutViewModel` + `HealthKitManager`.
|
|
||||||
final class AnalyticsCoordinator {
|
|
||||||
|
|
||||||
struct ReconcileResult: Equatable {
|
|
||||||
var weekliesGenerated: [String] = []
|
|
||||||
var monthliesGenerated: [String] = []
|
|
||||||
var isEmpty: Bool { weekliesGenerated.isEmpty && monthliesGenerated.isEmpty }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Samples for a completed period plus its comparison baseline.
|
|
||||||
typealias PeriodSamples = (days: [DaySample], previous: [DaySample])
|
|
||||||
|
|
||||||
private let store: AnalyticsStore
|
|
||||||
private let calendar: Calendar
|
|
||||||
private let now: () -> Date
|
|
||||||
private let retentionMonths: Int
|
|
||||||
private let weekSamples: (_ weekStartKey: String) -> PeriodSamples
|
|
||||||
private let monthSamples: (_ monthKey: String) -> PeriodSamples
|
|
||||||
|
|
||||||
init(store: AnalyticsStore,
|
|
||||||
calendar: Calendar,
|
|
||||||
retentionMonths: Int = 12,
|
|
||||||
now: @escaping () -> Date = Date.init,
|
|
||||||
weekSamples: @escaping (String) -> PeriodSamples,
|
|
||||||
monthSamples: @escaping (String) -> PeriodSamples) {
|
|
||||||
self.store = store
|
|
||||||
self.calendar = calendar
|
|
||||||
self.retentionMonths = retentionMonths
|
|
||||||
self.now = now
|
|
||||||
self.weekSamples = weekSamples
|
|
||||||
self.monthSamples = monthSamples
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backfill every completed week/month within the retention window that has
|
|
||||||
/// no report yet, then prune expired data. Returns which periods were newly
|
|
||||||
/// generated (empty on a no-op run → drives "should I notify?" decisions).
|
|
||||||
@discardableResult
|
|
||||||
func reconcile() -> ReconcileResult {
|
|
||||||
let ref = now()
|
|
||||||
var result = ReconcileResult()
|
|
||||||
|
|
||||||
let allWeeks = AnalyticsEngine.completedWeekStartKeys(now: ref, months: retentionMonths, calendar: calendar)
|
|
||||||
for key in AnalyticsEngine.missingKeys(allWeeks, existing: store.existingWeeklyKeys()) {
|
|
||||||
let s = weekSamples(key)
|
|
||||||
let report = AnalyticsEngine.weeklyReport(weekStartKey: key, days: s.days,
|
|
||||||
previousWeekDays: s.previous, generatedAt: ref)
|
|
||||||
if store.saveWeeklyReport(report) { result.weekliesGenerated.append(key) }
|
|
||||||
}
|
|
||||||
|
|
||||||
let allMonths = AnalyticsEngine.completedMonthKeys(now: ref, months: retentionMonths, calendar: calendar)
|
|
||||||
for key in AnalyticsEngine.missingKeys(allMonths, existing: store.existingMonthlyKeys()) {
|
|
||||||
let s = monthSamples(key)
|
|
||||||
let report = AnalyticsEngine.monthlyReport(monthKey: key, days: s.days,
|
|
||||||
previousMonthDays: s.previous, generatedAt: ref)
|
|
||||||
if store.saveMonthlyReport(report) { result.monthliesGenerated.append(key) }
|
|
||||||
}
|
|
||||||
|
|
||||||
store.prune(now: ref, months: retentionMonths, calendar: calendar)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The most recent completed weekly / monthly report keys (for "notify about
|
|
||||||
/// the latest report" without duplicating). Deep links for these come from
|
|
||||||
/// `AnalyticsDeepLink`.
|
|
||||||
func latestWeeklyKey() -> String? { store.existingWeeklyKeys().max() }
|
|
||||||
func latestMonthlyKey() -> String? { store.existingMonthlyKeys().max() }
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Deep links into the Analytics area — used by weekly/monthly report
|
|
||||||
/// notifications to route straight to the relevant report. Pure value type with
|
|
||||||
/// symmetric URL encode/decode so routing is testable without any UI.
|
|
||||||
///
|
|
||||||
/// Format: wenza://analytics → overview
|
|
||||||
/// wenza://analytics/weekly/<yyyy-MM-dd> → a weekly report
|
|
||||||
/// wenza://analytics/monthly/<yyyy-MM> → a monthly report
|
|
||||||
enum AnalyticsDeepLink: Equatable, Identifiable {
|
|
||||||
case overview
|
|
||||||
case weekly(weekStartKey: String)
|
|
||||||
case monthly(monthKey: String)
|
|
||||||
|
|
||||||
static let scheme = "wenza"
|
|
||||||
static let host = "analytics"
|
|
||||||
|
|
||||||
var id: String { stringValue }
|
|
||||||
|
|
||||||
/// Stable userInfo key carried on notifications.
|
|
||||||
static let userInfoKey = "kisani.analytics.deeplink"
|
|
||||||
|
|
||||||
var url: URL {
|
|
||||||
var c = URLComponents()
|
|
||||||
c.scheme = Self.scheme
|
|
||||||
c.host = Self.host
|
|
||||||
switch self {
|
|
||||||
case .overview: c.path = ""
|
|
||||||
case .weekly(let key): c.path = "/weekly/\(key)"
|
|
||||||
case .monthly(let key): c.path = "/monthly/\(key)"
|
|
||||||
}
|
|
||||||
return c.url!
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(url: URL) {
|
|
||||||
guard url.scheme == Self.scheme, url.host == Self.host else { return nil }
|
|
||||||
let parts = url.path.split(separator: "/").map(String.init)
|
|
||||||
switch parts.first {
|
|
||||||
case nil:
|
|
||||||
self = .overview
|
|
||||||
case "weekly":
|
|
||||||
guard parts.count >= 2 else { return nil }
|
|
||||||
self = .weekly(weekStartKey: parts[1])
|
|
||||||
case "monthly":
|
|
||||||
guard parts.count >= 2 else { return nil }
|
|
||||||
self = .monthly(monthKey: parts[1])
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build/parse via a plain string (for notification userInfo payloads).
|
|
||||||
var stringValue: String { url.absoluteString }
|
|
||||||
init?(string: String) {
|
|
||||||
guard let url = URL(string: string) else { return nil }
|
|
||||||
self.init(url: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// Deterministic, side-effect-free analytics calculations. No SwiftUI, no
|
|
||||||
// persistence, no HealthKit, no LLM — pure functions over value types so every
|
|
||||||
// formula is unit-testable and reproducible. All calendar math takes an injected
|
|
||||||
// `Calendar` so locale / firstWeekday / timezone / DST behavior is testable.
|
|
||||||
//
|
|
||||||
// FORMULAS
|
|
||||||
// volume = Σ (weight × reps) over completed sets [kg]
|
|
||||||
// percentChange = (current − baseline) / |baseline| × 100 [%]
|
|
||||||
// consistency = min(1, sessions / scheduled) [0…1]
|
|
||||||
// trend = classify(least-squares slope / |mean|) vs threshold
|
|
||||||
//
|
|
||||||
// All comparisons degrade honestly: missing baseline → no %, tiny/zero baseline
|
|
||||||
// → no % (avoids divide-by-tiny), new exercise / rest day → not meaningful.
|
|
||||||
// Language is factual and supportive; nothing here diagnoses or advises medically.
|
|
||||||
|
|
||||||
enum AnalyticsEngine {
|
|
||||||
|
|
||||||
/// Informational, non-medical disclaimer surfaced with every report.
|
|
||||||
static let disclaimer = """
|
|
||||||
These are informational summaries of your own logged activity — not medical \
|
|
||||||
advice, diagnosis, or treatment. Talk to a qualified professional for health \
|
|
||||||
decisions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
// MARK: - Exercise identity
|
|
||||||
|
|
||||||
/// Normalized identity so the same exercise matches across days regardless of
|
|
||||||
/// casing / whitespace. Deterministic and locale-independent.
|
|
||||||
static func exerciseIdentity(_ name: String) -> String {
|
|
||||||
name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.lowercased()
|
|
||||||
.replacingOccurrences(of: " ", with: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delta
|
|
||||||
|
|
||||||
/// Compare `current` against `baseline`, guarding percentage honesty.
|
|
||||||
/// - `isNew`: no prior baseline exists (e.g. a newly added exercise).
|
|
||||||
/// - `restDay`: baseline day was a rest day → % is not meaningful.
|
|
||||||
static func delta(current: Double,
|
|
||||||
baseline: Double?,
|
|
||||||
minMeaningfulBaseline: Double = 0.0001,
|
|
||||||
isNew: Bool = false,
|
|
||||||
restDay: Bool = false) -> MetricDelta {
|
|
||||||
guard let base = baseline, !isNew, !restDay else {
|
|
||||||
return MetricDelta(current: current,
|
|
||||||
baseline: baseline,
|
|
||||||
absolute: baseline.map { current - $0 },
|
|
||||||
percent: nil,
|
|
||||||
direction: current > 0 ? .up : .flat,
|
|
||||||
meaningful: false)
|
|
||||||
}
|
|
||||||
let abs = current - base
|
|
||||||
let dir: MetricDirection = abs > 0 ? .up : (abs < 0 ? .down : .flat)
|
|
||||||
// Only compute % when the baseline magnitude is meaningful.
|
|
||||||
let pct: Double? = Swift.abs(base) >= minMeaningfulBaseline ? (abs / Swift.abs(base)) * 100 : nil
|
|
||||||
return MetricDelta(current: current,
|
|
||||||
baseline: base,
|
|
||||||
absolute: abs,
|
|
||||||
percent: pct,
|
|
||||||
direction: dir,
|
|
||||||
meaningful: pct != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify a single week/month-over-period change from its volume delta.
|
|
||||||
static func classifyChange(_ d: MetricDelta, plateauPct: Double = 3) -> TrendClassification {
|
|
||||||
guard d.meaningful, let pct = d.percent else { return .insufficientData }
|
|
||||||
if pct > plateauPct { return .improving }
|
|
||||||
if pct < -plateauPct { return .declining }
|
|
||||||
return .plateau
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Consistency
|
|
||||||
|
|
||||||
static func consistency(sessions: Int, scheduled: Int) -> Double {
|
|
||||||
guard scheduled > 0 else { return 0 }
|
|
||||||
return min(1.0, Double(sessions) / Double(scheduled))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Volume
|
|
||||||
|
|
||||||
static func volume(weight: Double, reps: Int) -> Double { weight * Double(reps) }
|
|
||||||
|
|
||||||
// MARK: - Trend (multi-point, for 12-month views)
|
|
||||||
|
|
||||||
static func classifyTrend(_ values: [Double],
|
|
||||||
minPoints: Int = 3,
|
|
||||||
relativeThreshold: Double = 0.03) -> TrendResult {
|
|
||||||
let n = values.count
|
|
||||||
guard n >= minPoints else {
|
|
||||||
return TrendResult(classification: .insufficientData, slope: 0, points: n)
|
|
||||||
}
|
|
||||||
let slope = leastSquaresSlope(values)
|
|
||||||
let mean = values.reduce(0, +) / Double(n)
|
|
||||||
guard Swift.abs(mean) > 0 else {
|
|
||||||
let cls: TrendClassification = slope == 0 ? .plateau : (slope > 0 ? .improving : .declining)
|
|
||||||
return TrendResult(classification: cls, slope: slope, points: n)
|
|
||||||
}
|
|
||||||
let rel = slope / Swift.abs(mean)
|
|
||||||
let cls: TrendClassification
|
|
||||||
if rel > relativeThreshold { cls = .improving }
|
|
||||||
else if rel < -relativeThreshold { cls = .declining }
|
|
||||||
else { cls = .plateau }
|
|
||||||
return TrendResult(classification: cls, slope: slope, points: n)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func leastSquaresSlope(_ y: [Double]) -> Double {
|
|
||||||
let n = Double(y.count)
|
|
||||||
guard n > 1 else { return 0 }
|
|
||||||
let xs = (0..<y.count).map(Double.init)
|
|
||||||
let sumX = xs.reduce(0, +)
|
|
||||||
let sumY = y.reduce(0, +)
|
|
||||||
let sumXY = zip(xs, y).map(*).reduce(0, +)
|
|
||||||
let sumXX = xs.map { $0 * $0 }.reduce(0, +)
|
|
||||||
let denom = n * sumXX - sumX * sumX
|
|
||||||
guard denom != 0 else { return 0 }
|
|
||||||
return (n * sumXY - sumX * sumY) / denom
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Outliers
|
|
||||||
|
|
||||||
/// Winsorize to the [lower, upper] percentile band to tame outliers before
|
|
||||||
/// trend/mean calculations. No-op for very small samples.
|
|
||||||
static func winsorize(_ values: [Double], lower: Double = 0.1, upper: Double = 0.9) -> [Double] {
|
|
||||||
guard values.count >= 5 else { return values }
|
|
||||||
let sorted = values.sorted()
|
|
||||||
let lo = percentile(sorted, lower)
|
|
||||||
let hi = percentile(sorted, upper)
|
|
||||||
return values.map { min(max($0, lo), hi) }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func percentile(_ sorted: [Double], _ p: Double) -> Double {
|
|
||||||
guard !sorted.isEmpty else { return 0 }
|
|
||||||
let idx = Int((Double(sorted.count - 1) * min(max(p, 0), 1)).rounded())
|
|
||||||
return sorted[min(max(idx, 0), sorted.count - 1)]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Exercise comparison
|
|
||||||
|
|
||||||
static func compareExercises(current: [ExerciseSample],
|
|
||||||
baseline: [ExerciseSample],
|
|
||||||
restDay: Bool = false) -> [ExerciseComparison] {
|
|
||||||
let baseByIdentity = Dictionary(baseline.map { ($0.identity, $0) }, uniquingKeysWith: { a, _ in a })
|
|
||||||
return current.map { cur in
|
|
||||||
let base = baseByIdentity[cur.identity]
|
|
||||||
let isNew = base == nil
|
|
||||||
return ExerciseComparison(
|
|
||||||
identity: cur.identity,
|
|
||||||
volume: delta(current: cur.volume, baseline: base?.volume, isNew: isNew, restDay: restDay),
|
|
||||||
sets: delta(current: Double(cur.sets), baseline: base.map { Double($0.sets) }, isNew: isNew, restDay: restDay),
|
|
||||||
reps: delta(current: Double(cur.reps), baseline: base.map { Double($0.reps) }, isNew: isNew, restDay: restDay),
|
|
||||||
isNew: isNew
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Daily comparison
|
|
||||||
|
|
||||||
static func dailyComparison(day: DaySample,
|
|
||||||
previousDay: DaySample?,
|
|
||||||
sameWeekdayLastWeek: DaySample?) -> DailyComparison {
|
|
||||||
DailyComparison(
|
|
||||||
dateKey: day.dateKey,
|
|
||||||
restDay: !day.didWorkout,
|
|
||||||
vsPreviousDay: compareExercises(current: day.exercises,
|
|
||||||
baseline: previousDay?.exercises ?? [],
|
|
||||||
restDay: previousDay.map { !$0.didWorkout } ?? true),
|
|
||||||
vsSameWeekdayLastWeek: compareExercises(current: day.exercises,
|
|
||||||
baseline: sameWeekdayLastWeek?.exercises ?? [],
|
|
||||||
restDay: sameWeekdayLastWeek.map { !$0.didWorkout } ?? true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Period summaries & reports
|
|
||||||
|
|
||||||
static func summarize(_ days: [DaySample]) -> PeriodSummary {
|
|
||||||
let sessions = days.filter { $0.didWorkout }.count
|
|
||||||
let scheduled = days.filter { $0.scheduled }.count
|
|
||||||
let missed = days.filter { $0.scheduled && !$0.didWorkout }.count
|
|
||||||
let sets = days.reduce(0) { $0 + $1.sets }
|
|
||||||
let volume = days.reduce(0) { $0 + $1.volume }
|
|
||||||
let duration = days.reduce(0) { $0 + $1.durationMinutes }
|
|
||||||
func avg(_ vals: [Int]) -> Int? { vals.isEmpty ? nil : Int((Double(vals.reduce(0, +)) / Double(vals.count)).rounded()) }
|
|
||||||
return PeriodSummary(sessions: sessions, sets: sets, volume: volume,
|
|
||||||
durationMinutes: duration, scheduled: scheduled,
|
|
||||||
missed: missed, consistency: consistency(sessions: sessions, scheduled: scheduled),
|
|
||||||
avgSteps: avg(days.compactMap { $0.steps }),
|
|
||||||
avgActiveCalories: avg(days.compactMap { $0.activeCalories }),
|
|
||||||
avgRestingHR: avg(days.compactMap { $0.restingHeartRate }))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func periodDeltas(current cur: PeriodSummary, previous prev: PeriodSummary) -> [String: MetricDelta] {
|
|
||||||
[
|
|
||||||
"sessions": delta(current: Double(cur.sessions), baseline: Double(prev.sessions)),
|
|
||||||
"sets": delta(current: Double(cur.sets), baseline: Double(prev.sets)),
|
|
||||||
"volume": delta(current: cur.volume, baseline: prev.volume),
|
|
||||||
"duration": delta(current: cur.durationMinutes, baseline: prev.durationMinutes),
|
|
||||||
"consistency": delta(current: cur.consistency * 100, baseline: prev.consistency * 100),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func weeklyReport(weekStartKey: String,
|
|
||||||
days: [DaySample],
|
|
||||||
previousWeekDays: [DaySample],
|
|
||||||
generatedAt: Date) -> WeeklyReport {
|
|
||||||
let cur = summarize(days)
|
|
||||||
let prev = summarize(previousWeekDays)
|
|
||||||
let deltas = periodDeltas(current: cur, previous: prev)
|
|
||||||
return WeeklyReport(
|
|
||||||
weekStartKey: weekStartKey,
|
|
||||||
generatedAt: generatedAt,
|
|
||||||
summary: cur,
|
|
||||||
vsPreviousWeek: deltas,
|
|
||||||
progression: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)),
|
|
||||||
disclaimer: disclaimer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func monthlyReport(monthKey: String,
|
|
||||||
days: [DaySample],
|
|
||||||
previousMonthDays: [DaySample],
|
|
||||||
generatedAt: Date) -> MonthlyReport {
|
|
||||||
let cur = summarize(days)
|
|
||||||
let prev = summarize(previousMonthDays)
|
|
||||||
let deltas = periodDeltas(current: cur, previous: prev)
|
|
||||||
return MonthlyReport(
|
|
||||||
monthKey: monthKey,
|
|
||||||
generatedAt: generatedAt,
|
|
||||||
summary: cur,
|
|
||||||
vsPreviousMonth: deltas,
|
|
||||||
classification: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)),
|
|
||||||
disclaimer: disclaimer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Calendar helpers (injected Calendar → locale/TZ/DST-safe)
|
|
||||||
|
|
||||||
private static func keyFormatter(_ calendar: Calendar) -> DateFormatter {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.calendar = calendar
|
|
||||||
f.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
f.timeZone = calendar.timeZone
|
|
||||||
f.dateFormat = "yyyy-MM-dd"
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
static func dateKey(for date: Date, calendar: Calendar) -> String {
|
|
||||||
keyFormatter(calendar).string(from: calendar.startOfDay(for: date))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func monthKey(for date: Date, calendar: Calendar) -> String {
|
|
||||||
let f = keyFormatter(calendar); f.dateFormat = "yyyy-MM"
|
|
||||||
return f.string(from: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func weekInterval(containing date: Date, calendar: Calendar) -> DateInterval? {
|
|
||||||
calendar.dateInterval(of: .weekOfYear, for: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func monthInterval(containing date: Date, calendar: Calendar) -> DateInterval? {
|
|
||||||
calendar.dateInterval(of: .month, for: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same weekday one week earlier — used for the day-vs-last-week compare.
|
|
||||||
static func sameWeekdayLastWeek(_ date: Date, calendar: Calendar) -> Date? {
|
|
||||||
calendar.date(byAdding: .weekOfYear, value: -1, to: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `date` falls in a completed period (strictly before the period
|
|
||||||
/// containing `now`) — reports are only generated for completed periods.
|
|
||||||
static func isCompletedWeek(_ date: Date, now: Date, calendar: Calendar) -> Bool {
|
|
||||||
guard let w = weekInterval(containing: date, calendar: calendar) else { return false }
|
|
||||||
return w.end <= (weekInterval(containing: now, calendar: calendar)?.start ?? now)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isCompletedMonth(_ date: Date, now: Date, calendar: Calendar) -> Bool {
|
|
||||||
guard let m = monthInterval(containing: date, calendar: calendar) else { return false }
|
|
||||||
return m.end <= (monthInterval(containing: now, calendar: calendar)?.start ?? now)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Retention
|
|
||||||
|
|
||||||
/// Keys older than `months` before `now` (for safe pruning of raw data).
|
|
||||||
static func expiredDayKeys(_ keys: [String], now: Date, months: Int = 12, calendar: Calendar) -> [String] {
|
|
||||||
guard let cutoff = calendar.date(byAdding: .month, value: -months, to: calendar.startOfDay(for: now)) else { return [] }
|
|
||||||
let cutoffKey = dateKey(for: cutoff, calendar: calendar)
|
|
||||||
return keys.filter { $0 < cutoffKey }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Reconciliation selection (which completed periods need a report)
|
|
||||||
|
|
||||||
/// Week-start keys of every completed week within the last `months`, oldest→newest.
|
|
||||||
/// Only weeks whose start is on/after the retention cutoff are included, so
|
|
||||||
/// generation and `prune` agree — otherwise a boundary week would be
|
|
||||||
/// generated then immediately pruned, regenerating on every run.
|
|
||||||
static func completedWeekStartKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
|
|
||||||
guard let windowStart = cal.date(byAdding: .month, value: -months, to: now),
|
|
||||||
let thisWeek = weekInterval(containing: now, calendar: cal),
|
|
||||||
var cursor = weekInterval(containing: windowStart, calendar: cal)?.start else { return [] }
|
|
||||||
let cutoffKey = dateKey(for: cal.startOfDay(for: windowStart), calendar: cal)
|
|
||||||
var keys: [String] = []
|
|
||||||
var guardCount = 0
|
|
||||||
while cursor < thisWeek.start, guardCount < 70 {
|
|
||||||
let key = dateKey(for: cursor, calendar: cal)
|
|
||||||
if key >= cutoffKey { keys.append(key) }
|
|
||||||
guard let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor) else { break }
|
|
||||||
cursor = next; guardCount += 1
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Month keys of every completed month within the last `months`, oldest→newest.
|
|
||||||
static func completedMonthKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
|
|
||||||
var keys: [String] = []
|
|
||||||
for m in stride(from: months, through: 1, by: -1) {
|
|
||||||
guard let d = cal.date(byAdding: .month, value: -m, to: now) else { continue }
|
|
||||||
let key = monthKey(for: d, calendar: cal)
|
|
||||||
if !keys.contains(key) { keys.append(key) }
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keys present in `all` but not in `existing` — the periods still needing a
|
|
||||||
/// report. Idempotent by construction: already-generated periods are skipped.
|
|
||||||
static func missingKeys(_ all: [String], existing: Set<String>) -> [String] {
|
|
||||||
all.filter { !existing.contains($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// Pure value types for the analytics system. No SwiftUI, no persistence — these
|
|
||||||
// are the inputs and outputs of `AnalyticsEngine`, kept decoupled from app
|
|
||||||
// models so the engine is deterministic and unit-testable in isolation.
|
|
||||||
// Adapters from WorkoutDayLog / HealthKit live in the persistence layer.
|
|
||||||
|
|
||||||
// MARK: - Direction & trend
|
|
||||||
|
|
||||||
/// Semantic direction of a change. Paired in UI with a non-color glyph so status
|
|
||||||
/// is never conveyed by color alone.
|
|
||||||
enum MetricDirection: String, Codable, Equatable {
|
|
||||||
case up, down, flat, none
|
|
||||||
}
|
|
||||||
|
|
||||||
enum TrendClassification: String, Codable, Equatable {
|
|
||||||
case improving, declining, plateau, insufficientData
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TrendResult: Codable, Equatable {
|
|
||||||
let classification: TrendClassification
|
|
||||||
let slope: Double // least-squares slope, units per period
|
|
||||||
let points: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Unit compatibility
|
|
||||||
|
|
||||||
/// Metrics are only comparable within the same physical dimension. Prevents
|
|
||||||
/// nonsense like comparing kilograms to minutes.
|
|
||||||
enum MetricUnit: String, Codable, Equatable {
|
|
||||||
case kilograms, pounds, count, minutes, kilometers, miles, kilocalories, bpm, percent, none
|
|
||||||
|
|
||||||
var dimension: String {
|
|
||||||
switch self {
|
|
||||||
case .kilograms, .pounds: return "mass"
|
|
||||||
case .kilometers, .miles: return "distance"
|
|
||||||
case .minutes: return "time"
|
|
||||||
case .kilocalories: return "energy"
|
|
||||||
case .bpm: return "rate"
|
|
||||||
case .count: return "count"
|
|
||||||
case .percent: return "ratio"
|
|
||||||
case .none: return "none"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isComparable(with other: MetricUnit) -> Bool { dimension == other.dimension }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delta
|
|
||||||
|
|
||||||
/// One metric compared against a baseline. `percent` is nil when there is no
|
|
||||||
/// baseline, the baseline is too small to be meaningful, or the comparison is
|
|
||||||
/// otherwise not honest (new item / rest day). `meaningful` gates UI display.
|
|
||||||
struct MetricDelta: Codable, Equatable {
|
|
||||||
let current: Double
|
|
||||||
let baseline: Double?
|
|
||||||
let absolute: Double?
|
|
||||||
let percent: Double?
|
|
||||||
let direction: MetricDirection
|
|
||||||
let meaningful: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Samples (engine inputs)
|
|
||||||
|
|
||||||
/// A single exercise's contribution on a day, keyed by a normalized identity.
|
|
||||||
struct ExerciseSample: Codable, Equatable {
|
|
||||||
let identity: String // normalized identity key (see AnalyticsEngine.exerciseIdentity)
|
|
||||||
let sets: Int
|
|
||||||
let reps: Int // total reps across completed sets
|
|
||||||
let volume: Double // Σ weight×reps of completed sets, kg
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A day of activity: app workout record + optional HealthKit reference values.
|
|
||||||
/// HealthKit fields are optional because authorization/data may be missing.
|
|
||||||
struct DaySample: Codable, Equatable {
|
|
||||||
let dateKey: String // "yyyy-MM-dd" in the reporting calendar
|
|
||||||
let didWorkout: Bool
|
|
||||||
let scheduled: Bool
|
|
||||||
let sessions: Int
|
|
||||||
let sets: Int
|
|
||||||
let volume: Double
|
|
||||||
let durationMinutes: Double
|
|
||||||
let exercises: [ExerciseSample]
|
|
||||||
// HealthKit reference (source of truth for health measurements) — may be nil.
|
|
||||||
let steps: Int?
|
|
||||||
let activeCalories: Int?
|
|
||||||
let restingHeartRate: Int?
|
|
||||||
|
|
||||||
init(dateKey: String, didWorkout: Bool = false, scheduled: Bool = false,
|
|
||||||
sessions: Int = 0, sets: Int = 0, volume: Double = 0, durationMinutes: Double = 0,
|
|
||||||
exercises: [ExerciseSample] = [], steps: Int? = nil,
|
|
||||||
activeCalories: Int? = nil, restingHeartRate: Int? = nil) {
|
|
||||||
self.dateKey = dateKey
|
|
||||||
self.didWorkout = didWorkout
|
|
||||||
self.scheduled = scheduled
|
|
||||||
self.sessions = sessions
|
|
||||||
self.sets = sets
|
|
||||||
self.volume = volume
|
|
||||||
self.durationMinutes = durationMinutes
|
|
||||||
self.exercises = exercises
|
|
||||||
self.steps = steps
|
|
||||||
self.activeCalories = activeCalories
|
|
||||||
self.restingHeartRate = restingHeartRate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Comparisons & reports (engine outputs; immutable snapshots)
|
|
||||||
|
|
||||||
struct ExerciseComparison: Codable, Equatable {
|
|
||||||
let identity: String
|
|
||||||
let volume: MetricDelta
|
|
||||||
let sets: MetricDelta
|
|
||||||
let reps: MetricDelta
|
|
||||||
let isNew: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DailyComparison: Codable, Equatable {
|
|
||||||
let dateKey: String
|
|
||||||
let restDay: Bool
|
|
||||||
let vsPreviousDay: [ExerciseComparison]
|
|
||||||
let vsSameWeekdayLastWeek: [ExerciseComparison]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PeriodSummary: Codable, Equatable {
|
|
||||||
let sessions: Int
|
|
||||||
let sets: Int
|
|
||||||
let volume: Double
|
|
||||||
let durationMinutes: Double
|
|
||||||
let scheduled: Int
|
|
||||||
let missed: Int
|
|
||||||
let consistency: Double // 0...1
|
|
||||||
// Available HealthKit trends — nil when no reference data in the period.
|
|
||||||
var avgSteps: Int? = nil
|
|
||||||
var avgActiveCalories: Int? = nil
|
|
||||||
var avgRestingHR: Int? = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Immutable weekly report. Once generated with `generatedAt`, later edits to
|
|
||||||
/// source data never change it (the persistence layer stores it frozen).
|
|
||||||
struct WeeklyReport: Codable, Equatable, Identifiable {
|
|
||||||
let weekStartKey: String // "yyyy-MM-dd" of the week's first day
|
|
||||||
let generatedAt: Date
|
|
||||||
let summary: PeriodSummary
|
|
||||||
let vsPreviousWeek: [String: MetricDelta]
|
|
||||||
let progression: TrendClassification
|
|
||||||
let disclaimer: String
|
|
||||||
var id: String { weekStartKey }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chart-friendly Identifiable points (tuples can't be keyed in SwiftUI Charts).
|
|
||||||
struct MonthVolumePoint: Identifiable, Equatable {
|
|
||||||
let monthKey: String
|
|
||||||
let volume: Double
|
|
||||||
var id: String { monthKey }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExerciseVolumePoint: Identifiable, Equatable {
|
|
||||||
let dateKey: String
|
|
||||||
let volume: Double
|
|
||||||
var id: String { dateKey }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MonthlyReport: Codable, Equatable, Identifiable {
|
|
||||||
let monthKey: String // "yyyy-MM"
|
|
||||||
let generatedAt: Date
|
|
||||||
let summary: PeriodSummary
|
|
||||||
let vsPreviousMonth: [String: MetricDelta]
|
|
||||||
let classification: TrendClassification
|
|
||||||
let disclaimer: String
|
|
||||||
var id: String { monthKey }
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// App-side glue for the analytics system. Owns the file store, builds the
|
|
||||||
// reconcile coordinator from a snapshot of live workout data, and exposes read
|
|
||||||
// APIs for the Analytics UI. Pure engine/store/coordinator/adapter are unit-
|
|
||||||
// tested; this layer is thin wiring (compiles in-target; not standalone-tested).
|
|
||||||
//
|
|
||||||
// Sample building is done by nonisolated static functions over a captured
|
|
||||||
// `WorkoutSnapshot` value, so the coordinator's closures don't touch main-actor
|
|
||||||
// state. HealthKit per-day historical reference is optional and currently left
|
|
||||||
// nil (honest — never fabricated); wiring it is a documented follow-up.
|
|
||||||
@MainActor
|
|
||||||
final class AnalyticsService: ObservableObject {
|
|
||||||
static let shared = AnalyticsService()
|
|
||||||
|
|
||||||
let store: AnalyticsStore
|
|
||||||
private let calendar: Calendar
|
|
||||||
|
|
||||||
@Published private(set) var lastReconcile: Date?
|
|
||||||
/// Snapshot from the most recent reconcile, for UI reads (daily comparison,
|
|
||||||
/// per-exercise history) without passing the view model around.
|
|
||||||
private var snapshot = WorkoutSnapshot()
|
|
||||||
|
|
||||||
init(store: AnalyticsStore = AnalyticsStore(), calendar: Calendar = .current) {
|
|
||||||
self.store = store
|
|
||||||
self.calendar = calendar
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Snapshot of live workout data (a value type)
|
|
||||||
|
|
||||||
struct WorkoutSnapshot {
|
|
||||||
var logByDate: [String: WorkoutDayLog] = [:]
|
|
||||||
var workoutDates: Set<String> = []
|
|
||||||
var scheduledWeekdays: Set<Int> = []
|
|
||||||
var missedDates: Set<String> = []
|
|
||||||
var healthByDate: [String: RawHealthDay] = [:] // HealthKit reference
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
@MainActor init(vm: WorkoutViewModel) {
|
|
||||||
self.logByDate = Dictionary(vm.history.map { ($0.date, $0) }, uniquingKeysWith: { a, _ in a })
|
|
||||||
self.workoutDates = Set(vm.workoutDates)
|
|
||||||
self.scheduledWeekdays = Set(vm.schedule.keys)
|
|
||||||
self.missedDates = Set(vm.missedDates)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cached HealthKit per-day reference (refreshed async). Merged into snapshots.
|
|
||||||
private var healthByDate: [String: RawHealthDay] = [:]
|
|
||||||
|
|
||||||
// MARK: - Reconcile (call on launch + scenePhase active)
|
|
||||||
|
|
||||||
/// Snapshot recent days, backfill missing completed weekly/monthly reports,
|
|
||||||
/// prune. Idempotent — safe to call every launch/foreground.
|
|
||||||
@discardableResult
|
|
||||||
func reconcile(using vm: WorkoutViewModel, now: Date = Date()) -> AnalyticsCoordinator.ReconcileResult {
|
|
||||||
var snap = WorkoutSnapshot(vm: vm)
|
|
||||||
snap.healthByDate = healthByDate
|
|
||||||
snapshot = snap
|
|
||||||
freezeRecentSnapshots(snap, now: now)
|
|
||||||
let result = makeCoordinator(snap, now: now).reconcile()
|
|
||||||
lastReconcile = now
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh the HealthKit per-day reference cache (~13 months). Call from the
|
|
||||||
/// app's async Health sync path, then reconcile so new snapshots capture it.
|
|
||||||
func refreshHealthReference() async {
|
|
||||||
guard let start = calendar.date(byAdding: .month, value: -13, to: Date()) else { return }
|
|
||||||
let raw = await HealthKitManager.shared.dailyReference(from: start, to: Date())
|
|
||||||
healthByDate = raw.mapValues { RawHealthDay(steps: $0.steps, activeCalories: $0.calories, restingHeartRate: $0.restingHR) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeCoordinator(_ snap: WorkoutSnapshot, now: Date) -> AnalyticsCoordinator {
|
|
||||||
let cal = calendar
|
|
||||||
return AnalyticsCoordinator(
|
|
||||||
store: store, calendar: cal, retentionMonths: 12, now: { now },
|
|
||||||
weekSamples: { key in
|
|
||||||
let start = Self.date(fromKey: key, calendar: cal) ?? now
|
|
||||||
let prevStart = cal.date(byAdding: .weekOfYear, value: -1, to: start) ?? start
|
|
||||||
return (Self.buildSamples(WorkoutAnalyticsAdapter.weekDayKeys(containing: start, calendar: cal), snap, cal),
|
|
||||||
Self.buildSamples(WorkoutAnalyticsAdapter.weekDayKeys(containing: prevStart, calendar: cal), snap, cal))
|
|
||||||
},
|
|
||||||
monthSamples: { key in
|
|
||||||
guard let start = Self.date(fromMonthKey: key, calendar: cal) else { return ([], []) }
|
|
||||||
let prevStart = cal.date(byAdding: .month, value: -1, to: start) ?? start
|
|
||||||
return (Self.buildSamples(WorkoutAnalyticsAdapter.monthDayKeys(containing: start, calendar: cal), snap, cal),
|
|
||||||
Self.buildSamples(WorkoutAnalyticsAdapter.monthDayKeys(containing: prevStart, calendar: cal), snap, cal))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Freeze immutable snapshots for completed recent days; refresh today's live.
|
|
||||||
private func freezeRecentSnapshots(_ snap: WorkoutSnapshot, now: Date) {
|
|
||||||
let today = calendar.startOfDay(for: now)
|
|
||||||
for back in 0...8 {
|
|
||||||
guard let d = calendar.date(byAdding: .day, value: -back, to: today) else { continue }
|
|
||||||
let key = AnalyticsEngine.dateKey(for: d, calendar: calendar)
|
|
||||||
store.saveSnapshot(Self.buildSamples([key], snap, calendar).first ?? DaySample(dateKey: key), isPast: back > 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sample building (nonisolated, pure over the snapshot value)
|
|
||||||
|
|
||||||
nonisolated static func buildSamples(_ keys: [String], _ snap: WorkoutSnapshot, _ cal: Calendar) -> [DaySample] {
|
|
||||||
var workouts: [String: RawWorkoutDay] = [:]
|
|
||||||
var scheduled: Set<String> = []
|
|
||||||
for key in keys {
|
|
||||||
if let d = date(fromKey: key, calendar: cal), snap.scheduledWeekdays.contains(cal.component(.weekday, from: d)) {
|
|
||||||
scheduled.insert(key)
|
|
||||||
}
|
|
||||||
if let log = snap.logByDate[key] {
|
|
||||||
workouts[key] = raw(from: log, done: snap.workoutDates.contains(key))
|
|
||||||
} else if snap.workoutDates.contains(key) {
|
|
||||||
workouts[key] = RawWorkoutDay(dateKey: key, didWorkout: true, sessions: 1, durationMinutes: 0, exercises: [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return WorkoutAnalyticsAdapter.daySamples(dayKeys: keys, workouts: workouts,
|
|
||||||
scheduledKeys: scheduled, health: snap.healthByDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated private static func raw(from log: WorkoutDayLog, done: Bool) -> RawWorkoutDay {
|
|
||||||
let samples = log.exercises.map { ex -> ExerciseSample in
|
|
||||||
let completed = ex.sets.filter { $0.done }.map { (weight: $0.weight, reps: $0.reps) }
|
|
||||||
return WorkoutAnalyticsAdapter.exerciseSample(name: ex.name, completedSets: completed)
|
|
||||||
}
|
|
||||||
return RawWorkoutDay(dateKey: log.date, didWorkout: done || !samples.isEmpty,
|
|
||||||
sessions: 1, durationMinutes: 0, exercises: samples)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Read APIs for the UI
|
|
||||||
|
|
||||||
func weeklyReports() -> [WeeklyReport] { store.allWeeklyReports().sorted { $0.weekStartKey > $1.weekStartKey } }
|
|
||||||
func monthlyReports() -> [MonthlyReport] { store.allMonthlyReports().sorted { $0.monthKey > $1.monthKey } }
|
|
||||||
|
|
||||||
func monthlyVolumeTrend() -> [MonthVolumePoint] {
|
|
||||||
store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey }
|
|
||||||
.map { MonthVolumePoint(monthKey: $0.monthKey, volume: $0.summary.volume) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func monthlyTrendClassification() -> TrendResult {
|
|
||||||
AnalyticsEngine.classifyTrend(monthlyVolumeTrend().map(\.volume))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Daily comparison for `date`: vs previous day and vs same weekday last week.
|
|
||||||
func dailyComparison(on date: Date) -> DailyComparison {
|
|
||||||
let dayKey = AnalyticsEngine.dateKey(for: date, calendar: calendar)
|
|
||||||
let prev = calendar.date(byAdding: .day, value: -1, to: date)
|
|
||||||
let lastWeek = AnalyticsEngine.sameWeekdayLastWeek(date, calendar: calendar)
|
|
||||||
return AnalyticsEngine.dailyComparison(
|
|
||||||
day: daySample(forKey: dayKey),
|
|
||||||
previousDay: prev.map { daySample(forKey: AnalyticsEngine.dateKey(for: $0, calendar: calendar)) },
|
|
||||||
sameWeekdayLastWeek: lastWeek.map { daySample(forKey: AnalyticsEngine.dateKey(for: $0, calendar: calendar)) })
|
|
||||||
}
|
|
||||||
|
|
||||||
private func daySample(forKey key: String) -> DaySample {
|
|
||||||
Self.buildSamples([key], snapshot, calendar).first ?? DaySample(dateKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-exercise volume history (dateKey→volume) across stored snapshots.
|
|
||||||
func exerciseHistory(identity: String) -> [ExerciseVolumePoint] {
|
|
||||||
store.allSnapshotKeys().sorted().compactMap { key in
|
|
||||||
guard let sample = store.snapshot(dateKey: key),
|
|
||||||
let ex = sample.exercises.first(where: { $0.identity == identity }) else { return nil }
|
|
||||||
return ExerciseVolumePoint(dateKey: key, volume: ex.volume)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Distinct exercise identities across stored snapshots (for the picker).
|
|
||||||
func knownExerciseIdentities() -> [String] {
|
|
||||||
var set = Set<String>()
|
|
||||||
for key in store.allSnapshotKeys() {
|
|
||||||
store.snapshot(dateKey: key)?.exercises.forEach { set.insert($0.identity) }
|
|
||||||
}
|
|
||||||
return set.sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Key helpers (nonisolated, pure)
|
|
||||||
|
|
||||||
nonisolated private static func date(fromKey key: String, calendar: Calendar) -> Date? {
|
|
||||||
formatter("yyyy-MM-dd", calendar).date(from: key)
|
|
||||||
}
|
|
||||||
nonisolated private static func date(fromMonthKey key: String, calendar: Calendar) -> Date? {
|
|
||||||
formatter("yyyy-MM", calendar).date(from: key)
|
|
||||||
}
|
|
||||||
nonisolated private static func formatter(_ format: String, _ calendar: Calendar) -> DateFormatter {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.calendar = calendar; f.timeZone = calendar.timeZone
|
|
||||||
f.locale = Locale(identifier: "en_US_POSIX"); f.dateFormat = format
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// File-based JSON persistence for the analytics system (per KC-51 decision:
|
|
||||||
// file store over SwiftData/Core Data — iOS 16-safe, unbounded local disk,
|
|
||||||
// lowest migration risk, easily reversible).
|
|
||||||
//
|
|
||||||
// Layout (under Application Support/Analytics):
|
|
||||||
// snapshots/<yyyy-MM-dd>.json immutable frozen DaySample per past day
|
|
||||||
// reports/weekly/<yyyy-MM-dd>.json
|
|
||||||
// reports/monthly/<yyyy-MM>.json
|
|
||||||
//
|
|
||||||
// Guarantees:
|
|
||||||
// - Immutability: a past day's snapshot and any report, once written, are never
|
|
||||||
// overwritten (`writeIfAbsent`). Later edits to source data cannot change a
|
|
||||||
// generated report.
|
|
||||||
// - Idempotency + dedup: files are keyed by period; re-saving an existing key is
|
|
||||||
// a no-op, so generation never duplicates.
|
|
||||||
// - Retention: `prune` removes snapshots/reports older than the window.
|
|
||||||
// - Nothing sensitive beyond what's needed is written (no raw HealthKit records,
|
|
||||||
// only the derived reference values already in DaySample).
|
|
||||||
final class AnalyticsStore {
|
|
||||||
|
|
||||||
private let root: URL
|
|
||||||
private let fm: FileManager
|
|
||||||
private let encoder: JSONEncoder
|
|
||||||
private let decoder: JSONDecoder
|
|
||||||
|
|
||||||
private var snapshotsDir: URL { root.appendingPathComponent("snapshots", isDirectory: true) }
|
|
||||||
private var weeklyDir: URL { root.appendingPathComponent("reports/weekly", isDirectory: true) }
|
|
||||||
private var monthlyDir: URL { root.appendingPathComponent("reports/monthly", isDirectory: true) }
|
|
||||||
|
|
||||||
init(root: URL, fileManager: FileManager = .default) {
|
|
||||||
self.root = root
|
|
||||||
self.fm = fileManager
|
|
||||||
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601; enc.outputFormatting = [.sortedKeys]
|
|
||||||
self.encoder = enc
|
|
||||||
let dec = JSONDecoder(); dec.dateDecodingStrategy = .iso8601
|
|
||||||
self.decoder = dec
|
|
||||||
ensureDirectories()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default location under Application Support.
|
|
||||||
convenience init() {
|
|
||||||
let base = (try? FileManager.default.url(for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask, appropriateFor: nil, create: true))
|
|
||||||
?? URL(fileURLWithPath: NSTemporaryDirectory())
|
|
||||||
self.init(root: base.appendingPathComponent("Analytics", isDirectory: true))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ensureDirectories() {
|
|
||||||
for dir in [snapshotsDir, weeklyDir, monthlyDir] {
|
|
||||||
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Snapshots (immutable once the day is in the past)
|
|
||||||
|
|
||||||
/// Persist a day snapshot. A past day (`isPast == true`) is frozen: if a file
|
|
||||||
/// already exists it is kept unchanged. Today/future may be updated in place.
|
|
||||||
@discardableResult
|
|
||||||
func saveSnapshot(_ day: DaySample, isPast: Bool) -> Bool {
|
|
||||||
let url = snapshotsDir.appendingPathComponent("\(day.dateKey).json")
|
|
||||||
if isPast && fm.fileExists(atPath: url.path) { return false } // frozen
|
|
||||||
return write(day, to: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshot(dateKey: String) -> DaySample? {
|
|
||||||
read(DaySample.self, from: snapshotsDir.appendingPathComponent("\(dateKey).json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func allSnapshotKeys() -> [String] {
|
|
||||||
listKeys(in: snapshotsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Weekly / monthly reports (immutable, idempotent)
|
|
||||||
|
|
||||||
/// Save a weekly report only if absent (idempotent, never overwritten).
|
|
||||||
/// Returns true if newly written, false if it already existed.
|
|
||||||
@discardableResult
|
|
||||||
func saveWeeklyReport(_ report: WeeklyReport) -> Bool {
|
|
||||||
writeIfAbsent(report, to: weeklyDir.appendingPathComponent("\(report.weekStartKey).json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func saveMonthlyReport(_ report: MonthlyReport) -> Bool {
|
|
||||||
writeIfAbsent(report, to: monthlyDir.appendingPathComponent("\(report.monthKey).json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func weeklyReport(weekStartKey: String) -> WeeklyReport? {
|
|
||||||
read(WeeklyReport.self, from: weeklyDir.appendingPathComponent("\(weekStartKey).json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func monthlyReport(monthKey: String) -> MonthlyReport? {
|
|
||||||
read(MonthlyReport.self, from: monthlyDir.appendingPathComponent("\(monthKey).json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func allWeeklyReports() -> [WeeklyReport] {
|
|
||||||
listKeys(in: weeklyDir).compactMap { weeklyReport(weekStartKey: $0) }
|
|
||||||
.sorted { $0.weekStartKey < $1.weekStartKey }
|
|
||||||
}
|
|
||||||
|
|
||||||
func allMonthlyReports() -> [MonthlyReport] {
|
|
||||||
listKeys(in: monthlyDir).compactMap { monthlyReport(monthKey: $0) }
|
|
||||||
.sorted { $0.monthKey < $1.monthKey }
|
|
||||||
}
|
|
||||||
|
|
||||||
func existingWeeklyKeys() -> Set<String> { Set(listKeys(in: weeklyDir)) }
|
|
||||||
func existingMonthlyKeys() -> Set<String> { Set(listKeys(in: monthlyDir)) }
|
|
||||||
|
|
||||||
// MARK: - Retention
|
|
||||||
|
|
||||||
/// Remove day snapshots (and weekly reports) whose key is older than the
|
|
||||||
/// retention cutoff. Monthly reports use the "yyyy-MM" cutoff. Returns the
|
|
||||||
/// number of files removed.
|
|
||||||
@discardableResult
|
|
||||||
func prune(now: Date, months: Int = 12, calendar: Calendar) -> Int {
|
|
||||||
var removed = 0
|
|
||||||
guard let cutoffDate = calendar.date(byAdding: .month, value: -months, to: calendar.startOfDay(for: now)) else { return 0 }
|
|
||||||
let dayCutoff = AnalyticsEngine.dateKey(for: cutoffDate, calendar: calendar)
|
|
||||||
let monthCutoff = AnalyticsEngine.monthKey(for: cutoffDate, calendar: calendar)
|
|
||||||
|
|
||||||
for key in listKeys(in: snapshotsDir) where key < dayCutoff {
|
|
||||||
if remove(snapshotsDir.appendingPathComponent("\(key).json")) { removed += 1 }
|
|
||||||
}
|
|
||||||
for key in listKeys(in: weeklyDir) where key < dayCutoff {
|
|
||||||
if remove(weeklyDir.appendingPathComponent("\(key).json")) { removed += 1 }
|
|
||||||
}
|
|
||||||
for key in listKeys(in: monthlyDir) where key < monthCutoff {
|
|
||||||
if remove(monthlyDir.appendingPathComponent("\(key).json")) { removed += 1 }
|
|
||||||
}
|
|
||||||
return removed
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Low-level IO
|
|
||||||
|
|
||||||
private func listKeys(in dir: URL) -> [String] {
|
|
||||||
guard let items = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return [] }
|
|
||||||
return items.filter { $0.pathExtension == "json" }
|
|
||||||
.map { $0.deletingPathExtension().lastPathComponent }
|
|
||||||
.sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func write<T: Encodable>(_ value: T, to url: URL) -> Bool {
|
|
||||||
guard let data = try? encoder.encode(value) else { return false }
|
|
||||||
do { try data.write(to: url, options: .atomic); return true } catch { return false }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func writeIfAbsent<T: Encodable>(_ value: T, to url: URL) -> Bool {
|
|
||||||
if fm.fileExists(atPath: url.path) { return false }
|
|
||||||
return write(value, to: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func read<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
|
|
||||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
||||||
return try? decoder.decode(type, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func remove(_ url: URL) -> Bool {
|
|
||||||
(try? fm.removeItem(at: url)) != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// Pure bridge from app workout data (+ HealthKit reference) into the engine's
|
|
||||||
// `DaySample` inputs. Kept free of app-model / HealthKit types so it stays
|
|
||||||
// deterministic and unit-testable; the app maps `WorkoutDayLog` → `RawWorkoutDay`
|
|
||||||
// and supplies HealthKit reference values at the call site.
|
|
||||||
|
|
||||||
/// One day's app-generated workout, already reduced to analysis primitives.
|
|
||||||
/// The app builds this from `WorkoutDayLog` (volume = Σ weight×reps of done sets).
|
|
||||||
struct RawWorkoutDay: Equatable {
|
|
||||||
let dateKey: String
|
|
||||||
let didWorkout: Bool
|
|
||||||
let sessions: Int
|
|
||||||
let durationMinutes: Double
|
|
||||||
let exercises: [ExerciseSample]
|
|
||||||
|
|
||||||
var sets: Int { exercises.reduce(0) { $0 + $1.sets } }
|
|
||||||
var volume: Double { exercises.reduce(0) { $0 + $1.volume } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HealthKit reference values for a day (source of truth for health measurements).
|
|
||||||
/// All optional — authorization or data may be missing.
|
|
||||||
struct RawHealthDay: Equatable {
|
|
||||||
let steps: Int?
|
|
||||||
let activeCalories: Int?
|
|
||||||
let restingHeartRate: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum WorkoutAnalyticsAdapter {
|
|
||||||
|
|
||||||
/// Build ordered `DaySample`s for `dayKeys`. A day is:
|
|
||||||
/// - `didWorkout` if it has a workout record with `didWorkout`,
|
|
||||||
/// - `scheduled` if its key is in `scheduledKeys`,
|
|
||||||
/// - enriched with HealthKit reference values where present.
|
|
||||||
/// Missing days become rest/empty samples (honest — no fabricated data).
|
|
||||||
static func daySamples(dayKeys: [String],
|
|
||||||
workouts: [String: RawWorkoutDay],
|
|
||||||
scheduledKeys: Set<String>,
|
|
||||||
health: [String: RawHealthDay] = [:]) -> [DaySample] {
|
|
||||||
dayKeys.map { key in
|
|
||||||
let w = workouts[key]
|
|
||||||
let h = health[key]
|
|
||||||
return DaySample(
|
|
||||||
dateKey: key,
|
|
||||||
didWorkout: w?.didWorkout ?? false,
|
|
||||||
scheduled: scheduledKeys.contains(key),
|
|
||||||
sessions: w?.sessions ?? 0,
|
|
||||||
sets: w?.sets ?? 0,
|
|
||||||
volume: w?.volume ?? 0,
|
|
||||||
durationMinutes: w?.durationMinutes ?? 0,
|
|
||||||
exercises: w?.exercises ?? [],
|
|
||||||
steps: h?.steps,
|
|
||||||
activeCalories: h?.activeCalories,
|
|
||||||
restingHeartRate: h?.restingHeartRate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inclusive list of day keys from `start` to `end`, in the given calendar.
|
|
||||||
static func dayKeys(from start: Date, to end: Date, calendar: Calendar) -> [String] {
|
|
||||||
var keys: [String] = []
|
|
||||||
var cursor = calendar.startOfDay(for: start)
|
|
||||||
let last = calendar.startOfDay(for: end)
|
|
||||||
var guardCount = 0
|
|
||||||
while cursor <= last, guardCount < 800 { // ~2yr ceiling
|
|
||||||
keys.append(AnalyticsEngine.dateKey(for: cursor, calendar: calendar))
|
|
||||||
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
|
|
||||||
cursor = next; guardCount += 1
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Day keys for the calendar week/month containing `date`.
|
|
||||||
static func weekDayKeys(containing date: Date, calendar: Calendar) -> [String] {
|
|
||||||
guard let w = AnalyticsEngine.weekInterval(containing: date, calendar: calendar) else { return [] }
|
|
||||||
return dayKeys(from: w.start, to: calendar.date(byAdding: .day, value: -1, to: w.end) ?? w.start, calendar: calendar)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func monthDayKeys(containing date: Date, calendar: Calendar) -> [String] {
|
|
||||||
guard let m = AnalyticsEngine.monthInterval(containing: date, calendar: calendar) else { return [] }
|
|
||||||
return dayKeys(from: m.start, to: calendar.date(byAdding: .day, value: -1, to: m.end) ?? m.start, calendar: calendar)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reduce an exercise's completed sets to an `ExerciseSample` (app helper —
|
|
||||||
/// mirrors `WorkoutDayLog.volume`). Kept here so the identity + volume rule is
|
|
||||||
/// defined once and testable.
|
|
||||||
static func exerciseSample(name: String, completedSets: [(weight: Double, reps: Int)]) -> ExerciseSample {
|
|
||||||
let reps = completedSets.reduce(0) { $0 + $1.reps }
|
|
||||||
let volume = completedSets.reduce(0.0) { $0 + AnalyticsEngine.volume(weight: $1.weight, reps: $1.reps) }
|
|
||||||
return ExerciseSample(identity: AnalyticsEngine.exerciseIdentity(name),
|
|
||||||
sets: completedSets.count, reps: reps, volume: volume)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,25 +10,6 @@ struct PressButtonStyle: ButtonStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A quiet accent button: outlined/tinted at rest, fills solid only while
|
|
||||||
/// pressed. Color is a response to touch, not a permanent decoration —
|
|
||||||
/// matches "nothing shouts, color earns its place."
|
|
||||||
struct QuietAccentButtonStyle: ButtonStyle {
|
|
||||||
@Environment(\.colorScheme) private var cs
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
configuration.label
|
|
||||||
.foregroundColor(configuration.isPressed ? .white : AppColors.accent)
|
|
||||||
.background(configuration.isPressed ? AppColors.accent : AppColors.accentSoft)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 11))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 11)
|
|
||||||
.stroke(AppColors.accent.opacity(configuration.isPressed ? 0 : 0.35), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
|
||||||
.animation(KisaniSpring.micro, value: configuration.isPressed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FabButtonStyle: ButtonStyle {
|
struct FabButtonStyle: ButtonStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ struct ContentView: View {
|
|||||||
return 0
|
return 0
|
||||||
}()
|
}()
|
||||||
@State private var showQuickAddTask = false
|
@State private var showQuickAddTask = false
|
||||||
@State private var analyticsLink: AnalyticsDeepLink?
|
|
||||||
@StateObject private var tabState = FloatingTabState()
|
@StateObject private var tabState = FloatingTabState()
|
||||||
@ObservedObject private var shortcuts = ShortcutHandler.shared
|
@ObservedObject private var shortcuts = ShortcutHandler.shared
|
||||||
@ObservedObject private var tutorial = TutorialManager.shared
|
@ObservedObject private var tutorial = TutorialManager.shared
|
||||||
@@ -106,16 +105,10 @@ struct ContentView: View {
|
|||||||
HealthKitManager.shared.bootstrap()
|
HealthKitManager.shared.bootstrap()
|
||||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||||
consumeWidgetAddTask()
|
consumeWidgetAddTask()
|
||||||
// Backfill any missing analytics reports (idempotent), then notify.
|
|
||||||
let generated = AnalyticsService.shared.reconcile(using: workoutVM)
|
|
||||||
NotificationManager.shared.scheduleAnalyticsReports(generated)
|
|
||||||
if let link = NotificationManager.shared.consumePendingAnalyticsDeepLink() { analyticsLink = link }
|
|
||||||
// Pull workouts from Health, then reschedule so a detected workout updates reminders.
|
// Pull workouts from Health, then reschedule so a detected workout updates reminders.
|
||||||
Task {
|
Task {
|
||||||
await workoutVM.syncFromHealthKit()
|
await workoutVM.syncFromHealthKit()
|
||||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||||
await AnalyticsService.shared.refreshHealthReference()
|
|
||||||
AnalyticsService.shared.reconcile(using: workoutVM)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { phase in
|
||||||
@@ -127,8 +120,6 @@ struct ContentView: View {
|
|||||||
workoutVM.checkPendingMissed()
|
workoutVM.checkPendingMissed()
|
||||||
CloudSyncManager.shared.backupSettings()
|
CloudSyncManager.shared.backupSettings()
|
||||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||||
let generated = AnalyticsService.shared.reconcile(using: workoutVM)
|
|
||||||
NotificationManager.shared.scheduleAnalyticsReports(generated)
|
|
||||||
Task {
|
Task {
|
||||||
await workoutVM.syncFromHealthKit()
|
await workoutVM.syncFromHealthKit()
|
||||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||||
@@ -170,11 +161,6 @@ struct ContentView: View {
|
|||||||
.presentationDetents([.height(150)])
|
.presentationDetents([.height(150)])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
.sheet(item: $analyticsLink) { link in
|
|
||||||
AnalyticsView(initialLink: link)
|
|
||||||
.presentationDetents([.large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
}
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Test hook: auto-open Quick Add (used to verify the voice→field binding
|
// Test hook: auto-open Quick Add (used to verify the voice→field binding
|
||||||
|
|||||||
1445
KisaniCal/ISSUES.md
1445
KisaniCal/ISSUES.md
File diff suppressed because it is too large
Load Diff
@@ -84,17 +84,7 @@ final class CloudSyncManager {
|
|||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
for key in changedKeys {
|
for key in changedKeys {
|
||||||
guard let val = kv.object(forKey: key) else { continue }
|
if let val = kv.object(forKey: key) {
|
||||||
if key.hasPrefix("kisani.workout.completedDates."), let incoming = val as? [String] {
|
|
||||||
// Workout dates are an append-only lifetime tally, and the Watch/
|
|
||||||
// HealthKit sync writes here independently of this device — a
|
|
||||||
// blind overwrite can race a fresh local write with a stale
|
|
||||||
// iCloud snapshot and silently drop already-logged days. Union
|
|
||||||
// instead, so an external change can only ever add days, never
|
|
||||||
// erase ones this device already knows about.
|
|
||||||
let existing = Set(UserDefaults.kisani.stringArray(forKey: key) ?? [])
|
|
||||||
UserDefaults.kisani.set(Array(existing.union(incoming)), forKey: key)
|
|
||||||
} else {
|
|
||||||
UserDefaults.kisani.set(val, forKey: key)
|
UserDefaults.kisani.set(val, forKey: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,51 +160,6 @@ final class HealthKitManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Historical daily reference (for analytics)
|
|
||||||
|
|
||||||
/// Per-day steps / active calories / resting heart rate over a range, keyed by
|
|
||||||
/// "yyyy-MM-dd". One bucketed statistics query per metric (efficient over a
|
|
||||||
/// year). HealthKit is the source of truth; values are read-only reference.
|
|
||||||
func dailyReference(from start: Date, to end: Date) async -> [String: (steps: Int?, calories: Int?, restingHR: Int?)] {
|
|
||||||
guard authorized, isAvailable else { return [:] }
|
|
||||||
let anchor = Calendar.current.startOfDay(for: start)
|
|
||||||
let interval = DateComponents(day: 1)
|
|
||||||
|
|
||||||
async let steps = statsCollection(.stepCount, unit: .count(), options: .cumulativeSum, anchor: anchor, end: end, interval: interval)
|
|
||||||
async let cals = statsCollection(.activeEnergyBurned, unit: .kilocalorie(), options: .cumulativeSum, anchor: anchor, end: end, interval: interval)
|
|
||||||
async let hr = statsCollection(.restingHeartRate, unit: HKUnit(from: "count/min"), options: .discreteAverage, anchor: anchor, end: end, interval: interval)
|
|
||||||
let (s, c, h) = await (steps, cals, hr)
|
|
||||||
|
|
||||||
var out: [String: (steps: Int?, calories: Int?, restingHR: Int?)] = [:]
|
|
||||||
for key in Set(s.keys).union(c.keys).union(h.keys) {
|
|
||||||
out[key] = (s[key].map(Int.init), c[key].map(Int.init), h[key].map(Int.init))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
private func statsCollection(_ id: HKQuantityTypeIdentifier, unit: HKUnit, options: HKStatisticsOptions,
|
|
||||||
anchor: Date, end: Date, interval: DateComponents) async -> [String: Double] {
|
|
||||||
guard let type = HKQuantityType.quantityType(forIdentifier: id) else { return [:] }
|
|
||||||
return await withCheckedContinuation { cont in
|
|
||||||
let q = HKStatisticsCollectionQuery(quantityType: type, quantitySamplePredicate: nil,
|
|
||||||
options: options, anchorDate: anchor, intervalComponents: interval)
|
|
||||||
q.initialResultsHandler = { _, results, _ in
|
|
||||||
var dict: [String: Double] = [:]
|
|
||||||
let fmt = DateFormatter()
|
|
||||||
fmt.calendar = Calendar.current; fmt.timeZone = Calendar.current.timeZone
|
|
||||||
fmt.locale = Locale(identifier: "en_US_POSIX"); fmt.dateFormat = "yyyy-MM-dd"
|
|
||||||
results?.enumerateStatistics(from: anchor, to: end) { stat, _ in
|
|
||||||
let quantity = options.contains(.cumulativeSum) ? stat.sumQuantity() : stat.averageQuantity()
|
|
||||||
if let v = quantity?.doubleValue(for: unit), v > 0 {
|
|
||||||
dict[fmt.string(from: stat.startDate)] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cont.resume(returning: dict)
|
|
||||||
}
|
|
||||||
store.execute(q)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Type sets
|
// MARK: - Type sets
|
||||||
|
|
||||||
private var readTypes: Set<HKObjectType> {
|
private var readTypes: Set<HKObjectType> {
|
||||||
|
|||||||
@@ -61,14 +61,7 @@ enum LiveActivityManager {
|
|||||||
Task {
|
Task {
|
||||||
for activity in Activity<TaskActivityAttributes>.activities
|
for activity in Activity<TaskActivityAttributes>.activities
|
||||||
where activity.attributes.taskID == taskID {
|
where activity.attributes.taskID == taskID {
|
||||||
// end(_:dismissalPolicy:) needs iOS 16.2; this file supports
|
await activity.end(dismissalPolicy: .immediate)
|
||||||
// 16.1 (Live Activities' own minimum), so fall back to the
|
|
||||||
// deprecated using:dismissalPolicy: overload below that.
|
|
||||||
if #available(iOS 16.2, *) {
|
|
||||||
await activity.end(nil, dismissalPolicy: .immediate)
|
|
||||||
} else {
|
|
||||||
await activity.end(using: nil, dismissalPolicy: .immediate)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
private static let logWorkoutActId = "LOG_WORKOUT"
|
private static let logWorkoutActId = "LOG_WORKOUT"
|
||||||
private static let missedWorkoutActId = "MISSED_WORKOUT"
|
private static let missedWorkoutActId = "MISSED_WORKOUT"
|
||||||
private static let snoozeWorkoutActId = "SNOOZE_WORKOUT"
|
private static let snoozeWorkoutActId = "SNOOZE_WORKOUT"
|
||||||
private static let habitCatId = "HABIT_LOG"
|
|
||||||
private static let habitDoneActId = "HABIT_DONE"
|
|
||||||
private static let habitNotTodayActId = "HABIT_NOT_TODAY"
|
|
||||||
private static let habitStopTrackingActId = "HABIT_STOP_TRACKING"
|
|
||||||
|
|
||||||
override private init() {
|
override private init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -53,15 +49,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
actions: [logAction, missedAction, snoozeAction],
|
actions: [logAction, missedAction, snoozeAction],
|
||||||
intentIdentifiers: [], options: .customDismissAction)
|
intentIdentifiers: [], options: .customDismissAction)
|
||||||
|
|
||||||
let habitDoneAction = UNNotificationAction(identifier: Self.habitDoneActId, title: "Done", options: [])
|
center.setNotificationCategories([taskCat, confirmCat])
|
||||||
let habitNotTodayAction = UNNotificationAction(identifier: Self.habitNotTodayActId, title: "Not Today", options: [])
|
|
||||||
let habitStopAction = UNNotificationAction(identifier: Self.habitStopTrackingActId, title: "Stop Tracking",
|
|
||||||
options: .destructive)
|
|
||||||
let habitCat = UNNotificationCategory(identifier: Self.habitCatId,
|
|
||||||
actions: [habitDoneAction, habitNotTodayAction, habitStopAction],
|
|
||||||
intentIdentifiers: [], options: .customDismissAction)
|
|
||||||
|
|
||||||
center.setNotificationCategories([taskCat, confirmCat, habitCat])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Complete task directly in UserDefaults (called from background)
|
// MARK: - Complete task directly in UserDefaults (called from background)
|
||||||
@@ -150,7 +138,6 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
self.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
|
self.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
|
||||||
self.scheduleCalendarEvents(snapshot: calEvents)
|
self.scheduleCalendarEvents(snapshot: calEvents)
|
||||||
self.schedulePeriodMilestones()
|
self.schedulePeriodMilestones()
|
||||||
self.scheduleHabitReminders()
|
|
||||||
self.updateBadge(snapshot: tasks)
|
self.updateBadge(snapshot: tasks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,198 +180,6 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
add(Self.periodIds[3], "This Year", "One more year passed.", year)
|
add(Self.periodIds[3], "This Year", "One more year passed.", year)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Habit reminders (prayer, bed-making, coffee/caffeine)
|
|
||||||
|
|
||||||
private static let habitPrefix = "kisani.habit"
|
|
||||||
|
|
||||||
/// Named slot definitions shared by Prayer and Coffee — (storage key
|
|
||||||
/// fragment, default hour, default minute, default enabled-when-master-on).
|
|
||||||
private static let prayerSlots: [(key: String, hour: Int, minute: Int, defaultOn: Bool)] = [
|
|
||||||
("Morning", 7, 0, true), ("Afternoon", 13, 0, true), ("Evening", 18, 0, true), ("Night", 21, 0, true),
|
|
||||||
]
|
|
||||||
private static let coffeeSlots: [(key: String, hour: Int, minute: Int, defaultOn: Bool)] = [
|
|
||||||
("Morning", 8, 0, true), ("Midday", 12, 0, false), ("Afternoon", 15, 0, false), ("Evening", 18, 0, false),
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Daily (every day, not weekday-scoped) habit nudges. Reads from
|
|
||||||
/// `UserDefaults.kisani` — the App Group suite — so it must match wherever
|
|
||||||
/// the Settings/Onboarding toggles actually write (see the `store:` param
|
|
||||||
/// on those `@AppStorage` declarations).
|
|
||||||
private func scheduleHabitReminders() {
|
|
||||||
let ud = UserDefaults.kisani
|
|
||||||
var wanted = Set<String>()
|
|
||||||
|
|
||||||
// `habitType` drives the "Done" action's log bucket ("bed"/"prayer"/
|
|
||||||
// "coffee") — tapping Done on any slot of a type logs one entry for
|
|
||||||
// that type today (deduped), feeding a simple lifetime "days done"
|
|
||||||
// count. Never touches the task list or Activity History.
|
|
||||||
func addDaily(id: String, enabled: Bool, hour: Int, minute: Int, title: String, body: String, habitType: String) {
|
|
||||||
guard enabled else { return }
|
|
||||||
wanted.insert(id)
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = title; content.body = body; content.sound = .default
|
|
||||||
content.categoryIdentifier = Self.habitCatId
|
|
||||||
content.userInfo = ["habitType": habitType]
|
|
||||||
var comps = DateComponents(); comps.hour = hour; comps.minute = minute
|
|
||||||
center.add(UNNotificationRequest(
|
|
||||||
identifier: id, content: content,
|
|
||||||
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bed-making — a single daily nudge, the simplest possible "start the
|
|
||||||
// day with a win."
|
|
||||||
addDaily(
|
|
||||||
id: "\(Self.habitPrefix).bed",
|
|
||||||
enabled: ud.object(forKey: "bedReminderEnabled") as? Bool ?? false,
|
|
||||||
hour: ud.object(forKey: "bedReminderHour") as? Int ?? 8,
|
|
||||||
minute: ud.object(forKey: "bedReminderMinute") as? Int ?? 0,
|
|
||||||
title: "Rise and shine",
|
|
||||||
body: "Have you made your bed? Small win, great start to the day.",
|
|
||||||
habitType: "bed"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Prayer — up to 4 independently-toggleable times a day.
|
|
||||||
let prayerOn = ud.object(forKey: "prayerReminderEnabled") as? Bool ?? false
|
|
||||||
for slot in Self.prayerSlots {
|
|
||||||
addDaily(
|
|
||||||
id: "\(Self.habitPrefix).prayer.\(slot.key)",
|
|
||||||
enabled: prayerOn && (ud.object(forKey: "prayer\(slot.key)Enabled") as? Bool ?? slot.defaultOn),
|
|
||||||
hour: ud.object(forKey: "prayer\(slot.key)Hour") as? Int ?? slot.hour,
|
|
||||||
minute: ud.object(forKey: "prayer\(slot.key)Minute") as? Int ?? slot.minute,
|
|
||||||
title: "A moment to pause",
|
|
||||||
body: "Have you had a chance to pray and give thanks?",
|
|
||||||
habitType: "prayer"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coffee / caffeine — up to 4 named times plus one custom reminder.
|
|
||||||
let coffeeOn = ud.object(forKey: "coffeeReminderEnabled") as? Bool ?? false
|
|
||||||
for slot in Self.coffeeSlots {
|
|
||||||
addDaily(
|
|
||||||
id: "\(Self.habitPrefix).coffee.\(slot.key)",
|
|
||||||
enabled: coffeeOn && (ud.object(forKey: "coffee\(slot.key)Enabled") as? Bool ?? slot.defaultOn),
|
|
||||||
hour: ud.object(forKey: "coffee\(slot.key)Hour") as? Int ?? slot.hour,
|
|
||||||
minute: ud.object(forKey: "coffee\(slot.key)Minute") as? Int ?? slot.minute,
|
|
||||||
title: "\(slot.key) coffee",
|
|
||||||
body: "Time for your \(slot.key.lowercased()) coffee?",
|
|
||||||
habitType: "coffee"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Empty until the user names it (the field's placeholder guides that) —
|
|
||||||
// fall back to a sensible default rather than an empty notification title.
|
|
||||||
let rawCustomLabel = ud.string(forKey: "coffeeCustomLabel") ?? ""
|
|
||||||
let customLabel = rawCustomLabel.isEmpty ? "Coffee" : rawCustomLabel
|
|
||||||
addDaily(
|
|
||||||
id: "\(Self.habitPrefix).coffee.custom",
|
|
||||||
enabled: coffeeOn && (ud.object(forKey: "coffeeCustomEnabled") as? Bool ?? false),
|
|
||||||
hour: ud.object(forKey: "coffeeCustomHour") as? Int ?? 16,
|
|
||||||
minute: ud.object(forKey: "coffeeCustomMinute") as? Int ?? 30,
|
|
||||||
title: customLabel,
|
|
||||||
body: "Time for \(customLabel.lowercased())?",
|
|
||||||
habitType: "coffee"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cancel anything previously scheduled that isn't wanted this pass
|
|
||||||
// (toggled off, or a slot that's no longer enabled). `add` with an
|
|
||||||
// existing identifier already replaces in place for the ones we kept.
|
|
||||||
let allPossibleIds = Set(
|
|
||||||
["\(Self.habitPrefix).bed", "\(Self.habitPrefix).coffee.custom"]
|
|
||||||
+ Self.prayerSlots.map { "\(Self.habitPrefix).prayer.\($0.key)" }
|
|
||||||
+ Self.coffeeSlots.map { "\(Self.habitPrefix).coffee.\($0.key)" }
|
|
||||||
)
|
|
||||||
center.removePendingNotificationRequests(withIdentifiers: Array(allPossibleIds.subtracting(wanted)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Habit "Done" logging (private, lightweight — no task-list footprint)
|
|
||||||
|
|
||||||
private static let habitLogKeyPrefix = "kisani.habit.doneDates."
|
|
||||||
|
|
||||||
/// Log today as done for a habit type ("bed"/"prayer"/"coffee"), once per
|
|
||||||
/// day (idempotent — tapping Done twice doesn't double-count). Mirrors the
|
|
||||||
/// app's "lifetime tally, never breaks" streak philosophy (see workout
|
|
||||||
/// streakDays) rather than a fragile consecutive-day streak.
|
|
||||||
private func logHabitDone(type: String) {
|
|
||||||
let ud = UserDefaults.kisani
|
|
||||||
let key = Self.habitLogKeyPrefix + type
|
|
||||||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
|
||||||
let today = fmt.string(from: Date())
|
|
||||||
var dates = Set(ud.stringArray(forKey: key) ?? [])
|
|
||||||
guard !dates.contains(today) else { return }
|
|
||||||
dates.insert(today)
|
|
||||||
ud.set(Array(dates), forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Turn off future reminders for a habit type (from its notification's
|
|
||||||
/// "Stop Tracking" action) — flips the master toggle so Settings reflects
|
|
||||||
/// it too, and immediately cancels every pending notification for that
|
|
||||||
/// type (all its slots), not just the one just tapped.
|
|
||||||
private func stopTrackingHabit(type: String) {
|
|
||||||
UserDefaults.kisani.set(false, forKey: "\(type)ReminderEnabled")
|
|
||||||
let prefix = "\(Self.habitPrefix).\(type)"
|
|
||||||
center.getPendingNotificationRequests { [weak self] requests in
|
|
||||||
let ids = requests.map(\.identifier).filter { $0.hasPrefix(prefix) }
|
|
||||||
self?.center.removePendingNotificationRequests(withIdentifiers: ids)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total distinct days logged done for a habit type — for a small "X days"
|
|
||||||
/// badge in Settings. Never surfaced in Today/Matrix/Activity History.
|
|
||||||
func habitDoneCount(type: String) -> Int {
|
|
||||||
(UserDefaults.kisani.stringArray(forKey: Self.habitLogKeyPrefix + type) ?? []).count
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Analytics report notifications (deep-linked)
|
|
||||||
|
|
||||||
/// Fire one local notification for each newly-generated weekly / monthly
|
|
||||||
/// report, deep-linking to it. Permission-aware and gated on the same
|
|
||||||
/// "Period milestones" setting. `result` only lists reports created this run
|
|
||||||
/// (reconcile is idempotent) and each uses a fixed per-period identifier with
|
|
||||||
/// remove-then-add, so notifications are never duplicated.
|
|
||||||
func scheduleAnalyticsReports(_ result: AnalyticsCoordinator.ReconcileResult) {
|
|
||||||
guard !result.isEmpty,
|
|
||||||
UserDefaults.kisani.object(forKey: "kisani.periodMilestones") as? Bool ?? true else { return }
|
|
||||||
center.getNotificationSettings { [weak self] settings in
|
|
||||||
guard let self,
|
|
||||||
settings.authorizationStatus == .authorized
|
|
||||||
|| settings.authorizationStatus == .provisional else { return }
|
|
||||||
if let week = result.weekliesGenerated.max() {
|
|
||||||
self.postAnalyticsReport(id: "kisani.analytics.weekly.\(week)",
|
|
||||||
title: "Your week in review",
|
|
||||||
body: "Your weekly training summary is ready.",
|
|
||||||
link: .weekly(weekStartKey: week))
|
|
||||||
}
|
|
||||||
if let month = result.monthliesGenerated.max() {
|
|
||||||
self.postAnalyticsReport(id: "kisani.analytics.monthly.\(month)",
|
|
||||||
title: "Your month in review",
|
|
||||||
body: "Your monthly training summary is ready.",
|
|
||||||
link: .monthly(monthKey: month))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func postAnalyticsReport(id: String, title: String, body: String, link: AnalyticsDeepLink) {
|
|
||||||
center.removePendingNotificationRequests(withIdentifiers: [id])
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = title
|
|
||||||
content.body = body
|
|
||||||
content.sound = .default
|
|
||||||
content.userInfo = [AnalyticsDeepLink.userInfoKey: link.stringValue]
|
|
||||||
// Reconcile already runs at an appropriate moment (app opened after the
|
|
||||||
// period closed) — not tied to exact background execution. Deliver shortly.
|
|
||||||
center.add(UNNotificationRequest(
|
|
||||||
identifier: id, content: content,
|
|
||||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The app reads this on active to route to a tapped report (mirrors the
|
|
||||||
/// existing pending-action pattern used for workout confirm/missed).
|
|
||||||
func consumePendingAnalyticsDeepLink() -> AnalyticsDeepLink? {
|
|
||||||
guard let s = UserDefaults.kisani.string(forKey: "kisani.analytics.pendingDeepLink") else { return nil }
|
|
||||||
UserDefaults.kisani.removeObject(forKey: "kisani.analytics.pendingDeepLink")
|
|
||||||
return AnalyticsDeepLink(string: s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Recurring task completion confirmation
|
// MARK: - Recurring task completion confirmation
|
||||||
|
|
||||||
/// Posts a quiet "✓ Done. Repeats …" confirmation when a recurring task's
|
/// Posts a quiet "✓ Done. Repeats …" confirmation when a recurring task's
|
||||||
@@ -739,11 +534,6 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
|
|||||||
let deeplink = info["deeplink"] as? String
|
let deeplink = info["deeplink"] as? String
|
||||||
let taskId = info["taskId"] as? String
|
let taskId = info["taskId"] as? String
|
||||||
|
|
||||||
// Analytics report tap → store the deep link for the app to route on active.
|
|
||||||
if let s = info[AnalyticsDeepLink.userInfoKey] as? String, AnalyticsDeepLink(string: s) != nil {
|
|
||||||
UserDefaults.kisani.set(s, forKey: "kisani.analytics.pendingDeepLink")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch response.actionIdentifier {
|
switch response.actionIdentifier {
|
||||||
|
|
||||||
case Self.actionMarkId:
|
case Self.actionMarkId:
|
||||||
@@ -775,19 +565,6 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
|
|||||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
|
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
|
||||||
))
|
))
|
||||||
|
|
||||||
case Self.habitDoneActId:
|
|
||||||
if let habitType = info["habitType"] as? String { logHabitDone(type: habitType) }
|
|
||||||
|
|
||||||
case Self.habitNotTodayActId:
|
|
||||||
// Intentionally a no-op beyond dismissing — the habit tally only
|
|
||||||
// ever counts "Done" taps (KC-40's lifetime-tally philosophy), so
|
|
||||||
// there's nothing to log for a skip. This just gives an explicit,
|
|
||||||
// discoverable "I saw it, skipping" action instead of only a swipe.
|
|
||||||
break
|
|
||||||
|
|
||||||
case Self.habitStopTrackingActId:
|
|
||||||
if let habitType = info["habitType"] as? String { stopTrackingHabit(type: habitType) }
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|||||||
@@ -104,24 +104,4 @@ final class TutorialManager: ObservableObject {
|
|||||||
else { withAnimation(KisaniSpring.entrance) { workoutStep += 1 } }
|
else { withAnimation(KisaniSpring.entrance) { workoutStep += 1 } }
|
||||||
}
|
}
|
||||||
func skipWorkout() { withAnimation(KisaniSpring.exit) { workoutDone = true } }
|
func skipWorkout() { withAnimation(KisaniSpring.exit) { workoutDone = true } }
|
||||||
|
|
||||||
// MARK: - Settings view tutorial
|
|
||||||
@AppStorage("kisani.tutorial.settings.v1") var settingsDone: Bool = false
|
|
||||||
@Published var settingsStep: Int = 0
|
|
||||||
|
|
||||||
let settingsTips: [PageTip] = [
|
|
||||||
.init(title: "Habit Reminders",
|
|
||||||
body: "Prayer, making your bed, coffee — under Habits, set daily nudges for the small routines you don't want to forget. Tap Done on the notification to keep a private streak.",
|
|
||||||
icon: "hands.sparkles"),
|
|
||||||
]
|
|
||||||
|
|
||||||
var settingsIsActive: Bool { !settingsDone }
|
|
||||||
var settingsCurrent: PageTip { settingsTips[min(settingsStep, settingsTips.count - 1)] }
|
|
||||||
var settingsIsLast: Bool { settingsStep == settingsTips.count - 1 }
|
|
||||||
|
|
||||||
func advanceSettings() {
|
|
||||||
if settingsIsLast { withAnimation(KisaniSpring.exit) { settingsDone = true } }
|
|
||||||
else { withAnimation(KisaniSpring.entrance) { settingsStep += 1 } }
|
|
||||||
}
|
|
||||||
func skipSettings() { withAnimation(KisaniSpring.exit) { settingsDone = true } }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,10 +233,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
@Published var activeProgramId: UUID
|
@Published var activeProgramId: UUID
|
||||||
@Published var activeSections: [WorkoutSection]
|
@Published var activeSections: [WorkoutSection]
|
||||||
@Published var workoutDates: [String] = []
|
@Published var workoutDates: [String] = []
|
||||||
/// Subset of `workoutDates` logged via the in-app Finish button rather than
|
|
||||||
/// detected from Apple Health/Watch — only these are eligible for Undo, since
|
|
||||||
/// Watch/Health is the source of truth once it has recorded a day itself.
|
|
||||||
@Published var manualWorkoutDates: [String] = []
|
|
||||||
@Published var missedDates: [String] = [] // days marked "didn't do it"
|
@Published var missedDates: [String] = [] // days marked "didn't do it"
|
||||||
@Published var compensations: [String: String] = [:] // rest-day "yyyy-MM-dd" → programId
|
@Published var compensations: [String: String] = [:] // rest-day "yyyy-MM-dd" → programId
|
||||||
@Published var askCompensate: MissedDay? = nil // triggers the compensation sheet
|
@Published var askCompensate: MissedDay? = nil // triggers the compensation sheet
|
||||||
@@ -328,7 +324,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
private let kSchedule: String
|
private let kSchedule: String
|
||||||
private let kActivePid: String
|
private let kActivePid: String
|
||||||
private let kWorkoutDates: String
|
private let kWorkoutDates: String
|
||||||
private let kManualWorkoutDates: String
|
|
||||||
private let kLastActiveDay: String
|
private let kLastActiveDay: String
|
||||||
private let kWorkoutHistory: String
|
private let kWorkoutHistory: String
|
||||||
private let kMissedDates: String
|
private let kMissedDates: String
|
||||||
@@ -340,7 +335,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
self.kSchedule = "kisani.workout.schedule.\(uid)"
|
self.kSchedule = "kisani.workout.schedule.\(uid)"
|
||||||
self.kActivePid = "kisani.workout.activePid.\(uid)"
|
self.kActivePid = "kisani.workout.activePid.\(uid)"
|
||||||
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
|
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
|
||||||
self.kManualWorkoutDates = "kisani.workout.manualDates.\(uid)"
|
|
||||||
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
|
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
|
||||||
self.kWorkoutHistory = "kisani.workout.history.\(uid)"
|
self.kWorkoutHistory = "kisani.workout.history.\(uid)"
|
||||||
self.kMissedDates = "kisani.workout.missedDates.\(uid)"
|
self.kMissedDates = "kisani.workout.missedDates.\(uid)"
|
||||||
@@ -352,7 +346,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.schedule.\(uid)")
|
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.schedule.\(uid)")
|
||||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.activePid.\(uid)")
|
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.activePid.\(uid)")
|
||||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(uid)")
|
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(uid)")
|
||||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.manualDates.\(uid)")
|
|
||||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.history.\(uid)")
|
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.history.\(uid)")
|
||||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.missedDates.\(uid)")
|
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.missedDates.\(uid)")
|
||||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.compensations.\(uid)")
|
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.compensations.\(uid)")
|
||||||
@@ -419,7 +412,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
activeSections = blank.sections
|
activeSections = blank.sections
|
||||||
}
|
}
|
||||||
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
|
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
|
||||||
manualWorkoutDates = UserDefaults.kisani.stringArray(forKey: kManualWorkoutDates) ?? []
|
|
||||||
missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? []
|
missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? []
|
||||||
compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:]
|
compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:]
|
||||||
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
|
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
|
||||||
@@ -438,7 +430,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
|
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
|
||||||
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
|
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
|
||||||
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
|
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
|
||||||
UserDefaults.kisani.set(manualWorkoutDates, forKey: kManualWorkoutDates)
|
|
||||||
UserDefaults.kisani.set(missedDates, forKey: kMissedDates)
|
UserDefaults.kisani.set(missedDates, forKey: kMissedDates)
|
||||||
UserDefaults.kisani.set(compensations, forKey: kCompensations)
|
UserDefaults.kisani.set(compensations, forKey: kCompensations)
|
||||||
CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates)
|
CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates)
|
||||||
@@ -450,7 +441,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
|
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
|
||||||
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
|
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
|
||||||
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
|
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
|
||||||
CloudSyncManager.shared.push(value: manualWorkoutDates, forKey: kManualWorkoutDates)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Daily Reset
|
// MARK: - Daily Reset
|
||||||
@@ -573,10 +563,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
snapshotDay(key) // record today's session in history
|
snapshotDay(key) // record today's session in history
|
||||||
guard !workoutDates.contains(key) else { return }
|
guard !workoutDates.contains(key) else { return }
|
||||||
workoutDates.append(key)
|
workoutDates.append(key)
|
||||||
// Only called from the manual Finish path (never from the HealthKit sync
|
|
||||||
// below), so a day only lands here — undo-eligible — when the app itself
|
|
||||||
// is the one claiming it, not Watch/Health.
|
|
||||||
if !manualWorkoutDates.contains(key) { manualWorkoutDates.append(key) }
|
|
||||||
save()
|
save()
|
||||||
let start = sessionStartDate ?? date.addingTimeInterval(-3600)
|
let start = sessionStartDate ?? date.addingTimeInterval(-3600)
|
||||||
Task { await HealthKitManager.shared.saveWorkout(start: start, end: date) }
|
Task { await HealthKitManager.shared.saveWorkout(start: start, end: date) }
|
||||||
@@ -647,13 +633,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
workoutDates.contains(iso.string(from: date))
|
workoutDates.contains(iso.string(from: date))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True only if THIS date's completion came from the in-app Finish button —
|
|
||||||
/// the one case where Undo is offered. A Watch/Health-detected day is never
|
|
||||||
/// undo-eligible, since that record lives outside the app.
|
|
||||||
func isManuallyLogged(on date: Date) -> Bool {
|
|
||||||
manualWorkoutDates.contains(iso.string(from: date))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Per-date status (Pending / Done / Missed / Compensation)
|
// MARK: - Per-date status (Pending / Done / Missed / Compensation)
|
||||||
|
|
||||||
enum WorkoutDayStatus { case none, pending, done, missed }
|
enum WorkoutDayStatus { case none, pending, done, missed }
|
||||||
@@ -676,16 +655,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
logWorkoutCompleted(on: date) // adds to workoutDates, snapshots history, saves
|
logWorkoutCompleted(on: date) // adds to workoutDates, snapshots history, saves
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Undo a manually-logged "done" mark — back to pending. Only ever offered
|
|
||||||
/// for days in `manualWorkoutDates`; a Watch/Health-sourced day has no undo
|
|
||||||
/// path here (that record isn't this app's to remove).
|
|
||||||
func unmarkWorkoutDone(on date: Date) {
|
|
||||||
let key = iso.string(from: date)
|
|
||||||
workoutDates.removeAll { $0 == key }
|
|
||||||
manualWorkoutDates.removeAll { $0 == key }
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark one specific day's workout missed (undoes a done mark for that day only).
|
/// Mark one specific day's workout missed (undoes a done mark for that day only).
|
||||||
func markWorkoutMissed(on date: Date) {
|
func markWorkoutMissed(on date: Date) {
|
||||||
let key = iso.string(from: date)
|
let key = iso.string(from: date)
|
||||||
@@ -694,7 +663,6 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Compensation (recover a missed workout on a rest day)
|
// MARK: - Compensation (recover a missed workout on a rest day)
|
||||||
|
|
||||||
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.
|
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.
|
||||||
@@ -835,9 +803,7 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
sessionStartDate = Date()
|
sessionStartDate = Date()
|
||||||
}
|
}
|
||||||
syncBack()
|
syncBack()
|
||||||
// Ticking the last box no longer silently logs the day as done — the
|
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
|
||||||
// main Workout card's explicit "Finish" is the single, confirmable way
|
|
||||||
// to count today (so mis-taps here can't jump the UI into the done state).
|
|
||||||
if wasMarkedDone && restTimerEnabled { startRestTimer() }
|
if wasMarkedDone && restTimerEnabled { startRestTimer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,13 +817,11 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
if done, sessionStartDate == nil { sessionStartDate = Date() }
|
if done, sessionStartDate == nil { sessionStartDate = Date() }
|
||||||
syncBack()
|
syncBack()
|
||||||
// See toggleSet: no longer auto-logs the day — "Finish" is explicit.
|
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark every set of the active workout done/undone — the "Check/Clear all
|
/// Mark every set of the active workout done/undone — the "Select all & mark
|
||||||
/// sets" quick action. Ticking every box no longer auto-logs the day as
|
/// complete" quick action. Logs completion when everything is checked.
|
||||||
/// done on its own; "Finish Workout" on the main card is the single,
|
|
||||||
/// confirmable action that counts today.
|
|
||||||
func setAllSetsDone(_ done: Bool) {
|
func setAllSetsDone(_ done: Bool) {
|
||||||
for si in activeSections.indices {
|
for si in activeSections.indices {
|
||||||
for ei in activeSections[si].exercises.indices {
|
for ei in activeSections[si].exercises.indices {
|
||||||
@@ -871,6 +835,7 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay)
|
UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay)
|
||||||
}
|
}
|
||||||
syncBack()
|
syncBack()
|
||||||
|
if done, doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSet(sectionId: UUID, to exerciseId: UUID) {
|
func addSet(sectionId: UUID, to exerciseId: UUID) {
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import WidgetKit
|
|||||||
struct TaskItem: Identifiable, Codable, Equatable {
|
struct TaskItem: Identifiable, Codable, Equatable {
|
||||||
var id = UUID()
|
var id = UUID()
|
||||||
var title: String
|
var title: String
|
||||||
// Optional so existing saved tasks decode safely (missing key → nil); the
|
|
||||||
// `Date()` default only fires for genuinely new tasks constructed after
|
|
||||||
// this field existed — it's evaluated fresh at each construction site.
|
|
||||||
var createdAt: Date? = Date()
|
|
||||||
var dueDate: Date?
|
var dueDate: Date?
|
||||||
var hasTime: Bool = false
|
var hasTime: Bool = false
|
||||||
var isComplete: Bool = false
|
var isComplete: Bool = false
|
||||||
@@ -18,9 +14,6 @@ struct TaskItem: Identifiable, Codable, Equatable {
|
|||||||
var taskColor: TaskColor = .orange
|
var taskColor: TaskColor = .orange
|
||||||
var isRecurring: Bool = false
|
var isRecurring: Bool = false
|
||||||
var recurrenceLabel: String? = nil
|
var recurrenceLabel: String? = nil
|
||||||
// "Every N [unit]" — optional so existing saved tasks decode safely (missing
|
|
||||||
// key → nil, treated as 1 everywhere). "Every Weekday" ignores this.
|
|
||||||
var recurrenceInterval: Int? = nil
|
|
||||||
var isPinned: Bool = false
|
var isPinned: Bool = false
|
||||||
var endDate: Date? = nil
|
var endDate: Date? = nil
|
||||||
var isAllDay: Bool = false
|
var isAllDay: Bool = false
|
||||||
@@ -33,15 +26,6 @@ struct TaskItem: Identifiable, Codable, Equatable {
|
|||||||
// Matrix: horizontal manual moves override urgency only.
|
// Matrix: horizontal manual moves override urgency only.
|
||||||
// nil = auto from deadline, true = force urgent, false = force not-urgent.
|
// nil = auto from deadline, true = force urgent, false = force not-urgent.
|
||||||
var urgencyOverride: Bool? = nil
|
var urgencyOverride: Bool? = nil
|
||||||
|
|
||||||
/// User-facing recurrence text — "Weekly" stays "Weekly" at interval 1 (no
|
|
||||||
/// visual change for any existing task), but becomes "Every 2 Weeks" etc.
|
|
||||||
/// once an interval is set. Use this anywhere recurrence text is shown to
|
|
||||||
/// the user; `recurrenceLabel` itself stays a plain rule key for matching.
|
|
||||||
var recurrenceDisplayLabel: String? {
|
|
||||||
guard let label = recurrenceLabel else { return nil }
|
|
||||||
return RecurrenceRule.displayLabel(label: label, interval: recurrenceInterval ?? 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Priority: String, Codable, CaseIterable, Identifiable {
|
enum Priority: String, Codable, CaseIterable, Identifiable {
|
||||||
@@ -120,18 +104,6 @@ enum TaskCategory: String, Codable {
|
|||||||
case domain = "Domain"
|
case domain = "Domain"
|
||||||
case annual = "Annual"
|
case annual = "Annual"
|
||||||
case custom = "Custom"
|
case custom = "Custom"
|
||||||
|
|
||||||
/// Icon-only glyph for task rows — `.reminder` is the generic default
|
|
||||||
/// and stays unmarked so the badge only appears when it adds information.
|
|
||||||
var glyph: String? {
|
|
||||||
switch self {
|
|
||||||
case .reminder: return nil
|
|
||||||
case .birthday: return "gift.fill"
|
|
||||||
case .domain: return "briefcase.fill"
|
|
||||||
case .annual: return "calendar"
|
|
||||||
case .custom: return "tag.fill"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TaskColor: String, Codable {
|
enum TaskColor: String, Codable {
|
||||||
@@ -281,15 +253,10 @@ class TaskViewModel: ObservableObject {
|
|||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let start = cal.startOfDay(for: now)
|
let start = cal.startOfDay(for: now)
|
||||||
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
||||||
// Timed ONE-OFF tasks whose time has already passed belong in overdue,
|
// Timed tasks whose time has already passed belong in overdue, not today.
|
||||||
// not today. Recurring occurrences always stay in Today regardless of
|
|
||||||
// time: overdueTasks intentionally excludes recurring tasks (postponing
|
|
||||||
// one would shift the recurrence rule's anchor date, not just today's
|
|
||||||
// occurrence), so without this a past-time recurring task would vanish
|
|
||||||
// from the app entirely instead of showing here like Calendar shows it.
|
|
||||||
return activeRows().filter {
|
return activeRows().filter {
|
||||||
guard let d = $0.dueDate else { return false }
|
guard let d = $0.dueDate else { return false }
|
||||||
if $0.hasTime && d < now && !$0.isRecurring { return false }
|
if $0.hasTime && d < now { return false }
|
||||||
return d >= start && d < end
|
return d >= start && d < end
|
||||||
}
|
}
|
||||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||||
@@ -393,13 +360,24 @@ class TaskViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a recurring task has an occurrence on `date` — aligned to its rule,
|
/// Whether a recurring task has an occurrence on `date` — aligned to its rule,
|
||||||
/// on/after its start, and on/before its "repeat until" (if set). Delegates to
|
/// on/after its start, and on/before its "repeat until" (if set).
|
||||||
/// `RecurrenceRule` (Shared/) so this exact math is also what both widget
|
|
||||||
/// extensions use for their countdown windows — one implementation, not three.
|
|
||||||
func isOccurrence(_ t: TaskItem, on date: Date) -> Bool {
|
func isOccurrence(_ t: TaskItem, on date: Date) -> Bool {
|
||||||
guard recurs(t), let start = t.dueDate else { return false }
|
guard recurs(t), let start = t.dueDate else { return false }
|
||||||
return RecurrenceRule.isOccurrence(label: t.recurrenceLabel, interval: t.recurrenceInterval ?? 1,
|
let cal = Calendar.current
|
||||||
start: start, date: date, end: t.recurrenceEnd)
|
let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start)
|
||||||
|
guard d0 >= s0 else { return false }
|
||||||
|
if let end = t.recurrenceEnd, d0 > cal.startOfDay(for: end) { return false }
|
||||||
|
switch t.recurrenceLabel {
|
||||||
|
case "Daily": return true
|
||||||
|
case "Every Weekday":
|
||||||
|
let wd = cal.component(.weekday, from: d0) // 1=Sun … 7=Sat
|
||||||
|
return wd >= 2 && wd <= 6
|
||||||
|
case "Weekly": return ((cal.dateComponents([.day], from: s0, to: d0).day ?? 0) % 7) == 0
|
||||||
|
case "Monthly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
|
||||||
|
case "Yearly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
|
||||||
|
&& cal.component(.month, from: s0) == cal.component(.month, from: d0)
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
|
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
|
||||||
@@ -422,21 +400,6 @@ class TaskViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complete a recurring task's occurrence AND stop the series — distinct
|
|
||||||
/// from Delete, which erases the task (and every past completion) outright.
|
|
||||||
/// The task, its title, and its full completedOccurrences history stay
|
|
||||||
/// intact; only occurrences after `date` stop being generated (via
|
|
||||||
/// `recurrenceEnd`, which `isOccurrence` already respects).
|
|
||||||
func completeAndStopSeries(_ t: TaskItem, on date: Date = Date()) {
|
|
||||||
guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return }
|
|
||||||
let key = occurrenceKey(date)
|
|
||||||
var set = Set(tasks[idx].completedOccurrences ?? [])
|
|
||||||
set.insert(key) // ensure done — never toggles off
|
|
||||||
tasks[idx].completedOccurrences = Array(set)
|
|
||||||
tasks[idx].recurrenceEnd = Calendar.current.startOfDay(for: date)
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A display copy of a recurring task pinned to one occurrence date, with that
|
/// A display copy of a recurring task pinned to one occurrence date, with that
|
||||||
/// occurrence's completion baked into `isComplete` (so existing rows just work).
|
/// occurrence's completion baked into `isComplete` (so existing rows just work).
|
||||||
func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem {
|
func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem {
|
||||||
@@ -633,7 +596,7 @@ class TaskViewModel: ObservableObject {
|
|||||||
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
|
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
|
||||||
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
|
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
|
||||||
taskColor: TaskColor = .orange, isRecurring: Bool = false,
|
taskColor: TaskColor = .orange, isRecurring: Bool = false,
|
||||||
recurrenceLabel: String? = nil, recurrenceInterval: Int? = nil,
|
recurrenceLabel: String? = nil,
|
||||||
endDate: Date? = nil, isAllDay: Bool = false,
|
endDate: Date? = nil, isAllDay: Bool = false,
|
||||||
priority: Priority = .none, reminderDate: Date? = nil,
|
priority: Priority = .none, reminderDate: Date? = nil,
|
||||||
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
|
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
|
||||||
@@ -646,7 +609,6 @@ class TaskViewModel: ObservableObject {
|
|||||||
endDate: endDate, isAllDay: isAllDay,
|
endDate: endDate, isAllDay: isAllDay,
|
||||||
priority: resolvedPriority, reminderDate: reminderDate,
|
priority: resolvedPriority, reminderDate: reminderDate,
|
||||||
constantReminder: constantReminder)
|
constantReminder: constantReminder)
|
||||||
item.recurrenceInterval = recurrenceInterval
|
|
||||||
item.recurrenceEnd = recurrenceEnd
|
item.recurrenceEnd = recurrenceEnd
|
||||||
item.quadrant = displayQuadrant(item) // color/widget identity follows placement
|
item.quadrant = displayQuadrant(item) // color/widget identity follows placement
|
||||||
tasks.append(item)
|
tasks.append(item)
|
||||||
@@ -656,15 +618,13 @@ class TaskViewModel: ObservableObject {
|
|||||||
func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool,
|
func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool,
|
||||||
isRecurring: Bool, recurrenceLabel: String?,
|
isRecurring: Bool, recurrenceLabel: String?,
|
||||||
endDate: Date?, isAllDay: Bool, reminderDate: Date?,
|
endDate: Date?, isAllDay: Bool, reminderDate: Date?,
|
||||||
constantReminder: Bool = false, recurrenceEnd: Date? = nil,
|
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
|
||||||
recurrenceInterval: Int? = nil) {
|
|
||||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
tasks[idx].title = title
|
tasks[idx].title = title
|
||||||
tasks[idx].dueDate = dueDate
|
tasks[idx].dueDate = dueDate
|
||||||
tasks[idx].hasTime = hasTime
|
tasks[idx].hasTime = hasTime
|
||||||
tasks[idx].isRecurring = isRecurring
|
tasks[idx].isRecurring = isRecurring
|
||||||
tasks[idx].recurrenceLabel = recurrenceLabel
|
tasks[idx].recurrenceLabel = recurrenceLabel
|
||||||
tasks[idx].recurrenceInterval = recurrenceInterval
|
|
||||||
tasks[idx].endDate = endDate
|
tasks[idx].endDate = endDate
|
||||||
tasks[idx].isAllDay = isAllDay
|
tasks[idx].isAllDay = isAllDay
|
||||||
tasks[idx].reminderDate = reminderDate
|
tasks[idx].reminderDate = reminderDate
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ struct AppColors {
|
|||||||
static let blue = Color(r: 77, g: 157, b: 224)
|
static let blue = Color(r: 77, g: 157, b: 224)
|
||||||
static let yellow = Color(r: 232, g: 184, b: 42)
|
static let yellow = Color(r: 232, g: 184, b: 42)
|
||||||
static let red = Color(r: 232, g: 66, b: 66)
|
static let red = Color(r: 232, g: 66, b: 66)
|
||||||
static let coffeeBrown = Color(r: 122, g: 84, b: 52)
|
|
||||||
|
|
||||||
static let accentSoft = Color(UIColor { $0.userInterfaceStyle == .dark
|
static let accentSoft = Color(UIColor { $0.userInterfaceStyle == .dark
|
||||||
? UIColor(r: 52, g: 28, b: 14) // dark: deep warm orange
|
? UIColor(r: 52, g: 28, b: 14) // dark: deep warm orange
|
||||||
|
|||||||
@@ -1,427 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import Charts
|
|
||||||
|
|
||||||
// Analytics area: Overview · Daily · Weekly · Monthly · 12-Month · Exercises.
|
|
||||||
// Reads from AnalyticsService (which reconciles reports elsewhere). Status is
|
|
||||||
// conveyed by BOTH color and a glyph (never color alone). Includes empty/partial
|
|
||||||
// states and an informational, non-medical disclaimer.
|
|
||||||
//
|
|
||||||
// NOTE: written without an available build in this environment — verify on device.
|
|
||||||
struct AnalyticsView: View {
|
|
||||||
@Environment(\.colorScheme) private var cs
|
|
||||||
@ObservedObject private var service = AnalyticsService.shared
|
|
||||||
@State private var area: Area = .overview
|
|
||||||
@State private var selectedExercise: String?
|
|
||||||
|
|
||||||
/// Optional deep link (from a report notification) picks the initial area.
|
|
||||||
init(initialLink: AnalyticsDeepLink? = nil) {
|
|
||||||
switch initialLink {
|
|
||||||
case .weekly: _area = State(initialValue: .weekly)
|
|
||||||
case .monthly: _area = State(initialValue: .monthly)
|
|
||||||
default: break // keep default .overview
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Area: String, CaseIterable, Identifiable {
|
|
||||||
case overview = "Overview", daily = "Daily", weekly = "Weekly"
|
|
||||||
case monthly = "Monthly", trends = "12-Month", exercises = "Exercises"
|
|
||||||
var id: String { rawValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var hasAnyReports: Bool {
|
|
||||||
!service.weeklyReports().isEmpty || !service.monthlyReports().isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
header
|
|
||||||
areaSelector
|
|
||||||
Divider().background(AppColors.border(cs))
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
content
|
|
||||||
disclaimer
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.padding(.bottom, 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(AppColors.background(cs).ignoresSafeArea())
|
|
||||||
}
|
|
||||||
|
|
||||||
private var header: some View {
|
|
||||||
Text("Analytics")
|
|
||||||
.font(AppFonts.sans(20, weight: .bold))
|
|
||||||
.foregroundColor(AppColors.text(cs))
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.horizontal, 16).padding(.top, 18).padding(.bottom, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var areaSelector: some View {
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ForEach(Area.allCases) { a in
|
|
||||||
let on = a == area
|
|
||||||
Text(a.rawValue)
|
|
||||||
.font(AppFonts.sans(12, weight: .semibold))
|
|
||||||
.foregroundColor(on ? .white : AppColors.text2(cs))
|
|
||||||
.padding(.horizontal, 12).padding(.vertical, 7)
|
|
||||||
.background(on ? AppColors.accent : AppColors.surface2(cs))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
.contentShape(Capsule())
|
|
||||||
.onTapGesture { withAnimation(KisaniSpring.micro) { area = a } }
|
|
||||||
.accessibilityLabel(a.rawValue)
|
|
||||||
.accessibilityAddTraits(on ? [.isButton, .isSelected] : [.isButton])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16).padding(.vertical, 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder private var content: some View {
|
|
||||||
if !hasAnyReports && area != .daily {
|
|
||||||
emptyState
|
|
||||||
} else {
|
|
||||||
switch area {
|
|
||||||
case .overview: overview
|
|
||||||
case .daily: dailySection
|
|
||||||
case .weekly: weeklySection
|
|
||||||
case .monthly: monthlySection
|
|
||||||
case .trends: trendsSection
|
|
||||||
case .exercises: exercisesSection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Empty state
|
|
||||||
|
|
||||||
private var emptyState: some View {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
|
||||||
.font(.system(size: 30)).foregroundColor(AppColors.text3(cs))
|
|
||||||
Text("No reports yet")
|
|
||||||
.font(AppFonts.sans(15, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
|
||||||
Text("As each week and month completes, an immutable summary appears here.")
|
|
||||||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity).padding(.vertical, 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Overview
|
|
||||||
|
|
||||||
private var overview: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
if let w = service.weeklyReports().first {
|
|
||||||
sectionTitle("Latest week")
|
|
||||||
weeklyCard(w)
|
|
||||||
}
|
|
||||||
if let m = service.monthlyReports().first {
|
|
||||||
sectionTitle("Latest month")
|
|
||||||
monthlyCard(m)
|
|
||||||
}
|
|
||||||
sectionTitle("12-month direction")
|
|
||||||
HStack {
|
|
||||||
trendBadge(service.monthlyTrendClassification().classification)
|
|
||||||
Spacer()
|
|
||||||
Text("\(service.monthlyReports().count) mo · \(service.weeklyReports().count) wk")
|
|
||||||
.font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
.padding(12).cardBG(cs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Daily
|
|
||||||
|
|
||||||
private var dailySection: some View {
|
|
||||||
let cmp = service.dailyComparison(on: Date())
|
|
||||||
return VStack(alignment: .leading, spacing: 16) {
|
|
||||||
if cmp.restDay {
|
|
||||||
infoCard("Rest day", "No workout logged today — nothing to compare.")
|
|
||||||
}
|
|
||||||
comparisonGroup("vs. yesterday", cmp.vsPreviousDay)
|
|
||||||
comparisonGroup("vs. same day last week", cmp.vsSameWeekdayLastWeek)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func comparisonGroup(_ title: String, _ comps: [ExerciseComparison]) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
sectionTitle(title)
|
|
||||||
if comps.isEmpty {
|
|
||||||
infoCard("Nothing to compare", "No matching exercises for this comparison.")
|
|
||||||
} else {
|
|
||||||
ForEach(comps, id: \.identity) { c in
|
|
||||||
HStack {
|
|
||||||
Text(c.identity.capitalized)
|
|
||||||
.font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text(cs))
|
|
||||||
if c.isNew {
|
|
||||||
Text("NEW").font(AppFonts.mono(8, weight: .bold))
|
|
||||||
.foregroundColor(AppColors.blue)
|
|
||||||
.padding(.horizontal, 5).padding(.vertical, 2)
|
|
||||||
.background(AppColors.blueSoft).clipShape(Capsule())
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
deltaBadge(c.volume, unit: "kg")
|
|
||||||
}
|
|
||||||
.padding(12).cardBG(cs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Weekly / Monthly
|
|
||||||
|
|
||||||
private var weeklySection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
ForEach(service.weeklyReports()) { weeklyCard($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var monthlySection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
ForEach(service.monthlyReports()) { monthlyCard($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func weeklyCard(_ r: WeeklyReport) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
HStack {
|
|
||||||
Text("Week of \(r.weekStartKey)")
|
|
||||||
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
||||||
Spacer()
|
|
||||||
trendBadge(r.progression)
|
|
||||||
}
|
|
||||||
summaryRow(r.summary)
|
|
||||||
healthRow(r.summary)
|
|
||||||
deltaChips(r.vsPreviousWeek)
|
|
||||||
}
|
|
||||||
.padding(14).cardBG(cs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func monthlyCard(_ r: MonthlyReport) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
HStack {
|
|
||||||
Text(r.monthKey)
|
|
||||||
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
||||||
Spacer()
|
|
||||||
trendBadge(r.classification)
|
|
||||||
}
|
|
||||||
summaryRow(r.summary)
|
|
||||||
healthRow(r.summary)
|
|
||||||
deltaChips(r.vsPreviousMonth)
|
|
||||||
}
|
|
||||||
.padding(14).cardBG(cs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func summaryRow(_ s: PeriodSummary) -> some View {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
stat("\(s.sessions)", "sessions")
|
|
||||||
stat("\(s.sets)", "sets")
|
|
||||||
stat("\(Int(s.volume))", "kg")
|
|
||||||
stat("\(Int(s.consistency * 100))%", "consistent")
|
|
||||||
if s.missed > 0 { stat("\(s.missed)", "missed") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Available HealthKit trends for the period (shown only when present).
|
|
||||||
@ViewBuilder private func healthRow(_ s: PeriodSummary) -> some View {
|
|
||||||
if s.avgSteps != nil || s.avgActiveCalories != nil || s.avgRestingHR != nil {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
if let v = s.avgSteps { stat("\(v)", "avg steps") }
|
|
||||||
if let v = s.avgActiveCalories { stat("\(v)", "avg kcal") }
|
|
||||||
if let v = s.avgRestingHR { stat("\(v)", "avg bpm") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deltaChips(_ deltas: [String: MetricDelta]) -> some View {
|
|
||||||
let order = ["volume", "sessions", "sets", "duration", "consistency"]
|
|
||||||
return FlowRow(order.compactMap { key -> (String, MetricDelta)? in
|
|
||||||
guard let d = deltas[key], d.meaningful, d.percent != nil else { return nil }
|
|
||||||
return (key, d)
|
|
||||||
}) { pair in
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text(pair.0).font(AppFonts.mono(9)).foregroundColor(AppColors.text3(cs))
|
|
||||||
deltaBadge(pair.1, unit: "")
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
||||||
.background(AppColors.surface2(cs)).clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 12-Month trends
|
|
||||||
|
|
||||||
private var trendsSection: some View {
|
|
||||||
let series = service.monthlyVolumeTrend()
|
|
||||||
return VStack(alignment: .leading, spacing: 12) {
|
|
||||||
HStack {
|
|
||||||
sectionTitle("Monthly volume")
|
|
||||||
Spacer()
|
|
||||||
trendBadge(service.monthlyTrendClassification().classification)
|
|
||||||
}
|
|
||||||
if series.isEmpty {
|
|
||||||
infoCard("Not enough data", "A few completed months are needed to chart a trend.")
|
|
||||||
} else {
|
|
||||||
Chart(series) { point in
|
|
||||||
BarMark(
|
|
||||||
x: .value("Month", point.monthKey),
|
|
||||||
y: .value("Volume", point.volume)
|
|
||||||
)
|
|
||||||
.foregroundStyle(AppColors.accent)
|
|
||||||
}
|
|
||||||
.frame(height: 200)
|
|
||||||
.padding(12).cardBG(cs)
|
|
||||||
.accessibilityLabel("Monthly training volume for the last year")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Per-exercise
|
|
||||||
|
|
||||||
private var exercisesSection: some View {
|
|
||||||
let names = service.knownExerciseIdentities()
|
|
||||||
return VStack(alignment: .leading, spacing: 12) {
|
|
||||||
if names.isEmpty {
|
|
||||||
infoCard("No exercise history", "Logged exercises will appear here over time.")
|
|
||||||
} else {
|
|
||||||
Menu {
|
|
||||||
ForEach(names, id: \.self) { n in
|
|
||||||
Button(n.capitalized) { selectedExercise = n }
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text((selectedExercise ?? names.first ?? "").capitalized)
|
|
||||||
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
||||||
Image(systemName: "chevron.down").font(.system(size: 11)).foregroundColor(AppColors.text3(cs))
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(12).cardBG(cs)
|
|
||||||
}
|
|
||||||
let id = selectedExercise ?? names.first ?? ""
|
|
||||||
let history = service.exerciseHistory(identity: id)
|
|
||||||
if history.count < 2 {
|
|
||||||
infoCard("Not enough history", "Log this exercise on more days to see a trend.")
|
|
||||||
} else {
|
|
||||||
Chart(history) { point in
|
|
||||||
LineMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume))
|
|
||||||
.foregroundStyle(AppColors.accent)
|
|
||||||
PointMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume))
|
|
||||||
.foregroundStyle(AppColors.accent)
|
|
||||||
}
|
|
||||||
.frame(height: 200)
|
|
||||||
.padding(12).cardBG(cs)
|
|
||||||
.accessibilityLabel("Volume history for \(id)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Reusable bits
|
|
||||||
|
|
||||||
private func sectionTitle(_ t: String) -> some View {
|
|
||||||
Text(t.uppercased())
|
|
||||||
.font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stat(_ value: String, _ label: String) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
Text(value).font(AppFonts.sans(15, weight: .bold)).foregroundColor(AppColors.text(cs))
|
|
||||||
Text(label).font(AppFonts.mono(8)).foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func infoCard(_ title: String, _ body: String) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
|
||||||
Text(title).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
|
||||||
Text(body).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(12).cardBG(cs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Direction shown by glyph + sign + color (glyph works without color).
|
|
||||||
private func deltaBadge(_ d: MetricDelta, unit: String) -> some View {
|
|
||||||
let (glyph, color): (String, Color) = {
|
|
||||||
switch d.direction {
|
|
||||||
case .up: return ("arrow.up.right", AppColors.green)
|
|
||||||
case .down: return ("arrow.down.right", AppColors.red)
|
|
||||||
case .flat: return ("equal", AppColors.text3(cs))
|
|
||||||
case .none: return ("minus", AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
let text: String = {
|
|
||||||
if let p = d.percent { return String(format: "%+.0f%%", p) }
|
|
||||||
if let a = d.absolute {
|
|
||||||
let u = unit.isEmpty ? "" : " " + unit
|
|
||||||
return String(format: "%+.0f", a) + u
|
|
||||||
}
|
|
||||||
return "—"
|
|
||||||
}()
|
|
||||||
return HStack(spacing: 3) {
|
|
||||||
Image(systemName: glyph).font(.system(size: 9, weight: .bold))
|
|
||||||
Text(text).font(AppFonts.mono(11, weight: .semibold))
|
|
||||||
}
|
|
||||||
.foregroundColor(d.meaningful ? color : AppColors.text3(cs))
|
|
||||||
.accessibilityElement(children: .combine)
|
|
||||||
.accessibilityLabel("\(d.direction.rawValue) \(text)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func trendBadge(_ t: TrendClassification) -> some View {
|
|
||||||
let (glyph, color, label): (String, Color, String) = {
|
|
||||||
switch t {
|
|
||||||
case .improving: return ("arrow.up.right", AppColors.green, "Improving")
|
|
||||||
case .declining: return ("arrow.down.right", AppColors.red, "Declining")
|
|
||||||
case .plateau: return ("equal", AppColors.text2(cs), "Plateau")
|
|
||||||
case .insufficientData: return ("minus", AppColors.text3(cs), "Not enough data")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return HStack(spacing: 4) {
|
|
||||||
Image(systemName: glyph).font(.system(size: 9, weight: .bold))
|
|
||||||
Text(label).font(AppFonts.mono(9, weight: .bold))
|
|
||||||
}
|
|
||||||
.foregroundColor(color)
|
|
||||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
||||||
.background(color.opacity(0.12)).clipShape(Capsule())
|
|
||||||
.accessibilityElement(children: .combine)
|
|
||||||
.accessibilityLabel("Trend: \(label)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var disclaimer: some View {
|
|
||||||
Text(AnalyticsEngine.disclaimer)
|
|
||||||
.font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs))
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A minimal wrapping row (chips flow to new lines).
|
|
||||||
private struct FlowRow<Data: RandomAccessCollection, Content: View>: View {
|
|
||||||
let items: Data
|
|
||||||
let content: (Data.Element) -> Content
|
|
||||||
init(_ items: Data, @ViewBuilder content: @escaping (Data.Element) -> Content) {
|
|
||||||
self.items = items; self.content = content
|
|
||||||
}
|
|
||||||
var body: some View {
|
|
||||||
// Simple 2-per-row grid is enough for the small delta-chip set.
|
|
||||||
let arr = Array(items)
|
|
||||||
return VStack(alignment: .leading, spacing: 6) {
|
|
||||||
ForEach(Array(stride(from: 0, to: arr.count, by: 2)), id: \.self) { i in
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
content(arr[i])
|
|
||||||
if i + 1 < arr.count { content(arr[i + 1]) }
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension View {
|
|
||||||
/// Standard card background used across the analytics screens.
|
|
||||||
func cardBG(_ cs: ColorScheme) -> some View {
|
|
||||||
self.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(AppColors.surface(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -615,7 +615,6 @@ struct CalendarView: View {
|
|||||||
onPin: { taskVM.pin($0) },
|
onPin: { taskVM.pin($0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) },
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
|
||||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||||
@@ -829,10 +828,8 @@ struct CalendarView: View {
|
|||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
cvTimeGrid(days: days).padding(.bottom, 40)
|
cvTimeGrid(days: days).padding(.bottom, 40)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid layout: the 7 days as cards (2 columns), each listing its schedule.
|
// Grid layout: the 7 days as cards (2 columns), each listing its schedule.
|
||||||
@@ -912,9 +909,7 @@ struct CalendarView: View {
|
|||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
cvTimeGrid(days: days).padding(.bottom, 40)
|
cvTimeGrid(days: days).padding(.bottom, 40)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Day
|
// MARK: - Day
|
||||||
@@ -1048,46 +1043,13 @@ struct CalendarView: View {
|
|||||||
draggable: item.taskID != nil && !item.recurring,
|
draggable: item.taskID != nil && !item.recurring,
|
||||||
onReschedule: { newStartMin in
|
onReschedule: { newStartMin in
|
||||||
rescheduleTask(id: item.taskID, on: date, toStartMin: newStartMin)
|
rescheduleTask(id: item.taskID, on: date, toStartMin: newStartMin)
|
||||||
},
|
}
|
||||||
menu: { cvBlockMenu(for: item) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Long-press menu for a timeline block. Task items get the full shared menu
|
|
||||||
/// (Complete / Snooze / date / move / edit / delete); events get nothing.
|
|
||||||
@ViewBuilder
|
|
||||||
private func cvBlockMenu(for item: CVTimeItem) -> some View {
|
|
||||||
if let id = item.taskID, let task = taskVM.tasks.first(where: { $0.id == id }) {
|
|
||||||
Button {
|
|
||||||
withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
|
|
||||||
} label: {
|
|
||||||
Label(task.isComplete ? "Mark Incomplete" : "Complete", systemImage: "checkmark.circle")
|
|
||||||
}
|
|
||||||
Menu {
|
|
||||||
Button { taskVM.postpone(task, minutes: 15) } label: { Label("15 minutes", systemImage: "clock") }
|
|
||||||
Button { taskVM.postpone(task, minutes: 60) } label: { Label("1 hour", systemImage: "clock") }
|
|
||||||
Button { taskVM.postpone(task, days: 1) } label: { Label("Tomorrow", systemImage: "sun.max") }
|
|
||||||
} label: {
|
|
||||||
Label("Snooze", systemImage: "clock.arrow.circlepath")
|
|
||||||
}
|
|
||||||
TaskMenuItems(
|
|
||||||
task: task,
|
|
||||||
onPin: { taskVM.pin($0) },
|
|
||||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
|
||||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
|
||||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
|
||||||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
|
||||||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
|
||||||
onEdit: { editingTask = $0 },
|
|
||||||
onDelete: { taskVM.delete($0) },
|
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move a timed task to a new start minute on the same day (drag-to-reschedule).
|
/// Move a timed task to a new start minute on the same day (drag-to-reschedule).
|
||||||
private func rescheduleTask(id: UUID?, on date: Date, toStartMin newStartMin: Int) {
|
private func rescheduleTask(id: UUID?, on date: Date, toStartMin newStartMin: Int) {
|
||||||
guard let id, let task = taskVM.tasks.first(where: { $0.id == id }) else { return }
|
guard let id, let task = taskVM.tasks.first(where: { $0.id == id }) else { return }
|
||||||
@@ -1199,14 +1161,13 @@ struct CVTimeItem: Identifiable {
|
|||||||
// A single timed block on the week/day timeline. Task-backed blocks can be
|
// A single timed block on the week/day timeline. Task-backed blocks can be
|
||||||
// dragged vertically to reschedule (snapped to 15-minute steps); events and
|
// dragged vertically to reschedule (snapped to 15-minute steps); events and
|
||||||
// recurring tasks render fixed.
|
// recurring tasks render fixed.
|
||||||
private struct CVTimeBlock<MenuContent: View>: View {
|
private struct CVTimeBlock: View {
|
||||||
let item: CVTimeItem
|
let item: CVTimeItem
|
||||||
let top: CGFloat
|
let top: CGFloat
|
||||||
let height: CGFloat
|
let height: CGFloat
|
||||||
let hourH: CGFloat
|
let hourH: CGFloat
|
||||||
let draggable: Bool
|
let draggable: Bool
|
||||||
let onReschedule: (Int) -> Void
|
let onReschedule: (Int) -> Void
|
||||||
@ViewBuilder let menu: () -> MenuContent
|
|
||||||
@GestureState private var dragY: CGFloat = 0
|
@GestureState private var dragY: CGFloat = 0
|
||||||
|
|
||||||
private var block: some View {
|
private var block: some View {
|
||||||
@@ -1229,21 +1190,18 @@ private struct CVTimeBlock<MenuContent: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
if draggable {
|
||||||
if draggable {
|
block.gesture(
|
||||||
block.gesture(
|
DragGesture(minimumDistance: 6)
|
||||||
DragGesture(minimumDistance: 6)
|
.updating($dragY) { value, state, _ in state = value.translation.height }
|
||||||
.updating($dragY) { value, state, _ in state = value.translation.height }
|
.onEnded { value in
|
||||||
.onEnded { value in
|
let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15
|
||||||
let deltaMin = Int((Double(value.translation.height) / Double(hourH) * 60).rounded() / 15) * 15
|
onReschedule(item.startMin + deltaMin)
|
||||||
onReschedule(item.startMin + deltaMin)
|
}
|
||||||
}
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
block
|
||||||
block
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.contextMenu { menu() } // empty for events → no menu; task actions otherwise
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1809,7 +1767,6 @@ struct DayTimelineView: View {
|
|||||||
var onPostpone: ((TaskItem) -> Void)? = nil
|
var onPostpone: ((TaskItem) -> Void)? = nil
|
||||||
var onEdit: ((TaskItem) -> Void)? = nil
|
var onEdit: ((TaskItem) -> Void)? = nil
|
||||||
var onDelete: ((TaskItem) -> Void)? = nil
|
var onDelete: ((TaskItem) -> Void)? = nil
|
||||||
var onCompleteAndStopSeries: ((TaskItem) -> Void)? = nil
|
|
||||||
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
|
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
|
||||||
var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil
|
var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil
|
||||||
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
|
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
|
||||||
@@ -1982,8 +1939,7 @@ struct DayTimelineView: View {
|
|||||||
onResetMatrixUrgency: onResetMatrixUrgency,
|
onResetMatrixUrgency: onResetMatrixUrgency,
|
||||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||||
onEdit: onEdit,
|
onEdit: onEdit,
|
||||||
onDelete: { onDelete?($0) },
|
onDelete: { onDelete?($0) }
|
||||||
onCompleteAndStopSeries: { onCompleteAndStopSeries?($0) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ struct MatrixView: View {
|
|||||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) },
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
|
||||||
onTapHeader: { drillQuadrant = .urgent; showDrill = true }
|
onTapHeader: { drillQuadrant = .urgent; showDrill = true }
|
||||||
)
|
)
|
||||||
QuadrantCard(
|
QuadrantCard(
|
||||||
@@ -94,7 +93,6 @@ struct MatrixView: View {
|
|||||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) },
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
|
||||||
onTapHeader: { drillQuadrant = .schedule; showDrill = true }
|
onTapHeader: { drillQuadrant = .schedule; showDrill = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,7 +118,6 @@ struct MatrixView: View {
|
|||||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) },
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
|
||||||
onTapHeader: { drillQuadrant = .delegate_; showDrill = true }
|
onTapHeader: { drillQuadrant = .delegate_; showDrill = true }
|
||||||
)
|
)
|
||||||
QuadrantCard(
|
QuadrantCard(
|
||||||
@@ -142,7 +139,6 @@ struct MatrixView: View {
|
|||||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) },
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
|
||||||
onTapHeader: { drillQuadrant = .eliminate; showDrill = true }
|
onTapHeader: { drillQuadrant = .eliminate; showDrill = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -215,7 +211,6 @@ struct QuadrantCard: View {
|
|||||||
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
|
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
|
||||||
var onEdit: ((TaskItem) -> Void)? = nil
|
var onEdit: ((TaskItem) -> Void)? = nil
|
||||||
var onDelete: ((TaskItem) -> Void)? = nil
|
var onDelete: ((TaskItem) -> Void)? = nil
|
||||||
var onCompleteAndStopSeries: ((TaskItem) -> Void)? = nil
|
|
||||||
var onTapHeader: (() -> Void)? = nil
|
var onTapHeader: (() -> Void)? = nil
|
||||||
|
|
||||||
private let shortFmt: DateFormatter = {
|
private let shortFmt: DateFormatter = {
|
||||||
@@ -331,8 +326,7 @@ struct QuadrantCard: View {
|
|||||||
onResetMatrixUrgency: { t in withAnimation(KisaniSpring.snappy) { onResetMatrixUrgency?(t) } },
|
onResetMatrixUrgency: { t in withAnimation(KisaniSpring.snappy) { onResetMatrixUrgency?(t) } },
|
||||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||||
onEdit: onEdit.map { cb in { cb($0) } },
|
onEdit: onEdit.map { cb in { cb($0) } },
|
||||||
onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } },
|
onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } }
|
||||||
onCompleteAndStopSeries: { t in withAnimation(KisaniSpring.snappy) { onCompleteAndStopSeries?(t) } }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,8 +487,7 @@ struct QuadrantDetailView: View {
|
|||||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) }
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) }
|
if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) }
|
||||||
@@ -521,8 +514,7 @@ struct QuadrantDetailView: View {
|
|||||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) }
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) }
|
if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) }
|
||||||
@@ -550,8 +542,7 @@ struct QuadrantDetailView: View {
|
|||||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) }
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if task.id != displayed.last?.id { Divider().padding(.leading, 46) }
|
if task.id != displayed.last?.id { Divider().padding(.leading, 46) }
|
||||||
@@ -738,7 +729,6 @@ struct TaskEditSheet: View {
|
|||||||
@State private var isRecurring: Bool
|
@State private var isRecurring: Bool
|
||||||
@State private var recurrenceLabel: String?
|
@State private var recurrenceLabel: String?
|
||||||
@State private var recurrenceEnd: Date?
|
@State private var recurrenceEnd: Date?
|
||||||
@State private var recurrenceInterval: Int?
|
|
||||||
@State private var endDate: Date?
|
@State private var endDate: Date?
|
||||||
@State private var isAllDay: Bool
|
@State private var isAllDay: Bool
|
||||||
@State private var reminderDate: Date?
|
@State private var reminderDate: Date?
|
||||||
@@ -753,7 +743,6 @@ struct TaskEditSheet: View {
|
|||||||
_isRecurring = State(initialValue: task.isRecurring)
|
_isRecurring = State(initialValue: task.isRecurring)
|
||||||
_recurrenceLabel = State(initialValue: task.recurrenceLabel)
|
_recurrenceLabel = State(initialValue: task.recurrenceLabel)
|
||||||
_recurrenceEnd = State(initialValue: task.recurrenceEnd)
|
_recurrenceEnd = State(initialValue: task.recurrenceEnd)
|
||||||
_recurrenceInterval = State(initialValue: task.recurrenceInterval)
|
|
||||||
_endDate = State(initialValue: task.endDate)
|
_endDate = State(initialValue: task.endDate)
|
||||||
_isAllDay = State(initialValue: task.isAllDay)
|
_isAllDay = State(initialValue: task.isAllDay)
|
||||||
_reminderDate = State(initialValue: task.reminderDate)
|
_reminderDate = State(initialValue: task.reminderDate)
|
||||||
@@ -815,7 +804,7 @@ struct TaskEditSheet: View {
|
|||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(task.quadrant.color)
|
.foregroundColor(task.quadrant.color)
|
||||||
if let lbl = recurrenceLabel {
|
if let lbl = recurrenceLabel {
|
||||||
Text(RecurrenceRule.displayLabel(label: lbl, interval: recurrenceInterval ?? 1))
|
Text(lbl)
|
||||||
.font(AppFonts.sans(12))
|
.font(AppFonts.sans(12))
|
||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
@@ -905,8 +894,7 @@ struct TaskEditSheet: View {
|
|||||||
isAllDay: $isAllDay,
|
isAllDay: $isAllDay,
|
||||||
reminderDate: $reminderDate,
|
reminderDate: $reminderDate,
|
||||||
constantReminder: $constantReminder,
|
constantReminder: $constantReminder,
|
||||||
recurrenceEnd: $recurrenceEnd,
|
recurrenceEnd: $recurrenceEnd
|
||||||
recurrenceInterval: $recurrenceInterval
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -917,8 +905,7 @@ struct TaskEditSheet: View {
|
|||||||
taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime,
|
taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime,
|
||||||
isRecurring: isRecurring, recurrenceLabel: recurrenceLabel,
|
isRecurring: isRecurring, recurrenceLabel: recurrenceLabel,
|
||||||
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate,
|
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate,
|
||||||
constantReminder: constantReminder, recurrenceEnd: recurrenceEnd,
|
constantReminder: constantReminder, recurrenceEnd: recurrenceEnd)
|
||||||
recurrenceInterval: recurrenceInterval)
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ struct OnboardingView: View {
|
|||||||
@AppStorage("heightCm") private var heightCm: Double = 0
|
@AppStorage("heightCm") private var heightCm: Double = 0
|
||||||
@AppStorage("weightUnit") private var weightUnit: Int = 0
|
@AppStorage("weightUnit") private var weightUnit: Int = 0
|
||||||
@AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: Int = 0
|
@AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: Int = 0
|
||||||
// Habit reminders — same App Group keys HabitRemindersSheet and
|
|
||||||
// NotificationManager use, so a toggle here is identical to Settings.
|
|
||||||
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
|
|
||||||
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
|
|
||||||
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
|
|
||||||
|
|
||||||
// Local (uncommitted) state
|
// Local (uncommitted) state
|
||||||
@State private var localAppearance: Int = 2 // dark by default
|
@State private var localAppearance: Int = 2 // dark by default
|
||||||
@@ -512,41 +507,6 @@ struct OnboardingView: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.animation(KisaniSpring.snappy, value: workoutEnabled)
|
.animation(KisaniSpring.snappy, value: workoutEnabled)
|
||||||
|
|
||||||
// ══════════════════════════════
|
|
||||||
// MARK: Habits
|
|
||||||
// ══════════════════════════════
|
|
||||||
OnboardingSectionHeader(title: "Habits")
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
OnboardingHabitToggle(
|
|
||||||
icon: "hands.sparkles.fill", iconColor: AppColors.yellow, iconBg: AppColors.yellowSoft,
|
|
||||||
title: "Prayer reminders",
|
|
||||||
subtitle: "\"Have you had a chance to pray and give thanks?\"",
|
|
||||||
isOn: $prayerReminderEnabled)
|
|
||||||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
|
||||||
OnboardingHabitToggle(
|
|
||||||
icon: "bed.double.fill", iconColor: AppColors.blue, iconBg: AppColors.blueSoft,
|
|
||||||
title: "Make your bed",
|
|
||||||
subtitle: "The simplest task that starts the day with a win",
|
|
||||||
isOn: $bedReminderEnabled)
|
|
||||||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
|
||||||
OnboardingHabitToggle(
|
|
||||||
icon: "cup.and.saucer.fill", iconColor: .white, iconBg: AppColors.coffeeBrown,
|
|
||||||
title: "Coffee reminders",
|
|
||||||
subtitle: "Track when you usually reach for a cup",
|
|
||||||
isOn: $coffeeReminderEnabled)
|
|
||||||
}
|
|
||||||
.cardStyle()
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
|
|
||||||
Text("You can turn any of these on or off — or fine-tune the exact times, or add a custom coffee reminder — anytime in Settings → Habits.")
|
|
||||||
.font(AppFonts.sans(11))
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
|
|
||||||
Text("All settings can be changed later in Settings.")
|
Text("All settings can be changed later in Settings.")
|
||||||
.font(AppFonts.sans(11))
|
.font(AppFonts.sans(11))
|
||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
@@ -617,37 +577,6 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Habit Toggle Row
|
|
||||||
|
|
||||||
/// A single habit reminder's opt-in row (icon, title, one-line description,
|
|
||||||
/// toggle) — used for Prayer / Bed-making / Coffee in onboarding. Per-slot
|
|
||||||
/// times and the custom coffee reminder are Settings-only, kept out of
|
|
||||||
/// onboarding to stay restrained.
|
|
||||||
private struct OnboardingHabitToggle: View {
|
|
||||||
@Environment(\.colorScheme) private var cs
|
|
||||||
let icon: String; let iconColor: Color; let iconBg: Color
|
|
||||||
let title: String; let subtitle: String
|
|
||||||
@Binding var isOn: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(iconColor)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
.background(iconBg)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(title).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
||||||
Text(subtitle).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).lineLimit(2)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Toggle("", isOn: $isOn).labelsHidden().tint(AppColors.accent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Section Header
|
// MARK: - Section Header
|
||||||
|
|
||||||
private struct OnboardingSectionHeader: View {
|
private struct OnboardingSectionHeader: View {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ struct SettingsView: View {
|
|||||||
@EnvironmentObject var taskVM: TaskViewModel
|
@EnvironmentObject var taskVM: TaskViewModel
|
||||||
@ObservedObject private var auth = AuthManager.shared
|
@ObservedObject private var auth = AuthManager.shared
|
||||||
@ObservedObject private var calStore = CalendarStore.shared
|
@ObservedObject private var calStore = CalendarStore.shared
|
||||||
@ObservedObject private var tm = TutorialManager.shared
|
|
||||||
|
|
||||||
// Persisted settings
|
// Persisted settings
|
||||||
@AppStorage("appearanceMode") private var appearanceMode = 0
|
@AppStorage("appearanceMode") private var appearanceMode = 0
|
||||||
@@ -34,9 +33,6 @@ struct SettingsView: View {
|
|||||||
@AppStorage("streakGoal") private var streakGoal = 5
|
@AppStorage("streakGoal") private var streakGoal = 5
|
||||||
// App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it.
|
// App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it.
|
||||||
@AppStorage("kisani.periodMilestones", store: UserDefaults.kisani) private var periodMilestones = true
|
@AppStorage("kisani.periodMilestones", store: UserDefaults.kisani) private var periodMilestones = true
|
||||||
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
|
|
||||||
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
|
|
||||||
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
|
|
||||||
|
|
||||||
// Sheet presentation
|
// Sheet presentation
|
||||||
@State private var showProfile = false
|
@State private var showProfile = false
|
||||||
@@ -49,7 +45,6 @@ struct SettingsView: View {
|
|||||||
@State private var showCalendar = false
|
@State private var showCalendar = false
|
||||||
@State private var showBackup = false
|
@State private var showBackup = false
|
||||||
@State private var showWorkoutSettings = false
|
@State private var showWorkoutSettings = false
|
||||||
@State private var showHabitReminders = false
|
|
||||||
@State private var showRestTimer = false
|
@State private var showRestTimer = false
|
||||||
@State private var showHelp = false
|
@State private var showHelp = false
|
||||||
@State private var showFollow = false
|
@State private var showFollow = false
|
||||||
@@ -59,10 +54,6 @@ struct SettingsView: View {
|
|||||||
private var restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" }
|
private var restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" }
|
||||||
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
|
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
|
||||||
private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] }
|
private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] }
|
||||||
private var habitsLabel: String? {
|
|
||||||
let on = [bedReminderEnabled, prayerReminderEnabled, coffeeReminderEnabled].filter { $0 }.count
|
|
||||||
return on == 0 ? nil : "\(on) on"
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -146,13 +137,6 @@ struct SettingsView: View {
|
|||||||
HealthToggleRow(isLast: true)
|
HealthToggleRow(isLast: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HABITS ──
|
|
||||||
SttSection(label: "Habits") {
|
|
||||||
SttNavRow(icon: "hands.sparkles", bg: AppColors.yellowSoft, fg: AppColors.yellow,
|
|
||||||
label: "Habit Reminders", value: habitsLabel,
|
|
||||||
valueColor: habitsLabel != nil ? AppColors.green : nil, isLast: true) { showHabitReminders = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ACCOUNT ──
|
// ── ACCOUNT ──
|
||||||
SttSection(label: "Account", secondary: true) {
|
SttSection(label: "Account", secondary: true) {
|
||||||
if let user = AuthManager.shared.currentUser {
|
if let user = AuthManager.shared.currentUser {
|
||||||
@@ -190,21 +174,6 @@ struct SettingsView: View {
|
|||||||
Spacer().frame(height: 40)
|
Spacer().frame(height: 40)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if tm.settingsIsActive {
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
InViewTutorialCard(
|
|
||||||
tips: tm.settingsTips,
|
|
||||||
step: $tm.settingsStep,
|
|
||||||
onAdvance: { tm.advanceSettings() },
|
|
||||||
onSkip: { tm.skipSettings() }
|
|
||||||
)
|
|
||||||
.padding(.bottom, 84)
|
|
||||||
}
|
|
||||||
.transition(.opacity)
|
|
||||||
.zIndex(10)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showProfile) { ProfileStatsSheet().environmentObject(taskVM).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
.sheet(isPresented: $showProfile) { ProfileStatsSheet().environmentObject(taskVM).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
||||||
.sheet(isPresented: $showTabBar) { TabBarSheet(showMatrix: $showMatrixTab, showWorkout: $showWorkoutTab).presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
|
.sheet(isPresented: $showTabBar) { TabBarSheet(showMatrix: $showMatrixTab, showWorkout: $showWorkoutTab).presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
|
||||||
@@ -216,7 +185,6 @@ struct SettingsView: View {
|
|||||||
.sheet(isPresented: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
.sheet(isPresented: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
||||||
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||||||
.sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
.sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
||||||
.sheet(isPresented: $showHabitReminders) { HabitRemindersSheet().environmentObject(workoutVM).environmentObject(taskVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
|
||||||
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
|
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
|
||||||
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
|
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
|
||||||
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||||||
@@ -912,261 +880,6 @@ private struct WorkoutSettingsSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Habit Reminders Sheet
|
|
||||||
//
|
|
||||||
// Three independent daily reminders: prayer (up to 4 named times a day),
|
|
||||||
// bed-making (one nudge — research says it's the easiest first win of the
|
|
||||||
// day), and coffee/caffeine (up to 4 named times plus one custom reminder).
|
|
||||||
// All keys use the App Group store (`UserDefaults.kisani`) — the same store
|
|
||||||
// NotificationManager reads from — so toggling here actually schedules.
|
|
||||||
private struct HabitRemindersSheet: View {
|
|
||||||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
|
||||||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
|
||||||
@EnvironmentObject var taskVM: TaskViewModel
|
|
||||||
|
|
||||||
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
|
|
||||||
@AppStorage("bedReminderHour", store: UserDefaults.kisani) private var bedReminderHour = 8
|
|
||||||
@AppStorage("bedReminderMinute", store: UserDefaults.kisani) private var bedReminderMinute = 0
|
|
||||||
|
|
||||||
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
|
|
||||||
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
|
|
||||||
|
|
||||||
@AppStorage("coffeeCustomEnabled", store: UserDefaults.kisani) private var coffeeCustomEnabled = false
|
|
||||||
@AppStorage("coffeeCustomLabel", store: UserDefaults.kisani) private var coffeeCustomLabel = ""
|
|
||||||
@AppStorage("coffeeCustomHour", store: UserDefaults.kisani) private var coffeeCustomHour = 16
|
|
||||||
@AppStorage("coffeeCustomMinute", store: UserDefaults.kisani) private var coffeeCustomMinute = 30
|
|
||||||
|
|
||||||
private let prayerSlots = ["Morning", "Afternoon", "Evening", "Night"]
|
|
||||||
private let coffeeSlots = ["Morning", "Midday", "Afternoon", "Evening"]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
SheetHeader(title: "Habit Reminders") {
|
|
||||||
dismiss()
|
|
||||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
|
||||||
}
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
|
||||||
|
|
||||||
// ── Prayer ──
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("PRAYER").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
HStack(spacing: 11) {
|
|
||||||
Image(systemName: "hands.sparkles.fill")
|
|
||||||
.font(.system(size: 13)).foregroundColor(AppColors.yellow)
|
|
||||||
.frame(width: 28, height: 28).background(AppColors.yellowSoft).cornerRadius(8)
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
|
||||||
habitBadge("prayer")
|
|
||||||
}
|
|
||||||
Text("\"Have you had a chance to pray and give thanks?\"")
|
|
||||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Toggle("", isOn: $prayerReminderEnabled).labelsHidden().tint(AppColors.accent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
|
||||||
if prayerReminderEnabled {
|
|
||||||
ForEach(prayerSlots, id: \.self) { slot in
|
|
||||||
HabitSlotRow(keyPrefix: "prayer\(slot)", label: slot,
|
|
||||||
defaultHour: Self.defaultHour(for: slot, prayer: true),
|
|
||||||
defaultMinute: 0, defaultEnabled: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.cardStyle()
|
|
||||||
.animation(KisaniSpring.snappy, value: prayerReminderEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bed-making ──
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("MORNING").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
HStack(spacing: 11) {
|
|
||||||
Image(systemName: "bed.double.fill")
|
|
||||||
.font(.system(size: 13)).foregroundColor(AppColors.blue)
|
|
||||||
.frame(width: 28, height: 28).background(AppColors.blueSoft).cornerRadius(8)
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text("Make your bed").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
|
||||||
habitBadge("bed")
|
|
||||||
}
|
|
||||||
Text("The simplest task that starts the day with a win.")
|
|
||||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Toggle("", isOn: $bedReminderEnabled).labelsHidden().tint(AppColors.accent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
|
||||||
if bedReminderEnabled {
|
|
||||||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
|
||||||
HabitTimeRow(label: "Remind me at", hour: $bedReminderHour, minute: $bedReminderMinute)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.cardStyle()
|
|
||||||
.animation(KisaniSpring.snappy, value: bedReminderEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Coffee / Caffeine ──
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("CAFFEINE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
HStack(spacing: 11) {
|
|
||||||
Image(systemName: "cup.and.saucer.fill")
|
|
||||||
.font(.system(size: 13)).foregroundColor(.white)
|
|
||||||
.frame(width: 28, height: 28).background(AppColors.coffeeBrown).cornerRadius(8)
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
|
||||||
habitBadge("coffee")
|
|
||||||
}
|
|
||||||
Text("Track when you usually reach for a cup.")
|
|
||||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Toggle("", isOn: $coffeeReminderEnabled).labelsHidden().tint(AppColors.accent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
|
||||||
if coffeeReminderEnabled {
|
|
||||||
ForEach(coffeeSlots, id: \.self) { slot in
|
|
||||||
HabitSlotRow(keyPrefix: "coffee\(slot)", label: slot,
|
|
||||||
defaultHour: Self.defaultHour(for: slot, prayer: false),
|
|
||||||
defaultMinute: 0, defaultEnabled: slot == "Morning")
|
|
||||||
}
|
|
||||||
Divider().background(AppColors.border(cs)).padding(.leading, 14)
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "pencil").font(.system(size: 9))
|
|
||||||
Text("YOUR OWN REMINDER").font(AppFonts.mono(8, weight: .bold))
|
|
||||||
}
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
|
||||||
HStack(spacing: 11) {
|
|
||||||
TextField("Name it, e.g. \"Second coffee\"", text: $coffeeCustomLabel)
|
|
||||||
.font(AppFonts.sans(12.5)).foregroundColor(AppColors.text(cs))
|
|
||||||
Spacer()
|
|
||||||
if coffeeCustomEnabled {
|
|
||||||
DatePicker("", selection: Binding(
|
|
||||||
get: { Calendar.current.date(bySettingHour: coffeeCustomHour, minute: coffeeCustomMinute, second: 0, of: Date()) ?? Date() },
|
|
||||||
set: { v in
|
|
||||||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
|
||||||
coffeeCustomHour = c.hour ?? coffeeCustomHour
|
|
||||||
coffeeCustomMinute = c.minute ?? coffeeCustomMinute
|
|
||||||
}
|
|
||||||
), displayedComponents: .hourAndMinute)
|
|
||||||
.labelsHidden().tint(AppColors.accent)
|
|
||||||
}
|
|
||||||
Toggle("", isOn: $coffeeCustomEnabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.cardStyle()
|
|
||||||
.animation(KisaniSpring.snappy, value: coffeeReminderEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("All reminders are local to this device and never share your responses — they're just gentle nudges.")
|
|
||||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.bottom, 20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(AppColors.background(cs).ignoresSafeArea())
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func defaultHour(for slot: String, prayer: Bool) -> Int {
|
|
||||||
let table: [String: Int] = prayer
|
|
||||||
? ["Morning": 7, "Afternoon": 13, "Evening": 18, "Night": 21]
|
|
||||||
: ["Morning": 8, "Midday": 12, "Afternoon": 15, "Evening": 18]
|
|
||||||
return table[slot] ?? 9
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A quiet "X days" badge from tapping "Done" on a habit's notifications —
|
|
||||||
/// a private lifetime tally, never shown in Today/Matrix/Activity History.
|
|
||||||
@ViewBuilder
|
|
||||||
private func habitBadge(_ type: String) -> some View {
|
|
||||||
let count = NotificationManager.shared.habitDoneCount(type: type)
|
|
||||||
if count > 0 {
|
|
||||||
AppBadge(text: "\(count)d", bg: AppColors.greenSoft, fg: AppColors.green)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One named, independently-toggleable daily time slot (e.g. "Morning") — used
|
|
||||||
/// for both Prayer and Coffee's dense multi-slot lists. Reads/writes
|
|
||||||
/// `UserDefaults.kisani` directly under `<keyPrefix>Enabled/Hour/Minute` so it
|
|
||||||
/// stays in lockstep with what `NotificationManager` schedules from.
|
|
||||||
private struct HabitSlotRow: View {
|
|
||||||
@Environment(\.colorScheme) private var cs
|
|
||||||
let keyPrefix: String
|
|
||||||
let label: String
|
|
||||||
let defaultHour: Int
|
|
||||||
let defaultMinute: Int
|
|
||||||
let defaultEnabled: Bool
|
|
||||||
|
|
||||||
@AppStorage private var enabled: Bool
|
|
||||||
@AppStorage private var hour: Int
|
|
||||||
@AppStorage private var minute: Int
|
|
||||||
|
|
||||||
init(keyPrefix: String, label: String, defaultHour: Int, defaultMinute: Int, defaultEnabled: Bool) {
|
|
||||||
self.keyPrefix = keyPrefix; self.label = label
|
|
||||||
self.defaultHour = defaultHour; self.defaultMinute = defaultMinute; self.defaultEnabled = defaultEnabled
|
|
||||||
_enabled = AppStorage(wrappedValue: defaultEnabled, "\(keyPrefix)Enabled", store: UserDefaults.kisani)
|
|
||||||
_hour = AppStorage(wrappedValue: defaultHour, "\(keyPrefix)Hour", store: UserDefaults.kisani)
|
|
||||||
_minute = AppStorage(wrappedValue: defaultMinute, "\(keyPrefix)Minute", store: UserDefaults.kisani)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
Divider().background(AppColors.border(cs)).padding(.leading, 14)
|
|
||||||
HStack(spacing: 11) {
|
|
||||||
Text(label).font(AppFonts.sans(12.5)).foregroundColor(AppColors.text2(cs))
|
|
||||||
Spacer()
|
|
||||||
if enabled {
|
|
||||||
DatePicker("", selection: Binding(
|
|
||||||
get: { Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date() },
|
|
||||||
set: { v in
|
|
||||||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
|
||||||
hour = c.hour ?? hour; minute = c.minute ?? minute
|
|
||||||
}
|
|
||||||
), displayedComponents: .hourAndMinute)
|
|
||||||
.labelsHidden().tint(AppColors.accent)
|
|
||||||
}
|
|
||||||
Toggle("", isOn: $enabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A plain "label + time picker" row (no toggle) — used where the toggle
|
|
||||||
/// already lives on the parent row (e.g. bed-making's single time).
|
|
||||||
private struct HabitTimeRow: View {
|
|
||||||
@Environment(\.colorScheme) private var cs
|
|
||||||
let label: String
|
|
||||||
@Binding var hour: Int
|
|
||||||
@Binding var minute: Int
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Text(label).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
|
||||||
Spacer()
|
|
||||||
DatePicker("", selection: Binding(
|
|
||||||
get: { Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date() },
|
|
||||||
set: { v in
|
|
||||||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
|
||||||
hour = c.hour ?? hour; minute = c.minute ?? minute
|
|
||||||
}
|
|
||||||
), displayedComponents: .hourAndMinute)
|
|
||||||
.labelsHidden().tint(AppColors.accent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14).padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct StepperRow: View {
|
private struct StepperRow: View {
|
||||||
@Environment(\.colorScheme) private var cs
|
@Environment(\.colorScheme) private var cs
|
||||||
let label: String; @Binding var value: Int; let range: ClosedRange<Int>; var isLast: Bool = false
|
let label: String; @Binding var value: Int; let range: ClosedRange<Int>; var isLast: Bool = false
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ struct TaskMenuItems: View {
|
|||||||
var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) }
|
var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) }
|
||||||
var onEdit: ((TaskItem) -> Void)? = nil
|
var onEdit: ((TaskItem) -> Void)? = nil
|
||||||
var onDelete: (TaskItem) -> Void = { _ in }
|
var onDelete: (TaskItem) -> Void = { _ in }
|
||||||
/// Complete today's occurrence AND stop the series — keeps the task and its
|
|
||||||
/// full completion history (unlike Delete, which erases both).
|
|
||||||
var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
|
|
||||||
|
|
||||||
private var cal: Calendar { .current }
|
private var cal: Calendar { .current }
|
||||||
|
|
||||||
@@ -135,12 +132,6 @@ struct TaskMenuItems: View {
|
|||||||
Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") }
|
Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") }
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.isRecurring {
|
|
||||||
Button { onCompleteAndStopSeries(task) } label: {
|
|
||||||
Label("Complete & Stop Series", systemImage: "checkmark.circle.badge.xmark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button(role: .destructive) { onDelete(task) } label: {
|
Button(role: .destructive) { onDelete(task) } label: {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ struct TodayView: View {
|
|||||||
onPin: { taskVM.pin($0) },
|
onPin: { taskVM.pin($0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) },
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
|
||||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||||
@@ -160,7 +159,6 @@ struct TodayView: View {
|
|||||||
onPin: { taskVM.pin($0) },
|
onPin: { taskVM.pin($0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) },
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
|
||||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||||
@@ -182,7 +180,6 @@ struct TodayView: View {
|
|||||||
onPin: { taskVM.pin($0) },
|
onPin: { taskVM.pin($0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) },
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
|
||||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||||
@@ -202,7 +199,6 @@ struct TodayView: View {
|
|||||||
onPin: { taskVM.pin($0) },
|
onPin: { taskVM.pin($0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) },
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
|
||||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||||
@@ -222,7 +218,6 @@ struct TodayView: View {
|
|||||||
onPin: { taskVM.pin($0) },
|
onPin: { taskVM.pin($0) },
|
||||||
onEdit: { editingTask = $0 },
|
onEdit: { editingTask = $0 },
|
||||||
onDelete: { taskVM.delete($0) },
|
onDelete: { taskVM.delete($0) },
|
||||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
|
||||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||||
@@ -564,25 +559,11 @@ private struct TodayTLRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
// TickTick-style — icon only, no text badge; date/time already
|
TagChip(
|
||||||
// shown in the leading time-track column for this row.
|
text: entry.task.category.rawValue,
|
||||||
HStack(spacing: 5) {
|
color: entry.color,
|
||||||
if let glyph = entry.task.category.glyph {
|
bg: entry.task.quadrant.softColor
|
||||||
Image(systemName: glyph)
|
)
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundColor(entry.color)
|
|
||||||
}
|
|
||||||
if entry.task.reminderDate != nil || entry.task.constantReminder {
|
|
||||||
Image(systemName: "alarm")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
if entry.task.isRecurring {
|
|
||||||
Image(systemName: "repeat")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(action: onToggle) {
|
Button(action: onToggle) {
|
||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||||
@@ -620,7 +601,6 @@ struct OverdueCard: View {
|
|||||||
var onPin: (TaskItem) -> Void = { _ in }
|
var onPin: (TaskItem) -> Void = { _ in }
|
||||||
var onEdit: (TaskItem) -> Void = { _ in }
|
var onEdit: (TaskItem) -> Void = { _ in }
|
||||||
var onDelete: (TaskItem) -> Void = { _ in }
|
var onDelete: (TaskItem) -> Void = { _ in }
|
||||||
var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
|
|
||||||
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
||||||
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
|
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
|
||||||
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
||||||
@@ -732,8 +712,7 @@ struct OverdueCard: View {
|
|||||||
onResetMatrixUrgency: onResetMatrixUrgency,
|
onResetMatrixUrgency: onResetMatrixUrgency,
|
||||||
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
|
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
|
||||||
onEdit: onEdit,
|
onEdit: onEdit,
|
||||||
onDelete: onDelete,
|
onDelete: onDelete
|
||||||
onCompleteAndStopSeries: onCompleteAndStopSeries
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1039,7 +1018,6 @@ struct UpcomingSection: View {
|
|||||||
var onPin: (TaskItem) -> Void = { _ in }
|
var onPin: (TaskItem) -> Void = { _ in }
|
||||||
var onEdit: (TaskItem) -> Void = { _ in }
|
var onEdit: (TaskItem) -> Void = { _ in }
|
||||||
let onDelete: (TaskItem) -> Void
|
let onDelete: (TaskItem) -> Void
|
||||||
var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
|
|
||||||
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
||||||
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
|
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
|
||||||
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
||||||
@@ -1113,8 +1091,7 @@ struct UpcomingSection: View {
|
|||||||
onResetMatrixUrgency: onResetMatrixUrgency,
|
onResetMatrixUrgency: onResetMatrixUrgency,
|
||||||
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
|
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
|
||||||
onEdit: onEdit,
|
onEdit: onEdit,
|
||||||
onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } },
|
onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } }
|
||||||
onCompleteAndStopSeries: { task in withAnimation(KisaniSpring.snappy) { onCompleteAndStopSeries(task) } }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
@@ -1350,48 +1327,46 @@ struct TaskRowView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(PressButtonStyle(scale: 0.82))
|
.buttonStyle(PressButtonStyle(scale: 0.82))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(task.title)
|
Text(task.title)
|
||||||
.font(AppFonts.sans(13, weight: .medium))
|
.font(AppFonts.sans(13))
|
||||||
.foregroundColor(AppColors.text(cs))
|
.foregroundColor(AppColors.text(cs))
|
||||||
if let d = task.dueDate, showDetails {
|
if let d = task.dueDate, showDetails {
|
||||||
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceDisplayLabel ?? "Repeat")" : "")")
|
HStack(spacing: 4) {
|
||||||
.font(AppFonts.mono(9.5))
|
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")")
|
||||||
.tracking(0.2)
|
.font(AppFonts.mono(9.5))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
Text("·")
|
||||||
|
.font(AppFonts.mono(9.5))
|
||||||
|
.foregroundColor(AppColors.text3(cs).opacity(0.4))
|
||||||
|
Text(countdownLabel(to: d))
|
||||||
|
.font(AppFonts.mono(9.5, weight: .semibold))
|
||||||
|
.foregroundColor(countdownColor(to: d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// ⏰ reminder · 🔁 recurring indicators
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
if task.reminderDate != nil || task.constantReminder {
|
||||||
|
Image(systemName: "alarm")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
if task.isRecurring {
|
||||||
|
Image(systemName: "repeat")
|
||||||
|
.font(.system(size: 11))
|
||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
TagChip(
|
||||||
|
text: task.category.rawValue,
|
||||||
// Right column mirrors the left: countdown metadata on top,
|
color: task.quadrant.color,
|
||||||
// icon-only category/reminder/series indicators below — fills
|
bg: task.quadrant.softColor
|
||||||
// what used to be dead space instead of a lone floating icon.
|
)
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
|
||||||
if let d = task.dueDate, showDetails {
|
|
||||||
Text(countdownLabel(to: d))
|
|
||||||
.font(AppFonts.mono(9.5, weight: .semibold))
|
|
||||||
.tracking(0.2)
|
|
||||||
.foregroundColor(countdownColor(to: d))
|
|
||||||
}
|
|
||||||
HStack(spacing: 5) {
|
|
||||||
if let glyph = task.category.glyph {
|
|
||||||
Image(systemName: glyph)
|
|
||||||
.font(.system(size: 10.5))
|
|
||||||
.foregroundColor(task.quadrant.color)
|
|
||||||
}
|
|
||||||
if task.reminderDate != nil || task.constantReminder {
|
|
||||||
Image(systemName: "alarm")
|
|
||||||
.font(.system(size: 10.5))
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
if task.isRecurring {
|
|
||||||
Image(systemName: "repeat")
|
|
||||||
.font(.system(size: 10.5))
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.trailing, 9)
|
.padding(.trailing, 9)
|
||||||
}
|
}
|
||||||
.frame(minHeight: 48)
|
.frame(minHeight: 48)
|
||||||
@@ -1515,7 +1490,6 @@ struct NLParsed {
|
|||||||
var tokenRanges: [NSRange] = []
|
var tokenRanges: [NSRange] = []
|
||||||
var isRecurring: Bool = false
|
var isRecurring: Bool = false
|
||||||
var recurrenceLabel: String? = nil
|
var recurrenceLabel: String? = nil
|
||||||
var recurrenceInterval: Int? = nil
|
|
||||||
var recurrenceEnd: Date? = nil
|
var recurrenceEnd: Date? = nil
|
||||||
var endDate: Date? = nil
|
var endDate: Date? = nil
|
||||||
var isAllDay: Bool = false
|
var isAllDay: Bool = false
|
||||||
@@ -1565,10 +1539,9 @@ struct NLTaskParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Recurrence ──────────────────────────────────────────────────────
|
// ── Recurrence ──────────────────────────────────────────────────────
|
||||||
if let (r, label, interval) = parseRecurrence(in: lower) {
|
if let (r, label) = parseRecurrence(in: lower) {
|
||||||
result.isRecurring = true
|
result.isRecurring = true
|
||||||
result.recurrenceLabel = label
|
result.recurrenceLabel = label
|
||||||
result.recurrenceInterval = interval > 1 ? interval : nil
|
|
||||||
rawRanges.append(r)
|
rawRanges.append(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1693,11 +1666,7 @@ struct NLTaskParser {
|
|||||||
|
|
||||||
// MARK: - Recurrence
|
// MARK: - Recurrence
|
||||||
|
|
||||||
private static func parseRecurrence(in text: String) -> (NSRange, String, Int)? {
|
private static func parseRecurrence(in text: String) -> (NSRange, String)? {
|
||||||
// Interval phrases ("every 2 weeks", "biweekly") are checked first since
|
|
||||||
// they're more specific than the plain unit phrases below.
|
|
||||||
if let (r, label, n) = parseIntervalRecurrence(in: text) { return (r, label, n) }
|
|
||||||
|
|
||||||
// Order matters: more specific phrases ("every weekday") must precede the
|
// Order matters: more specific phrases ("every weekday") must precede the
|
||||||
// broader ones ("every week" / "every day") so they win.
|
// broader ones ("every week" / "every day") so they win.
|
||||||
let pats: [(String, String)] = [
|
let pats: [(String, String)] = [
|
||||||
@@ -1717,36 +1686,7 @@ struct NLTaskParser {
|
|||||||
("\\bdaily\\b", "Daily"),
|
("\\bdaily\\b", "Daily"),
|
||||||
]
|
]
|
||||||
for (pat, label) in pats {
|
for (pat, label) in pats {
|
||||||
if let r = match(pat, in: text) { return (r, label, 1) }
|
if let r = match(pat, in: text) { return (r, label) }
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "every 2 weeks", "every three months", "biweekly", "fortnightly",
|
|
||||||
/// "bimonthly" — anything with an actual N > 1 attached. "every 1 week"
|
|
||||||
/// falls through to the plain patterns above rather than being handled here.
|
|
||||||
private static func parseIntervalRecurrence(in text: String) -> (NSRange, String, Int)? {
|
|
||||||
if let r = match("\\b(biweekly|fortnightly)\\b", in: text) { return (r, "Weekly", 2) }
|
|
||||||
if let r = match("\\bbimonthly\\b", in: text) { return (r, "Monthly", 2) }
|
|
||||||
|
|
||||||
let wordNums = ["a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4,
|
|
||||||
"five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10]
|
|
||||||
let units: [(String, String)] = [
|
|
||||||
("days?", "Daily"),
|
|
||||||
("weeks?", "Weekly"),
|
|
||||||
("months?", "Monthly"),
|
|
||||||
("years?", "Yearly"),
|
|
||||||
]
|
|
||||||
for (unitPat, label) in units {
|
|
||||||
let pat = "\\bevery\\s+(\\d+|a|an|one|two|three|four|five|six|seven|eight|nine|ten)\\s+\(unitPat)\\b"
|
|
||||||
guard let rx = try? NSRegularExpression(pattern: pat),
|
|
||||||
let m = rx.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
|
|
||||||
m.numberOfRanges >= 2,
|
|
||||||
let nr = Range(m.range(at: 1), in: text) else { continue }
|
|
||||||
let ns = String(text[nr])
|
|
||||||
let n = Int(ns) ?? wordNums[ns] ?? 1
|
|
||||||
guard n > 1 else { continue }
|
|
||||||
return (m.range, label, n)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1990,9 +1930,7 @@ struct AddTaskSheet: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.system(size: 11, weight: .semibold))
|
||||||
Text(parsed.recurrenceLabel.map {
|
Text(parsed.recurrenceLabel ?? "Recurring")
|
||||||
RecurrenceRule.displayLabel(label: $0, interval: parsed.recurrenceInterval ?? 1)
|
|
||||||
} ?? "Recurring")
|
|
||||||
.font(AppFonts.sans(13, weight: .semibold))
|
.font(AppFonts.sans(13, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundColor(AppColors.accent)
|
.foregroundColor(AppColors.accent)
|
||||||
@@ -2108,8 +2046,7 @@ struct AddTaskSheet: View {
|
|||||||
isAllDay: $parsed.isAllDay,
|
isAllDay: $parsed.isAllDay,
|
||||||
reminderDate: $reminderDate,
|
reminderDate: $reminderDate,
|
||||||
constantReminder: $constantReminder,
|
constantReminder: $constantReminder,
|
||||||
recurrenceEnd: $parsed.recurrenceEnd,
|
recurrenceEnd: $parsed.recurrenceEnd
|
||||||
recurrenceInterval: $parsed.recurrenceInterval
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2120,7 +2057,6 @@ struct AddTaskSheet: View {
|
|||||||
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
|
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
|
||||||
quadrant: prefilledQuadrant, category: selectedCategory,
|
quadrant: prefilledQuadrant, category: selectedCategory,
|
||||||
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
|
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
|
||||||
recurrenceInterval: parsed.recurrenceInterval,
|
|
||||||
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
|
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
|
||||||
priority: selectedPriority,
|
priority: selectedPriority,
|
||||||
reminderDate: reminderDate, constantReminder: constantReminder,
|
reminderDate: reminderDate, constantReminder: constantReminder,
|
||||||
@@ -2138,7 +2074,6 @@ struct TaskDatePickerSheet: View {
|
|||||||
@Binding var isRecurring: Bool
|
@Binding var isRecurring: Bool
|
||||||
@Binding var recurrenceLabel: String?
|
@Binding var recurrenceLabel: String?
|
||||||
@Binding var recurrenceEnd: Date?
|
@Binding var recurrenceEnd: Date?
|
||||||
@Binding var recurrenceInterval: Int?
|
|
||||||
@Binding var endDate: Date?
|
@Binding var endDate: Date?
|
||||||
@Binding var isAllDay: Bool
|
@Binding var isAllDay: Bool
|
||||||
@Binding var reminderDate: Date?
|
@Binding var reminderDate: Date?
|
||||||
@@ -2160,7 +2095,6 @@ struct TaskDatePickerSheet: View {
|
|||||||
@State private var customNumber: Int
|
@State private var customNumber: Int
|
||||||
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
|
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
|
||||||
@State private var localRepeat: String
|
@State private var localRepeat: String
|
||||||
@State private var localRecurrenceInterval: Int
|
|
||||||
@State private var localRecurrenceEnd: Date?
|
@State private var localRecurrenceEnd: Date?
|
||||||
@State private var showRecurrenceEndPicker = false
|
@State private var showRecurrenceEndPicker = false
|
||||||
@State private var localIsAllDay: Bool
|
@State private var localIsAllDay: Bool
|
||||||
@@ -2171,14 +2105,12 @@ struct TaskDatePickerSheet: View {
|
|||||||
endDate: Binding<Date?>, isAllDay: Binding<Bool>,
|
endDate: Binding<Date?>, isAllDay: Binding<Bool>,
|
||||||
reminderDate: Binding<Date?> = .constant(nil),
|
reminderDate: Binding<Date?> = .constant(nil),
|
||||||
constantReminder: Binding<Bool> = .constant(false),
|
constantReminder: Binding<Bool> = .constant(false),
|
||||||
recurrenceEnd: Binding<Date?> = .constant(nil),
|
recurrenceEnd: Binding<Date?> = .constant(nil)) {
|
||||||
recurrenceInterval: Binding<Int?> = .constant(nil)) {
|
|
||||||
_selectedDate = selectedDate
|
_selectedDate = selectedDate
|
||||||
_hasTime = hasTime
|
_hasTime = hasTime
|
||||||
_isRecurring = isRecurring
|
_isRecurring = isRecurring
|
||||||
_recurrenceLabel = recurrenceLabel
|
_recurrenceLabel = recurrenceLabel
|
||||||
_recurrenceEnd = recurrenceEnd
|
_recurrenceEnd = recurrenceEnd
|
||||||
_recurrenceInterval = recurrenceInterval
|
|
||||||
_endDate = endDate
|
_endDate = endDate
|
||||||
_isAllDay = isAllDay
|
_isAllDay = isAllDay
|
||||||
_reminderDate = reminderDate
|
_reminderDate = reminderDate
|
||||||
@@ -2189,7 +2121,6 @@ struct TaskDatePickerSheet: View {
|
|||||||
_localEndDate = State(initialValue: endDate.wrappedValue ?? start)
|
_localEndDate = State(initialValue: endDate.wrappedValue ?? start)
|
||||||
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
|
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
|
||||||
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
|
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
|
||||||
_localRecurrenceInterval = State(initialValue: recurrenceInterval.wrappedValue ?? 1)
|
|
||||||
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
|
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
|
||||||
_localIsAllDay = State(initialValue: isAllDay.wrappedValue)
|
_localIsAllDay = State(initialValue: isAllDay.wrappedValue)
|
||||||
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
|
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
|
||||||
@@ -2336,13 +2267,6 @@ struct TaskDatePickerSheet: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
// ── Every N (only for units where an interval means anything —
|
|
||||||
// "Every Weekday" is always Mon–Fri, no interval concept) ──
|
|
||||||
if localRepeat != "None" && localRepeat != "Every Weekday" {
|
|
||||||
Divider().padding(.leading, 54)
|
|
||||||
intervalRow
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Repeat until (only when a recurrence is set) ──
|
// ── Repeat until (only when a recurrence is set) ──
|
||||||
if localRepeat != "None" {
|
if localRepeat != "None" {
|
||||||
Divider().padding(.leading, 54)
|
Divider().padding(.leading, 54)
|
||||||
@@ -2706,37 +2630,6 @@ struct TaskDatePickerSheet: View {
|
|||||||
.padding(.horizontal, 16).frame(height: 52)
|
.padding(.horizontal, 16).frame(height: 52)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var intervalUnitLabel: String {
|
|
||||||
let plural = localRecurrenceInterval != 1
|
|
||||||
switch localRepeat {
|
|
||||||
case "Daily": return plural ? "Days" : "Day"
|
|
||||||
case "Weekly": return plural ? "Weeks" : "Week"
|
|
||||||
case "Monthly": return plural ? "Months" : "Month"
|
|
||||||
case "Yearly": return plural ? "Years" : "Year"
|
|
||||||
default: return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var intervalRow: some View {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
Image(systemName: "number")
|
|
||||||
.font(.system(size: 18))
|
|
||||||
.foregroundColor(localRecurrenceInterval > 1 ? AppColors.accent : .secondary)
|
|
||||||
.frame(width: 24)
|
|
||||||
Text("Every")
|
|
||||||
.font(AppFonts.sans(16)).foregroundColor(.primary)
|
|
||||||
Spacer()
|
|
||||||
Stepper(value: $localRecurrenceInterval, in: 1...30) {
|
|
||||||
Text("\(localRecurrenceInterval) \(intervalUnitLabel)")
|
|
||||||
.font(AppFonts.sans(15))
|
|
||||||
.foregroundColor(localRecurrenceInterval > 1 ? AppColors.accent : .secondary)
|
|
||||||
}
|
|
||||||
.fixedSize()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16).frame(height: 52)
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RepeatOption { let key: String; let display: String }
|
private struct RepeatOption { let key: String; let display: String }
|
||||||
|
|
||||||
private var repeatOptions: [RepeatOption] {
|
private var repeatOptions: [RepeatOption] {
|
||||||
@@ -2764,12 +2657,7 @@ struct TaskDatePickerSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var repeatDisplayValue: String {
|
private var repeatDisplayValue: String {
|
||||||
// At interval 1 keep the richer "Weekly (Tuesday)" style text; once an
|
repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
|
||||||
// interval is set, "Every 2 Weeks" is clearer than trying to combine both.
|
|
||||||
if localRecurrenceInterval > 1 {
|
|
||||||
return RecurrenceRule.displayLabel(label: localRepeat, interval: localRecurrenceInterval)
|
|
||||||
}
|
|
||||||
return repeatOptions.first(where: { $0.key == localRepeat })?.display ?? localRepeat
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var timeFmt: DateFormatter {
|
private var timeFmt: DateFormatter {
|
||||||
@@ -2813,11 +2701,6 @@ struct TaskDatePickerSheet: View {
|
|||||||
isRecurring = localRepeat != "None"
|
isRecurring = localRepeat != "None"
|
||||||
recurrenceLabel = localRepeat != "None" ? localRepeat : nil
|
recurrenceLabel = localRepeat != "None" ? localRepeat : nil
|
||||||
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : nil
|
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : nil
|
||||||
// Store nil (not 1) at the default interval — keeps "no interval set"
|
|
||||||
// and "explicitly every 1" indistinguishable, matching every other
|
|
||||||
// task's data shape and RecurrenceRule's own nil-means-1 default.
|
|
||||||
recurrenceInterval = (localRepeat != "None" && localRepeat != "Every Weekday"
|
|
||||||
&& localRecurrenceInterval > 1) ? localRecurrenceInterval : nil
|
|
||||||
// Reminder is a lead-time offset from the task date at the chosen time-of-day.
|
// Reminder is a lead-time offset from the task date at the chosen time-of-day.
|
||||||
if let off = localReminderOffset {
|
if let off = localReminderOffset {
|
||||||
reminderDate = computedReminderDate(offset: off)
|
reminderDate = computedReminderDate(offset: off)
|
||||||
|
|||||||
@@ -64,10 +64,8 @@ struct WorkoutView: View {
|
|||||||
@State private var showAddSection = false
|
@State private var showAddSection = false
|
||||||
@State private var showHistory = false
|
@State private var showHistory = false
|
||||||
@State private var showStats = false
|
@State private var showStats = false
|
||||||
@State private var showAnalytics = false
|
|
||||||
@State private var isReordering = false
|
@State private var isReordering = false
|
||||||
@State private var trainAnyway = false // override the rest-day state for today
|
@State private var trainAnyway = false // override the rest-day state for today
|
||||||
@State private var showClearAllConfirm = false
|
|
||||||
@ObservedObject private var tm = TutorialManager.shared
|
@ObservedObject private var tm = TutorialManager.shared
|
||||||
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
@@ -105,11 +103,13 @@ struct WorkoutView: View {
|
|||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
IButton(icon: "chart.line.uptrend.xyaxis") { showAnalytics = true }
|
|
||||||
IButton(icon: "chart.bar.xaxis") { showStats = true }
|
IButton(icon: "chart.bar.xaxis") { showStats = true }
|
||||||
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
|
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
|
||||||
Menu {
|
Menu {
|
||||||
Button(role: .destructive) { showClearAllConfirm = true } label: {
|
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } } label: {
|
||||||
|
Label("Mark All Complete", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } label: {
|
||||||
Label("Clear All Sets", systemImage: "circle")
|
Label("Clear All Sets", systemImage: "circle")
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
@@ -138,18 +138,8 @@ struct WorkoutView: View {
|
|||||||
.moveDisabled(true)
|
.moveDisabled(true)
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
// ── Dot Grid Progress + Completion ──
|
// ── Dot Grid Progress ──
|
||||||
DotProgressCard(
|
DotProgressCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets)
|
||||||
progress: vm.progress, done: vm.doneSets, total: vm.totalSets,
|
|
||||||
isDoneToday: vm.isWorkoutComplete(on: Date()),
|
|
||||||
isMissedToday: vm.isWorkoutMissed(on: Date()),
|
|
||||||
onFinish: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutDone(on: Date()) } },
|
|
||||||
onMarkAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } },
|
|
||||||
onClearAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } },
|
|
||||||
onNotCompleted: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutMissed(on: Date()) } },
|
|
||||||
isManuallyLogged: vm.isManuallyLogged(on: Date()),
|
|
||||||
onUndo: { withAnimation(KisaniSpring.snappy) { vm.unmarkWorkoutDone(on: Date()) } }
|
|
||||||
)
|
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14))
|
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14))
|
||||||
@@ -334,28 +324,11 @@ struct WorkoutView: View {
|
|||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAnalytics) {
|
|
||||||
AnalyticsView()
|
|
||||||
.presentationDetents([.large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showStats) {
|
.sheet(isPresented: $showStats) {
|
||||||
WorkoutStatsView().environmentObject(vm)
|
WorkoutStatsView().environmentObject(vm)
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
|
||||||
"Clear all logged sets?",
|
|
||||||
isPresented: $showClearAllConfirm,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button("Clear All", role: .destructive) {
|
|
||||||
withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) }
|
|
||||||
}
|
|
||||||
Button("Cancel", role: .cancel) {}
|
|
||||||
} message: {
|
|
||||||
Text("This un-checks every set in today's workout. It won't undo an already-finished workout.")
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showAddSection) {
|
.sheet(isPresented: $showAddSection) {
|
||||||
AddSectionSheet().environmentObject(vm)
|
AddSectionSheet().environmentObject(vm)
|
||||||
.presentationDetents([.height(240)])
|
.presentationDetents([.height(240)])
|
||||||
@@ -591,223 +564,34 @@ struct AddSectionSheet: View {
|
|||||||
struct DotProgressCard: View {
|
struct DotProgressCard: View {
|
||||||
@Environment(\.colorScheme) private var cs
|
@Environment(\.colorScheme) private var cs
|
||||||
let progress: Double; let done: Int; let total: Int
|
let progress: Double; let done: Int; let total: Int
|
||||||
let isDoneToday: Bool
|
|
||||||
let isMissedToday: Bool
|
|
||||||
/// Capture today as done regardless of how many sets are checked — the app's
|
|
||||||
/// own set-ticking is a nice-to-have log, not the source of truth for whether
|
|
||||||
/// a workout happened (Health/Watch already knows that independently).
|
|
||||||
let onFinish: () -> Void
|
|
||||||
let onMarkAllSets: () -> Void
|
|
||||||
let onClearAllSets: () -> Void
|
|
||||||
let onNotCompleted: () -> Void
|
|
||||||
/// Whether today's "done" mark came from this app's own Finish button rather
|
|
||||||
/// than Watch/Health — only manually-logged days offer an Undo (see body).
|
|
||||||
let isManuallyLogged: Bool
|
|
||||||
let onUndo: () -> Void
|
|
||||||
|
|
||||||
/// Every quick action that changes today's state is confirmed first — a
|
|
||||||
/// stray tap here shouldn't silently rewrite the day.
|
|
||||||
private enum PendingAction: Identifiable {
|
|
||||||
case finish, checkAll, clearAll, notCompleted, undo
|
|
||||||
var id: Self { self }
|
|
||||||
|
|
||||||
var confirmLabel: String {
|
|
||||||
switch self {
|
|
||||||
case .finish: return "Finish Anyway"
|
|
||||||
case .checkAll: return "Check All"
|
|
||||||
case .clearAll: return "Clear All"
|
|
||||||
case .notCompleted: return "Mark Not Completed"
|
|
||||||
case .undo: return "Undo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var isDestructive: Bool { self == .clearAll || self == .notCompleted || self == .undo }
|
|
||||||
}
|
|
||||||
@State private var pendingAction: PendingAction?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(statusTitle)
|
Text("\(done) of \(total) sets done")
|
||||||
.font(AppFonts.sans(13, weight: .semibold))
|
.font(AppFonts.sans(13, weight: .semibold))
|
||||||
.foregroundColor(AppColors.text(cs))
|
.foregroundColor(AppColors.text(cs))
|
||||||
Text(statusSubtitle)
|
Text(total == 0 ? "Add exercises to get started"
|
||||||
|
: done == total && total > 0 ? "All sets done"
|
||||||
|
: "\(total - done) sets remaining")
|
||||||
.font(AppFonts.mono(9))
|
.font(AppFonts.mono(9))
|
||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
statusBadge
|
Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
|
||||||
|
.font(AppFonts.mono(13, weight: .bold))
|
||||||
|
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress dots stay visible regardless of state — finishing the
|
|
||||||
// day shouldn't replace the one view that shows how it went.
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("PROGRESS")
|
Text("PROGRESS")
|
||||||
.font(AppFonts.mono(8, weight: .bold))
|
.font(AppFonts.mono(8, weight: .bold))
|
||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
DotGridProgress(progress: total > 0 ? progress : 0, cs: cs)
|
DotGridProgress(progress: total > 0 ? progress : 0, cs: cs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch/Health is the source of truth once it has logged a day —
|
|
||||||
// no in-card way to undo that. A manually-logged day (this app's
|
|
||||||
// own Finish button) is the one case Undo is still offered for,
|
|
||||||
// confirmed first like every other state change here.
|
|
||||||
if !(isDoneToday || isMissedToday) {
|
|
||||||
actionRow
|
|
||||||
} else if isDoneToday && isManuallyLogged {
|
|
||||||
manualUndoRow
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.cardStyle(borderColor: isDoneToday ? AppColors.green.opacity(0.3) : nil)
|
.cardStyle()
|
||||||
.confirmationDialog(
|
|
||||||
confirmTitle,
|
|
||||||
isPresented: Binding(get: { pendingAction != nil }, set: { if !$0 { pendingAction = nil } }),
|
|
||||||
titleVisibility: .visible,
|
|
||||||
presenting: pendingAction
|
|
||||||
) { action in
|
|
||||||
Button(action.confirmLabel, role: action.isDestructive ? .destructive : nil) {
|
|
||||||
perform(action)
|
|
||||||
}
|
|
||||||
Button("Cancel", role: .cancel) {}
|
|
||||||
} message: { action in
|
|
||||||
Text(confirmMessage(action))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var confirmTitle: String {
|
|
||||||
switch pendingAction {
|
|
||||||
case .finish: return "Finish with \(done) of \(total) sets logged?"
|
|
||||||
case .checkAll: return "Check every set as done?"
|
|
||||||
case .clearAll: return "Clear all logged sets?"
|
|
||||||
case .notCompleted: return "Mark today as not completed?"
|
|
||||||
case .undo: return "Undo today's logged workout?"
|
|
||||||
case nil: return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func confirmMessage(_ action: PendingAction) -> String {
|
|
||||||
switch action {
|
|
||||||
case .finish:
|
|
||||||
return total > 0
|
|
||||||
? "You've logged \(Int(progress * 100))% of sets. This still counts as today's workout."
|
|
||||||
: "No sets logged yet. This still counts as today's workout."
|
|
||||||
case .checkAll:
|
|
||||||
return "This ticks all \(total) sets as done — useful if you trained but didn't log along the way."
|
|
||||||
case .clearAll:
|
|
||||||
return "This un-checks all \(total) sets. It won't undo today's logged workout."
|
|
||||||
case .notCompleted:
|
|
||||||
return isDoneToday ? "This replaces today's logged workout with \"not completed.\"" : "You can undo this afterward."
|
|
||||||
case .undo:
|
|
||||||
return "This removes today's manually logged workout and goes back to pending. It won't affect anything recorded by Health or your Watch."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func perform(_ action: PendingAction) {
|
|
||||||
switch action {
|
|
||||||
case .finish: onFinish()
|
|
||||||
case .checkAll: onMarkAllSets()
|
|
||||||
case .clearAll: onClearAllSets()
|
|
||||||
case .notCompleted: onNotCompleted()
|
|
||||||
case .undo: onUndo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var statusTitle: String {
|
|
||||||
if isDoneToday { return "Workout logged" }
|
|
||||||
if isMissedToday { return "Marked as not completed" }
|
|
||||||
return "\(done) of \(total) sets done"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var statusSubtitle: String {
|
|
||||||
if isDoneToday { return "Counted toward today · \(done)/\(total) sets logged" }
|
|
||||||
if isMissedToday { return "Doesn't count toward today" }
|
|
||||||
if total == 0 { return "Add exercises to get started" }
|
|
||||||
if done == total { return "All sets done" }
|
|
||||||
return "\(total - done) sets remaining"
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder private var statusBadge: some View {
|
|
||||||
if isDoneToday {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.font(.system(size: 18)).foregroundColor(AppColors.green)
|
|
||||||
} else if isMissedToday {
|
|
||||||
Image(systemName: "moon.zzz.fill")
|
|
||||||
.font(.system(size: 16)).foregroundColor(AppColors.text3(cs))
|
|
||||||
} else {
|
|
||||||
Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
|
|
||||||
.font(AppFonts.mono(13, weight: .bold))
|
|
||||||
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Primary: finish the workout as-is (honest capture, not gated on 100% sets).
|
|
||||||
/// Secondary: the mechanical "tick every box" action (toggles to a clear once
|
|
||||||
/// all are checked), and the explicit miss. Every action here that changes
|
|
||||||
/// today's state is confirmed first — see `pendingAction`.
|
|
||||||
private var actionRow: some View {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Button {
|
|
||||||
pendingAction = .finish
|
|
||||||
} label: {
|
|
||||||
Label(done == total && total > 0 ? "Finish Workout" : "Finish Anyway",
|
|
||||||
systemImage: "checkmark.circle.fill")
|
|
||||||
.font(AppFonts.sans(13, weight: .semibold))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 11)
|
|
||||||
}
|
|
||||||
.buttonStyle(QuietAccentButtonStyle())
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if done == total && total > 0 {
|
|
||||||
Button { pendingAction = .clearAll } label: {
|
|
||||||
Label("Clear all sets", systemImage: "circle")
|
|
||||||
.font(AppFonts.sans(11, weight: .medium))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
.foregroundColor(AppColors.text2(cs))
|
|
||||||
.background(AppColors.surface2(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
||||||
.buttonStyle(PressButtonStyle(scale: 0.97))
|
|
||||||
} else {
|
|
||||||
Button { pendingAction = .checkAll } label: {
|
|
||||||
Label("Check all sets", systemImage: "checkmark.circle")
|
|
||||||
.font(AppFonts.sans(11, weight: .medium))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
.foregroundColor(AppColors.text2(cs))
|
|
||||||
.background(AppColors.surface2(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
||||||
.buttonStyle(PressButtonStyle(scale: 0.97))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button { pendingAction = .notCompleted } label: {
|
|
||||||
Label("Didn't train", systemImage: "moon.zzz")
|
|
||||||
.font(AppFonts.sans(11, weight: .medium))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
|
||||||
.background(AppColors.surface2(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
||||||
.buttonStyle(PressButtonStyle(scale: 0.97))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Only shown for a manually-logged day — tapping this always confirms first
|
|
||||||
/// (see `PendingAction.undo`), so a stray tap can't silently un-log a workout.
|
|
||||||
private var manualUndoRow: some View {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button { pendingAction = .undo } label: {
|
|
||||||
Label("Undo", systemImage: "arrow.uturn.backward")
|
|
||||||
.font(AppFonts.sans(11, weight: .semibold))
|
|
||||||
}
|
|
||||||
.foregroundColor(AppColors.accent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import KisaniCal
|
|
||||||
|
|
||||||
/// Adapter tests: exercise reduction (identity + volume), inclusive day-key
|
|
||||||
/// ranges, and app-data → DaySample mapping (rest/missing/scheduled handling).
|
|
||||||
final class AnalyticsAdapterTests: XCTestCase {
|
|
||||||
|
|
||||||
private func cal() -> Calendar {
|
|
||||||
var c = Calendar(identifier: .gregorian)
|
|
||||||
c.timeZone = TimeZone(identifier: "UTC")!
|
|
||||||
c.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
func testExerciseSampleReduction() {
|
|
||||||
let es = WorkoutAnalyticsAdapter.exerciseSample(name: " Bench Press ",
|
|
||||||
completedSets: [(60, 10), (60, 8), (70, 6)])
|
|
||||||
XCTAssertEqual(es.identity, "bench press")
|
|
||||||
XCTAssertEqual(es.sets, 3)
|
|
||||||
XCTAssertEqual(es.reps, 24)
|
|
||||||
XCTAssertEqual(es.volume, 1500) // 600 + 480 + 420
|
|
||||||
}
|
|
||||||
|
|
||||||
func testInclusiveDayKeys() {
|
|
||||||
let c = cal()
|
|
||||||
let start = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
|
|
||||||
let keys = WorkoutAnalyticsAdapter.dayKeys(from: start, to: c.date(byAdding: .day, value: 6, to: start)!, calendar: c)
|
|
||||||
XCTAssertEqual(keys.count, 7)
|
|
||||||
XCTAssertEqual(keys, keys.sorted())
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDaySampleMapping() {
|
|
||||||
let c = cal()
|
|
||||||
let start = Date(timeIntervalSince1970: 1_783_500_000)
|
|
||||||
let keys = WorkoutAnalyticsAdapter.dayKeys(from: start, to: c.date(byAdding: .day, value: 6, to: start)!, calendar: c)
|
|
||||||
let es = WorkoutAnalyticsAdapter.exerciseSample(name: "Squat", completedSets: [(100, 5), (100, 5)])
|
|
||||||
let raw = RawWorkoutDay(dateKey: keys[0], didWorkout: true, sessions: 1, durationMinutes: 45, exercises: [es])
|
|
||||||
let samples = WorkoutAnalyticsAdapter.daySamples(
|
|
||||||
dayKeys: keys,
|
|
||||||
workouts: [keys[0]: raw],
|
|
||||||
scheduledKeys: [keys[0], keys[2]],
|
|
||||||
health: [keys[0]: RawHealthDay(steps: 8000, activeCalories: 500, restingHeartRate: 55)])
|
|
||||||
|
|
||||||
XCTAssertEqual(samples.count, 7)
|
|
||||||
XCTAssertTrue(samples[0].didWorkout)
|
|
||||||
XCTAssertEqual(samples[0].volume, 1000)
|
|
||||||
XCTAssertTrue(samples[0].scheduled)
|
|
||||||
XCTAssertEqual(samples[0].steps, 8000)
|
|
||||||
XCTAssertFalse(samples[1].didWorkout) // rest / missing
|
|
||||||
XCTAssertEqual(samples[1].volume, 0)
|
|
||||||
XCTAssertNil(samples[1].steps) // honest: no fabricated data
|
|
||||||
XCTAssertTrue(samples[2].scheduled && !samples[2].didWorkout) // scheduled but missed
|
|
||||||
}
|
|
||||||
|
|
||||||
func testWeekAndMonthDayKeys() {
|
|
||||||
let c = cal()
|
|
||||||
let d = Date(timeIntervalSince1970: 1_783_500_000)
|
|
||||||
XCTAssertEqual(WorkoutAnalyticsAdapter.weekDayKeys(containing: d, calendar: c).count, 7)
|
|
||||||
XCTAssertEqual(WorkoutAnalyticsAdapter.monthDayKeys(containing: d, calendar: c).count, 31) // July
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import KisaniCal
|
|
||||||
|
|
||||||
/// Deep-link routing + reconcile idempotency (no duplicate reports across runs).
|
|
||||||
final class AnalyticsCoordinatorTests: XCTestCase {
|
|
||||||
|
|
||||||
private func cal() -> Calendar {
|
|
||||||
var c = Calendar(identifier: .gregorian)
|
|
||||||
c.timeZone = TimeZone(identifier: "UTC")!
|
|
||||||
c.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
private let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
|
|
||||||
|
|
||||||
// MARK: - Deep links
|
|
||||||
|
|
||||||
func testDeepLinkRoundTrips() {
|
|
||||||
XCTAssertEqual(AnalyticsDeepLink(url: AnalyticsDeepLink.overview.url), .overview)
|
|
||||||
XCTAssertEqual(AnalyticsDeepLink(url: AnalyticsDeepLink.weekly(weekStartKey: "2026-06-29").url),
|
|
||||||
.weekly(weekStartKey: "2026-06-29"))
|
|
||||||
XCTAssertEqual(AnalyticsDeepLink(string: "wenza://analytics/monthly/2026-05"),
|
|
||||||
.monthly(monthKey: "2026-05"))
|
|
||||||
XCTAssertEqual(AnalyticsDeepLink.weekly(weekStartKey: "2026-06-29").url.absoluteString,
|
|
||||||
"wenza://analytics/weekly/2026-06-29")
|
|
||||||
XCTAssertNil(AnalyticsDeepLink(string: "http://x/weekly/2026-06-29"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Reconcile idempotency
|
|
||||||
|
|
||||||
func testReconcileGeneratesThenIsIdempotent() {
|
|
||||||
let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("coord_\(UUID().uuidString)")
|
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
|
||||||
let store = AnalyticsStore(root: tmp)
|
|
||||||
func sample(_ v: Double) -> [DaySample] {
|
|
||||||
[DaySample(dateKey: "x", didWorkout: true, scheduled: true, sessions: 1, sets: 10, volume: v)]
|
|
||||||
}
|
|
||||||
let coord = AnalyticsCoordinator(
|
|
||||||
store: store, calendar: cal(), retentionMonths: 12, now: { self.now },
|
|
||||||
weekSamples: { _ in (sample(1000), sample(800)) },
|
|
||||||
monthSamples: { _ in (sample(4000), sample(3500)) })
|
|
||||||
|
|
||||||
let first = coord.reconcile()
|
|
||||||
XCTAssertGreaterThanOrEqual(first.weekliesGenerated.count, 50)
|
|
||||||
XCTAssertEqual(first.monthliesGenerated.count, 12)
|
|
||||||
|
|
||||||
let second = coord.reconcile()
|
|
||||||
XCTAssertTrue(second.isEmpty, "second run must generate nothing (no duplicate reports)")
|
|
||||||
|
|
||||||
XCTAssertEqual(store.allWeeklyReports().count, first.weekliesGenerated.count)
|
|
||||||
XCTAssertEqual(store.allMonthlyReports().count, 12)
|
|
||||||
XCTAssertEqual(store.allWeeklyReports().first?.progression, .improving) // 1000 vs 800
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import KisaniCal
|
|
||||||
|
|
||||||
/// Deterministic tests for the pure analytics engine — formulas, honest-degradation,
|
|
||||||
/// trend classification, and calendar/timezone/DST/firstWeekday boundary behavior.
|
|
||||||
final class AnalyticsEngineTests: XCTestCase {
|
|
||||||
|
|
||||||
// MARK: - Delta / percentage change
|
|
||||||
|
|
||||||
func testDeltaNormal() {
|
|
||||||
let d = AnalyticsEngine.delta(current: 120, baseline: 100)
|
|
||||||
XCTAssertEqual(d.absolute, 20)
|
|
||||||
XCTAssertEqual(d.percent!, 20, accuracy: 0.0001)
|
|
||||||
XCTAssertEqual(d.direction, .up)
|
|
||||||
XCTAssertTrue(d.meaningful)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDeltaDecrease() {
|
|
||||||
let d = AnalyticsEngine.delta(current: 80, baseline: 100)
|
|
||||||
XCTAssertEqual(d.percent!, -20, accuracy: 0.0001)
|
|
||||||
XCTAssertEqual(d.direction, .down)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDeltaNoBaselineIsNotMeaningful() {
|
|
||||||
let d = AnalyticsEngine.delta(current: 50, baseline: nil)
|
|
||||||
XCTAssertNil(d.percent)
|
|
||||||
XCTAssertNil(d.absolute)
|
|
||||||
XCTAssertFalse(d.meaningful)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDeltaZeroBaselineNoPercent() {
|
|
||||||
// Divide-by-zero guard: zero baseline yields an absolute but no percent.
|
|
||||||
let d = AnalyticsEngine.delta(current: 30, baseline: 0)
|
|
||||||
XCTAssertEqual(d.absolute, 30)
|
|
||||||
XCTAssertNil(d.percent)
|
|
||||||
XCTAssertFalse(d.meaningful)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDeltaTinyBaselineNoPercent() {
|
|
||||||
let d = AnalyticsEngine.delta(current: 10, baseline: 0.00001)
|
|
||||||
XCTAssertNil(d.percent, "percentage against a negligible baseline is suppressed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDeltaNewExercise() {
|
|
||||||
let d = AnalyticsEngine.delta(current: 500, baseline: 400, isNew: true)
|
|
||||||
XCTAssertNil(d.percent)
|
|
||||||
XCTAssertFalse(d.meaningful)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDeltaRestDayBaseline() {
|
|
||||||
let d = AnalyticsEngine.delta(current: 500, baseline: 400, restDay: true)
|
|
||||||
XCTAssertFalse(d.meaningful)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Consistency
|
|
||||||
|
|
||||||
func testConsistencyNormal() {
|
|
||||||
XCTAssertEqual(AnalyticsEngine.consistency(sessions: 4, scheduled: 5), 0.8, accuracy: 0.0001)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testConsistencyZeroScheduled() {
|
|
||||||
XCTAssertEqual(AnalyticsEngine.consistency(sessions: 3, scheduled: 0), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testConsistencyCappedAtOne() {
|
|
||||||
XCTAssertEqual(AnalyticsEngine.consistency(sessions: 6, scheduled: 5), 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Volume
|
|
||||||
|
|
||||||
func testVolume() {
|
|
||||||
XCTAssertEqual(AnalyticsEngine.volume(weight: 60, reps: 10), 600)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Trend classification (multi-point)
|
|
||||||
|
|
||||||
func testTrendImproving() {
|
|
||||||
XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 110, 120, 130]).classification, .improving)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testTrendDeclining() {
|
|
||||||
XCTAssertEqual(AnalyticsEngine.classifyTrend([130, 120, 110, 100]).classification, .declining)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testTrendPlateau() {
|
|
||||||
XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 101, 99, 100]).classification, .plateau)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testTrendInsufficientData() {
|
|
||||||
XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 110]).classification, .insufficientData)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testTrendAllZeroIsPlateau() {
|
|
||||||
XCTAssertEqual(AnalyticsEngine.classifyTrend([0, 0, 0, 0]).classification, .plateau)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - classifyChange (week/month over period)
|
|
||||||
|
|
||||||
func testClassifyChangeImproving() {
|
|
||||||
let d = AnalyticsEngine.delta(current: 120, baseline: 100)
|
|
||||||
XCTAssertEqual(AnalyticsEngine.classifyChange(d), .improving)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testClassifyChangePlateau() {
|
|
||||||
let d = AnalyticsEngine.delta(current: 101, baseline: 100)
|
|
||||||
XCTAssertEqual(AnalyticsEngine.classifyChange(d), .plateau)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testClassifyChangeInsufficient() {
|
|
||||||
let d = AnalyticsEngine.delta(current: 100, baseline: nil)
|
|
||||||
XCTAssertEqual(AnalyticsEngine.classifyChange(d), .insufficientData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Outliers
|
|
||||||
|
|
||||||
func testWinsorizeClampsExtremes() {
|
|
||||||
let w = AnalyticsEngine.winsorize([1, 2, 3, 4, 5, 6, 7, 8, 9, 1000])
|
|
||||||
XCTAssertLessThan(w.max()!, 1000, "extreme high value should be clamped")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testWinsorizeSmallSampleNoop() {
|
|
||||||
let input = [1.0, 999.0]
|
|
||||||
XCTAssertEqual(AnalyticsEngine.winsorize(input), input)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Exercise identity & comparison
|
|
||||||
|
|
||||||
func testExerciseIdentityNormalizes() {
|
|
||||||
XCTAssertEqual(AnalyticsEngine.exerciseIdentity(" Bench Press "),
|
|
||||||
AnalyticsEngine.exerciseIdentity("bench press"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCompareExercisesMarksNew() {
|
|
||||||
let cur = [ExerciseSample(identity: "squat", sets: 3, reps: 15, volume: 900),
|
|
||||||
ExerciseSample(identity: "curl", sets: 3, reps: 30, volume: 300)]
|
|
||||||
let base = [ExerciseSample(identity: "squat", sets: 3, reps: 12, volume: 720)]
|
|
||||||
let comps = AnalyticsEngine.compareExercises(current: cur, baseline: base)
|
|
||||||
let squat = comps.first { $0.identity == "squat" }!
|
|
||||||
let curl = comps.first { $0.identity == "curl" }!
|
|
||||||
XCTAssertFalse(squat.isNew)
|
|
||||||
XCTAssertEqual(squat.volume.percent!, 25, accuracy: 0.0001) // 900 vs 720
|
|
||||||
XCTAssertTrue(curl.isNew)
|
|
||||||
XCTAssertNil(curl.volume.percent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Unit compatibility
|
|
||||||
|
|
||||||
func testUnitComparability() {
|
|
||||||
XCTAssertTrue(MetricUnit.kilograms.isComparable(with: .pounds))
|
|
||||||
XCTAssertFalse(MetricUnit.kilograms.isComparable(with: .minutes))
|
|
||||||
XCTAssertTrue(MetricUnit.kilometers.isComparable(with: .miles))
|
|
||||||
XCTAssertFalse(MetricUnit.bpm.isComparable(with: .kilocalories))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Period summary
|
|
||||||
|
|
||||||
func testSummarizeCountsSessionsAndMissed() {
|
|
||||||
let days = [
|
|
||||||
DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000, durationMinutes: 60),
|
|
||||||
DaySample(dateKey: "2026-07-07", didWorkout: false, scheduled: true), // missed
|
|
||||||
DaySample(dateKey: "2026-07-08", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 900, durationMinutes: 55),
|
|
||||||
DaySample(dateKey: "2026-07-09", didWorkout: false, scheduled: false), // rest
|
|
||||||
]
|
|
||||||
let s = AnalyticsEngine.summarize(days)
|
|
||||||
XCTAssertEqual(s.sessions, 2)
|
|
||||||
XCTAssertEqual(s.scheduled, 3)
|
|
||||||
XCTAssertEqual(s.missed, 1)
|
|
||||||
XCTAssertEqual(s.sets, 38)
|
|
||||||
XCTAssertEqual(s.volume, 1900)
|
|
||||||
XCTAssertEqual(s.consistency, 2.0/3.0, accuracy: 0.0001)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - HealthKit reference aggregation (partial/missing data honest)
|
|
||||||
|
|
||||||
func testSummarizeAggregatesHealthKitAveragesWhenPresent() {
|
|
||||||
let days = [
|
|
||||||
DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000,
|
|
||||||
steps: 8000, activeCalories: 500, restingHeartRate: 55),
|
|
||||||
DaySample(dateKey: "2026-07-07", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 900,
|
|
||||||
steps: 10000, activeCalories: 600, restingHeartRate: 57),
|
|
||||||
DaySample(dateKey: "2026-07-08", didWorkout: false, scheduled: false) // no HK reference
|
|
||||||
]
|
|
||||||
let s = AnalyticsEngine.summarize(days)
|
|
||||||
XCTAssertEqual(s.avgSteps, 9000)
|
|
||||||
XCTAssertEqual(s.avgActiveCalories, 550)
|
|
||||||
XCTAssertEqual(s.avgRestingHR, 56)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSummarizeHealthKitNilWhenAbsent() {
|
|
||||||
let s = AnalyticsEngine.summarize([DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sets: 20, volume: 1000)])
|
|
||||||
XCTAssertNil(s.avgSteps)
|
|
||||||
XCTAssertNil(s.avgActiveCalories)
|
|
||||||
XCTAssertNil(s.avgRestingHR)
|
|
||||||
XCTAssertEqual(s.volume, 1000) // workout metrics unaffected
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Reports (idempotency / determinism)
|
|
||||||
|
|
||||||
func testWeeklyReportDeterministic() {
|
|
||||||
let gen = Date(timeIntervalSince1970: 1_780_000_000)
|
|
||||||
let days = [DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)]
|
|
||||||
let prev = [DaySample(dateKey: "2026-06-29", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)]
|
|
||||||
let r1 = AnalyticsEngine.weeklyReport(weekStartKey: "2026-07-06", days: days, previousWeekDays: prev, generatedAt: gen)
|
|
||||||
let r2 = AnalyticsEngine.weeklyReport(weekStartKey: "2026-07-06", days: days, previousWeekDays: prev, generatedAt: gen)
|
|
||||||
XCTAssertEqual(r1, r2, "same inputs → identical report (idempotent)")
|
|
||||||
XCTAssertEqual(r1.vsPreviousWeek["volume"]!.percent!, 25, accuracy: 0.0001)
|
|
||||||
XCTAssertEqual(r1.progression, .improving)
|
|
||||||
XCTAssertFalse(r1.disclaimer.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMonthlyReportInsufficientWhenNoPrevious() {
|
|
||||||
let days = [DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)]
|
|
||||||
let r = AnalyticsEngine.monthlyReport(monthKey: "2026-07", days: days, previousMonthDays: [], generatedAt: Date())
|
|
||||||
// previous volume is 0 → no meaningful %, classification insufficient.
|
|
||||||
XCTAssertEqual(r.classification, .insufficientData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Calendar / timezone / firstWeekday / DST boundaries
|
|
||||||
|
|
||||||
private func calendar(tz: String, firstWeekday: Int = 1) -> Calendar {
|
|
||||||
var c = Calendar(identifier: .gregorian)
|
|
||||||
c.timeZone = TimeZone(identifier: tz)!
|
|
||||||
c.firstWeekday = firstWeekday
|
|
||||||
c.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDateKeyRespectsTimezone() {
|
|
||||||
// 2026-07-07T02:30Z is still 2026-07-06 (22:30) in New York (UTC-4).
|
|
||||||
let instant = Date(timeIntervalSince1970: 1_783_391_400) // 2026-07-07T02:30:00Z
|
|
||||||
let utc = AnalyticsEngine.dateKey(for: instant, calendar: calendar(tz: "UTC"))
|
|
||||||
let ny = AnalyticsEngine.dateKey(for: instant, calendar: calendar(tz: "America/New_York"))
|
|
||||||
XCTAssertEqual(utc, "2026-07-07")
|
|
||||||
XCTAssertEqual(ny, "2026-07-06")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testFirstWeekdayChangesWeekStart() {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_783_500_000) // a Tuesday
|
|
||||||
let sundayFirst = calendar(tz: "UTC", firstWeekday: 1)
|
|
||||||
let mondayFirst = calendar(tz: "UTC", firstWeekday: 2)
|
|
||||||
let wSun = AnalyticsEngine.weekInterval(containing: date, calendar: sundayFirst)!
|
|
||||||
let wMon = AnalyticsEngine.weekInterval(containing: date, calendar: mondayFirst)!
|
|
||||||
XCTAssertNotEqual(wSun.start, wMon.start, "week start differs by firstWeekday")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDSTSpringForwardDayKeyStable() {
|
|
||||||
// US DST spring-forward 2026-03-08. The 23-hour day must still key correctly.
|
|
||||||
let cal = calendar(tz: "America/New_York")
|
|
||||||
var comps = DateComponents(); comps.year = 2026; comps.month = 3; comps.day = 8; comps.hour = 12
|
|
||||||
let noon = cal.date(from: comps)!
|
|
||||||
XCTAssertEqual(AnalyticsEngine.dateKey(for: noon, calendar: cal), "2026-03-08")
|
|
||||||
// start-of-day is well-defined despite the missing hour
|
|
||||||
let interval = AnalyticsEngine.weekInterval(containing: noon, calendar: cal)
|
|
||||||
XCTAssertNotNil(interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMonthKey() {
|
|
||||||
let cal = calendar(tz: "UTC")
|
|
||||||
let d = Date(timeIntervalSince1970: 1_783_500_000)
|
|
||||||
XCTAssertEqual(AnalyticsEngine.monthKey(for: d, calendar: cal), "2026-07")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Completed-period gating & retention
|
|
||||||
|
|
||||||
func testCompletedWeekGating() {
|
|
||||||
let cal = calendar(tz: "UTC")
|
|
||||||
let now = Date(timeIntervalSince1970: 1_783_500_000) // "this week"
|
|
||||||
let lastWeek = cal.date(byAdding: .weekOfYear, value: -1, to: now)!
|
|
||||||
XCTAssertTrue(AnalyticsEngine.isCompletedWeek(lastWeek, now: now, calendar: cal))
|
|
||||||
XCTAssertFalse(AnalyticsEngine.isCompletedWeek(now, now: now, calendar: cal))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRetentionExpiry() {
|
|
||||||
let cal = calendar(tz: "UTC")
|
|
||||||
let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07
|
|
||||||
let old = AnalyticsEngine.dateKey(for: cal.date(byAdding: .month, value: -13, to: now)!, calendar: cal)
|
|
||||||
let recent = AnalyticsEngine.dateKey(for: cal.date(byAdding: .month, value: -1, to: now)!, calendar: cal)
|
|
||||||
let expired = AnalyticsEngine.expiredDayKeys([old, recent], now: now, months: 12, calendar: cal)
|
|
||||||
XCTAssertTrue(expired.contains(old))
|
|
||||||
XCTAssertFalse(expired.contains(recent))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import KisaniCal
|
|
||||||
|
|
||||||
/// Persistence tests: snapshot immutability, report idempotency/dedup,
|
|
||||||
/// reconciliation selection, and retention pruning. Each test runs against a
|
|
||||||
/// fresh temp directory. (Verified standalone via swiftc; mirrored here for the
|
|
||||||
/// Xcode test target.)
|
|
||||||
final class AnalyticsStoreTests: XCTestCase {
|
|
||||||
|
|
||||||
private var tmp: URL!
|
|
||||||
private var store: AnalyticsStore!
|
|
||||||
private let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
|
|
||||||
|
|
||||||
private func cal(_ tz: String = "UTC") -> Calendar {
|
|
||||||
var c = Calendar(identifier: .gregorian)
|
|
||||||
c.timeZone = TimeZone(identifier: tz)!
|
|
||||||
c.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("analytics_test_\(UUID().uuidString)")
|
|
||||||
store = AnalyticsStore(root: tmp)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
try? FileManager.default.removeItem(at: tmp)
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPastSnapshotIsFrozen() {
|
|
||||||
let past = DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 1, sets: 10, volume: 500)
|
|
||||||
XCTAssertTrue(store.saveSnapshot(past, isPast: true))
|
|
||||||
let edited = DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 9, sets: 99, volume: 9999)
|
|
||||||
XCTAssertFalse(store.saveSnapshot(edited, isPast: true), "past snapshot must not be overwritten")
|
|
||||||
XCTAssertEqual(store.snapshot(dateKey: "2026-06-01")?.sets, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testTodaySnapshotUpdatable() {
|
|
||||||
_ = store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: false, scheduled: true), isPast: false)
|
|
||||||
XCTAssertTrue(store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000), isPast: false))
|
|
||||||
XCTAssertEqual(store.snapshot(dateKey: "2026-07-08")?.sets, 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testWeeklyReportIdempotentAndImmutable() {
|
|
||||||
let wr = AnalyticsEngine.weeklyReport(
|
|
||||||
weekStartKey: "2026-06-29",
|
|
||||||
days: [DaySample(dateKey: "2026-06-29", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)],
|
|
||||||
previousWeekDays: [DaySample(dateKey: "2026-06-22", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)],
|
|
||||||
generatedAt: now)
|
|
||||||
XCTAssertTrue(store.saveWeeklyReport(wr))
|
|
||||||
XCTAssertFalse(store.saveWeeklyReport(wr), "re-saving must be a no-op (dedup)")
|
|
||||||
XCTAssertEqual(store.weeklyReport(weekStartKey: "2026-06-29")?.progression, .improving)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMonthlyReportDedup() {
|
|
||||||
let mr = AnalyticsEngine.monthlyReport(
|
|
||||||
monthKey: "2026-06",
|
|
||||||
days: [DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)],
|
|
||||||
previousMonthDays: [DaySample(dateKey: "2026-05-01", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)],
|
|
||||||
generatedAt: now)
|
|
||||||
XCTAssertTrue(store.saveMonthlyReport(mr))
|
|
||||||
XCTAssertFalse(store.saveMonthlyReport(mr))
|
|
||||||
XCTAssertNotNil(store.monthlyReport(monthKey: "2026-06"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testReconciliationSelection() {
|
|
||||||
let c = cal()
|
|
||||||
let weekKeys = AnalyticsEngine.completedWeekStartKeys(now: now, months: 12, calendar: c)
|
|
||||||
XCTAssertTrue((50...53).contains(weekKeys.count))
|
|
||||||
let currentWeekKey = AnalyticsEngine.dateKey(for: AnalyticsEngine.weekInterval(containing: now, calendar: c)!.start, calendar: c)
|
|
||||||
XCTAssertFalse(weekKeys.contains(currentWeekKey), "current (incomplete) week excluded")
|
|
||||||
|
|
||||||
let monthKeys = AnalyticsEngine.completedMonthKeys(now: now, months: 12, calendar: c)
|
|
||||||
XCTAssertEqual(monthKeys.count, 12)
|
|
||||||
XCTAssertFalse(monthKeys.contains("2026-07"), "current month excluded")
|
|
||||||
|
|
||||||
// A saved report is not re-selected (idempotent reconciliation).
|
|
||||||
let wr = AnalyticsEngine.weeklyReport(weekStartKey: weekKeys.last!, days: [], previousWeekDays: [], generatedAt: now)
|
|
||||||
_ = store.saveWeeklyReport(wr)
|
|
||||||
XCTAssertFalse(AnalyticsEngine.missingKeys(weekKeys, existing: store.existingWeeklyKeys()).contains(weekKeys.last!))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRetentionPrune() {
|
|
||||||
let c = cal()
|
|
||||||
_ = store.saveSnapshot(DaySample(dateKey: "2025-01-01", didWorkout: true), isPast: true) // >12mo old
|
|
||||||
_ = store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: true), isPast: false)
|
|
||||||
let removed = store.prune(now: now, months: 12, calendar: c)
|
|
||||||
XCTAssertGreaterThanOrEqual(removed, 1)
|
|
||||||
XCTAssertNil(store.snapshot(dateKey: "2025-01-01"), "expired snapshot pruned")
|
|
||||||
XCTAssertNotNil(store.snapshot(dateKey: "2026-07-08"), "recent snapshot survives")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import KisaniCal
|
@testable import KisaniCal
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class StreakLogicTests: XCTestCase {
|
final class StreakLogicTests: XCTestCase {
|
||||||
|
|
||||||
private let cal = Calendar.current
|
private let cal = Calendar.current
|
||||||
|
|||||||
@@ -78,8 +78,6 @@ struct EventEntity: AppEntity {
|
|||||||
let dueDate: Date?
|
let dueDate: Date?
|
||||||
var isRecurring: Bool = false
|
var isRecurring: Bool = false
|
||||||
var recurrenceLabel: String? = nil
|
var recurrenceLabel: String? = nil
|
||||||
var recurrenceInterval: Int? = nil
|
|
||||||
var createdAt: Date? = nil
|
|
||||||
|
|
||||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
|
||||||
static var defaultQuery = EventQuery()
|
static var defaultQuery = EventQuery()
|
||||||
@@ -107,9 +105,7 @@ struct EventQuery: EntityQuery {
|
|||||||
.filter { !$0.isComplete && $0.dueDate != nil }
|
.filter { !$0.isComplete && $0.dueDate != nil }
|
||||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||||
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
|
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
|
||||||
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
|
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
|
||||||
recurrenceInterval: $0.recurrenceInterval,
|
|
||||||
createdAt: $0.createdAt) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,9 +235,7 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now }
|
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now }
|
||||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||||
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
|
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
|
||||||
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
|
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
|
||||||
recurrenceInterval: $0.recurrenceInterval,
|
|
||||||
createdAt: $0.createdAt) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tracked task ("Track Countdown"), but only while it's still live: not
|
/// The tracked task ("Track Countdown"), but only while it's still live: not
|
||||||
@@ -273,16 +267,13 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
/// Resolve the progress window (start → target) for any event. Recurring events
|
/// Resolve the progress window (start → target) for any event. Recurring events
|
||||||
/// (Mode B) span the current occurrence period: previous → next occurrence —
|
/// (Mode B) span the current occurrence period: previous → next occurrence —
|
||||||
/// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count
|
/// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count
|
||||||
/// from an explicit start, the task's real creation date, the tracking date,
|
/// from an explicit start, the tracking date, or first-seen, up to the due date.
|
||||||
/// or first-seen, up to the due date — in that priority order.
|
private func resolveSpan(due: Date, isRecurring: Bool, label: String?,
|
||||||
private func resolveSpan(due: Date, isRecurring: Bool, label: String?, interval: Int? = nil,
|
anchorKey: String, explicitStart: Date?) -> (start: Date, target: Date) {
|
||||||
anchorKey: String, explicitStart: Date?,
|
if isRecurring, let label, let span = Self.recurrenceSpan(label: label, due: due) {
|
||||||
taskCreatedAt: Date? = nil) -> (start: Date, target: Date) {
|
|
||||||
if isRecurring, let label, let span = Self.recurrenceSpan(label: label, interval: interval ?? 1, due: due) {
|
|
||||||
return (span.prev, span.next)
|
return (span.prev, span.next)
|
||||||
}
|
}
|
||||||
if let s = explicitStart, s < due { return (s, due) }
|
if let s = explicitStart, s < due { return (s, due) }
|
||||||
if let created = taskCreatedAt, created < due { return (created, due) }
|
|
||||||
return (Self.storedAnchor(key: anchorKey, target: due), due)
|
return (Self.storedAnchor(key: anchorKey, target: due), due)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,9 +288,7 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
let trackedId = task.id.uuidString
|
let trackedId = task.id.uuidString
|
||||||
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
|
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
|
||||||
let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring,
|
let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring,
|
||||||
label: task.recurrenceLabel, interval: task.recurrenceInterval,
|
label: task.recurrenceLabel, anchorKey: trackedId, explicitStart: since)
|
||||||
anchorKey: trackedId, explicitStart: since,
|
|
||||||
taskCreatedAt: task.createdAt)
|
|
||||||
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
|
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,15 +302,12 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
}
|
}
|
||||||
let ev = upcoming[idx]
|
let ev = upcoming[idx]
|
||||||
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||||
interval: ev.recurrenceInterval,
|
anchorKey: ev.id, explicitStart: nil)
|
||||||
anchorKey: ev.id, explicitStart: nil, taskCreatedAt: ev.createdAt)
|
|
||||||
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||||
}
|
}
|
||||||
if let ev = config.event, let due = ev.dueDate {
|
if let ev = config.event, let due = ev.dueDate {
|
||||||
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||||
interval: ev.recurrenceInterval,
|
anchorKey: ev.id, explicitStart: config.startDate)
|
||||||
anchorKey: ev.id, explicitStart: config.startDate,
|
|
||||||
taskCreatedAt: ev.createdAt)
|
|
||||||
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||||
}
|
}
|
||||||
// Fully custom event (no task behind it).
|
// Fully custom event (no task behind it).
|
||||||
@@ -347,11 +333,30 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
|
|
||||||
/// Mode B window for a recurring event: the occurrence span containing "now"
|
/// Mode B window for a recurring event: the occurrence span containing "now"
|
||||||
/// (previous occurrence → next occurrence), derived from the rule label.
|
/// (previous occurrence → next occurrence), derived from the rule label.
|
||||||
/// The actual math lives in RecurrenceRule (Shared/) — shared with the main
|
private static func recurrenceSpan(label: String, due: Date) -> (prev: Date, next: Date)? {
|
||||||
/// app's occurrence matching and the other countdown widget's copy of this
|
let cal = Calendar.current
|
||||||
/// same calculation, so none of the three can independently drift.
|
let now = Date()
|
||||||
private static func recurrenceSpan(label: String, interval: Int = 1, due: Date) -> (prev: Date, next: Date)? {
|
let comp: Calendar.Component
|
||||||
RecurrenceRule.span(label: label, interval: interval, due: due, now: Date())
|
let step: Int
|
||||||
|
switch label {
|
||||||
|
case "Daily", "Every Weekday": comp = .day; step = 1
|
||||||
|
case "Weekly": comp = .day; step = 7
|
||||||
|
case "Monthly": comp = .month; step = 1
|
||||||
|
case "Yearly": comp = .year; step = 1
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
var next = due
|
||||||
|
var guardRail = 0
|
||||||
|
if next > now {
|
||||||
|
// Walk back until the previous occurrence is in the past.
|
||||||
|
while let prev = cal.date(byAdding: comp, value: -step, to: next),
|
||||||
|
prev > now, guardRail < 4000 { next = prev; guardRail += 1 }
|
||||||
|
} else {
|
||||||
|
while next <= now, let n = cal.date(byAdding: comp, value: step, to: next),
|
||||||
|
guardRail < 4000 { next = n; guardRail += 1 }
|
||||||
|
}
|
||||||
|
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
|
||||||
|
return (prev, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ enum ProgressDotMode: String, AppEnum {
|
|||||||
case day
|
case day
|
||||||
case week
|
case week
|
||||||
case month
|
case month
|
||||||
case year
|
|
||||||
case customEvent
|
case customEvent
|
||||||
|
|
||||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
|
||||||
@@ -48,7 +47,6 @@ enum ProgressDotMode: String, AppEnum {
|
|||||||
.day: "This Day",
|
.day: "This Day",
|
||||||
.week: "This Week",
|
.week: "This Week",
|
||||||
.month: "This Month",
|
.month: "This Month",
|
||||||
.year: "This Year",
|
|
||||||
.customEvent: "Custom Event",
|
.customEvent: "Custom Event",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -117,13 +115,6 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
|
|||||||
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
|
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
|
||||||
progress: Self.progress(now: date, start: start, end: end),
|
progress: Self.progress(now: date, start: start, end: end),
|
||||||
totalDots: 100)
|
totalDots: 100)
|
||||||
case .year:
|
|
||||||
let interval = Calendar.current.dateInterval(of: .year, for: date)
|
|
||||||
let start = interval?.start ?? date
|
|
||||||
let end = interval?.end ?? date
|
|
||||||
return ProgressDotEntry(date: date, title: Self.yearTitle(for: date),
|
|
||||||
progress: Self.progress(now: date, start: start, end: end),
|
|
||||||
totalDots: 100)
|
|
||||||
case .customEvent:
|
case .customEvent:
|
||||||
guard let event = configuration.event,
|
guard let event = configuration.event,
|
||||||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
|
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
|
||||||
@@ -197,16 +188,45 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
|
|||||||
return Calendar.current.date(byAdding: .day, value: -totalDays, to: target) ?? now
|
return Calendar.current.date(byAdding: .day, value: -totalDays, to: target) ?? now
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recurrence-window math itself lives in RecurrenceRule (Shared/) so this
|
|
||||||
// widget's countdown can never drift from the main app's occurrence logic
|
|
||||||
// or from the other countdown widget's copy of the same calculation.
|
|
||||||
private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
|
private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
|
||||||
let category = task.category.lowercased()
|
let category = task.category.lowercased()
|
||||||
if category == "birthday" || category == "annual" {
|
if category == "birthday" || category == "annual" {
|
||||||
return RecurrenceRule.span(label: "Yearly", due: due, now: now)
|
return recurrenceSpan(label: "Yearly", due: due, now: now)
|
||||||
}
|
}
|
||||||
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
|
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
|
||||||
return RecurrenceRule.span(label: label, interval: task.recurrenceInterval ?? 1, due: due, now: now)
|
return recurrenceSpan(label: label, due: due, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func recurrenceSpan(label: String, due: Date, now: Date) -> (prev: Date, next: Date)? {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let comp: Calendar.Component
|
||||||
|
let step: Int
|
||||||
|
switch label {
|
||||||
|
case "Daily", "Every Weekday": comp = .day; step = 1
|
||||||
|
case "Weekly": comp = .day; step = 7
|
||||||
|
case "Monthly": comp = .month; step = 1
|
||||||
|
case "Yearly": comp = .year; step = 1
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = due
|
||||||
|
var guardRail = 0
|
||||||
|
if next > now {
|
||||||
|
while let prev = cal.date(byAdding: comp, value: -step, to: next),
|
||||||
|
prev > now, guardRail < 4000 {
|
||||||
|
next = prev
|
||||||
|
guardRail += 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next),
|
||||||
|
guardRail < 4000 {
|
||||||
|
next = candidate
|
||||||
|
guardRail += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
|
||||||
|
return (prev, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func startOfISOWeek(containing date: Date) -> Date {
|
private static func startOfISOWeek(containing date: Date) -> Date {
|
||||||
@@ -231,12 +251,6 @@ struct ProgressDotProvider: AppIntentTimelineProvider {
|
|||||||
formatter.dateFormat = "MMMM yyyy"
|
formatter.dateFormat = "MMMM yyyy"
|
||||||
return formatter.string(from: date)
|
return formatter.string(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func yearTitle(for date: Date) -> String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyy"
|
|
||||||
return formatter.string(from: date)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DayProgressWidget: Widget {
|
struct DayProgressWidget: Widget {
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import AppIntents
|
|
||||||
import ActivityKit
|
|
||||||
|
|
||||||
/// Ends a running task Live Activity directly from its Lock Screen / Dynamic
|
|
||||||
/// Island card — no need to open the app. Runs in the widget extension
|
|
||||||
/// process (that's how LiveActivityIntent works), so it talks to ActivityKit
|
|
||||||
/// directly rather than going through LiveActivityManager (app target only).
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
struct StopCountdownIntent: LiveActivityIntent {
|
|
||||||
static var title: LocalizedStringResource = "Stop Countdown"
|
|
||||||
static var description = IntentDescription("Ends this task's live countdown.")
|
|
||||||
|
|
||||||
@Parameter(title: "Task ID")
|
|
||||||
var taskID: String
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
init(taskID: String) { self.taskID = taskID }
|
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult {
|
|
||||||
for activity in Activity<TaskActivityAttributes>.activities
|
|
||||||
where activity.attributes.taskID == taskID {
|
|
||||||
await activity.end(nil, dismissalPolicy: .immediate)
|
|
||||||
}
|
|
||||||
return .result()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,8 @@ struct TaskLiveActivity: Widget {
|
|||||||
ActivityConfiguration(for: TaskActivityAttributes.self) { context in
|
ActivityConfiguration(for: TaskActivityAttributes.self) { context in
|
||||||
// ── Lock screen / banner ──
|
// ── Lock screen / banner ──
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "hourglass")
|
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle")
|
||||||
.font(.system(size: 20))
|
.font(.system(size: 22))
|
||||||
.foregroundColor(context.state.isComplete ? .green : accent)
|
.foregroundColor(context.state.isComplete ? .green : accent)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -40,18 +40,6 @@ struct TaskLiveActivity: Widget {
|
|||||||
.frame(width: 92, alignment: .trailing)
|
.frame(width: 92, alignment: .trailing)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit stop control — a Live Activity can't be prevented
|
|
||||||
// from being swiped away by the system, so this gives an
|
|
||||||
// intentional, in-card way to end tracking instead.
|
|
||||||
if #available(iOS 17.0, *), !context.state.isComplete {
|
|
||||||
Button(intent: StopCountdownIntent(taskID: context.attributes.taskID)) {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.font(.system(size: 18))
|
|
||||||
.foregroundColor(.white.opacity(0.5))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.leading, 14)
|
.padding(.leading, 14)
|
||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
@@ -81,16 +69,6 @@ struct TaskLiveActivity: Widget {
|
|||||||
.font(.system(.callout, design: .monospaced))
|
.font(.system(.callout, design: .monospaced))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
|
||||||
if #available(iOS 17.0, *), !context.state.isComplete {
|
|
||||||
Button(intent: StopCountdownIntent(taskID: context.attributes.taskID)) {
|
|
||||||
Label("Stop Countdown", systemImage: "xmark.circle")
|
|
||||||
.font(.system(.caption, design: .monospaced))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.tint(accent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "checklist").foregroundColor(accent)
|
Image(systemName: "checklist").foregroundColor(accent)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ struct WidgetTask: Codable, Identifiable {
|
|||||||
let category: String
|
let category: String
|
||||||
var isRecurring: Bool = false
|
var isRecurring: Bool = false
|
||||||
var recurrenceLabel: String? = nil
|
var recurrenceLabel: String? = nil
|
||||||
var recurrenceInterval: Int? = nil
|
|
||||||
var createdAt: Date? = nil
|
var createdAt: Date? = nil
|
||||||
var reminderDate: Date? = nil
|
var reminderDate: Date? = nil
|
||||||
var progress: Double? = nil
|
var progress: Double? = nil
|
||||||
@@ -68,7 +67,6 @@ func loadWidgetTasks() -> [WidgetTask] {
|
|||||||
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
|
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
|
||||||
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
|
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
|
||||||
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
|
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
|
||||||
recurrenceInterval: $0.recurrenceInterval,
|
|
||||||
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
|
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
|
||||||
progress: $0.countdownProgress ?? $0.progress)
|
progress: $0.countdownProgress ?? $0.progress)
|
||||||
}
|
}
|
||||||
@@ -84,14 +82,13 @@ private struct RawTask: Codable {
|
|||||||
var category: String = "reminder"
|
var category: String = "reminder"
|
||||||
var isRecurring: Bool? = nil
|
var isRecurring: Bool? = nil
|
||||||
var recurrenceLabel: String? = nil
|
var recurrenceLabel: String? = nil
|
||||||
var recurrenceInterval: Int? = nil
|
|
||||||
var createdAt: Date? = nil
|
var createdAt: Date? = nil
|
||||||
var reminderDate: Date? = nil
|
var reminderDate: Date? = nil
|
||||||
var progress: Double? = nil
|
var progress: Double? = nil
|
||||||
var countdownProgress: Double? = nil
|
var countdownProgress: Double? = nil
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
|
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
|
||||||
case recurrenceInterval, createdAt, reminderDate, progress, countdownProgress
|
case createdAt, reminderDate, progress, countdownProgress
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Shared, framework-free recurrence math. Compiled into the main app
|
|
||||||
/// (`TaskItem.isOccurrence`) AND both widget extensions (their countdown
|
|
||||||
/// progress-window calculations) from `Shared/`, so all three can never
|
|
||||||
/// independently drift from each other the way three hand-written copies did.
|
|
||||||
///
|
|
||||||
/// `interval` defaults to 1 everywhere — every case below is written so that
|
|
||||||
/// interval == 1 produces results identical to this app's pre-interval
|
|
||||||
/// behavior (verified in RecurrenceRuleTests.swift).
|
|
||||||
enum RecurrenceRule {
|
|
||||||
|
|
||||||
/// Whether `date` is a valid occurrence of a rule starting at `start`,
|
|
||||||
/// repeating every `interval` units of `label`, bounded by `end` if set.
|
|
||||||
static func isOccurrence(label: String?, interval: Int = 1, start: Date, date: Date,
|
|
||||||
end: Date? = nil, calendar: Calendar = .current) -> Bool {
|
|
||||||
let cal = calendar
|
|
||||||
let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start)
|
|
||||||
guard d0 >= s0 else { return false }
|
|
||||||
if let end, d0 > cal.startOfDay(for: end) { return false }
|
|
||||||
let n = max(1, interval)
|
|
||||||
|
|
||||||
switch label {
|
|
||||||
case "Daily":
|
|
||||||
let days = cal.dateComponents([.day], from: s0, to: d0).day ?? 0
|
|
||||||
return days % n == 0
|
|
||||||
|
|
||||||
case "Every Weekday":
|
|
||||||
// No interval concept — always Mon–Fri, same as before intervals existed.
|
|
||||||
let wd = cal.component(.weekday, from: d0) // 1=Sun … 7=Sat
|
|
||||||
return wd >= 2 && wd <= 6
|
|
||||||
|
|
||||||
case "Weekly":
|
|
||||||
let days = cal.dateComponents([.day], from: s0, to: d0).day ?? 0
|
|
||||||
return days % (7 * n) == 0
|
|
||||||
|
|
||||||
case "Monthly":
|
|
||||||
guard cal.component(.day, from: s0) == cal.component(.day, from: d0) else { return false }
|
|
||||||
let months = cal.dateComponents([.month], from: s0, to: d0).month ?? 0
|
|
||||||
return months % n == 0
|
|
||||||
|
|
||||||
case "Yearly":
|
|
||||||
guard cal.component(.day, from: s0) == cal.component(.day, from: d0),
|
|
||||||
cal.component(.month, from: s0) == cal.component(.month, from: d0)
|
|
||||||
else { return false }
|
|
||||||
let years = cal.dateComponents([.year], from: s0, to: d0).year ?? 0
|
|
||||||
return years % n == 0
|
|
||||||
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The occurrence window (prev, next) containing `now` — used by the
|
|
||||||
/// countdown-dot widgets to size their progress bar. `prev` is the most
|
|
||||||
/// recent occurrence on/before `now`; `next` is the following one.
|
|
||||||
/// Returns nil for an unrecognized label.
|
|
||||||
static func span(label: String, interval: Int = 1, due: Date, now: Date,
|
|
||||||
calendar: Calendar = .current) -> (prev: Date, next: Date)? {
|
|
||||||
let cal = calendar
|
|
||||||
let n = max(1, interval)
|
|
||||||
let comp: Calendar.Component
|
|
||||||
let step: Int
|
|
||||||
switch label {
|
|
||||||
case "Daily": comp = .day; step = n
|
|
||||||
case "Every Weekday": comp = .day; step = 1 // no interval concept, see isOccurrence
|
|
||||||
case "Weekly": comp = .day; step = 7 * n
|
|
||||||
case "Monthly": comp = .month; step = n
|
|
||||||
case "Yearly": comp = .year; step = n
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var next = due
|
|
||||||
var guardRail = 0
|
|
||||||
if next > now {
|
|
||||||
while let prev = cal.date(byAdding: comp, value: -step, to: next),
|
|
||||||
prev > now, guardRail < 4000 {
|
|
||||||
next = prev
|
|
||||||
guardRail += 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
while next <= now,
|
|
||||||
let candidate = cal.date(byAdding: comp, value: step, to: next),
|
|
||||||
guardRail < 4000 {
|
|
||||||
next = candidate
|
|
||||||
guardRail += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
|
|
||||||
return (prev, next)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User-facing recurrence text — "Weekly" at interval 1 (unchanged from
|
|
||||||
/// before intervals existed), "Every 2 Weeks" once N > 1. One shared
|
|
||||||
/// formatter so the main app and the create/edit picker UI can't drift.
|
|
||||||
static func displayLabel(label: String, interval: Int = 1) -> String {
|
|
||||||
let n = max(1, interval)
|
|
||||||
guard n > 1, label != "Every Weekday" else { return label }
|
|
||||||
let unit: String
|
|
||||||
switch label {
|
|
||||||
case "Daily": unit = "Days"
|
|
||||||
case "Weekly": unit = "Weeks"
|
|
||||||
case "Monthly": unit = "Months"
|
|
||||||
case "Yearly": unit = "Years"
|
|
||||||
default: return label
|
|
||||||
}
|
|
||||||
return "Every \(n) \(unit)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ settings:
|
|||||||
base:
|
base:
|
||||||
DEVELOPMENT_TEAM: K8BLMMR883
|
DEVELOPMENT_TEAM: K8BLMMR883
|
||||||
MARKETING_VERSION: "2.0"
|
MARKETING_VERSION: "2.0"
|
||||||
CURRENT_PROJECT_VERSION: "10"
|
CURRENT_PROJECT_VERSION: "9"
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
KisaniCal:
|
KisaniCal:
|
||||||
@@ -37,7 +37,6 @@ targets:
|
|||||||
deploymentTarget: "16.0"
|
deploymentTarget: "16.0"
|
||||||
sources:
|
sources:
|
||||||
- path: KisaniCal
|
- path: KisaniCal
|
||||||
- path: Shared
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- target: KisaniCalWidgets
|
- target: KisaniCalWidgets
|
||||||
embed: true
|
embed: true
|
||||||
@@ -91,7 +90,6 @@ targets:
|
|||||||
- path: KisaniCalWidgets
|
- path: KisaniCalWidgets
|
||||||
- path: KisaniCal/Managers/AppGroup.swift
|
- path: KisaniCal/Managers/AppGroup.swift
|
||||||
- path: KisaniCal/Managers/TaskActivityAttributes.swift
|
- path: KisaniCal/Managers/TaskActivityAttributes.swift
|
||||||
- path: Shared
|
|
||||||
info:
|
info:
|
||||||
path: KisaniCalWidgets/Info.plist
|
path: KisaniCalWidgets/Info.plist
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
Reference in New Issue
Block a user